From d3eb3930d5481999bef3a6473e6955bb27bc47ea Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Fri, 11 Sep 2026 16:54:03 +0800 Subject: [PATCH 01/16] fix(vscode): shut the ACP CLI down gracefully instead of killing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit disconnect() used to child.kill() the CLI: TerminateProcess on Windows (cleanup never runs, MCP/PTY/conhost children orphaned — the teardown half of #11303) and a bare SIGTERM on POSIX with no bound. Now stdin is closed so the CLI runs its own shutdown (SessionEnd hooks, MCP pool drain, session dispose, exit-time reaper), with a bounded escalation behind it: 45s grace sized from the CLI's own drain budgets, then a process-group SIGTERM (catchable, runs the signal cleanup), then SIGKILL after 10s. Windows goes straight to a taskkill tree kill once the grace expires, since console processes have no catchable terminate. Replacing the current session (session/new, session/load) now also closes the superseded one — a retained session keeps firing autonomous turns when its background tasks complete, which is what grew conhost.exe without bound in #11303. The close is conditional (onlyIfUnheld) with an 8s drain budget, and refused/failed closes retry on a 60s doubling backoff capped at 1h, so in-flight work is never dropped and the leak stays tracked. The CLI side of that close path gets the matching concurrency idempotency: a SIGTERM landing mid-ide_close now joins the in-flight MCP pool drain and session dispose instead of running them twice. Superseded connections can no longer tear down the live one: child handlers bind their own child, inbound callbacks and stale request resolutions check against the connection they were wired on. Refs #11510 #11511 #11303 --- .../cli/src/acp-integration/acpAgent.test.ts | 91 ++ packages/cli/src/acp-integration/acpAgent.ts | 42 +- .../src/services/acpConnection.test.ts | 1077 ++++++++++++++++- .../src/services/acpConnection.ts | 441 ++++++- 4 files changed, 1621 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 023620d99a6..ccca194a387 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -1665,6 +1665,97 @@ describe('runAcpAgent shutdown cleanup', () => { await agentPromise; }); + it('SIGTERM during ide_close drain runs shutdownMcpPool once', async () => { + // The VS Code extension escalates to SIGTERM once its shutdown grace + // expires; when that lands mid-ide_close, the signal path must join the + // in-flight MCP pool drain instead of running shutdownMcpPool again. + const { agent, agentPromise } = await startPreloadTestAgent(); + expect(agent).toBeDefined(); + + let resolveDrain!: () => void; + const shutdownMcpPool = vi.fn( + () => + new Promise((resolve) => { + resolveDrain = resolve; + }), + ); + const disposeSessions = vi.fn().mockResolvedValue(undefined); + Object.assign(agent!, { shutdownMcpPool, disposeSessions }); + + await vi.waitFor(() => { + expect(sigTermListeners.length).toBeGreaterThan(0); + }); + + // connection.closed resolving drives the ide_close path, which parks on + // the drain held open here. + mockConnectionState.resolve(); + await vi.waitFor(() => { + expect(shutdownMcpPool).toHaveBeenCalledTimes(1); + }); + + sigTermListeners[0]('SIGTERM'); + // The signal path passes disposeSessions and then joins the parked drain; + // its runExitCleanup can only run once the shared drain settles. + await vi.waitFor(() => { + expect(disposeSessions).toHaveBeenCalledTimes(1); + }); + expect(shutdownMcpPool).toHaveBeenCalledTimes(1); + expect(mockRunExitCleanup).not.toHaveBeenCalled(); + + resolveDrain(); + await agentPromise; + await vi.waitFor(() => { + expect(processExitSpy).toHaveBeenCalledWith(0); + }); + + expect(shutdownMcpPool).toHaveBeenCalledTimes(1); + expect(disposeSessions).toHaveBeenCalledTimes(1); + expect(mockRunExitCleanup).toHaveBeenCalledTimes(1); + }); + + it('SIGTERM during ide_close disposes sessions once', async () => { + // Same overlap, one step later: the signal path must join the in-flight + // disposeSessions instead of snapshotting the same sessions and running + // closeStoredSession (beginClose/abort) for each a second time. + const { agent, agentPromise } = await startPreloadTestAgent(); + expect(agent).toBeDefined(); + + const shutdownMcpPool = vi.fn().mockResolvedValue(undefined); + let resolveDispose!: () => void; + const disposeSessions = vi.fn( + () => + new Promise((resolve) => { + resolveDispose = resolve; + }), + ); + Object.assign(agent!, { shutdownMcpPool, disposeSessions }); + + await vi.waitFor(() => { + expect(sigTermListeners.length).toBeGreaterThan(0); + }); + + // ide_close drains (the mock resolves immediately) and then parks on the + // dispose held open here. + mockConnectionState.resolve(); + await vi.waitFor(() => { + expect(disposeSessions).toHaveBeenCalledTimes(1); + }); + + sigTermListeners[0]('SIGTERM'); + await flushImmediate(); + expect(disposeSessions).toHaveBeenCalledTimes(1); + expect(mockRunExitCleanup).not.toHaveBeenCalled(); + + resolveDispose(); + await agentPromise; + await vi.waitFor(() => { + expect(processExitSpy).toHaveBeenCalledWith(0); + }); + + expect(disposeSessions).toHaveBeenCalledTimes(1); + expect(shutdownMcpPool).toHaveBeenCalledTimes(1); + }); + it('still exits even if runExitCleanup throws', async () => { mockRunExitCleanup.mockRejectedValueOnce(new Error('cleanup failed')); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 11fccc4bd63..9db768000ad 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -2910,18 +2910,42 @@ export async function runAcpAgent( // Both the SIGTERM handler and the IDE-initiated close path need // to drain the MCP pool before runExitCleanup. Single helper - // closure keeps the timeout + log labels consistent. + // closure keeps the timeout + log labels consistent. The memoized + // promise makes it idempotent: a SIGTERM landing mid-ide_close (the + // VS Code extension escalates to SIGTERM once its shutdown grace + // expires) joins the in-flight drain instead of running + // shutdownMcpPool a second time. First call wins; later calls — + // including a stricter one — join it. + let drainPoolPromise: Promise | undefined; const drainPoolBeforeExit = async ( label: string, strict = false, ): Promise => { if (!agentInstance) return; - try { - await agentInstance.shutdownMcpPool(8_000); - } catch (err) { - debugLogger.error(`[ACP] MCP pool drain (${label}) error:`, err); - if (strict) throw err; - } + if (drainPoolPromise) return drainPoolPromise; + drainPoolPromise = (async () => { + try { + await agentInstance?.shutdownMcpPool(8_000); + } catch (err) { + debugLogger.error(`[ACP] MCP pool drain (${label}) error:`, err); + if (strict) throw err; + } + })(); + return drainPoolPromise; + }; + + // disposeSessions() is idempotent per call but not under concurrency: two + // overlapping calls snapshot the same session entries and each runs + // closeStoredSession for them (double beginClose, double abort). SIGTERM + // landing mid-ide_close is exactly that overlap, so both shutdown paths + // share one in-flight dispose. + let disposeSessionsPromise: Promise | undefined; + const disposeSessionsOnce = (): Promise => { + if (!agentInstance) return Promise.resolve(); + if (!disposeSessionsPromise) { + disposeSessionsPromise = agentInstance.disposeSessions(); + } + return disposeSessionsPromise; }; // Handle SIGTERM/SIGINT for graceful shutdown. @@ -3079,7 +3103,7 @@ export async function runAcpAgent( try { // Fire SessionEnd hook for all active sessions (aligned with core path) await fireSessionEndOnce(SessionEndReason.Other); - await agentInstance?.disposeSessions(); + await disposeSessionsOnce(); try { process.stdin.destroy(); @@ -3131,7 +3155,7 @@ export async function runAcpAgent( // Mirror the SIGTERM handler's pool drain on the IDE-initiated // normal close path to avoid leaking shared MCP entries. await drainPoolBeforeExit('ide_close'); - await agentInstance?.disposeSessions(); + await disposeSessionsOnce(); } } finally { process.off('SIGTERM', shutdownHandler); diff --git a/packages/vscode-ide-companion/src/services/acpConnection.test.ts b/packages/vscode-ide-companion/src/services/acpConnection.test.ts index 716cacf644a..c4ffa4a338c 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.test.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.test.ts @@ -4,25 +4,75 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; import { RequestError } from '@agentclientprotocol/sdk'; import type { ContentBlock } from '@agentclientprotocol/sdk'; +import { PassThrough } from 'node:stream'; +import { logger } from '../utils/logger.js'; const spawnMock = vi.hoisted(() => vi.fn()); +const execFileMock = vi.hoisted(() => vi.fn()); +const sdkClientFactory = vi.hoisted(() => ({ + factory: null as null | ((agent: unknown) => Record), +})); + +// Mirrors the module-private SHUTDOWN_GRACE_MS in acpConnection.ts. Kept as a +// literal here on purpose: the escalation tests step to just before and just +// after the deadline, so a grace that changes without these tests changing +// fails them instead of silently widening or vacating the pin. +const SHUTDOWN_GRACE_MS = 45_000; +// Same pin for the second rung of the POSIX escalation ladder +// (SIGTERM_GRACE_MS): SIGKILL must not land until this long after SIGTERM. +const SIGTERM_GRACE_MS = 10_000; +// Same pin for the refused-close backoff rungs (CLOSE_RETRY_BASE_MS and +// CLOSE_RETRY_CEILING_MS): 60s, doubling, capped at 1h. +const CLOSE_RETRY_BASE_MS = 60_000; +const CLOSE_RETRY_CEILING_MS = 3_600_000; // AcpConnection imports AcpFileHandler which imports vscode. // Mock vscode so it can be resolved without the actual VS Code runtime. vi.mock('vscode', () => ({})); vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, spawn: spawnMock }; + return { ...actual, spawn: spawnMock, execFile: execFileMock }; +}); +vi.mock('@agentclientprotocol/sdk', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + // Capture the Client factory so tests can drive the inbound callbacks + // (sessionUpdate / writeTextFile / ...) that guard against a superseded + // connection. The real SDK would hold this factory internally. + ClientSideConnection: class { + constructor(factory: (agent: unknown) => Record) { + sdkClientFactory.factory = factory; + } + initialize = vi.fn().mockResolvedValue({ protocolVersion: '1.0' }); + }, + ndJsonStream: () => ({}), + }; }); import { AcpConnection } from './acpConnection.js'; import { ACP_ERROR_CODES } from '../constants/acpSchema.js'; type AcpConnectionInternal = { - child: { killed: boolean; exitCode: number | null; kill?: () => void } | null; + child: { + killed: boolean; + exitCode: number | null; + signalCode?: string | null; + pid?: number; + kill?: () => void; + stdin?: { + end: () => void; + destroyed?: boolean; + writableEnded?: boolean; + once?: (event: string, listener: () => void) => unknown; + } | null; + once?: (event: string, listener: () => void) => unknown; + } | null; sdkConnection: unknown; sessionId: string | null; lastExitCode: number | null; @@ -39,11 +89,19 @@ function createConnection(overrides?: Partial) { return conn; } +function createMockStdin(end = vi.fn()) { + return { end, destroyed: false, writableEnded: false, once: vi.fn() }; +} + function createMockChild(overrides?: Record) { return { killed: false, exitCode: null, + signalCode: null, + pid: 4242, kill: vi.fn(), + stdin: createMockStdin(), + once: vi.fn(), ...overrides, } as unknown as AcpConnectionInternal['child']; } @@ -71,6 +129,40 @@ describe('AcpConnection process spawning', () => { vi.unstubAllEnvs(); } }); + + it('spawns the child detached on POSIX but not on Windows', async () => { + // The POSIX escalation is a process-group signal (process.kill(-pid, + // ...)): it reaches the CLI's whole group only because the child is + // spawned detached and so leads its own group. Windows has no signalable + // group — its tree kill goes through taskkill, and there `detached` only + // changes console attachment. Pin both sides so a future refactor of the + // options object cannot silently turn the group signal into a root-only + // one that orphans every descendant (the #11303 leak). + spawnMock.mockClear(); + spawnMock.mockReturnValue(createMockChild()); + const makeConn = () => { + const conn = new AcpConnection() as unknown as { + connect: (cliEntryPath: string) => Promise; + setupChildProcessHandlers: () => Promise; + }; + conn.setupChildProcessHandlers = vi.fn().mockResolvedValue(undefined); + return conn; + }; + const platform = vi.spyOn(process, 'platform', 'get'); + try { + platform.mockReturnValue('linux'); + await makeConn().connect(process.execPath); + platform.mockReturnValue('win32'); + await makeConn().connect(process.execPath); + + const detachedAt = (i: number) => + (spawnMock.mock.calls[i]?.[2] as { detached?: boolean }).detached; + expect(detachedAt(0)).toBe(true); + expect(detachedAt(1)).toBe(false); + } finally { + platform.mockRestore(); + } + }); }); describe('AcpConnection readTextFile error mapping', () => { @@ -203,6 +295,16 @@ describe('AcpConnection.ensureConnection', () => { }); describe('AcpConnection child exit cleanup', () => { + beforeEach(() => { + execFileMock.mockReset(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + it('disconnect clears child, sdkConnection, and sessionId', () => { const conn = createConnection({ child: createMockChild(), @@ -218,17 +320,982 @@ describe('AcpConnection child exit cleanup', () => { expect(acpConn.currentSessionId).toBeNull(); }); - it('disconnect calls kill on the child process', () => { + it('disconnect closes the CLI stdin instead of killing it (#11303)', () => { + // `child.kill()` is TerminateProcess on Windows: the CLI's + // `process.on('exit')` cleanup never runs, so every PTY, ConPTY host and + // child process it is tracking is orphaned. Ending stdin closes the ACP + // stream, which is the CLI's own graceful shutdown path. const mockKill = vi.fn(); + const mockEnd = vi.fn(); const conn = createConnection({ - child: createMockChild({ kill: mockKill }), + child: createMockChild({ + kill: mockKill, + stdin: createMockStdin(mockEnd), + }), sdkConnection: {}, sessionId: 'test-session', }); (conn as unknown as AcpConnection).disconnect(); + + expect(mockEnd).toHaveBeenCalledOnce(); + expect(mockKill).not.toHaveBeenCalled(); + }); + + it('does not force-kill a child that failed to spawn', () => { + const mockKill = vi.fn(); + const conn = createConnection({ + child: createMockChild({ kill: mockKill, pid: undefined }), + }); + + (conn as unknown as AcpConnection).disconnect(); + vi.advanceTimersByTime(SHUTDOWN_GRACE_MS + SIGTERM_GRACE_MS); + + expect(execFileMock).not.toHaveBeenCalled(); + expect(mockKill).not.toHaveBeenCalled(); + }); + + it('does not end stdin that is already closed', () => { + // Even when stdin cannot be ended (already closed), the escalation timer + // must still be armed: an early return here would leave the CLI's process + // group running forever. Assert the escalation ladder still climbs past + // the grace. + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); + const end = vi.fn(); + const conn = createConnection({ + child: createMockChild({ + stdin: { ...createMockStdin(end), writableEnded: true }, + }), + }); + + (conn as unknown as AcpConnection).disconnect(); + + expect(end).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(SHUTDOWN_GRACE_MS); + expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGTERM'); + vi.advanceTimersByTime(SIGTERM_GRACE_MS); + expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGKILL'); + }); + + it('handles a synchronous stdin close failure', () => { + // A synchronous stdin.end() failure (e.g. EPIPE) must not short-circuit + // disconnect(): the escalation timer still has to be armed, or a failing + // stdin close leaves the CLI's process group running forever. + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); + const closeError = new Error('EPIPE'); + const once = vi.fn(); + const logError = vi.spyOn(logger, 'error').mockImplementation(() => {}); + const conn = createConnection({ + child: createMockChild({ + stdin: { + ...createMockStdin( + vi.fn(() => { + throw closeError; + }), + ), + once, + }, + }), + }); + + expect(() => (conn as unknown as AcpConnection).disconnect()).not.toThrow(); + expect(once).toHaveBeenCalledWith('error', expect.any(Function)); + expect(logError).toHaveBeenCalledWith( + '[ACP] Failed to close CLI stdin during disconnect:', + closeError, + ); + + vi.advanceTimersByTime(SHUTDOWN_GRACE_MS); + expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGTERM'); + vi.advanceTimersByTime(SIGTERM_GRACE_MS); + expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGKILL'); + }); + + it('disconnect escalates stdin close → SIGTERM → SIGKILL on POSIX', () => { + // Pinned so the assertion does not depend on which runner executes it. + const platform = vi + .spyOn(process, 'platform', 'get') + .mockReturnValue('linux'); + const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); + try { + const mockKill = vi.fn(); + const child = createMockChild({ kill: mockKill }); + const conn = createConnection({ + child, + sdkConnection: {}, + sessionId: 'test-session', + }); + + (conn as unknown as AcpConnection).disconnect(); + expect(mockKill).not.toHaveBeenCalled(); + expect(killSpy).not.toHaveBeenCalled(); + + // The grace has to outlast the CLI's own wind-down (8s MCP pool drain + + // 30s session drain), so nothing may be signalled one tick before it + // expires. A grace shorter than that wind-down reds this assertion. + vi.advanceTimersByTime(SHUTDOWN_GRACE_MS - 1); + expect(killSpy).not.toHaveBeenCalled(); + + // First rung: SIGTERM to the process GROUP (negative pid), not a bare + // kill(). SIGTERM stays catchable, so the CLI's signal cleanup and its + // exit-time reaper still run before the last rung. Removing the group + // signal or jumping straight to SIGKILL reds this. + vi.advanceTimersByTime(1); + expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGTERM'); + expect(killSpy).not.toHaveBeenCalledWith(-4242, 'SIGKILL'); + expect(mockKill).not.toHaveBeenCalled(); + + // Second rung, SIGTERM_GRACE_MS later: SIGKILL to the same group. + vi.advanceTimersByTime(SIGTERM_GRACE_MS - 1); + expect(killSpy).not.toHaveBeenCalledWith(-4242, 'SIGKILL'); + vi.advanceTimersByTime(1); + expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGKILL'); + } finally { + killSpy.mockRestore(); + platform.mockRestore(); + } + }); + + it('disconnect does not escalate against a CLI that exited on its own', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); + try { + const mockKill = vi.fn(); + let exitListener: (() => void) | undefined; + const child = createMockChild({ + kill: mockKill, + once: vi.fn((event: string, listener: () => void) => { + if (event === 'exit') exitListener = listener; + }), + }); + const conn = createConnection({ + child, + sdkConnection: {}, + sessionId: 'test-session', + }); + + (conn as unknown as AcpConnection).disconnect(); + exitListener?.(); + // Past both deadlines the cancelled timers would have fired at, + // otherwise the cancellation this test pins is never exercised. + vi.advanceTimersByTime(SHUTDOWN_GRACE_MS + SIGTERM_GRACE_MS); + + expect(killSpy).not.toHaveBeenCalled(); + expect(mockKill).not.toHaveBeenCalled(); + } finally { + killSpy.mockRestore(); + } + }); + + it('does not force-kill a CLI that exits within the SIGTERM grace', () => { + // The ladder stops once the child exits: SIGTERM landed, and the SIGKILL + // rung must never fire for a CLI that is already winding down. + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); + try { + const mockKill = vi.fn(); + let exitListener: (() => void) | undefined; + const child = createMockChild({ + kill: mockKill, + once: vi.fn((event: string, listener: () => void) => { + if (event === 'exit') exitListener = listener; + }), + }); + const conn = createConnection({ + child, + sdkConnection: {}, + sessionId: 'test-session', + }); + + (conn as unknown as AcpConnection).disconnect(); + vi.advanceTimersByTime(SHUTDOWN_GRACE_MS); + expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGTERM'); + + exitListener?.(); + vi.advanceTimersByTime(SIGTERM_GRACE_MS); + + expect(killSpy).not.toHaveBeenCalledWith(-4242, 'SIGKILL'); + expect(mockKill).not.toHaveBeenCalled(); + } finally { + killSpy.mockRestore(); + } + }); + + it('escalates through taskkill /t on Windows, not a bare kill', () => { + // Windows CI is skipped on PRs, so the platform is faked here rather than + // left to whichever runner happens to execute the suite. + const platform = vi + .spyOn(process, 'platform', 'get') + .mockReturnValue('win32'); + try { + const mockKill = vi.fn(); + const conn = createConnection({ + child: createMockChild({ kill: mockKill, pid: 4242 }), + sdkConnection: {}, + sessionId: 'test-session', + }); + + (conn as unknown as AcpConnection).disconnect(); + vi.advanceTimersByTime(SHUTDOWN_GRACE_MS); + + // A tree kill: the CLI is unresponsive by now, so nothing else will reap + // the shells and ConPTY hosts underneath it. See #11303. + expect(execFileMock).toHaveBeenCalledWith( + expect.stringMatching(/\\System32\\taskkill\.exe$/i), + ['/f', '/t', '/pid', '4242'], + expect.objectContaining({ windowsHide: true, timeout: 2_000 }), + expect.any(Function), + ); + expect(mockKill).not.toHaveBeenCalled(); + } finally { + platform.mockRestore(); + } + }); + + it('falls back when taskkill cannot terminate the CLI tree', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + execFileMock.mockImplementation( + ( + _file: string, + _args: string[], + _options: object, + callback: (error: Error | null) => void, + ) => { + callback(new Error('ERROR_ACCESS_DENIED')); + }, + ); + const mockKill = vi.fn(); + const conn = createConnection({ + child: createMockChild({ kill: mockKill }), + }); + + (conn as unknown as AcpConnection).disconnect(); + vi.advanceTimersByTime(SHUTDOWN_GRACE_MS); + expect(mockKill).toHaveBeenCalledOnce(); }); + + it('a superseded child exiting does not tear down its replacement', async () => { + // disconnect() now lets the CLI wind down on its own, so a superseded child + // can still be exiting after connect() installed its replacement. An exit + // handler keyed only on `this.child` would null out the live connection. + let exitHandler: + | ((code: number | null, signal: string | null) => void) + | undefined; + const oldChild = createMockChild({ + on: vi.fn((event: string, listener: unknown) => { + if (event === 'exit') { + exitHandler = listener as ( + code: number | null, + signal: string | null, + ) => void; + } + }), + }); + const conn = createConnection({ child: oldChild }); + const acpConn = conn as unknown as AcpConnection; + const onDisconnected = vi.fn(); + acpConn.onDisconnected = onDisconnected; + + // Only the listener wiring matters here; the rest of the setup (its 1s + // settle, the web-stream conversion) has nothing to assert on these mocks. + void (conn as unknown as { setupChildProcessHandlers: () => Promise }) + .setupChildProcessHandlers() + .catch(() => {}); + + // connect() has since replaced the child. + const newChild = createMockChild(); + conn.child = newChild; + + exitHandler?.(0, null); + + expect(conn.child).toBe(newChild); + expect(onDisconnected).not.toHaveBeenCalled(); + // The exit also rejects the promise initialize() races. Nothing has + // attached to it at this point, so it must already be marked handled or + // this is an unhandled rejection in the extension host — vitest reports it + // as a suite error even with every test green. + await Promise.resolve(); + }); + + it('a live child exiting clears the connection and fires onDisconnected', async () => { + // The exit-handler teardown is keyed on the connection still being + // current. For the CURRENT direction (no supersede), a live child exit + // must clear child/sdkConnection/sessionId and fire onDisconnected, or + // the teardown silently never runs. A mutant like + // `if (this.child === ownChild && !ownChild)` (always false) reds this. + let exitHandler: + | ((code: number | null, signal: string | null) => void) + | undefined; + const child = createMockChild({ + on: vi.fn((event: string, listener: unknown) => { + if (event === 'exit') { + exitHandler = listener as ( + code: number | null, + signal: string | null, + ) => void; + } + }), + }); + const conn = createConnection({ child }); + const acpConn = conn as unknown as AcpConnection; + const onDisconnected = vi.fn(); + acpConn.onDisconnected = onDisconnected; + conn.sdkConnection = { initialize: vi.fn() }; + conn.sessionId = 'test-session'; + + // Only the listener wiring matters here; the rest of the setup (its 1s + // settle, the web-stream conversion) has nothing to assert on these mocks. + void (conn as unknown as { setupChildProcessHandlers: () => Promise }) + .setupChildProcessHandlers() + .catch(() => {}); + + exitHandler?.(0, null); + + expect(conn.child).toBeNull(); + expect(conn.sdkConnection).toBeNull(); + expect(conn.sessionId).toBeNull(); + expect(onDisconnected).toHaveBeenCalledWith(0, null); + await Promise.resolve(); + }); + + it('a superseded connection stops dispatching inbound callbacks', async () => { + // The inbound callbacks on the SDK Client object read `this.*` at call + // time. `disconnect()` ends stdin and nulls sdkConnection but does not + // close the superseded child's stdout, so its ClientSideConnection stays + // live. Each callback must gate on the connection it was built for + // (`this.sdkConnection !== wiredConnection`), or the retired connection + // keeps dispatching into callbacks bound to the live replacement. This + // case drives the writeTextFile and sessionUpdate guards specifically; + // requestPermission, readTextFile and extNotification carry the same gate + // but are not exercised here. Removing either driven guard fires its spy. + try { + const stdout = new PassThrough(); + const stdin = new PassThrough(); + const oldChild = createMockChild({ stdout, stdin, on: vi.fn() }); + const conn = new AcpConnection() as unknown as AcpConnectionInternal & { + onSessionUpdate: (data: unknown) => void; + fileHandler: { + handleWriteTextFile: (request: unknown) => Promise; + }; + }; + conn.child = oldChild; + conn.onSessionUpdate = vi.fn(); + const writeSpy = vi + .spyOn(conn.fileHandler, 'handleWriteTextFile') + .mockResolvedValue({}); + + const setup = ( + conn as unknown as { + setupChildProcessHandlers: () => Promise; + } + ).setupChildProcessHandlers(); + await vi.advanceTimersByTimeAsync(1000); + await setup; + + const client = sdkClientFactory.factory?.(null); + expect(client).toBeDefined(); + const writeTextFile = ( + client as unknown as { + writeTextFile: (request: unknown) => Promise; + } + ).writeTextFile; + const sessionUpdate = ( + client as unknown as { + sessionUpdate: (notification: unknown) => Promise; + } + ).sessionUpdate; + + // Supersede the connection the way a re-connect() does: disconnect() + // nulls both child and sdkConnection, while the superseded connection's + // stdout (still open) keeps its ClientSideConnection dispatching. + (conn as unknown as AcpConnection).disconnect(); + + await expect( + writeTextFile({ path: '/tmp/x', content: 'x', sessionId: 's' }), + ).rejects.toBeInstanceOf(RequestError); + expect(writeSpy).not.toHaveBeenCalled(); + + await sessionUpdate({}); + expect(conn.onSessionUpdate).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('a re-connected replacement keeps the superseded connection muted', async () => { + // Every other supersede test drives the gate through disconnect(), which + // nulls this.child and this.sdkConnection together, so the captured + // identity predicate (`this.sdkConnection !== wiredConnection`) and a + // weaker `!this.child` always agree. A real re-connect() installs a + // replacement child and a fresh sdkConnection while the old connection's + // stdout is still live — this.child is truthy again, so a gate weakened to + // `!this.child` would resume dispatching the retired connection's + // callbacks. This case pins the stronger predicate. + try { + const oldChild = createMockChild({ + stdout: new PassThrough(), + stdin: new PassThrough(), + on: vi.fn(), + }); + const newChild = createMockChild({ + stdout: new PassThrough(), + stdin: new PassThrough(), + on: vi.fn(), + }); + spawnMock.mockReset(); + spawnMock.mockReturnValueOnce(oldChild).mockReturnValueOnce(newChild); + + const conn = new AcpConnection() as unknown as AcpConnectionInternal & { + connect: (cliEntryPath: string) => Promise; + onSessionUpdate: (data: unknown) => void; + fileHandler: { + handleWriteTextFile: (request: unknown) => Promise; + }; + }; + conn.onSessionUpdate = vi.fn(); + const writeSpy = vi + .spyOn(conn.fileHandler, 'handleWriteTextFile') + .mockResolvedValue({}); + + // First connect: capture the old client before it is superseded. + const first = conn.connect(process.execPath); + await vi.advanceTimersByTimeAsync(1000); + await first; + const oldClient = sdkClientFactory.factory?.(null); + + // Re-connect: disconnect() retires the old child, then a replacement + // child and a fresh sdkConnection are installed while the old stdout is + // still dispatching. + const second = conn.connect(process.execPath); + await vi.advanceTimersByTimeAsync(1000); + await second; + + expect(conn.child).toBe(newChild); + + const writeTextFile = ( + oldClient as unknown as { + writeTextFile: (request: unknown) => Promise; + } + ).writeTextFile; + const sessionUpdate = ( + oldClient as unknown as { + sessionUpdate: (notification: unknown) => Promise; + } + ).sessionUpdate; + + await expect( + writeTextFile({ path: '/tmp/x', content: 'x', sessionId: 's' }), + ).rejects.toBeInstanceOf(RequestError); + expect(writeSpy).not.toHaveBeenCalled(); + + await sessionUpdate({}); + expect(conn.onSessionUpdate).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('does not stamp a superseded connection with a stale session id', async () => { + // newSession/loadSession write this.sessionId only after awaiting the + // connection they captured. A response from a retired CLI can resolve + // after disconnect() nulled sessionId; the post-await write must gate on + // the captured connection, or the dead session's id lands back on the + // replacement connection's field. Removing either guard reds this test. + let resolveNewSession!: (value: unknown) => void; + let resolveLoadSession!: (value: unknown) => void; + const sdk = { + newSession: vi.fn( + () => + new Promise((resolve) => { + resolveNewSession = resolve; + }), + ), + loadSession: vi.fn( + () => + new Promise((resolve) => { + resolveLoadSession = resolve; + }), + ), + }; + const conn = createConnection({ + child: createMockChild(), + sdkConnection: sdk, + sessionId: 'test-session', + }); + const acp = conn as unknown as AcpConnection; + + const newPromise = acp.newSession(); + const loadPromise = acp.loadSession('stale-session'); + acp.disconnect(); + + resolveNewSession({ sessionId: 'stale-from-retired-cli' }); + resolveLoadSession({}); + // The guard must fail the call, not resolve it. Returning the retired + // CLI's payload lets qwenAgentManager.applySessionStateFromResult write + // the dead model/mode state into the live webview's baselines, and + // createNewSession hands that same promise to every concurrent caller via + // sessionCreateInFlight. Reverting either guard to `return response` reds + // both assertions below. + await expect(newPromise).rejects.toMatchObject({ + code: ACP_ERROR_CODES.INTERNAL_ERROR, + data: { details: 'connection superseded' }, + }); + await expect(loadPromise).rejects.toMatchObject({ + code: ACP_ERROR_CODES.INTERNAL_ERROR, + data: { details: 'connection superseded' }, + }); + + expect(acp.currentSessionId).toBeNull(); + }); + + it('a re-connected replacement does not stamp the retired session id', async () => { + // Mirrors 'does not stamp a superseded connection with a stale session id' + // but supersedes by re-connect instead of disconnect(). disconnect() nulls + // this.child and this.sdkConnection together, so a gate weakened to + // `!this.child` still bails there. After a re-connect this.child is truthy + // again (the replacement), so `!this.child` would NOT bail and the retired + // CLI's session/new + session/load would stamp their ids back onto the + // live connection. This case pins `this.sdkConnection !== conn` (and its + // `=== conn` mirror) against that substitution. + let resolveNewSession!: (value: unknown) => void; + let resolveLoadSession!: (value: unknown) => void; + const oldSdk = { + newSession: vi.fn( + () => + new Promise((resolve) => { + resolveNewSession = resolve; + }), + ), + loadSession: vi.fn( + () => + new Promise((resolve) => { + resolveLoadSession = resolve; + }), + ), + }; + const conn = createConnection({ + child: createMockChild(), + sdkConnection: oldSdk, + sessionId: 'live-session-1', + }); + const acp = conn as unknown as AcpConnection; + + const newPromise = acp.newSession(); + const loadPromise = acp.loadSession('stale-session'); + + // Re-connect: install a replacement child and a fresh sdkConnection while + // the retired sdk's promises are still in flight. + conn.child = createMockChild(); + conn.sdkConnection = { newSession: vi.fn(), loadSession: vi.fn() }; + conn.sessionId = 'live-session-2'; + + resolveNewSession({ sessionId: 'stale-from-retired-cli' }); + resolveLoadSession({}); + // Same return-value pin as the disconnect() case, on the re-connect path: + // the retired CLI's payload must not reach the caller, or it is applied to + // the replacement connection's live webview state. + await expect(newPromise).rejects.toMatchObject({ + code: ACP_ERROR_CODES.INTERNAL_ERROR, + data: { details: 'connection superseded' }, + }); + await expect(loadPromise).rejects.toMatchObject({ + code: ACP_ERROR_CODES.INTERNAL_ERROR, + data: { details: 'connection superseded' }, + }); + + expect(acp.currentSessionId).toBe('live-session-2'); + }); + + it('a re-connected replacement does not fire onEndTurn for a retired prompt', async () => { + // sendPrompt gates onEndTurn on the captured connection so a stale prompt + // resolving after a re-connect does not clear the replacement's streaming + // state. disconnect() nulls this.child and this.sdkConnection together, so + // `!this.child` still bails there; only a re-connect (this.child truthy + // again) can tell the two predicates apart. + let resolvePrompt!: (value: unknown) => void; + const oldSdk = { + prompt: vi.fn( + () => + new Promise((resolve) => { + resolvePrompt = resolve; + }), + ), + }; + const onEndTurn = vi.fn(); + const conn = createConnection({ + child: createMockChild(), + sdkConnection: oldSdk, + sessionId: 'session-1', + }); + (conn as unknown as AcpConnection).onEndTurn = onEndTurn; + const acp = conn as unknown as AcpConnection; + + const promptPromise = acp.sendPrompt('hi'); + + conn.child = createMockChild(); + conn.sdkConnection = { prompt: vi.fn() }; + conn.sessionId = 'session-2'; + + resolvePrompt({ stopReason: 'end_turn' }); + await promptPromise; + + expect(onEndTurn).not.toHaveBeenCalled(); + }); +}); + +describe('AcpConnection superseded session close (#11303)', () => { + // The agent keeps a session alive until told otherwise, and a retained + // session still fires autonomous model turns when its background tasks + // complete. Replacing the current session must therefore tell the agent to + // close the superseded one, or every New Session / history switch strands + // one more live session in the CLI process. + + const closeParams = (sessionId: string) => ({ + sessionId, + requireFlush: true, + onlyIfUnheld: true, + drainTimeoutMs: 8_000, + }); + + it('newSession closes the superseded session on the same connection', async () => { + const extMethod = vi.fn().mockResolvedValue({ closed: true }); + const sdk = { + newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), + extMethod, + }; + const conn = createConnection({ + child: createMockChild(), + sdkConnection: sdk, + sessionId: 'session-a', + }); + + await (conn as unknown as AcpConnection).newSession(); + + expect(extMethod).toHaveBeenCalledWith( + 'qwen/control/session/close', + closeParams('session-a'), + ); + expect(conn.sessionId).toBe('session-b'); + }); + + it('newSession does not close anything for the first session', async () => { + const extMethod = vi.fn().mockResolvedValue({ closed: true }); + const sdk = { + newSession: vi.fn().mockResolvedValue({ sessionId: 'session-a' }), + extMethod, + }; + const conn = createConnection({ + child: createMockChild(), + sdkConnection: sdk, + sessionId: null, + }); + + await (conn as unknown as AcpConnection).newSession(); + + expect(extMethod).not.toHaveBeenCalled(); + }); + + it('loadSession closes the superseded session on the same connection', async () => { + const extMethod = vi.fn().mockResolvedValue({ closed: true }); + const sdk = { + loadSession: vi.fn().mockResolvedValue({}), + extMethod, + }; + const conn = createConnection({ + child: createMockChild(), + sdkConnection: sdk, + sessionId: 'session-a', + }); + + await (conn as unknown as AcpConnection).loadSession('session-c'); + + expect(extMethod).toHaveBeenCalledWith( + 'qwen/control/session/close', + closeParams('session-a'), + ); + expect(conn.sessionId).toBe('session-c'); + }); + + it('loadSession does not close when reloading the current session', async () => { + // Re-loading the session already on screen (e.g. history hydration after a + // reconnect) must not close it out from under the live conversation. + const extMethod = vi.fn().mockResolvedValue({ closed: true }); + const sdk = { + loadSession: vi.fn().mockResolvedValue({}), + extMethod, + }; + const conn = createConnection({ + child: createMockChild(), + sdkConnection: sdk, + sessionId: 'session-a', + }); + + await (conn as unknown as AcpConnection).loadSession('session-a'); + + expect(extMethod).not.toHaveBeenCalled(); + }); + + it('sends the close conditionally so held work is refused, not dropped (#11511)', async () => { + // Navigation is automatic cleanup, not explicit destruction: a session + // that still holds active work must be refused ({closed: false, holds}) + // rather than force-closed. Pin the onlyIfUnheld + drain budget the CLI + // contract keys on; dropping onlyIfUnheld reds this test. + const extMethod = vi + .fn() + .mockResolvedValue({ closed: false, holds: ['running-task'] }); + const sdk = { + newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), + extMethod, + }; + const conn = createConnection({ + child: createMockChild(), + sdkConnection: sdk, + sessionId: 'session-a', + }); + + await (conn as unknown as AcpConnection).newSession(); + + expect(extMethod).toHaveBeenCalledWith('qwen/control/session/close', { + sessionId: 'session-a', + requireFlush: true, + onlyIfUnheld: true, + drainTimeoutMs: 8_000, + }); + expect(conn.sessionId).toBe('session-b'); + }); + + describe('superseded close retry (#11511)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const countClosesFor = (extMethod: Mock, sessionId: string) => + extMethod.mock.calls.filter( + ([, params]) => + (params as { sessionId?: string }).sessionId === sessionId, + ).length; + + it('retries a refused superseded close on a backoff until it succeeds', async () => { + const extMethod = vi + .fn() + .mockResolvedValueOnce({ closed: false, holds: ['running-task'] }) + .mockResolvedValueOnce({ closed: true }); + const sdk = { + newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), + extMethod, + }; + const conn = createConnection({ + child: createMockChild(), + sdkConnection: sdk, + sessionId: 'session-a', + }); + + await (conn as unknown as AcpConnection).newSession(); + await vi.advanceTimersByTimeAsync(0); + expect(extMethod).toHaveBeenCalledTimes(1); + + // First retry only once the 60s rung expires. + await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS - 1); + expect(extMethod).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(extMethod).toHaveBeenCalledTimes(2); + expect(extMethod).toHaveBeenNthCalledWith( + 2, + 'qwen/control/session/close', + closeParams('session-a'), + ); + + // Closed for good: no third attempt, ever. + await vi.advanceTimersByTimeAsync(CLOSE_RETRY_CEILING_MS); + expect(extMethod).toHaveBeenCalledTimes(2); + }); + + it('backs off exponentially while a superseded close keeps being refused', async () => { + const extMethod = vi + .fn() + .mockResolvedValue({ closed: false, holds: ['running-task'] }); + const sdk = { + newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), + extMethod, + }; + const conn = createConnection({ + child: createMockChild(), + sdkConnection: sdk, + sessionId: 'session-a', + }); + + await (conn as unknown as AcpConnection).newSession(); + await vi.advanceTimersByTimeAsync(0); + expect(extMethod).toHaveBeenCalledTimes(1); + + // First retry after 60s. + await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS); + expect(extMethod).toHaveBeenCalledTimes(2); + + // Second refusal doubles the rung: another 120s, not 60s. + await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS); + expect(extMethod).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS); + expect(extMethod).toHaveBeenCalledTimes(3); + }); + + it('cancels the retry when the superseded session is loaded again', async () => { + const extMethod = vi + .fn() + .mockResolvedValueOnce({ closed: false, holds: ['running-task'] }) + .mockResolvedValue({ closed: true }); + const sdk = { + newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), + loadSession: vi.fn().mockResolvedValue({}), + extMethod, + }; + const conn = createConnection({ + child: createMockChild(), + sdkConnection: sdk, + sessionId: 'session-a', + }); + const acp = conn as unknown as AcpConnection; + + await acp.newSession(); + await vi.advanceTimersByTimeAsync(0); + expect(countClosesFor(extMethod, 'session-a')).toBe(1); + + // The superseded session becomes current again: session-b is now the + // one being closed, and the pending session-a retry must never fire. + await acp.loadSession('session-a'); + await vi.advanceTimersByTimeAsync(0); + expect(countClosesFor(extMethod, 'session-b')).toBe(1); + + await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS * 4); + expect(countClosesFor(extMethod, 'session-a')).toBe(1); + }); + + it('stops retrying superseded closes after disconnect', async () => { + const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); + try { + const extMethod = vi + .fn() + .mockResolvedValue({ closed: false, holds: ['running-task'] }); + const sdk = { + newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), + extMethod, + }; + const conn = createConnection({ + child: createMockChild(), + sdkConnection: sdk, + sessionId: 'session-a', + }); + const acp = conn as unknown as AcpConnection; + + await acp.newSession(); + await vi.advanceTimersByTimeAsync(0); + expect(extMethod).toHaveBeenCalledTimes(1); + + acp.disconnect(); + await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS * 4); + expect(extMethod).toHaveBeenCalledTimes(1); + } finally { + killSpy.mockRestore(); + } + }); + + it('re-drives an expired close retry on the next session replacement', async () => { + const extMethod = vi + .fn() + .mockResolvedValue({ closed: false, holds: ['running-task'] }); + const sdk = { + newSession: vi + .fn() + .mockResolvedValueOnce({ sessionId: 'session-b' }) + .mockResolvedValueOnce({ sessionId: 'session-c' }), + extMethod, + }; + const conn = createConnection({ + child: createMockChild(), + sdkConnection: sdk, + sessionId: 'session-a', + }); + const acp = conn as unknown as AcpConnection; + + await acp.newSession(); + await vi.advanceTimersByTimeAsync(0); + expect(countClosesFor(extMethod, 'session-a')).toBe(1); + + // Move the clock past the 60s rung WITHOUT running timers: the retry is + // due but the timer thread has not fired. The next replacement must + // drive it immediately (the daemon equivalent is the next active-work + // snapshot). + vi.setSystemTime(Date.now() + CLOSE_RETRY_BASE_MS + 1000); + + await acp.newSession(); + await vi.advanceTimersByTimeAsync(0); + expect(countClosesFor(extMethod, 'session-a')).toBe(2); + expect(countClosesFor(extMethod, 'session-b')).toBe(1); + }); + + it('retries a close that errors (older CLI) without failing the new session', async () => { + // Old-CLI compatibility: the ext method rejects, the replacement + // session still succeeds, and the failure lands on the same retry + // table instead of being dropped. + const extMethod = vi + .fn() + .mockRejectedValue(new Error('Method not found')); + const sdk = { + newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), + extMethod, + }; + const conn = createConnection({ + child: createMockChild(), + sdkConnection: sdk, + sessionId: 'session-a', + }); + const acp = conn as unknown as AcpConnection; + + await expect(acp.newSession()).resolves.toMatchObject({ + sessionId: 'session-b', + }); + await vi.advanceTimersByTimeAsync(0); + expect(extMethod).toHaveBeenCalledTimes(1); + expect(conn.sessionId).toBe('session-b'); + + await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS); + expect(extMethod).toHaveBeenCalledTimes(2); + }); + }); + + it('a failed close does not fail the new session', async () => { + // Older CLIs have no session/close ext method; the replacement session + // must still succeed, and the swallowed rejection must not surface as an + // unhandled rejection. + const extMethod = vi.fn().mockRejectedValue(new Error('Method not found')); + const sdk = { + newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), + extMethod, + }; + const conn = createConnection({ + child: createMockChild(), + sdkConnection: sdk, + sessionId: 'session-a', + }); + const acp = conn as unknown as AcpConnection; + + await expect(acp.newSession()).resolves.toMatchObject({ + sessionId: 'session-b', + }); + // Let the fire-and-forget rejection settle so an unhandled one would fail + // the run rather than leak into an unrelated later test. + await new Promise((resolve) => setImmediate(resolve)); + + expect(conn.sessionId).toBe('session-b'); + }); }); describe('AcpConnection onDisconnected callback', () => { diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 1d65d6de2ee..ca3eb2afb1a 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -37,12 +37,59 @@ import type { } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { ChildProcess, SpawnOptions } from 'child_process'; -import { spawn } from 'child_process'; +import { execFile, spawn } from 'child_process'; import { Readable, Writable } from 'node:stream'; import * as fs from 'node:fs'; import { AcpFileHandler } from './acpFileHandler.js'; import { ACP_ERROR_CODES } from '../constants/acpSchema.js'; +/** + * How long the CLI gets to shut itself down after its stdin is closed, before + * the escalation ladder starts. + * + * This has to outlast the CLI's own wind-down, or the escalation lands in the + * middle of a shutdown that is progressing correctly and skips the + * `process.on('exit')` cleanup this teardown exists to protect. On the + * ide_close path the CLI budgets 8s for the MCP pool drain + * (`shutdownMcpPool(8_000)`) plus 30s for the session drain + * (`SESSION_DRAIN_TIMEOUT_MS`), both in acpAgent.ts, plus up to 5s for + * `runExitCleanup()` (`OVERALL_CLEANUP_TIMEOUT_MS`) in the `finally` wrapping + * `runAcpAgent` (llm.tsx) — 43s bounded — so 45s covers all three stages + * that always run. SessionEnd hooks are user-configured and can still + * exceed it (`DEFAULT_HOOK_TIMEOUT` is 60s each), so the escalation stays as + * the backstop rather than being removed; on POSIX it starts with a catchable + * SIGTERM so even an over-budget hook run gets the CLI's exit-time reaper + * before the SIGKILL rung. + */ +const SHUTDOWN_GRACE_MS = 45_000; + +/** + * How long the POSIX escalation waits between the SIGTERM rung and the + * SIGKILL rung. SIGTERM triggers the CLI's `shutdownHandler`, whose remaining + * work at that point is bounded by `runExitCleanup()` + * (`OVERALL_CLEANUP_TIMEOUT_MS` = 5s) — the SessionEnd hook and MCP pool + * drain either already ran or join the in-flight ide_close ones — so 10s + * covers the cleanup ceiling plus margin. + */ +const SIGTERM_GRACE_MS = 10_000; + +// Resolve taskkill by absolute System32 path, never the bare name: on Windows +// a bare command is resolved through PATH *and* the current directory, so a +// taskkill.exe planted in the workspace would run with the extension host's +// environment. +const WINDOWS_TASKKILL = `${process.env['SystemRoot'] || 'C:\\Windows'}\\System32\\taskkill.exe`; + +// Drain budget handed to the CLI on a conditional superseded-session close, +// aligned with the daemon's sessionCloseDrainBudgetMs(10_000) = 8s. +const SUPERSEDED_CLOSE_DRAIN_MS = 8_000; + +// Backoff rungs for refused/failed superseded-session closes, aligned with +// the daemon's activeWorkCloseRetryDelayMs (bridgeTypes.ts): 60s, doubling, +// capped at 1h. Duplicated here rather than imported: the extension host +// cannot reach into the bridge package's internals. +const CLOSE_RETRY_BASE_MS = 60_000; +const CLOSE_RETRY_CEILING_MS = 3_600_000; + /** * ACP Connection Handler for VSCode Extension * @@ -57,6 +104,12 @@ export class AcpConnection { private fileHandler = new AcpFileHandler(); private lastExitCode: number | null = null; private lastExitSignal: string | null = null; + private supersededCloseRetries = new Map< + string, + { failures: number; retryAt: number } + >(); + private supersededCloseInFlight = new Set(); + private supersededCloseTimer: NodeJS.Timeout | null = null; onSessionUpdate: (data: SessionNotification) => void = () => {}; onPermissionRequest: (data: RequestPermissionRequest) => Promise<{ @@ -130,6 +183,14 @@ export class AcpConnection { stdio: ['pipe', 'pipe', 'pipe'], env, shell: false, + // A detached child becomes a process-group leader on POSIX, so the + // disconnect() escalation can signal the whole group and reach the CLI + // root and its non-detached MCP stdio children. It does NOT reach + // descendants that call setsid() — detached hook supervisors and + // monitors, and node-pty sessions — so those survive the escalation. + // Windows has no process group to signal — its tree kill goes through + // taskkill instead. + detached: process.platform !== 'win32', }; this.child = spawn(spawnCommand, spawnArgs, options); @@ -139,13 +200,26 @@ export class AcpConnection { private async setupChildProcessHandlers(): Promise { let spawnError: Error | null = null; const stderrChunks: string[] = []; + // Bind the handlers below to THIS child. `disconnect()` now lets the CLI + // wind down on its own, so a superseded child can still be exiting while + // `connect()` has already installed its replacement — and an exit handler + // that only tested `this.child` would then tear down the live connection + // and report it as disconnected. + const ownChild = this.child!; let rejectOnExit: ((error: Error) => void) | null = null; const processExitPromise = new Promise((_resolve, reject) => { rejectOnExit = reject; }); - - this.child!.stderr?.on('data', (data: Buffer) => { + // The only consumer is the Promise.race in initialize(), which attaches + // much later. A child that exits before then — a failed startup, or a + // superseded child winding down after disconnect() — would otherwise + // reject this with no handler attached, i.e. an unhandled rejection in the + // extension host. Marking it handled here changes nothing for the race, + // which still receives the original promise and still sees the rejection. + void processExitPromise.catch(() => {}); + + ownChild.stderr?.on('data', (data: Buffer) => { const message = data.toString(); stderrChunks.push(message); if ( @@ -158,11 +232,11 @@ export class AcpConnection { } }); - this.child!.on('error', (error: Error) => { + ownChild.on('error', (error: Error) => { spawnError = error; }); - this.child!.on('exit', (code: number | null, signal: string | null) => { + ownChild.on('exit', (code: number | null, signal: string | null) => { logger.error( `[ACP qwen] Process exited with code: ${code}, signal: ${signal}`, ); @@ -179,7 +253,7 @@ export class AcpConnection { ), ); - if (this.child) { + if (this.child === ownChild) { this.sdkConnection = null; this.sessionId = null; this.child = null; @@ -214,9 +288,21 @@ export class AcpConnection { const stream = ndJsonStream(stdin, stdout); // Build the SDK Client implementation that bridges to our callbacks. - this.sdkConnection = new ClientSideConnection( + // Capture the connection in a local so the inbound callbacks below can + // detect that THIS connection has been retired. disconnect() nulls both + // this.child and this.sdkConnection, then a re-connect() installs a + // replacement — but the superseded connection's stdout is still live and + // dispatching through the grace window. Comparing against the captured + // connection (not this.child, which is nulled before the grace timer and + // re-runs on the still-current child) stays correct across that window. + const wiredConnection = new ClientSideConnection( (_agent: Agent): Client => ({ sessionUpdate: (params: SessionNotification): Promise => { + if (this.sdkConnection !== wiredConnection) { + // A fire-and-forget notifier on a superseded connection must not + // re-enter callbacks that read `this.*` at call time. + return Promise.resolve(); + } this.onSessionUpdate(params as unknown as SessionNotification); return Promise.resolve(); }, @@ -224,6 +310,11 @@ export class AcpConnection { requestPermission: async ( params: RequestPermissionRequest, ): Promise => { + if (this.sdkConnection !== wiredConnection) { + throw RequestError.internalError({ + details: 'connection superseded', + }); + } const permissionData = params as unknown as RequestPermissionRequest; try { // Check if this is an ask_user_question request by inspecting rawInput @@ -310,6 +401,11 @@ export class AcpConnection { readTextFile: async ( params: ReadTextFileRequest, ): Promise => { + if (this.sdkConnection !== wiredConnection) { + throw RequestError.internalError({ + details: 'connection superseded', + }); + } try { const result = await this.fileHandler.handleReadTextFile({ path: params.path, @@ -326,6 +422,11 @@ export class AcpConnection { writeTextFile: async ( params: WriteTextFileRequest, ): Promise => { + if (this.sdkConnection !== wiredConnection) { + throw RequestError.internalError({ + details: 'connection superseded', + }); + } await this.fileHandler.handleWriteTextFile({ path: params.path, content: params.content, @@ -337,10 +438,18 @@ export class AcpConnection { extNotification: async ( method: string, params: Record, - ): Promise => this.handleExtNotification(method, params), + ): Promise => { + if (this.sdkConnection !== wiredConnection) { + // A fire-and-forget notifier on a superseded connection must not + // re-enter `this.*` callbacks; drop it instead of erroring. + return; + } + return this.handleExtNotification(method, params); + }, }), stream, ); + this.sdkConnection = wiredConnection; // Race the SDK initialize against process exit so we don't hang forever // if the CLI crashes before responding. @@ -458,15 +567,169 @@ export class AcpConnection { return response; } + /** + * The agent keeps every session alive until told otherwise, and a retained + * session still fires autonomous model turns when its background tasks + * complete — each turn can spawn shells, which is what grew conhost.exe + * without bound in #11303 while the window stayed open. Replacing the + * current session (session/new, session/load) therefore closes the + * superseded one. + * + * The close is conditional (`onlyIfUnheld`): navigation is automatic + * cleanup, not explicit destruction, so a session that still holds active + * work is refused (`{closed: false, holds}`) and is retried on a backoff + * rather than force-closed — dropping in-flight work is exactly what the + * condition protects against. Fire-and-forget either way: a refused, + * failed or unsupported (older CLI) close must never block the user's new + * session, and a later session/load of the closed id simply re-reads the + * flushed transcript. + */ + private closeSupersededSession( + previousSessionId: string | null, + nextSessionId: string | null, + ): void { + if (nextSessionId) { + // A session that is current again must not stay on the retry table. + this.supersededCloseRetries.delete(nextSessionId); + } + if (previousSessionId && previousSessionId !== nextSessionId) { + this.sendSupersededClose(previousSessionId); + } + // A replacement is also the moment to re-drive any close whose backoff + // already expired while no timer was due (the daemon equivalent is the + // next active-work snapshot). + this.driveDueSupersededCloseRetries(); + } + + private sendSupersededClose(sessionId: string): void { + // Always send on the CURRENT connection: by the time a retry fires, the + // connection the session was superseded on may have been replaced. + const conn = this.sdkConnection; + if ( + !conn || + !this.isConnected || + this.supersededCloseInFlight.has(sessionId) + ) { + return; + } + this.supersededCloseInFlight.add(sessionId); + conn + .extMethod('qwen/control/session/close', { + sessionId, + requireFlush: true, + onlyIfUnheld: true, + drainTimeoutMs: SUPERSEDED_CLOSE_DRAIN_MS, + }) + .then((result) => { + this.supersededCloseInFlight.delete(sessionId); + if (result['closed'] === true) { + this.supersededCloseRetries.delete(sessionId); + this.armSupersededCloseTimer(); + } else { + // Refused while the session still holds active work; keep it and + // probe again on the backoff rungs. + logger.warn( + '[ACP] Superseded session close was refused:', + sessionId, + result['holds'], + ); + this.scheduleSupersededCloseRetry(sessionId); + } + }) + .catch((error: unknown) => { + this.supersededCloseInFlight.delete(sessionId); + // Older CLIs have no session/close ext method; count it as a failure + // and keep retrying on the same table so the leak stays tracked. + logger.warn( + '[ACP] Failed to close superseded session:', + error instanceof Error ? error.message : String(error), + ); + this.scheduleSupersededCloseRetry(sessionId); + }); + } + + private scheduleSupersededCloseRetry(sessionId: string): void { + const failures = + (this.supersededCloseRetries.get(sessionId)?.failures ?? 0) + 1; + const delay = Math.min( + CLOSE_RETRY_BASE_MS * 2 ** (failures - 1), + CLOSE_RETRY_CEILING_MS, + ); + this.supersededCloseRetries.set(sessionId, { + failures, + retryAt: Date.now() + delay, + }); + this.armSupersededCloseTimer(); + } + + private armSupersededCloseTimer(): void { + if (this.supersededCloseTimer) { + clearTimeout(this.supersededCloseTimer); + this.supersededCloseTimer = null; + } + let earliest: number | null = null; + for (const [sessionId, entry] of this.supersededCloseRetries) { + if (this.supersededCloseInFlight.has(sessionId)) { + continue; + } + if (earliest === null || entry.retryAt < earliest) { + earliest = entry.retryAt; + } + } + if (earliest === null) { + return; + } + this.supersededCloseTimer = setTimeout( + () => { + this.supersededCloseTimer = null; + this.driveDueSupersededCloseRetries(); + }, + Math.max(earliest - Date.now(), 0), + ); + } + + private driveDueSupersededCloseRetries(): void { + if (this.supersededCloseRetries.size === 0) { + return; + } + const now = Date.now(); + for (const [sessionId, entry] of [...this.supersededCloseRetries]) { + if (entry.retryAt > now || this.supersededCloseInFlight.has(sessionId)) { + continue; + } + if (!this.isConnected || this.sessionId === sessionId) { + // The CLI is gone, or the session was reloaded onto the live + // connection and is no longer superseded. + this.supersededCloseRetries.delete(sessionId); + continue; + } + this.sendSupersededClose(sessionId); + } + this.armSupersededCloseTimer(); + } + async newSession(cwd: string = process.cwd()): Promise { const conn = this.ensureConnection(); + const previousSessionId = this.sessionId; logger.log('[ACP] Sending session/new request with cwd:', cwd); const response: NewSessionResponse = await conn.newSession({ cwd, mcpServers: [], }); + // A stale session/new can resolve after disconnect() (or a re-connect) + // retired this connection. Handing the payload back would let the caller + // apply the retired CLI's model and mode state to the live webview + // (`applySessionStateFromResult` in qwenAgentManager.ts), and writing would + // stamp the dead session's id onto the replacement connection's field, so + // fail instead — the same shape the inbound callback guards use above. + if (this.sdkConnection !== conn) { + throw RequestError.internalError({ + details: 'connection superseded', + }); + } this.sessionId = response.sessionId || null; logger.log('[ACP] Session created with ID:', this.sessionId); + this.closeSupersededSession(previousSessionId, this.sessionId); return response; } @@ -483,6 +746,12 @@ export class AcpConnection { sessionId: this.sessionId, prompt: promptBlocks, }); + // A stale prompt can resolve after disconnect() (or a re-connect) retired + // this connection. Firing onEndTurn then would clear the replacement + // session's streaming state, so bail out before touching onEndTurn. + if (this.sdkConnection !== conn) { + return response; + } // Emit end-of-turn from stopReason if (response.stopReason) { this.onEndTurn(response.stopReason); @@ -525,17 +794,16 @@ export class AcpConnection { cwdOverride?: string, ): Promise { const conn = this.ensureConnection(); + const previousSessionId = this.sessionId; logger.log('[ACP] Sending session/load request for session:', sessionId); const cwd = cwdOverride || this.workingDir; + let response: LoadSessionResponse; try { - const response = await conn.loadSession({ + response = await conn.loadSession({ sessionId, cwd, mcpServers: [], }); - logger.log('[ACP] Session load succeeded for session:', sessionId); - this.sessionId = sessionId; - return response; } catch (error) { logger.error( '[ACP] Session load request failed:', @@ -543,6 +811,23 @@ export class AcpConnection { ); throw error; } + // A stale session/load can resolve after disconnect() (or a re-connect) + // retired this connection. Handing the payload back would let the caller + // apply the retired CLI's model and mode state to the live webview + // (`applySessionStateFromResult` and `restoreBaselineSessionStateAfterLoad` + // in qwenAgentManager.ts), and writing would stamp the dead session's id + // onto the replacement connection's field, so fail instead. Checked outside + // the catch above so a supersede is not logged as a request failure, and + // before the success log so a discarded load prints no success line. + if (this.sdkConnection !== conn) { + throw RequestError.internalError({ + details: 'connection superseded', + }); + } + logger.log('[ACP] Session load succeeded for session:', sessionId); + this.sessionId = sessionId; + this.closeSupersededSession(previousSessionId, sessionId); + return response; } async listSessions(options?: { @@ -676,12 +961,136 @@ export class AcpConnection { } disconnect(): void { - if (this.child) { - this.child.kill(); - this.child = null; - } + const child = this.child; + this.child = null; this.sdkConnection = null; this.sessionId = null; + // The CLI process is going away; any pending conditional-close retry + // targets it, so drop the table instead of signalling a dead connection. + this.supersededCloseRetries.clear(); + if (this.supersededCloseTimer) { + clearTimeout(this.supersededCloseTimer); + this.supersededCloseTimer = null; + } + if (!child) { + return; + } + if (child.pid === undefined) { + return; + } + const childPid = child.pid; + + // Close the child's stdin instead of killing it. Ending the ndjson stream + // is the CLI's own shutdown path: `await connection.closed` returns, it + // fires SessionEnd hooks, drains the MCP pool, disposes its sessions and + // exits normally — so its `process.on('exit')` cleanup runs and reaps the + // PTYs, ConPTY hosts and child processes it is tracking. + // + // A bare `child.kill()` is `TerminateProcess` on Windows: none of that + // runs, and everything the CLI was tracking is orphaned until the VS Code + // window itself closes. That is the teardown half of #11303. + let graceTimer: NodeJS.Timeout | undefined; + let killTimer: NodeJS.Timeout | undefined; + child.once('exit', () => { + if (graceTimer) { + clearTimeout(graceTimer); + graceTimer = undefined; + } + if (killTimer) { + clearTimeout(killTimer); + killTimer = undefined; + } + }); + const stdin = child.stdin; + if (stdin && !stdin.destroyed && !stdin.writableEnded) { + // A late write error on a pipe whose reader is gone is reported as an + // 'error' event, and an unhandled one on an EventEmitter throws — in the + // extension host, not here. Swallow it: we are tearing this down anyway. + stdin.once('error', () => {}); + try { + stdin.end(); + } catch (error) { + logger.error( + '[ACP] Failed to close CLI stdin during disconnect:', + error, + ); + } + } + + // Escalate only if the graceful path did not land. POSIX climbs a ladder — + // SIGTERM (catchable, runs the CLI's bounded signal cleanup and its + // exit-time reaper) and only then SIGKILL — while Windows goes straight + // to the tree kill: it has no catchable terminate for console processes. + graceTimer = setTimeout(() => { + graceTimer = undefined; + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + if (process.platform === 'win32' && child.pid) { + // A tree kill is right here: at this point the CLI is unresponsive, + // so nothing else will reap the shells and ConPTY hosts underneath it. + logger.error( + `[ACP] CLI did not exit within ${SHUTDOWN_GRACE_MS}ms of stdin close; force-killing its process tree`, + ); + execFile( + WINDOWS_TASKKILL, + ['/f', '/t', '/pid', String(child.pid)], + { windowsHide: true, timeout: 2_000 }, + (error) => { + if (error) { + logger.error('[ACP] taskkill failed for the CLI tree:', error); + try { + child.kill(); + } catch { + // Already gone. + } + } + }, + ); + return; + } + // The child is detached on POSIX, so it leads its own process group: + // signalling the group reaches the CLI root and its non-detached children + // (MCP stdio servers). It does NOT reach descendants that call setsid() — + // detached hook supervisors and monitors, and node-pty sessions. + logger.error( + `[ACP] CLI did not exit within ${SHUTDOWN_GRACE_MS}ms of stdin close; sending SIGTERM to its process group`, + ); + try { + process.kill(-childPid, 'SIGTERM'); + } catch { + // The process group is already gone (or the child predates the + // detached spawn). The root signal is the fallback. + try { + child.kill('SIGTERM'); + } catch { + // Already gone. + } + } + killTimer = setTimeout(() => { + killTimer = undefined; + // Re-check before signalling: after 45+s the pid may have been + // recycled by an unrelated process group. + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + // SIGKILL also skips the CLI's own exit-time reaper + // (forceKillActivePosixHookProcesses), which is why it is the last + // rung and not the first. + logger.error( + `[ACP] CLI still alive ${SIGTERM_GRACE_MS}ms after SIGTERM; force-killing its process group`, + ); + try { + process.kill(-childPid, 'SIGKILL'); + } catch { + try { + child.kill('SIGKILL'); + } catch { + // Already gone. + } + } + }, SIGTERM_GRACE_MS); + }, SHUTDOWN_GRACE_MS); } get isConnected(): boolean { From 94c10c81029bfe5e2f32e78fc079b8122177aed6 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 12 Sep 2026 00:51:09 +0800 Subject: [PATCH 02/16] fix(acp): close shutdown and superseded-session races --- .../cli/src/acp-integration/acpAgent.test.ts | 49 ++++ packages/cli/src/acp-integration/acpAgent.ts | 99 +++++--- .../src/services/acpConnection.test.ts | 178 +++++++++++++- .../src/services/acpConnection.ts | 226 ++++++++++++------ 4 files changed, 430 insertions(+), 122 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index ccca194a387..638721d5931 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -1756,6 +1756,51 @@ describe('runAcpAgent shutdown cleanup', () => { expect(shutdownMcpPool).toHaveBeenCalledTimes(1); }); + it('SIGTERM during ide_close SessionEnd waits for the in-flight hook', async () => { + let resolveHook!: () => void; + const fireSessionEndEvent = vi.fn( + () => + new Promise((resolve) => { + resolveHook = resolve; + }), + ); + mockConfig.getHookSystem = vi.fn().mockReturnValue({ + fireSessionEndEvent, + }); + mockConfig.hasHooksForEvent = vi.fn().mockReturnValue(true); + + const { agent, agentPromise } = await startPreloadTestAgent(); + expect(agent).toBeDefined(); + const disposeSessions = vi.fn().mockResolvedValue(undefined); + const shutdownMcpPool = vi.fn().mockResolvedValue(undefined); + Object.assign(agent!, { disposeSessions, shutdownMcpPool }); + + await vi.waitFor(() => { + expect(sigTermListeners.length).toBeGreaterThan(0); + }); + + mockConnectionState.resolve(); + await vi.waitFor(() => { + expect(fireSessionEndEvent).toHaveBeenCalledWith( + SessionEndReason.PromptInputExit, + expect.any(AbortSignal), + ); + }); + + sigTermListeners[0]('SIGTERM'); + await flushImmediate(); + expect(disposeSessions).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalledWith(0); + + resolveHook(); + await agentPromise; + await vi.waitFor(() => { + expect(processExitSpy).toHaveBeenCalledWith(0); + }); + expect(disposeSessions).toHaveBeenCalledTimes(1); + expect(shutdownMcpPool).toHaveBeenCalledTimes(1); + }); + it('still exits even if runExitCleanup throws', async () => { mockRunExitCleanup.mockRejectedValueOnce(new Error('cleanup failed')); @@ -2064,6 +2109,7 @@ describe('runAcpAgent SessionEnd hooks', () => { await vi.waitFor(() => { expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith( SessionEndReason.PromptInputExit, + expect.any(AbortSignal), ); }); @@ -21256,12 +21302,15 @@ describe('QwenAgent MCP SSE/HTTP support', () => { expect(bootstrapHookSystem.fireSessionEndEvent).toHaveBeenCalledWith( SessionEndReason.PromptInputExit, + expect.any(AbortSignal), ); expect(sessionHookSystemA.fireSessionEndEvent).toHaveBeenCalledWith( SessionEndReason.PromptInputExit, + expect.any(AbortSignal), ); expect(sessionHookSystemB.fireSessionEndEvent).toHaveBeenCalledWith( SessionEndReason.PromptInputExit, + expect.any(AbortSignal), ); }); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 9db768000ad..9c9263fb174 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -2954,53 +2954,80 @@ export async function runAcpAgent( // causing the ACP process to ignore termination signals. let shuttingDown = false; let managedShutdownPromise: Promise | undefined; - let sessionEndFired = false; + let sessionEndPromise: Promise | undefined; // Helper to fire SessionEnd hook once, preventing double-fire from both // shutdown handler path and connection.closed path. - const fireSessionEndOnce = async ( + const fireSessionEndOnce = ( reason: SessionEndReason, managedConfigs?: Config[], - ) => { - if (sessionEndFired) return; - sessionEndFired = true; - - const configs = new Set(managedConfigs ?? [config]); - if (!managedConfigs) { - const sessions = agentInstance?.getActiveSessions(); - if (sessions) { - for (const session of sessions) { - const sessionConfig = session.getConfig?.(); - if (sessionConfig) { - configs.add(sessionConfig); + ): Promise => { + if (sessionEndPromise) return sessionEndPromise; + + sessionEndPromise = (async () => { + const configs = new Set(managedConfigs ?? [config]); + if (!managedConfigs) { + const sessions = agentInstance?.getActiveSessions(); + if (sessions) { + for (const session of sessions) { + const sessionConfig = session.getConfig?.(); + if (sessionConfig) { + configs.add(sessionConfig); + } } } } - } - const failures: unknown[] = []; - for (const cfg of configs) { - const hookSystem = cfg.getHookSystem?.(); - const hooksEnabled = !cfg.getDisableAllHooks?.(); - if ( - !hooksEnabled || - !hookSystem || - !cfg.hasHooksForEvent?.('SessionEnd') - ) { - continue; - } + // IDE disconnects have a bounded hook budget. The signal path still + // joins this promise when it overlaps the IDE close, so it cannot + // dispose sessions or drain the MCP pool underneath a live hook. + const ideCloseHookTimeoutMs = 30_000; + const hookAbortController = + reason === SessionEndReason.PromptInputExit + ? new AbortController() + : undefined; + const hookTimeout = hookAbortController + ? setTimeout(() => hookAbortController.abort(), ideCloseHookTimeoutMs) + : undefined; + hookTimeout?.unref(); + try { - await hookSystem.fireSessionEndEvent(reason); - } catch (err) { - if (managedConfigs) failures.push(err); - debugLogger.warn( - `SessionEnd hook failed: ${err instanceof Error ? err.message : String(err)}`, - ); + const failures: unknown[] = []; + for (const cfg of configs) { + const hookSystem = cfg.getHookSystem?.(); + const hooksEnabled = !cfg.getDisableAllHooks?.(); + if ( + !hooksEnabled || + !hookSystem || + !cfg.hasHooksForEvent?.('SessionEnd') + ) { + continue; + } + try { + if (hookAbortController) { + await hookSystem.fireSessionEndEvent( + reason, + hookAbortController.signal, + ); + } else { + await hookSystem.fireSessionEndEvent(reason); + } + } catch (err) { + if (managedConfigs) failures.push(err); + debugLogger.warn( + `SessionEnd hook failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + if (failures.length > 0) { + throw new AggregateError(failures, 'SessionEnd hook shutdown failed'); + } + } finally { + if (hookTimeout) clearTimeout(hookTimeout); } - } - if (failures.length > 0) { - throw new AggregateError(failures, 'SessionEnd hook shutdown failed'); - } + })(); + + return sessionEndPromise; }; const shutdownManagedAgent = ( diff --git a/packages/vscode-ide-companion/src/services/acpConnection.test.ts b/packages/vscode-ide-companion/src/services/acpConnection.test.ts index c4ffa4a338c..77532d2051e 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.test.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.test.ts @@ -21,10 +21,10 @@ const sdkClientFactory = vi.hoisted(() => ({ // literal here on purpose: the escalation tests step to just before and just // after the deadline, so a grace that changes without these tests changing // fails them instead of silently widening or vacating the pin. -const SHUTDOWN_GRACE_MS = 45_000; +const SHUTDOWN_GRACE_MS = 75_000; // Same pin for the second rung of the POSIX escalation ladder // (SIGTERM_GRACE_MS): SIGKILL must not land until this long after SIGTERM. -const SIGTERM_GRACE_MS = 10_000; +const SIGTERM_GRACE_MS = 45_000; // Same pin for the refused-close backoff rungs (CLOSE_RETRY_BASE_MS and // CLOSE_RETRY_CEILING_MS): 60s, doubling, capped at 1h. const CLOSE_RETRY_BASE_MS = 60_000; @@ -621,6 +621,39 @@ describe('AcpConnection child exit cleanup', () => { await Promise.resolve(); }); + it('does not wire replacement streams into a retired startup', async () => { + vi.useFakeTimers(); + try { + const oldChild = createMockChild({ + stdout: new PassThrough(), + stdin: new PassThrough(), + on: vi.fn(), + }); + const newChild = createMockChild({ + stdout: new PassThrough(), + stdin: new PassThrough(), + on: vi.fn(), + }); + const conn = createConnection({ child: oldChild }); + const setup = ( + conn as unknown as { + setupChildProcessHandlers: () => Promise; + } + ).setupChildProcessHandlers(); + const setupFailure = await expect(setup).rejects.toThrow( + /failed to start|superseded/i, + ); + + conn.child = newChild; + await vi.advanceTimersByTimeAsync(1000); + + await setupFailure; + expect(conn.sdkConnection).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + it('a live child exiting clears the connection and fires onDisconnected', async () => { // The exit-handler teardown is keyed on the connection still being // current. For the CURRENT direction (no supersede), a live child exit @@ -842,10 +875,12 @@ describe('AcpConnection child exit cleanup', () => { // both assertions below. await expect(newPromise).rejects.toMatchObject({ code: ACP_ERROR_CODES.INTERNAL_ERROR, + message: expect.stringContaining('connection superseded'), data: { details: 'connection superseded' }, }); await expect(loadPromise).rejects.toMatchObject({ code: ACP_ERROR_CODES.INTERNAL_ERROR, + message: expect.stringContaining('connection superseded'), data: { details: 'connection superseded' }, }); @@ -895,15 +930,17 @@ describe('AcpConnection child exit cleanup', () => { resolveNewSession({ sessionId: 'stale-from-retired-cli' }); resolveLoadSession({}); - // Same return-value pin as the disconnect() case, on the re-connect path: + // Same stale-result pin as the disconnect() case, on the re-connect path: // the retired CLI's payload must not reach the caller, or it is applied to // the replacement connection's live webview state. await expect(newPromise).rejects.toMatchObject({ code: ACP_ERROR_CODES.INTERNAL_ERROR, + message: expect.stringContaining('connection superseded'), data: { details: 'connection superseded' }, }); await expect(loadPromise).rejects.toMatchObject({ code: ACP_ERROR_CODES.INTERNAL_ERROR, + message: expect.stringContaining('connection superseded'), data: { details: 'connection superseded' }, }); @@ -941,8 +978,46 @@ describe('AcpConnection child exit cleanup', () => { conn.sessionId = 'session-2'; resolvePrompt({ stopReason: 'end_turn' }); - await promptPromise; + await expect(promptPromise).rejects.toMatchObject({ + code: ACP_ERROR_CODES.INTERNAL_ERROR, + message: expect.stringContaining('connection superseded'), + data: { details: 'connection superseded' }, + }); + + expect(onEndTurn).not.toHaveBeenCalled(); + }); + + it('does not fire onEndTurn when the prompt session is superseded in place', async () => { + let resolvePrompt!: (value: unknown) => void; + const sdk = { + prompt: vi.fn( + () => + new Promise((resolve) => { + resolvePrompt = resolve; + }), + ), + }; + const onEndTurn = vi.fn(); + const conn = createConnection({ + child: createMockChild(), + sdkConnection: sdk, + sessionId: 'session-a', + }); + (conn as unknown as AcpConnection).onEndTurn = onEndTurn; + const acp = conn as unknown as AcpConnection; + const promptPromise = acp.sendPrompt('hi'); + // Session replacement on the same live connection leaves the SDK object + // unchanged, so the session identity must be part of the stale-result + // guard as well. + conn.sessionId = 'session-b'; + + resolvePrompt({ stopReason: 'end_turn' }); + await expect(promptPromise).rejects.toMatchObject({ + code: ACP_ERROR_CODES.INTERNAL_ERROR, + message: expect.stringContaining('connection superseded'), + data: { details: 'connection superseded' }, + }); expect(onEndTurn).not.toHaveBeenCalled(); }); }); @@ -1140,9 +1215,8 @@ describe('AcpConnection superseded session close (#11303)', () => { await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS); expect(extMethod).toHaveBeenCalledTimes(2); - // Second refusal doubles the rung: another 120s, not 60s. - await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS); - expect(extMethod).toHaveBeenCalledTimes(2); + // A refusal is evidence that the session still has active work, not a + // transport failure, so each probe stays on the base rung. await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS); expect(extMethod).toHaveBeenCalledTimes(3); }); @@ -1178,6 +1252,44 @@ describe('AcpConnection superseded session close (#11303)', () => { expect(countClosesFor(extMethod, 'session-a')).toBe(1); }); + it('waits for an in-flight close before loading that session again', async () => { + let resolveClose!: (value: unknown) => void; + const extMethod = vi.fn( + () => + new Promise((resolve) => { + resolveClose = resolve; + }), + ); + const loadSession = vi.fn().mockResolvedValue({}); + const sdk = { + newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), + loadSession, + extMethod, + }; + const conn = createConnection({ + child: createMockChild(), + sdkConnection: sdk, + sessionId: 'session-a', + }); + const acp = conn as unknown as AcpConnection; + + await acp.newSession(); + await vi.advanceTimersByTimeAsync(0); + expect(extMethod).toHaveBeenCalledTimes(1); + + const loadPromise = acp.loadSession('session-a'); + await vi.advanceTimersByTimeAsync(0); + expect(loadSession).not.toHaveBeenCalled(); + + resolveClose({ closed: false, holds: ['running-task'] }); + await loadPromise; + expect(loadSession).toHaveBeenCalledWith({ + sessionId: 'session-a', + cwd: process.cwd(), + mcpServers: [], + }); + }); + it('stops retrying superseded closes after disconnect', async () => { const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); try { @@ -1207,6 +1319,50 @@ describe('AcpConnection superseded session close (#11303)', () => { } }); + it('clears and cancels an in-flight close when disconnect retires the connection', async () => { + let resolveClose!: (value: unknown) => void; + const extMethod = vi.fn( + () => + new Promise((resolve) => { + resolveClose = resolve; + }), + ); + const sdk = { + newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), + extMethod, + }; + const conn = createConnection({ + child: createMockChild(), + sdkConnection: sdk, + sessionId: 'session-a', + }); + const acp = conn as unknown as AcpConnection; + + await acp.newSession(); + await vi.advanceTimersByTimeAsync(0); + expect(extMethod).toHaveBeenCalledTimes(1); + expect( + (acp as unknown as { supersededCloseInFlight: Set }) + .supersededCloseInFlight.size, + ).toBe(1); + + acp.disconnect(); + expect( + (acp as unknown as { supersededCloseInFlight: Set }) + .supersededCloseInFlight.size, + ).toBe(0); + + resolveClose({ closed: false, holds: ['running-task'] }); + await vi.advanceTimersByTimeAsync(0); + expect( + ( + acp as unknown as { + supersededCloseRetries: Map; + } + ).supersededCloseRetries.size, + ).toBe(0); + }); + it('re-drives an expired close retry on the next session replacement', async () => { const extMethod = vi .fn() @@ -1241,10 +1397,10 @@ describe('AcpConnection superseded session close (#11303)', () => { expect(countClosesFor(extMethod, 'session-b')).toBe(1); }); - it('retries a close that errors (older CLI) without failing the new session', async () => { + it('does not retry an unsupported close method on an older CLI', async () => { // Old-CLI compatibility: the ext method rejects, the replacement - // session still succeeds, and the failure lands on the same retry - // table instead of being dropped. + // session still succeeds, and an operation the CLI cannot implement is + // not retried forever. const extMethod = vi .fn() .mockRejectedValue(new Error('Method not found')); @@ -1267,7 +1423,7 @@ describe('AcpConnection superseded session close (#11303)', () => { expect(conn.sessionId).toBe('session-b'); await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS); - expect(extMethod).toHaveBeenCalledTimes(2); + expect(extMethod).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index ca3eb2afb1a..bfac4ea6651 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -42,6 +42,11 @@ import { Readable, Writable } from 'node:stream'; import * as fs from 'node:fs'; import { AcpFileHandler } from './acpFileHandler.js'; import { ACP_ERROR_CODES } from '../constants/acpSchema.js'; +import { + ACTIVE_WORK_CLOSE_RETRY_BASE_MS, + ACTIVE_WORK_CLOSE_RETRY_CEILING_MS, + sessionCloseDrainBudgetMs, +} from '@qwen-code/acp-bridge/bridgeTypes'; /** * How long the CLI gets to shut itself down after its stdin is closed, before @@ -50,28 +55,22 @@ import { ACP_ERROR_CODES } from '../constants/acpSchema.js'; * This has to outlast the CLI's own wind-down, or the escalation lands in the * middle of a shutdown that is progressing correctly and skips the * `process.on('exit')` cleanup this teardown exists to protect. On the - * ide_close path the CLI budgets 8s for the MCP pool drain - * (`shutdownMcpPool(8_000)`) plus 30s for the session drain - * (`SESSION_DRAIN_TIMEOUT_MS`), both in acpAgent.ts, plus up to 5s for - * `runExitCleanup()` (`OVERALL_CLEANUP_TIMEOUT_MS`) in the `finally` wrapping - * `runAcpAgent` (llm.tsx) — 43s bounded — so 45s covers all three stages - * that always run. SessionEnd hooks are user-configured and can still - * exceed it (`DEFAULT_HOOK_TIMEOUT` is 60s each), so the escalation stays as - * the backstop rather than being removed; on POSIX it starts with a catchable - * SIGTERM so even an over-budget hook run gets the CLI's exit-time reaper - * before the SIGKILL rung. + * ide_close path SessionEnd hooks are capped at 30s, followed by the CLI's + * 8s MCP pool drain, 30s session drain, and 5s exit cleanup: 73s bounded. + * Keep a small margin above that bound. The escalation remains a backstop for + * a CLI that is genuinely wedged. */ -const SHUTDOWN_GRACE_MS = 45_000; +const SHUTDOWN_GRACE_MS = 75_000; /** * How long the POSIX escalation waits between the SIGTERM rung and the - * SIGKILL rung. SIGTERM triggers the CLI's `shutdownHandler`, whose remaining - * work at that point is bounded by `runExitCleanup()` - * (`OVERALL_CLEANUP_TIMEOUT_MS` = 5s) — the SessionEnd hook and MCP pool - * drain either already ran or join the in-flight ide_close ones — so 10s - * covers the cleanup ceiling plus margin. + * SIGKILL rung. SIGTERM triggers the CLI's `shutdownHandler`. If it overlaps + * an IDE close, the handler joins the in-flight SessionEnd hook, session + * dispose and MCP drain; otherwise it may owe the full 30s session drain, 8s + * MCP drain and 5s exit cleanup. Keep this rung above that 43s bound so + * SIGKILL remains a last resort and the CLI's exit-time reaper gets a chance. */ -const SIGTERM_GRACE_MS = 10_000; +const SIGTERM_GRACE_MS = 45_000; // Resolve taskkill by absolute System32 path, never the bare name: on Windows // a bare command is resolved through PATH *and* the current directory, so a @@ -79,16 +78,8 @@ const SIGTERM_GRACE_MS = 10_000; // environment. const WINDOWS_TASKKILL = `${process.env['SystemRoot'] || 'C:\\Windows'}\\System32\\taskkill.exe`; -// Drain budget handed to the CLI on a conditional superseded-session close, -// aligned with the daemon's sessionCloseDrainBudgetMs(10_000) = 8s. -const SUPERSEDED_CLOSE_DRAIN_MS = 8_000; - -// Backoff rungs for refused/failed superseded-session closes, aligned with -// the daemon's activeWorkCloseRetryDelayMs (bridgeTypes.ts): 60s, doubling, -// capped at 1h. Duplicated here rather than imported: the extension host -// cannot reach into the bridge package's internals. -const CLOSE_RETRY_BASE_MS = 60_000; -const CLOSE_RETRY_CEILING_MS = 3_600_000; +// Drain budget handed to the CLI on a conditional superseded-session close. +const SUPERSEDED_CLOSE_DRAIN_MS = sessionCloseDrainBudgetMs(10_000); /** * ACP Connection Handler for VSCode Extension @@ -109,7 +100,10 @@ export class AcpConnection { { failures: number; retryAt: number } >(); private supersededCloseInFlight = new Set(); + private supersededClosePromises = new Map>(); + private supersededCloseCancels = new Map void>(); private supersededCloseTimer: NodeJS.Timeout | null = null; + private connectionGeneration = 0; onSessionUpdate: (data: SessionNotification) => void = () => {}; onPermissionRequest: (data: RequestPermissionRequest) => Promise<{ @@ -267,7 +261,7 @@ export class AcpConnection { throw spawnError; } - if (!this.child || this.child.killed) { + if (this.child !== ownChild || ownChild.killed) { const code = this.lastExitCode ?? this.child?.exitCode ?? null; const signal = this.lastExitSignal; const stderrOutput = stderrChunks.join('').trim(); @@ -281,9 +275,9 @@ export class AcpConnection { // Convert Node.js child process streams to Web Streams for SDK const stdout = Readable.toWeb( - this.child.stdout!, + ownChild.stdout!, ) as ReadableStream; - const stdin = Writable.toWeb(this.child.stdin!) as WritableStream; + const stdin = Writable.toWeb(ownChild.stdin!) as WritableStream; const stream = ndJsonStream(stdin, stdout); @@ -311,9 +305,10 @@ export class AcpConnection { params: RequestPermissionRequest, ): Promise => { if (this.sdkConnection !== wiredConnection) { - throw RequestError.internalError({ - details: 'connection superseded', - }); + throw RequestError.internalError( + { details: 'connection superseded' }, + 'connection superseded', + ); } const permissionData = params as unknown as RequestPermissionRequest; try { @@ -402,9 +397,10 @@ export class AcpConnection { params: ReadTextFileRequest, ): Promise => { if (this.sdkConnection !== wiredConnection) { - throw RequestError.internalError({ - details: 'connection superseded', - }); + throw RequestError.internalError( + { details: 'connection superseded' }, + 'connection superseded', + ); } try { const result = await this.fileHandler.handleReadTextFile({ @@ -423,9 +419,10 @@ export class AcpConnection { params: WriteTextFileRequest, ): Promise => { if (this.sdkConnection !== wiredConnection) { - throw RequestError.internalError({ - details: 'connection superseded', - }); + throw RequestError.internalError( + { details: 'connection superseded' }, + 'connection superseded', + ); } await this.fileHandler.handleWriteTextFile({ path: params.path, @@ -455,7 +452,7 @@ export class AcpConnection { // if the CLI crashes before responding. logger.log('[ACP] Sending initialize request...'); const initResponse = await Promise.race([ - this.sdkConnection.initialize({ + wiredConnection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { fs: { @@ -467,6 +464,13 @@ export class AcpConnection { processExitPromise, ]); + if (this.sdkConnection !== wiredConnection || this.child !== ownChild) { + throw RequestError.internalError( + { details: 'connection superseded' }, + 'connection superseded', + ); + } + logger.log('[ACP] Initialize successful'); logger.log( '[ACP] Initialization response protocol:', @@ -569,11 +573,9 @@ export class AcpConnection { /** * The agent keeps every session alive until told otherwise, and a retained - * session still fires autonomous model turns when its background tasks - * complete — each turn can spawn shells, which is what grew conhost.exe - * without bound in #11303 while the window stayed open. Replacing the - * current session (session/new, session/load) therefore closes the - * superseded one. + * session can continue autonomous work after it leaves the foreground. + * Replacing the current session (session/new, session/load) therefore asks + * the CLI to close the superseded one. * * The close is conditional (`onlyIfUnheld`): navigation is automatic * cleanup, not explicit destruction, so a session that still holds active @@ -601,6 +603,13 @@ export class AcpConnection { this.driveDueSupersededCloseRetries(); } + private isUnsupportedSupersededCloseError(error: unknown): boolean { + return ( + (error instanceof RequestError && error.code === -32601) || + (error instanceof Error && /method not found/i.test(error.message)) + ); + } + private sendSupersededClose(sessionId: string): void { // Always send on the CURRENT connection: by the time a retry fires, the // connection the session was superseded on may have been replaced. @@ -612,19 +621,32 @@ export class AcpConnection { ) { return; } + const generation = this.connectionGeneration; this.supersededCloseInFlight.add(sessionId); - conn - .extMethod('qwen/control/session/close', { - sessionId, - requireFlush: true, - onlyIfUnheld: true, - drainTimeoutMs: SUPERSEDED_CLOSE_DRAIN_MS, - }) + let cancelClose!: () => void; + const cancelled = new Promise((resolve) => { + cancelClose = resolve; + }); + this.supersededCloseCancels.set(sessionId, cancelClose); + + const operation = Promise.resolve() + .then(() => + conn.extMethod('qwen/control/session/close', { + sessionId, + requireFlush: true, + onlyIfUnheld: true, + drainTimeoutMs: SUPERSEDED_CLOSE_DRAIN_MS, + }), + ) .then((result) => { - this.supersededCloseInFlight.delete(sessionId); + if ( + generation !== this.connectionGeneration || + this.sdkConnection !== conn + ) { + return; + } if (result['closed'] === true) { this.supersededCloseRetries.delete(sessionId); - this.armSupersededCloseTimer(); } else { // Refused while the session still holds active work; keep it and // probe again on the backoff rungs. @@ -633,27 +655,55 @@ export class AcpConnection { sessionId, result['holds'], ); - this.scheduleSupersededCloseRetry(sessionId); + this.scheduleSupersededCloseRetry(sessionId, true); } }) .catch((error: unknown) => { - this.supersededCloseInFlight.delete(sessionId); + if ( + generation !== this.connectionGeneration || + this.sdkConnection !== conn + ) { + return; + } + if (this.isUnsupportedSupersededCloseError(error)) { + // Older CLIs do not implement this optional extension method. Keep + // the replacement session usable, but do not retry an operation + // that can never succeed on this process. + this.supersededCloseRetries.delete(sessionId); + return; + } // Older CLIs have no session/close ext method; count it as a failure - // and keep retrying on the same table so the leak stays tracked. + // and keep retrying on the same table so transient failures stay + // tracked. logger.warn( '[ACP] Failed to close superseded session:', error instanceof Error ? error.message : String(error), ); this.scheduleSupersededCloseRetry(sessionId); }); + + const tracked = Promise.race([operation, cancelled]).finally(() => { + this.supersededCloseInFlight.delete(sessionId); + if (this.supersededClosePromises.get(sessionId) === tracked) { + this.supersededClosePromises.delete(sessionId); + this.supersededCloseCancels.delete(sessionId); + } + this.armSupersededCloseTimer(); + }); + this.supersededClosePromises.set(sessionId, tracked); } - private scheduleSupersededCloseRetry(sessionId: string): void { + private scheduleSupersededCloseRetry( + sessionId: string, + resetFailures = false, + ): void { const failures = - (this.supersededCloseRetries.get(sessionId)?.failures ?? 0) + 1; + (resetFailures + ? 0 + : (this.supersededCloseRetries.get(sessionId)?.failures ?? 0)) + 1; const delay = Math.min( - CLOSE_RETRY_BASE_MS * 2 ** (failures - 1), - CLOSE_RETRY_CEILING_MS, + ACTIVE_WORK_CLOSE_RETRY_BASE_MS * 2 ** (failures - 1), + ACTIVE_WORK_CLOSE_RETRY_CEILING_MS, ); this.supersededCloseRetries.set(sessionId, { failures, @@ -684,7 +734,7 @@ export class AcpConnection { this.supersededCloseTimer = null; this.driveDueSupersededCloseRetries(); }, - Math.max(earliest - Date.now(), 0), + Math.max(earliest - Date.now(), 1_000), ); } @@ -723,9 +773,10 @@ export class AcpConnection { // stamp the dead session's id onto the replacement connection's field, so // fail instead — the same shape the inbound callback guards use above. if (this.sdkConnection !== conn) { - throw RequestError.internalError({ - details: 'connection superseded', - }); + throw RequestError.internalError( + { details: 'connection superseded' }, + 'connection superseded', + ); } this.sessionId = response.sessionId || null; logger.log('[ACP] Session created with ID:', this.sessionId); @@ -735,7 +786,8 @@ export class AcpConnection { async sendPrompt(prompt: string | ContentBlock[]): Promise { const conn = this.ensureConnection(); - if (!this.sessionId) { + const promptSessionId = this.sessionId; + if (!promptSessionId) { throw new Error('No active ACP session'); } const promptBlocks = @@ -743,14 +795,17 @@ export class AcpConnection { ? [{ type: 'text' as const, text: prompt }] : prompt; const response: PromptResponse = await conn.prompt({ - sessionId: this.sessionId, + sessionId: promptSessionId, prompt: promptBlocks, }); - // A stale prompt can resolve after disconnect() (or a re-connect) retired - // this connection. Firing onEndTurn then would clear the replacement - // session's streaming state, so bail out before touching onEndTurn. - if (this.sdkConnection !== conn) { - return response; + // A stale prompt can resolve after disconnect(), re-connect(), or an + // in-place session replacement. Firing onEndTurn then would clear the + // replacement session's streaming state, so fail before touching it. + if (this.sdkConnection !== conn || this.sessionId !== promptSessionId) { + throw RequestError.internalError( + { details: 'connection superseded' }, + 'connection superseded', + ); } // Emit end-of-turn from stopReason if (response.stopReason) { @@ -795,6 +850,19 @@ export class AcpConnection { ): Promise { const conn = this.ensureConnection(); const previousSessionId = this.sessionId; + // The daemon rejects a load while its conditional close gate is active. + // Wait for that close to settle before loading the same session again; + // disconnect() resolves the tracked wait when the connection is retired. + const pendingClose = this.supersededClosePromises.get(sessionId); + if (pendingClose) { + await pendingClose; + if (this.sdkConnection !== conn) { + throw RequestError.internalError( + { details: 'connection superseded' }, + 'connection superseded', + ); + } + } logger.log('[ACP] Sending session/load request for session:', sessionId); const cwd = cwdOverride || this.workingDir; let response: LoadSessionResponse; @@ -820,9 +888,10 @@ export class AcpConnection { // the catch above so a supersede is not logged as a request failure, and // before the success log so a discarded load prints no success line. if (this.sdkConnection !== conn) { - throw RequestError.internalError({ - details: 'connection superseded', - }); + throw RequestError.internalError( + { details: 'connection superseded' }, + 'connection superseded', + ); } logger.log('[ACP] Session load succeeded for session:', sessionId); this.sessionId = sessionId; @@ -961,6 +1030,13 @@ export class AcpConnection { } disconnect(): void { + this.connectionGeneration += 1; + for (const cancel of this.supersededCloseCancels.values()) { + cancel(); + } + this.supersededCloseCancels.clear(); + this.supersededClosePromises.clear(); + this.supersededCloseInFlight.clear(); const child = this.child; this.child = null; this.sdkConnection = null; From 99a72dded7a5423fd8022ed1c5d28f4ee2407b26 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 12 Sep 2026 00:58:06 +0800 Subject: [PATCH 03/16] test(acp): pin shutdown and retry invariants --- .../src/services/acpConnection.test.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/packages/vscode-ide-companion/src/services/acpConnection.test.ts b/packages/vscode-ide-companion/src/services/acpConnection.test.ts index 77532d2051e..9bc6dede17b 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.test.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.test.ts @@ -320,6 +320,11 @@ describe('AcpConnection child exit cleanup', () => { expect(acpConn.currentSessionId).toBeNull(); }); + it('disconnect is a no-op when there is no child', () => { + const conn = createConnection({ child: null }); + expect(() => (conn as unknown as AcpConnection).disconnect()).not.toThrow(); + }); + it('disconnect closes the CLI stdin instead of killing it (#11303)', () => { // `child.kill()` is TerminateProcess on Windows: the CLI's // `process.on('exit')` cleanup never runs, so every PTY, ConPTY host and @@ -524,6 +529,26 @@ describe('AcpConnection child exit cleanup', () => { } }); + it('does not signal after exitCode or signalCode is observed', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); + try { + for (const exitInfo of [ + { exitCode: 0, signalCode: null }, + { exitCode: null, signalCode: 'SIGTERM' }, + ]) { + const conn = createConnection({ + child: createMockChild(exitInfo), + }); + (conn as unknown as AcpConnection).disconnect(); + vi.advanceTimersByTime(SHUTDOWN_GRACE_MS + SIGTERM_GRACE_MS); + } + expect(killSpy).not.toHaveBeenCalled(); + } finally { + killSpy.mockRestore(); + } + }); + it('escalates through taskkill /t on Windows, not a bare kill', () => { // Windows CI is skipped on PRs, so the platform is faked here rather than // left to whichever runner happens to execute the suite. @@ -609,10 +634,14 @@ describe('AcpConnection child exit cleanup', () => { // connect() has since replaced the child. const newChild = createMockChild(); conn.child = newChild; + conn.sdkConnection = {}; + conn.sessionId = 'replacement-session'; exitHandler?.(0, null); expect(conn.child).toBe(newChild); + expect(conn.sdkConnection).toEqual({}); + expect(conn.sessionId).toBe('replacement-session'); expect(onDisconnected).not.toHaveBeenCalled(); // The exit also rejects the promise initialize() races. Nothing has // attached to it at this point, so it must already be marked handled or @@ -779,6 +808,7 @@ describe('AcpConnection child exit cleanup', () => { stdin: new PassThrough(), on: vi.fn(), }); + const oldStdinEnd = vi.spyOn(oldChild.stdin as PassThrough, 'end'); spawnMock.mockReset(); spawnMock.mockReturnValueOnce(oldChild).mockReturnValueOnce(newChild); @@ -808,6 +838,7 @@ describe('AcpConnection child exit cleanup', () => { await second; expect(conn.child).toBe(newChild); + expect(oldStdinEnd).toHaveBeenCalledOnce(); const writeTextFile = ( oldClient as unknown as { @@ -1290,6 +1321,59 @@ describe('AcpConnection superseded session close (#11303)', () => { }); }); + it('deduplicates concurrent close attempts for one session', async () => { + let resolveClose!: (value: unknown) => void; + const extMethod = vi.fn( + () => + new Promise((resolve) => { + resolveClose = resolve; + }), + ); + const sdk = { extMethod }; + const conn = createConnection({ + child: createMockChild(), + sdkConnection: sdk, + sessionId: 'session-b', + }); + const acp = conn as unknown as AcpConnection; + const sendClose = ( + acp as unknown as { sendSupersededClose: (id: string) => void } + ).sendSupersededClose; + + sendClose.call(acp, 'session-a'); + sendClose.call(acp, 'session-a'); + await vi.advanceTimersByTimeAsync(0); + expect(extMethod).toHaveBeenCalledTimes(1); + + resolveClose({ closed: true }); + await vi.advanceTimersByTimeAsync(0); + }); + + it('caps transient close retry backoff at one hour', () => { + const conn = createConnection({ + child: createMockChild(), + sdkConnection: { extMethod: vi.fn() }, + sessionId: 'session-b', + }); + const acp = conn as unknown as AcpConnection; + const scheduleRetry = ( + acp as unknown as { + scheduleSupersededCloseRetry: (id: string) => void; + } + ).scheduleSupersededCloseRetry; + + for (let i = 0; i < 10; i += 1) { + scheduleRetry.call(acp, 'session-a'); + } + + const entry = ( + acp as unknown as { + supersededCloseRetries: Map; + } + ).supersededCloseRetries.get('session-a'); + expect(entry?.retryAt - Date.now()).toBe(CLOSE_RETRY_CEILING_MS); + }); + it('stops retrying superseded closes after disconnect', async () => { const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); try { @@ -1392,6 +1476,9 @@ describe('AcpConnection superseded session close (#11303)', () => { vi.setSystemTime(Date.now() + CLOSE_RETRY_BASE_MS + 1000); await acp.newSession(); + // closeSupersededSession() must drive the expired entry immediately; + // observe that synchronous catch-up before advancing any timers. + expect(countClosesFor(extMethod, 'session-a')).toBe(2); await vi.advanceTimersByTimeAsync(0); expect(countClosesFor(extMethod, 'session-a')).toBe(2); expect(countClosesFor(extMethod, 'session-b')).toBe(1); From 315c34a7574fe893dc9c130f85a4c9ca8c9a9520 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 12 Sep 2026 01:13:47 +0800 Subject: [PATCH 04/16] test(acp): satisfy strict companion type checks --- .../src/services/acpConnection.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/vscode-ide-companion/src/services/acpConnection.test.ts b/packages/vscode-ide-companion/src/services/acpConnection.test.ts index 9bc6dede17b..de32fe8defa 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.test.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.test.ts @@ -93,7 +93,9 @@ function createMockStdin(end = vi.fn()) { return { end, destroyed: false, writableEnded: false, once: vi.fn() }; } -function createMockChild(overrides?: Record) { +function createMockChild( + overrides?: Record, +): NonNullable { return { killed: false, exitCode: null, @@ -103,7 +105,7 @@ function createMockChild(overrides?: Record) { stdin: createMockStdin(), once: vi.fn(), ...overrides, - } as unknown as AcpConnectionInternal['child']; + } as unknown as NonNullable; } describe('AcpConnection process spawning', () => { @@ -1371,7 +1373,10 @@ describe('AcpConnection superseded session close (#11303)', () => { supersededCloseRetries: Map; } ).supersededCloseRetries.get('session-a'); - expect(entry?.retryAt - Date.now()).toBe(CLOSE_RETRY_CEILING_MS); + if (!entry) { + throw new Error('expected a retry entry'); + } + expect(entry.retryAt - Date.now()).toBe(CLOSE_RETRY_CEILING_MS); }); it('stops retrying superseded closes after disconnect', async () => { From dd585033b9b36eab4753c31e4a4a2086e0173fb0 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 12 Sep 2026 01:18:27 +0800 Subject: [PATCH 05/16] test(acp): await startup supersede assertion --- .../vscode-ide-companion/src/services/acpConnection.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vscode-ide-companion/src/services/acpConnection.test.ts b/packages/vscode-ide-companion/src/services/acpConnection.test.ts index de32fe8defa..c3d85e4ae26 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.test.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.test.ts @@ -671,7 +671,7 @@ describe('AcpConnection child exit cleanup', () => { setupChildProcessHandlers: () => Promise; } ).setupChildProcessHandlers(); - const setupFailure = await expect(setup).rejects.toThrow( + const setupFailure = expect(setup).rejects.toThrow( /failed to start|superseded/i, ); From 71c3577a9fd9955bf3c6e263fa61127ddd0402c8 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 12 Sep 2026 01:31:49 +0800 Subject: [PATCH 06/16] fix(acp): bound signal shutdown hooks --- .../cli/src/acp-integration/acpAgent.test.ts | 41 +++++++++++++++++++ packages/cli/src/acp-integration/acpAgent.ts | 37 ++++++++--------- .../src/services/acpConnection.test.ts | 4 +- .../src/services/acpConnection.ts | 12 +++--- 4 files changed, 66 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 638721d5931..41463f346ca 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -2074,6 +2074,7 @@ describe('runAcpAgent SessionEnd hooks', () => { await vi.waitFor(() => { expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith( SessionEndReason.Other, + expect.any(AbortSignal), ); }); @@ -2093,6 +2094,7 @@ describe('runAcpAgent SessionEnd hooks', () => { await vi.waitFor(() => { expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith( SessionEndReason.Other, + expect.any(AbortSignal), ); }); @@ -2173,6 +2175,7 @@ describe('runAcpAgent SessionEnd hooks', () => { await vi.waitFor(() => { expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith( SessionEndReason.Other, + expect.any(AbortSignal), ); }); @@ -2185,6 +2188,44 @@ describe('runAcpAgent SessionEnd hooks', () => { // SessionEnd should have been called exactly once expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledTimes(1); }); + + it('bounds SessionEnd hooks when SIGTERM arrives before connection.closed', async () => { + const fireSessionEndEvent = vi.fn( + (_reason: SessionEndReason, signal?: AbortSignal) => + new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(), { once: true }); + }), + ); + mockConfig.getHookSystem = vi.fn().mockReturnValue({ + fireSessionEndEvent, + }); + mockConfig.hasHooksForEvent = vi.fn().mockReturnValue(true); + + const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv); + await vi.waitFor(() => { + expect(sigTermListeners.length).toBeGreaterThan(0); + }); + + vi.useFakeTimers(); + try { + sigTermListeners[0]('SIGTERM'); + expect(fireSessionEndEvent).toHaveBeenCalledWith( + SessionEndReason.Other, + expect.any(AbortSignal), + ); + expect(mockRunExitCleanup).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(30_000); + await vi.waitFor(() => { + expect(mockRunExitCleanup).toHaveBeenCalledTimes(1); + }); + } finally { + vi.useRealTimers(); + } + + mockConnectionState.resolve(); + await agentPromise; + }); }); // --------------------------------------------------------------------------- diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 9c9263fb174..cd3f8ae6d93 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -2978,18 +2978,19 @@ export async function runAcpAgent( } } - // IDE disconnects have a bounded hook budget. The signal path still - // joins this promise when it overlaps the IDE close, so it cannot - // dispose sessions or drain the MCP pool underneath a live hook. - const ideCloseHookTimeoutMs = 30_000; - const hookAbortController = - reason === SessionEndReason.PromptInputExit - ? new AbortController() - : undefined; - const hookTimeout = hookAbortController - ? setTimeout(() => hookAbortController.abort(), ideCloseHookTimeoutMs) - : undefined; - hookTimeout?.unref(); + // Shutdown has a bounded hook budget for every entry point. The signal + // path can arrive before connection.closed (for example when the + // process receives SIGTERM directly), so leaving Other unbounded would + // let a slow hook outlive the companion's escalation window. The signal + // also lets the hook runner terminate its child process tree instead of + // merely abandoning the promise. + const sessionEndHookTimeoutMs = 30_000; + const hookAbortController = new AbortController(); + const hookTimeout = setTimeout( + () => hookAbortController.abort(), + sessionEndHookTimeoutMs, + ); + hookTimeout.unref(); try { const failures: unknown[] = []; @@ -3004,14 +3005,10 @@ export async function runAcpAgent( continue; } try { - if (hookAbortController) { - await hookSystem.fireSessionEndEvent( - reason, - hookAbortController.signal, - ); - } else { - await hookSystem.fireSessionEndEvent(reason); - } + await hookSystem.fireSessionEndEvent( + reason, + hookAbortController.signal, + ); } catch (err) { if (managedConfigs) failures.push(err); debugLogger.warn( diff --git a/packages/vscode-ide-companion/src/services/acpConnection.test.ts b/packages/vscode-ide-companion/src/services/acpConnection.test.ts index c3d85e4ae26..3d8c236ded4 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.test.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.test.ts @@ -24,7 +24,7 @@ const sdkClientFactory = vi.hoisted(() => ({ const SHUTDOWN_GRACE_MS = 75_000; // Same pin for the second rung of the POSIX escalation ladder // (SIGTERM_GRACE_MS): SIGKILL must not land until this long after SIGTERM. -const SIGTERM_GRACE_MS = 45_000; +const SIGTERM_GRACE_MS = 75_000; // Same pin for the refused-close backoff rungs (CLOSE_RETRY_BASE_MS and // CLOSE_RETRY_CEILING_MS): 60s, doubling, capped at 1h. const CLOSE_RETRY_BASE_MS = 60_000; @@ -671,7 +671,7 @@ describe('AcpConnection child exit cleanup', () => { setupChildProcessHandlers: () => Promise; } ).setupChildProcessHandlers(); - const setupFailure = expect(setup).rejects.toThrow( + const setupFailure = await expect(setup).rejects.toThrow( /failed to start|superseded/i, ); diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index bfac4ea6651..b07ea3612b4 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -64,13 +64,13 @@ const SHUTDOWN_GRACE_MS = 75_000; /** * How long the POSIX escalation waits between the SIGTERM rung and the - * SIGKILL rung. SIGTERM triggers the CLI's `shutdownHandler`. If it overlaps - * an IDE close, the handler joins the in-flight SessionEnd hook, session - * dispose and MCP drain; otherwise it may owe the full 30s session drain, 8s - * MCP drain and 5s exit cleanup. Keep this rung above that 43s bound so - * SIGKILL remains a last resort and the CLI's exit-time reaper gets a chance. + * SIGKILL rung. SIGTERM triggers the CLI's `shutdownHandler`. The handler's + * SessionEnd hooks are capped at 30s, followed by the 30s session drain, 8s + * MCP drain and 5s exit cleanup. Keep this rung above that 73s bound so + * SIGKILL remains a last resort and the CLI's exit-time reaper gets a chance, + * even when SIGTERM arrives before the normal connection-close path. */ -const SIGTERM_GRACE_MS = 45_000; +const SIGTERM_GRACE_MS = 75_000; // Resolve taskkill by absolute System32 path, never the bare name: on Windows // a bare command is resolved through PATH *and* the current directory, so a From bbe224cc355916e1a039f4f6b3cd06ba5d3d8809 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 12 Sep 2026 01:37:13 +0800 Subject: [PATCH 07/16] docs(acp): update escalation timing comment --- packages/vscode-ide-companion/src/services/acpConnection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index b07ea3612b4..21c9e367a33 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -1145,7 +1145,7 @@ export class AcpConnection { } killTimer = setTimeout(() => { killTimer = undefined; - // Re-check before signalling: after 45+s the pid may have been + // Re-check before signalling: after 75+s the pid may have been // recycled by an unrelated process group. if (child.exitCode !== null || child.signalCode !== null) { return; From 2c835b3d7b1de72d042a992c4832bd45655ec328 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 12 Sep 2026 11:32:05 +0800 Subject: [PATCH 08/16] fix(vscode): narrow ACP teardown to graceful shutdown --- .../e2e-tests/vscode-acp-graceful-shutdown.md | 21 + docs/design/vscode-acp-graceful-shutdown.md | 30 + .../vscode-acp-graceful-shutdown.zh-CN.md | 30 + .../cli/src/acp-integration/acpAgent.test.ts | 143 +- packages/cli/src/acp-integration/acpAgent.ts | 105 +- packages/cli/src/utils/cleanup.test.ts | 20 + packages/cli/src/utils/cleanup.ts | 14 +- .../src/services/acpConnection.test.ts | 1380 ++--------------- .../src/services/acpConnection.ts | 419 +---- 9 files changed, 350 insertions(+), 1812 deletions(-) create mode 100644 .qwen/e2e-tests/vscode-acp-graceful-shutdown.md create mode 100644 docs/design/vscode-acp-graceful-shutdown.md create mode 100644 docs/design/vscode-acp-graceful-shutdown.zh-CN.md diff --git a/.qwen/e2e-tests/vscode-acp-graceful-shutdown.md b/.qwen/e2e-tests/vscode-acp-graceful-shutdown.md new file mode 100644 index 00000000000..dcdc6a86089 --- /dev/null +++ b/.qwen/e2e-tests/vscode-acp-graceful-shutdown.md @@ -0,0 +1,21 @@ +# VS Code ACP graceful shutdown E2E plan + +## Scenario + +1. Start a VS Code Companion chat that launches an MCP stdio child. +2. Close or reload the companion while the chat is idle. +3. Confirm the ACP CLI exits normally and its MCP child does not remain alive. +4. Repeat with an intentionally unresponsive ACP child and confirm the host escalates only after the graceful deadline. +5. Reopen the companion during teardown and confirm the replacement connection remains usable after the old child exits. + +## Expected result + +- Ordinary teardown reaches the CLI cleanup path before the host uses a forced termination. +- No old child exit, notification, or response clears the replacement connection. +- POSIX escalation targets the ACP process group; Windows escalation targets the process tree. + +## Automated coverage + +The focused companion and CLI tests cover the shutdown ladder, replacement races, overlapping shutdown phases, and hook deadline. Native Windows process-tree behavior and a real VS Code host remain manual validation boundaries. + +The global `qwen` CLI cannot reproduce the companion-owned child lifecycle, so a standalone CLI dry run is not a faithful baseline for this scenario. diff --git a/docs/design/vscode-acp-graceful-shutdown.md b/docs/design/vscode-acp-graceful-shutdown.md new file mode 100644 index 00000000000..09a45ae775e --- /dev/null +++ b/docs/design/vscode-acp-graceful-shutdown.md @@ -0,0 +1,30 @@ +# VS Code ACP graceful shutdown + +[中文版](./vscode-acp-graceful-shutdown.zh-CN.md) + +## Problem + +The VS Code companion terminated its ACP child immediately when the panel disconnected. On Windows this bypassed the CLI shutdown path and could leave shells and ConPTY descendants alive. A graceful child can also overlap a replacement connection, so an old exit or response must not clear state owned by the replacement. + +## Decision + +The companion closes the ACP child's stdin first. The CLI treats the closed transport as a normal shutdown, fires SessionEnd hooks, drains MCP clients, disposes sessions, and runs process cleanup. + +Shutdown is bounded: + +- On POSIX, the ACP child leads a process group. After 75 seconds the companion sends SIGTERM to that group, then SIGKILL after another 75 seconds. +- On Windows, after 75 seconds the companion invokes the absolute System32 `taskkill.exe` path with `/f /t`. +- Exit handlers and asynchronous responses are tied to the child and connection that created them, so a retired process cannot mutate a replacement connection. +- Overlapping EOF and signal shutdown paths share the same SessionEnd, MCP drain, session-disposal, and registered process-cleanup work. SessionEnd hooks start concurrently and share a 30-second abort budget. + +## Scope + +This change covers ACP process teardown and the connection races introduced by graceful teardown. It does not close sessions when the user switches conversations and does not change hook process-tree ownership, which is handled separately. + +## Verification + +- Disconnect closes stdin before any forced termination. +- POSIX and Windows escalation target the complete child tree. +- A normal child exit cancels escalation. +- A retired child or response cannot clear or update its replacement. +- EOF and signal shutdown execute each cleanup phase once, and all SessionEnd hooks begin within the shared deadline. diff --git a/docs/design/vscode-acp-graceful-shutdown.zh-CN.md b/docs/design/vscode-acp-graceful-shutdown.zh-CN.md new file mode 100644 index 00000000000..d68d6a719f5 --- /dev/null +++ b/docs/design/vscode-acp-graceful-shutdown.zh-CN.md @@ -0,0 +1,30 @@ +# VS Code ACP 优雅退出 + +[English](./vscode-acp-graceful-shutdown.md) + +## 问题 + +VS Code companion 在面板断开时会立即终止 ACP 子进程。Windows 上这种方式会绕过 CLI 的退出流程,可能遗留 shell 和 ConPTY 后代进程。优雅退出期间旧进程还可能与新连接短暂并存,因此旧进程的退出事件或异步响应不能清理新连接持有的状态。 + +## 决策 + +companion 首先关闭 ACP 子进程的 stdin。CLI 将传输关闭作为正常退出处理,触发 SessionEnd hooks、排空 MCP 客户端、释放会话并执行进程清理。 + +退出流程有明确上限: + +- POSIX 上 ACP 子进程作为进程组组长。75 秒后 companion 向该进程组发送 SIGTERM,再等待 75 秒后发送 SIGKILL。 +- Windows 上等待 75 秒后,companion 使用 System32 下的绝对 `taskkill.exe` 路径和 `/f /t` 参数终止进程树。 +- 退出处理器和异步响应绑定到创建它们的子进程与连接,已退役进程不能修改替代连接。 +- EOF 与信号触发的重叠退出共享同一次 SessionEnd、MCP 排空、会话释放和已注册进程清理。所有 SessionEnd hooks 并发启动,并共享 30 秒的中止时限。 + +## 范围 + +本次只处理 ACP 进程退出,以及优雅退出引入的连接竞态。不处理用户切换会话时关闭旧会话,也不改变 hook 进程树的归属;后者由独立改动处理。 + +## 验证 + +- 断开连接时先关闭 stdin,不立即强制终止。 +- POSIX 与 Windows 的升级路径都覆盖完整子进程树。 +- 子进程正常退出后取消升级计时器。 +- 已退役子进程的退出或响应不能清理或更新替代连接。 +- EOF 与信号重叠时每个清理阶段只执行一次,且所有 SessionEnd hooks 都在共享时限内启动。 diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 2265e01fc07..64917d28cdf 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -1686,98 +1686,7 @@ describe('runAcpAgent shutdown cleanup', () => { await agentPromise; }); - it('SIGTERM during ide_close drain runs shutdownMcpPool once', async () => { - // The VS Code extension escalates to SIGTERM once its shutdown grace - // expires; when that lands mid-ide_close, the signal path must join the - // in-flight MCP pool drain instead of running shutdownMcpPool again. - const { agent, agentPromise } = await startPreloadTestAgent(); - expect(agent).toBeDefined(); - - let resolveDrain!: () => void; - const shutdownMcpPool = vi.fn( - () => - new Promise((resolve) => { - resolveDrain = resolve; - }), - ); - const disposeSessions = vi.fn().mockResolvedValue(undefined); - Object.assign(agent!, { shutdownMcpPool, disposeSessions }); - - await vi.waitFor(() => { - expect(sigTermListeners.length).toBeGreaterThan(0); - }); - - // connection.closed resolving drives the ide_close path, which parks on - // the drain held open here. - mockConnectionState.resolve(); - await vi.waitFor(() => { - expect(shutdownMcpPool).toHaveBeenCalledTimes(1); - }); - - sigTermListeners[0]('SIGTERM'); - // The signal path passes disposeSessions and then joins the parked drain; - // its runExitCleanup can only run once the shared drain settles. - await vi.waitFor(() => { - expect(disposeSessions).toHaveBeenCalledTimes(1); - }); - expect(shutdownMcpPool).toHaveBeenCalledTimes(1); - expect(mockRunExitCleanup).not.toHaveBeenCalled(); - - resolveDrain(); - await agentPromise; - await vi.waitFor(() => { - expect(processExitSpy).toHaveBeenCalledWith(0); - }); - - expect(shutdownMcpPool).toHaveBeenCalledTimes(1); - expect(disposeSessions).toHaveBeenCalledTimes(1); - expect(mockRunExitCleanup).toHaveBeenCalledTimes(1); - }); - - it('SIGTERM during ide_close disposes sessions once', async () => { - // Same overlap, one step later: the signal path must join the in-flight - // disposeSessions instead of snapshotting the same sessions and running - // closeStoredSession (beginClose/abort) for each a second time. - const { agent, agentPromise } = await startPreloadTestAgent(); - expect(agent).toBeDefined(); - - const shutdownMcpPool = vi.fn().mockResolvedValue(undefined); - let resolveDispose!: () => void; - const disposeSessions = vi.fn( - () => - new Promise((resolve) => { - resolveDispose = resolve; - }), - ); - Object.assign(agent!, { shutdownMcpPool, disposeSessions }); - - await vi.waitFor(() => { - expect(sigTermListeners.length).toBeGreaterThan(0); - }); - - // ide_close drains (the mock resolves immediately) and then parks on the - // dispose held open here. - mockConnectionState.resolve(); - await vi.waitFor(() => { - expect(disposeSessions).toHaveBeenCalledTimes(1); - }); - - sigTermListeners[0]('SIGTERM'); - await flushImmediate(); - expect(disposeSessions).toHaveBeenCalledTimes(1); - expect(mockRunExitCleanup).not.toHaveBeenCalled(); - - resolveDispose(); - await agentPromise; - await vi.waitFor(() => { - expect(processExitSpy).toHaveBeenCalledWith(0); - }); - - expect(disposeSessions).toHaveBeenCalledTimes(1); - expect(shutdownMcpPool).toHaveBeenCalledTimes(1); - }); - - it('SIGTERM during ide_close SessionEnd waits for the in-flight hook', async () => { + it('shares in-flight cleanup when SIGTERM overlaps an IDE close', async () => { let resolveHook!: () => void; const fireSessionEndEvent = vi.fn( () => @@ -1789,16 +1698,11 @@ describe('runAcpAgent shutdown cleanup', () => { fireSessionEndEvent, }); mockConfig.hasHooksForEvent = vi.fn().mockReturnValue(true); - const { agent, agentPromise } = await startPreloadTestAgent(); expect(agent).toBeDefined(); - const disposeSessions = vi.fn().mockResolvedValue(undefined); const shutdownMcpPool = vi.fn().mockResolvedValue(undefined); - Object.assign(agent!, { disposeSessions, shutdownMcpPool }); - - await vi.waitFor(() => { - expect(sigTermListeners.length).toBeGreaterThan(0); - }); + const disposeSessions = vi.fn().mockResolvedValue(undefined); + Object.assign(agent!, { shutdownMcpPool, disposeSessions }); mockConnectionState.resolve(); await vi.waitFor(() => { @@ -1807,19 +1711,17 @@ describe('runAcpAgent shutdown cleanup', () => { expect.any(AbortSignal), ); }); - sigTermListeners[0]('SIGTERM'); await flushImmediate(); expect(disposeSessions).not.toHaveBeenCalled(); - expect(processExitSpy).not.toHaveBeenCalledWith(0); resolveHook(); await agentPromise; - await vi.waitFor(() => { - expect(processExitSpy).toHaveBeenCalledWith(0); - }); - expect(disposeSessions).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(processExitSpy).toHaveBeenCalledWith(0)); + + expect(fireSessionEndEvent).toHaveBeenCalledTimes(1); expect(shutdownMcpPool).toHaveBeenCalledTimes(1); + expect(disposeSessions).toHaveBeenCalledTimes(1); }); it('still exits even if runExitCleanup throws', async () => { @@ -2210,32 +2112,24 @@ describe('runAcpAgent SessionEnd hooks', () => { expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledTimes(1); }); - it('bounds SessionEnd hooks when SIGTERM arrives before connection.closed', async () => { - const fireSessionEndEvent = vi.fn( + it('aborts a SessionEnd hook after the shutdown budget', async () => { + mockHookSystem.fireSessionEndEvent.mockImplementation( (_reason: SessionEndReason, signal?: AbortSignal) => new Promise((resolve) => { signal?.addEventListener('abort', () => resolve(), { once: true }); }), ); - mockConfig.getHookSystem = vi.fn().mockReturnValue({ - fireSessionEndEvent, - }); - mockConfig.hasHooksForEvent = vi.fn().mockReturnValue(true); - const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv); - await vi.waitFor(() => { - expect(sigTermListeners.length).toBeGreaterThan(0); - }); + await vi.waitFor(() => expect(sigTermListeners.length).toBeGreaterThan(0)); vi.useFakeTimers(); try { sigTermListeners[0]('SIGTERM'); - expect(fireSessionEndEvent).toHaveBeenCalledWith( + await Promise.resolve(); + expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith( SessionEndReason.Other, expect.any(AbortSignal), ); - expect(mockRunExitCleanup).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(30_000); await vi.waitFor(() => { expect(mockRunExitCleanup).toHaveBeenCalledTimes(1); @@ -21347,8 +21241,14 @@ describe('QwenAgent MCP SSE/HTTP support', () => { .mockImplementation((event: string) => event === 'SessionEnd'); const innerConfigA = await setupSessionMocks('session-end-a'); + let resolveSessionEndA!: () => void; const sessionHookSystemA = { - fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), + fireSessionEndEvent: vi.fn( + () => + new Promise((resolve) => { + resolveSessionEndA = resolve; + }), + ), fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), }; innerConfigA.getHookSystem = vi.fn().mockReturnValue(sessionHookSystemA); @@ -21413,6 +21313,11 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agent.newSession({ cwd: '/tmp', mcpServers: [] }); mockConnectionState.resolve(); + await vi.waitFor(() => { + expect(sessionHookSystemA.fireSessionEndEvent).toHaveBeenCalled(); + expect(sessionHookSystemB.fireSessionEndEvent).toHaveBeenCalled(); + }); + resolveSessionEndA(); await agentPromise; expect(bootstrapHookSystem.fireSessionEndEvent).toHaveBeenCalledWith( diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index e44b9268f78..8740bd4ec97 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -2911,41 +2911,26 @@ export async function runAcpAgent( // Both the SIGTERM handler and the IDE-initiated close path need // to drain the MCP pool before runExitCleanup. Single helper - // closure keeps the timeout + log labels consistent. The memoized - // promise makes it idempotent: a SIGTERM landing mid-ide_close (the - // VS Code extension escalates to SIGTERM once its shutdown grace - // expires) joins the in-flight drain instead of running - // shutdownMcpPool a second time. First call wins; later calls — - // including a stricter one — join it. + // closure keeps the timeout + log labels consistent. let drainPoolPromise: Promise | undefined; const drainPoolBeforeExit = async ( label: string, strict = false, ): Promise => { if (!agentInstance) return; - if (drainPoolPromise) return drainPoolPromise; - drainPoolPromise = (async () => { - try { - await agentInstance?.shutdownMcpPool(8_000); - } catch (err) { - debugLogger.error(`[ACP] MCP pool drain (${label}) error:`, err); - if (strict) throw err; - } - })(); - return drainPoolPromise; + try { + drainPoolPromise ??= agentInstance.shutdownMcpPool(8_000); + await drainPoolPromise; + } catch (err) { + debugLogger.error(`[ACP] MCP pool drain (${label}) error:`, err); + if (strict) throw err; + } }; - // disposeSessions() is idempotent per call but not under concurrency: two - // overlapping calls snapshot the same session entries and each runs - // closeStoredSession for them (double beginClose, double abort). SIGTERM - // landing mid-ide_close is exactly that overlap, so both shutdown paths - // share one in-flight dispose. let disposeSessionsPromise: Promise | undefined; const disposeSessionsOnce = (): Promise => { if (!agentInstance) return Promise.resolve(); - if (!disposeSessionsPromise) { - disposeSessionsPromise = agentInstance.disposeSessions(); - } + disposeSessionsPromise ??= agentInstance.disposeSessions(); return disposeSessionsPromise; }; @@ -2979,52 +2964,46 @@ export async function runAcpAgent( } } - // Shutdown has a bounded hook budget for every entry point. The signal - // path can arrive before connection.closed (for example when the - // process receives SIGTERM directly), so leaving Other unbounded would - // let a slow hook outlive the companion's escalation window. The signal - // also lets the hook runner terminate its child process tree instead of - // merely abandoning the promise. - const sessionEndHookTimeoutMs = 30_000; - const hookAbortController = new AbortController(); - const hookTimeout = setTimeout( - () => hookAbortController.abort(), - sessionEndHookTimeoutMs, - ); - hookTimeout.unref(); - + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30_000); + timeout.unref(); try { - const failures: unknown[] = []; - for (const cfg of configs) { - const hookSystem = cfg.getHookSystem?.(); - const hooksEnabled = !cfg.getDisableAllHooks?.(); - if ( - !hooksEnabled || - !hookSystem || - !cfg.hasHooksForEvent?.('SessionEnd') - ) { - continue; - } - try { - await hookSystem.fireSessionEndEvent( - reason, - hookAbortController.signal, - ); - } catch (err) { - if (managedConfigs) failures.push(err); - debugLogger.warn( - `SessionEnd hook failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } + const results = await Promise.allSettled( + [...configs].flatMap((cfg) => { + const hookSystem = cfg.getHookSystem?.(); + if ( + cfg.getDisableAllHooks?.() || + !hookSystem || + typeof hookSystem.fireSessionEndEvent !== 'function' || + !cfg.hasHooksForEvent?.('SessionEnd') + ) { + return []; + } + return [ + Promise.resolve().then(() => + hookSystem.fireSessionEndEvent(reason, controller.signal), + ), + ]; + }), + ); + const failures = results + .filter( + (result): result is PromiseRejectedResult => + result.status === 'rejected', + ) + .map((result) => result.reason); + for (const failure of failures) { + debugLogger.warn( + `SessionEnd hook failed: ${failure instanceof Error ? failure.message : String(failure)}`, + ); } - if (failures.length > 0) { + if (managedConfigs && failures.length > 0) { throw new AggregateError(failures, 'SessionEnd hook shutdown failed'); } } finally { - if (hookTimeout) clearTimeout(hookTimeout); + clearTimeout(timeout); } })(); - return sessionEndPromise; }; diff --git a/packages/cli/src/utils/cleanup.test.ts b/packages/cli/src/utils/cleanup.test.ts index 80f2fe5afba..d5280017981 100644 --- a/packages/cli/src/utils/cleanup.test.ts +++ b/packages/cli/src/utils/cleanup.test.ts @@ -52,6 +52,26 @@ describe('cleanup', () => { expect(asyncFn).toHaveBeenCalledTimes(1); }); + it('shares an in-flight cleanup pass between concurrent callers', async () => { + let finishCleanup: (() => void) | undefined; + const cleanupFn = vi.fn( + () => + new Promise((resolve) => { + finishCleanup = resolve; + }), + ); + registerCleanup(cleanupFn); + + const first = runExitCleanup(); + await vi.waitFor(() => expect(cleanupFn).toHaveBeenCalledOnce()); + const second = runExitCleanup(); + + expect(second).toBe(first); + finishCleanup?.(); + await Promise.all([first, second]); + expect(cleanupFn).toHaveBeenCalledOnce(); + }); + it('should let a caller unregister a cleanup', async () => { const cleanupFn = vi.fn(); const unregister = registerCleanup(cleanupFn); diff --git a/packages/cli/src/utils/cleanup.ts b/packages/cli/src/utils/cleanup.ts index 8a0c28e68de..4e092745fe9 100644 --- a/packages/cli/src/utils/cleanup.ts +++ b/packages/cli/src/utils/cleanup.ts @@ -8,6 +8,7 @@ import { promises as fs } from 'node:fs'; import { join } from 'node:path'; const cleanupFunctions: Array<(() => void) | (() => Promise)> = []; +let exitCleanupPromise: Promise | undefined; export function registerCleanup( fn: (() => void) | (() => Promise), @@ -65,8 +66,19 @@ export interface RunExitCleanupOptions { _testOverallTimeoutMs?: number; } -export async function runExitCleanup( +export function runExitCleanup( options: RunExitCleanupOptions = {}, +): Promise { + if (exitCleanupPromise) return exitCleanupPromise; + const cleanup = runExitCleanupPass(options).finally(() => { + if (exitCleanupPromise === cleanup) exitCleanupPromise = undefined; + }); + exitCleanupPromise = cleanup; + return cleanup; +} + +async function runExitCleanupPass( + options: RunExitCleanupOptions, ): Promise { const perFn = options._testPerFnTimeoutMs ?? PER_CLEANUP_TIMEOUT_MS; const overall = options._testOverallTimeoutMs ?? OVERALL_CLEANUP_TIMEOUT_MS; diff --git a/packages/vscode-ide-companion/src/services/acpConnection.test.ts b/packages/vscode-ide-companion/src/services/acpConnection.test.ts index 3d8c236ded4..3f6aa331a18 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.test.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.test.ts @@ -5,30 +5,16 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Mock } from 'vitest'; import { RequestError } from '@agentclientprotocol/sdk'; -import type { ContentBlock } from '@agentclientprotocol/sdk'; -import { PassThrough } from 'node:stream'; -import { logger } from '../utils/logger.js'; +import type { + ContentBlock, + LoadSessionResponse, + NewSessionResponse, + PromptResponse, +} from '@agentclientprotocol/sdk'; const spawnMock = vi.hoisted(() => vi.fn()); const execFileMock = vi.hoisted(() => vi.fn()); -const sdkClientFactory = vi.hoisted(() => ({ - factory: null as null | ((agent: unknown) => Record), -})); - -// Mirrors the module-private SHUTDOWN_GRACE_MS in acpConnection.ts. Kept as a -// literal here on purpose: the escalation tests step to just before and just -// after the deadline, so a grace that changes without these tests changing -// fails them instead of silently widening or vacating the pin. -const SHUTDOWN_GRACE_MS = 75_000; -// Same pin for the second rung of the POSIX escalation ladder -// (SIGTERM_GRACE_MS): SIGKILL must not land until this long after SIGTERM. -const SIGTERM_GRACE_MS = 75_000; -// Same pin for the refused-close backoff rungs (CLOSE_RETRY_BASE_MS and -// CLOSE_RETRY_CEILING_MS): 60s, doubling, capped at 1h. -const CLOSE_RETRY_BASE_MS = 60_000; -const CLOSE_RETRY_CEILING_MS = 3_600_000; // AcpConnection imports AcpFileHandler which imports vscode. // Mock vscode so it can be resolved without the actual VS Code runtime. @@ -37,42 +23,29 @@ vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, spawn: spawnMock, execFile: execFileMock }; }); -vi.mock('@agentclientprotocol/sdk', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - // Capture the Client factory so tests can drive the inbound callbacks - // (sessionUpdate / writeTextFile / ...) that guard against a superseded - // connection. The real SDK would hold this factory internally. - ClientSideConnection: class { - constructor(factory: (agent: unknown) => Record) { - sdkClientFactory.factory = factory; - } - initialize = vi.fn().mockResolvedValue({ protocolVersion: '1.0' }); - }, - ndJsonStream: () => ({}), - }; -}); import { AcpConnection } from './acpConnection.js'; import { ACP_ERROR_CODES } from '../constants/acpSchema.js'; -type AcpConnectionInternal = { - child: { - killed: boolean; - exitCode: number | null; - signalCode?: string | null; - pid?: number; - kill?: () => void; - stdin?: { - end: () => void; - destroyed?: boolean; - writableEnded?: boolean; - once?: (event: string, listener: () => void) => unknown; - } | null; - once?: (event: string, listener: () => void) => unknown; +type MockChild = { + killed: boolean; + exitCode: number | null; + signalCode?: NodeJS.Signals | null; + pid?: number; + kill?: (signal?: NodeJS.Signals) => boolean; + stdin?: { + destroyed?: boolean; + writableEnded?: boolean; + end: () => void; + once: (event: string, listener: () => void) => unknown; } | null; + stderr?: { on: (event: string, listener: (data: Buffer) => void) => unknown }; + on?: (event: string, listener: (...args: unknown[]) => void) => unknown; + once?: (event: string, listener: () => void) => unknown; +}; + +type AcpConnectionInternal = { + child: MockChild | null; sdkConnection: unknown; sessionId: string | null; lastExitCode: number | null; @@ -89,23 +62,22 @@ function createConnection(overrides?: Partial) { return conn; } -function createMockStdin(end = vi.fn()) { - return { end, destroyed: false, writableEnded: false, once: vi.fn() }; -} - -function createMockChild( - overrides?: Record, -): NonNullable { +function createMockChild(overrides?: Record) { return { killed: false, exitCode: null, signalCode: null, pid: 4242, - kill: vi.fn(), - stdin: createMockStdin(), + kill: vi.fn().mockReturnValue(true), + stdin: { + destroyed: false, + writableEnded: false, + end: vi.fn(), + once: vi.fn(), + }, once: vi.fn(), ...overrides, - } as unknown as NonNullable; + } as MockChild; } describe('AcpConnection process spawning', () => { @@ -132,38 +104,21 @@ describe('AcpConnection process spawning', () => { } }); - it('spawns the child detached on POSIX but not on Windows', async () => { - // The POSIX escalation is a process-group signal (process.kill(-pid, - // ...)): it reaches the CLI's whole group only because the child is - // spawned detached and so leads its own group. Windows has no signalable - // group — its tree kill goes through taskkill, and there `detached` only - // changes console attachment. Pin both sides so a future refactor of the - // options object cannot silently turn the group signal into a root-only - // one that orphans every descendant (the #11303 leak). - spawnMock.mockClear(); + it('creates a POSIX process group for shutdown escalation', async () => { spawnMock.mockReturnValue(createMockChild()); - const makeConn = () => { - const conn = new AcpConnection() as unknown as { - connect: (cliEntryPath: string) => Promise; - setupChildProcessHandlers: () => Promise; - }; - conn.setupChildProcessHandlers = vi.fn().mockResolvedValue(undefined); - return conn; + const conn = new AcpConnection() as unknown as { + connect: (cliEntryPath: string) => Promise; + setupChildProcessHandlers: () => Promise; }; - const platform = vi.spyOn(process, 'platform', 'get'); - try { - platform.mockReturnValue('linux'); - await makeConn().connect(process.execPath); - platform.mockReturnValue('win32'); - await makeConn().connect(process.execPath); + conn.setupChildProcessHandlers = vi.fn().mockResolvedValue(undefined); - const detachedAt = (i: number) => - (spawnMock.mock.calls[i]?.[2] as { detached?: boolean }).detached; - expect(detachedAt(0)).toBe(true); - expect(detachedAt(1)).toBe(false); - } finally { - platform.mockRestore(); - } + await conn.connect(process.execPath); + + expect(spawnMock).toHaveBeenCalledWith( + process.execPath, + expect.any(Array), + expect.objectContaining({ detached: process.platform !== 'win32' }), + ); }); }); @@ -298,8 +253,8 @@ describe('AcpConnection.ensureConnection', () => { describe('AcpConnection child exit cleanup', () => { beforeEach(() => { - execFileMock.mockReset(); vi.useFakeTimers(); + execFileMock.mockReset(); }); afterEach(() => { @@ -322,1227 +277,176 @@ describe('AcpConnection child exit cleanup', () => { expect(acpConn.currentSessionId).toBeNull(); }); - it('disconnect is a no-op when there is no child', () => { - const conn = createConnection({ child: null }); - expect(() => (conn as unknown as AcpConnection).disconnect()).not.toThrow(); - }); - - it('disconnect closes the CLI stdin instead of killing it (#11303)', () => { - // `child.kill()` is TerminateProcess on Windows: the CLI's - // `process.on('exit')` cleanup never runs, so every PTY, ConPTY host and - // child process it is tracking is orphaned. Ending stdin closes the ACP - // stream, which is the CLI's own graceful shutdown path. + it('disconnect closes stdin before escalating', () => { const mockKill = vi.fn(); - const mockEnd = vi.fn(); + const end = vi.fn(); const conn = createConnection({ child: createMockChild({ kill: mockKill, - stdin: createMockStdin(mockEnd), + stdin: { + destroyed: false, + writableEnded: false, + end, + once: vi.fn(), + }, }), sdkConnection: {}, sessionId: 'test-session', }); (conn as unknown as AcpConnection).disconnect(); - - expect(mockEnd).toHaveBeenCalledOnce(); - expect(mockKill).not.toHaveBeenCalled(); - }); - - it('does not force-kill a child that failed to spawn', () => { - const mockKill = vi.fn(); - const conn = createConnection({ - child: createMockChild({ kill: mockKill, pid: undefined }), - }); - - (conn as unknown as AcpConnection).disconnect(); - vi.advanceTimersByTime(SHUTDOWN_GRACE_MS + SIGTERM_GRACE_MS); - - expect(execFileMock).not.toHaveBeenCalled(); + expect(end).toHaveBeenCalledOnce(); expect(mockKill).not.toHaveBeenCalled(); }); - it('does not end stdin that is already closed', () => { - // Even when stdin cannot be ended (already closed), the escalation timer - // must still be armed: an early return here would leave the CLI's process - // group running forever. Assert the escalation ladder still climbs past - // the grace. - vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); - const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); + it('disconnect closes stdin even when the child has no pid', () => { const end = vi.fn(); const conn = createConnection({ child: createMockChild({ - stdin: { ...createMockStdin(end), writableEnded: true }, - }), - }); - - (conn as unknown as AcpConnection).disconnect(); - - expect(end).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(SHUTDOWN_GRACE_MS); - expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGTERM'); - vi.advanceTimersByTime(SIGTERM_GRACE_MS); - expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGKILL'); - }); - - it('handles a synchronous stdin close failure', () => { - // A synchronous stdin.end() failure (e.g. EPIPE) must not short-circuit - // disconnect(): the escalation timer still has to be armed, or a failing - // stdin close leaves the CLI's process group running forever. - vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); - const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); - const closeError = new Error('EPIPE'); - const once = vi.fn(); - const logError = vi.spyOn(logger, 'error').mockImplementation(() => {}); - const conn = createConnection({ - child: createMockChild({ + pid: undefined, stdin: { - ...createMockStdin( - vi.fn(() => { - throw closeError; - }), - ), - once, + destroyed: false, + writableEnded: false, + end, + once: vi.fn(), }, }), }); - expect(() => (conn as unknown as AcpConnection).disconnect()).not.toThrow(); - expect(once).toHaveBeenCalledWith('error', expect.any(Function)); - expect(logError).toHaveBeenCalledWith( - '[ACP] Failed to close CLI stdin during disconnect:', - closeError, - ); - - vi.advanceTimersByTime(SHUTDOWN_GRACE_MS); - expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGTERM'); - vi.advanceTimersByTime(SIGTERM_GRACE_MS); - expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGKILL'); - }); - - it('disconnect escalates stdin close → SIGTERM → SIGKILL on POSIX', () => { - // Pinned so the assertion does not depend on which runner executes it. - const platform = vi - .spyOn(process, 'platform', 'get') - .mockReturnValue('linux'); - const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); - try { - const mockKill = vi.fn(); - const child = createMockChild({ kill: mockKill }); - const conn = createConnection({ - child, - sdkConnection: {}, - sessionId: 'test-session', - }); - - (conn as unknown as AcpConnection).disconnect(); - expect(mockKill).not.toHaveBeenCalled(); - expect(killSpy).not.toHaveBeenCalled(); - - // The grace has to outlast the CLI's own wind-down (8s MCP pool drain + - // 30s session drain), so nothing may be signalled one tick before it - // expires. A grace shorter than that wind-down reds this assertion. - vi.advanceTimersByTime(SHUTDOWN_GRACE_MS - 1); - expect(killSpy).not.toHaveBeenCalled(); - - // First rung: SIGTERM to the process GROUP (negative pid), not a bare - // kill(). SIGTERM stays catchable, so the CLI's signal cleanup and its - // exit-time reaper still run before the last rung. Removing the group - // signal or jumping straight to SIGKILL reds this. - vi.advanceTimersByTime(1); - expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGTERM'); - expect(killSpy).not.toHaveBeenCalledWith(-4242, 'SIGKILL'); - expect(mockKill).not.toHaveBeenCalled(); - - // Second rung, SIGTERM_GRACE_MS later: SIGKILL to the same group. - vi.advanceTimersByTime(SIGTERM_GRACE_MS - 1); - expect(killSpy).not.toHaveBeenCalledWith(-4242, 'SIGKILL'); - vi.advanceTimersByTime(1); - expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGKILL'); - } finally { - killSpy.mockRestore(); - platform.mockRestore(); - } - }); - - it('disconnect does not escalate against a CLI that exited on its own', () => { - vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); - const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); - try { - const mockKill = vi.fn(); - let exitListener: (() => void) | undefined; - const child = createMockChild({ - kill: mockKill, - once: vi.fn((event: string, listener: () => void) => { - if (event === 'exit') exitListener = listener; - }), - }); - const conn = createConnection({ - child, - sdkConnection: {}, - sessionId: 'test-session', - }); - - (conn as unknown as AcpConnection).disconnect(); - exitListener?.(); - // Past both deadlines the cancelled timers would have fired at, - // otherwise the cancellation this test pins is never exercised. - vi.advanceTimersByTime(SHUTDOWN_GRACE_MS + SIGTERM_GRACE_MS); - - expect(killSpy).not.toHaveBeenCalled(); - expect(mockKill).not.toHaveBeenCalled(); - } finally { - killSpy.mockRestore(); - } - }); - - it('does not force-kill a CLI that exits within the SIGTERM grace', () => { - // The ladder stops once the child exits: SIGTERM landed, and the SIGKILL - // rung must never fire for a CLI that is already winding down. - vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); - const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); - try { - const mockKill = vi.fn(); - let exitListener: (() => void) | undefined; - const child = createMockChild({ - kill: mockKill, - once: vi.fn((event: string, listener: () => void) => { - if (event === 'exit') exitListener = listener; - }), - }); - const conn = createConnection({ - child, - sdkConnection: {}, - sessionId: 'test-session', - }); - - (conn as unknown as AcpConnection).disconnect(); - vi.advanceTimersByTime(SHUTDOWN_GRACE_MS); - expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGTERM'); - - exitListener?.(); - vi.advanceTimersByTime(SIGTERM_GRACE_MS); - - expect(killSpy).not.toHaveBeenCalledWith(-4242, 'SIGKILL'); - expect(mockKill).not.toHaveBeenCalled(); - } finally { - killSpy.mockRestore(); - } - }); + (conn as unknown as AcpConnection).disconnect(); - it('does not signal after exitCode or signalCode is observed', () => { - vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); - const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); - try { - for (const exitInfo of [ - { exitCode: 0, signalCode: null }, - { exitCode: null, signalCode: 'SIGTERM' }, - ]) { - const conn = createConnection({ - child: createMockChild(exitInfo), - }); - (conn as unknown as AcpConnection).disconnect(); - vi.advanceTimersByTime(SHUTDOWN_GRACE_MS + SIGTERM_GRACE_MS); - } - expect(killSpy).not.toHaveBeenCalled(); - } finally { - killSpy.mockRestore(); - } + expect(end).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); }); - it('escalates through taskkill /t on Windows, not a bare kill', () => { - // Windows CI is skipped on PRs, so the platform is faked here rather than - // left to whichever runner happens to execute the suite. - const platform = vi - .spyOn(process, 'platform', 'get') - .mockReturnValue('win32'); - try { - const mockKill = vi.fn(); - const conn = createConnection({ - child: createMockChild({ kill: mockKill, pid: 4242 }), - sdkConnection: {}, - sessionId: 'test-session', - }); - - (conn as unknown as AcpConnection).disconnect(); - vi.advanceTimersByTime(SHUTDOWN_GRACE_MS); + it('escalates to the POSIX process group after both grace periods', () => { + if (process.platform === 'win32') return; + const kill = vi.spyOn(process, 'kill').mockReturnValue(true); + const conn = createConnection({ child: createMockChild() }); - // A tree kill: the CLI is unresponsive by now, so nothing else will reap - // the shells and ConPTY hosts underneath it. See #11303. - expect(execFileMock).toHaveBeenCalledWith( - expect.stringMatching(/\\System32\\taskkill\.exe$/i), - ['/f', '/t', '/pid', '4242'], - expect.objectContaining({ windowsHide: true, timeout: 2_000 }), - expect.any(Function), - ); - expect(mockKill).not.toHaveBeenCalled(); - } finally { - platform.mockRestore(); - } + (conn as unknown as AcpConnection).disconnect(); + vi.advanceTimersByTime(75_000); + expect(kill).toHaveBeenCalledWith(-4242, 'SIGTERM'); + vi.advanceTimersByTime(75_000); + expect(kill).toHaveBeenCalledWith(-4242, 'SIGKILL'); }); - it('falls back when taskkill cannot terminate the CLI tree', () => { + it('uses taskkill for an unresponsive Windows process tree', () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); - execFileMock.mockImplementation( - ( - _file: string, - _args: string[], - _options: object, - callback: (error: Error | null) => void, - ) => { - callback(new Error('ERROR_ACCESS_DENIED')); - }, - ); - const mockKill = vi.fn(); + const childKill = vi.fn(); const conn = createConnection({ - child: createMockChild({ kill: mockKill }), + child: createMockChild({ kill: childKill }), }); (conn as unknown as AcpConnection).disconnect(); - vi.advanceTimersByTime(SHUTDOWN_GRACE_MS); + vi.advanceTimersByTime(75_000); - expect(mockKill).toHaveBeenCalledOnce(); + expect(execFileMock).toHaveBeenCalledWith( + expect.stringMatching(/\\System32\\taskkill\.exe$/i), + ['/f', '/t', '/pid', '4242'], + expect.objectContaining({ windowsHide: true }), + expect.any(Function), + ); + expect(childKill).not.toHaveBeenCalled(); }); - it('a superseded child exiting does not tear down its replacement', async () => { - // disconnect() now lets the CLI wind down on its own, so a superseded child - // can still be exiting after connect() installed its replacement. An exit - // handler keyed only on `this.child` would null out the live connection. - let exitHandler: - | ((code: number | null, signal: string | null) => void) - | undefined; - const oldChild = createMockChild({ - on: vi.fn((event: string, listener: unknown) => { - if (event === 'exit') { - exitHandler = listener as ( - code: number | null, - signal: string | null, - ) => void; - } + it('cancels escalation when the child exits normally', () => { + let onExit: (() => void) | undefined; + const kill = vi.spyOn(process, 'kill').mockReturnValue(true); + const child = createMockChild({ + once: vi.fn((event: string, listener: () => void) => { + if (event === 'exit') onExit = listener; }), }); - const conn = createConnection({ child: oldChild }); - const acpConn = conn as unknown as AcpConnection; - const onDisconnected = vi.fn(); - acpConn.onDisconnected = onDisconnected; - - // Only the listener wiring matters here; the rest of the setup (its 1s - // settle, the web-stream conversion) has nothing to assert on these mocks. - void (conn as unknown as { setupChildProcessHandlers: () => Promise }) - .setupChildProcessHandlers() - .catch(() => {}); - - // connect() has since replaced the child. - const newChild = createMockChild(); - conn.child = newChild; - conn.sdkConnection = {}; - conn.sessionId = 'replacement-session'; - - exitHandler?.(0, null); - - expect(conn.child).toBe(newChild); - expect(conn.sdkConnection).toEqual({}); - expect(conn.sessionId).toBe('replacement-session'); - expect(onDisconnected).not.toHaveBeenCalled(); - // The exit also rejects the promise initialize() races. Nothing has - // attached to it at this point, so it must already be marked handled or - // this is an unhandled rejection in the extension host — vitest reports it - // as a suite error even with every test green. - await Promise.resolve(); - }); - - it('does not wire replacement streams into a retired startup', async () => { - vi.useFakeTimers(); - try { - const oldChild = createMockChild({ - stdout: new PassThrough(), - stdin: new PassThrough(), - on: vi.fn(), - }); - const newChild = createMockChild({ - stdout: new PassThrough(), - stdin: new PassThrough(), - on: vi.fn(), - }); - const conn = createConnection({ child: oldChild }); - const setup = ( - conn as unknown as { - setupChildProcessHandlers: () => Promise; - } - ).setupChildProcessHandlers(); - const setupFailure = await expect(setup).rejects.toThrow( - /failed to start|superseded/i, - ); + const conn = createConnection({ child }); - conn.child = newChild; - await vi.advanceTimersByTimeAsync(1000); + (conn as unknown as AcpConnection).disconnect(); + onExit?.(); + vi.advanceTimersByTime(150_000); - await setupFailure; - expect(conn.sdkConnection).toBeNull(); - } finally { - vi.useRealTimers(); - } + expect(kill).not.toHaveBeenCalled(); + expect(child.kill).not.toHaveBeenCalled(); }); - it('a live child exiting clears the connection and fires onDisconnected', async () => { - // The exit-handler teardown is keyed on the connection still being - // current. For the CURRENT direction (no supersede), a live child exit - // must clear child/sdkConnection/sessionId and fire onDisconnected, or - // the teardown silently never runs. A mutant like - // `if (this.child === ownChild && !ownChild)` (always false) reds this. + it('ignores an exit from a child replaced during graceful shutdown', async () => { let exitHandler: | ((code: number | null, signal: string | null) => void) | undefined; - const child = createMockChild({ - on: vi.fn((event: string, listener: unknown) => { + const oldChild = createMockChild({ + stderr: { on: vi.fn() }, + on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { if (event === 'exit') { - exitHandler = listener as ( - code: number | null, - signal: string | null, - ) => void; + exitHandler = listener as typeof exitHandler; } }), }); - const conn = createConnection({ child }); - const acpConn = conn as unknown as AcpConnection; - const onDisconnected = vi.fn(); - acpConn.onDisconnected = onDisconnected; - conn.sdkConnection = { initialize: vi.fn() }; - conn.sessionId = 'test-session'; - - // Only the listener wiring matters here; the rest of the setup (its 1s - // settle, the web-stream conversion) has nothing to assert on these mocks. - void (conn as unknown as { setupChildProcessHandlers: () => Promise }) - .setupChildProcessHandlers() - .catch(() => {}); + const conn = createConnection({ child: oldChild }); + const setup = ( + conn as unknown as { setupChildProcessHandlers: () => Promise } + ).setupChildProcessHandlers(); + void setup.catch(() => {}); + const replacement = createMockChild(); + conn.child = replacement; + conn.sdkConnection = {}; + conn.sessionId = 'replacement'; exitHandler?.(0, null); + await vi.advanceTimersByTimeAsync(1_000); + await expect(setup).rejects.toThrow(/failed to start/i); - expect(conn.child).toBeNull(); - expect(conn.sdkConnection).toBeNull(); - expect(conn.sessionId).toBeNull(); - expect(onDisconnected).toHaveBeenCalledWith(0, null); - await Promise.resolve(); - }); - - it('a superseded connection stops dispatching inbound callbacks', async () => { - // The inbound callbacks on the SDK Client object read `this.*` at call - // time. `disconnect()` ends stdin and nulls sdkConnection but does not - // close the superseded child's stdout, so its ClientSideConnection stays - // live. Each callback must gate on the connection it was built for - // (`this.sdkConnection !== wiredConnection`), or the retired connection - // keeps dispatching into callbacks bound to the live replacement. This - // case drives the writeTextFile and sessionUpdate guards specifically; - // requestPermission, readTextFile and extNotification carry the same gate - // but are not exercised here. Removing either driven guard fires its spy. - try { - const stdout = new PassThrough(); - const stdin = new PassThrough(); - const oldChild = createMockChild({ stdout, stdin, on: vi.fn() }); - const conn = new AcpConnection() as unknown as AcpConnectionInternal & { - onSessionUpdate: (data: unknown) => void; - fileHandler: { - handleWriteTextFile: (request: unknown) => Promise; - }; - }; - conn.child = oldChild; - conn.onSessionUpdate = vi.fn(); - const writeSpy = vi - .spyOn(conn.fileHandler, 'handleWriteTextFile') - .mockResolvedValue({}); - - const setup = ( - conn as unknown as { - setupChildProcessHandlers: () => Promise; - } - ).setupChildProcessHandlers(); - await vi.advanceTimersByTimeAsync(1000); - await setup; - - const client = sdkClientFactory.factory?.(null); - expect(client).toBeDefined(); - const writeTextFile = ( - client as unknown as { - writeTextFile: (request: unknown) => Promise; - } - ).writeTextFile; - const sessionUpdate = ( - client as unknown as { - sessionUpdate: (notification: unknown) => Promise; - } - ).sessionUpdate; - - // Supersede the connection the way a re-connect() does: disconnect() - // nulls both child and sdkConnection, while the superseded connection's - // stdout (still open) keeps its ClientSideConnection dispatching. - (conn as unknown as AcpConnection).disconnect(); - - await expect( - writeTextFile({ path: '/tmp/x', content: 'x', sessionId: 's' }), - ).rejects.toBeInstanceOf(RequestError); - expect(writeSpy).not.toHaveBeenCalled(); - - await sessionUpdate({}); - expect(conn.onSessionUpdate).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); - - it('a re-connected replacement keeps the superseded connection muted', async () => { - // Every other supersede test drives the gate through disconnect(), which - // nulls this.child and this.sdkConnection together, so the captured - // identity predicate (`this.sdkConnection !== wiredConnection`) and a - // weaker `!this.child` always agree. A real re-connect() installs a - // replacement child and a fresh sdkConnection while the old connection's - // stdout is still live — this.child is truthy again, so a gate weakened to - // `!this.child` would resume dispatching the retired connection's - // callbacks. This case pins the stronger predicate. - try { - const oldChild = createMockChild({ - stdout: new PassThrough(), - stdin: new PassThrough(), - on: vi.fn(), - }); - const newChild = createMockChild({ - stdout: new PassThrough(), - stdin: new PassThrough(), - on: vi.fn(), - }); - const oldStdinEnd = vi.spyOn(oldChild.stdin as PassThrough, 'end'); - spawnMock.mockReset(); - spawnMock.mockReturnValueOnce(oldChild).mockReturnValueOnce(newChild); - - const conn = new AcpConnection() as unknown as AcpConnectionInternal & { - connect: (cliEntryPath: string) => Promise; - onSessionUpdate: (data: unknown) => void; - fileHandler: { - handleWriteTextFile: (request: unknown) => Promise; - }; - }; - conn.onSessionUpdate = vi.fn(); - const writeSpy = vi - .spyOn(conn.fileHandler, 'handleWriteTextFile') - .mockResolvedValue({}); - - // First connect: capture the old client before it is superseded. - const first = conn.connect(process.execPath); - await vi.advanceTimersByTimeAsync(1000); - await first; - const oldClient = sdkClientFactory.factory?.(null); - - // Re-connect: disconnect() retires the old child, then a replacement - // child and a fresh sdkConnection are installed while the old stdout is - // still dispatching. - const second = conn.connect(process.execPath); - await vi.advanceTimersByTimeAsync(1000); - await second; - - expect(conn.child).toBe(newChild); - expect(oldStdinEnd).toHaveBeenCalledOnce(); - - const writeTextFile = ( - oldClient as unknown as { - writeTextFile: (request: unknown) => Promise; - } - ).writeTextFile; - const sessionUpdate = ( - oldClient as unknown as { - sessionUpdate: (notification: unknown) => Promise; - } - ).sessionUpdate; - - await expect( - writeTextFile({ path: '/tmp/x', content: 'x', sessionId: 's' }), - ).rejects.toBeInstanceOf(RequestError); - expect(writeSpy).not.toHaveBeenCalled(); - - await sessionUpdate({}); - expect(conn.onSessionUpdate).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } + expect(conn.child).toBe(replacement); + expect(conn.sdkConnection).toEqual({}); + expect(conn.sessionId).toBe('replacement'); }); +}); - it('does not stamp a superseded connection with a stale session id', async () => { - // newSession/loadSession write this.sessionId only after awaiting the - // connection they captured. A response from a retired CLI can resolve - // after disconnect() nulled sessionId; the post-await write must gate on - // the captured connection, or the dead session's id lands back on the - // replacement connection's field. Removing either guard reds this test. - let resolveNewSession!: (value: unknown) => void; - let resolveLoadSession!: (value: unknown) => void; +describe('AcpConnection stale responses', () => { + it('does not apply session or prompt responses from a retired connection', async () => { + let resolveNew!: (value: NewSessionResponse) => void; + let resolveLoad!: (value: LoadSessionResponse) => void; + let resolvePrompt!: (value: PromptResponse) => void; const sdk = { newSession: vi.fn( () => - new Promise((resolve) => { - resolveNewSession = resolve; - }), + new Promise((resolve) => (resolveNew = resolve)), ), loadSession: vi.fn( () => - new Promise((resolve) => { - resolveLoadSession = resolve; - }), - ), - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'test-session', - }); - const acp = conn as unknown as AcpConnection; - - const newPromise = acp.newSession(); - const loadPromise = acp.loadSession('stale-session'); - acp.disconnect(); - - resolveNewSession({ sessionId: 'stale-from-retired-cli' }); - resolveLoadSession({}); - // The guard must fail the call, not resolve it. Returning the retired - // CLI's payload lets qwenAgentManager.applySessionStateFromResult write - // the dead model/mode state into the live webview's baselines, and - // createNewSession hands that same promise to every concurrent caller via - // sessionCreateInFlight. Reverting either guard to `return response` reds - // both assertions below. - await expect(newPromise).rejects.toMatchObject({ - code: ACP_ERROR_CODES.INTERNAL_ERROR, - message: expect.stringContaining('connection superseded'), - data: { details: 'connection superseded' }, - }); - await expect(loadPromise).rejects.toMatchObject({ - code: ACP_ERROR_CODES.INTERNAL_ERROR, - message: expect.stringContaining('connection superseded'), - data: { details: 'connection superseded' }, - }); - - expect(acp.currentSessionId).toBeNull(); - }); - - it('a re-connected replacement does not stamp the retired session id', async () => { - // Mirrors 'does not stamp a superseded connection with a stale session id' - // but supersedes by re-connect instead of disconnect(). disconnect() nulls - // this.child and this.sdkConnection together, so a gate weakened to - // `!this.child` still bails there. After a re-connect this.child is truthy - // again (the replacement), so `!this.child` would NOT bail and the retired - // CLI's session/new + session/load would stamp their ids back onto the - // live connection. This case pins `this.sdkConnection !== conn` (and its - // `=== conn` mirror) against that substitution. - let resolveNewSession!: (value: unknown) => void; - let resolveLoadSession!: (value: unknown) => void; - const oldSdk = { - newSession: vi.fn( - () => - new Promise((resolve) => { - resolveNewSession = resolve; - }), - ), - loadSession: vi.fn( - () => - new Promise((resolve) => { - resolveLoadSession = resolve; - }), - ), - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: oldSdk, - sessionId: 'live-session-1', - }); - const acp = conn as unknown as AcpConnection; - - const newPromise = acp.newSession(); - const loadPromise = acp.loadSession('stale-session'); - - // Re-connect: install a replacement child and a fresh sdkConnection while - // the retired sdk's promises are still in flight. - conn.child = createMockChild(); - conn.sdkConnection = { newSession: vi.fn(), loadSession: vi.fn() }; - conn.sessionId = 'live-session-2'; - - resolveNewSession({ sessionId: 'stale-from-retired-cli' }); - resolveLoadSession({}); - // Same stale-result pin as the disconnect() case, on the re-connect path: - // the retired CLI's payload must not reach the caller, or it is applied to - // the replacement connection's live webview state. - await expect(newPromise).rejects.toMatchObject({ - code: ACP_ERROR_CODES.INTERNAL_ERROR, - message: expect.stringContaining('connection superseded'), - data: { details: 'connection superseded' }, - }); - await expect(loadPromise).rejects.toMatchObject({ - code: ACP_ERROR_CODES.INTERNAL_ERROR, - message: expect.stringContaining('connection superseded'), - data: { details: 'connection superseded' }, - }); - - expect(acp.currentSessionId).toBe('live-session-2'); - }); - - it('a re-connected replacement does not fire onEndTurn for a retired prompt', async () => { - // sendPrompt gates onEndTurn on the captured connection so a stale prompt - // resolving after a re-connect does not clear the replacement's streaming - // state. disconnect() nulls this.child and this.sdkConnection together, so - // `!this.child` still bails there; only a re-connect (this.child truthy - // again) can tell the two predicates apart. - let resolvePrompt!: (value: unknown) => void; - const oldSdk = { - prompt: vi.fn( - () => - new Promise((resolve) => { - resolvePrompt = resolve; - }), + new Promise( + (resolve) => (resolveLoad = resolve), + ), ), - }; - const onEndTurn = vi.fn(); - const conn = createConnection({ - child: createMockChild(), - sdkConnection: oldSdk, - sessionId: 'session-1', - }); - (conn as unknown as AcpConnection).onEndTurn = onEndTurn; - const acp = conn as unknown as AcpConnection; - - const promptPromise = acp.sendPrompt('hi'); - - conn.child = createMockChild(); - conn.sdkConnection = { prompt: vi.fn() }; - conn.sessionId = 'session-2'; - - resolvePrompt({ stopReason: 'end_turn' }); - await expect(promptPromise).rejects.toMatchObject({ - code: ACP_ERROR_CODES.INTERNAL_ERROR, - message: expect.stringContaining('connection superseded'), - data: { details: 'connection superseded' }, - }); - - expect(onEndTurn).not.toHaveBeenCalled(); - }); - - it('does not fire onEndTurn when the prompt session is superseded in place', async () => { - let resolvePrompt!: (value: unknown) => void; - const sdk = { prompt: vi.fn( () => - new Promise((resolve) => { - resolvePrompt = resolve; - }), + new Promise((resolve) => (resolvePrompt = resolve)), ), }; const onEndTurn = vi.fn(); const conn = createConnection({ child: createMockChild(), sdkConnection: sdk, - sessionId: 'session-a', + sessionId: 'old-session', }); (conn as unknown as AcpConnection).onEndTurn = onEndTurn; const acp = conn as unknown as AcpConnection; - const promptPromise = acp.sendPrompt('hi'); - // Session replacement on the same live connection leaves the SDK object - // unchanged, so the session identity must be part of the stale-result - // guard as well. - conn.sessionId = 'session-b'; - + const create = acp.newSession(); + const load = acp.loadSession('loaded-session'); + const prompt = acp.sendPrompt('hello'); + void create.catch(() => {}); + void load.catch(() => {}); + void prompt.catch(() => {}); + conn.sdkConnection = {}; + conn.sessionId = 'replacement'; + resolveNew({ sessionId: 'created-session' }); + resolveLoad({}); resolvePrompt({ stopReason: 'end_turn' }); - await expect(promptPromise).rejects.toMatchObject({ - code: ACP_ERROR_CODES.INTERNAL_ERROR, - message: expect.stringContaining('connection superseded'), - data: { details: 'connection superseded' }, - }); - expect(onEndTurn).not.toHaveBeenCalled(); - }); -}); - -describe('AcpConnection superseded session close (#11303)', () => { - // The agent keeps a session alive until told otherwise, and a retained - // session still fires autonomous model turns when its background tasks - // complete. Replacing the current session must therefore tell the agent to - // close the superseded one, or every New Session / history switch strands - // one more live session in the CLI process. - - const closeParams = (sessionId: string) => ({ - sessionId, - requireFlush: true, - onlyIfUnheld: true, - drainTimeoutMs: 8_000, - }); - - it('newSession closes the superseded session on the same connection', async () => { - const extMethod = vi.fn().mockResolvedValue({ closed: true }); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - - await (conn as unknown as AcpConnection).newSession(); - - expect(extMethod).toHaveBeenCalledWith( - 'qwen/control/session/close', - closeParams('session-a'), - ); - expect(conn.sessionId).toBe('session-b'); - }); - - it('newSession does not close anything for the first session', async () => { - const extMethod = vi.fn().mockResolvedValue({ closed: true }); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-a' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: null, - }); - - await (conn as unknown as AcpConnection).newSession(); - - expect(extMethod).not.toHaveBeenCalled(); - }); - - it('loadSession closes the superseded session on the same connection', async () => { - const extMethod = vi.fn().mockResolvedValue({ closed: true }); - const sdk = { - loadSession: vi.fn().mockResolvedValue({}), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - - await (conn as unknown as AcpConnection).loadSession('session-c'); - - expect(extMethod).toHaveBeenCalledWith( - 'qwen/control/session/close', - closeParams('session-a'), - ); - expect(conn.sessionId).toBe('session-c'); - }); - - it('loadSession does not close when reloading the current session', async () => { - // Re-loading the session already on screen (e.g. history hydration after a - // reconnect) must not close it out from under the live conversation. - const extMethod = vi.fn().mockResolvedValue({ closed: true }); - const sdk = { - loadSession: vi.fn().mockResolvedValue({}), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - - await (conn as unknown as AcpConnection).loadSession('session-a'); - - expect(extMethod).not.toHaveBeenCalled(); - }); - it('sends the close conditionally so held work is refused, not dropped (#11511)', async () => { - // Navigation is automatic cleanup, not explicit destruction: a session - // that still holds active work must be refused ({closed: false, holds}) - // rather than force-closed. Pin the onlyIfUnheld + drain budget the CLI - // contract keys on; dropping onlyIfUnheld reds this test. - const extMethod = vi - .fn() - .mockResolvedValue({ closed: false, holds: ['running-task'] }); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - - await (conn as unknown as AcpConnection).newSession(); - - expect(extMethod).toHaveBeenCalledWith('qwen/control/session/close', { - sessionId: 'session-a', - requireFlush: true, - onlyIfUnheld: true, - drainTimeoutMs: 8_000, - }); - expect(conn.sessionId).toBe('session-b'); - }); - - describe('superseded close retry (#11511)', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - const countClosesFor = (extMethod: Mock, sessionId: string) => - extMethod.mock.calls.filter( - ([, params]) => - (params as { sessionId?: string }).sessionId === sessionId, - ).length; - - it('retries a refused superseded close on a backoff until it succeeds', async () => { - const extMethod = vi - .fn() - .mockResolvedValueOnce({ closed: false, holds: ['running-task'] }) - .mockResolvedValueOnce({ closed: true }); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - - await (conn as unknown as AcpConnection).newSession(); - await vi.advanceTimersByTimeAsync(0); - expect(extMethod).toHaveBeenCalledTimes(1); - - // First retry only once the 60s rung expires. - await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS - 1); - expect(extMethod).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(1); - expect(extMethod).toHaveBeenCalledTimes(2); - expect(extMethod).toHaveBeenNthCalledWith( - 2, - 'qwen/control/session/close', - closeParams('session-a'), - ); - - // Closed for good: no third attempt, ever. - await vi.advanceTimersByTimeAsync(CLOSE_RETRY_CEILING_MS); - expect(extMethod).toHaveBeenCalledTimes(2); - }); - - it('backs off exponentially while a superseded close keeps being refused', async () => { - const extMethod = vi - .fn() - .mockResolvedValue({ closed: false, holds: ['running-task'] }); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - - await (conn as unknown as AcpConnection).newSession(); - await vi.advanceTimersByTimeAsync(0); - expect(extMethod).toHaveBeenCalledTimes(1); - - // First retry after 60s. - await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS); - expect(extMethod).toHaveBeenCalledTimes(2); - - // A refusal is evidence that the session still has active work, not a - // transport failure, so each probe stays on the base rung. - await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS); - expect(extMethod).toHaveBeenCalledTimes(3); - }); - - it('cancels the retry when the superseded session is loaded again', async () => { - const extMethod = vi - .fn() - .mockResolvedValueOnce({ closed: false, holds: ['running-task'] }) - .mockResolvedValue({ closed: true }); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - loadSession: vi.fn().mockResolvedValue({}), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - const acp = conn as unknown as AcpConnection; - - await acp.newSession(); - await vi.advanceTimersByTimeAsync(0); - expect(countClosesFor(extMethod, 'session-a')).toBe(1); - - // The superseded session becomes current again: session-b is now the - // one being closed, and the pending session-a retry must never fire. - await acp.loadSession('session-a'); - await vi.advanceTimersByTimeAsync(0); - expect(countClosesFor(extMethod, 'session-b')).toBe(1); - - await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS * 4); - expect(countClosesFor(extMethod, 'session-a')).toBe(1); - }); - - it('waits for an in-flight close before loading that session again', async () => { - let resolveClose!: (value: unknown) => void; - const extMethod = vi.fn( - () => - new Promise((resolve) => { - resolveClose = resolve; - }), - ); - const loadSession = vi.fn().mockResolvedValue({}); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - loadSession, - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - const acp = conn as unknown as AcpConnection; - - await acp.newSession(); - await vi.advanceTimersByTimeAsync(0); - expect(extMethod).toHaveBeenCalledTimes(1); - - const loadPromise = acp.loadSession('session-a'); - await vi.advanceTimersByTimeAsync(0); - expect(loadSession).not.toHaveBeenCalled(); - - resolveClose({ closed: false, holds: ['running-task'] }); - await loadPromise; - expect(loadSession).toHaveBeenCalledWith({ - sessionId: 'session-a', - cwd: process.cwd(), - mcpServers: [], - }); - }); - - it('deduplicates concurrent close attempts for one session', async () => { - let resolveClose!: (value: unknown) => void; - const extMethod = vi.fn( - () => - new Promise((resolve) => { - resolveClose = resolve; - }), - ); - const sdk = { extMethod }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-b', - }); - const acp = conn as unknown as AcpConnection; - const sendClose = ( - acp as unknown as { sendSupersededClose: (id: string) => void } - ).sendSupersededClose; - - sendClose.call(acp, 'session-a'); - sendClose.call(acp, 'session-a'); - await vi.advanceTimersByTimeAsync(0); - expect(extMethod).toHaveBeenCalledTimes(1); - - resolveClose({ closed: true }); - await vi.advanceTimersByTimeAsync(0); - }); - - it('caps transient close retry backoff at one hour', () => { - const conn = createConnection({ - child: createMockChild(), - sdkConnection: { extMethod: vi.fn() }, - sessionId: 'session-b', - }); - const acp = conn as unknown as AcpConnection; - const scheduleRetry = ( - acp as unknown as { - scheduleSupersededCloseRetry: (id: string) => void; - } - ).scheduleSupersededCloseRetry; - - for (let i = 0; i < 10; i += 1) { - scheduleRetry.call(acp, 'session-a'); - } - - const entry = ( - acp as unknown as { - supersededCloseRetries: Map; - } - ).supersededCloseRetries.get('session-a'); - if (!entry) { - throw new Error('expected a retry entry'); - } - expect(entry.retryAt - Date.now()).toBe(CLOSE_RETRY_CEILING_MS); - }); - - it('stops retrying superseded closes after disconnect', async () => { - const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); - try { - const extMethod = vi - .fn() - .mockResolvedValue({ closed: false, holds: ['running-task'] }); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - const acp = conn as unknown as AcpConnection; - - await acp.newSession(); - await vi.advanceTimersByTimeAsync(0); - expect(extMethod).toHaveBeenCalledTimes(1); - - acp.disconnect(); - await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS * 4); - expect(extMethod).toHaveBeenCalledTimes(1); - } finally { - killSpy.mockRestore(); - } - }); - - it('clears and cancels an in-flight close when disconnect retires the connection', async () => { - let resolveClose!: (value: unknown) => void; - const extMethod = vi.fn( - () => - new Promise((resolve) => { - resolveClose = resolve; - }), - ); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - const acp = conn as unknown as AcpConnection; - - await acp.newSession(); - await vi.advanceTimersByTimeAsync(0); - expect(extMethod).toHaveBeenCalledTimes(1); - expect( - (acp as unknown as { supersededCloseInFlight: Set }) - .supersededCloseInFlight.size, - ).toBe(1); - - acp.disconnect(); - expect( - (acp as unknown as { supersededCloseInFlight: Set }) - .supersededCloseInFlight.size, - ).toBe(0); - - resolveClose({ closed: false, holds: ['running-task'] }); - await vi.advanceTimersByTimeAsync(0); - expect( - ( - acp as unknown as { - supersededCloseRetries: Map; - } - ).supersededCloseRetries.size, - ).toBe(0); - }); - - it('re-drives an expired close retry on the next session replacement', async () => { - const extMethod = vi - .fn() - .mockResolvedValue({ closed: false, holds: ['running-task'] }); - const sdk = { - newSession: vi - .fn() - .mockResolvedValueOnce({ sessionId: 'session-b' }) - .mockResolvedValueOnce({ sessionId: 'session-c' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - const acp = conn as unknown as AcpConnection; - - await acp.newSession(); - await vi.advanceTimersByTimeAsync(0); - expect(countClosesFor(extMethod, 'session-a')).toBe(1); - - // Move the clock past the 60s rung WITHOUT running timers: the retry is - // due but the timer thread has not fired. The next replacement must - // drive it immediately (the daemon equivalent is the next active-work - // snapshot). - vi.setSystemTime(Date.now() + CLOSE_RETRY_BASE_MS + 1000); - - await acp.newSession(); - // closeSupersededSession() must drive the expired entry immediately; - // observe that synchronous catch-up before advancing any timers. - expect(countClosesFor(extMethod, 'session-a')).toBe(2); - await vi.advanceTimersByTimeAsync(0); - expect(countClosesFor(extMethod, 'session-a')).toBe(2); - expect(countClosesFor(extMethod, 'session-b')).toBe(1); - }); - - it('does not retry an unsupported close method on an older CLI', async () => { - // Old-CLI compatibility: the ext method rejects, the replacement - // session still succeeds, and an operation the CLI cannot implement is - // not retried forever. - const extMethod = vi - .fn() - .mockRejectedValue(new Error('Method not found')); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - const acp = conn as unknown as AcpConnection; - - await expect(acp.newSession()).resolves.toMatchObject({ - sessionId: 'session-b', - }); - await vi.advanceTimersByTimeAsync(0); - expect(extMethod).toHaveBeenCalledTimes(1); - expect(conn.sessionId).toBe('session-b'); - - await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS); - expect(extMethod).toHaveBeenCalledTimes(1); - }); - }); - - it('a failed close does not fail the new session', async () => { - // Older CLIs have no session/close ext method; the replacement session - // must still succeed, and the swallowed rejection must not surface as an - // unhandled rejection. - const extMethod = vi.fn().mockRejectedValue(new Error('Method not found')); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - const acp = conn as unknown as AcpConnection; - - await expect(acp.newSession()).resolves.toMatchObject({ - sessionId: 'session-b', - }); - // Let the fire-and-forget rejection settle so an unhandled one would fail - // the run rather than leak into an unrelated later test. - await new Promise((resolve) => setImmediate(resolve)); - - expect(conn.sessionId).toBe('session-b'); + await expect(create).rejects.toThrow('connection superseded'); + await expect(load).rejects.toThrow('connection superseded'); + await expect(prompt).rejects.toThrow('connection superseded'); + expect(conn.sessionId).toBe('replacement'); + expect(onEndTurn).not.toHaveBeenCalled(); }); }); diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 21c9e367a33..3a8f2ec91b5 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -42,45 +42,11 @@ import { Readable, Writable } from 'node:stream'; import * as fs from 'node:fs'; import { AcpFileHandler } from './acpFileHandler.js'; import { ACP_ERROR_CODES } from '../constants/acpSchema.js'; -import { - ACTIVE_WORK_CLOSE_RETRY_BASE_MS, - ACTIVE_WORK_CLOSE_RETRY_CEILING_MS, - sessionCloseDrainBudgetMs, -} from '@qwen-code/acp-bridge/bridgeTypes'; -/** - * How long the CLI gets to shut itself down after its stdin is closed, before - * the escalation ladder starts. - * - * This has to outlast the CLI's own wind-down, or the escalation lands in the - * middle of a shutdown that is progressing correctly and skips the - * `process.on('exit')` cleanup this teardown exists to protect. On the - * ide_close path SessionEnd hooks are capped at 30s, followed by the CLI's - * 8s MCP pool drain, 30s session drain, and 5s exit cleanup: 73s bounded. - * Keep a small margin above that bound. The escalation remains a backstop for - * a CLI that is genuinely wedged. - */ const SHUTDOWN_GRACE_MS = 75_000; - -/** - * How long the POSIX escalation waits between the SIGTERM rung and the - * SIGKILL rung. SIGTERM triggers the CLI's `shutdownHandler`. The handler's - * SessionEnd hooks are capped at 30s, followed by the 30s session drain, 8s - * MCP drain and 5s exit cleanup. Keep this rung above that 73s bound so - * SIGKILL remains a last resort and the CLI's exit-time reaper gets a chance, - * even when SIGTERM arrives before the normal connection-close path. - */ const SIGTERM_GRACE_MS = 75_000; - -// Resolve taskkill by absolute System32 path, never the bare name: on Windows -// a bare command is resolved through PATH *and* the current directory, so a -// taskkill.exe planted in the workspace would run with the extension host's -// environment. const WINDOWS_TASKKILL = `${process.env['SystemRoot'] || 'C:\\Windows'}\\System32\\taskkill.exe`; -// Drain budget handed to the CLI on a conditional superseded-session close. -const SUPERSEDED_CLOSE_DRAIN_MS = sessionCloseDrainBudgetMs(10_000); - /** * ACP Connection Handler for VSCode Extension * @@ -95,15 +61,6 @@ export class AcpConnection { private fileHandler = new AcpFileHandler(); private lastExitCode: number | null = null; private lastExitSignal: string | null = null; - private supersededCloseRetries = new Map< - string, - { failures: number; retryAt: number } - >(); - private supersededCloseInFlight = new Set(); - private supersededClosePromises = new Map>(); - private supersededCloseCancels = new Map void>(); - private supersededCloseTimer: NodeJS.Timeout | null = null; - private connectionGeneration = 0; onSessionUpdate: (data: SessionNotification) => void = () => {}; onPermissionRequest: (data: RequestPermissionRequest) => Promise<{ @@ -177,13 +134,6 @@ export class AcpConnection { stdio: ['pipe', 'pipe', 'pipe'], env, shell: false, - // A detached child becomes a process-group leader on POSIX, so the - // disconnect() escalation can signal the whole group and reach the CLI - // root and its non-detached MCP stdio children. It does NOT reach - // descendants that call setsid() — detached hook supervisors and - // monitors, and node-pty sessions — so those survive the escalation. - // Windows has no process group to signal — its tree kill goes through - // taskkill instead. detached: process.platform !== 'win32', }; @@ -194,23 +144,12 @@ export class AcpConnection { private async setupChildProcessHandlers(): Promise { let spawnError: Error | null = null; const stderrChunks: string[] = []; - // Bind the handlers below to THIS child. `disconnect()` now lets the CLI - // wind down on its own, so a superseded child can still be exiting while - // `connect()` has already installed its replacement — and an exit handler - // that only tested `this.child` would then tear down the live connection - // and report it as disconnected. const ownChild = this.child!; let rejectOnExit: ((error: Error) => void) | null = null; const processExitPromise = new Promise((_resolve, reject) => { rejectOnExit = reject; }); - // The only consumer is the Promise.race in initialize(), which attaches - // much later. A child that exits before then — a failed startup, or a - // superseded child winding down after disconnect() — would otherwise - // reject this with no handler attached, i.e. an unhandled rejection in the - // extension host. Marking it handled here changes nothing for the race, - // which still receives the original promise and still sees the rejection. void processExitPromise.catch(() => {}); ownChild.stderr?.on('data', (data: Buffer) => { @@ -262,7 +201,7 @@ export class AcpConnection { } if (this.child !== ownChild || ownChild.killed) { - const code = this.lastExitCode ?? this.child?.exitCode ?? null; + const code = this.lastExitCode ?? ownChild.exitCode ?? null; const signal = this.lastExitSignal; const stderrOutput = stderrChunks.join('').trim(); const stderrSuffix = stderrOutput @@ -282,19 +221,10 @@ export class AcpConnection { const stream = ndJsonStream(stdin, stdout); // Build the SDK Client implementation that bridges to our callbacks. - // Capture the connection in a local so the inbound callbacks below can - // detect that THIS connection has been retired. disconnect() nulls both - // this.child and this.sdkConnection, then a re-connect() installs a - // replacement — but the superseded connection's stdout is still live and - // dispatching through the grace window. Comparing against the captured - // connection (not this.child, which is nulled before the grace timer and - // re-runs on the still-current child) stays correct across that window. const wiredConnection = new ClientSideConnection( (_agent: Agent): Client => ({ sessionUpdate: (params: SessionNotification): Promise => { if (this.sdkConnection !== wiredConnection) { - // A fire-and-forget notifier on a superseded connection must not - // re-enter callbacks that read `this.*` at call time. return Promise.resolve(); } this.onSessionUpdate(params as unknown as SessionNotification); @@ -436,12 +366,8 @@ export class AcpConnection { method: string, params: Record, ): Promise => { - if (this.sdkConnection !== wiredConnection) { - // A fire-and-forget notifier on a superseded connection must not - // re-enter `this.*` callbacks; drop it instead of erroring. - return; - } - return this.handleExtNotification(method, params); + if (this.sdkConnection !== wiredConnection) return; + this.handleExtNotification(method, params); }, }), stream, @@ -571,207 +497,13 @@ export class AcpConnection { return response; } - /** - * The agent keeps every session alive until told otherwise, and a retained - * session can continue autonomous work after it leaves the foreground. - * Replacing the current session (session/new, session/load) therefore asks - * the CLI to close the superseded one. - * - * The close is conditional (`onlyIfUnheld`): navigation is automatic - * cleanup, not explicit destruction, so a session that still holds active - * work is refused (`{closed: false, holds}`) and is retried on a backoff - * rather than force-closed — dropping in-flight work is exactly what the - * condition protects against. Fire-and-forget either way: a refused, - * failed or unsupported (older CLI) close must never block the user's new - * session, and a later session/load of the closed id simply re-reads the - * flushed transcript. - */ - private closeSupersededSession( - previousSessionId: string | null, - nextSessionId: string | null, - ): void { - if (nextSessionId) { - // A session that is current again must not stay on the retry table. - this.supersededCloseRetries.delete(nextSessionId); - } - if (previousSessionId && previousSessionId !== nextSessionId) { - this.sendSupersededClose(previousSessionId); - } - // A replacement is also the moment to re-drive any close whose backoff - // already expired while no timer was due (the daemon equivalent is the - // next active-work snapshot). - this.driveDueSupersededCloseRetries(); - } - - private isUnsupportedSupersededCloseError(error: unknown): boolean { - return ( - (error instanceof RequestError && error.code === -32601) || - (error instanceof Error && /method not found/i.test(error.message)) - ); - } - - private sendSupersededClose(sessionId: string): void { - // Always send on the CURRENT connection: by the time a retry fires, the - // connection the session was superseded on may have been replaced. - const conn = this.sdkConnection; - if ( - !conn || - !this.isConnected || - this.supersededCloseInFlight.has(sessionId) - ) { - return; - } - const generation = this.connectionGeneration; - this.supersededCloseInFlight.add(sessionId); - let cancelClose!: () => void; - const cancelled = new Promise((resolve) => { - cancelClose = resolve; - }); - this.supersededCloseCancels.set(sessionId, cancelClose); - - const operation = Promise.resolve() - .then(() => - conn.extMethod('qwen/control/session/close', { - sessionId, - requireFlush: true, - onlyIfUnheld: true, - drainTimeoutMs: SUPERSEDED_CLOSE_DRAIN_MS, - }), - ) - .then((result) => { - if ( - generation !== this.connectionGeneration || - this.sdkConnection !== conn - ) { - return; - } - if (result['closed'] === true) { - this.supersededCloseRetries.delete(sessionId); - } else { - // Refused while the session still holds active work; keep it and - // probe again on the backoff rungs. - logger.warn( - '[ACP] Superseded session close was refused:', - sessionId, - result['holds'], - ); - this.scheduleSupersededCloseRetry(sessionId, true); - } - }) - .catch((error: unknown) => { - if ( - generation !== this.connectionGeneration || - this.sdkConnection !== conn - ) { - return; - } - if (this.isUnsupportedSupersededCloseError(error)) { - // Older CLIs do not implement this optional extension method. Keep - // the replacement session usable, but do not retry an operation - // that can never succeed on this process. - this.supersededCloseRetries.delete(sessionId); - return; - } - // Older CLIs have no session/close ext method; count it as a failure - // and keep retrying on the same table so transient failures stay - // tracked. - logger.warn( - '[ACP] Failed to close superseded session:', - error instanceof Error ? error.message : String(error), - ); - this.scheduleSupersededCloseRetry(sessionId); - }); - - const tracked = Promise.race([operation, cancelled]).finally(() => { - this.supersededCloseInFlight.delete(sessionId); - if (this.supersededClosePromises.get(sessionId) === tracked) { - this.supersededClosePromises.delete(sessionId); - this.supersededCloseCancels.delete(sessionId); - } - this.armSupersededCloseTimer(); - }); - this.supersededClosePromises.set(sessionId, tracked); - } - - private scheduleSupersededCloseRetry( - sessionId: string, - resetFailures = false, - ): void { - const failures = - (resetFailures - ? 0 - : (this.supersededCloseRetries.get(sessionId)?.failures ?? 0)) + 1; - const delay = Math.min( - ACTIVE_WORK_CLOSE_RETRY_BASE_MS * 2 ** (failures - 1), - ACTIVE_WORK_CLOSE_RETRY_CEILING_MS, - ); - this.supersededCloseRetries.set(sessionId, { - failures, - retryAt: Date.now() + delay, - }); - this.armSupersededCloseTimer(); - } - - private armSupersededCloseTimer(): void { - if (this.supersededCloseTimer) { - clearTimeout(this.supersededCloseTimer); - this.supersededCloseTimer = null; - } - let earliest: number | null = null; - for (const [sessionId, entry] of this.supersededCloseRetries) { - if (this.supersededCloseInFlight.has(sessionId)) { - continue; - } - if (earliest === null || entry.retryAt < earliest) { - earliest = entry.retryAt; - } - } - if (earliest === null) { - return; - } - this.supersededCloseTimer = setTimeout( - () => { - this.supersededCloseTimer = null; - this.driveDueSupersededCloseRetries(); - }, - Math.max(earliest - Date.now(), 1_000), - ); - } - - private driveDueSupersededCloseRetries(): void { - if (this.supersededCloseRetries.size === 0) { - return; - } - const now = Date.now(); - for (const [sessionId, entry] of [...this.supersededCloseRetries]) { - if (entry.retryAt > now || this.supersededCloseInFlight.has(sessionId)) { - continue; - } - if (!this.isConnected || this.sessionId === sessionId) { - // The CLI is gone, or the session was reloaded onto the live - // connection and is no longer superseded. - this.supersededCloseRetries.delete(sessionId); - continue; - } - this.sendSupersededClose(sessionId); - } - this.armSupersededCloseTimer(); - } - async newSession(cwd: string = process.cwd()): Promise { const conn = this.ensureConnection(); - const previousSessionId = this.sessionId; logger.log('[ACP] Sending session/new request with cwd:', cwd); const response: NewSessionResponse = await conn.newSession({ cwd, mcpServers: [], }); - // A stale session/new can resolve after disconnect() (or a re-connect) - // retired this connection. Handing the payload back would let the caller - // apply the retired CLI's model and mode state to the live webview - // (`applySessionStateFromResult` in qwenAgentManager.ts), and writing would - // stamp the dead session's id onto the replacement connection's field, so - // fail instead — the same shape the inbound callback guards use above. if (this.sdkConnection !== conn) { throw RequestError.internalError( { details: 'connection superseded' }, @@ -780,7 +512,6 @@ export class AcpConnection { } this.sessionId = response.sessionId || null; logger.log('[ACP] Session created with ID:', this.sessionId); - this.closeSupersededSession(previousSessionId, this.sessionId); return response; } @@ -798,9 +529,6 @@ export class AcpConnection { sessionId: promptSessionId, prompt: promptBlocks, }); - // A stale prompt can resolve after disconnect(), re-connect(), or an - // in-place session replacement. Firing onEndTurn then would clear the - // replacement session's streaming state, so fail before touching it. if (this.sdkConnection !== conn || this.sessionId !== promptSessionId) { throw RequestError.internalError( { details: 'connection superseded' }, @@ -849,20 +577,6 @@ export class AcpConnection { cwdOverride?: string, ): Promise { const conn = this.ensureConnection(); - const previousSessionId = this.sessionId; - // The daemon rejects a load while its conditional close gate is active. - // Wait for that close to settle before loading the same session again; - // disconnect() resolves the tracked wait when the connection is retired. - const pendingClose = this.supersededClosePromises.get(sessionId); - if (pendingClose) { - await pendingClose; - if (this.sdkConnection !== conn) { - throw RequestError.internalError( - { details: 'connection superseded' }, - 'connection superseded', - ); - } - } logger.log('[ACP] Sending session/load request for session:', sessionId); const cwd = cwdOverride || this.workingDir; let response: LoadSessionResponse; @@ -879,14 +593,6 @@ export class AcpConnection { ); throw error; } - // A stale session/load can resolve after disconnect() (or a re-connect) - // retired this connection. Handing the payload back would let the caller - // apply the retired CLI's model and mode state to the live webview - // (`applySessionStateFromResult` and `restoreBaselineSessionStateAfterLoad` - // in qwenAgentManager.ts), and writing would stamp the dead session's id - // onto the replacement connection's field, so fail instead. Checked outside - // the catch above so a supersede is not logged as a request failure, and - // before the success log so a discarded load prints no success line. if (this.sdkConnection !== conn) { throw RequestError.internalError( { details: 'connection superseded' }, @@ -895,7 +601,6 @@ export class AcpConnection { } logger.log('[ACP] Session load succeeded for session:', sessionId); this.sessionId = sessionId; - this.closeSupersededSession(previousSessionId, sessionId); return response; } @@ -1030,61 +735,16 @@ export class AcpConnection { } disconnect(): void { - this.connectionGeneration += 1; - for (const cancel of this.supersededCloseCancels.values()) { - cancel(); - } - this.supersededCloseCancels.clear(); - this.supersededClosePromises.clear(); - this.supersededCloseInFlight.clear(); const child = this.child; this.child = null; this.sdkConnection = null; this.sessionId = null; - // The CLI process is going away; any pending conditional-close retry - // targets it, so drop the table instead of signalling a dead connection. - this.supersededCloseRetries.clear(); - if (this.supersededCloseTimer) { - clearTimeout(this.supersededCloseTimer); - this.supersededCloseTimer = null; - } - if (!child) { - return; - } - if (child.pid === undefined) { - return; - } - const childPid = child.pid; + if (!child) return; - // Close the child's stdin instead of killing it. Ending the ndjson stream - // is the CLI's own shutdown path: `await connection.closed` returns, it - // fires SessionEnd hooks, drains the MCP pool, disposes its sessions and - // exits normally — so its `process.on('exit')` cleanup runs and reaps the - // PTYs, ConPTY hosts and child processes it is tracking. - // - // A bare `child.kill()` is `TerminateProcess` on Windows: none of that - // runs, and everything the CLI was tracking is orphaned until the VS Code - // window itself closes. That is the teardown half of #11303. - let graceTimer: NodeJS.Timeout | undefined; - let killTimer: NodeJS.Timeout | undefined; - child.once('exit', () => { - if (graceTimer) { - clearTimeout(graceTimer); - graceTimer = undefined; - } - if (killTimer) { - clearTimeout(killTimer); - killTimer = undefined; - } - }); - const stdin = child.stdin; - if (stdin && !stdin.destroyed && !stdin.writableEnded) { - // A late write error on a pipe whose reader is gone is reported as an - // 'error' event, and an unhandled one on an EventEmitter throws — in the - // extension host, not here. Swallow it: we are tearing this down anyway. - stdin.once('error', () => {}); + if (child.stdin && !child.stdin.destroyed && !child.stdin.writableEnded) { + child.stdin.once('error', () => {}); try { - stdin.end(); + child.stdin.end(); } catch (error) { logger.error( '[ACP] Failed to close CLI stdin during disconnect:', @@ -1093,50 +753,34 @@ export class AcpConnection { } } - // Escalate only if the graceful path did not land. POSIX climbs a ladder — - // SIGTERM (catchable, runs the CLI's bounded signal cleanup and its - // exit-time reaper) and only then SIGKILL — while Windows goes straight - // to the tree kill: it has no catchable terminate for console processes. - graceTimer = setTimeout(() => { - graceTimer = undefined; - if (child.exitCode !== null || child.signalCode !== null) { - return; - } - if (process.platform === 'win32' && child.pid) { - // A tree kill is right here: at this point the CLI is unresponsive, - // so nothing else will reap the shells and ConPTY hosts underneath it. - logger.error( - `[ACP] CLI did not exit within ${SHUTDOWN_GRACE_MS}ms of stdin close; force-killing its process tree`, - ); + const childPid = child.pid; + if (!childPid) return; + + let killTimer: NodeJS.Timeout | undefined; + const graceTimer = setTimeout(() => { + if (child.exitCode !== null || child.signalCode !== null) return; + + if (process.platform === 'win32') { execFile( WINDOWS_TASKKILL, - ['/f', '/t', '/pid', String(child.pid)], + ['/f', '/t', '/pid', String(childPid)], { windowsHide: true, timeout: 2_000 }, (error) => { - if (error) { - logger.error('[ACP] taskkill failed for the CLI tree:', error); - try { - child.kill(); - } catch { - // Already gone. - } + if (!error) return; + logger.error('[ACP] taskkill failed for the CLI tree:', error); + try { + child.kill(); + } catch { + // Already gone. } }, ); return; } - // The child is detached on POSIX, so it leads its own process group: - // signalling the group reaches the CLI root and its non-detached children - // (MCP stdio servers). It does NOT reach descendants that call setsid() — - // detached hook supervisors and monitors, and node-pty sessions. - logger.error( - `[ACP] CLI did not exit within ${SHUTDOWN_GRACE_MS}ms of stdin close; sending SIGTERM to its process group`, - ); + try { process.kill(-childPid, 'SIGTERM'); } catch { - // The process group is already gone (or the child predates the - // detached spawn). The root signal is the fallback. try { child.kill('SIGTERM'); } catch { @@ -1144,18 +788,7 @@ export class AcpConnection { } } killTimer = setTimeout(() => { - killTimer = undefined; - // Re-check before signalling: after 75+s the pid may have been - // recycled by an unrelated process group. - if (child.exitCode !== null || child.signalCode !== null) { - return; - } - // SIGKILL also skips the CLI's own exit-time reaper - // (forceKillActivePosixHookProcesses), which is why it is the last - // rung and not the first. - logger.error( - `[ACP] CLI still alive ${SIGTERM_GRACE_MS}ms after SIGTERM; force-killing its process group`, - ); + if (child.exitCode !== null || child.signalCode !== null) return; try { process.kill(-childPid, 'SIGKILL'); } catch { @@ -1167,6 +800,10 @@ export class AcpConnection { } }, SIGTERM_GRACE_MS); }, SHUTDOWN_GRACE_MS); + child.once('exit', () => { + clearTimeout(graceTimer); + if (killTimer) clearTimeout(killTimer); + }); } get isConnected(): boolean { From 4cfe551abb8e0adba89d312cae355bb05bb24f9a Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 12 Sep 2026 11:32:05 +0800 Subject: [PATCH 09/16] fix(vscode): narrow ACP teardown to graceful shutdown --- .../e2e-tests/vscode-acp-graceful-shutdown.md | 21 + docs/design/vscode-acp-graceful-shutdown.md | 30 + .../vscode-acp-graceful-shutdown.zh-CN.md | 30 + .../cli/src/acp-integration/acpAgent.test.ts | 143 +- packages/cli/src/acp-integration/acpAgent.ts | 105 +- packages/cli/src/utils/cleanup.test.ts | 20 + packages/cli/src/utils/cleanup.ts | 14 +- .../src/services/acpConnection.test.ts | 1395 ++--------------- .../src/services/acpConnection.ts | 429 +---- 9 files changed, 375 insertions(+), 1812 deletions(-) create mode 100644 .qwen/e2e-tests/vscode-acp-graceful-shutdown.md create mode 100644 docs/design/vscode-acp-graceful-shutdown.md create mode 100644 docs/design/vscode-acp-graceful-shutdown.zh-CN.md diff --git a/.qwen/e2e-tests/vscode-acp-graceful-shutdown.md b/.qwen/e2e-tests/vscode-acp-graceful-shutdown.md new file mode 100644 index 00000000000..dcdc6a86089 --- /dev/null +++ b/.qwen/e2e-tests/vscode-acp-graceful-shutdown.md @@ -0,0 +1,21 @@ +# VS Code ACP graceful shutdown E2E plan + +## Scenario + +1. Start a VS Code Companion chat that launches an MCP stdio child. +2. Close or reload the companion while the chat is idle. +3. Confirm the ACP CLI exits normally and its MCP child does not remain alive. +4. Repeat with an intentionally unresponsive ACP child and confirm the host escalates only after the graceful deadline. +5. Reopen the companion during teardown and confirm the replacement connection remains usable after the old child exits. + +## Expected result + +- Ordinary teardown reaches the CLI cleanup path before the host uses a forced termination. +- No old child exit, notification, or response clears the replacement connection. +- POSIX escalation targets the ACP process group; Windows escalation targets the process tree. + +## Automated coverage + +The focused companion and CLI tests cover the shutdown ladder, replacement races, overlapping shutdown phases, and hook deadline. Native Windows process-tree behavior and a real VS Code host remain manual validation boundaries. + +The global `qwen` CLI cannot reproduce the companion-owned child lifecycle, so a standalone CLI dry run is not a faithful baseline for this scenario. diff --git a/docs/design/vscode-acp-graceful-shutdown.md b/docs/design/vscode-acp-graceful-shutdown.md new file mode 100644 index 00000000000..09a45ae775e --- /dev/null +++ b/docs/design/vscode-acp-graceful-shutdown.md @@ -0,0 +1,30 @@ +# VS Code ACP graceful shutdown + +[中文版](./vscode-acp-graceful-shutdown.zh-CN.md) + +## Problem + +The VS Code companion terminated its ACP child immediately when the panel disconnected. On Windows this bypassed the CLI shutdown path and could leave shells and ConPTY descendants alive. A graceful child can also overlap a replacement connection, so an old exit or response must not clear state owned by the replacement. + +## Decision + +The companion closes the ACP child's stdin first. The CLI treats the closed transport as a normal shutdown, fires SessionEnd hooks, drains MCP clients, disposes sessions, and runs process cleanup. + +Shutdown is bounded: + +- On POSIX, the ACP child leads a process group. After 75 seconds the companion sends SIGTERM to that group, then SIGKILL after another 75 seconds. +- On Windows, after 75 seconds the companion invokes the absolute System32 `taskkill.exe` path with `/f /t`. +- Exit handlers and asynchronous responses are tied to the child and connection that created them, so a retired process cannot mutate a replacement connection. +- Overlapping EOF and signal shutdown paths share the same SessionEnd, MCP drain, session-disposal, and registered process-cleanup work. SessionEnd hooks start concurrently and share a 30-second abort budget. + +## Scope + +This change covers ACP process teardown and the connection races introduced by graceful teardown. It does not close sessions when the user switches conversations and does not change hook process-tree ownership, which is handled separately. + +## Verification + +- Disconnect closes stdin before any forced termination. +- POSIX and Windows escalation target the complete child tree. +- A normal child exit cancels escalation. +- A retired child or response cannot clear or update its replacement. +- EOF and signal shutdown execute each cleanup phase once, and all SessionEnd hooks begin within the shared deadline. diff --git a/docs/design/vscode-acp-graceful-shutdown.zh-CN.md b/docs/design/vscode-acp-graceful-shutdown.zh-CN.md new file mode 100644 index 00000000000..d68d6a719f5 --- /dev/null +++ b/docs/design/vscode-acp-graceful-shutdown.zh-CN.md @@ -0,0 +1,30 @@ +# VS Code ACP 优雅退出 + +[English](./vscode-acp-graceful-shutdown.md) + +## 问题 + +VS Code companion 在面板断开时会立即终止 ACP 子进程。Windows 上这种方式会绕过 CLI 的退出流程,可能遗留 shell 和 ConPTY 后代进程。优雅退出期间旧进程还可能与新连接短暂并存,因此旧进程的退出事件或异步响应不能清理新连接持有的状态。 + +## 决策 + +companion 首先关闭 ACP 子进程的 stdin。CLI 将传输关闭作为正常退出处理,触发 SessionEnd hooks、排空 MCP 客户端、释放会话并执行进程清理。 + +退出流程有明确上限: + +- POSIX 上 ACP 子进程作为进程组组长。75 秒后 companion 向该进程组发送 SIGTERM,再等待 75 秒后发送 SIGKILL。 +- Windows 上等待 75 秒后,companion 使用 System32 下的绝对 `taskkill.exe` 路径和 `/f /t` 参数终止进程树。 +- 退出处理器和异步响应绑定到创建它们的子进程与连接,已退役进程不能修改替代连接。 +- EOF 与信号触发的重叠退出共享同一次 SessionEnd、MCP 排空、会话释放和已注册进程清理。所有 SessionEnd hooks 并发启动,并共享 30 秒的中止时限。 + +## 范围 + +本次只处理 ACP 进程退出,以及优雅退出引入的连接竞态。不处理用户切换会话时关闭旧会话,也不改变 hook 进程树的归属;后者由独立改动处理。 + +## 验证 + +- 断开连接时先关闭 stdin,不立即强制终止。 +- POSIX 与 Windows 的升级路径都覆盖完整子进程树。 +- 子进程正常退出后取消升级计时器。 +- 已退役子进程的退出或响应不能清理或更新替代连接。 +- EOF 与信号重叠时每个清理阶段只执行一次,且所有 SessionEnd hooks 都在共享时限内启动。 diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 2265e01fc07..64917d28cdf 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -1686,98 +1686,7 @@ describe('runAcpAgent shutdown cleanup', () => { await agentPromise; }); - it('SIGTERM during ide_close drain runs shutdownMcpPool once', async () => { - // The VS Code extension escalates to SIGTERM once its shutdown grace - // expires; when that lands mid-ide_close, the signal path must join the - // in-flight MCP pool drain instead of running shutdownMcpPool again. - const { agent, agentPromise } = await startPreloadTestAgent(); - expect(agent).toBeDefined(); - - let resolveDrain!: () => void; - const shutdownMcpPool = vi.fn( - () => - new Promise((resolve) => { - resolveDrain = resolve; - }), - ); - const disposeSessions = vi.fn().mockResolvedValue(undefined); - Object.assign(agent!, { shutdownMcpPool, disposeSessions }); - - await vi.waitFor(() => { - expect(sigTermListeners.length).toBeGreaterThan(0); - }); - - // connection.closed resolving drives the ide_close path, which parks on - // the drain held open here. - mockConnectionState.resolve(); - await vi.waitFor(() => { - expect(shutdownMcpPool).toHaveBeenCalledTimes(1); - }); - - sigTermListeners[0]('SIGTERM'); - // The signal path passes disposeSessions and then joins the parked drain; - // its runExitCleanup can only run once the shared drain settles. - await vi.waitFor(() => { - expect(disposeSessions).toHaveBeenCalledTimes(1); - }); - expect(shutdownMcpPool).toHaveBeenCalledTimes(1); - expect(mockRunExitCleanup).not.toHaveBeenCalled(); - - resolveDrain(); - await agentPromise; - await vi.waitFor(() => { - expect(processExitSpy).toHaveBeenCalledWith(0); - }); - - expect(shutdownMcpPool).toHaveBeenCalledTimes(1); - expect(disposeSessions).toHaveBeenCalledTimes(1); - expect(mockRunExitCleanup).toHaveBeenCalledTimes(1); - }); - - it('SIGTERM during ide_close disposes sessions once', async () => { - // Same overlap, one step later: the signal path must join the in-flight - // disposeSessions instead of snapshotting the same sessions and running - // closeStoredSession (beginClose/abort) for each a second time. - const { agent, agentPromise } = await startPreloadTestAgent(); - expect(agent).toBeDefined(); - - const shutdownMcpPool = vi.fn().mockResolvedValue(undefined); - let resolveDispose!: () => void; - const disposeSessions = vi.fn( - () => - new Promise((resolve) => { - resolveDispose = resolve; - }), - ); - Object.assign(agent!, { shutdownMcpPool, disposeSessions }); - - await vi.waitFor(() => { - expect(sigTermListeners.length).toBeGreaterThan(0); - }); - - // ide_close drains (the mock resolves immediately) and then parks on the - // dispose held open here. - mockConnectionState.resolve(); - await vi.waitFor(() => { - expect(disposeSessions).toHaveBeenCalledTimes(1); - }); - - sigTermListeners[0]('SIGTERM'); - await flushImmediate(); - expect(disposeSessions).toHaveBeenCalledTimes(1); - expect(mockRunExitCleanup).not.toHaveBeenCalled(); - - resolveDispose(); - await agentPromise; - await vi.waitFor(() => { - expect(processExitSpy).toHaveBeenCalledWith(0); - }); - - expect(disposeSessions).toHaveBeenCalledTimes(1); - expect(shutdownMcpPool).toHaveBeenCalledTimes(1); - }); - - it('SIGTERM during ide_close SessionEnd waits for the in-flight hook', async () => { + it('shares in-flight cleanup when SIGTERM overlaps an IDE close', async () => { let resolveHook!: () => void; const fireSessionEndEvent = vi.fn( () => @@ -1789,16 +1698,11 @@ describe('runAcpAgent shutdown cleanup', () => { fireSessionEndEvent, }); mockConfig.hasHooksForEvent = vi.fn().mockReturnValue(true); - const { agent, agentPromise } = await startPreloadTestAgent(); expect(agent).toBeDefined(); - const disposeSessions = vi.fn().mockResolvedValue(undefined); const shutdownMcpPool = vi.fn().mockResolvedValue(undefined); - Object.assign(agent!, { disposeSessions, shutdownMcpPool }); - - await vi.waitFor(() => { - expect(sigTermListeners.length).toBeGreaterThan(0); - }); + const disposeSessions = vi.fn().mockResolvedValue(undefined); + Object.assign(agent!, { shutdownMcpPool, disposeSessions }); mockConnectionState.resolve(); await vi.waitFor(() => { @@ -1807,19 +1711,17 @@ describe('runAcpAgent shutdown cleanup', () => { expect.any(AbortSignal), ); }); - sigTermListeners[0]('SIGTERM'); await flushImmediate(); expect(disposeSessions).not.toHaveBeenCalled(); - expect(processExitSpy).not.toHaveBeenCalledWith(0); resolveHook(); await agentPromise; - await vi.waitFor(() => { - expect(processExitSpy).toHaveBeenCalledWith(0); - }); - expect(disposeSessions).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(processExitSpy).toHaveBeenCalledWith(0)); + + expect(fireSessionEndEvent).toHaveBeenCalledTimes(1); expect(shutdownMcpPool).toHaveBeenCalledTimes(1); + expect(disposeSessions).toHaveBeenCalledTimes(1); }); it('still exits even if runExitCleanup throws', async () => { @@ -2210,32 +2112,24 @@ describe('runAcpAgent SessionEnd hooks', () => { expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledTimes(1); }); - it('bounds SessionEnd hooks when SIGTERM arrives before connection.closed', async () => { - const fireSessionEndEvent = vi.fn( + it('aborts a SessionEnd hook after the shutdown budget', async () => { + mockHookSystem.fireSessionEndEvent.mockImplementation( (_reason: SessionEndReason, signal?: AbortSignal) => new Promise((resolve) => { signal?.addEventListener('abort', () => resolve(), { once: true }); }), ); - mockConfig.getHookSystem = vi.fn().mockReturnValue({ - fireSessionEndEvent, - }); - mockConfig.hasHooksForEvent = vi.fn().mockReturnValue(true); - const agentPromise = runAcpAgent(mockConfig, mockSettings, mockArgv); - await vi.waitFor(() => { - expect(sigTermListeners.length).toBeGreaterThan(0); - }); + await vi.waitFor(() => expect(sigTermListeners.length).toBeGreaterThan(0)); vi.useFakeTimers(); try { sigTermListeners[0]('SIGTERM'); - expect(fireSessionEndEvent).toHaveBeenCalledWith( + await Promise.resolve(); + expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith( SessionEndReason.Other, expect.any(AbortSignal), ); - expect(mockRunExitCleanup).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(30_000); await vi.waitFor(() => { expect(mockRunExitCleanup).toHaveBeenCalledTimes(1); @@ -21347,8 +21241,14 @@ describe('QwenAgent MCP SSE/HTTP support', () => { .mockImplementation((event: string) => event === 'SessionEnd'); const innerConfigA = await setupSessionMocks('session-end-a'); + let resolveSessionEndA!: () => void; const sessionHookSystemA = { - fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), + fireSessionEndEvent: vi.fn( + () => + new Promise((resolve) => { + resolveSessionEndA = resolve; + }), + ), fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), }; innerConfigA.getHookSystem = vi.fn().mockReturnValue(sessionHookSystemA); @@ -21413,6 +21313,11 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agent.newSession({ cwd: '/tmp', mcpServers: [] }); mockConnectionState.resolve(); + await vi.waitFor(() => { + expect(sessionHookSystemA.fireSessionEndEvent).toHaveBeenCalled(); + expect(sessionHookSystemB.fireSessionEndEvent).toHaveBeenCalled(); + }); + resolveSessionEndA(); await agentPromise; expect(bootstrapHookSystem.fireSessionEndEvent).toHaveBeenCalledWith( diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index e44b9268f78..8740bd4ec97 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -2911,41 +2911,26 @@ export async function runAcpAgent( // Both the SIGTERM handler and the IDE-initiated close path need // to drain the MCP pool before runExitCleanup. Single helper - // closure keeps the timeout + log labels consistent. The memoized - // promise makes it idempotent: a SIGTERM landing mid-ide_close (the - // VS Code extension escalates to SIGTERM once its shutdown grace - // expires) joins the in-flight drain instead of running - // shutdownMcpPool a second time. First call wins; later calls — - // including a stricter one — join it. + // closure keeps the timeout + log labels consistent. let drainPoolPromise: Promise | undefined; const drainPoolBeforeExit = async ( label: string, strict = false, ): Promise => { if (!agentInstance) return; - if (drainPoolPromise) return drainPoolPromise; - drainPoolPromise = (async () => { - try { - await agentInstance?.shutdownMcpPool(8_000); - } catch (err) { - debugLogger.error(`[ACP] MCP pool drain (${label}) error:`, err); - if (strict) throw err; - } - })(); - return drainPoolPromise; + try { + drainPoolPromise ??= agentInstance.shutdownMcpPool(8_000); + await drainPoolPromise; + } catch (err) { + debugLogger.error(`[ACP] MCP pool drain (${label}) error:`, err); + if (strict) throw err; + } }; - // disposeSessions() is idempotent per call but not under concurrency: two - // overlapping calls snapshot the same session entries and each runs - // closeStoredSession for them (double beginClose, double abort). SIGTERM - // landing mid-ide_close is exactly that overlap, so both shutdown paths - // share one in-flight dispose. let disposeSessionsPromise: Promise | undefined; const disposeSessionsOnce = (): Promise => { if (!agentInstance) return Promise.resolve(); - if (!disposeSessionsPromise) { - disposeSessionsPromise = agentInstance.disposeSessions(); - } + disposeSessionsPromise ??= agentInstance.disposeSessions(); return disposeSessionsPromise; }; @@ -2979,52 +2964,46 @@ export async function runAcpAgent( } } - // Shutdown has a bounded hook budget for every entry point. The signal - // path can arrive before connection.closed (for example when the - // process receives SIGTERM directly), so leaving Other unbounded would - // let a slow hook outlive the companion's escalation window. The signal - // also lets the hook runner terminate its child process tree instead of - // merely abandoning the promise. - const sessionEndHookTimeoutMs = 30_000; - const hookAbortController = new AbortController(); - const hookTimeout = setTimeout( - () => hookAbortController.abort(), - sessionEndHookTimeoutMs, - ); - hookTimeout.unref(); - + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30_000); + timeout.unref(); try { - const failures: unknown[] = []; - for (const cfg of configs) { - const hookSystem = cfg.getHookSystem?.(); - const hooksEnabled = !cfg.getDisableAllHooks?.(); - if ( - !hooksEnabled || - !hookSystem || - !cfg.hasHooksForEvent?.('SessionEnd') - ) { - continue; - } - try { - await hookSystem.fireSessionEndEvent( - reason, - hookAbortController.signal, - ); - } catch (err) { - if (managedConfigs) failures.push(err); - debugLogger.warn( - `SessionEnd hook failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } + const results = await Promise.allSettled( + [...configs].flatMap((cfg) => { + const hookSystem = cfg.getHookSystem?.(); + if ( + cfg.getDisableAllHooks?.() || + !hookSystem || + typeof hookSystem.fireSessionEndEvent !== 'function' || + !cfg.hasHooksForEvent?.('SessionEnd') + ) { + return []; + } + return [ + Promise.resolve().then(() => + hookSystem.fireSessionEndEvent(reason, controller.signal), + ), + ]; + }), + ); + const failures = results + .filter( + (result): result is PromiseRejectedResult => + result.status === 'rejected', + ) + .map((result) => result.reason); + for (const failure of failures) { + debugLogger.warn( + `SessionEnd hook failed: ${failure instanceof Error ? failure.message : String(failure)}`, + ); } - if (failures.length > 0) { + if (managedConfigs && failures.length > 0) { throw new AggregateError(failures, 'SessionEnd hook shutdown failed'); } } finally { - if (hookTimeout) clearTimeout(hookTimeout); + clearTimeout(timeout); } })(); - return sessionEndPromise; }; diff --git a/packages/cli/src/utils/cleanup.test.ts b/packages/cli/src/utils/cleanup.test.ts index 80f2fe5afba..d5280017981 100644 --- a/packages/cli/src/utils/cleanup.test.ts +++ b/packages/cli/src/utils/cleanup.test.ts @@ -52,6 +52,26 @@ describe('cleanup', () => { expect(asyncFn).toHaveBeenCalledTimes(1); }); + it('shares an in-flight cleanup pass between concurrent callers', async () => { + let finishCleanup: (() => void) | undefined; + const cleanupFn = vi.fn( + () => + new Promise((resolve) => { + finishCleanup = resolve; + }), + ); + registerCleanup(cleanupFn); + + const first = runExitCleanup(); + await vi.waitFor(() => expect(cleanupFn).toHaveBeenCalledOnce()); + const second = runExitCleanup(); + + expect(second).toBe(first); + finishCleanup?.(); + await Promise.all([first, second]); + expect(cleanupFn).toHaveBeenCalledOnce(); + }); + it('should let a caller unregister a cleanup', async () => { const cleanupFn = vi.fn(); const unregister = registerCleanup(cleanupFn); diff --git a/packages/cli/src/utils/cleanup.ts b/packages/cli/src/utils/cleanup.ts index 8a0c28e68de..4e092745fe9 100644 --- a/packages/cli/src/utils/cleanup.ts +++ b/packages/cli/src/utils/cleanup.ts @@ -8,6 +8,7 @@ import { promises as fs } from 'node:fs'; import { join } from 'node:path'; const cleanupFunctions: Array<(() => void) | (() => Promise)> = []; +let exitCleanupPromise: Promise | undefined; export function registerCleanup( fn: (() => void) | (() => Promise), @@ -65,8 +66,19 @@ export interface RunExitCleanupOptions { _testOverallTimeoutMs?: number; } -export async function runExitCleanup( +export function runExitCleanup( options: RunExitCleanupOptions = {}, +): Promise { + if (exitCleanupPromise) return exitCleanupPromise; + const cleanup = runExitCleanupPass(options).finally(() => { + if (exitCleanupPromise === cleanup) exitCleanupPromise = undefined; + }); + exitCleanupPromise = cleanup; + return cleanup; +} + +async function runExitCleanupPass( + options: RunExitCleanupOptions, ): Promise { const perFn = options._testPerFnTimeoutMs ?? PER_CLEANUP_TIMEOUT_MS; const overall = options._testOverallTimeoutMs ?? OVERALL_CLEANUP_TIMEOUT_MS; diff --git a/packages/vscode-ide-companion/src/services/acpConnection.test.ts b/packages/vscode-ide-companion/src/services/acpConnection.test.ts index 3d8c236ded4..6c6573aa425 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.test.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.test.ts @@ -5,30 +5,16 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Mock } from 'vitest'; import { RequestError } from '@agentclientprotocol/sdk'; -import type { ContentBlock } from '@agentclientprotocol/sdk'; -import { PassThrough } from 'node:stream'; -import { logger } from '../utils/logger.js'; +import type { + ContentBlock, + LoadSessionResponse, + NewSessionResponse, + PromptResponse, +} from '@agentclientprotocol/sdk'; const spawnMock = vi.hoisted(() => vi.fn()); const execFileMock = vi.hoisted(() => vi.fn()); -const sdkClientFactory = vi.hoisted(() => ({ - factory: null as null | ((agent: unknown) => Record), -})); - -// Mirrors the module-private SHUTDOWN_GRACE_MS in acpConnection.ts. Kept as a -// literal here on purpose: the escalation tests step to just before and just -// after the deadline, so a grace that changes without these tests changing -// fails them instead of silently widening or vacating the pin. -const SHUTDOWN_GRACE_MS = 75_000; -// Same pin for the second rung of the POSIX escalation ladder -// (SIGTERM_GRACE_MS): SIGKILL must not land until this long after SIGTERM. -const SIGTERM_GRACE_MS = 75_000; -// Same pin for the refused-close backoff rungs (CLOSE_RETRY_BASE_MS and -// CLOSE_RETRY_CEILING_MS): 60s, doubling, capped at 1h. -const CLOSE_RETRY_BASE_MS = 60_000; -const CLOSE_RETRY_CEILING_MS = 3_600_000; // AcpConnection imports AcpFileHandler which imports vscode. // Mock vscode so it can be resolved without the actual VS Code runtime. @@ -37,42 +23,29 @@ vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, spawn: spawnMock, execFile: execFileMock }; }); -vi.mock('@agentclientprotocol/sdk', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - // Capture the Client factory so tests can drive the inbound callbacks - // (sessionUpdate / writeTextFile / ...) that guard against a superseded - // connection. The real SDK would hold this factory internally. - ClientSideConnection: class { - constructor(factory: (agent: unknown) => Record) { - sdkClientFactory.factory = factory; - } - initialize = vi.fn().mockResolvedValue({ protocolVersion: '1.0' }); - }, - ndJsonStream: () => ({}), - }; -}); import { AcpConnection } from './acpConnection.js'; import { ACP_ERROR_CODES } from '../constants/acpSchema.js'; -type AcpConnectionInternal = { - child: { - killed: boolean; - exitCode: number | null; - signalCode?: string | null; - pid?: number; - kill?: () => void; - stdin?: { - end: () => void; - destroyed?: boolean; - writableEnded?: boolean; - once?: (event: string, listener: () => void) => unknown; - } | null; - once?: (event: string, listener: () => void) => unknown; +type MockChild = { + killed: boolean; + exitCode: number | null; + signalCode?: NodeJS.Signals | null; + pid?: number; + kill?: (signal?: NodeJS.Signals) => boolean; + stdin?: { + destroyed?: boolean; + writableEnded?: boolean; + end: () => void; + once: (event: string, listener: () => void) => unknown; } | null; + stderr?: { on: (event: string, listener: (data: Buffer) => void) => unknown }; + on?: (event: string, listener: (...args: unknown[]) => void) => unknown; + once?: (event: string, listener: () => void) => unknown; +}; + +type AcpConnectionInternal = { + child: MockChild | null; sdkConnection: unknown; sessionId: string | null; lastExitCode: number | null; @@ -89,23 +62,22 @@ function createConnection(overrides?: Partial) { return conn; } -function createMockStdin(end = vi.fn()) { - return { end, destroyed: false, writableEnded: false, once: vi.fn() }; -} - -function createMockChild( - overrides?: Record, -): NonNullable { +function createMockChild(overrides?: Record) { return { killed: false, exitCode: null, signalCode: null, pid: 4242, - kill: vi.fn(), - stdin: createMockStdin(), + kill: vi.fn().mockReturnValue(true), + stdin: { + destroyed: false, + writableEnded: false, + end: vi.fn(), + once: vi.fn(), + }, once: vi.fn(), ...overrides, - } as unknown as NonNullable; + } as MockChild; } describe('AcpConnection process spawning', () => { @@ -132,38 +104,21 @@ describe('AcpConnection process spawning', () => { } }); - it('spawns the child detached on POSIX but not on Windows', async () => { - // The POSIX escalation is a process-group signal (process.kill(-pid, - // ...)): it reaches the CLI's whole group only because the child is - // spawned detached and so leads its own group. Windows has no signalable - // group — its tree kill goes through taskkill, and there `detached` only - // changes console attachment. Pin both sides so a future refactor of the - // options object cannot silently turn the group signal into a root-only - // one that orphans every descendant (the #11303 leak). - spawnMock.mockClear(); + it('creates a POSIX process group for shutdown escalation', async () => { spawnMock.mockReturnValue(createMockChild()); - const makeConn = () => { - const conn = new AcpConnection() as unknown as { - connect: (cliEntryPath: string) => Promise; - setupChildProcessHandlers: () => Promise; - }; - conn.setupChildProcessHandlers = vi.fn().mockResolvedValue(undefined); - return conn; + const conn = new AcpConnection() as unknown as { + connect: (cliEntryPath: string) => Promise; + setupChildProcessHandlers: () => Promise; }; - const platform = vi.spyOn(process, 'platform', 'get'); - try { - platform.mockReturnValue('linux'); - await makeConn().connect(process.execPath); - platform.mockReturnValue('win32'); - await makeConn().connect(process.execPath); + conn.setupChildProcessHandlers = vi.fn().mockResolvedValue(undefined); - const detachedAt = (i: number) => - (spawnMock.mock.calls[i]?.[2] as { detached?: boolean }).detached; - expect(detachedAt(0)).toBe(true); - expect(detachedAt(1)).toBe(false); - } finally { - platform.mockRestore(); - } + await conn.connect(process.execPath); + + expect(spawnMock).toHaveBeenCalledWith( + process.execPath, + expect.any(Array), + expect.objectContaining({ detached: process.platform !== 'win32' }), + ); }); }); @@ -298,8 +253,8 @@ describe('AcpConnection.ensureConnection', () => { describe('AcpConnection child exit cleanup', () => { beforeEach(() => { - execFileMock.mockReset(); vi.useFakeTimers(); + execFileMock.mockReset(); }); afterEach(() => { @@ -322,1227 +277,197 @@ describe('AcpConnection child exit cleanup', () => { expect(acpConn.currentSessionId).toBeNull(); }); - it('disconnect is a no-op when there is no child', () => { - const conn = createConnection({ child: null }); - expect(() => (conn as unknown as AcpConnection).disconnect()).not.toThrow(); - }); - - it('disconnect closes the CLI stdin instead of killing it (#11303)', () => { - // `child.kill()` is TerminateProcess on Windows: the CLI's - // `process.on('exit')` cleanup never runs, so every PTY, ConPTY host and - // child process it is tracking is orphaned. Ending stdin closes the ACP - // stream, which is the CLI's own graceful shutdown path. + it('disconnect closes stdin before escalating', () => { const mockKill = vi.fn(); - const mockEnd = vi.fn(); + const end = vi.fn(); const conn = createConnection({ child: createMockChild({ kill: mockKill, - stdin: createMockStdin(mockEnd), + stdin: { + destroyed: false, + writableEnded: false, + end, + once: vi.fn(), + }, }), sdkConnection: {}, sessionId: 'test-session', }); (conn as unknown as AcpConnection).disconnect(); - - expect(mockEnd).toHaveBeenCalledOnce(); - expect(mockKill).not.toHaveBeenCalled(); - }); - - it('does not force-kill a child that failed to spawn', () => { - const mockKill = vi.fn(); - const conn = createConnection({ - child: createMockChild({ kill: mockKill, pid: undefined }), - }); - - (conn as unknown as AcpConnection).disconnect(); - vi.advanceTimersByTime(SHUTDOWN_GRACE_MS + SIGTERM_GRACE_MS); - - expect(execFileMock).not.toHaveBeenCalled(); + expect(end).toHaveBeenCalledOnce(); expect(mockKill).not.toHaveBeenCalled(); }); - it('does not end stdin that is already closed', () => { - // Even when stdin cannot be ended (already closed), the escalation timer - // must still be armed: an early return here would leave the CLI's process - // group running forever. Assert the escalation ladder still climbs past - // the grace. - vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); - const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); + it('disconnect closes stdin even when the child has no pid', () => { const end = vi.fn(); const conn = createConnection({ child: createMockChild({ - stdin: { ...createMockStdin(end), writableEnded: true }, - }), - }); - - (conn as unknown as AcpConnection).disconnect(); - - expect(end).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(SHUTDOWN_GRACE_MS); - expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGTERM'); - vi.advanceTimersByTime(SIGTERM_GRACE_MS); - expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGKILL'); - }); - - it('handles a synchronous stdin close failure', () => { - // A synchronous stdin.end() failure (e.g. EPIPE) must not short-circuit - // disconnect(): the escalation timer still has to be armed, or a failing - // stdin close leaves the CLI's process group running forever. - vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); - const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); - const closeError = new Error('EPIPE'); - const once = vi.fn(); - const logError = vi.spyOn(logger, 'error').mockImplementation(() => {}); - const conn = createConnection({ - child: createMockChild({ + pid: undefined, stdin: { - ...createMockStdin( - vi.fn(() => { - throw closeError; - }), - ), - once, + destroyed: false, + writableEnded: false, + end, + once: vi.fn(), }, }), }); - expect(() => (conn as unknown as AcpConnection).disconnect()).not.toThrow(); - expect(once).toHaveBeenCalledWith('error', expect.any(Function)); - expect(logError).toHaveBeenCalledWith( - '[ACP] Failed to close CLI stdin during disconnect:', - closeError, - ); - - vi.advanceTimersByTime(SHUTDOWN_GRACE_MS); - expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGTERM'); - vi.advanceTimersByTime(SIGTERM_GRACE_MS); - expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGKILL'); - }); - - it('disconnect escalates stdin close → SIGTERM → SIGKILL on POSIX', () => { - // Pinned so the assertion does not depend on which runner executes it. - const platform = vi - .spyOn(process, 'platform', 'get') - .mockReturnValue('linux'); - const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); - try { - const mockKill = vi.fn(); - const child = createMockChild({ kill: mockKill }); - const conn = createConnection({ - child, - sdkConnection: {}, - sessionId: 'test-session', - }); - - (conn as unknown as AcpConnection).disconnect(); - expect(mockKill).not.toHaveBeenCalled(); - expect(killSpy).not.toHaveBeenCalled(); - - // The grace has to outlast the CLI's own wind-down (8s MCP pool drain + - // 30s session drain), so nothing may be signalled one tick before it - // expires. A grace shorter than that wind-down reds this assertion. - vi.advanceTimersByTime(SHUTDOWN_GRACE_MS - 1); - expect(killSpy).not.toHaveBeenCalled(); - - // First rung: SIGTERM to the process GROUP (negative pid), not a bare - // kill(). SIGTERM stays catchable, so the CLI's signal cleanup and its - // exit-time reaper still run before the last rung. Removing the group - // signal or jumping straight to SIGKILL reds this. - vi.advanceTimersByTime(1); - expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGTERM'); - expect(killSpy).not.toHaveBeenCalledWith(-4242, 'SIGKILL'); - expect(mockKill).not.toHaveBeenCalled(); - - // Second rung, SIGTERM_GRACE_MS later: SIGKILL to the same group. - vi.advanceTimersByTime(SIGTERM_GRACE_MS - 1); - expect(killSpy).not.toHaveBeenCalledWith(-4242, 'SIGKILL'); - vi.advanceTimersByTime(1); - expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGKILL'); - } finally { - killSpy.mockRestore(); - platform.mockRestore(); - } - }); - - it('disconnect does not escalate against a CLI that exited on its own', () => { - vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); - const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); - try { - const mockKill = vi.fn(); - let exitListener: (() => void) | undefined; - const child = createMockChild({ - kill: mockKill, - once: vi.fn((event: string, listener: () => void) => { - if (event === 'exit') exitListener = listener; - }), - }); - const conn = createConnection({ - child, - sdkConnection: {}, - sessionId: 'test-session', - }); - - (conn as unknown as AcpConnection).disconnect(); - exitListener?.(); - // Past both deadlines the cancelled timers would have fired at, - // otherwise the cancellation this test pins is never exercised. - vi.advanceTimersByTime(SHUTDOWN_GRACE_MS + SIGTERM_GRACE_MS); + (conn as unknown as AcpConnection).disconnect(); - expect(killSpy).not.toHaveBeenCalled(); - expect(mockKill).not.toHaveBeenCalled(); - } finally { - killSpy.mockRestore(); - } + expect(end).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); }); - it('does not force-kill a CLI that exits within the SIGTERM grace', () => { - // The ladder stops once the child exits: SIGTERM landed, and the SIGKILL - // rung must never fire for a CLI that is already winding down. - vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); - const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); - try { - const mockKill = vi.fn(); - let exitListener: (() => void) | undefined; - const child = createMockChild({ - kill: mockKill, - once: vi.fn((event: string, listener: () => void) => { - if (event === 'exit') exitListener = listener; - }), - }); - const conn = createConnection({ - child, - sdkConnection: {}, - sessionId: 'test-session', - }); - - (conn as unknown as AcpConnection).disconnect(); - vi.advanceTimersByTime(SHUTDOWN_GRACE_MS); - expect(killSpy).toHaveBeenCalledWith(-4242, 'SIGTERM'); - - exitListener?.(); - vi.advanceTimersByTime(SIGTERM_GRACE_MS); - - expect(killSpy).not.toHaveBeenCalledWith(-4242, 'SIGKILL'); - expect(mockKill).not.toHaveBeenCalled(); - } finally { - killSpy.mockRestore(); - } - }); + it('escalates to the POSIX process group after both grace periods', () => { + if (process.platform === 'win32') return; + const kill = vi.spyOn(process, 'kill').mockReturnValue(true); + const conn = createConnection({ child: createMockChild() }); - it('does not signal after exitCode or signalCode is observed', () => { - vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); - const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); - try { - for (const exitInfo of [ - { exitCode: 0, signalCode: null }, - { exitCode: null, signalCode: 'SIGTERM' }, - ]) { - const conn = createConnection({ - child: createMockChild(exitInfo), - }); - (conn as unknown as AcpConnection).disconnect(); - vi.advanceTimersByTime(SHUTDOWN_GRACE_MS + SIGTERM_GRACE_MS); - } - expect(killSpy).not.toHaveBeenCalled(); - } finally { - killSpy.mockRestore(); - } + (conn as unknown as AcpConnection).disconnect(); + vi.advanceTimersByTime(75_000); + expect(kill).toHaveBeenCalledWith(-4242, 'SIGTERM'); + vi.advanceTimersByTime(75_000); + expect(kill).toHaveBeenCalledWith(-4242, 'SIGKILL'); }); - it('escalates through taskkill /t on Windows, not a bare kill', () => { - // Windows CI is skipped on PRs, so the platform is faked here rather than - // left to whichever runner happens to execute the suite. - const platform = vi - .spyOn(process, 'platform', 'get') - .mockReturnValue('win32'); - try { - const mockKill = vi.fn(); - const conn = createConnection({ - child: createMockChild({ kill: mockKill, pid: 4242 }), - sdkConnection: {}, - sessionId: 'test-session', - }); - - (conn as unknown as AcpConnection).disconnect(); - vi.advanceTimersByTime(SHUTDOWN_GRACE_MS); + it('falls back to signalling the child when POSIX group signalling fails', () => { + if (process.platform === 'win32') return; + vi.spyOn(process, 'kill').mockImplementation(() => { + throw new Error('missing process group'); + }); + const childKill = vi.fn().mockReturnValue(true); + const conn = createConnection({ + child: createMockChild({ kill: childKill }), + }); - // A tree kill: the CLI is unresponsive by now, so nothing else will reap - // the shells and ConPTY hosts underneath it. See #11303. - expect(execFileMock).toHaveBeenCalledWith( - expect.stringMatching(/\\System32\\taskkill\.exe$/i), - ['/f', '/t', '/pid', '4242'], - expect.objectContaining({ windowsHide: true, timeout: 2_000 }), - expect.any(Function), - ); - expect(mockKill).not.toHaveBeenCalled(); - } finally { - platform.mockRestore(); - } + (conn as unknown as AcpConnection).disconnect(); + vi.advanceTimersByTime(75_000); + expect(childKill).toHaveBeenCalledWith('SIGTERM'); + vi.advanceTimersByTime(75_000); + expect(childKill).toHaveBeenCalledWith('SIGKILL'); }); - it('falls back when taskkill cannot terminate the CLI tree', () => { + it('uses taskkill for an unresponsive Windows process tree', () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); - execFileMock.mockImplementation( - ( - _file: string, - _args: string[], - _options: object, - callback: (error: Error | null) => void, - ) => { - callback(new Error('ERROR_ACCESS_DENIED')); - }, - ); - const mockKill = vi.fn(); + const childKill = vi.fn(); const conn = createConnection({ - child: createMockChild({ kill: mockKill }), + child: createMockChild({ kill: childKill }), }); (conn as unknown as AcpConnection).disconnect(); - vi.advanceTimersByTime(SHUTDOWN_GRACE_MS); + vi.advanceTimersByTime(75_000); - expect(mockKill).toHaveBeenCalledOnce(); + expect(execFileMock).toHaveBeenCalledWith( + expect.stringMatching(/\\System32\\taskkill\.exe$/i), + ['/f', '/t', '/pid', '4242'], + expect.objectContaining({ windowsHide: true }), + expect.any(Function), + ); + expect(childKill).not.toHaveBeenCalled(); }); - it('a superseded child exiting does not tear down its replacement', async () => { - // disconnect() now lets the CLI wind down on its own, so a superseded child - // can still be exiting after connect() installed its replacement. An exit - // handler keyed only on `this.child` would null out the live connection. - let exitHandler: - | ((code: number | null, signal: string | null) => void) - | undefined; - const oldChild = createMockChild({ - on: vi.fn((event: string, listener: unknown) => { - if (event === 'exit') { - exitHandler = listener as ( - code: number | null, - signal: string | null, - ) => void; - } + it('cancels escalation when the child exits normally', () => { + let onExit: (() => void) | undefined; + const kill = vi.spyOn(process, 'kill').mockReturnValue(true); + const child = createMockChild({ + once: vi.fn((event: string, listener: () => void) => { + if (event === 'exit') onExit = listener; }), }); - const conn = createConnection({ child: oldChild }); - const acpConn = conn as unknown as AcpConnection; - const onDisconnected = vi.fn(); - acpConn.onDisconnected = onDisconnected; - - // Only the listener wiring matters here; the rest of the setup (its 1s - // settle, the web-stream conversion) has nothing to assert on these mocks. - void (conn as unknown as { setupChildProcessHandlers: () => Promise }) - .setupChildProcessHandlers() - .catch(() => {}); - - // connect() has since replaced the child. - const newChild = createMockChild(); - conn.child = newChild; - conn.sdkConnection = {}; - conn.sessionId = 'replacement-session'; - - exitHandler?.(0, null); - - expect(conn.child).toBe(newChild); - expect(conn.sdkConnection).toEqual({}); - expect(conn.sessionId).toBe('replacement-session'); - expect(onDisconnected).not.toHaveBeenCalled(); - // The exit also rejects the promise initialize() races. Nothing has - // attached to it at this point, so it must already be marked handled or - // this is an unhandled rejection in the extension host — vitest reports it - // as a suite error even with every test green. - await Promise.resolve(); - }); - - it('does not wire replacement streams into a retired startup', async () => { - vi.useFakeTimers(); - try { - const oldChild = createMockChild({ - stdout: new PassThrough(), - stdin: new PassThrough(), - on: vi.fn(), - }); - const newChild = createMockChild({ - stdout: new PassThrough(), - stdin: new PassThrough(), - on: vi.fn(), - }); - const conn = createConnection({ child: oldChild }); - const setup = ( - conn as unknown as { - setupChildProcessHandlers: () => Promise; - } - ).setupChildProcessHandlers(); - const setupFailure = await expect(setup).rejects.toThrow( - /failed to start|superseded/i, - ); + const conn = createConnection({ child }); - conn.child = newChild; - await vi.advanceTimersByTimeAsync(1000); + (conn as unknown as AcpConnection).disconnect(); + onExit?.(); + vi.advanceTimersByTime(150_000); - await setupFailure; - expect(conn.sdkConnection).toBeNull(); - } finally { - vi.useRealTimers(); - } + expect(kill).not.toHaveBeenCalled(); + expect(child.kill).not.toHaveBeenCalled(); }); - it('a live child exiting clears the connection and fires onDisconnected', async () => { - // The exit-handler teardown is keyed on the connection still being - // current. For the CURRENT direction (no supersede), a live child exit - // must clear child/sdkConnection/sessionId and fire onDisconnected, or - // the teardown silently never runs. A mutant like - // `if (this.child === ownChild && !ownChild)` (always false) reds this. + it('ignores an exit from a child replaced during graceful shutdown', async () => { let exitHandler: | ((code: number | null, signal: string | null) => void) | undefined; - const child = createMockChild({ - on: vi.fn((event: string, listener: unknown) => { + const oldChild = createMockChild({ + stderr: { on: vi.fn() }, + on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { if (event === 'exit') { - exitHandler = listener as ( - code: number | null, - signal: string | null, - ) => void; + exitHandler = listener as typeof exitHandler; } }), }); - const conn = createConnection({ child }); - const acpConn = conn as unknown as AcpConnection; - const onDisconnected = vi.fn(); - acpConn.onDisconnected = onDisconnected; - conn.sdkConnection = { initialize: vi.fn() }; - conn.sessionId = 'test-session'; - - // Only the listener wiring matters here; the rest of the setup (its 1s - // settle, the web-stream conversion) has nothing to assert on these mocks. - void (conn as unknown as { setupChildProcessHandlers: () => Promise }) - .setupChildProcessHandlers() - .catch(() => {}); + const conn = createConnection({ child: oldChild }); + const setup = ( + conn as unknown as { setupChildProcessHandlers: () => Promise } + ).setupChildProcessHandlers(); + void setup.catch(() => {}); + const replacement = createMockChild(); + conn.child = replacement; + conn.sdkConnection = {}; + conn.sessionId = 'replacement'; + conn.lastExitCode = 7; + conn.lastExitSignal = 'SIGTERM'; exitHandler?.(0, null); + await vi.advanceTimersByTimeAsync(1_000); + await expect(setup).rejects.toThrow(/failed to start/i); - expect(conn.child).toBeNull(); - expect(conn.sdkConnection).toBeNull(); - expect(conn.sessionId).toBeNull(); - expect(onDisconnected).toHaveBeenCalledWith(0, null); - await Promise.resolve(); - }); - - it('a superseded connection stops dispatching inbound callbacks', async () => { - // The inbound callbacks on the SDK Client object read `this.*` at call - // time. `disconnect()` ends stdin and nulls sdkConnection but does not - // close the superseded child's stdout, so its ClientSideConnection stays - // live. Each callback must gate on the connection it was built for - // (`this.sdkConnection !== wiredConnection`), or the retired connection - // keeps dispatching into callbacks bound to the live replacement. This - // case drives the writeTextFile and sessionUpdate guards specifically; - // requestPermission, readTextFile and extNotification carry the same gate - // but are not exercised here. Removing either driven guard fires its spy. - try { - const stdout = new PassThrough(); - const stdin = new PassThrough(); - const oldChild = createMockChild({ stdout, stdin, on: vi.fn() }); - const conn = new AcpConnection() as unknown as AcpConnectionInternal & { - onSessionUpdate: (data: unknown) => void; - fileHandler: { - handleWriteTextFile: (request: unknown) => Promise; - }; - }; - conn.child = oldChild; - conn.onSessionUpdate = vi.fn(); - const writeSpy = vi - .spyOn(conn.fileHandler, 'handleWriteTextFile') - .mockResolvedValue({}); - - const setup = ( - conn as unknown as { - setupChildProcessHandlers: () => Promise; - } - ).setupChildProcessHandlers(); - await vi.advanceTimersByTimeAsync(1000); - await setup; - - const client = sdkClientFactory.factory?.(null); - expect(client).toBeDefined(); - const writeTextFile = ( - client as unknown as { - writeTextFile: (request: unknown) => Promise; - } - ).writeTextFile; - const sessionUpdate = ( - client as unknown as { - sessionUpdate: (notification: unknown) => Promise; - } - ).sessionUpdate; - - // Supersede the connection the way a re-connect() does: disconnect() - // nulls both child and sdkConnection, while the superseded connection's - // stdout (still open) keeps its ClientSideConnection dispatching. - (conn as unknown as AcpConnection).disconnect(); - - await expect( - writeTextFile({ path: '/tmp/x', content: 'x', sessionId: 's' }), - ).rejects.toBeInstanceOf(RequestError); - expect(writeSpy).not.toHaveBeenCalled(); - - await sessionUpdate({}); - expect(conn.onSessionUpdate).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); - - it('a re-connected replacement keeps the superseded connection muted', async () => { - // Every other supersede test drives the gate through disconnect(), which - // nulls this.child and this.sdkConnection together, so the captured - // identity predicate (`this.sdkConnection !== wiredConnection`) and a - // weaker `!this.child` always agree. A real re-connect() installs a - // replacement child and a fresh sdkConnection while the old connection's - // stdout is still live — this.child is truthy again, so a gate weakened to - // `!this.child` would resume dispatching the retired connection's - // callbacks. This case pins the stronger predicate. - try { - const oldChild = createMockChild({ - stdout: new PassThrough(), - stdin: new PassThrough(), - on: vi.fn(), - }); - const newChild = createMockChild({ - stdout: new PassThrough(), - stdin: new PassThrough(), - on: vi.fn(), - }); - const oldStdinEnd = vi.spyOn(oldChild.stdin as PassThrough, 'end'); - spawnMock.mockReset(); - spawnMock.mockReturnValueOnce(oldChild).mockReturnValueOnce(newChild); - - const conn = new AcpConnection() as unknown as AcpConnectionInternal & { - connect: (cliEntryPath: string) => Promise; - onSessionUpdate: (data: unknown) => void; - fileHandler: { - handleWriteTextFile: (request: unknown) => Promise; - }; - }; - conn.onSessionUpdate = vi.fn(); - const writeSpy = vi - .spyOn(conn.fileHandler, 'handleWriteTextFile') - .mockResolvedValue({}); - - // First connect: capture the old client before it is superseded. - const first = conn.connect(process.execPath); - await vi.advanceTimersByTimeAsync(1000); - await first; - const oldClient = sdkClientFactory.factory?.(null); - - // Re-connect: disconnect() retires the old child, then a replacement - // child and a fresh sdkConnection are installed while the old stdout is - // still dispatching. - const second = conn.connect(process.execPath); - await vi.advanceTimersByTimeAsync(1000); - await second; - - expect(conn.child).toBe(newChild); - expect(oldStdinEnd).toHaveBeenCalledOnce(); - - const writeTextFile = ( - oldClient as unknown as { - writeTextFile: (request: unknown) => Promise; - } - ).writeTextFile; - const sessionUpdate = ( - oldClient as unknown as { - sessionUpdate: (notification: unknown) => Promise; - } - ).sessionUpdate; - - await expect( - writeTextFile({ path: '/tmp/x', content: 'x', sessionId: 's' }), - ).rejects.toBeInstanceOf(RequestError); - expect(writeSpy).not.toHaveBeenCalled(); - - await sessionUpdate({}); - expect(conn.onSessionUpdate).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } + expect(conn.child).toBe(replacement); + expect(conn.sdkConnection).toEqual({}); + expect(conn.sessionId).toBe('replacement'); + expect(conn.lastExitCode).toBe(7); + expect(conn.lastExitSignal).toBe('SIGTERM'); }); +}); - it('does not stamp a superseded connection with a stale session id', async () => { - // newSession/loadSession write this.sessionId only after awaiting the - // connection they captured. A response from a retired CLI can resolve - // after disconnect() nulled sessionId; the post-await write must gate on - // the captured connection, or the dead session's id lands back on the - // replacement connection's field. Removing either guard reds this test. - let resolveNewSession!: (value: unknown) => void; - let resolveLoadSession!: (value: unknown) => void; +describe('AcpConnection stale responses', () => { + it('does not apply session or prompt responses from a retired connection', async () => { + let resolveNew!: (value: NewSessionResponse) => void; + let resolveLoad!: (value: LoadSessionResponse) => void; + let resolvePrompt!: (value: PromptResponse) => void; const sdk = { newSession: vi.fn( () => - new Promise((resolve) => { - resolveNewSession = resolve; - }), - ), - loadSession: vi.fn( - () => - new Promise((resolve) => { - resolveLoadSession = resolve; - }), - ), - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'test-session', - }); - const acp = conn as unknown as AcpConnection; - - const newPromise = acp.newSession(); - const loadPromise = acp.loadSession('stale-session'); - acp.disconnect(); - - resolveNewSession({ sessionId: 'stale-from-retired-cli' }); - resolveLoadSession({}); - // The guard must fail the call, not resolve it. Returning the retired - // CLI's payload lets qwenAgentManager.applySessionStateFromResult write - // the dead model/mode state into the live webview's baselines, and - // createNewSession hands that same promise to every concurrent caller via - // sessionCreateInFlight. Reverting either guard to `return response` reds - // both assertions below. - await expect(newPromise).rejects.toMatchObject({ - code: ACP_ERROR_CODES.INTERNAL_ERROR, - message: expect.stringContaining('connection superseded'), - data: { details: 'connection superseded' }, - }); - await expect(loadPromise).rejects.toMatchObject({ - code: ACP_ERROR_CODES.INTERNAL_ERROR, - message: expect.stringContaining('connection superseded'), - data: { details: 'connection superseded' }, - }); - - expect(acp.currentSessionId).toBeNull(); - }); - - it('a re-connected replacement does not stamp the retired session id', async () => { - // Mirrors 'does not stamp a superseded connection with a stale session id' - // but supersedes by re-connect instead of disconnect(). disconnect() nulls - // this.child and this.sdkConnection together, so a gate weakened to - // `!this.child` still bails there. After a re-connect this.child is truthy - // again (the replacement), so `!this.child` would NOT bail and the retired - // CLI's session/new + session/load would stamp their ids back onto the - // live connection. This case pins `this.sdkConnection !== conn` (and its - // `=== conn` mirror) against that substitution. - let resolveNewSession!: (value: unknown) => void; - let resolveLoadSession!: (value: unknown) => void; - const oldSdk = { - newSession: vi.fn( - () => - new Promise((resolve) => { - resolveNewSession = resolve; - }), + new Promise((resolve) => (resolveNew = resolve)), ), loadSession: vi.fn( () => - new Promise((resolve) => { - resolveLoadSession = resolve; - }), - ), - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: oldSdk, - sessionId: 'live-session-1', - }); - const acp = conn as unknown as AcpConnection; - - const newPromise = acp.newSession(); - const loadPromise = acp.loadSession('stale-session'); - - // Re-connect: install a replacement child and a fresh sdkConnection while - // the retired sdk's promises are still in flight. - conn.child = createMockChild(); - conn.sdkConnection = { newSession: vi.fn(), loadSession: vi.fn() }; - conn.sessionId = 'live-session-2'; - - resolveNewSession({ sessionId: 'stale-from-retired-cli' }); - resolveLoadSession({}); - // Same stale-result pin as the disconnect() case, on the re-connect path: - // the retired CLI's payload must not reach the caller, or it is applied to - // the replacement connection's live webview state. - await expect(newPromise).rejects.toMatchObject({ - code: ACP_ERROR_CODES.INTERNAL_ERROR, - message: expect.stringContaining('connection superseded'), - data: { details: 'connection superseded' }, - }); - await expect(loadPromise).rejects.toMatchObject({ - code: ACP_ERROR_CODES.INTERNAL_ERROR, - message: expect.stringContaining('connection superseded'), - data: { details: 'connection superseded' }, - }); - - expect(acp.currentSessionId).toBe('live-session-2'); - }); - - it('a re-connected replacement does not fire onEndTurn for a retired prompt', async () => { - // sendPrompt gates onEndTurn on the captured connection so a stale prompt - // resolving after a re-connect does not clear the replacement's streaming - // state. disconnect() nulls this.child and this.sdkConnection together, so - // `!this.child` still bails there; only a re-connect (this.child truthy - // again) can tell the two predicates apart. - let resolvePrompt!: (value: unknown) => void; - const oldSdk = { - prompt: vi.fn( - () => - new Promise((resolve) => { - resolvePrompt = resolve; - }), + new Promise( + (resolve) => (resolveLoad = resolve), + ), ), - }; - const onEndTurn = vi.fn(); - const conn = createConnection({ - child: createMockChild(), - sdkConnection: oldSdk, - sessionId: 'session-1', - }); - (conn as unknown as AcpConnection).onEndTurn = onEndTurn; - const acp = conn as unknown as AcpConnection; - - const promptPromise = acp.sendPrompt('hi'); - - conn.child = createMockChild(); - conn.sdkConnection = { prompt: vi.fn() }; - conn.sessionId = 'session-2'; - - resolvePrompt({ stopReason: 'end_turn' }); - await expect(promptPromise).rejects.toMatchObject({ - code: ACP_ERROR_CODES.INTERNAL_ERROR, - message: expect.stringContaining('connection superseded'), - data: { details: 'connection superseded' }, - }); - - expect(onEndTurn).not.toHaveBeenCalled(); - }); - - it('does not fire onEndTurn when the prompt session is superseded in place', async () => { - let resolvePrompt!: (value: unknown) => void; - const sdk = { prompt: vi.fn( () => - new Promise((resolve) => { - resolvePrompt = resolve; - }), + new Promise((resolve) => (resolvePrompt = resolve)), ), }; const onEndTurn = vi.fn(); const conn = createConnection({ child: createMockChild(), sdkConnection: sdk, - sessionId: 'session-a', + sessionId: 'old-session', }); (conn as unknown as AcpConnection).onEndTurn = onEndTurn; const acp = conn as unknown as AcpConnection; - const promptPromise = acp.sendPrompt('hi'); - // Session replacement on the same live connection leaves the SDK object - // unchanged, so the session identity must be part of the stale-result - // guard as well. - conn.sessionId = 'session-b'; - + const create = acp.newSession(); + const load = acp.loadSession('loaded-session'); + const prompt = acp.sendPrompt('hello'); + void create.catch(() => {}); + void load.catch(() => {}); + void prompt.catch(() => {}); + conn.sdkConnection = {}; + conn.sessionId = 'replacement'; + resolveNew({ sessionId: 'created-session' }); + resolveLoad({}); resolvePrompt({ stopReason: 'end_turn' }); - await expect(promptPromise).rejects.toMatchObject({ - code: ACP_ERROR_CODES.INTERNAL_ERROR, - message: expect.stringContaining('connection superseded'), - data: { details: 'connection superseded' }, - }); - expect(onEndTurn).not.toHaveBeenCalled(); - }); -}); - -describe('AcpConnection superseded session close (#11303)', () => { - // The agent keeps a session alive until told otherwise, and a retained - // session still fires autonomous model turns when its background tasks - // complete. Replacing the current session must therefore tell the agent to - // close the superseded one, or every New Session / history switch strands - // one more live session in the CLI process. - - const closeParams = (sessionId: string) => ({ - sessionId, - requireFlush: true, - onlyIfUnheld: true, - drainTimeoutMs: 8_000, - }); - - it('newSession closes the superseded session on the same connection', async () => { - const extMethod = vi.fn().mockResolvedValue({ closed: true }); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - - await (conn as unknown as AcpConnection).newSession(); - - expect(extMethod).toHaveBeenCalledWith( - 'qwen/control/session/close', - closeParams('session-a'), - ); - expect(conn.sessionId).toBe('session-b'); - }); - - it('newSession does not close anything for the first session', async () => { - const extMethod = vi.fn().mockResolvedValue({ closed: true }); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-a' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: null, - }); - - await (conn as unknown as AcpConnection).newSession(); - - expect(extMethod).not.toHaveBeenCalled(); - }); - - it('loadSession closes the superseded session on the same connection', async () => { - const extMethod = vi.fn().mockResolvedValue({ closed: true }); - const sdk = { - loadSession: vi.fn().mockResolvedValue({}), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - - await (conn as unknown as AcpConnection).loadSession('session-c'); - - expect(extMethod).toHaveBeenCalledWith( - 'qwen/control/session/close', - closeParams('session-a'), - ); - expect(conn.sessionId).toBe('session-c'); - }); - - it('loadSession does not close when reloading the current session', async () => { - // Re-loading the session already on screen (e.g. history hydration after a - // reconnect) must not close it out from under the live conversation. - const extMethod = vi.fn().mockResolvedValue({ closed: true }); - const sdk = { - loadSession: vi.fn().mockResolvedValue({}), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - - await (conn as unknown as AcpConnection).loadSession('session-a'); - - expect(extMethod).not.toHaveBeenCalled(); - }); - - it('sends the close conditionally so held work is refused, not dropped (#11511)', async () => { - // Navigation is automatic cleanup, not explicit destruction: a session - // that still holds active work must be refused ({closed: false, holds}) - // rather than force-closed. Pin the onlyIfUnheld + drain budget the CLI - // contract keys on; dropping onlyIfUnheld reds this test. - const extMethod = vi - .fn() - .mockResolvedValue({ closed: false, holds: ['running-task'] }); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - - await (conn as unknown as AcpConnection).newSession(); - - expect(extMethod).toHaveBeenCalledWith('qwen/control/session/close', { - sessionId: 'session-a', - requireFlush: true, - onlyIfUnheld: true, - drainTimeoutMs: 8_000, - }); - expect(conn.sessionId).toBe('session-b'); - }); - - describe('superseded close retry (#11511)', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - const countClosesFor = (extMethod: Mock, sessionId: string) => - extMethod.mock.calls.filter( - ([, params]) => - (params as { sessionId?: string }).sessionId === sessionId, - ).length; - - it('retries a refused superseded close on a backoff until it succeeds', async () => { - const extMethod = vi - .fn() - .mockResolvedValueOnce({ closed: false, holds: ['running-task'] }) - .mockResolvedValueOnce({ closed: true }); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - - await (conn as unknown as AcpConnection).newSession(); - await vi.advanceTimersByTimeAsync(0); - expect(extMethod).toHaveBeenCalledTimes(1); - - // First retry only once the 60s rung expires. - await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS - 1); - expect(extMethod).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(1); - expect(extMethod).toHaveBeenCalledTimes(2); - expect(extMethod).toHaveBeenNthCalledWith( - 2, - 'qwen/control/session/close', - closeParams('session-a'), - ); - - // Closed for good: no third attempt, ever. - await vi.advanceTimersByTimeAsync(CLOSE_RETRY_CEILING_MS); - expect(extMethod).toHaveBeenCalledTimes(2); - }); - - it('backs off exponentially while a superseded close keeps being refused', async () => { - const extMethod = vi - .fn() - .mockResolvedValue({ closed: false, holds: ['running-task'] }); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - - await (conn as unknown as AcpConnection).newSession(); - await vi.advanceTimersByTimeAsync(0); - expect(extMethod).toHaveBeenCalledTimes(1); - - // First retry after 60s. - await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS); - expect(extMethod).toHaveBeenCalledTimes(2); - - // A refusal is evidence that the session still has active work, not a - // transport failure, so each probe stays on the base rung. - await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS); - expect(extMethod).toHaveBeenCalledTimes(3); - }); - - it('cancels the retry when the superseded session is loaded again', async () => { - const extMethod = vi - .fn() - .mockResolvedValueOnce({ closed: false, holds: ['running-task'] }) - .mockResolvedValue({ closed: true }); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - loadSession: vi.fn().mockResolvedValue({}), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - const acp = conn as unknown as AcpConnection; - - await acp.newSession(); - await vi.advanceTimersByTimeAsync(0); - expect(countClosesFor(extMethod, 'session-a')).toBe(1); - - // The superseded session becomes current again: session-b is now the - // one being closed, and the pending session-a retry must never fire. - await acp.loadSession('session-a'); - await vi.advanceTimersByTimeAsync(0); - expect(countClosesFor(extMethod, 'session-b')).toBe(1); - - await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS * 4); - expect(countClosesFor(extMethod, 'session-a')).toBe(1); - }); - - it('waits for an in-flight close before loading that session again', async () => { - let resolveClose!: (value: unknown) => void; - const extMethod = vi.fn( - () => - new Promise((resolve) => { - resolveClose = resolve; - }), - ); - const loadSession = vi.fn().mockResolvedValue({}); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - loadSession, - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - const acp = conn as unknown as AcpConnection; - - await acp.newSession(); - await vi.advanceTimersByTimeAsync(0); - expect(extMethod).toHaveBeenCalledTimes(1); - - const loadPromise = acp.loadSession('session-a'); - await vi.advanceTimersByTimeAsync(0); - expect(loadSession).not.toHaveBeenCalled(); - - resolveClose({ closed: false, holds: ['running-task'] }); - await loadPromise; - expect(loadSession).toHaveBeenCalledWith({ - sessionId: 'session-a', - cwd: process.cwd(), - mcpServers: [], - }); - }); - - it('deduplicates concurrent close attempts for one session', async () => { - let resolveClose!: (value: unknown) => void; - const extMethod = vi.fn( - () => - new Promise((resolve) => { - resolveClose = resolve; - }), - ); - const sdk = { extMethod }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-b', - }); - const acp = conn as unknown as AcpConnection; - const sendClose = ( - acp as unknown as { sendSupersededClose: (id: string) => void } - ).sendSupersededClose; - sendClose.call(acp, 'session-a'); - sendClose.call(acp, 'session-a'); - await vi.advanceTimersByTimeAsync(0); - expect(extMethod).toHaveBeenCalledTimes(1); - - resolveClose({ closed: true }); - await vi.advanceTimersByTimeAsync(0); - }); - - it('caps transient close retry backoff at one hour', () => { - const conn = createConnection({ - child: createMockChild(), - sdkConnection: { extMethod: vi.fn() }, - sessionId: 'session-b', - }); - const acp = conn as unknown as AcpConnection; - const scheduleRetry = ( - acp as unknown as { - scheduleSupersededCloseRetry: (id: string) => void; - } - ).scheduleSupersededCloseRetry; - - for (let i = 0; i < 10; i += 1) { - scheduleRetry.call(acp, 'session-a'); - } - - const entry = ( - acp as unknown as { - supersededCloseRetries: Map; - } - ).supersededCloseRetries.get('session-a'); - if (!entry) { - throw new Error('expected a retry entry'); - } - expect(entry.retryAt - Date.now()).toBe(CLOSE_RETRY_CEILING_MS); - }); - - it('stops retrying superseded closes after disconnect', async () => { - const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); - try { - const extMethod = vi - .fn() - .mockResolvedValue({ closed: false, holds: ['running-task'] }); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - const acp = conn as unknown as AcpConnection; - - await acp.newSession(); - await vi.advanceTimersByTimeAsync(0); - expect(extMethod).toHaveBeenCalledTimes(1); - - acp.disconnect(); - await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS * 4); - expect(extMethod).toHaveBeenCalledTimes(1); - } finally { - killSpy.mockRestore(); - } - }); - - it('clears and cancels an in-flight close when disconnect retires the connection', async () => { - let resolveClose!: (value: unknown) => void; - const extMethod = vi.fn( - () => - new Promise((resolve) => { - resolveClose = resolve; - }), - ); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - const acp = conn as unknown as AcpConnection; - - await acp.newSession(); - await vi.advanceTimersByTimeAsync(0); - expect(extMethod).toHaveBeenCalledTimes(1); - expect( - (acp as unknown as { supersededCloseInFlight: Set }) - .supersededCloseInFlight.size, - ).toBe(1); - - acp.disconnect(); - expect( - (acp as unknown as { supersededCloseInFlight: Set }) - .supersededCloseInFlight.size, - ).toBe(0); - - resolveClose({ closed: false, holds: ['running-task'] }); - await vi.advanceTimersByTimeAsync(0); - expect( - ( - acp as unknown as { - supersededCloseRetries: Map; - } - ).supersededCloseRetries.size, - ).toBe(0); - }); - - it('re-drives an expired close retry on the next session replacement', async () => { - const extMethod = vi - .fn() - .mockResolvedValue({ closed: false, holds: ['running-task'] }); - const sdk = { - newSession: vi - .fn() - .mockResolvedValueOnce({ sessionId: 'session-b' }) - .mockResolvedValueOnce({ sessionId: 'session-c' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - const acp = conn as unknown as AcpConnection; - - await acp.newSession(); - await vi.advanceTimersByTimeAsync(0); - expect(countClosesFor(extMethod, 'session-a')).toBe(1); - - // Move the clock past the 60s rung WITHOUT running timers: the retry is - // due but the timer thread has not fired. The next replacement must - // drive it immediately (the daemon equivalent is the next active-work - // snapshot). - vi.setSystemTime(Date.now() + CLOSE_RETRY_BASE_MS + 1000); - - await acp.newSession(); - // closeSupersededSession() must drive the expired entry immediately; - // observe that synchronous catch-up before advancing any timers. - expect(countClosesFor(extMethod, 'session-a')).toBe(2); - await vi.advanceTimersByTimeAsync(0); - expect(countClosesFor(extMethod, 'session-a')).toBe(2); - expect(countClosesFor(extMethod, 'session-b')).toBe(1); - }); - - it('does not retry an unsupported close method on an older CLI', async () => { - // Old-CLI compatibility: the ext method rejects, the replacement - // session still succeeds, and an operation the CLI cannot implement is - // not retried forever. - const extMethod = vi - .fn() - .mockRejectedValue(new Error('Method not found')); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - const acp = conn as unknown as AcpConnection; - - await expect(acp.newSession()).resolves.toMatchObject({ - sessionId: 'session-b', - }); - await vi.advanceTimersByTimeAsync(0); - expect(extMethod).toHaveBeenCalledTimes(1); - expect(conn.sessionId).toBe('session-b'); - - await vi.advanceTimersByTimeAsync(CLOSE_RETRY_BASE_MS); - expect(extMethod).toHaveBeenCalledTimes(1); - }); - }); - - it('a failed close does not fail the new session', async () => { - // Older CLIs have no session/close ext method; the replacement session - // must still succeed, and the swallowed rejection must not surface as an - // unhandled rejection. - const extMethod = vi.fn().mockRejectedValue(new Error('Method not found')); - const sdk = { - newSession: vi.fn().mockResolvedValue({ sessionId: 'session-b' }), - extMethod, - }; - const conn = createConnection({ - child: createMockChild(), - sdkConnection: sdk, - sessionId: 'session-a', - }); - const acp = conn as unknown as AcpConnection; - - await expect(acp.newSession()).resolves.toMatchObject({ - sessionId: 'session-b', - }); - // Let the fire-and-forget rejection settle so an unhandled one would fail - // the run rather than leak into an unrelated later test. - await new Promise((resolve) => setImmediate(resolve)); - - expect(conn.sessionId).toBe('session-b'); + await expect(create).rejects.toThrow('connection superseded'); + await expect(load).rejects.toThrow('connection superseded'); + await expect(prompt).rejects.toThrow('connection superseded'); + expect(conn.sessionId).toBe('replacement'); + expect(onEndTurn).not.toHaveBeenCalled(); }); }); diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 21c9e367a33..b59bac1da95 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -42,45 +42,11 @@ import { Readable, Writable } from 'node:stream'; import * as fs from 'node:fs'; import { AcpFileHandler } from './acpFileHandler.js'; import { ACP_ERROR_CODES } from '../constants/acpSchema.js'; -import { - ACTIVE_WORK_CLOSE_RETRY_BASE_MS, - ACTIVE_WORK_CLOSE_RETRY_CEILING_MS, - sessionCloseDrainBudgetMs, -} from '@qwen-code/acp-bridge/bridgeTypes'; -/** - * How long the CLI gets to shut itself down after its stdin is closed, before - * the escalation ladder starts. - * - * This has to outlast the CLI's own wind-down, or the escalation lands in the - * middle of a shutdown that is progressing correctly and skips the - * `process.on('exit')` cleanup this teardown exists to protect. On the - * ide_close path SessionEnd hooks are capped at 30s, followed by the CLI's - * 8s MCP pool drain, 30s session drain, and 5s exit cleanup: 73s bounded. - * Keep a small margin above that bound. The escalation remains a backstop for - * a CLI that is genuinely wedged. - */ const SHUTDOWN_GRACE_MS = 75_000; - -/** - * How long the POSIX escalation waits between the SIGTERM rung and the - * SIGKILL rung. SIGTERM triggers the CLI's `shutdownHandler`. The handler's - * SessionEnd hooks are capped at 30s, followed by the 30s session drain, 8s - * MCP drain and 5s exit cleanup. Keep this rung above that 73s bound so - * SIGKILL remains a last resort and the CLI's exit-time reaper gets a chance, - * even when SIGTERM arrives before the normal connection-close path. - */ const SIGTERM_GRACE_MS = 75_000; - -// Resolve taskkill by absolute System32 path, never the bare name: on Windows -// a bare command is resolved through PATH *and* the current directory, so a -// taskkill.exe planted in the workspace would run with the extension host's -// environment. const WINDOWS_TASKKILL = `${process.env['SystemRoot'] || 'C:\\Windows'}\\System32\\taskkill.exe`; -// Drain budget handed to the CLI on a conditional superseded-session close. -const SUPERSEDED_CLOSE_DRAIN_MS = sessionCloseDrainBudgetMs(10_000); - /** * ACP Connection Handler for VSCode Extension * @@ -95,15 +61,6 @@ export class AcpConnection { private fileHandler = new AcpFileHandler(); private lastExitCode: number | null = null; private lastExitSignal: string | null = null; - private supersededCloseRetries = new Map< - string, - { failures: number; retryAt: number } - >(); - private supersededCloseInFlight = new Set(); - private supersededClosePromises = new Map>(); - private supersededCloseCancels = new Map void>(); - private supersededCloseTimer: NodeJS.Timeout | null = null; - private connectionGeneration = 0; onSessionUpdate: (data: SessionNotification) => void = () => {}; onPermissionRequest: (data: RequestPermissionRequest) => Promise<{ @@ -177,13 +134,6 @@ export class AcpConnection { stdio: ['pipe', 'pipe', 'pipe'], env, shell: false, - // A detached child becomes a process-group leader on POSIX, so the - // disconnect() escalation can signal the whole group and reach the CLI - // root and its non-detached MCP stdio children. It does NOT reach - // descendants that call setsid() — detached hook supervisors and - // monitors, and node-pty sessions — so those survive the escalation. - // Windows has no process group to signal — its tree kill goes through - // taskkill instead. detached: process.platform !== 'win32', }; @@ -194,23 +144,14 @@ export class AcpConnection { private async setupChildProcessHandlers(): Promise { let spawnError: Error | null = null; const stderrChunks: string[] = []; - // Bind the handlers below to THIS child. `disconnect()` now lets the CLI - // wind down on its own, so a superseded child can still be exiting while - // `connect()` has already installed its replacement — and an exit handler - // that only tested `this.child` would then tear down the live connection - // and report it as disconnected. const ownChild = this.child!; + let ownExitCode: number | null = null; + let ownExitSignal: string | null = null; let rejectOnExit: ((error: Error) => void) | null = null; const processExitPromise = new Promise((_resolve, reject) => { rejectOnExit = reject; }); - // The only consumer is the Promise.race in initialize(), which attaches - // much later. A child that exits before then — a failed startup, or a - // superseded child winding down after disconnect() — would otherwise - // reject this with no handler attached, i.e. an unhandled rejection in the - // extension host. Marking it handled here changes nothing for the race, - // which still receives the original promise and still sees the rejection. void processExitPromise.catch(() => {}); ownChild.stderr?.on('data', (data: Buffer) => { @@ -234,8 +175,8 @@ export class AcpConnection { logger.error( `[ACP qwen] Process exited with code: ${code}, signal: ${signal}`, ); - this.lastExitCode = code; - this.lastExitSignal = signal; + ownExitCode = code; + ownExitSignal = signal; const stderrOutput = stderrChunks.join('').trim(); const stderrSuffix = stderrOutput @@ -248,6 +189,8 @@ export class AcpConnection { ); if (this.child === ownChild) { + this.lastExitCode = code; + this.lastExitSignal = signal; this.sdkConnection = null; this.sessionId = null; this.child = null; @@ -262,8 +205,8 @@ export class AcpConnection { } if (this.child !== ownChild || ownChild.killed) { - const code = this.lastExitCode ?? this.child?.exitCode ?? null; - const signal = this.lastExitSignal; + const code = ownExitCode ?? ownChild.exitCode ?? null; + const signal = ownExitSignal ?? ownChild.signalCode ?? null; const stderrOutput = stderrChunks.join('').trim(); const stderrSuffix = stderrOutput ? `\nCLI stderr: ${stderrOutput.slice(-500)}` @@ -282,19 +225,10 @@ export class AcpConnection { const stream = ndJsonStream(stdin, stdout); // Build the SDK Client implementation that bridges to our callbacks. - // Capture the connection in a local so the inbound callbacks below can - // detect that THIS connection has been retired. disconnect() nulls both - // this.child and this.sdkConnection, then a re-connect() installs a - // replacement — but the superseded connection's stdout is still live and - // dispatching through the grace window. Comparing against the captured - // connection (not this.child, which is nulled before the grace timer and - // re-runs on the still-current child) stays correct across that window. const wiredConnection = new ClientSideConnection( (_agent: Agent): Client => ({ sessionUpdate: (params: SessionNotification): Promise => { if (this.sdkConnection !== wiredConnection) { - // A fire-and-forget notifier on a superseded connection must not - // re-enter callbacks that read `this.*` at call time. return Promise.resolve(); } this.onSessionUpdate(params as unknown as SessionNotification); @@ -436,12 +370,8 @@ export class AcpConnection { method: string, params: Record, ): Promise => { - if (this.sdkConnection !== wiredConnection) { - // A fire-and-forget notifier on a superseded connection must not - // re-enter `this.*` callbacks; drop it instead of erroring. - return; - } - return this.handleExtNotification(method, params); + if (this.sdkConnection !== wiredConnection) return; + this.handleExtNotification(method, params); }, }), stream, @@ -571,207 +501,13 @@ export class AcpConnection { return response; } - /** - * The agent keeps every session alive until told otherwise, and a retained - * session can continue autonomous work after it leaves the foreground. - * Replacing the current session (session/new, session/load) therefore asks - * the CLI to close the superseded one. - * - * The close is conditional (`onlyIfUnheld`): navigation is automatic - * cleanup, not explicit destruction, so a session that still holds active - * work is refused (`{closed: false, holds}`) and is retried on a backoff - * rather than force-closed — dropping in-flight work is exactly what the - * condition protects against. Fire-and-forget either way: a refused, - * failed or unsupported (older CLI) close must never block the user's new - * session, and a later session/load of the closed id simply re-reads the - * flushed transcript. - */ - private closeSupersededSession( - previousSessionId: string | null, - nextSessionId: string | null, - ): void { - if (nextSessionId) { - // A session that is current again must not stay on the retry table. - this.supersededCloseRetries.delete(nextSessionId); - } - if (previousSessionId && previousSessionId !== nextSessionId) { - this.sendSupersededClose(previousSessionId); - } - // A replacement is also the moment to re-drive any close whose backoff - // already expired while no timer was due (the daemon equivalent is the - // next active-work snapshot). - this.driveDueSupersededCloseRetries(); - } - - private isUnsupportedSupersededCloseError(error: unknown): boolean { - return ( - (error instanceof RequestError && error.code === -32601) || - (error instanceof Error && /method not found/i.test(error.message)) - ); - } - - private sendSupersededClose(sessionId: string): void { - // Always send on the CURRENT connection: by the time a retry fires, the - // connection the session was superseded on may have been replaced. - const conn = this.sdkConnection; - if ( - !conn || - !this.isConnected || - this.supersededCloseInFlight.has(sessionId) - ) { - return; - } - const generation = this.connectionGeneration; - this.supersededCloseInFlight.add(sessionId); - let cancelClose!: () => void; - const cancelled = new Promise((resolve) => { - cancelClose = resolve; - }); - this.supersededCloseCancels.set(sessionId, cancelClose); - - const operation = Promise.resolve() - .then(() => - conn.extMethod('qwen/control/session/close', { - sessionId, - requireFlush: true, - onlyIfUnheld: true, - drainTimeoutMs: SUPERSEDED_CLOSE_DRAIN_MS, - }), - ) - .then((result) => { - if ( - generation !== this.connectionGeneration || - this.sdkConnection !== conn - ) { - return; - } - if (result['closed'] === true) { - this.supersededCloseRetries.delete(sessionId); - } else { - // Refused while the session still holds active work; keep it and - // probe again on the backoff rungs. - logger.warn( - '[ACP] Superseded session close was refused:', - sessionId, - result['holds'], - ); - this.scheduleSupersededCloseRetry(sessionId, true); - } - }) - .catch((error: unknown) => { - if ( - generation !== this.connectionGeneration || - this.sdkConnection !== conn - ) { - return; - } - if (this.isUnsupportedSupersededCloseError(error)) { - // Older CLIs do not implement this optional extension method. Keep - // the replacement session usable, but do not retry an operation - // that can never succeed on this process. - this.supersededCloseRetries.delete(sessionId); - return; - } - // Older CLIs have no session/close ext method; count it as a failure - // and keep retrying on the same table so transient failures stay - // tracked. - logger.warn( - '[ACP] Failed to close superseded session:', - error instanceof Error ? error.message : String(error), - ); - this.scheduleSupersededCloseRetry(sessionId); - }); - - const tracked = Promise.race([operation, cancelled]).finally(() => { - this.supersededCloseInFlight.delete(sessionId); - if (this.supersededClosePromises.get(sessionId) === tracked) { - this.supersededClosePromises.delete(sessionId); - this.supersededCloseCancels.delete(sessionId); - } - this.armSupersededCloseTimer(); - }); - this.supersededClosePromises.set(sessionId, tracked); - } - - private scheduleSupersededCloseRetry( - sessionId: string, - resetFailures = false, - ): void { - const failures = - (resetFailures - ? 0 - : (this.supersededCloseRetries.get(sessionId)?.failures ?? 0)) + 1; - const delay = Math.min( - ACTIVE_WORK_CLOSE_RETRY_BASE_MS * 2 ** (failures - 1), - ACTIVE_WORK_CLOSE_RETRY_CEILING_MS, - ); - this.supersededCloseRetries.set(sessionId, { - failures, - retryAt: Date.now() + delay, - }); - this.armSupersededCloseTimer(); - } - - private armSupersededCloseTimer(): void { - if (this.supersededCloseTimer) { - clearTimeout(this.supersededCloseTimer); - this.supersededCloseTimer = null; - } - let earliest: number | null = null; - for (const [sessionId, entry] of this.supersededCloseRetries) { - if (this.supersededCloseInFlight.has(sessionId)) { - continue; - } - if (earliest === null || entry.retryAt < earliest) { - earliest = entry.retryAt; - } - } - if (earliest === null) { - return; - } - this.supersededCloseTimer = setTimeout( - () => { - this.supersededCloseTimer = null; - this.driveDueSupersededCloseRetries(); - }, - Math.max(earliest - Date.now(), 1_000), - ); - } - - private driveDueSupersededCloseRetries(): void { - if (this.supersededCloseRetries.size === 0) { - return; - } - const now = Date.now(); - for (const [sessionId, entry] of [...this.supersededCloseRetries]) { - if (entry.retryAt > now || this.supersededCloseInFlight.has(sessionId)) { - continue; - } - if (!this.isConnected || this.sessionId === sessionId) { - // The CLI is gone, or the session was reloaded onto the live - // connection and is no longer superseded. - this.supersededCloseRetries.delete(sessionId); - continue; - } - this.sendSupersededClose(sessionId); - } - this.armSupersededCloseTimer(); - } - async newSession(cwd: string = process.cwd()): Promise { const conn = this.ensureConnection(); - const previousSessionId = this.sessionId; logger.log('[ACP] Sending session/new request with cwd:', cwd); const response: NewSessionResponse = await conn.newSession({ cwd, mcpServers: [], }); - // A stale session/new can resolve after disconnect() (or a re-connect) - // retired this connection. Handing the payload back would let the caller - // apply the retired CLI's model and mode state to the live webview - // (`applySessionStateFromResult` in qwenAgentManager.ts), and writing would - // stamp the dead session's id onto the replacement connection's field, so - // fail instead — the same shape the inbound callback guards use above. if (this.sdkConnection !== conn) { throw RequestError.internalError( { details: 'connection superseded' }, @@ -780,7 +516,6 @@ export class AcpConnection { } this.sessionId = response.sessionId || null; logger.log('[ACP] Session created with ID:', this.sessionId); - this.closeSupersededSession(previousSessionId, this.sessionId); return response; } @@ -798,9 +533,6 @@ export class AcpConnection { sessionId: promptSessionId, prompt: promptBlocks, }); - // A stale prompt can resolve after disconnect(), re-connect(), or an - // in-place session replacement. Firing onEndTurn then would clear the - // replacement session's streaming state, so fail before touching it. if (this.sdkConnection !== conn || this.sessionId !== promptSessionId) { throw RequestError.internalError( { details: 'connection superseded' }, @@ -849,20 +581,6 @@ export class AcpConnection { cwdOverride?: string, ): Promise { const conn = this.ensureConnection(); - const previousSessionId = this.sessionId; - // The daemon rejects a load while its conditional close gate is active. - // Wait for that close to settle before loading the same session again; - // disconnect() resolves the tracked wait when the connection is retired. - const pendingClose = this.supersededClosePromises.get(sessionId); - if (pendingClose) { - await pendingClose; - if (this.sdkConnection !== conn) { - throw RequestError.internalError( - { details: 'connection superseded' }, - 'connection superseded', - ); - } - } logger.log('[ACP] Sending session/load request for session:', sessionId); const cwd = cwdOverride || this.workingDir; let response: LoadSessionResponse; @@ -879,14 +597,6 @@ export class AcpConnection { ); throw error; } - // A stale session/load can resolve after disconnect() (or a re-connect) - // retired this connection. Handing the payload back would let the caller - // apply the retired CLI's model and mode state to the live webview - // (`applySessionStateFromResult` and `restoreBaselineSessionStateAfterLoad` - // in qwenAgentManager.ts), and writing would stamp the dead session's id - // onto the replacement connection's field, so fail instead. Checked outside - // the catch above so a supersede is not logged as a request failure, and - // before the success log so a discarded load prints no success line. if (this.sdkConnection !== conn) { throw RequestError.internalError( { details: 'connection superseded' }, @@ -895,7 +605,6 @@ export class AcpConnection { } logger.log('[ACP] Session load succeeded for session:', sessionId); this.sessionId = sessionId; - this.closeSupersededSession(previousSessionId, sessionId); return response; } @@ -1030,61 +739,16 @@ export class AcpConnection { } disconnect(): void { - this.connectionGeneration += 1; - for (const cancel of this.supersededCloseCancels.values()) { - cancel(); - } - this.supersededCloseCancels.clear(); - this.supersededClosePromises.clear(); - this.supersededCloseInFlight.clear(); const child = this.child; this.child = null; this.sdkConnection = null; this.sessionId = null; - // The CLI process is going away; any pending conditional-close retry - // targets it, so drop the table instead of signalling a dead connection. - this.supersededCloseRetries.clear(); - if (this.supersededCloseTimer) { - clearTimeout(this.supersededCloseTimer); - this.supersededCloseTimer = null; - } - if (!child) { - return; - } - if (child.pid === undefined) { - return; - } - const childPid = child.pid; + if (!child) return; - // Close the child's stdin instead of killing it. Ending the ndjson stream - // is the CLI's own shutdown path: `await connection.closed` returns, it - // fires SessionEnd hooks, drains the MCP pool, disposes its sessions and - // exits normally — so its `process.on('exit')` cleanup runs and reaps the - // PTYs, ConPTY hosts and child processes it is tracking. - // - // A bare `child.kill()` is `TerminateProcess` on Windows: none of that - // runs, and everything the CLI was tracking is orphaned until the VS Code - // window itself closes. That is the teardown half of #11303. - let graceTimer: NodeJS.Timeout | undefined; - let killTimer: NodeJS.Timeout | undefined; - child.once('exit', () => { - if (graceTimer) { - clearTimeout(graceTimer); - graceTimer = undefined; - } - if (killTimer) { - clearTimeout(killTimer); - killTimer = undefined; - } - }); - const stdin = child.stdin; - if (stdin && !stdin.destroyed && !stdin.writableEnded) { - // A late write error on a pipe whose reader is gone is reported as an - // 'error' event, and an unhandled one on an EventEmitter throws — in the - // extension host, not here. Swallow it: we are tearing this down anyway. - stdin.once('error', () => {}); + if (child.stdin && !child.stdin.destroyed && !child.stdin.writableEnded) { + child.stdin.once('error', () => {}); try { - stdin.end(); + child.stdin.end(); } catch (error) { logger.error( '[ACP] Failed to close CLI stdin during disconnect:', @@ -1093,50 +757,34 @@ export class AcpConnection { } } - // Escalate only if the graceful path did not land. POSIX climbs a ladder — - // SIGTERM (catchable, runs the CLI's bounded signal cleanup and its - // exit-time reaper) and only then SIGKILL — while Windows goes straight - // to the tree kill: it has no catchable terminate for console processes. - graceTimer = setTimeout(() => { - graceTimer = undefined; - if (child.exitCode !== null || child.signalCode !== null) { - return; - } - if (process.platform === 'win32' && child.pid) { - // A tree kill is right here: at this point the CLI is unresponsive, - // so nothing else will reap the shells and ConPTY hosts underneath it. - logger.error( - `[ACP] CLI did not exit within ${SHUTDOWN_GRACE_MS}ms of stdin close; force-killing its process tree`, - ); + const childPid = child.pid; + if (!childPid) return; + + let killTimer: NodeJS.Timeout | undefined; + const graceTimer = setTimeout(() => { + if (child.exitCode !== null || child.signalCode !== null) return; + + if (process.platform === 'win32') { execFile( WINDOWS_TASKKILL, - ['/f', '/t', '/pid', String(child.pid)], + ['/f', '/t', '/pid', String(childPid)], { windowsHide: true, timeout: 2_000 }, (error) => { - if (error) { - logger.error('[ACP] taskkill failed for the CLI tree:', error); - try { - child.kill(); - } catch { - // Already gone. - } + if (!error) return; + logger.error('[ACP] taskkill failed for the CLI tree:', error); + try { + child.kill(); + } catch { + // Already gone. } }, ); return; } - // The child is detached on POSIX, so it leads its own process group: - // signalling the group reaches the CLI root and its non-detached children - // (MCP stdio servers). It does NOT reach descendants that call setsid() — - // detached hook supervisors and monitors, and node-pty sessions. - logger.error( - `[ACP] CLI did not exit within ${SHUTDOWN_GRACE_MS}ms of stdin close; sending SIGTERM to its process group`, - ); + try { process.kill(-childPid, 'SIGTERM'); } catch { - // The process group is already gone (or the child predates the - // detached spawn). The root signal is the fallback. try { child.kill('SIGTERM'); } catch { @@ -1144,18 +792,7 @@ export class AcpConnection { } } killTimer = setTimeout(() => { - killTimer = undefined; - // Re-check before signalling: after 75+s the pid may have been - // recycled by an unrelated process group. - if (child.exitCode !== null || child.signalCode !== null) { - return; - } - // SIGKILL also skips the CLI's own exit-time reaper - // (forceKillActivePosixHookProcesses), which is why it is the last - // rung and not the first. - logger.error( - `[ACP] CLI still alive ${SIGTERM_GRACE_MS}ms after SIGTERM; force-killing its process group`, - ); + if (child.exitCode !== null || child.signalCode !== null) return; try { process.kill(-childPid, 'SIGKILL'); } catch { @@ -1167,6 +804,10 @@ export class AcpConnection { } }, SIGTERM_GRACE_MS); }, SHUTDOWN_GRACE_MS); + child.once('exit', () => { + clearTimeout(graceTimer); + if (killTimer) clearTimeout(killTimer); + }); } get isConnected(): boolean { From c0aaaca71346e9cf208f2db25c629a71a2b2e4a0 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 12 Sep 2026 12:04:53 +0800 Subject: [PATCH 10/16] test(vscode): cover same-session stale responses --- .../vscode-ide-companion/src/services/acpConnection.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vscode-ide-companion/src/services/acpConnection.test.ts b/packages/vscode-ide-companion/src/services/acpConnection.test.ts index 6c6573aa425..9356d36e0cb 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.test.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.test.ts @@ -458,7 +458,7 @@ describe('AcpConnection stale responses', () => { void load.catch(() => {}); void prompt.catch(() => {}); conn.sdkConnection = {}; - conn.sessionId = 'replacement'; + conn.sessionId = 'old-session'; resolveNew({ sessionId: 'created-session' }); resolveLoad({}); resolvePrompt({ stopReason: 'end_turn' }); @@ -466,7 +466,7 @@ describe('AcpConnection stale responses', () => { await expect(create).rejects.toThrow('connection superseded'); await expect(load).rejects.toThrow('connection superseded'); await expect(prompt).rejects.toThrow('connection superseded'); - expect(conn.sessionId).toBe('replacement'); + expect(conn.sessionId).toBe('old-session'); expect(onEndTurn).not.toHaveBeenCalled(); }); }); From b462fdfaee4e63206df4ca90759b509f7f76e7bf Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 12 Sep 2026 18:06:59 +0800 Subject: [PATCH 11/16] fix(cli): fail ACP shutdown when a SessionEnd hook is cancelled A cancelled SessionEnd hook resolves fireSessionEndEvent to undefined instead of rejecting, so Promise.allSettled never observed it and the CLI exited 0 as though every hook ran. Detect the abort signal and surface it as a shutdown failure (non-zero exit). Also reset the in-flight exit-cleanup promise in the test helper so it cannot leak across vitest cases. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmty6iqs0zv --- packages/cli/src/acp-integration/acpAgent.ts | 12 ++++++++++++ packages/cli/src/utils/cleanup.ts | 1 + 2 files changed, 13 insertions(+) diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 8740bd4ec97..bed3c1988e4 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -2992,6 +2992,18 @@ export async function runAcpAgent( result.status === 'rejected', ) .map((result) => result.reason); + // A SessionEnd hook that outlives the 30s budget is cancelled rather + // than rejected: `fireSessionEndEvent` resolves to `undefined` for a + // cancelled hook (the `{ success: false, outcome: 'cancelled' }` + // result never rejects), so `Promise.allSettled` cannot observe it and + // `failures` above stays empty. Detect the abort directly so a + // cancelled hook still surfaces as a shutdown failure (non-zero exit) + // instead of the CLI exiting 0 as though every hook had run. + if (controller.signal.aborted) { + failures.push( + new Error('SessionEnd hook did not complete within 30s (cancelled)'), + ); + } for (const failure of failures) { debugLogger.warn( `SessionEnd hook failed: ${failure instanceof Error ? failure.message : String(failure)}`, diff --git a/packages/cli/src/utils/cleanup.ts b/packages/cli/src/utils/cleanup.ts index 4e092745fe9..234fc51f6e4 100644 --- a/packages/cli/src/utils/cleanup.ts +++ b/packages/cli/src/utils/cleanup.ts @@ -118,6 +118,7 @@ async function runExitCleanupPass( */ export function _resetCleanupFunctionsForTest(): void { cleanupFunctions.length = 0; + exitCleanupPromise = undefined; } export async function cleanupCheckpoints() { From 7f8c1182e3407d643230ae16dfd12b804074bd72 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 12 Sep 2026 18:07:04 +0800 Subject: [PATCH 12/16] fix(vscode): deliver prompts that finish after a same-connection session switch Dropping the session-id axis from the superseded-connection guard so a turn that completes after the user switches sessions on the same live connection resolves instead of being reported as a hard failure. Also remove the now-write-only lastExitCode/lastExitSignal fields and the assertions maintaining them. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmty6iqs0zv --- .../src/services/acpConnection.test.ts | 43 +++++++++++++------ .../src/services/acpConnection.ts | 14 +++--- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/packages/vscode-ide-companion/src/services/acpConnection.test.ts b/packages/vscode-ide-companion/src/services/acpConnection.test.ts index 9356d36e0cb..6a697d0ab5b 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.test.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.test.ts @@ -48,8 +48,6 @@ type AcpConnectionInternal = { child: MockChild | null; sdkConnection: unknown; sessionId: string | null; - lastExitCode: number | null; - lastExitSignal: string | null; mapReadTextFileError: (error: unknown, filePath: string) => unknown; ensureConnection: () => unknown; }; @@ -406,8 +404,6 @@ describe('AcpConnection child exit cleanup', () => { conn.child = replacement; conn.sdkConnection = {}; conn.sessionId = 'replacement'; - conn.lastExitCode = 7; - conn.lastExitSignal = 'SIGTERM'; exitHandler?.(0, null); await vi.advanceTimersByTimeAsync(1_000); @@ -416,8 +412,6 @@ describe('AcpConnection child exit cleanup', () => { expect(conn.child).toBe(replacement); expect(conn.sdkConnection).toEqual({}); expect(conn.sessionId).toBe('replacement'); - expect(conn.lastExitCode).toBe(7); - expect(conn.lastExitSignal).toBe('SIGTERM'); }); }); @@ -469,6 +463,35 @@ describe('AcpConnection stale responses', () => { expect(conn.sessionId).toBe('old-session'); expect(onEndTurn).not.toHaveBeenCalled(); }); + + it('delivers a prompt that completes after a session switch on the same connection', async () => { + let resolvePrompt!: (value: PromptResponse) => void; + const sdk = { + prompt: vi.fn( + () => + new Promise((resolve) => (resolvePrompt = resolve)), + ), + }; + const onEndTurn = vi.fn(); + const conn = createConnection({ + child: createMockChild(), + sdkConnection: sdk, + sessionId: 'session-a', + }); + (conn as unknown as AcpConnection).onEndTurn = onEndTurn; + const acp = conn as unknown as AcpConnection; + + const prompt = acp.sendPrompt('hello'); + void prompt.catch(() => {}); + // The user switches to another session on the SAME live connection while + // the prompt is in flight. This must not be reported as a superseded + // connection: the turn completed, so it resolves and emits end-of-turn. + conn.sessionId = 'session-b'; + resolvePrompt({ stopReason: 'end_turn' }); + + await expect(prompt).resolves.toMatchObject({ stopReason: 'end_turn' }); + expect(onEndTurn).toHaveBeenCalledWith('end_turn'); + }); }); describe('AcpConnection onDisconnected callback', () => { @@ -488,14 +511,6 @@ describe('AcpConnection onDisconnected callback', () => { }); }); -describe('AcpConnection lastExitCode/lastExitSignal', () => { - it('initializes exit info as null', () => { - const conn = createConnection(); - expect(conn.lastExitCode).toBeNull(); - expect(conn.lastExitSignal).toBeNull(); - }); -}); - describe('AcpConnection extension notifications', () => { it('parses end_turn reason and source', () => { const conn = new AcpConnection(); diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index b59bac1da95..9cbc2df0632 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -59,8 +59,6 @@ export class AcpConnection { private sessionId: string | null = null; private workingDir: string = process.cwd(); private fileHandler = new AcpFileHandler(); - private lastExitCode: number | null = null; - private lastExitSignal: string | null = null; onSessionUpdate: (data: SessionNotification) => void = () => {}; onPermissionRequest: (data: RequestPermissionRequest) => Promise<{ @@ -92,8 +90,6 @@ export class AcpConnection { this.disconnect(); } - this.lastExitCode = null; - this.lastExitSignal = null; this.workingDir = workingDir; const env = { ...process.env }; @@ -189,8 +185,6 @@ export class AcpConnection { ); if (this.child === ownChild) { - this.lastExitCode = code; - this.lastExitSignal = signal; this.sdkConnection = null; this.sessionId = null; this.child = null; @@ -533,7 +527,13 @@ export class AcpConnection { sessionId: promptSessionId, prompt: promptBlocks, }); - if (this.sdkConnection !== conn || this.sessionId !== promptSessionId) { + // Only a truly superseded connection (or a prompt rejected/cancelled by + // the SDK) should error. Switching to another session on the SAME live + // connection reassigns `this.sessionId` (see `newSession`) without + // replacing `sdkConnection`, so folding `this.sessionId !== + // promptSessionId` into this guard would misreport a successfully + // completed turn as "connection superseded". + if (this.sdkConnection !== conn) { throw RequestError.internalError( { details: 'connection superseded' }, 'connection superseded', From 5914facdae5389f04e3c54bf9325cd18db339595 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 12 Sep 2026 18:44:07 +0800 Subject: [PATCH 13/16] test(vscode): pin deferred ACP shutdown coverage gaps Pin four Suggestion-level coverage gaps deferred in the #11642 review: - assert the stdin EPIPE guard installs an 'error' listener (R3-4) - reset spawnMock per case and pin 'detached' on both platforms (R3-5) - cover the exit-handler ownership split: onDisconnected fires only for the current child, and the failed-start message pins exit code/signal (R3-6) - cover the taskkill error-callback fallback to child.kill() (R3-7) Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmty8nwk9zy --- .../src/services/acpConnection.test.ts | 97 +++++++++++++++++-- 1 file changed, 89 insertions(+), 8 deletions(-) diff --git a/packages/vscode-ide-companion/src/services/acpConnection.test.ts b/packages/vscode-ide-companion/src/services/acpConnection.test.ts index 6a697d0ab5b..7acf6f02d8d 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.test.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.test.ts @@ -79,6 +79,14 @@ function createMockChild(overrides?: Record) { } describe('AcpConnection process spawning', () => { + beforeEach(() => { + spawnMock.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + it('runs the managed ACP child in Electron Node mode', async () => { vi.stubEnv('ELECTRON_RUN_AS_NODE', ''); vi.stubEnv('QWEN_CODE_SCRUB_ELECTRON_RUN_AS_NODE', ''); @@ -103,6 +111,7 @@ describe('AcpConnection process spawning', () => { }); it('creates a POSIX process group for shutdown escalation', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); spawnMock.mockReturnValue(createMockChild()); const conn = new AcpConnection() as unknown as { connect: (cliEntryPath: string) => Promise; @@ -112,11 +121,23 @@ describe('AcpConnection process spawning', () => { await conn.connect(process.execPath); - expect(spawnMock).toHaveBeenCalledWith( - process.execPath, - expect.any(Array), - expect.objectContaining({ detached: process.platform !== 'win32' }), - ); + const options = spawnMock.mock.calls.at(-1)?.[2] as { detached?: boolean }; + expect(options.detached).toBe(true); + }); + + it('does not detach the ACP child on Windows', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + spawnMock.mockReturnValue(createMockChild()); + const conn = new AcpConnection() as unknown as { + connect: (cliEntryPath: string) => Promise; + setupChildProcessHandlers: () => Promise; + }; + conn.setupChildProcessHandlers = vi.fn().mockResolvedValue(undefined); + + await conn.connect(process.execPath); + + const options = spawnMock.mock.calls.at(-1)?.[2] as { detached?: boolean }; + expect(options.detached).toBe(false); }); }); @@ -278,6 +299,7 @@ describe('AcpConnection child exit cleanup', () => { it('disconnect closes stdin before escalating', () => { const mockKill = vi.fn(); const end = vi.fn(); + const stdinOnce = vi.fn(); const conn = createConnection({ child: createMockChild({ kill: mockKill, @@ -285,7 +307,7 @@ describe('AcpConnection child exit cleanup', () => { destroyed: false, writableEnded: false, end, - once: vi.fn(), + once: stdinOnce, }, }), sdkConnection: {}, @@ -294,6 +316,7 @@ describe('AcpConnection child exit cleanup', () => { (conn as unknown as AcpConnection).disconnect(); expect(end).toHaveBeenCalledOnce(); + expect(stdinOnce).toHaveBeenCalledWith('error', expect.any(Function)); expect(mockKill).not.toHaveBeenCalled(); }); @@ -365,6 +388,30 @@ describe('AcpConnection child exit cleanup', () => { expect(childKill).not.toHaveBeenCalled(); }); + it('degrades to child.kill() when taskkill fails', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + const childKill = vi.fn().mockReturnValue(true); + const conn = createConnection({ + child: createMockChild({ kill: childKill }), + }); + execFileMock.mockImplementation( + ( + _file: unknown, + _args: unknown, + _options: unknown, + callback: (error: Error) => void, + ) => { + callback(new Error('spawn taskkill.exe ENOENT')); + }, + ); + + (conn as unknown as AcpConnection).disconnect(); + vi.advanceTimersByTime(75_000); + + expect(execFileMock).toHaveBeenCalled(); + expect(childKill).toHaveBeenCalled(); + }); + it('cancels escalation when the child exits normally', () => { let onExit: (() => void) | undefined; const kill = vi.spyOn(process, 'kill').mockReturnValue(true); @@ -395,7 +442,9 @@ describe('AcpConnection child exit cleanup', () => { } }), }); + const onDisconnected = vi.fn(); const conn = createConnection({ child: oldChild }); + (conn as unknown as AcpConnection).onDisconnected = onDisconnected; const setup = ( conn as unknown as { setupChildProcessHandlers: () => Promise } ).setupChildProcessHandlers(); @@ -405,13 +454,45 @@ describe('AcpConnection child exit cleanup', () => { conn.sdkConnection = {}; conn.sessionId = 'replacement'; - exitHandler?.(0, null); + exitHandler?.(3, 'SIGTERM'); await vi.advanceTimersByTimeAsync(1_000); - await expect(setup).rejects.toThrow(/failed to start/i); + await expect(setup).rejects.toThrow( + 'Qwen ACP process failed to start (exit code: 3, signal: SIGTERM)', + ); expect(conn.child).toBe(replacement); expect(conn.sdkConnection).toEqual({}); expect(conn.sessionId).toBe('replacement'); + expect(onDisconnected).not.toHaveBeenCalled(); + }); + + it('invokes onDisconnected with the exit info when the current child exits', async () => { + let exitHandler: + | ((code: number | null, signal: string | null) => void) + | undefined; + const child = createMockChild({ + stderr: { on: vi.fn() }, + on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + if (event === 'exit') { + exitHandler = listener as typeof exitHandler; + } + }), + }); + const onDisconnected = vi.fn(); + const conn = createConnection({ child }); + (conn as unknown as AcpConnection).onDisconnected = onDisconnected; + const setup = ( + conn as unknown as { setupChildProcessHandlers: () => Promise } + ).setupChildProcessHandlers(); + void setup.catch(() => {}); + + exitHandler?.(1, 'SIGTERM'); + await vi.advanceTimersByTimeAsync(1_000); + + expect(onDisconnected).toHaveBeenCalledWith(1, 'SIGTERM'); + expect(conn.child).toBeNull(); + expect(conn.sdkConnection).toBeNull(); + expect(conn.sessionId).toBeNull(); }); }); From 02a4908ec0cb2ef7e8a0ccc550d735ce79e07cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Sat, 12 Sep 2026 20:23:20 +0800 Subject: [PATCH 14/16] style(cli): satisfy prettier on acpAgent's SessionEnd failure message The Lint & Static lane's Run Prettier step failed on this file at 5914facdae with a single wrapping complaint. `npx prettier --write` produces exactly this rewrap of the `new Error(...)` argument and nothing else, and `--check` is clean afterwards; eslint on the file is clean too. No behaviour change: the whole diff is whitespace inside one call's argument list, so the string the failure carries is byte-identical. Co-authored-by: Qwen-Coder --- packages/cli/src/acp-integration/acpAgent.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index bed3c1988e4..f630df6dbe1 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -3001,7 +3001,9 @@ export async function runAcpAgent( // instead of the CLI exiting 0 as though every hook had run. if (controller.signal.aborted) { failures.push( - new Error('SessionEnd hook did not complete within 30s (cancelled)'), + new Error( + 'SessionEnd hook did not complete within 30s (cancelled)', + ), ); } for (const failure of failures) { From 42be10069fb0deae6b968c72e2622378d140e2ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Sat, 12 Sep 2026 20:28:25 +0800 Subject: [PATCH 15/16] docs(cli): say what a cancelled SessionEnd hook costs on each path The comment promised the abort detection makes a cancelled hook "surface as a shutdown failure (non-zero exit)", but the throw ten lines below is gated on `managedConfigs`, so an unmanaged shutdown records the failure, logs the warning, and still exits 0. State both outcomes instead of only the one the managed path delivers. Comment only: the diff carries no non-comment line, and prettier and eslint are clean on the file. Co-authored-by: Qwen-Coder --- packages/cli/src/acp-integration/acpAgent.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index f630df6dbe1..452dc36c6c0 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -2996,9 +2996,12 @@ export async function runAcpAgent( // than rejected: `fireSessionEndEvent` resolves to `undefined` for a // cancelled hook (the `{ success: false, outcome: 'cancelled' }` // result never rejects), so `Promise.allSettled` cannot observe it and - // `failures` above stays empty. Detect the abort directly so a - // cancelled hook still surfaces as a shutdown failure (non-zero exit) - // instead of the CLI exiting 0 as though every hook had run. + // `failures` above stays empty. Detect the abort directly so the + // cancellation is recorded at all, instead of the CLI exiting 0 as + // though every hook had run. What recording it buys depends on the + // caller: the throw below is gated on `managedConfigs`, so a managed + // shutdown turns it into a non-zero exit while an unmanaged one gets + // the warning line only. if (controller.signal.aborted) { failures.push( new Error( From ebbeffecda0e0a8d1f5134df5e01325f307ab5ed Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 12 Sep 2026 20:39:48 +0800 Subject: [PATCH 16/16] docs(vscode): narrow the escalation Verification bullet to what each arm delivers The bullet claimed "POSIX and Windows escalation target the complete child tree". POSIX signals the child's process group, which does not reach a descendant that created its own group via setsid(); Windows walks the tree via taskkill /f /t but degrades to child.kill() (direct child only) when taskkill fails. Align both language versions with the Decision section and the code. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtycy85205 --- docs/design/vscode-acp-graceful-shutdown.md | 2 +- docs/design/vscode-acp-graceful-shutdown.zh-CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design/vscode-acp-graceful-shutdown.md b/docs/design/vscode-acp-graceful-shutdown.md index 09a45ae775e..68538c24e23 100644 --- a/docs/design/vscode-acp-graceful-shutdown.md +++ b/docs/design/vscode-acp-graceful-shutdown.md @@ -24,7 +24,7 @@ This change covers ACP process teardown and the connection races introduced by g ## Verification - Disconnect closes stdin before any forced termination. -- POSIX and Windows escalation target the complete child tree. +- POSIX escalation targets the ACP child's process group; Windows escalation targets the process tree via `taskkill /f /t`, degrading to the direct child if taskkill fails. - A normal child exit cancels escalation. - A retired child or response cannot clear or update its replacement. - EOF and signal shutdown execute each cleanup phase once, and all SessionEnd hooks begin within the shared deadline. diff --git a/docs/design/vscode-acp-graceful-shutdown.zh-CN.md b/docs/design/vscode-acp-graceful-shutdown.zh-CN.md index d68d6a719f5..405ba5341ef 100644 --- a/docs/design/vscode-acp-graceful-shutdown.zh-CN.md +++ b/docs/design/vscode-acp-graceful-shutdown.zh-CN.md @@ -24,7 +24,7 @@ companion 首先关闭 ACP 子进程的 stdin。CLI 将传输关闭作为正常 ## 验证 - 断开连接时先关闭 stdin,不立即强制终止。 -- POSIX 与 Windows 的升级路径都覆盖完整子进程树。 +- POSIX 升级路径针对 ACP 子进程所在的进程组;Windows 升级路径通过 `taskkill /f /t` 针对进程树,taskkill 失败时退化为只终止直接子进程。 - 子进程正常退出后取消升级计时器。 - 已退役子进程的退出或响应不能清理或更新替代连接。 - EOF 与信号重叠时每个清理阶段只执行一次,且所有 SessionEnd hooks 都在共享时限内启动。