Skip to content
Closed
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
161c784
fix(test): wait for interactive PTY sessions to end during cleanup (#…
qwen-code-dev-bot Sep 4, 2026
4b9222f
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 4, 2026
01b7633
test(integration): shrink the cleanup grace and warn when it expires …
qwen-code-dev-bot Sep 4, 2026
4d8a642
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 4, 2026
2e6c42e
test(integration): pin the exit grace to literal bounds (#10990)
qwen-code-dev-bot Sep 4, 2026
0b3f089
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 4, 2026
55aa9d4
test(scripts): pin the prompt-latency spec in the no-AK script list
qwen-code-dev-bot Sep 4, 2026
f02543b
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 5, 2026
9b34a6e
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 5, 2026
38fb1ad
fix(test): wait for the whole interactive PTY session in rig cleanup …
qwen-code-dev-bot Sep 5, 2026
6ff44c4
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 5, 2026
605b0a3
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 5, 2026
4e37d2e
test(integration): drop the contradicted #10990 attribution from clea…
qwen-code-dev-bot Sep 5, 2026
2897955
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 6, 2026
fc8801c
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 6, 2026
b22948e
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 7, 2026
654835a
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 7, 2026
8d89d4d
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 7, 2026
7b43e33
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 7, 2026
8269a66
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 8, 2026
062eb23
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 8, 2026
d1648f8
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 8, 2026
eb92aa8
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 8, 2026
69878a2
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 8, 2026
2a5e729
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 8, 2026
a6a77c9
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 9, 2026
68fbc1d
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 9, 2026
edaf7e1
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 9, 2026
2775328
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 10, 2026
1a28655
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 10, 2026
c19a32d
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 11, 2026
646b726
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 11, 2026
c7ed49d
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 12, 2026
cac0a60
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 13, 2026
78b1c47
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 14, 2026
0d5f53b
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 15, 2026
a5ec379
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 16, 2026
080ebb5
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 17, 2026
d7a69c8
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 18, 2026
69022da
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 18, 2026
400e14a
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 19, 2026
8670efd
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 20, 2026
a45b7fd
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 21, 2026
9a4ecba
Merge branch 'main' into autofix/issue-10990
yiliang114 Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 156 additions & 23 deletions integration-tests/test-helper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import { existsSync } from 'node:fs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TestRig } from './test-helper.js';
import { INTERACTIVE_EXIT_GRACE_MS, TestRig } from './test-helper.js';

function isProcessAlive(pid: number): boolean {
try {
Expand All @@ -17,6 +17,19 @@ function isProcessAlive(pid: number): boolean {
}
}

// How long the stand-in below stays alive after it is signalled. The real CLI
// traps SIGHUP and exits only once its own exit-cleanup chain has drained, so
// a stand-in that dies on the default action would let cleanup() return early
// and still look correct.
const STAND_IN_EXIT_DELAY_MS = 750;

// Every interactive case below stands in for the CLI through rig.bundlePath.
// The installed-release lane spawns the installed CLI instead and never runs a
// stand-in, so these cases cannot measure that lane directly — the wrapper
// stand-in below reproduces its process topology on the bundle lane instead.
const usesInstalledCli =
process.env['INTEGRATION_TEST_USE_INSTALLED_GEMINI'] === 'true';

describe('TestRig', () => {
const originalKeepOutput = process.env['KEEP_OUTPUT'];

Expand Down Expand Up @@ -63,31 +76,151 @@ describe('TestRig', () => {
expect(existsSync(testDir)).toBe(true);
});

it('kills an interactive session a test never closed during cleanup', async () => {
// KEEP_OUTPUT is what CI sets, and it makes cleanup() keep the test
// directory — the spawned child must not survive that path either.
process.env['KEEP_OUTPUT'] = 'true';
const rig = new TestRig();
await rig.setup('cleanup kills interactive session');
// Stands in for the CLI bundle: what is under test is that cleanup ends
// whatever runInteractive spawned, not what the CLI itself does.
rig.bundlePath = rig.createFile(
'idle-cli.js',
'setInterval(() => {}, 1000);\n',
);
it.skipIf(usesInstalledCli)(
'waits for an interactive session a test never closed to end',
async () => {
// KEEP_OUTPUT is what CI sets, and it makes cleanup() keep the test
// directory — the spawned child must not survive that path either.
process.env['KEEP_OUTPUT'] = 'true';
const rig = new TestRig();
await rig.setup('cleanup kills interactive session');
// Stands in for the CLI bundle: what is under test is that cleanup ends
// whatever runInteractive spawned, not what the CLI itself does.
rig.bundlePath = rig.createFile(
'slow-exit-cli.js',
'process.on("SIGHUP", () => setTimeout(() => process.exit(129), ' +
`${STAND_IN_EXIT_DELAY_MS}));\n` +
'setInterval(() => {}, 1000);\n' +
'process.stdout.write("STAND_IN_READY\\n");\n',
);

const { ptyProcess } = rig.runInteractive();
expect(isProcessAlive(ptyProcess.pid)).toBe(true);
const { ptyProcess } = rig.runInteractive();
expect(isProcessAlive(ptyProcess.pid)).toBe(true);
// Signal before the handler is installed and the default action ends the
// child at once, measuring nothing. A real session is booted by the time
// its test ends, so wait for the stand-in to report itself up.
expect(await rig.waitForText('STAND_IN_READY', 30_000)).toBe(true);

await rig.cleanup();
const cleanupStartedAt = Date.now();
await rig.cleanup();
const cleanupTookMs = Date.now() - cleanupStartedAt;

await expect
.poll(() => isProcessAlive(ptyProcess.pid), {
message: 'the interactive CLI child outlived cleanup()',
timeout: 10_000,
})
.toBe(false);
});
// Signalling alone returns straight through the delay above, leaving the
// child forwarding PTY bytes into a worker vitest is tearing down.
expect(
cleanupTookMs,
'cleanup() returned before the interactive CLI child exited',
).toBeGreaterThanOrEqual(STAND_IN_EXIT_DELAY_MS);
// Nor may it fall through the whole grace, which is what cleanup() does
// when `exited` never settles — i.e. when the onExit wiring breaks. The
// bound sits far above the stand-in's exit delay, far below the grace.
expect(cleanupTookMs).toBeLessThan(5_000);
await expect
.poll(() => isProcessAlive(ptyProcess.pid), {
message: 'the interactive CLI child outlived cleanup()',
timeout: 10_000,
})
.toBe(false);
},
);

it.skipIf(usesInstalledCli)(
'waits for the CLI the installed bin wrapper relaunched to end',
async () => {
process.env['KEEP_OUTPUT'] = 'true';
const rig = new TestRig();
await rig.setup('cleanup waits past the bin wrapper');
// Mimics the installed-release lane, where the bin wrapper node-pty
// spawns relaunches the real CLI with spawnSync and installs no signal
// handler: SIGHUP ends the wrapper at once, while the relaunched CLI
// traps it and keeps draining. That CLI, not the wrapper, is what
// cleanup() must not return early on.
const relaunched = rig.createFile(
'relaunched-cli.cjs',
'process.on("SIGHUP", () => setTimeout(() => process.exit(129), ' +
`${STAND_IN_EXIT_DELAY_MS}));\n` +
'setInterval(() => {}, 1000);\n' +
'process.stdout.write("RELAUNCHED_PID=" + process.pid + ' +
'"\\nRELAUNCHED_READY\\n");\n',
);
rig.bundlePath = rig.createFile(
'bin-wrapper.cjs',
"const { spawnSync } = require('node:child_process');\n" +
`const result = spawnSync(process.execPath, [${JSON.stringify(
relaunched,
)}], {\n` +
" stdio: 'inherit',\n" +
'});\n' +
'if (result.signal) process.kill(process.pid, result.signal);\n' +
'else process.exit(result.status ?? 1);\n',
);

const { ptyProcess } = rig.runInteractive();
expect(await rig.waitForText('RELAUNCHED_READY', 30_000)).toBe(true);
const reported = /RELAUNCHED_PID=(\d+)/.exec(rig._interactiveOutput);
expect(
reported,
'the relaunched CLI never reported its pid',
).not.toBeNull();
const relaunchedPid = Number(reported![1]);
expect(isProcessAlive(relaunchedPid)).toBe(true);

const cleanupStartedAt = Date.now();
await rig.cleanup();

// The wrapper dies on SIGHUP's default action within milliseconds, so
// only a wait that sees past it can still be running here.
expect(Date.now() - cleanupStartedAt).toBeGreaterThanOrEqual(
STAND_IN_EXIT_DELAY_MS,
);
expect(
isProcessAlive(relaunchedPid),
'cleanup() returned while the relaunched CLI was still draining',
).toBe(false);
expect(isProcessAlive(ptyProcess.pid)).toBe(false);
},
);

it.skipIf(usesInstalledCli)(
'stops waiting for an interactive child that never exits',
async () => {
process.env['KEEP_OUTPUT'] = 'true';
const rig = new TestRig();
await rig.setup('cleanup gives up on a child that never exits');
rig.bundlePath = rig.createFile(
'never-exit-cli.js',
'process.on("SIGHUP", () => {});\n' +
'setInterval(() => {}, 1000);\n' +
'process.stdout.write("STAND_IN_READY\\n");\n',
);

const { ptyProcess } = rig.runInteractive();
expect(await rig.waitForText('STAND_IN_READY', 30_000)).toBe(true);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

const cleanupStartedAt = Date.now();
try {
await rig.cleanup();
} finally {
// cleanup() gave up on this child, so the rig no longer tracks it.
ptyProcess.kill('SIGKILL');
}
const cleanupTookMs = Date.now() - cleanupStartedAt;

// Literals, not the constant: a bound derived from it retunes with the
// value it polices. Floor is the CLI's 5s exit-cleanup chain, ceiling
// vitest's 10s default hookTimeout — see INTERACTIVE_EXIT_GRACE_MS.
expect(INTERACTIVE_EXIT_GRACE_MS).toBeGreaterThan(5_000);
expect(INTERACTIVE_EXIT_GRACE_MS).toBeLessThan(10_000);
// Bounded, or a child that ignores SIGHUP hangs teardown forever.
expect(cleanupTookMs).toBeLessThan(INTERACTIVE_EXIT_GRACE_MS + 5_000);
// Giving up has to name the child it abandoned, or the EPIPE crash this
// wait exists to prevent returns with nothing pointing back at it.
expect(warn.mock.calls.flat().join(' ')).toContain(
String(ptyProcess.pid),
);
},
);

it.each([
[
Expand Down
83 changes: 79 additions & 4 deletions integration-tests/test-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,62 @@ export function validateModelOutput(
return true;
}

// The CLI traps SIGHUP and exits only once `runExitCleanup()` has drained, a
// chain it bounds at 5s (packages/cli/src/utils/cleanup.ts), so the grace has
// to outlast that. It also has to stay inside the 10s hookTimeout vitest
// defaults to, because cleanup() runs in afterEach hooks: a grace that eats
// the whole hook budget is reported as a generic "Hook timed out" instead of
// naming the child that never exited.
export const INTERACTIVE_EXIT_GRACE_MS = 8_000;

// `process.kill(-pid, 0)` reports whether anything is left in the process
// group `pid` leads; it throws once the group is empty. Windows has no
// negative-pid process groups and always throws, so there the child's own exit
// stays the whole bound.
function sessionAlive(pid: number): boolean {
try {
process.kill(-pid, 0);
return true;
} catch {
return false;
}
}

// Resolves true once the whole PTY session is gone, false when `ms` elapses
// first. `exited` covers only the process node-pty spawned, and node-pty
// signals only that pid — on the installed-release lane it is the bin wrapper,
// which relaunches the real CLI with spawnSync and dies on SIGHUP's default
// action at once, so `exited` settles while that CLI is still draining into
// the PTY. The spawned process leads its own process group and the
// relaunched CLI stays in it, and a group reads empty only once every member
// has exited and been reaped, so wait for both inside the same grace. Timers
// are unrefed so a lost race leaves no handle holding the worker's event loop
// open.
function sessionEndsWithin(
pid: number,
exited: Promise<unknown>,
ms: number,
): Promise<boolean> {
return new Promise((resolve) => {
const deadline = Date.now() + ms;
let childExited = false;
const check = () => {
const gone = childExited && !sessionAlive(pid);
if (gone || Date.now() >= deadline) {
clearInterval(timer);
resolve(gone);
}
};
const onChildExit = () => {
childExited = true;
check();
};
const timer = setInterval(check, 50);
timer.unref();
void exited.then(onChildExit, onChildExit);
});
}

// Simulates typing a string one character at a time to avoid paste detection.
export async function type(ptyProcess: pty.IPty, text: string) {
const delay = 5;
Expand Down Expand Up @@ -205,7 +261,10 @@ export class TestRig {
testName?: string;
_lastRunStdout?: string;
_interactiveOutput = '';
private readonly interactiveProcesses: pty.IPty[] = [];
private readonly interactiveProcesses: Array<{
ptyProcess: pty.IPty;
exited: Promise<unknown>;
}> = [];

constructor() {
this.bundlePath = join(__dirname, '..', 'dist/cli.js');
Expand Down Expand Up @@ -504,13 +563,26 @@ export class TestRig {
async cleanup() {
// A session a test never closed keeps its CLI child forwarding PTY bytes
// into this worker's stdout; after vitest tears the worker down those
// writes EPIPE and fail an otherwise all-green run (#10969).
for (const ptyProcess of this.interactiveProcesses.splice(0)) {
// writes EPIPE and fail an otherwise all-green run (#10969). Signalling
// alone still returns with the child alive and writing, so wait for it to

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in 4e37d2e4a4: both (#10990) code attributions are dropped (the cleanup() comment and the comment above sessionEndsWithin), while the teardown wait, the process-group gate, and all three witness tests are unchanged, and .github/scripts/ci/main-failure-signature.mjs is untouched. Locally verified before editing: 56f75adf29 (PR 10986) is an ancestor of HEAD, the detector script is byte-identical since b7815a7e1a, and the log-download fallback is documented in .github/workflows/main-ci-failure-issue.yml:88-96. The archived CI logs themselves cannot be refetched from this sandbox (no GitHub credentials), so those rest on your archived-log witness.

One part stays open, and it is why this thread is left unresolved: the remaining Fixes #10990 trailer lives in the PR body — no branch commit carries it (verified with git log origin/main..HEAD) — and this bot has no credentials to edit the PR body or rewrite history. Please drop the trailer from the PR body and keep it out of the final merge message, so merging does not auto-close #10990 against a cause the logs contradict.

中文说明

已在 4e37d2e4a4 中落实:两处 (#10990) 代码归因均已删除(cleanup() 注释与 sessionEndsWithin 上方的注释),teardown 等待、进程组门控与三个见证测试全部原样保留,.github/scripts/ci/main-failure-signature.mjs 未触碰。编辑前已在本地验证:56f75adf29(PR 10986)是 HEAD 的祖先;detector 脚本自 b7815a7e1a 以来逐字节未变;日志下载降级路径在 .github/workflows/main-ci-failure-issue.yml:88-96 中有文档记载。归档 CI 日志本身无法在本沙箱内重新抓取(无 GitHub 凭据),该部分以你给出的归档日志见证为准。

仍有一项保持开放,这也是本 thread 不关闭的原因:剩余的 Fixes #10990 trailer 位于 PR body 中 —— 分支上没有任何 commit 携带它(已用 git log origin/main..HEAD 验证)—— 而本 bot 没有凭据编辑 PR body 或改写历史。请从 PR body 中删除该 trailer,并确保最终合并信息中也不含它,以免合并时以一个被日志否证的归因自动关闭 #10990。

// actually go away.
for (const { ptyProcess, exited } of this.interactiveProcesses.splice(0)) {
try {
ptyProcess.kill();
} catch {
// Process may have already exited
}
const ended = await sessionEndsWithin(
ptyProcess.pid,
exited,
INTERACTIVE_EXIT_GRACE_MS,
);
if (!ended) {
console.warn(
`interactive CLI process group ${ptyProcess.pid} did not end ` +
`within ${INTERACTIVE_EXIT_GRACE_MS}ms; continuing cleanup`,
);
}
}

// Clean up test directory
Expand Down Expand Up @@ -964,7 +1036,10 @@ export class TestRig {
...e2eRendererEnv(renderer),
} as { [key: string]: string },
});
this.interactiveProcesses.push(ptyProcess);
const exited = new Promise<void>((resolve) => {
ptyProcess.onExit(() => resolve());
});
this.interactiveProcesses.push({ ptyProcess, exited });

ptyProcess.onData((data) => {
this._interactiveOutput += data;
Expand Down
Loading