Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d3eb393
fix(vscode): shut the ACP CLI down gracefully instead of killing it
yiliang114 Sep 11, 2026
94c10c8
fix(acp): close shutdown and superseded-session races
yiliang114 Sep 11, 2026
99a72dd
test(acp): pin shutdown and retry invariants
yiliang114 Sep 11, 2026
315c34a
test(acp): satisfy strict companion type checks
yiliang114 Sep 11, 2026
dd58503
test(acp): await startup supersede assertion
yiliang114 Sep 11, 2026
71c3577
fix(acp): bound signal shutdown hooks
yiliang114 Sep 11, 2026
bbe224c
docs(acp): update escalation timing comment
yiliang114 Sep 11, 2026
67bd4c7
Merge remote-tracking branch 'origin/main' into fix/acp-graceful-shut…
yiliang114 Sep 12, 2026
2c835b3
fix(vscode): narrow ACP teardown to graceful shutdown
yiliang114 Sep 12, 2026
4cfe551
fix(vscode): narrow ACP teardown to graceful shutdown
yiliang114 Sep 12, 2026
edbdac4
Merge remote-tracking branch 'origin/fix/acp-graceful-shutdown' into …
yiliang114 Sep 12, 2026
c0aaaca
test(vscode): cover same-session stale responses
yiliang114 Sep 12, 2026
b462fdf
fix(cli): fail ACP shutdown when a SessionEnd hook is cancelled
yiliang114 Sep 12, 2026
7f8c118
fix(vscode): deliver prompts that finish after a same-connection sess…
yiliang114 Sep 12, 2026
5914fac
test(vscode): pin deferred ACP shutdown coverage gaps
yiliang114 Sep 12, 2026
02a4908
style(cli): satisfy prettier on acpAgent's SessionEnd failure message
yiliang114 Sep 12, 2026
42be100
docs(cli): say what a cancelled SessionEnd hook costs on each path
yiliang114 Sep 12, 2026
ebbeffe
docs(vscode): narrow the escalation Verification bullet to what each …
yiliang114 Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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<void>((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'));

Expand Down
42 changes: 33 additions & 9 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> | undefined;
const drainPoolBeforeExit = async (
label: string,
strict = false,
): Promise<void> => {
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<void> | undefined;
const disposeSessionsOnce = (): Promise<void> => {
if (!agentInstance) return Promise.resolve();
if (!disposeSessionsPromise) {
disposeSessionsPromise = agentInstance.disposeSessions();
}
return disposeSessionsPromise;
};

// Handle SIGTERM/SIGINT for graceful shutdown.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
Loading