-
Notifications
You must be signed in to change notification settings - Fork 3k
fix(test): wait for interactive PTY sessions to end during cleanup #11001
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -17,6 +17,12 @@ 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; | ||||||||
|
|
||||||||
| describe('TestRig', () => { | ||||||||
| const originalKeepOutput = process.env['KEEP_OUTPUT']; | ||||||||
|
|
||||||||
|
|
@@ -63,7 +69,7 @@ describe('TestRig', () => { | |||||||
| expect(existsSync(testDir)).toBe(true); | ||||||||
| }); | ||||||||
|
|
||||||||
| it('kills an interactive session a test never closed during cleanup', async () => { | ||||||||
| it('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'; | ||||||||
|
|
@@ -72,15 +78,30 @@ describe('TestRig', () => { | |||||||
| // 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', | ||||||||
| '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); | ||||||||
| // 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); | ||||||||
|
|
||||||||
| const cleanupStartedAt = Date.now(); | ||||||||
| await rig.cleanup(); | ||||||||
|
|
||||||||
| const cleanupTookMs = Date.now() - cleanupStartedAt; | ||||||||
|
|
||||||||
| // 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); | ||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R1-7: The duration assertion is lower-bounded only, so the test cannot tell "cleanup resolved because Witness:
Suggested change
The same test is the fix witness: once the upper bound exists, the never-settling- 中文说明时长断言只有下限,因此测试无法区分“因 同一测试即修复验收标准:上限加上之后,令 — qwen3.8-max via Qwen Code /review (v0.23.0) |
||||||||
| await expect | ||||||||
| .poll(() => isProcessAlive(ptyProcess.pid), { | ||||||||
| message: 'the interactive CLI child outlived cleanup()', | ||||||||
|
|
||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -124,6 +124,26 @@ 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). Waiting longer | ||||||
| // than that bound is what makes cleanup() return with the child actually gone. | ||||||
| const INTERACTIVE_EXIT_GRACE_MS = 10_000; | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R1-1: The 10-second per-session grace sits exactly on vitest's default Witness:
Suggested change
Note: 中文说明每会话 10 秒的宽限恰好等于 vitest 默认的 注意: — qwen3.8-max via Qwen Code /review (v0.23.0) |
||||||
|
|
||||||
| // Resolves when `promise` settles, or after `ms` if it never does. The timer | ||||||
| // is cleared and unrefed so a won race leaves no handle holding the worker's | ||||||
| // event loop open. | ||||||
| function settleWithin(promise: Promise<unknown>, ms: number): Promise<void> { | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R1-2: Witness: Note: 中文说明
注意: — qwen3.8-max via Qwen Code /review (v0.23.0) |
||||||
| return new Promise((resolve) => { | ||||||
| const timer = setTimeout(resolve, ms); | ||||||
| timer.unref(); | ||||||
| const settle = () => { | ||||||
| clearTimeout(timer); | ||||||
| resolve(); | ||||||
| }; | ||||||
| void promise.then(settle, settle); | ||||||
| }); | ||||||
| } | ||||||
|
|
||||||
| // 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; | ||||||
|
|
@@ -200,7 +220,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'); | ||||||
|
|
@@ -496,13 +519,16 @@ 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 | ||||||
| // actually go away (#10990). | ||||||
| for (const { ptyProcess, exited } of this.interactiveProcesses.splice(0)) { | ||||||
| try { | ||||||
| ptyProcess.kill(); | ||||||
| } catch { | ||||||
| // Process may have already exited | ||||||
| } | ||||||
| await settleWithin(exited, INTERACTIVE_EXIT_GRACE_MS); | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R1-3: The timeout arm of Witness: Note: the bound must stay above the real CLI's exit-cleanup chain — 中文说明
注意:上限必须保持高于真实 CLI 的退出清理链 —— — qwen3.8-max via Qwen Code /review (v0.23.0)
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R1-4: When the 10s grace expires, const exitedInTime = await settleWithin(exited, INTERACTIVE_EXIT_GRACE_MS);
if (!exitedInTime) {
console.warn(
`interactive CLI child (pid ${ptyProcess.pid}) did not exit within `
+ `${INTERACTIVE_EXIT_GRACE_MS}ms; continuing cleanup`,
);
}Witness: Note: 中文说明10 秒宽限到期时, 注意: — qwen3.8-max via Qwen Code /review (v0.23.0) |
||||||
| } | ||||||
|
|
||||||
| // Clean up test directory | ||||||
|
|
@@ -944,7 +970,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; | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] R1-6: This regression test assumes the bundle lane. On the installed-release lane (
INTEGRATION_TEST_USE_INSTALLED_GEMINI=true),_getCommandAndArgs(integration-tests/test-helper.ts:292-303) returns command'qwen'with nobundlePath, sorunInteractivespawns the installed CLI and never executes the stand-in —STAND_IN_READYnever prints,waitForTextpolls the full 30s, and the test fails red for a reason unrelated to what that lane validates. No CI lane sets this variable, so the breakage is confined to manual installed-release verification runs. Skip the test on that lane:it.skipIf(process.env.INTEGRATION_TEST_USE_INSTALLED_GEMINI === 'true')(...)(combinable with a win32 platform skip).Witness:
Note: the lane sentinel is
process.env.INTEGRATION_TEST_USE_INSTALLED_GEMINI === 'true'atintegration-tests/test-helper.ts:297— the skip must key off that exact variable and value.中文说明
该回归测试假定了 bundle 通道。在安装版通道(
INTEGRATION_TEST_USE_INSTALLED_GEMINI=true)下,_getCommandAndArgs(integration-tests/test-helper.ts:292-303)返回命令'qwen'且不带bundlePath,因此runInteractive启动的是已安装的 CLI,替身脚本根本不会执行 ——STAND_IN_READY永不出现,waitForText会轮询满 30 秒,测试以与该通道验证目标无关的原因失败。没有任何 CI 通道设置该变量,因此影响仅限手工的安装版验证。建议在该通道上跳过本测试:it.skipIf(process.env.INTEGRATION_TEST_USE_INSTALLED_GEMINI === 'true')(...)(可与 win32 平台跳过合并)。注意:通道哨兵是
integration-tests/test-helper.ts:297处的process.env.INTEGRATION_TEST_USE_INSTALLED_GEMINI === 'true'—— 跳过条件必须精确使用该变量与取值。— qwen3.8-max via Qwen Code /review (v0.23.0)