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 {
2828 type PermissionUpdate,
2929 type Query as ClaudeQuery,
3030 type Settings as ClaudeSdkSettings,
31 type SpawnOptions as ClaudeSpawnOptions,
32 type SpawnedProcess as ClaudeSpawnedProcess,
3133 type SDKAssistantMessage,
3234 type SDKAPIRetryMessage,
3335 type SDKMessage,
......@@ -39,6 +41,8 @@ import type {
3941 AskUserQuestionInput,
4042 WebSearchOutput,
4143} 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";
4246import { parseCliArgs } from "@t3tools/shared/cliArgs";
4347import { AgentScope } from "@t3tools/shared/AgentScope";
4448import * as HostProcess from "@t3tools/shared/HostProcess";
......@@ -108,6 +112,7 @@ import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts";
108112import { compileClaudeModelSelection } from "../../claudeModelOptions.ts";
109113import * as ServerConfig from "../../config.ts";
110114import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
115import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";
111116import {
112117 claudeSignedOutMessage,
113118 makeClaudeEnvironment,
......@@ -225,6 +230,37 @@ const CLAUDE_SCRIPT_EXTENSIONS = [".js", ".mjs", ".tsx", ".ts", ".jsx"];
225230// mcp_set_servers once each server connected or hit its own 30 s timeout.
226231const 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
228264export const ClaudeProviderCapabilitiesV2 = {
229265 sessions: {
230266 supportsMultipleProviderThreadsPerSession: false,
......@@ -661,6 +697,7 @@ export const layerQueryRunner: Layer.Layer<
661697 const crypto = yield* Crypto.Crypto;
662698 const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers;
663699 const agentScope = yield* AgentScope;
700 const platform = yield* HostProcess.Platform;
664701
665702 return ClaudeAgentSdkQueryRunner.of({
666703 allocateSessionId: crypto
......@@ -689,9 +726,8 @@ export const layerQueryRunner: Layer.Layer<
689726 // of the environment every command the agent runs inherits.
690727 const { mcpServers, ...queryOptions } = input.options;
691728 // The SDK spawns a native binary as `path ...executableArgs ...sdkArgs`,
692 // which lets the agent scope wrapper go in front of the CLI while the
693 // SDK keeps its own stderr capture and exit reporting. The SDK runs a
694 // script entry through node instead, so those launch unwrapped.
729 // which lets the agent scope wrapper go in front of the CLI. The SDK
730 // runs a script entry through node instead, so those launch unwrapped.
695731 const binaryPath = queryOptions.pathToClaudeCodeExecutable;
696732 const launch =
697733 binaryPath === undefined ||
......@@ -712,11 +748,23 @@ export const layerQueryRunner: Layer.Layer<
712748 pathToClaudeCodeExecutable: launch.command,
713749 executableArgs: [...launch.args, ...(queryOptions.executableArgs ?? [])],
714750 };
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);
715756 // The CLI is running once query() returns, so it is closed if anything
716757 // before the session is handed out fails or is interrupted.
717758 const queryRuntime = yield* Effect.uninterruptibleMask((restore) =>
718759 Effect.try({
719 try: () => query({ prompt, options }),
760 try: () =>
761 query({
762 prompt,
763 options:
764 spawnClaudeCodeProcess === undefined
765 ? options
766 : { ...options, spawnClaudeCodeProcess },
767 }),
720768 catch: (cause) => queryRunnerError(cause, "query"),
721769 }).pipe(
722770 Effect.tap((queryRuntime) =>
apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts+80-1
......@@ -1,6 +1,11 @@
1// @effect-diagnostics nodeBuiltinImport:off -- the fake CLI and its leftover child are real processes.
12import * 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";
27import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
8import { assert, describe, it } from "@effect/vitest";
49import { ProviderSessionId, ThreadId } from "@t3tools/contracts";
510import * as Effect from "effect/Effect";
611import * as Exit from "effect/Exit";
......@@ -8,6 +13,7 @@ import * as Layer from "effect/Layer";
813import { vi } from "vite-plus/test";
914
1015import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers";
16import * as HostProcess from "@t3tools/shared/HostProcess";
1117import {
1218 CLAUDE_AGENT_SDK_QUERY_PROTOCOL,
1319 ClaudeAgentSdkQueryRunner,
......@@ -78,3 +84,76 @@ it.effect("logs successful task stops for replay and omits failed stops", () =>
7884 );
7985 }).pipe(Effect.provide(NodeServices.layer)),
8086);
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});