Repository navigation
fix(server): settle Claude turns when the CLI exits #30675
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -352,6 +352,8 @@ const CLAUDE_STOP_CONFIRM_TIMEOUT_MS = 10_000; | |
| const CLAUDE_STDERR_TAIL_LENGTH = 2_048; | ||
| // The process group is killed on exit, so its stderr closes promptly. | ||
| const CLAUDE_STDERR_DRAIN_TIMEOUT = Duration.seconds(1); | ||
| // Process exit can precede buffered SDK messages, but descendants can keep stdout open forever. | ||
| const CLAUDE_QUERY_EXIT_DRAIN_TIMEOUT = Duration.seconds(1); | ||
| // Claude Code's refusal of bypassPermissions as root, which it explains only on stderr. | ||
| const CLAUDE_ROOT_REFUSAL = | ||
| "--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons"; | ||
|
|
@@ -610,6 +612,15 @@ export class ClaudeCodeRootRefusalError extends Schema.TaggedError<ClaudeCodeRoo | |
| } | ||
| } | ||
|
|
||
| class ClaudeProcessExitError extends Schema.TaggedError<ClaudeProcessExitError>()( | ||
| "ClaudeProcessExitError", | ||
| {}, | ||
| ) { | ||
| override get message(): string { | ||
| return "Claude Code exited before its message stream closed. Retry the turn."; | ||
| } | ||
| } | ||
|
|
||
| class ClaudeMcpRegistrationTimeoutError extends Schema.TaggedError<ClaudeMcpRegistrationTimeoutError>()( | ||
| "ClaudeMcpRegistrationTimeoutError", | ||
| {}, | ||
|
|
@@ -1252,23 +1263,28 @@ export const layerQueryRunner: Layer.Layer< | |
| }); | ||
| let spawnedProcess: ClaudeSpawnedProcess | undefined; | ||
| let processExited = false; | ||
| let processExit: Promise<void> | undefined; | ||
| const processExit = Promise.withResolvers<void>(); | ||
| const spawnClaudeCodeProcess = (spawnOptions: ClaudeSpawnOptions) => { | ||
| const child = spawnProcess(spawnOptions); | ||
| spawnedProcess = child; | ||
| // A spawn that fails emits only "error". | ||
| const releaseAgent = portDiscovery.trackAgent(); | ||
| child.once("exit", releaseAgent); | ||
| child.on("error", releaseAgent); | ||
| processExited = child.exitCode !== null || child.signalCode != null; | ||
| processExit = new Promise<void>((resolve) => { | ||
| if (processExited) resolve(); | ||
| else | ||
| child.once("exit", () => { | ||
| processExited = true; | ||
| resolve(); | ||
| }); | ||
| child.once("error", () => { | ||
| // A failed spawn emits no exit event and never receives a pid. | ||
| if ("pid" in child && child.pid === undefined) { | ||
| processExited = true; | ||
| processExit.resolve(); | ||
| } | ||
| }); | ||
| processExited = child.exitCode !== null || child.signalCode != null; | ||
| if (processExited) processExit.resolve(); | ||
| else | ||
| child.once("exit", () => { | ||
| processExited = true; | ||
| processExit.resolve(); | ||
|
Comment on lines
+1284
to
+1286
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| }); | ||
| return child; | ||
| }; | ||
| const queryRuntime = yield* Effect.uninterruptibleMask((restore) => | ||
|
|
@@ -1335,10 +1351,10 @@ export const layerQueryRunner: Layer.Layer< | |
| Effect.andThen( | ||
| Effect.tryPromise({ | ||
| try: async () => { | ||
| if (spawnedProcess !== undefined && !processExited) { | ||
| spawnedProcess.kill("SIGKILL"); | ||
| if (spawnedProcess !== undefined) { | ||
| if (!processExited) spawnedProcess.kill("SIGKILL"); | ||
| await processExit.promise; | ||
| } | ||
| await processExit; | ||
| }, | ||
| catch: (cause) => queryRunnerError(cause, "close"), | ||
| }).pipe(Effect.timeout("10 seconds")), | ||
|
|
@@ -1362,6 +1378,14 @@ export const layerQueryRunner: Layer.Layer< | |
| claudeQueryMessages(queryRuntime), | ||
| (cause) => cause, | ||
| ).pipe( | ||
| Stream.interruptWhen( | ||
| Effect.promise(() => processExit.promise).pipe( | ||
| Effect.andThen(Effect.sleep(CLAUDE_QUERY_EXIT_DRAIN_TIMEOUT)), | ||
| Effect.andThen( | ||
| Effect.fail(queryRunnerError(new ClaudeProcessExitError(), "processExit")), | ||
| ), | ||
| ), | ||
| ), | ||
| Stream.catch((cause) => | ||
| Stream.fromEffect( | ||
| Effect.gen(function* () { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: pingdotgg/t3code
Length of output: 7601
Script executed:
Repository: pingdotgg/t3code
Length of output: 28076
Script executed:
Repository: pingdotgg/t3code
Length of output: 15592
Script executed:
Repository: pingdotgg/t3code
Length of output: 9892
Test message delivery after process exit.
The buffered-message test writes before exit, so it does not exercise the drain interval. Start consuming the stream, emit exit with stdout open, and advance
TestClockby less than one second. Then write the message, close stdout, and assert successful delivery. Retain the existing buffered-message coverage.