From c318b80fccd0316bd7ecffbf888cd741158966fe Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Tue, 6 Oct 2026 16:33:36 +0200 Subject: [PATCH 1/2] Run elfuse runtime lanes on OCI images scripts/ci/oci-runtime.sh runs elfuse on a real runtime per key: python, node, go, jvm, c, and redis, each driving a workload from scripts/ci/workloads and requiring its sentinel. The node and redis lanes serve from one guest to the host or to a second guest. The oci-runtime job builds crane and umoci from source as oci-smoke does, then runs each key on the self-hosted macOS runner, with a store per key and runner, since the runners on one host share $HOME. Like oci-smoke, it pulls the library images through mirror.gcr.io. The helpers oci-smoke.sh defined move to scripts/ci/oci-lib.sh, which both lanes source. wait_for there probes once more than the oci-smoke.sh copy did, so it fails after the full timeout instead of half a second short of it. --- .github/workflows/build.yml | 63 +++++++++ scripts/ci/oci-lib.sh | 36 +++++ scripts/ci/oci-runtime.sh | 165 +++++++++++++++++++++++ scripts/ci/oci-smoke.sh | 36 +---- scripts/ci/workloads/c-workload.sh | 57 ++++++++ scripts/ci/workloads/go-workload.sh | 55 ++++++++ scripts/ci/workloads/jvm-workload.sh | 75 +++++++++++ scripts/ci/workloads/node-compute.js | 42 ++++++ scripts/ci/workloads/node-server.js | 24 ++++ scripts/ci/workloads/python-workload.py | 171 ++++++++++++++++++++++++ scripts/ci/workloads/redis-driver.sh | 62 +++++++++ scripts/ci/workloads/unix-socket.py | 30 +++++ 12 files changed, 782 insertions(+), 34 deletions(-) create mode 100755 scripts/ci/oci-lib.sh create mode 100755 scripts/ci/oci-runtime.sh create mode 100644 scripts/ci/workloads/c-workload.sh create mode 100644 scripts/ci/workloads/go-workload.sh create mode 100644 scripts/ci/workloads/jvm-workload.sh create mode 100644 scripts/ci/workloads/node-compute.js create mode 100644 scripts/ci/workloads/node-server.js create mode 100644 scripts/ci/workloads/python-workload.py create mode 100644 scripts/ci/workloads/redis-driver.sh create mode 100644 scripts/ci/workloads/unix-socket.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b8dea836..6cf89664 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,7 @@ # runtime-macos : HVF runtime tests on self-hosted Apple Silicon, # including release, ASAN, UBSAN, and TSAN variants # oci-smoke : OCI front end lane on self-hosted Apple Silicon +# oci-runtime : elfuse runtime lanes on OCI images, self-hosted # # Runtime and sanitizer tests require Hypervisor.framework, which # GitHub-hosted macOS runners do not expose. Those tests run on self-hosted @@ -460,3 +461,65 @@ jobs: set -euo pipefail chmod +x build/elfuse ELFUSE_OCI_STORE="$HOME/.cache/elfuse-ci/oci-smoke-$RUNNER_NAME" scripts/ci/oci-smoke.sh + + oci-runtime: + name: OCI runtime (${{ matrix.key }}) + needs: build-macos + if: > + github.repository == 'sysprog21/elfuse' && + (github.event_name == 'push' || github.event_name == 'pull_request' || + github.event_name == 'workflow_dispatch' || + github.event_name == 'merge_group') + runs-on: [self-hosted, macOS, arm64] + timeout-minutes: ${{ matrix.timeout }} + strategy: + fail-fast: false + matrix: + include: + - { key: python, timeout: 30 } + - { key: node, timeout: 30 } + - { key: go, timeout: 30 } + - { key: jvm, timeout: 45 } + - { key: c, timeout: 45 } + - { key: redis, timeout: 30 } + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Download prebuilt elfuse binary + uses: actions/download-artifact@v7 + with: + name: elfuse-${{ runner.os }}-${{ runner.arch }} + path: build + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version: '1.27' + cache: false + + - name: Build crane and umoci + # go install refuses umoci: its go.mod has a replace directive. + env: + CRANE_VERSION: v0.22.1 + UMOCI_VERSION: v0.6.0 + run: | + set -euo pipefail + bin="$RUNNER_TEMP/oci-tools" + GOBIN="$bin" go install \ + "github.com/google/go-containerregistry/cmd/crane@$CRANE_VERSION" + umoci="github.com/opencontainers/umoci@$UMOCI_VERSION" + go mod download "$umoci" + go build -C "$(go env GOMODCACHE)/$umoci" -o "$bin/umoci" ./cmd/umoci + echo "$bin" >> "$GITHUB_PATH" + "$bin/crane" version + "$bin/umoci" --version + + - name: Run the ${{ matrix.key }} lane + # A downloaded artifact keeps its signature but not its execute bit. + # Runners on one host share $HOME, so each gets its own store. + run: | + set -euo pipefail + chmod +x build/elfuse + ELFUSE_OCI_STORE="$HOME/.cache/elfuse-ci/oci-runtime-${{ matrix.key }}-$RUNNER_NAME" \ + scripts/ci/oci-runtime.sh ${{ matrix.key }} diff --git a/scripts/ci/oci-lib.sh b/scripts/ci/oci-lib.sh new file mode 100755 index 00000000..94af6d08 --- /dev/null +++ b/scripts/ci/oci-lib.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Helpers shared by the OCI CI lanes. +set -euo pipefail + +fail() +{ + echo "FAIL: $*" >&2 + exit 1 +} + +# Drain stdin so pipefail does not report the producer's SIGPIPE. +expect_grep() +{ + grep -F -- "$1" > /dev/null +} + +run_capture() +{ + local desc="$1" pattern="$2" out + shift 2 + out="$("$@")" || fail "$desc: exited $?: $out" + printf '%s: %s\n' "$desc" "$out" + printf '%s\n' "$out" | expect_grep "$pattern" || fail "$desc: output lacks $pattern" +} + +# Poll a predicate without letting its failures escape set -e. +wait_for() +{ + local timeout="$1" desc="$2" i=0 + shift 2 + while ! "$@" > /dev/null; do + i=$((i + 1)) + [ "$i" -le $((timeout * 2)) ] || fail "timed out after ${timeout}s waiting for: $desc" + sleep 0.5 + done +} diff --git a/scripts/ci/oci-runtime.sh b/scripts/ci/oci-runtime.sh new file mode 100755 index 00000000..3435eab1 --- /dev/null +++ b/scripts/ci/oci-runtime.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# Run one elfuse runtime lane on an OCI image and require its sentinel. The keys +# are the case arms below and the oci-runtime matrix in build.yml. Usage: +# ELFUSE_OCI_STORE= scripts/ci/oci-runtime.sh +# shellcheck source=scripts/ci/oci-lib.sh +. "$(dirname "$0")/oci-lib.sh" + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +BIN="$ROOT/scripts/elfuse-oci.py" +: "${ELFUSE_OCI_STORE:?set ELFUSE_OCI_STORE to the store directory to use}" +export ELFUSE_OCI_STORE + +# The library images through mirror.gcr.io, so the lane does not depend on +# Docker Hub's anonymous pull limit. +MIRROR=mirror.gcr.io/library + +key="${1:?usage: oci-runtime.sh }" +WL="$ROOT/scripts/ci/workloads" + +guest="" +srv_outfile="" +on_exit() +{ + rc=$? + # Reap the guest before a persistent runner reuses its port or clone. + if [ -n "$guest" ] && kill -0 "$guest" 2> /dev/null; then + kill "$guest" 2> /dev/null || true + wait "$guest" 2> /dev/null || true + fi + if [ "$rc" -ne 0 ] && [ -s "$srv_outfile" ]; then + echo "--- server guest output ---" >&2 + cat "$srv_outfile" >&2 + fi + [ -z "$srv_outfile" ] || rm -f "$srv_outfile" + exit "$rc" +} +trap on_exit EXIT + +server_printed() +{ + kill -0 "$guest" 2> /dev/null || fail "server guest exited before readiness" + grep -Eq "$1" "$srv_outfile" +} + +server_gone() +{ + ! kill -0 "$guest" 2> /dev/null +} + +# Start a server guest in the background and wait for pattern in its output. +start_server() +{ + local pattern="$1" + shift + srv_outfile="$(mktemp)" + "$@" > "$srv_outfile" 2>&1 & + guest=$! + wait_for 60 "server output matching $pattern" server_printed "$pattern" +} + +await_server_exit() +{ + wait_for 10 "$1 exit" server_gone + wait "$guest" || { + guest="" + fail "$1 exited non-zero" + } + guest="" +} + +run_node() +{ + run_capture node-compute elfuse-oci-node-compute-ok \ + "$BIN" run --entrypoint /usr/local/bin/node "$MIRROR/node:22-alpine" \ + -e "$(cat "$WL/node-compute.js")" + + start_server '^PORT=' \ + "$BIN" run --entrypoint /usr/local/bin/node "$MIRROR/node:22-alpine" \ + -e "$(cat "$WL/node-server.js")" + local port + port="$(awk -F= '/^PORT=/{print $2; exit}' "$srv_outfile")" + # Flushed output can precede listen readiness. + wait_for 30 "node server on 127.0.0.1:$port" nc -z 127.0.0.1 "$port" + + local i=0 reqs=100 body + while [ "$i" -lt "$reqs" ]; do + body="$(curl -fsS "http://127.0.0.1:$port/")" \ + || fail "node server request $i failed" + [ "$body" = elfuse-node-server-ok ] \ + || fail "node server request $i returned '$body'" + i=$((i + 1)) + done + printf 'node server answered %d requests\n' "$reqs" + + # The guest can reset /quit while exiting successfully. + curl -fsS -o /dev/null "http://127.0.0.1:$port/quit" || true + await_server_exit "node server after /quit" +} + +run_redis() +{ + # Redis treats port 0 as disabled. + local port="" try=0 + while [ "$try" -lt 10 ]; do + port=$((20000 + RANDOM % 20000)) + if ! nc -z 127.0.0.1 "$port" 2> /dev/null; then + break + fi + port="" + try=$((try + 1)) + done + [ -n "$port" ] || fail "no free loopback port for redis after 10 probes" + + # Make BGSAVE the only background save. + start_server 'Ready to accept connections' \ + "$BIN" run --entrypoint /usr/local/bin/redis-server "$MIRROR/redis:7-alpine" \ + --bind 127.0.0.1 --port "$port" --save '' --dir /data + wait_for 30 "redis server on 127.0.0.1:$port" nc -z 127.0.0.1 "$port" + + run_capture redis-driver elfuse-oci-redis-workload-ok \ + "$BIN" run --entrypoint /bin/sh "$MIRROR/redis:7-alpine" \ + -c "$(cat "$WL/redis-driver.sh")" sh "$port" + + await_server_exit "redis server after SHUTDOWN" +} + +command -v nc > /dev/null 2>&1 || { + echo "nc is required" >&2 + exit 2 +} + +"$BIN" clean > /dev/null + +case "$key" in + python) + run_capture python elfuse-oci-python-workload-ok \ + "$BIN" run --entrypoint /usr/local/bin/python3 "$MIRROR/python:3.12-slim" \ + -c "$(cat "$WL/python-workload.py")" + + # A socket path too deep for the host's sun_path is shortened there and + # must read back as the guest bound it. + run_capture 'unix socket' elfuse-unix-sock-ok \ + "$BIN" run --entrypoint /usr/local/bin/python3 "$MIRROR/python:3.12-slim" \ + -c "$(cat "$WL/unix-socket.py")" + ;; + node) run_node ;; + redis) run_redis ;; + go) + run_capture go elfuse-oci-go-workload-ok \ + "$BIN" run "$MIRROR/golang:1.23-alpine" /bin/sh -c "$(cat "$WL/go-workload.sh")" + ;; + jvm) + run_capture jvm elfuse-oci-jvm-workload-ok \ + "$BIN" run "$MIRROR/eclipse-temurin:21" /bin/sh -c "$(cat "$WL/jvm-workload.sh")" + ;; + c) + run_capture c elfuse-oci-c-workload-ok \ + "$BIN" run "$MIRROR/gcc:14" /bin/sh -c "$(cat "$WL/c-workload.sh")" + ;; + *) fail "unknown runtime key: $key" ;; +esac + +"$BIN" clean > /dev/null + +echo "runtime $key OK" diff --git a/scripts/ci/oci-smoke.sh b/scripts/ci/oci-smoke.sh index 5cb10a92..4655884d 100755 --- a/scripts/ci/oci-smoke.sh +++ b/scripts/ci/oci-smoke.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Drive scripts/elfuse-oci.py end to end under elfuse. Usage: # ELFUSE_OCI_STORE= scripts/ci/oci-smoke.sh -set -euo pipefail +# shellcheck source=scripts/ci/oci-lib.sh +. "$(dirname "$0")/oci-lib.sh" ROOT="$(cd "$(dirname "$0")/../.." && pwd)" BIN="$ROOT/scripts/elfuse-oci.py" @@ -27,39 +28,6 @@ on_exit() } trap on_exit EXIT -fail() -{ - echo "FAIL: $*" >&2 - exit 1 -} - -# Drain stdin so pipefail does not report the producer's SIGPIPE. -expect_grep() -{ - grep -F -- "$1" > /dev/null -} - -run_capture() -{ - local desc="$1" pattern="$2" out - shift 2 - out="$("$@")" || fail "$desc: exited $?: $out" - printf '%s: %s\n' "$desc" "$out" - printf '%s\n' "$out" | expect_grep "$pattern" || fail "$desc: output lacks $pattern" -} - -# Poll a predicate without letting its failures escape set -e. -wait_for() -{ - local timeout="$1" desc="$2" i=0 - shift 2 - while ! "$@" > /dev/null; do - i=$((i + 1)) - [ "$i" -lt $((timeout * 2)) ] || fail "timed out after ${timeout}s waiting for: $desc" - sleep 0.5 - done -} - runs_gone() { [ -z "$(ls "$ELFUSE_OCI_STORE/run" 2> /dev/null)" ] diff --git a/scripts/ci/workloads/c-workload.sh b/scripts/ci/workloads/c-workload.sh new file mode 100644 index 00000000..53740650 --- /dev/null +++ b/scripts/ci/workloads/c-workload.sh @@ -0,0 +1,57 @@ +# shellcheck shell=sh +# Compile a C project under POSIX sh with a tab-free generated Makefile. +set -e + +d=/tmp/elfuse-c-work +rm -rf "$d" +mkdir -p "$d" +cd "$d" + +cat > mathx.h << 'EOF' +#ifndef MATHX_H +#define MATHX_H +int tri(int n); +#endif +EOF +cat > mathx.c << 'EOF' +#include "mathx.h" +int tri(int n) { + int s = 0; + for (int i = 1; i <= n; i++) s += i; + return s; +} +EOF +cat > main.c << 'EOF' +#include +#include "mathx.h" +int main(void) { + printf("%d\n", tri(100)); + return 0; +} +EOF +cat > Makefile << 'EOF' +.RECIPEPREFIX = > +app: main.o mathx.o +> $(CC) -O1 -o app main.o mathx.o +%.o: %.c mathx.h +> $(CC) -O1 -c -o $@ $< +EOF + +make -j1 +r=$(./app) +if [ "$r" != "5050" ]; then + echo "make project produced: $r" >&2 + exit 1 +fi + +awk 'BEGIN { + for (i = 0; i < 1000; i++) print "int f" i "(void){return " i ";}"; + printf "typedef int(*fn)(void);\nstatic fn t[]={"; + for (i = 0; i < 1000; i++) printf "f%d,", i; + print "};"; + print "int main(void){long s=0;for(int i=0;i<1000;i++)s+=t[i]();return s==499500?0:1;}"; +}' > big.c +gcc -O1 -o big big.c +./big + +echo elfuse-oci-c-workload-ok diff --git a/scripts/ci/workloads/go-workload.sh b/scripts/ci/workloads/go-workload.sh new file mode 100644 index 00000000..710b932c --- /dev/null +++ b/scripts/ci/workloads/go-workload.sh @@ -0,0 +1,55 @@ +# shellcheck shell=sh +# Exercise Go filesystem traversal and goroutine scheduling. +set -e + +# Async preemption can deliver a signal while the vCPU is in the EL1 MRS path. +export GOCACHE=/tmp/elfuse-go-cache GOTOOLCHAIN=local GODEBUG=asyncpreemptoff=1 + +# elfuse reports one CPU, so GOMAXPROCS=2 keeps the workload concurrent. +export GOMAXPROCS=2 + +go version + +d=/tmp/elfuse-go-work +rm -rf "$d" +mkdir -p "$d/src" +cd "$d/src" + +# Generate fixed inputs independent of image contents. +n=64 +i=1 +while [ "$i" -le "$n" ]; do + printf 'package p\n\nfunc F%s() {\n\tx :=1\n\t_ = x\n}\n' "$i" > "f$i.go" + i=$((i + 1)) +done + +listed=$(gofmt -l . | wc -l | tr -d ' ') +if [ "$listed" != "$n" ]; then + echo "gofmt -l named $listed files, expected $n" >&2 + gofmt -l . >&2 + exit 1 +fi + +gofmt -w . + +printf 'package p\n\nfunc F7() {\n\tx := 1\n\t_ = x\n}\n' > "$d/f7.expected" +if ! cmp -s f7.go "$d/f7.expected"; then + echo "f7.go after gofmt -w:" >&2 + cat f7.go >&2 + exit 1 +fi + +remaining=$(gofmt -l . | wc -l | tr -d ' ') +if [ "$remaining" != "0" ]; then + echo "gofmt -l still names $remaining files after -w" >&2 + gofmt -l . >&2 + exit 1 +fi + +root=$(go env GOROOT) +if [ ! -x "$root/bin/go" ]; then + echo "go env GOROOT gave $root, which holds no go binary" >&2 + exit 1 +fi + +echo "elfuse-oci-go-workload-ok files=$n formatted=$n" diff --git a/scripts/ci/workloads/jvm-workload.sh b/scripts/ci/workloads/jvm-workload.sh new file mode 100644 index 00000000..a735ef19 --- /dev/null +++ b/scripts/ci/workloads/jvm-workload.sh @@ -0,0 +1,75 @@ +# shellcheck shell=sh +# Compile and run a JVM workload under POSIX sh. +set -e + +d=/tmp/elfuse-jvm-work +rm -rf "$d" +mkdir -p "$d" +cd "$d" +cat > Main.java << 'EOF' +import java.nio.file.*; +import java.security.MessageDigest; +import java.util.*; +import java.util.concurrent.*; + +public class Main { + static String sha256(byte[] b) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] d = md.digest(b); + StringBuilder sb = new StringBuilder(); + for (byte x : d) sb.append(String.format("%02x", x)); + return sb.toString(); + } + + public static void main(String[] args) throws Exception { + Map m = new HashMap<>(); + for (int i = 0; i < 1000; i++) m.put(i, i * i); + long collSum = 0; + for (int v : m.values()) collSum += v; + + Path p = Paths.get("data.bin"); + byte[] payload = new byte[65536]; + for (int i = 0; i < payload.length; i++) payload[i] = (byte) (i & 0xff); + Files.write(p, payload); + byte[] back = Files.readAllBytes(p); + if (!Arrays.equals(payload, back)) { + System.err.println("file io mismatch"); + System.exit(1); + } + String digest = sha256(back); + + ExecutorService ex = Executors.newFixedThreadPool(8); + List> fs = new ArrayList<>(); + for (int t = 0; t < 8; t++) { + final int base = t; + fs.add(ex.submit(() -> { + int s = 0; + for (int i = 0; i < 100000; i++) s += (base + i) & 7; + return s; + })); + } + long threadSum = 0; + for (Future f : fs) threadSum += f.get(); + ex.shutdown(); + + Process pr = new ProcessBuilder("/bin/echo", "child-ok") + .redirectErrorStream(true).start(); + String childOut = new String(pr.getInputStream().readAllBytes()).trim(); + int rc = pr.waitFor(); + if (rc != 0 || !childOut.equals("child-ok")) { + System.err.println("subprocess failed: " + childOut); + System.exit(1); + } + + if (collSum != 332833500L || threadSum != 2800000L + || !digest.equals("7daca2095d0438260fa849183dfc67faa459fdf4936e1bc91eec6b281b27e4c2")) { + System.err.println("sanity failed"); + System.exit(1); + } + System.out.println("elfuse-oci-jvm-workload-ok"); + } +} +EOF + +javac Main.java +java Main diff --git a/scripts/ci/workloads/node-compute.js b/scripts/ci/workloads/node-compute.js new file mode 100644 index 00000000..4055f231 --- /dev/null +++ b/scripts/ci/workloads/node-compute.js @@ -0,0 +1,42 @@ +'use strict'; +// Exercise filesystem, zlib, JSON, and crypto operations in the guest. + +const crypto = require('crypto'); +const zlib = require('zlib'); +const fs = require('fs'); +const path = require('path'); + +const DIR = '/tmp/elfuse-node'; +fs.mkdirSync(DIR, { recursive: true }); + +const N = 400; +for (let i = 0; i < N; i++) { + const p = path.join(DIR, 'f' + i); + const body = 'elfuse-node-' + i + '\n'; + fs.writeFileSync(p, body); + if (fs.readFileSync(p).toString() !== body) { + console.error('file content mismatch: ' + p); + process.exit(1); + } +} + +const payload = Buffer.alloc(1 << 16, 0x61); +if (!zlib.gunzipSync(zlib.gzipSync(payload)).equals(payload)) { + console.error('zlib round-trip mismatch'); + process.exit(1); +} + +const doc = { items: Array.from({ length: 256 }, (_, i) => ({ k: i, v: 'tok-' + i })) }; +const back = JSON.parse(JSON.stringify(doc)); +if (back.items.length !== 256 || back.items[255].v !== 'tok-255') { + console.error('json round-trip mismatch'); + process.exit(1); +} + +if (crypto.createHash('sha256').update('elfuse').digest('hex') !== + '87097914b16b96d167ef08a895d4d3f81f24267a617faca4b97c30183eb5fe02') { + console.error('sha256 self-check failed'); + process.exit(1); +} + +console.log('elfuse-oci-node-compute-ok'); diff --git a/scripts/ci/workloads/node-server.js b/scripts/ci/workloads/node-server.js new file mode 100644 index 00000000..e52f583c --- /dev/null +++ b/scripts/ci/workloads/node-server.js @@ -0,0 +1,24 @@ +'use strict'; +// elfuse exposes guest loopback sockets on the host. Port 0 avoids collisions. + +const http = require('http'); + +const server = http.createServer((req, res) => { + if (req.url === '/quit') { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('bye\n'); + server.close(() => process.exit(0)); + return; + } + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('elfuse-node-server-ok\n'); +}); + +server.on('error', (e) => { + console.error('server error: ' + e.message); + process.exit(1); +}); + +server.listen(0, '127.0.0.1', () => { + console.log('PORT=' + server.address().port); +}); diff --git a/scripts/ci/workloads/python-workload.py b/scripts/ci/workloads/python-workload.py new file mode 100644 index 00000000..43df9f17 --- /dev/null +++ b/scripts/ci/workloads/python-workload.py @@ -0,0 +1,171 @@ +"""Exercise Python filesystem, SQLite, and process operations under elfuse.""" + +import json +import os +import re +import shutil +import sqlite3 +import subprocess +import sys +import threading + +DB = "/tmp/elfuse-workload.db" +TREE = "/tmp/elfuse-workload-tree" +THREADS = 8 +PER_THREAD = 2500 +FANOUT = 20 +SUBPROCS = 10 + + +def setup_db(): + con = sqlite3.connect(DB) + try: + # WAL may fall back when the guest filesystem cannot back shared memory. + con.execute("PRAGMA journal_mode=WAL") + con.execute( + "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, " + "tid INTEGER NOT NULL, n INTEGER NOT NULL)" + ) + con.commit() + finally: + con.close() + + +def worker(tid): + con = sqlite3.connect(DB, timeout=60) + try: + for n in range(PER_THREAD): + con.execute("INSERT INTO t (tid, n) VALUES (?, ?)", (tid, n)) + con.commit() + finally: + con.close() + + +def db_count(): + con = sqlite3.connect(DB, timeout=60) + try: + (count,) = con.execute("SELECT COUNT(*) FROM t").fetchone() + return count + finally: + con.close() + + +def db_query_ok(): + con = sqlite3.connect(DB, timeout=60) + try: + rows = con.execute( + "SELECT tid, COUNT(*), MIN(n), MAX(n), SUM(n) FROM t" + " GROUP BY tid ORDER BY tid" + ).fetchall() + finally: + con.close() + want_sum = PER_THREAD * (PER_THREAD - 1) // 2 + return rows == [ + (tid, PER_THREAD, 0, PER_THREAD - 1, want_sum) for tid in range(THREADS) + ] + + +def json_regex_churn(): + word = re.compile(r"tok-(\d+)") + for r in range(2000): + doc = {"round": r, "items": [{"k": i, "v": "tok-%d" % i} for i in range(16)]} + blob = json.dumps(doc) + back = json.loads(blob) + got = [int(m) for m in word.findall(blob)] + if got != list(range(16)) or back != doc: + return False + return True + + +def content(i, j): + return "elfuse-workload-%d-%d\n" % (i, j) + + +def fs_fanout_ok(): + for i in range(FANOUT): + for j in range(FANOUT): + d = os.path.join(TREE, str(i), str(j)) + os.makedirs(d, exist_ok=True) + with open(os.path.join(d, "f"), "w") as fh: + fh.write(content(i, j)) + fh.flush() + os.fsync(fh.fileno()) + read_back = [] + for i in range(FANOUT): + for j in range(FANOUT): + with open(os.path.join(TREE, str(i), str(j), "f")) as fh: + read_back.append(fh.read()) + expected = [content(i, j) for i in range(FANOUT) for j in range(FANOUT)] + return read_back == expected + + +def walk_stdlib_ok(): + # The exact stdlib module count varies across image patch releases. + root = os.path.dirname(os.__file__) + n = 0 + for _, _, files in os.walk(root): + n += sum(1 for f in files if f.endswith(".py")) + return n > 200 + + +def subprocesses_ok(): + for i in range(SUBPROCS): + code = "print('child-%d')" % i + out = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + check=True, + timeout=60, + ).stdout.strip() + if out != "child-%d" % i: + return False + return True + + +def main(): + # Plain rootfs runs can retain files from an earlier invocation. + if os.path.exists(DB): + os.remove(DB) + shutil.rmtree(TREE, ignore_errors=True) + setup_db() + threads = [threading.Thread(target=worker, args=(i,)) for i in range(THREADS)] + for t in threads: + t.start() + for t in threads: + t.join() + + count = db_count() + if count != THREADS * PER_THREAD: + print( + "sqlite row count %d != %d (concurrent writers lost rows)" + % (count, THREADS * PER_THREAD), + file=sys.stderr, + ) + sys.exit(1) + + if not db_query_ok(): + print("sqlite per-thread aggregate mismatch", file=sys.stderr) + sys.exit(1) + + if not json_regex_churn(): + print("json/regex round-trip mismatch", file=sys.stderr) + sys.exit(1) + + if not fs_fanout_ok(): + print("filesystem fan-out content mismatch", file=sys.stderr) + sys.exit(1) + + if not walk_stdlib_ok(): + print("stdlib walk found too few modules", file=sys.stderr) + sys.exit(1) + + if not subprocesses_ok(): + print("subprocess output mismatch", file=sys.stderr) + sys.exit(1) + + print("elfuse-oci-python-workload-ok") + + +if __name__ == "__main__": + main() diff --git a/scripts/ci/workloads/redis-driver.sh b/scripts/ci/workloads/redis-driver.sh new file mode 100644 index 00000000..12533464 --- /dev/null +++ b/scripts/ci/workloads/redis-driver.sh @@ -0,0 +1,62 @@ +# shellcheck shell=sh +# Drive BGSAVE from a second guest while redis-server runs in the first. +set -e +port="$1" + +r() +{ + redis-cli -h 127.0.0.1 -p "$port" "$@" +} + +# redis-cli exits 0 for an error reply, so compare every response. +out=$(r PING) +if [ "$out" != PONG ]; then + echo "PING returned '$out'" >&2 + exit 1 +fi + +out=$(r SET elfuse:workload elfuse-redis-value) +if [ "$out" != OK ]; then + echo "SET returned '$out'" >&2 + exit 1 +fi + +out=$(r GET elfuse:workload) +if [ "$out" != elfuse-redis-value ]; then + echo "GET returned '$out'" >&2 + exit 1 +fi + +# rdb_last_bgsave_status starts at ok, so also wait for this save to finish. +out=$(r BGSAVE) +if [ "$out" != "Background saving started" ]; then + echo "BGSAVE returned '$out'" >&2 + exit 1 +fi + +# INFO lines end in \r\n, so substring greps only, never full-line matches. +i=0 +while [ "$i" -lt 30 ]; do + if r INFO persistence | grep -q 'rdb_bgsave_in_progress:0'; then + break + fi + sleep 1 + i=$((i + 1)) +done + +# Do not let the loop timeout fall through to the boot-time ok status. +info="$(r INFO persistence)" +if ! printf '%s' "$info" | grep -q 'rdb_bgsave_in_progress:0'; then + printf '%s\n' "$info" >&2 + echo "BGSAVE still in progress after 30s" >&2 + exit 1 +fi +if ! printf '%s' "$info" | grep -q 'rdb_last_bgsave_status:ok'; then + printf '%s\n' "$info" >&2 + echo "BGSAVE did not finish with status ok" >&2 + exit 1 +fi + +echo "elfuse-oci-redis-workload-ok" + +r SHUTDOWN NOSAVE || true diff --git a/scripts/ci/workloads/unix-socket.py b/scripts/ci/workloads/unix-socket.py new file mode 100644 index 00000000..7530af45 --- /dev/null +++ b/scripts/ci/workloads/unix-socket.py @@ -0,0 +1,30 @@ +import socket, threading, os +path = "/srv-sock/" + "d" * 50 + "/echo.sock" +os.makedirs(os.path.dirname(path), exist_ok=True) +srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +srv.settimeout(30) +srv.bind(path) +assert srv.getsockname() == path, srv.getsockname() +srv.listen(1) +msg = b"elfuse-unix-sock-ok" +def recv_exact(sock, n): + buf = b"" + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + assert chunk, "peer closed early" + buf += chunk + return buf +def serve(): + conn, _ = srv.accept() + conn.settimeout(30) + conn.sendall(recv_exact(conn, len(msg))) + conn.close() +t = threading.Thread(target=serve) +t.start() +cli = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +cli.settimeout(30) +cli.connect(path) +cli.sendall(msg) +print(recv_exact(cli, len(msg)).decode()) +t.join(30) +assert not t.is_alive(), "echo thread hung" From e3e97d30646187f403e0ea329dfc3de87a79f7db Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Tue, 6 Oct 2026 16:33:36 +0200 Subject: [PATCH 2/2] Document the runtime lanes docs/oci-images.md lists what each oci-runtime key covers, and docs/usage.md gives the local command. --- docs/oci-images.md | 12 ++++++++++++ docs/usage.md | 9 +++++++++ 2 files changed, 21 insertions(+) diff --git a/docs/oci-images.md b/docs/oci-images.md index a57e5e68..aee705e1 100644 --- a/docs/oci-images.md +++ b/docs/oci-images.md @@ -146,3 +146,15 @@ to any more, left when a tag moved and was pulled again, shows as ``. `scripts/ci/oci-smoke.sh`: those table tests, then pull, run, list, and clean under elfuse on Alpine and Debian images, an image whose `/etc` climbs to a host directory, and `clean` refusing a non-store and a store in use. + +The self-hosted `oci-runtime` job runs elfuse itself on real runtimes, one lane +per key of `scripts/ci/oci-runtime.sh`: + +| Key | Coverage | +|-----|----------| +| `python` | Threaded SQLite writers, a filesystem walk, subprocesses, and a Unix socket path too deep for the host's `sun_path` | +| `node` | Filesystem, zlib, JSON, and crypto work, then 100 requests to an HTTP server guest | +| `go` | `gofmt` over a generated tree with `GOMAXPROCS=2` | +| `jvm` | `javac`, a thread pool, SHA-256 hashing, and `ProcessBuilder` | +| `c` | A multi-file `make` project and a 1000-function compile | +| `redis` | A server guest driven by a second guest through `SET` and `BGSAVE` | diff --git a/docs/usage.md b/docs/usage.md index 651e0a1d..cca0f072 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -393,3 +393,12 @@ ELFUSE_OCI_STORE=/tmp/oci-scratch scripts/ci/oci-smoke.sh The lane needs macOS with Hypervisor.framework and network access, and ends by cleaning the store. Its coverage is in [oci-images.md](oci-images.md#validation). + +### Running The Runtime Lanes Locally + +```sh +make elfuse +ELFUSE_OCI_STORE=/tmp/oci-scratch scripts/ci/oci-runtime.sh python # or node, go, jvm, c, redis +``` + +Every lane also needs `nc`, and the node lane `curl`.