Skip to content

Commit a96a5f3

Browse files
committed
test: bound Windows overflow slices and preserve child cleanup
Co-authored-by: JUN <243035832+lidge-jun@users.noreply.github.com>
1 parent e804cd4 commit a96a5f3

6 files changed

Lines changed: 343 additions & 16 deletions

‎scripts/ci/test-durations.windows.tsv‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Per-file Bun test durations in milliseconds, read from hosted CI job logs.
22
# Consumed by scripts/ci/run-bun-test-batches.sh to balance shards; a file without a row weighs the median.
33
# Regenerate with scripts/ci/test-durations.ts (usage in its header); do not edit by hand.
4-
# 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
4+
# 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
55
4873 tests/adapters/abort-race.test.ts
66
2703 tests/adapters/adapter-buffered-tool-conformance.test.ts
77
897 tests/adapters/adapter-error-inline.test.ts
@@ -427,6 +427,13 @@
427427
2818 tests/claude-integration/messages-native-opaque-state.test.ts
428428
15045 tests/claude-integration/messages-native-scope.test.ts
429429
52752 tests/claude-integration/messages-native.test.ts
430+
224 tests/claude-integration/messages-overflow-baseline-contract.test.ts
431+
391302 tests/claude-integration/messages-overflow-builtin.test.ts
432+
9197 tests/claude-integration/messages-overflow-collectors.test.ts
433+
290644 tests/claude-integration/messages-overflow-native.test.ts
434+
8748 tests/claude-integration/messages-overflow-stream.test.ts
435+
346067 tests/claude-integration/messages-overflow-translated.test.ts
436+
255760 tests/claude-integration/messages-overflow-verdicts.test.ts
430437
71096 tests/claude-integration/messages-request-id-endpoint.test.ts
431438
174 tests/claude-integration/messages-request-id-headers.test.ts
432439
45635 tests/claude-integration/messages-revoked-token.test.ts

‎tests/claude-integration/messages-overflow-baseline-contract.test.ts‎

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { expect, test } from "bun:test";
22
import { createHash } from "node:crypto";
3-
import { readFileSync } from "node:fs";
3+
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { isAbsolute, join, relative } from "node:path";
46
import { repoPath } from "../helpers/repo-root";
7+
import { collectOverflowChild, finishOverflowScratch, overflowScratchBase, type OverflowChild } from "../helpers/messages-overflow-child";
8+
import { TEST_TEMP_OWNER_FILE } from "../../scripts/test-temp";
9+
import { trackOverflowAsyncSpawns } from "../helpers/messages-overflow-spawn-tracker";
10+
import { setWindowsOwnerAclAsyncRunnerForTests, verifyWindowsEntriesAclAsync } from "../../src/lib/windows-owner-acl";
511

612
const digest = (text: string | Uint8Array) => createHash("sha256").update(text).digest("hex");
713
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", () => {
2228
expect(source.length).toBeGreaterThan(0);
2329
expect(digest(source)).toBe("d5d0573f377c5d7894bff343a0bd9cebec46df5bc405b112238862479a3e7328");
2430
});
31+
32+
test("retained overflow scratch is outside nested test sandboxes and junction aliases", () => {
33+
const root = mkdtempSync(join(tmpdir(), "ocx-overflow-location-"));
34+
try {
35+
const nested = join(root, "nested");
36+
const inner = join(nested, "tmp");
37+
mkdirSync(inner, { recursive: true });
38+
writeFileSync(join(root, TEST_TEMP_OWNER_FILE), "{}");
39+
writeFileSync(join(nested, TEST_TEMP_OWNER_FILE), "{}");
40+
const alias = join(root, "alias");
41+
symlinkSync(inner, alias, process.platform === "win32" ? "junction" : "dir");
42+
const base = overflowScratchBase(alias);
43+
const rel = relative(realpathSync.native(root), base);
44+
expect(rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(rel)).toBe(true);
45+
expect(base).toBe(overflowScratchBase(inner));
46+
} finally { rmSync(root, { recursive: true, force: true }); }
47+
});
48+
49+
test("overflow child drains both streams before returning its exit", async () => {
50+
const child = Bun.spawn([process.execPath, "-e", "process.stdout.write('out'); process.stderr.write('err');"], { stdout: "pipe", stderr: "pipe" });
51+
const result = await collectOverflowChild(child, performance.now() + 25_000);
52+
expect(result).toEqual({ exit: 0, stdout: "out", stderr: "err" });
53+
expect(child.exitCode).toBe(0);
54+
}, 30_000);
55+
56+
test("overflow deadline kills and joins the owned real child", async () => {
57+
const child = Bun.spawn([process.execPath, "-e", "process.stdout.write('out'); process.stderr.write('err'); setInterval(() => {}, 1000);"], { stdout: "pipe", stderr: "pipe" });
58+
let joined = false;
59+
try {
60+
await expect(collectOverflowChild(child, performance.now() + 500)).rejects.toThrow("internal deadline");
61+
const exit = await child.exited;
62+
joined = true;
63+
expect(exit).not.toBe(0);
64+
} finally {
65+
if (!joined) { try { child.kill(); } finally { await child.exited; } }
66+
}
67+
}, 30_000);
68+
69+
test("a pipe failure kills and joins the still-live owned child", async () => {
70+
let code: number | null = null;
71+
let resolveExit!: (value: number) => void;
72+
let kills = 0;
73+
const child: OverflowChild = {
74+
pid: 42,
75+
get exitCode() { return code; },
76+
exited: new Promise(resolve => { resolveExit = resolve; }),
77+
stdout: new ReadableStream({ start(controller) { controller.error(new Error("fixture pipe failure")); } }),
78+
stderr: new ReadableStream({ start(controller) { controller.close(); } }),
79+
kill() { kills++; code = 143; resolveExit(143); },
80+
};
81+
await expect(collectOverflowChild(child, performance.now() + 2_000)).rejects.toThrow("cleanup incomplete");
82+
expect(kills).toBe(1);
83+
expect(await child.exited).toBe(143);
84+
});
85+
86+
test("expired deadline is not accepted when a delayed timer loses the race", async () => {
87+
const closed = () => new ReadableStream<Uint8Array>({ start(controller) { controller.close(); } });
88+
const child: OverflowChild = { pid: 42, exitCode: 0, exited: Promise.resolve(0), stdout: closed(), stderr: closed(), kill() { throw new Error("already exited"); } };
89+
await expect(collectOverflowChild(child, performance.now() - 1)).rejects.toThrow("internal deadline");
90+
});
91+
92+
test("unconfirmed termination and a held pipe are reported without waiting forever", async () => {
93+
let kills = 0;
94+
const child: OverflowChild = {
95+
pid: 42, exitCode: null, exited: new Promise(() => {}),
96+
stdout: new ReadableStream({ start(controller) { controller.close(); } }),
97+
stderr: new ReadableStream({}), kill() { kills++; },
98+
};
99+
await expect(collectOverflowChild(child, performance.now() + 10, 30)).rejects.toThrow("cleanup incomplete");
100+
expect(kills).toBe(1);
101+
});
102+
103+
test("overflow child preserves a pipe failure and its cleanup failures", async () => {
104+
const child: OverflowChild = {
105+
pid: 42, exitCode: null, exited: new Promise(() => {}),
106+
stdout: new ReadableStream({ start(controller) { controller.error(new Error("fixture pipe failure")); } }),
107+
stderr: new ReadableStream({}), kill() { throw new Error("fixture kill failure"); },
108+
};
109+
let caught: unknown;
110+
try { await collectOverflowChild(child, performance.now() + 1_000, 30); } catch (error) { caught = error; }
111+
expect(caught).toBeInstanceOf(AggregateError);
112+
const details = (caught as AggregateError).errors.map(String).join("\n");
113+
expect(details).toContain("fixture pipe failure");
114+
expect(details).toContain("fixture kill failure");
115+
expect(details).toContain("exit or output drain was not confirmed");
116+
});
117+
118+
test("overflow cleanup retains unverified scratch and preserves a primary failure", () => {
119+
const primary = new Error("primary fixture failure");
120+
let removed = false;
121+
expect(() => finishOverflowScratch("fixture-scratch", false, { error: primary }, () => { removed = true; })).toThrow("primary fixture failure");
122+
expect(removed).toBe(false);
123+
});
124+
125+
test("overflow cleanup reports both the primary and removal failures", () => {
126+
const primary = new Error("primary fixture failure");
127+
const cleanup = new Error("fixture removal failure");
128+
let caught: unknown;
129+
try { finishOverflowScratch("fixture-scratch", true, { error: primary }, () => { throw cleanup; }); } catch (error) { caught = error; }
130+
expect(caught).toBeInstanceOf(AggregateError);
131+
expect((caught as AggregateError).errors).toEqual([primary, cleanup]);
132+
});
133+
134+
test("overflow subprocess tracker waits for a real child after its caller returns", async () => {
135+
const tracker = trackOverflowAsyncSpawns();
136+
let child: ReturnType<typeof Bun.spawn<{ stdin: "pipe"; stdout: "pipe"; stderr: "pipe" }>> | undefined;
137+
let exited = false;
138+
let watchdog: ReturnType<typeof setTimeout> | undefined;
139+
try {
140+
child = Bun.spawn([process.execPath, "-e", "await Bun.stdin.text();"], { stdin: "pipe", stdout: "pipe", stderr: "pipe" });
141+
const owned = child;
142+
watchdog = setTimeout(() => { if (!exited) owned.kill(); }, 5_000);
143+
let drained = false;
144+
const draining = tracker.drain().then(() => { drained = true; });
145+
await Promise.resolve();
146+
expect(drained).toBe(false);
147+
owned.stdin.end();
148+
await draining;
149+
const exit = await owned.exited;
150+
exited = true;
151+
expect(exit).toBe(0);
152+
expect(drained).toBe(true);
153+
} finally {
154+
clearTimeout(watchdog);
155+
if (child && !exited) { try { child.kill(); } catch { /* already exited */ } await child.exited; }
156+
tracker.restore();
157+
}
158+
}, 30_000);
159+
160+
test("overflow tracker joins an ACL verifier child after the verifier reports timeout", async () => {
161+
const tracker = trackOverflowAsyncSpawns();
162+
let child: ReturnType<typeof Bun.spawn<{ stdin: "pipe"; stdout: "pipe"; stderr: "pipe" }>> | undefined;
163+
let joined = false;
164+
let watchdog: ReturnType<typeof setTimeout> | undefined;
165+
setWindowsOwnerAclAsyncRunnerForTests(async () => {
166+
child = Bun.spawn([process.execPath, "-e", "await Bun.stdin.text();"], { stdin: "pipe", stdout: "pipe", stderr: "pipe" });
167+
const owned = child;
168+
watchdog = setTimeout(() => { if (!joined) owned.kill(); }, 5_000);
169+
return { success: false, timedOut: true, stdout: new Uint8Array() };
170+
});
171+
try {
172+
expect(await verifyWindowsEntriesAclAsync([{ path: "fixture", directory: true }], 1_000)).toBe(false);
173+
if (!child) throw new Error("ACL fixture did not spawn its child");
174+
let drained = false;
175+
const draining = tracker.drain().then(() => { drained = true; });
176+
await Promise.resolve();
177+
expect(drained).toBe(false);
178+
child.stdin.end();
179+
await draining;
180+
expect(await child.exited).toBe(0);
181+
joined = true;
182+
} finally {
183+
clearTimeout(watchdog);
184+
setWindowsOwnerAclAsyncRunnerForTests(null);
185+
if (child && !joined) { try { child.kill(); } catch { /* already exited */ } await child.exited; }
186+
tracker.restore();
187+
}
188+
}, 30_000);

‎tests/helpers/messages-overflow-assertions.ts‎

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { messagesFold502Cases } from "./messages-sse-fold-exceptions";
22
import { hostedWindowsQuarantine } from "./windows-ci-quarantine";
33
import { expect, test } from "bun:test";
4-
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs";
5-
import { tmpdir } from "node:os";
4+
import { mkdtempSync, mkdirSync, readFileSync } from "node:fs";
65
import { join } from "node:path";
76
import { ANTHROPIC_ERROR_TYPES, ANTHROPIC_ERROR_CODES } from "../../src/lib/client-error-body";
87
import { fixturePath, helperPath, repoRoot } from "./repo-root";
8+
import { collectOverflowChild, finishOverflowScratch, overflowScratchBase } from "./messages-overflow-child";
99
import { applicableMessagesOverflowFixtures, messagesOverflowLanes, messagesOverflowCaseId, messagesOverflowCaseTableHash, type MessagesOverflowFixture } from "./messages-overflow-cases";
1010

1111
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<string, boolean> };
@@ -22,6 +22,9 @@ if (golden.caseTableSha256 !== messagesOverflowCaseTableHash()) {
2222
}
2323
export const cases = messagesOverflowLanes.flatMap(lane => applicableMessagesOverflowFixtures(lane).map(input => ({ lane, input })));
2424
export const key = (row: Pick<Row, "lane" | "input">) => messagesOverflowCaseId(row.lane, row.input);
25+
// Hosted Windows 64-row subprocesses for these network-backed lanes approached
26+
// or exceeded 30s; the pure converter/collector lanes finished in under 1s.
27+
const WINDOWS_SLOW_LANES = new Set<Row["lane"]>(["passthrough", "count_tokens", "native-http", "native-fold", "native-fold-builtin", "translated-http", "translated-fold", "translated-json"]);
2528

2629
// Reviewed #8148 deltas against the immutable #8153 baseline. No runtime classifier
2730
// chooses expectations: only these five fixtures on the two direct-writer paths change.
@@ -122,7 +125,7 @@ function assertRow(base: Pick<Row, "lane" | "input">, current: Row) {
122125
expect(after.code).toBe(typeof expected.code === "string" && expected.code !== "thread_unsupported_request" && ANTHROPIC_ERROR_CODES.has(expected.code) ? expected.code : undefined);
123126
}
124127

125-
/** Each test owns only one 64-row child run; no shared hook computes the full matrix. */
128+
/** Each test owns one bounded child run; no shared hook computes the full matrix. */
126129
export function registerMessagesOverflowRows(lanes: readonly Row["lane"][]) {
127130
expect(golden.schemaVersion).toBe(8);
128131
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"][]) {
132135
expect(golden.baseCommit).toBe("72e80d280d614bdf4f1aada9b233f6732404afec");
133136
for (const lane of lanes) {
134137
const rows = cases.filter(row => row.lane === lane);
135-
const networkBacked = ["passthrough", "count_tokens", "native-http", "native-fold", "native-fold-builtin", "translated-http", "translated-fold", "translated-json"].includes(lane);
136-
for (let offset = 0; offset < rows.length; offset += 64) {
137-
const selected = rows.slice(offset, offset + 64);
138-
test.skipIf(hostedWindowsQuarantine && networkBacked)(`${lane}: origin/dev rows ${offset + 1}-${offset + selected.length}`, async () => {
138+
const sliceSize = process.platform === "win32" && WINDOWS_SLOW_LANES.has(lane) ? 16 : 64;
139+
const slices = Array.from({ length: Math.ceil(rows.length / sliceSize) }, (_, index) => rows.slice(index * sliceSize, (index + 1) * sliceSize));
140+
expect(slices.flat().map(key)).toEqual(rows.map(key));
141+
for (const [index, selected] of slices.entries()) {
142+
const offset = index * sliceSize;
143+
// #8262 hosted-Windows quarantine stays until a Windows lane=all run passes with it removed.
144+
test.skipIf(hostedWindowsQuarantine && WINDOWS_SLOW_LANES.has(lane))(`${lane}: origin/dev rows ${offset + 1}-${offset + selected.length}`, async () => {
139145
const started = performance.now();
140-
const scratch = mkdtempSync(join(tmpdir(), "ocx-overflow-slice-"));
146+
const scratch = mkdtempSync(join(overflowScratchBase(), "ocx-overflow-slice-"));
147+
let safeToRemove = true;
148+
let failure: { error: unknown } | undefined;
141149
try {
142-
mkdirSync(join(scratch, "ocx")); mkdirSync(join(scratch, "codex"));
150+
mkdirSync(join(scratch, "ocx")); mkdirSync(join(scratch, "codex")); mkdirSync(join(scratch, "tmp"));
143151
const output = join(scratch, "head.json");
144-
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" });
145-
const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
152+
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" });
153+
safeToRemove = false;
154+
const { exit, stdout, stderr } = await collectOverflowChild(child, process.platform === "win32" ? started + 25_000 : null);
146155
if (exit !== 0) throw new Error(`Head slice exited ${exit}\n${stdout}\n${stderr}`);
147-
const result = JSON.parse(readFileSync(output, "utf8")) as { rows: Row[] };
156+
const result = JSON.parse(readFileSync(output, "utf8")) as { rows: Row[]; fixtureAsyncSpawnsDrained?: boolean };
157+
if (result.fixtureAsyncSpawnsDrained !== true) throw new Error("Head slice did not confirm fixture subprocess cleanup");
158+
safeToRemove = true;
148159
expect(result.rows).toHaveLength(selected.length);
149160
for (const [index, row] of result.rows.entries()) { expect(key(row)).toBe(key(selected[index]!)); assertRow(selected[index]!, row); }
150161
console.info(`MESSAGES_OVERFLOW_ROWS ${lane} ${offset} ${selected.length} ${Math.round(performance.now() - started)}ms`);
151-
} finally { rmSync(scratch, { recursive: true, force: true }); }
162+
} catch (error) { failure = { error }; }
163+
finally { finishOverflowScratch(scratch, safeToRemove, failure); }
152164
}, 30000);
153165
}
154166
}

0 commit comments

Comments
 (0)