mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 00:44:07 +01:00
fix: eliminate browser and process-cleanup test flakes
Pin every CI surface to Bun 1.4.0 to avoid extra-stdio finalizers closing reused live sockets. Add an isolated GC/listener regression that fails on Bun 1.3.13, and prevent coordinated rollback to an affected CI runtime. Check renderer cleanup against the render's own staging directory so concurrent renders cannot invalidate the assertion. Make the no-pgrep process-tree walk tolerate disappearing /proc entries, and synchronize its test fixture through child readiness and pipe EOF instead of sleeps. Validation: 9,157 passed, 31 skipped, zero failures across 556 files with retries disabled. Build, all-host generation freshness, and skill checks passed. All three races have failing-before/passing-after regressions.
This commit is contained in:
1 parent
5e48b4d277
commit
042daeee71
17 files changed
+262
-63
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+71
-18
@@ -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'), '<!doctype html><title>Late</title><body><script>setTimeout(() => { window.later = { ok: true }; }, 800);</script></body>');
|
||||
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<RenderResult> {
|
||||
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<typeof fs.mkdtempSync>) => {
|
||||
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<RenderResult>;
|
||||
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 <nonce URL> → 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: <first stderr line>", 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 () => {
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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/<pid>/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', () => {
|
||||
|
||||
Reference in new issue
Block a user