From 161c784514d843a9060895da2e25693807f7d7f6 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Fri, 4 Sep 2026 04:40:19 +0000 Subject: [PATCH 1/6] fix(test): wait for interactive PTY sessions to end during cleanup (#10990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup signalled each leaked session but returned without waiting for it to go away. The CLI traps SIGHUP and exits only once runExitCleanup() has drained, a chain it bounds at 5s, so kill() returns with the child still alive and still forwarding PTY bytes into the worker's stdout — measured at 83ms for a booted session, exiting with the CLI's SIGHUP code 129. That is the window #10969 was meant to close. A full interactive leg run on the parent commit shows a CLI child reparented to init at the moment its vitest worker exited; the same run after this change orphans none, with an identical result set. The wait costs each session's real drain (35-42ms measured) and is bounded above the CLI's own 5s ceiling. The witness now pins the wait itself. Its stand-in traps SIGHUP and exits after a delay like the real CLI, and reports itself booted first: signalling a child that has not installed its handler ends it on the default action, which measured nothing. Deleting the wait turns it red at 0ms against a 750ms floor; deleting the kill turns it red on the survival poll. --- integration-tests/test-helper.test.ts | 29 ++++++++++++++++++--- integration-tests/test-helper.ts | 37 ++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/integration-tests/test-helper.test.ts b/integration-tests/test-helper.test.ts index 604e6886acd..6731cd0dfb6 100644 --- a/integration-tests/test-helper.test.ts +++ b/integration-tests/test-helper.test.ts @@ -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); await expect .poll(() => isProcessAlive(ptyProcess.pid), { message: 'the interactive CLI child outlived cleanup()', diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index 48ecd938cf9..7826826b98f 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -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; + +// 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, ms: number): Promise { + 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; + }> = []; 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); } // 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((resolve) => { + ptyProcess.onExit(() => resolve()); + }); + this.interactiveProcesses.push({ ptyProcess, exited }); ptyProcess.onData((data) => { this._interactiveOutput += data; From 01b7633dc09b04748f1fe46c3c89782eebb3e916 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Fri, 4 Sep 2026 09:31:33 +0000 Subject: [PATCH 2/6] test(integration): shrink the cleanup grace and warn when it expires (#10990) The per-session grace sat exactly on vitest's 10s default hookTimeout, and cleanup() runs inside afterEach hooks, so a fully expired grace consumed the whole hook budget and surfaced as a generic "Hook timed out" blaming the hook rather than the child that never exited. Keep the grace above the CLI's own 5s exit-cleanup bound but strictly inside the hook budget. Giving up was also silent: a child that outlives the grace keeps forwarding PTY bytes into a worker vitest is tearing down, which is the EPIPE failure this wait exists to prevent, recurring with nothing pointing at the expired wait. Name the abandoned pid in a warning. Cover both arms of the wait. The timeout arm was removable with the suite still green, and the duration assertion was lower-bounded only, so broken onExit wiring passed as a full-grace fall-through. Skip both stand-in cases on the installed-release lane, where the spawned CLI is the installed one and the stand-in script never runs. --- integration-tests/test-helper.test.ts | 131 ++++++++++++++++++-------- integration-tests/test-helper.ts | 30 +++--- 2 files changed, 109 insertions(+), 52 deletions(-) diff --git a/integration-tests/test-helper.test.ts b/integration-tests/test-helper.test.ts index 6731cd0dfb6..5c5021de036 100644 --- a/integration-tests/test-helper.test.ts +++ b/integration-tests/test-helper.test.ts @@ -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 { @@ -23,6 +23,12 @@ function isProcessAlive(pid: number): boolean { // and still look correct. const STAND_IN_EXIT_DELAY_MS = 750; +// Both interactive cases below stand in for the CLI through rig.bundlePath. +// The installed-release lane spawns the installed CLI instead and never runs +// the stand-in, so there is nothing for them to measure there. +const usesInstalledCli = + process.env['INTEGRATION_TEST_USE_INSTALLED_GEMINI'] === 'true'; + describe('TestRig', () => { const originalKeepOutput = process.env['KEEP_OUTPUT']; @@ -69,46 +75,89 @@ describe('TestRig', () => { expect(existsSync(testDir)).toBe(true); }); - 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'; - 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); - // 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); - await expect - .poll(() => isProcessAlive(ptyProcess.pid), { - message: 'the interactive CLI child outlived cleanup()', - timeout: 10_000, - }) - .toBe(false); - }); + 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); + // 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); + // 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)( + '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; + + // 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([ [ diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index 7826826b98f..96e5c4f943c 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -125,20 +125,23 @@ export function validateModelOutput( } // 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; - -// 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, ms: number): Promise { +// 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; + +// Resolves true when `promise` settles, false when `ms` elapses first. The +// timer is cleared and unrefed so a won race leaves no handle holding the +// worker's event loop open. +function settleWithin(promise: Promise, ms: number): Promise { return new Promise((resolve) => { - const timer = setTimeout(resolve, ms); + const timer = setTimeout(() => resolve(false), ms); timer.unref(); const settle = () => { clearTimeout(timer); - resolve(); + resolve(true); }; void promise.then(settle, settle); }); @@ -528,7 +531,12 @@ export class TestRig { } catch { // Process may have already exited } - await settleWithin(exited, INTERACTIVE_EXIT_GRACE_MS); + if (!(await settleWithin(exited, INTERACTIVE_EXIT_GRACE_MS))) { + console.warn( + `interactive CLI child (pid ${ptyProcess.pid}) did not exit within ` + + `${INTERACTIVE_EXIT_GRACE_MS}ms; continuing cleanup`, + ); + } } // Clean up test directory From 2e6c42e36a41b2aca9ae84763ff742bad78fc5df Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Fri, 4 Sep 2026 14:17:53 +0000 Subject: [PATCH 3/6] test(integration): pin the exit grace to literal bounds (#10990) --- integration-tests/test-helper.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/integration-tests/test-helper.test.ts b/integration-tests/test-helper.test.ts index 5c5021de036..dd1feb7ba10 100644 --- a/integration-tests/test-helper.test.ts +++ b/integration-tests/test-helper.test.ts @@ -149,6 +149,11 @@ describe('TestRig', () => { } 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 From 55aa9d41c304bfa95ac799cd7287e8d5f7c9887f Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Fri, 4 Sep 2026 20:06:39 +0000 Subject: [PATCH 4/6] test(scripts): pin the prompt-latency spec in the no-AK script list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #11004 added ./cli/_prompt-latency-policy.test.ts to test:integration:no-ak:sandbox:none but left the byte-exact pin in no-ak-integration-ci.test.js without it, so `npm run test:scripts` — and with it the required `Test (ubuntu-latest, Node 22.x)` check — fails on every branch whose base includes that commit, this one included. --- scripts/tests/no-ak-integration-ci.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/tests/no-ak-integration-ci.test.js b/scripts/tests/no-ak-integration-ci.test.js index 9a8e4404af8..a66c52e6d50 100644 --- a/scripts/tests/no-ak-integration-ci.test.js +++ b/scripts/tests/no-ak-integration-ci.test.js @@ -181,6 +181,7 @@ describe('no-AK integration CI wiring', () => { './qwen-live-m2-inject.test.ts', './qwen-live-m2-permission.test.ts', './qwen-live-m2-steering.test.ts', + './cli/_prompt-latency-policy.test.ts', './cli/daemon-invocation-context.test.ts', './cli/list_directory.test.ts', './cli/qwen-serve-routes.test.ts', From 38fb1ad08ab5056864f2acbea8b94bc98039b1a8 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Sat, 5 Sep 2026 07:43:04 +0000 Subject: [PATCH 5/6] fix(test): wait for the whole interactive PTY session in rig cleanup (#10990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node-pty signals only the process it spawned. On the installed-release lane that process is the bin wrapper, which relaunches the real CLI with spawnSync and dies on SIGHUP's default action at once — so the grace added for #10990 settled in ~200ms while the relaunched CLI was still draining into the PTY, certifying the very late-write race the wait exists to close. Wait for the process group the spawned process leads, not its exit alone, inside the same single grace. The group reads empty only once every member has exited and been reaped. Windows has no negative-pid process groups, so there the spawned child's exit stays the whole bound. --- integration-tests/test-helper.test.ts | 64 +++++++++++++++++++++++++-- integration-tests/test-helper.ts | 64 +++++++++++++++++++++------ 2 files changed, 112 insertions(+), 16 deletions(-) diff --git a/integration-tests/test-helper.test.ts b/integration-tests/test-helper.test.ts index dd1feb7ba10..d99dc245872 100644 --- a/integration-tests/test-helper.test.ts +++ b/integration-tests/test-helper.test.ts @@ -23,9 +23,10 @@ function isProcessAlive(pid: number): boolean { // and still look correct. const STAND_IN_EXIT_DELAY_MS = 750; -// Both interactive cases below stand in for the CLI through rig.bundlePath. -// The installed-release lane spawns the installed CLI instead and never runs -// the stand-in, so there is nothing for them to measure there. +// 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'; @@ -123,6 +124,63 @@ describe('TestRig', () => { }, ); + 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 () => { diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index 96e5c4f943c..f821226dc29 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -132,18 +132,51 @@ export function validateModelOutput( // naming the child that never exited. export const INTERACTIVE_EXIT_GRACE_MS = 8_000; -// Resolves true when `promise` settles, false when `ms` elapses first. The -// timer is cleared and unrefed so a won race leaves no handle holding the -// worker's event loop open. -function settleWithin(promise: Promise, ms: number): Promise { +// `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 (#10990). 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, + ms: number, +): Promise { return new Promise((resolve) => { - const timer = setTimeout(() => resolve(false), ms); - timer.unref(); - const settle = () => { - clearTimeout(timer); - resolve(true); + 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(); }; - void promise.then(settle, settle); + const timer = setInterval(check, 50); + timer.unref(); + void exited.then(onChildExit, onChildExit); }); } @@ -531,10 +564,15 @@ export class TestRig { } catch { // Process may have already exited } - if (!(await settleWithin(exited, INTERACTIVE_EXIT_GRACE_MS))) { + const ended = await sessionEndsWithin( + ptyProcess.pid, + exited, + INTERACTIVE_EXIT_GRACE_MS, + ); + if (!ended) { console.warn( - `interactive CLI child (pid ${ptyProcess.pid}) did not exit within ` + - `${INTERACTIVE_EXIT_GRACE_MS}ms; continuing cleanup`, + `interactive CLI process group ${ptyProcess.pid} did not end ` + + `within ${INTERACTIVE_EXIT_GRACE_MS}ms; continuing cleanup`, ); } } From 4e37d2e4a4d2cc8ff13787502d4b34b00f660032 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Sat, 5 Sep 2026 21:08:08 +0000 Subject: [PATCH 6/6] test(integration): drop the contradicted #10990 attribution from cleanup comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six OpenTUI legs cited for #10990 each name a failing test in their archived logs (five mid-turn-submit, one context-compress) and contain no EPIPE or unhandled error; the detector's "Failing tests identified: 0" was its documented log-download fallback, and the recurring OpenTUI signature was fixed by 56f75adf29 (PR 10986), already an ancestor of this branch. The teardown wait stays — it is measured teardown hygiene — but it must not certify itself as the root-cause fix for #10990. Co-authored-by: Qwen-Coder --- integration-tests/test-helper.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index c2b09dfddb3..2e5c77455b6 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -155,7 +155,7 @@ function sessionAlive(pid: number): boolean { // 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 (#10990). The spawned process leads its own process group and the +// 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 @@ -562,7 +562,7 @@ export class TestRig { // into this worker's stdout; after vitest tears the worker down those // 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). + // actually go away. for (const { ptyProcess, exited } of this.interactiveProcesses.splice(0)) { try { ptyProcess.kill();