diff --git a/.github/docker/Dockerfile.ci b/.github/docker/Dockerfile.ci index 39247f4a1..ff4b4c87f 100644 --- a/.github/docker/Dockerfile.ci +++ b/.github/docker/Dockerfile.ci @@ -69,10 +69,10 @@ RUN curl --retry 5 --retry-delay 5 --retry-connrefused -fsSL "https://nodejs.org # The version MUST be passed as a positional arg — bun.sh/install ignores a # BUN_VERSION env var, so the old `| BUN_VERSION=x.y.z bash` form silently # installed latest on every image rebuild (observed: 1.3.13/1.3.14 drift vs -# the 1.3.10 devs run locally). +# the 1.3.10 devs ran locally). ENV BUN_INSTALL="/usr/local" RUN curl --retry 5 --retry-delay 5 --retry-connrefused -fsSL https://bun.sh/install \ - | bash -s "bun-v1.3.13" + | bash -s "bun-v1.4.0" # Claude CLI — pinned to an EXACT version, same discipline as the bun pin # above. The PTY harness (test/helpers/claude-pty-runner.ts) screen-scrapes diff --git a/.github/workflows/evals-periodic.yml b/.github/workflows/evals-periodic.yml index 67c92ef1a..8b114aa63 100644 --- a/.github/workflows/evals-periodic.yml +++ b/.github/workflows/evals-periodic.yml @@ -272,7 +272,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.13 + bun-version: 1.4.0 - run: bun install --frozen-lockfile diff --git a/.github/workflows/evals.yml b/.github/workflows/evals.yml index 71231a87a..4194ac092 100644 --- a/.github/workflows/evals.yml +++ b/.github/workflows/evals.yml @@ -261,7 +261,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.13 + bun-version: 1.4.0 - run: bun install --frozen-lockfile diff --git a/.github/workflows/free-tests.yml b/.github/workflows/free-tests.yml index 3dcfc1ad5..fd0368df0 100644 --- a/.github/workflows/free-tests.yml +++ b/.github/workflows/free-tests.yml @@ -54,7 +54,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.13 + bun-version: 1.4.0 - uses: actions/cache@v6 with: diff --git a/.github/workflows/make-pdf-gate.yml b/.github/workflows/make-pdf-gate.yml index e35f6d590..f9ee442c7 100644 --- a/.github/workflows/make-pdf-gate.yml +++ b/.github/workflows/make-pdf-gate.yml @@ -53,7 +53,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.13 + bun-version: 1.4.0 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index 6fc745e75..9168e832a 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -38,7 +38,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.3.13 + bun-version: 1.4.0 - name: Install frozen dependencies run: bun install --frozen-lockfile --ignore-scripts diff --git a/.github/workflows/skill-docs.yml b/.github/workflows/skill-docs.yml index bdd3ec3b7..966b878be 100644 --- a/.github/workflows/skill-docs.yml +++ b/.github/workflows/skill-docs.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.13 + bun-version: 1.4.0 - run: bun install --frozen-lockfile # One generation pass for ALL 10 hosts. gen-skill-docs --host all # hard-fails on any per-host generation error (scripts/gen-skill-docs.ts diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml index 3c8dcb1f6..fb715c03d 100644 --- a/.github/workflows/version-gate.yml +++ b/.github/workflows/version-gate.yml @@ -29,7 +29,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.13 + bun-version: 1.4.0 - name: Read versions id: versions diff --git a/.github/workflows/windows-free-tests.yml b/.github/workflows/windows-free-tests.yml index 25170f364..8879f79a0 100644 --- a/.github/workflows/windows-free-tests.yml +++ b/.github/workflows/windows-free-tests.yml @@ -47,7 +47,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.13 + bun-version: 1.4.0 # bun install was 35s of a 55s job, all network. Cache keyed on the # lockfile; bun's install cache lives under ~/.bun/install/cache on diff --git a/.github/workflows/windows-setup-e2e.yml b/.github/workflows/windows-setup-e2e.yml index 6eeac3b2e..9f41c1d03 100644 --- a/.github/workflows/windows-setup-e2e.yml +++ b/.github/workflows/windows-setup-e2e.yml @@ -45,7 +45,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.13 + bun-version: 1.4.0 # Same lockfile-keyed install cache as windows-free-tests.yml (install # was 45s of a 64s job, all network). diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 583d5d78d..4f9345feb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,7 +6,7 @@ stages: - check variables: - BUN_VERSION: "1.3.13" + BUN_VERSION: "1.4.0" .setup-bun: &setup-bun - apt-get update -qq && apt-get install -qq -y curl jq git diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5d388f4f1..9d4264d54 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -138,6 +138,11 @@ never touches a symlinked or non-gstack directory. ### Setup +Development and tests require Bun 1.4.0 or newer; CI pins and tests 1.4.0. +Earlier Linux versions can close unrelated live file descriptors during +subprocess garbage collection, causing intermittent browser and HTTP fixture +failures ([upstream diagnosis](https://github.com/oven-sh/bun/issues/34785#issuecomment-5020318035)). + ```bash # 1. Copy .env.example and add your API key cp .env.example .env diff --git a/setup b/setup index cd4765ee1..57383a419 100755 --- a/setup +++ b/setup @@ -675,9 +675,9 @@ fi # potentially leaving Playwright cache locks held. macOS ships no setsid # binary, so a portable group-kill isn't available; walk `pgrep -P` children # depth-first instead (pgrep exists on macOS and Linux). Falls back to a -# plain kill of the root pid when pgrep is unavailable. +# /proc walk where available, otherwise a plain kill of the root pid. _kill_tree() { - local pid="$1" child + local pid="$1" child stat_file stat_line proc_state proc_parent proc_rest if command -v pgrep >/dev/null 2>&1; then for child in $(pgrep -P "$pid" 2>/dev/null); do _kill_tree "$child" @@ -686,8 +686,15 @@ _kill_tree() { # debian-slim and git-bash ship no pgrep: walk /proc for children. The # comm field "(name)" may contain spaces and parens, so strip through the # LAST closing paren (proc(5)) before reading the ppid (second field after it). - for child in $(awk -v p="$pid" '{ s=$0; sub(/^.*\) /, "", s); split(s, f, " "); if (f[2]==p) print $1 }' /proc/[0-9]*/stat 2>/dev/null); do - _kill_tree "$child" + # A process may exit after glob expansion. One awk over every stat file + # aborts at that missing entry and never visits the remaining live children. + for stat_file in /proc/[0-9]*/stat; do + IFS= read -r stat_line 2>/dev/null < "$stat_file" || continue + child="${stat_line%% *}" + IFS=' ' read -r proc_state proc_parent proc_rest <<< "${stat_line##*) }" + if [ "$proc_parent" = "$pid" ]; then + _kill_tree "$child" + fi done fi kill -9 "$pid" 2>/dev/null || true diff --git a/test/aside-render.test.ts b/test/aside-render.test.ts index 53460c267..70003824f 100644 --- a/test/aside-render.test.ts +++ b/test/aside-render.test.ts @@ -6,7 +6,7 @@ * installed and open (macOS dev machines); the live fallback render runs * wherever a browse binary resolves (Linux CI builds one via build:gates). */ -import { describe, test, expect, beforeAll, afterAll, setDefaultTimeout } from 'bun:test'; +import { describe, test, expect, beforeAll, afterAll, setDefaultTimeout, spyOn } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -114,8 +114,7 @@ async function liveRoundTrip(engine: 'aside' | 'browse', renderFn: typeof render ], timeoutMs: 90_000, }); - expect(out.error).toBeUndefined(); - expect(out.ok).toBe(true); + expectOk(out); expect(out.engine).toBe(engine); expect(out.outputs).toEqual([path.join(dir, 'out.pdf'), path.join(dir, 'm.jpg'), path.join(dir, 'v.txt'), path.join(dir, 'bytes.bin')]); expect(fs.readFileSync(path.join(dir, 'out.pdf')).subarray(0, 4).toString()).toBe('%PDF'); @@ -135,8 +134,7 @@ async function lateReadiness(engine: 'aside' | 'browse', renderFn: typeof render fs.writeFileSync(path.join(dir, 'late.html'), 'Late'); try { const out = await renderFn({ file: path.join(dir, 'late.html'), waitFor: { expression: 'window.later.ok', timeoutMs: 10_000 }, steps: [{ kind: 'eval', expression: 'document.title' }], timeoutMs: 60_000 }); - expect(out.error).toBeUndefined(); - expect(out.ok).toBe(true); + expectOk(out); expect(out.engine).toBe(engine); expect(out.evals[0]).toBe('Late'); } finally { @@ -318,7 +316,32 @@ const expectOk = (r: RenderResult): void => { // not latency. Bun's 5s default once failed a CI run whose render was merely slow // under a full six-shard load, so the budget is generous and hangs still fail. setDefaultTimeout(30_000); -const browseWorkDirs = (): string[] => fs.readdirSync(SAFE_TMP_DIR).filter((n) => n.startsWith('gstack-render-browse-')); + +/** Check this render's staging directory, regardless of other renders using /tmp. */ +async function renderCheckingCleanup(spec: RenderSpec, bin: string, renderFn = renderWithBrowse): Promise { + const workDirs: string[] = []; + const mkdtemp = fs.mkdtempSync; + // The renderer allocates before its first await. Observe that synchronous + // call, then restore immediately so unrelated async work is never captured. + const allocation = spyOn(fs, 'mkdtempSync').mockImplementation(((...args: Parameters) => { + const dir = mkdtemp(...args); + if (args[0] === path.join(SAFE_TMP_DIR, 'gstack-render-browse-')) workDirs.push(dir.toString()); + return dir; + }) as typeof fs.mkdtempSync); + let pending: Promise; + try { + pending = renderFn(spec, bin); + } finally { + allocation.mockRestore(); + } + try { + return await pending; + } finally { + // A changed allocation boundary must fail, not silently skip leak checks. + expect(workDirs, 'expected to observe this render\'s staging directory').toHaveLength(1); + for (const dir of workDirs) expect(fs.existsSync(dir), `render leaked staging directory: ${dir}`).toBe(false); + } +} /** The subprocess driver: one job per process, so the module's engine cache and the spawn-time PATH are both under the test's control. */ function writeDriver(dir: string): string { @@ -656,10 +679,47 @@ describe.skipIf(!HERMETIC)('aside-render: renderWithBrowse — daemon CLI contra }; const T = '--tab-id 7'; + test('cleanup remains verifiable when another render removes its staging directory', async () => { + const sibling = fs.mkdtempSync(path.join(SAFE_TMP_DIR, 'gstack-render-browse-')); + try { + // Synchronize the other render's cleanup with our newtab command, so + // this reproduces the shared-/tmp race without relying on timing. + const b = fake({ newtab: `rmdir '${sibling.replaceAll("'", "'\\''")}'\necho '{"tabId":7}'` }); + const r = await renderCheckingCleanup({ file: doc, steps: [] }, b); + expectOk(r); + expect(fs.existsSync(sibling)).toBe(false); + } finally { + fs.rmSync(sibling, { recursive: true, force: true }); + } + }); + + test('cleanup leaves another render\'s new staging directory intact', async () => { + const sibling = fs.mkdtempSync(path.join(SAFE_TMP_DIR, 'gstack-render-browse-')); + fs.rmdirSync(sibling); + try { + const b = fake({ newtab: `mkdir '${sibling.replaceAll("'", "'\\''")}'\necho '{"tabId":7}'` }); + expectOk(await renderCheckingCleanup({ file: doc, steps: [] }, b)); + expect(fs.existsSync(sibling)).toBe(true); + } finally { + fs.rmSync(sibling, { recursive: true, force: true }); + } + }); + + test('cleanup check still rejects an owned staging directory leak', async () => { + let leaked: string | undefined; + try { + await expect(renderCheckingCleanup({ file: doc, steps: [] }, 'unused', async () => { + leaked = fs.mkdtempSync(path.join(SAFE_TMP_DIR, 'gstack-render-browse-')); + return { ok: true, engine: 'browse', outputs: [], evals: {}, stdout: '' }; + })).rejects.toThrow('render leaked staging directory'); + } finally { + if (leaked) fs.rmSync(leaked, { recursive: true, force: true }); + } + }); + test('happy path: newtab → goto → per-step CLI calls → closetab; artifacts copied, evals inline, work dir and server released', async () => { - const before = browseWorkDirs(); const b = fake(); - const r = await renderWithBrowse({ + const r = await renderCheckingCleanup({ file: doc, steps: [ { kind: 'pdf', out: path.join(outDir, 'doc.pdf'), options: { paperWidth: 8.5, paperHeight: 11 } }, @@ -693,23 +753,19 @@ describe.skipIf(!HERMETIC)('aside-render: renderWithBrowse — daemon CLI contra const payload = fs.readFileSync(`${log}.payloads`, 'utf8'); expect(payload).toContain('"width":"8.5in"'); expect(payload).toMatch(/"output":"\/tmp\/gstack-render-browse-[^"]+\/gstack-render-0\.pdf"/); - expect(browseWorkDirs()).toEqual(before); // /tmp staging dir removed await expect(fetch(goto.slice('goto '.length, -` ${T}`.length))).rejects.toThrow(); // loopback server stopped }); test('`newtab --json` without a tabId → the named error, no closetab, no staging dir left in /tmp', async () => { - const before = browseWorkDirs(); - const r = await renderWithBrowse({ file: doc, steps: [{ kind: 'eval', expression: '1' }] }, fake({ newtab: `echo '{"ok":true}'` })); + const r = await renderCheckingCleanup({ file: doc, steps: [{ kind: 'eval', expression: '1' }] }, fake({ newtab: `echo '{"ok":true}'` })); expect(r.ok).toBe(false); expect(r.engine).toBe('browse'); expect(r.error).toBe('browse newtab --json returned no tabId'); expect(readLines(log)).toEqual(['newtab --json']); - expect(browseWorkDirs()).toEqual(before); }); test('a failing goto → "browse goto failed: ", the tab is still closed, /tmp is left clean', async () => { - const before = browseWorkDirs(); - const r = await renderWithBrowse({ file: doc, steps: [{ kind: 'pdf', out: path.join(outDir, 'x.pdf') }] }, fake({ goto: 'echo "net::ERR_CONNECTION_REFUSED at http://127.0.0.1" >&2; echo "second line" >&2; exit 1' })); + const r = await renderCheckingCleanup({ file: doc, steps: [{ kind: 'pdf', out: path.join(outDir, 'x.pdf') }] }, fake({ goto: 'echo "net::ERR_CONNECTION_REFUSED at http://127.0.0.1" >&2; echo "second line" >&2; exit 1' })); expect(r.ok).toBe(false); expect(r.error!.startsWith('browse goto failed:')).toBe(true); expect(r.error).toContain('net::ERR_CONNECTION_REFUSED'); @@ -719,7 +775,6 @@ describe.skipIf(!HERMETIC)('aside-render: renderWithBrowse — daemon CLI contra expect(lines.some((l) => l.startsWith('goto '))).toBe(true); expect(lines.at(-1)).toBe('closetab 7'); expect(lines.some((l) => l.startsWith('pdf '))).toBe(false); - expect(browseWorkDirs()).toEqual(before); expect(fs.existsSync(path.join(outDir, 'x.pdf'))).toBe(false); }); @@ -803,19 +858,17 @@ describe.skipIf(!HERMETIC)('aside-render: renderWithBrowse — daemon CLI contra // runProc is not exported: its timeout + kill path is observed through a hanging fake. test('a CLI call that hangs past spec.timeoutMs is killed and reported as timed out — even when a grandchild keeps the pipes open', async () => { - const before = browseWorkDirs(); // `sleep` is a CHILD of the sh fake, so SIGTERM kills sh while sleep still holds stdout/stderr: // the read must give up on its own (timeout + 10s) rather than wait for EOF. 14s (not 30s) so no orphan outlives this file. const b = fake({ newtab: 'sleep 14' }); const t0 = Date.now(); - const r = await renderWithBrowse({ file: doc, steps: [{ kind: 'eval', expression: '1' }], timeoutMs: 1_500 }, b); + const r = await renderCheckingCleanup({ file: doc, steps: [{ kind: 'eval', expression: '1' }], timeoutMs: 1_500 }, b); const elapsed = Date.now() - t0; expect(r.ok).toBe(false); expect(r.error!.startsWith('browse newtab failed:')).toBe(true); expect(r.error).toContain('timed out'); expect(elapsed).toBeLessThan(25_000); expect(readLines(log)).toEqual(['newtab --json']); // no tab → nothing to close - expect(browseWorkDirs()).toEqual(before); }, 40_000); test('a hanging CLI that honours SIGTERM is reaped promptly at the budget', async () => { diff --git a/test/bun-subprocess-fd-lifetime.test.ts b/test/bun-subprocess-fd-lifetime.test.ts new file mode 100644 index 000000000..6714298c2 --- /dev/null +++ b/test/bun-subprocess-fd-lifetime.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from 'bun:test'; + +// Run the ownership probe in a child: an affected Bun can close arbitrary +// recycled descriptors, including the test runner's own sockets and pipes. +// Playwright uses the same extra-stdio slots for Chromium's CDP transport. +// Upstream ownership fixes: oven-sh/bun#32520 and oven-sh/bun#33828. +const fixture = String.raw` + const { spawn } = require('node:child_process'); + const rounds = 4; + const listenersPerRound = 8; + for (let round = 0; round < rounds; round++) { + let child = spawn(process.execPath, ['-e', ''], { + stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe', 'pipe'], + }); + await new Promise((resolve, reject) => { + child.once('exit', resolve); + child.once('error', reject); + }); + await Promise.all(child.stdio.slice(3).map(socket => { + if (socket.closed) return; + return new Promise(resolve => { + // Subscribe before destroy: descriptor reuse must follow the actual + // close event, not a delay that may expire before close under load. + socket.once('close', resolve); + socket.destroy(); + }); + })); + + // The OS can now reuse the closed extra-stdio descriptors for these + // listeners. Capture their URLs before GC; affected runtimes can also + // invalidate server.port when the underlying listener vanishes. + const listeners = Array.from({ length: listenersPerRound }, () => { + const server = Bun.serve({ + hostname: '127.0.0.1', port: 0, fetch: () => new Response('alive'), + }); + return { server, url: 'http://127.0.0.1:' + server.port + '/' }; + }); + child = null; + Bun.gc(true); + await Bun.sleep(0); + Bun.gc(true); + + const failures = []; + for (const { url } of listeners) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(1_000) }); + const body = await response.text(); + if (response.status !== 200 || body !== 'alive') { + failures.push({ url, status: response.status, body }); + } + } catch (error) { + failures.push({ url, error: String(error) }); + } + } + if (failures.length) { + console.error(JSON.stringify({ bun: Bun.version, round, failures })); + // Only this isolated process is affected. Avoid asking the broken + // runtime to close descriptors again; process exit releases them. + process.exit(1); + } + for (const { server } of listeners) await server.stop(true); + } + console.log(JSON.stringify({ checkedListeners: rounds * listenersPerRound })); +`; + +test.skipIf(process.platform === 'win32')('extra-stdio cleanup preserves unrelated HTTP listeners after GC', async () => { + const child = Bun.spawn([process.execPath, '-e', fixture], { + stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', timeout: 10_000, killSignal: 'SIGKILL', + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited, + ]); + const diagnosis = `Bun ${Bun.version} failed the subprocess descriptor-ownership probe. ` + + 'Install the repository\'s pinned Bun version (1.4.0 or newer); ' + + 'older Bun can double-close extra stdio and destroy unrelated browser/server sockets ' + + '(oven-sh/bun#32520, #33828).\n' + + `exit=${code}\nstdout:\n${stdout}\nstderr:\n${stderr}`; + expect(code, diagnosis).toBe(0); + expect(JSON.parse(stdout), diagnosis).toEqual({ checkedListeners: 32 }); + expect(stderr, diagnosis).toBe(''); +}, 15_000); diff --git a/test/bun-version-drift.test.ts b/test/bun-version-drift.test.ts index ce3dd4dd5..98a80f917 100644 --- a/test/bun-version-drift.test.ts +++ b/test/bun-version-drift.test.ts @@ -73,4 +73,18 @@ describe('bun version pins', () => { expect(versions, `bun version drift across CI surfaces:\n${detail}`).toHaveLength(1); expect(versions[0]).toMatch(/^\d+\.\d+\.\d+$/); }); + + test('every CI surface requires Bun 1.4.0 or newer for safe extra-stdio ownership', () => { + // Matching pins alone would allow every lane to regress together. Older + // Linux Bun releases double-close extra stdio FDs during subprocess GC, + // which can close unrelated listeners after the OS reuses an FD number. + // https://github.com/oven-sh/bun/issues/34785#issuecomment-5020318035 + for (const pin of collectPins()) { + expect(pin.version, `${pin.surface} must pin a stable numeric version`).toMatch(/^\d+\.\d+\.\d+$/); + expect( + Bun.semver.satisfies(pin.version, '>=1.4.0'), + `${pin.surface} pins Bun ${pin.version}; Bun >=1.4.0 is required for safe extra-stdio ownership`, + ).toBe(true); + } + }); }); diff --git a/test/setup-playwright-best-effort.test.ts b/test/setup-playwright-best-effort.test.ts index bb5829f3d..746d04102 100644 --- a/test/setup-playwright-best-effort.test.ts +++ b/test/setup-playwright-best-effort.test.ts @@ -180,6 +180,62 @@ function runBlock(opts: { return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1, elapsedMs: Date.now() - t0, tmp }; } +/** Run the real fallback against a child that reports readiness over a pipe. */ +function runKillTreeFallback(disappearingEntry = false): { status: number | null; stdout: string; stderr: string } { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-killtree-')); + try { + const bin = path.join(tmp, 'bin'); + fs.mkdirSync(bin); + for (const name of ['bash', 'sleep', 'awk', 'mkdir']) { + const real = (spawnSync('which', [name], { encoding: 'utf-8', timeout: 10_000 }).stdout ?? '').trim(); + if (!real) throw new Error(`kill-tree fixture needs ${name}`); + fs.symlinkSync(real, path.join(bin, name)); + } + const procRoot = path.join(tmp, 'proc'); + if (disappearingEntry) { + fs.mkdirSync(path.join(procRoot, '0'), { recursive: true }); + // Glob expansion still includes this symlink, but opening it fails just + // like /proc//stat when that process exits before the reader opens it. + fs.symlinkSync(path.join(tmp, 'exited-process'), path.join(procRoot, '0', 'stat')); + } + const killTree = disappearingEntry + ? extractFn('_kill_tree').replace('/proc/[0-9]*/stat', `"${procRoot}"/[0-9]*/stat`) + : extractFn('_kill_tree'); + const script = [ + `export PATH="${bin}"`, + 'hash -r', + 'command -v pgrep >/dev/null 2>&1 && { echo "PGREP_PRESENT"; exit 1; }', + killTree, + // The intermediate shell reports the real grandchild PID after fork. + // Both processes hold the output pipe open until they are terminated. + 'coproc TREE { sleep 30 & printf "%s\\n" "$!"; wait; }', + 'pid=$TREE_PID; child=', + 'trap \'kill -9 "${child:-}" "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true\' EXIT', + 'exec {reader}<&"${TREE[0]}"', + 'IFS= read -r -t 5 child <&"$reader" || { echo "CHILD_NOT_READY"; exit 1; }', + '[[ "$child" =~ ^[1-9][0-9]*$ ]] || { echo "INVALID_CHILD_PID"; exit 1; }', + 'kill -0 "$child" 2>/dev/null || { echo "CHILD_EXITED_BEFORE_KILL"; exit 1; }', + ...(disappearingEntry ? [ + `mkdir "${procRoot}/$child"`, + 'IFS= read -r stat_line < "/proc/$child/stat"', + // Keep the real pid/ppid and remaining fields. A comm containing both + // spaces and parentheses must not shift the parser's parent PID field. + `printf '%s (fixture child ) with (parens)) %s\\n' "$child" "${'$'}{stat_line##*) }" > "${procRoot}/$child/stat"`, + ] : []), + '_kill_tree "$pid"', + // EOF witnesses termination even if init has not yet reaped the child. + // A missed descendant still holds the pipe, causing a bounded failure. + 'if IFS= read -r -t 5 remaining <&"$reader"; then echo "UNEXPECTED_CHILD_OUTPUT"; exit 1; else read_code=$?; fi', + '[ "$read_code" -eq 1 ] || { echo "CHILD_ALIVE"; exit 1; }', + 'echo "CHILD_DEAD"', + ].join('\n'); + const r = spawnSync('/bin/bash', ['-c', script], { encoding: 'utf-8', timeout: 20_000 }); + return { status: r.status, stdout: r.stdout ?? '', stderr: (r.stderr ?? '') + (r.error ? `\n${r.error.message}` : '') }; + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + describe('setup: Chromium bootstrap block executes best-effort', () => { test('probe ok: no reason recorded, no install attempted', () => { const r = runBlock({ probe: 'ok', bunx: 'exit 0' }); @@ -263,35 +319,18 @@ describe('setup: Chromium bootstrap block executes best-effort', () => { test('_kill_tree without pgrep on PATH still kills the grandchild (walks /proc)', () => { if (!fs.existsSync('/proc')) return; - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-killtree-')); - try { - const bin = path.join(tmp, 'bin'); - fs.mkdirSync(bin); - for (const name of ['bash', 'sleep', 'awk']) { - const real = (spawnSync('which', [name], { encoding: 'utf-8', timeout: 10_000 }).stdout ?? '').trim(); - if (real) fs.symlinkSync(real, path.join(bin, name)); - } - const script = [ - `export PATH="${bin}"`, - 'hash -r', - 'command -v pgrep >/dev/null 2>&1 && { echo "PGREP_PRESENT"; exit 0; }', - extractFn('_kill_tree'), - // two commands so bash forks a real subshell instead of exec-ing sleep directly - '( sleep 30; true ) & pid=$!', - 'sleep 0.3', - // find the sleep grandchild via /proc, the same way the fallback does - 'child=$(awk -v p="$pid" \'{ s=$0; sub(/^[^)]*\\) /, "", s); split(s, f, " "); if (f[2]==p) { print $1; exit } }\' /proc/[0-9]*/stat 2>/dev/null)', - '[ -n "$child" ] || { echo "NO_CHILD"; exit 0; }', - '_kill_tree "$pid"', - 'sleep 0.3', - 'if kill -0 "$child" 2>/dev/null; then echo "CHILD_ALIVE"; else echo "CHILD_DEAD"; fi', - ].join('\n'); - const r = spawnSync('/bin/bash', ['-c', script], { encoding: 'utf-8', timeout: 20_000 }); - expect(r.stdout).toContain('CHILD_DEAD'); - expect(r.stdout).not.toContain('PGREP_PRESENT'); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + const r = runKillTreeFallback(); + expect(r.status, `${r.stdout}\n${r.stderr}`).toBe(0); + expect(r.stdout).toContain('CHILD_DEAD'); + expect(r.stdout).not.toContain('PGREP_PRESENT'); + }, 30_000); + + test('_kill_tree skips vanished /proc entries and parses a child comm with spaces and parentheses', () => { + if (!fs.existsSync('/proc')) return; + const r = runKillTreeFallback(true); + expect(r.status, `${r.stdout}\n${r.stderr}`).toBe(0); + expect(r.stdout).toContain('CHILD_DEAD'); + expect(r.stdout).not.toContain('PGREP_PRESENT'); }, 30_000); test('install succeeds but the post-install probe fails: reason post-install-launch with the userns hint', () => {