diff --git a/scripts/ci/test-durations.windows.tsv b/scripts/ci/test-durations.windows.tsv index 8160bf7f196..5ee31b6bd6c 100644 --- a/scripts/ci/test-durations.windows.tsv +++ b/scripts/ci/test-durations.windows.tsv @@ -1,7 +1,7 @@ # Per-file Bun test durations in milliseconds, read from hosted CI job logs. # Consumed by scripts/ci/run-bun-test-batches.sh to balance shards; a file without a row weighs the median. # Regenerate with scripts/ci/test-durations.ts (usage in its header); do not edit by hand. -# Source: runs 41491122166 (workflow_dispatch on dev 1800b4d7f: windows 7/11, 8/11, 10/11 failed and 9/11 cancelled; files measured to completion count) and 41462914814 (dev 09b558580), windows 1/11-11/11; prior rows retained for unmeasured files +# Source: runs 41491122166 (workflow_dispatch on dev 1800b4d7f: windows 7/11, 8/11, 10/11 failed and 9/11 cancelled; files measured to completion count) and 41462914814 (dev 09b558580), windows 1/11-11/11; prior rows retained for unmeasured files; provisional overflow primary groups: 42161735382 jobs 127708835065 baseline pass, 127708835193 builtin 4 timed out, 127708835075 collectors pass, 127708835100 stream pass, 127708835179 verdicts pass; 42168265793 jobs 127730254061 native 1 timed out, 127730254018 verdicts pass; 42145900556 job 127657289210 translated 3 timed out 4873 tests/adapters/abort-race.test.ts 2703 tests/adapters/adapter-buffered-tool-conformance.test.ts 897 tests/adapters/adapter-error-inline.test.ts @@ -427,6 +427,13 @@ 2818 tests/claude-integration/messages-native-opaque-state.test.ts 15045 tests/claude-integration/messages-native-scope.test.ts 52752 tests/claude-integration/messages-native.test.ts +224 tests/claude-integration/messages-overflow-baseline-contract.test.ts +391302 tests/claude-integration/messages-overflow-builtin.test.ts +9197 tests/claude-integration/messages-overflow-collectors.test.ts +290644 tests/claude-integration/messages-overflow-native.test.ts +8748 tests/claude-integration/messages-overflow-stream.test.ts +346067 tests/claude-integration/messages-overflow-translated.test.ts +255760 tests/claude-integration/messages-overflow-verdicts.test.ts 71096 tests/claude-integration/messages-request-id-endpoint.test.ts 174 tests/claude-integration/messages-request-id-headers.test.ts 45635 tests/claude-integration/messages-revoked-token.test.ts diff --git a/tests/claude-integration/messages-overflow-baseline-contract.test.ts b/tests/claude-integration/messages-overflow-baseline-contract.test.ts index 64f653c6050..be8d1451b07 100644 --- a/tests/claude-integration/messages-overflow-baseline-contract.test.ts +++ b/tests/claude-integration/messages-overflow-baseline-contract.test.ts @@ -1,7 +1,13 @@ import { expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, relative } from "node:path"; import { repoPath } from "../helpers/repo-root"; +import { collectOverflowChild, finishOverflowScratch, overflowScratchBase, type OverflowChild } from "../helpers/messages-overflow-child"; +import { TEST_TEMP_OWNER_FILE } from "../../scripts/test-temp"; +import { trackOverflowAsyncSpawns } from "../helpers/messages-overflow-spawn-tracker"; +import { setWindowsOwnerAclAsyncRunnerForTests, verifyWindowsEntriesAclAsync } from "../../src/lib/windows-owner-acl"; const digest = (text: string | Uint8Array) => createHash("sha256").update(text).digest("hex"); test("#8128 shared overflow predicate changes only in the documented 5xx and conditional cases", () => { @@ -22,3 +28,161 @@ test("#8128 behavior suite retains origin/dev source", () => { expect(source.length).toBeGreaterThan(0); expect(digest(source)).toBe("d5d0573f377c5d7894bff343a0bd9cebec46df5bc405b112238862479a3e7328"); }); + +test("retained overflow scratch is outside nested test sandboxes and junction aliases", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-overflow-location-")); + try { + const nested = join(root, "nested"); + const inner = join(nested, "tmp"); + mkdirSync(inner, { recursive: true }); + writeFileSync(join(root, TEST_TEMP_OWNER_FILE), "{}"); + writeFileSync(join(nested, TEST_TEMP_OWNER_FILE), "{}"); + const alias = join(root, "alias"); + symlinkSync(inner, alias, process.platform === "win32" ? "junction" : "dir"); + const base = overflowScratchBase(alias); + const rel = relative(realpathSync.native(root), base); + expect(rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(rel)).toBe(true); + expect(base).toBe(overflowScratchBase(inner)); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("overflow child drains both streams before returning its exit", async () => { + const child = Bun.spawn([process.execPath, "-e", "process.stdout.write('out'); process.stderr.write('err');"], { stdout: "pipe", stderr: "pipe" }); + const result = await collectOverflowChild(child, performance.now() + 25_000); + expect(result).toEqual({ exit: 0, stdout: "out", stderr: "err" }); + expect(child.exitCode).toBe(0); +}, 30_000); + +test("overflow deadline kills and joins the owned real child", async () => { + const child = Bun.spawn([process.execPath, "-e", "process.stdout.write('out'); process.stderr.write('err'); setInterval(() => {}, 1000);"], { stdout: "pipe", stderr: "pipe" }); + let joined = false; + try { + await expect(collectOverflowChild(child, performance.now() + 500)).rejects.toThrow("internal deadline"); + const exit = await child.exited; + joined = true; + expect(exit).not.toBe(0); + } finally { + if (!joined) { try { child.kill(); } finally { await child.exited; } } + } +}, 30_000); + +test("a pipe failure kills and joins the still-live owned child", async () => { + let code: number | null = null; + let resolveExit!: (value: number) => void; + let kills = 0; + const child: OverflowChild = { + pid: 42, + get exitCode() { return code; }, + exited: new Promise(resolve => { resolveExit = resolve; }), + stdout: new ReadableStream({ start(controller) { controller.error(new Error("fixture pipe failure")); } }), + stderr: new ReadableStream({ start(controller) { controller.close(); } }), + kill() { kills++; code = 143; resolveExit(143); }, + }; + await expect(collectOverflowChild(child, performance.now() + 2_000)).rejects.toThrow("cleanup incomplete"); + expect(kills).toBe(1); + expect(await child.exited).toBe(143); +}); + +test("expired deadline is not accepted when a delayed timer loses the race", async () => { + const closed = () => new ReadableStream({ start(controller) { controller.close(); } }); + const child: OverflowChild = { pid: 42, exitCode: 0, exited: Promise.resolve(0), stdout: closed(), stderr: closed(), kill() { throw new Error("already exited"); } }; + await expect(collectOverflowChild(child, performance.now() - 1)).rejects.toThrow("internal deadline"); +}); + +test("unconfirmed termination and a held pipe are reported without waiting forever", async () => { + let kills = 0; + const child: OverflowChild = { + pid: 42, exitCode: null, exited: new Promise(() => {}), + stdout: new ReadableStream({ start(controller) { controller.close(); } }), + stderr: new ReadableStream({}), kill() { kills++; }, + }; + await expect(collectOverflowChild(child, performance.now() + 10, 30)).rejects.toThrow("cleanup incomplete"); + expect(kills).toBe(1); +}); + +test("overflow child preserves a pipe failure and its cleanup failures", async () => { + const child: OverflowChild = { + pid: 42, exitCode: null, exited: new Promise(() => {}), + stdout: new ReadableStream({ start(controller) { controller.error(new Error("fixture pipe failure")); } }), + stderr: new ReadableStream({}), kill() { throw new Error("fixture kill failure"); }, + }; + let caught: unknown; + try { await collectOverflowChild(child, performance.now() + 1_000, 30); } catch (error) { caught = error; } + expect(caught).toBeInstanceOf(AggregateError); + const details = (caught as AggregateError).errors.map(String).join("\n"); + expect(details).toContain("fixture pipe failure"); + expect(details).toContain("fixture kill failure"); + expect(details).toContain("exit or output drain was not confirmed"); +}); + +test("overflow cleanup retains unverified scratch and preserves a primary failure", () => { + const primary = new Error("primary fixture failure"); + let removed = false; + expect(() => finishOverflowScratch("fixture-scratch", false, { error: primary }, () => { removed = true; })).toThrow("primary fixture failure"); + expect(removed).toBe(false); +}); + +test("overflow cleanup reports both the primary and removal failures", () => { + const primary = new Error("primary fixture failure"); + const cleanup = new Error("fixture removal failure"); + let caught: unknown; + try { finishOverflowScratch("fixture-scratch", true, { error: primary }, () => { throw cleanup; }); } catch (error) { caught = error; } + expect(caught).toBeInstanceOf(AggregateError); + expect((caught as AggregateError).errors).toEqual([primary, cleanup]); +}); + +test("overflow subprocess tracker waits for a real child after its caller returns", async () => { + const tracker = trackOverflowAsyncSpawns(); + let child: ReturnType> | undefined; + let exited = false; + let watchdog: ReturnType | undefined; + try { + child = Bun.spawn([process.execPath, "-e", "await Bun.stdin.text();"], { stdin: "pipe", stdout: "pipe", stderr: "pipe" }); + const owned = child; + watchdog = setTimeout(() => { if (!exited) owned.kill(); }, 5_000); + let drained = false; + const draining = tracker.drain().then(() => { drained = true; }); + await Promise.resolve(); + expect(drained).toBe(false); + owned.stdin.end(); + await draining; + const exit = await owned.exited; + exited = true; + expect(exit).toBe(0); + expect(drained).toBe(true); + } finally { + clearTimeout(watchdog); + if (child && !exited) { try { child.kill(); } catch { /* already exited */ } await child.exited; } + tracker.restore(); + } +}, 30_000); + +test("overflow tracker joins an ACL verifier child after the verifier reports timeout", async () => { + const tracker = trackOverflowAsyncSpawns(); + let child: ReturnType> | undefined; + let joined = false; + let watchdog: ReturnType | undefined; + setWindowsOwnerAclAsyncRunnerForTests(async () => { + child = Bun.spawn([process.execPath, "-e", "await Bun.stdin.text();"], { stdin: "pipe", stdout: "pipe", stderr: "pipe" }); + const owned = child; + watchdog = setTimeout(() => { if (!joined) owned.kill(); }, 5_000); + return { success: false, timedOut: true, stdout: new Uint8Array() }; + }); + try { + expect(await verifyWindowsEntriesAclAsync([{ path: "fixture", directory: true }], 1_000)).toBe(false); + if (!child) throw new Error("ACL fixture did not spawn its child"); + let drained = false; + const draining = tracker.drain().then(() => { drained = true; }); + await Promise.resolve(); + expect(drained).toBe(false); + child.stdin.end(); + await draining; + expect(await child.exited).toBe(0); + joined = true; + } finally { + clearTimeout(watchdog); + setWindowsOwnerAclAsyncRunnerForTests(null); + if (child && !joined) { try { child.kill(); } catch { /* already exited */ } await child.exited; } + tracker.restore(); + } +}, 30_000); diff --git a/tests/helpers/messages-overflow-assertions.ts b/tests/helpers/messages-overflow-assertions.ts index 4cdd06856b3..d666856a8e2 100644 --- a/tests/helpers/messages-overflow-assertions.ts +++ b/tests/helpers/messages-overflow-assertions.ts @@ -1,11 +1,11 @@ import { messagesFold502Cases } from "./messages-sse-fold-exceptions"; import { hostedWindowsQuarantine } from "./windows-ci-quarantine"; import { expect, test } from "bun:test"; -import { mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { mkdtempSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { ANTHROPIC_ERROR_TYPES, ANTHROPIC_ERROR_CODES } from "../../src/lib/client-error-body"; import { fixturePath, helperPath, repoRoot } from "./repo-root"; +import { collectOverflowChild, finishOverflowScratch, overflowScratchBase } from "./messages-overflow-child"; import { applicableMessagesOverflowFixtures, messagesOverflowLanes, messagesOverflowCaseId, messagesOverflowCaseTableHash, type MessagesOverflowFixture } from "./messages-overflow-cases"; type Observation = { status: number; bodySha256: string; bodyLength: number; clientError: boolean; envelopeType?: string; mediaType: string | null; type?: string; code?: string | null; text: string; clientRecognizes: boolean; compactionSentence: boolean; internalDecisions: Record }; @@ -22,6 +22,9 @@ if (golden.caseTableSha256 !== messagesOverflowCaseTableHash()) { } export const cases = messagesOverflowLanes.flatMap(lane => applicableMessagesOverflowFixtures(lane).map(input => ({ lane, input }))); export const key = (row: Pick) => messagesOverflowCaseId(row.lane, row.input); +// Hosted Windows 64-row subprocesses for these network-backed lanes approached +// or exceeded 30s; the pure converter/collector lanes finished in under 1s. +const WINDOWS_SLOW_LANES = new Set(["passthrough", "count_tokens", "native-http", "native-fold", "native-fold-builtin", "translated-http", "translated-fold", "translated-json"]); // Reviewed #8148 deltas against the immutable #8153 baseline. No runtime classifier // chooses expectations: only these five fixtures on the two direct-writer paths change. @@ -122,7 +125,7 @@ function assertRow(base: Pick, current: Row) { expect(after.code).toBe(typeof expected.code === "string" && expected.code !== "thread_unsupported_request" && ANTHROPIC_ERROR_CODES.has(expected.code) ? expected.code : undefined); } -/** Each test owns only one 64-row child run; no shared hook computes the full matrix. */ +/** Each test owns one bounded child run; no shared hook computes the full matrix. */ export function registerMessagesOverflowRows(lanes: readonly Row["lane"][]) { expect(golden.schemaVersion).toBe(8); expect(golden.columns).toEqual(["lane", "status", "clientRecognizes", "decisions", "class", "type", "code", "mediaType", "clientError", "bodySha256?", "bodyLength?"]); @@ -132,23 +135,32 @@ export function registerMessagesOverflowRows(lanes: readonly Row["lane"][]) { expect(golden.baseCommit).toBe("72e80d280d614bdf4f1aada9b233f6732404afec"); for (const lane of lanes) { const rows = cases.filter(row => row.lane === lane); - const networkBacked = ["passthrough", "count_tokens", "native-http", "native-fold", "native-fold-builtin", "translated-http", "translated-fold", "translated-json"].includes(lane); - for (let offset = 0; offset < rows.length; offset += 64) { - const selected = rows.slice(offset, offset + 64); - test.skipIf(hostedWindowsQuarantine && networkBacked)(`${lane}: origin/dev rows ${offset + 1}-${offset + selected.length}`, async () => { + const sliceSize = process.platform === "win32" && WINDOWS_SLOW_LANES.has(lane) ? 16 : 64; + const slices = Array.from({ length: Math.ceil(rows.length / sliceSize) }, (_, index) => rows.slice(index * sliceSize, (index + 1) * sliceSize)); + expect(slices.flat().map(key)).toEqual(rows.map(key)); + for (const [index, selected] of slices.entries()) { + const offset = index * sliceSize; + // #8262 hosted-Windows quarantine stays until a Windows lane=all run passes with it removed. + test.skipIf(hostedWindowsQuarantine && WINDOWS_SLOW_LANES.has(lane))(`${lane}: origin/dev rows ${offset + 1}-${offset + selected.length}`, async () => { const started = performance.now(); - const scratch = mkdtempSync(join(tmpdir(), "ocx-overflow-slice-")); + const scratch = mkdtempSync(join(overflowScratchBase(), "ocx-overflow-slice-")); + let safeToRemove = true; + let failure: { error: unknown } | undefined; try { - mkdirSync(join(scratch, "ocx")); mkdirSync(join(scratch, "codex")); + mkdirSync(join(scratch, "ocx")); mkdirSync(join(scratch, "codex")); mkdirSync(join(scratch, "tmp")); const output = join(scratch, "head.json"); - const child = Bun.spawn([process.execPath, helperPath("messages-overflow-differential.ts"), "head", scratch, output, lane, String(offset), "64"], { cwd: repoRoot(), env: { ...process.env, OPENCODEX_HOME: join(scratch, "ocx"), CODEX_HOME: join(scratch, "codex") }, stdout: "pipe", stderr: "pipe" }); - const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + const child = Bun.spawn([process.execPath, helperPath("messages-overflow-differential.ts"), "head", scratch, output, lane, String(offset), String(sliceSize)], { cwd: repoRoot(), env: { ...process.env, HOME: scratch, USERPROFILE: scratch, TEMP: join(scratch, "tmp"), TMP: join(scratch, "tmp"), TMPDIR: join(scratch, "tmp"), OPENCODEX_HOME: join(scratch, "ocx"), CODEX_HOME: join(scratch, "codex"), CODEX_SQLITE_HOME: join(scratch, "codex"), OCX_OWNER_REGISTRY_DIR: join(scratch, "ocx", "ocx-homes"), CLAUDE_CONFIG_DIR: join(scratch, "claude"), GROK_HOME: join(scratch, "grok") }, stdout: "pipe", stderr: "pipe" }); + safeToRemove = false; + const { exit, stdout, stderr } = await collectOverflowChild(child, process.platform === "win32" ? started + 25_000 : null); if (exit !== 0) throw new Error(`Head slice exited ${exit}\n${stdout}\n${stderr}`); - const result = JSON.parse(readFileSync(output, "utf8")) as { rows: Row[] }; + const result = JSON.parse(readFileSync(output, "utf8")) as { rows: Row[]; fixtureAsyncSpawnsDrained?: boolean }; + if (result.fixtureAsyncSpawnsDrained !== true) throw new Error("Head slice did not confirm fixture subprocess cleanup"); + safeToRemove = true; expect(result.rows).toHaveLength(selected.length); for (const [index, row] of result.rows.entries()) { expect(key(row)).toBe(key(selected[index]!)); assertRow(selected[index]!, row); } console.info(`MESSAGES_OVERFLOW_ROWS ${lane} ${offset} ${selected.length} ${Math.round(performance.now() - started)}ms`); - } finally { rmSync(scratch, { recursive: true, force: true }); } + } catch (error) { failure = { error }; } + finally { finishOverflowScratch(scratch, safeToRemove, failure); } }, 30000); } } diff --git a/tests/helpers/messages-overflow-child.ts b/tests/helpers/messages-overflow-child.ts new file mode 100644 index 00000000000..84ad3d57dbb --- /dev/null +++ b/tests/helpers/messages-overflow-child.ts @@ -0,0 +1,98 @@ +import { lstatSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { TEST_TEMP_OWNER_FILE } from "../../scripts/test-temp"; + +/** Place retained child state outside every parent test-sandbox cleanup root. */ +export function overflowScratchBase(temp = tmpdir()): string { + const canonical = realpathSync.native(temp); + let current = canonical; + let outermost: string | undefined; + for (;;) { + try { lstatSync(join(current, TEST_TEMP_OWNER_FILE)); outermost = current; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + if (!outermost) return canonical; + const outside = dirname(outermost); + if (outside === outermost) throw new Error("test sandbox cannot be escaped safely"); + return outside; +} + +/** Preserve a test failure if scratch cleanup also fails; never remove unverified state. */ +export function finishOverflowScratch( + root: string, + safeToRemove: boolean, + failure: { error: unknown } | undefined, + remove: (path: string) => void = path => rmSync(path, { recursive: true, force: true }), +): void { + let cleanupError: unknown; + let cleanupFailed = false; + try { + if (safeToRemove) remove(root); + else console.error(`Overflow child cleanup was not confirmed; retained scratch: ${root}`); + } catch (error) { cleanupError = error; cleanupFailed = true; } + if (failure && cleanupFailed) throw new AggregateError([failure.error, cleanupError], "overflow failure and scratch cleanup failure"); + if (failure) throw failure.error; + if (cleanupFailed) throw cleanupError; +} + +/** The overflow test owns this direct child and both of its output streams. */ +export interface OverflowChild { + readonly pid: number; + readonly exitCode: number | null; + readonly exited: Promise; + readonly stdout: ReadableStream; + readonly stderr: ReadableStream; + kill(): unknown; +} + +type Outcome = { exit: number; stdout: string; stderr: string }; + +/** + * An internal deadline leaves time inside Bun's unchanged outer test budget to + * stop and join the direct child. An unsuccessful call never authorizes the + * caller to remove its scratch: a terminated child may have descendants. + */ +export async function collectOverflowChild(child: OverflowChild, deadline: number | null, reapMs = 2_000): Promise { + let exitObserved = false; + const exit = Promise.resolve().then(() => child.exited).then(code => { exitObserved = true; return code; }); + const stdout = Promise.resolve().then(() => new Response(child.stdout).text()); + const stderr = Promise.resolve().then(() => new Response(child.stderr).text()); + // Attach handlers immediately, including to the losing completion branch. + const joined = Promise.allSettled([exit, stdout, stderr]); + const completion = Promise.all([exit, stdout, stderr]).then( + ([code, out, err]) => ({ kind: "complete" as const, value: { exit: code, stdout: out, stderr: err } }), + error => ({ kind: "failed" as const, error }), + ); + let timer: ReturnType | undefined; + const timeout = new Promise<{ kind: "timeout" }>(resolve => { + if (deadline !== null) timer = setTimeout(() => resolve({ kind: "timeout" }), Math.max(0, deadline - performance.now())); + }); + try { + const first = await Promise.race([completion, timeout]); + if (first.kind === "complete" && (deadline === null || performance.now() < deadline)) return first.value; + const primary = first.kind === "failed" ? first.error : new Error("overflow child exceeded its internal deadline"); + const cleanupErrors: unknown[] = []; + // On Windows Bun can resolve `exited` with 143 while leaving exitCode null. + // The joined exit promise, not that stale property, owns the lifetime. + if (!exitObserved) { + try { child.kill(); } catch (error) { if (!exitObserved) cleanupErrors.push(error); } + } + let reapTimer: ReturnType | undefined; + const remaining = deadline === null ? reapMs : Math.max(0, Math.min(reapMs, deadline + reapMs - performance.now())); + const cleanup = await Promise.race([ + joined, + new Promise(resolve => { reapTimer = setTimeout(() => resolve(null), remaining); }), + ]); + clearTimeout(reapTimer); + if (cleanup === null) cleanupErrors.push(new Error(`child ${child.pid} exit or output drain was not confirmed`)); + else for (const result of cleanup) if (result.status === "rejected") cleanupErrors.push(result.reason); + if (cleanupErrors.length) throw new AggregateError([primary, ...cleanupErrors], `overflow child ${child.pid} failed; cleanup incomplete`); + throw primary; + } finally { + clearTimeout(timer); + } +} diff --git a/tests/helpers/messages-overflow-differential.ts b/tests/helpers/messages-overflow-differential.ts index 5290c4d98eb..b424d2b0780 100644 --- a/tests/helpers/messages-overflow-differential.ts +++ b/tests/helpers/messages-overflow-differential.ts @@ -65,6 +65,9 @@ function replaceOnce(source: string, needle: string, replacement: string): strin build.onLoad({ filter }, ({ path }) => ({ contents: readFileSync(overlays.get(path)!, "utf8"), loader: "ts" })); } }); } +// Install before the request graph loads, so even an ACL verifier that returns +// after requesting termination cannot hide its still-live PowerShell child. +const spawnTracker = base ? undefined : (await import("./messages-overflow-spawn-tracker")).trackOverflowAsyncSpawns(); const { runMessagesOverflowCase } = await import("./messages-overflow-case"); const { upstreamMessagesRequestIdHeaders } = await import("../../src/server/messages-response-headers"); if (base && upstreamMessagesRequestIdHeaders(new Headers({ "request-id": "req_fixture" }))["request-id"] !== "req_fixture") throw new Error("Baseline modules were not loaded"); @@ -118,4 +121,17 @@ if (base) { const groups = []; for (let index = 0; index < lines.length; index += 3) groups.push(lines.slice(index, index + 3).join(",")); await Bun.write(output, `${JSON.stringify(header).slice(0, -1)},"outcomes":{\n${groups.join(",\n")}\n}}\n`); -} else await Bun.write(output, JSON.stringify({ schemaVersion: 8, rows }) + "\n"); +} else { + // Successful exit is not enough on Windows: an async ACL child can outlive its caller. + const { settleBackgroundWork } = await import("../../src/lib/background-work"); + const settled = await settleBackgroundWork({ timeoutMs: 2_000 }); + if (!settled.settled) throw new Error(`Overflow background work did not settle: ${settled.pending.join(", ")}`); + const { flushConfigDirHardeningForTests } = await import("../../src/config/paths"); + const { flushWindowsSecretAclReapsBeforeRemoval, windowsSecretAclReapPendingAtOrBelow } = await import("../../src/lib/windows-secret-acl"); + await flushConfigDirHardeningForTests(); + await flushWindowsSecretAclReapsBeforeRemoval(scratch); + if (windowsSecretAclReapPendingAtOrBelow(scratch)) throw new Error("Overflow ACL child remains active"); + await spawnTracker!.drain(); + spawnTracker!.restore(); + await Bun.write(output, JSON.stringify({ schemaVersion: 8, rows, fixtureAsyncSpawnsDrained: true }) + "\n"); +} diff --git a/tests/helpers/messages-overflow-spawn-tracker.ts b/tests/helpers/messages-overflow-spawn-tracker.ts new file mode 100644 index 00000000000..471d51c91b1 --- /dev/null +++ b/tests/helpers/messages-overflow-spawn-tracker.ts @@ -0,0 +1,30 @@ +/** + * Fixture-only observation of async Bun subprocesses. Some Windows read-only + * ACL probes return on timeout before their PowerShell child has actually exited. + * This wrapper preserves the real spawn and keeps its exit promise until joined. + */ +export function trackOverflowAsyncSpawns() { + const original = Bun.spawn; + const pending = new Set>(); + const failures: unknown[] = []; + const wrapped = ((...args: unknown[]) => { + const child = Reflect.apply(original, Bun, args) as ReturnType; + try { + const observed = Promise.resolve(child.exited).then(() => {}, error => { failures.push(error); }); + pending.add(observed); + void observed.then(() => pending.delete(observed)); + } catch (error) { failures.push(error); } + return child; + }) as typeof Bun.spawn; + Bun.spawn = wrapped; + return { + async drain() { + while (pending.size) await Promise.all([...pending]); + if (failures.length) throw new AggregateError(failures, "overflow subprocess exit was not observed"); + }, + restore() { + if (Bun.spawn !== wrapped) throw new Error("overflow subprocess tracker was replaced"); + Bun.spawn = original; + }, + }; +}