Skip to content

Commit a693d77

Browse files
t3dotggclaude
andcommitted
fix(server): closing a Claude session stops its plugin MCP servers
The Claude CLI stops the MCP servers it starts, but not their children, so a plugin's `bun server.ts` outlived its `bun run` wrapper and the session, spinning on a pipe nobody read. The adapter now starts the CLI as its own process group leader and SIGKILLs the rest of the group when the CLI exits. Diagnosis from #19045. Fixes #3089. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 614b432 commit a693d77

2 files changed

Lines changed: 132 additions & 5 deletions

File tree

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts‎

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
45+
import * as NodeChildProcess from "node:child_process";
4246
import { parseCliArgs } from "@t3tools/shared/cliArgs";
4347
import { AgentScope } from "@t3tools/shared/AgentScope";
4448
import * as HostProcess from "@t3tools/shared/HostProcess";
@@ -108,6 +112,7 @@ import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts";
108112
import { compileClaudeModelSelection } from "../../claudeModelOptions.ts";
109113
import * as ServerConfig from "../../config.ts";
110114
import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
115+
import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";
111116
import {
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.
226231
const 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+
*/
246+
function 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+
228264
export 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‎

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
1+
// @effect-diagnostics nodeBuiltinImport:off -- the fake CLI and its leftover child are real processes.
12
import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk";
3+
import * as NodeChildProcess from "node:child_process";
4+
import * as NodeFS from "node:fs";
5+
import * as NodeOS from "node:os";
6+
import * as NodePath from "node:path";
27
import * as NodeServices from "@effect/platform-node/NodeServices";
3-
import { assert, it } from "@effect/vitest";
8+
import { assert, describe, it } from "@effect/vitest";
49
import { ProviderSessionId, ThreadId } from "@t3tools/contracts";
510
import * as Effect from "effect/Effect";
611
import * as Exit from "effect/Exit";
712
import * as Layer from "effect/Layer";
813
import { vi } from "vite-plus/test";
914

1015
import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers";
16+
import * as HostProcess from "@t3tools/shared/HostProcess";
1117
import {
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+
88+
const 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. */
99+
function 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+
106+
const 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+
122+
describe.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+
});

0 commit comments

Comments
 (0)