authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 01:51:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 08:51:29+00:00
log3213a0c77b4ba8e37a361b884d1035eb8dfd76d1
treeb843c41146e6307f84934fde9a03de5cb03aafcb
parent8d11fe1261f0dfd74f58a1b1ae8ad7e400905fcb
signature Signed by PGP key B5690EEEBB952194

fix(server): closing a Claude session no longer leaves plugin MCP servers running (#19322)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

2 files changed, 132 insertions(+), 5 deletions(-)

apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+52-4
...@@ -28,6 +28,8 @@ import {...@@ -28,6 +28,8 @@ import {
28 type PermissionUpdate,28 type PermissionUpdate,
29 type Query as ClaudeQuery,29 type Query as ClaudeQuery,
30 type Settings as ClaudeSdkSettings,30 type Settings as ClaudeSdkSettings,
31 type SpawnOptions as ClaudeSpawnOptions,
32 type SpawnedProcess as ClaudeSpawnedProcess,
31 type SDKAssistantMessage,33 type SDKAssistantMessage,
32 type SDKAPIRetryMessage,34 type SDKAPIRetryMessage,
33 type SDKMessage,35 type SDKMessage,
...@@ -39,6 +41,8 @@ import type {...@@ -39,6 +41,8 @@ import type {
39 AskUserQuestionInput,41 AskUserQuestionInput,
40 WebSearchOutput,42 WebSearchOutput,
41} from "@anthropic-ai/claude-agent-sdk/sdk-tools";43} from "@anthropic-ai/claude-agent-sdk/sdk-tools";
44// @effect-diagnostics-next-line nodeBuiltinImport:off -- the SDK takes a Node ChildProcess for the CLI.
45import * as NodeChildProcess from "node:child_process";
42import { parseCliArgs } from "@t3tools/shared/cliArgs";46import { parseCliArgs } from "@t3tools/shared/cliArgs";
43import { AgentScope } from "@t3tools/shared/AgentScope";47import { AgentScope } from "@t3tools/shared/AgentScope";
44import * as HostProcess from "@t3tools/shared/HostProcess";48import * as HostProcess from "@t3tools/shared/HostProcess";
...@@ -108,6 +112,7 @@ import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts";...@@ -108,6 +112,7 @@ import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts";
108import { compileClaudeModelSelection } from "../../claudeModelOptions.ts";112import { compileClaudeModelSelection } from "../../claudeModelOptions.ts";
109import * as ServerConfig from "../../config.ts";113import * as ServerConfig from "../../config.ts";
110import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";114import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
115import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";
111import {116import {
112 claudeSignedOutMessage,117 claudeSignedOutMessage,
113 makeClaudeEnvironment,118 makeClaudeEnvironment,
...@@ -225,6 +230,37 @@ const CLAUDE_SCRIPT_EXTENSIONS = [".js", ".mjs", ".tsx", ".ts", ".jsx"];...@@ -225,6 +230,37 @@ const CLAUDE_SCRIPT_EXTENSIONS = [".js", ".mjs", ".tsx", ".ts", ".jsx"];
225// mcp_set_servers once each server connected or hit its own 30 s timeout.230// mcp_set_servers once each server connected or hit its own 30 s timeout.
226const CLAUDE_MCP_REGISTRATION_TIMEOUT = "90 seconds";231const CLAUDE_MCP_REGISTRATION_TIMEOUT = "90 seconds";
227232
233/**
234 * Starts the Claude CLI as the leader of its own process group. When the CLI
235 * exits it stops the MCP servers it started, but not their children: a
236 * plugin's `bun run start` wrapper dies and its `bun server.ts` keeps running,
237 * writing to a pipe nobody reads (#3089). Those stay in the CLI's group, so
238 * the rest of the group gets SIGKILL once the CLI exits for any reason.
239 * Commands the agent runs through its Bash tool start their own groups, so
240 * background jobs it launched are not part of this.
241 *
242 * The SDK reads stderr only from its own spawn, where it redacts credentials
243 * before adding the tail to exit errors. Here stderr is dropped instead, so
244 * exit errors carry only the code or signal.
245 */
246function spawnClaudeProcessGroup(options: ClaudeSpawnOptions): ClaudeSpawnedProcess {
247 const child = NodeChildProcess.spawn(options.command, options.args, {
248 cwd: options.cwd,
249 env: options.env,
250 signal: options.signal,
251 stdio: ["pipe", "pipe", "ignore"],
252 detached: true,
253 });
254 child.once("exit", () => {
255 try {
256 signalProcessGroup(child.pid ?? 0, "SIGKILL");
257 } catch {
258 // The group is already empty.
259 }
260 });
261 return child;
262}
263
228export const ClaudeProviderCapabilitiesV2 = {264export const ClaudeProviderCapabilitiesV2 = {
229 sessions: {265 sessions: {
230 supportsMultipleProviderThreadsPerSession: false,266 supportsMultipleProviderThreadsPerSession: false,
...@@ -661,6 +697,7 @@ export const layerQueryRunner: Layer.Layer<...@@ -661,6 +697,7 @@ export const layerQueryRunner: Layer.Layer<
661 const crypto = yield* Crypto.Crypto;697 const crypto = yield* Crypto.Crypto;
662 const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers;698 const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers;
663 const agentScope = yield* AgentScope;699 const agentScope = yield* AgentScope;
700 const platform = yield* HostProcess.Platform;
664701
665 return ClaudeAgentSdkQueryRunner.of({702 return ClaudeAgentSdkQueryRunner.of({
666 allocateSessionId: crypto703 allocateSessionId: crypto
...@@ -689,9 +726,8 @@ export const layerQueryRunner: Layer.Layer<...@@ -689,9 +726,8 @@ export const layerQueryRunner: Layer.Layer<
689 // of the environment every command the agent runs inherits.726 // of the environment every command the agent runs inherits.
690 const { mcpServers, ...queryOptions } = input.options;727 const { mcpServers, ...queryOptions } = input.options;
691 // The SDK spawns a native binary as `path ...executableArgs ...sdkArgs`,728 // The SDK spawns a native binary as `path ...executableArgs ...sdkArgs`,
692 // which lets the agent scope wrapper go in front of the CLI while the729 // which lets the agent scope wrapper go in front of the CLI. The SDK
693 // SDK keeps its own stderr capture and exit reporting. The SDK runs a730 // runs a script entry through node instead, so those launch unwrapped.
694 // script entry through node instead, so those launch unwrapped.
695 const binaryPath = queryOptions.pathToClaudeCodeExecutable;731 const binaryPath = queryOptions.pathToClaudeCodeExecutable;
696 const launch =732 const launch =
697 binaryPath === undefined ||733 binaryPath === undefined ||
...@@ -712,11 +748,23 @@ export const layerQueryRunner: Layer.Layer<...@@ -712,11 +748,23 @@ export const layerQueryRunner: Layer.Layer<
712 pathToClaudeCodeExecutable: launch.command,748 pathToClaudeCodeExecutable: launch.command,
713 executableArgs: [...launch.args, ...(queryOptions.executableArgs ?? [])],749 executableArgs: [...launch.args, ...(queryOptions.executableArgs ?? [])],
714 };750 };
751 // A spawner the caller passed keeps its process. Windows has no process
752 // groups, so there the SDK spawns the CLI itself.
753 const spawnClaudeCodeProcess =
754 options.spawnClaudeCodeProcess ??
755 (platform === "win32" ? undefined : spawnClaudeProcessGroup);
715 // The CLI is running once query() returns, so it is closed if anything756 // The CLI is running once query() returns, so it is closed if anything
716 // before the session is handed out fails or is interrupted.757 // before the session is handed out fails or is interrupted.
717 const queryRuntime = yield* Effect.uninterruptibleMask((restore) =>758 const queryRuntime = yield* Effect.uninterruptibleMask((restore) =>
718 Effect.try({759 Effect.try({
719 try: () => query({ prompt, options }),760 try: () =>
761 query({
762 prompt,
763 options:
764 spawnClaudeCodeProcess === undefined
765 ? options
766 : { ...options, spawnClaudeCodeProcess },
767 }),
720 catch: (cause) => queryRunnerError(cause, "query"),768 catch: (cause) => queryRunnerError(cause, "query"),
721 }).pipe(769 }).pipe(
722 Effect.tap((queryRuntime) =>770 Effect.tap((queryRuntime) =>
apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts+80-1
...@@ -1,6 +1,11 @@...@@ -1,6 +1,11 @@
1// @effect-diagnostics nodeBuiltinImport:off -- the fake CLI and its leftover child are real processes.
1import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk";2import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk";
3import * as NodeChildProcess from "node:child_process";
4import * as NodeFS from "node:fs";
5import * as NodeOS from "node:os";
6import * as NodePath from "node:path";
2import * as NodeServices from "@effect/platform-node/NodeServices";7import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";8import { assert, describe, it } from "@effect/vitest";
4import { ProviderSessionId, ThreadId } from "@t3tools/contracts";9import { ProviderSessionId, ThreadId } from "@t3tools/contracts";
5import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";11import * as Exit from "effect/Exit";
...@@ -8,6 +13,7 @@ import * as Layer from "effect/Layer";...@@ -8,6 +13,7 @@ import * as Layer from "effect/Layer";
8import { vi } from "vite-plus/test";13import { vi } from "vite-plus/test";
914
10import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers";15import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers";
16import * as HostProcess from "@t3tools/shared/HostProcess";
11import {17import {
12 CLAUDE_AGENT_SDK_QUERY_PROTOCOL,18 CLAUDE_AGENT_SDK_QUERY_PROTOCOL,
13 ClaudeAgentSdkQueryRunner,19 ClaudeAgentSdkQueryRunner,
...@@ -78,3 +84,76 @@ it.effect("logs successful task stops for replay and omits failed stops", () =>...@@ -78,3 +84,76 @@ it.effect("logs successful task stops for replay and omits failed stops", () =>
78 );84 );
79 }).pipe(Effect.provide(NodeServices.layer)),85 }).pipe(Effect.provide(NodeServices.layer)),
80);86);
87
88const realQueryRunner = layerQueryRunner.pipe(
89 Layer.provide(
90 Layer.succeed(ProviderEventLoggers, {
91 native: undefined,
92 canonical: undefined,
93 }),
94 ),
95 Layer.provide(NodeServices.layer),
96);
97
98/** Writes an executable stand-in for the Claude CLI into a fresh directory. */
99function fakeClaudeCli(script: string): string {
100 const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-cli-"));
101 const cli = NodePath.join(dir, "claude");
102 NodeFS.writeFileSync(cli, `#!/bin/sh\n${script}\n`, { mode: 0o755 });
103 return cli;
104}
105
106const openFakeCli = (cli: string) =>
107 Effect.gen(function* () {
108 const runner = yield* ClaudeAgentSdkQueryRunner;
109 return yield* runner.open({
110 threadId: ThreadId.make("thread-fake-cli"),
111 providerSessionId: ProviderSessionId.make("session-fake-cli"),
112 options: {
113 sessionId: "native-session-fake-cli",
114 model: "claude-sonnet-4-6",
115 tools: [],
116 permissionMode: "default",
117 pathToClaudeCodeExecutable: cli,
118 },
119 });
120 });
121
122describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI process", () => {
123 // The CLI stops the MCP servers it started, but not their children: a
124 // plugin's `bun server.ts` outlives its `bun run` wrapper (#3089). The fake
125 // CLI leaves a child running the same way. The child holds a FIFO open, so
126 // its death reads as end-of-file here, with no polling.
127 it.effect("stops what the CLI left running when the session closes", () =>
128 Effect.gen(function* () {
129 const cli = fakeClaudeCli(
130 [
131 'dir=$(dirname "$0")',
132 'sleep 600 > "$dir/alive" &',
133 'echo $! > "$dir/leftover.pid"',
134 "cat > /dev/null",
135 ].join("\n"),
136 );
137 const dir = NodePath.dirname(cli);
138 NodeChildProcess.execFileSync("mkfifo", [NodePath.join(dir, "alive")]);
139 yield* Effect.addFinalizer(() =>
140 Effect.sync(() => {
141 // Only does anything when the test fails: do not leak its child.
142 try {
143 process.kill(Number(NodeFS.readFileSync(NodePath.join(dir, "leftover.pid"), "utf8")));
144 } catch {}
145 NodeFS.rmSync(dir, { recursive: true, force: true });
146 }),
147 );
148 const alive = NodeFS.createReadStream(NodePath.join(dir, "alive"));
149 const leftoverStarted = new Promise<void>((resolve) => alive.once("open", () => resolve()));
150 const leftoverStopped = new Promise<void>((resolve) => alive.once("close", () => resolve()));
151 alive.resume();
152
153 const session = yield* openFakeCli(cli);
154 yield* Effect.promise(() => leftoverStarted);
155 yield* session.close;
156 yield* Effect.promise(() => leftoverStopped);
157 }).pipe(Effect.provide(realQueryRunner)),
158 );
159});