Skip to content

Stop ExecV3 hanging, and stop it losing output - #499

Open
shellicar wants to merge 11 commits into
mainfrom
fix/exec-hang
Open

Stop ExecV3 hanging, and stop it losing output#499
shellicar wants to merge 11 commits into
mainfrom
fix/exec-hang

Conversation

@shellicar

@shellicar shellicar commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Summary

  • A cancelled or timed-out pipe returns instead of hanging the caller with every process already dead.
  • A producer stops when its consumer exits, without the CLI having to notice and intervene.
  • A command whose redirect target cannot be opened now fails and says why, rather than running and reporting success while its output went nowhere.
  • stdout and stderr aimed at the same file are refused, whatever the spelling, instead of one silently overwriting the other.
  • A command producing very large output truncates loudly instead of ending the whole call.

A producer stopped by a broken pipe now reports either a SIGPIPE signal or a non-zero exit carrying the program's own message. Which one appears is timing, not anything a caller controls, so both should be read as the same outcome. The reasoning and the options are in CLAUDE.md under Known Debt.

@shellicar shellicar added this to the 1.0 milestone Aug 2, 2026
@shellicar shellicar added bug Something isn't working pkg: claude-sdk-tools The SDK tools package pkg: exec-core Process execution used by the tools labels Aug 2, 2026
@shellicar shellicar self-assigned this Aug 2, 2026
@shellicar
shellicar requested a review from bananabot9000 August 2, 2026 15:18
@shellicar shellicar changed the title Stop a cancelled ExecV3 pipe from hanging the CLI Stop ExecV3 hanging, and stop it losing output Aug 3, 2026
@shellicar
shellicar marked this pull request as ready for review August 3, 2026 13:22
A pipeline cancelled mid-flight left its run promise unsettled, so the CLI hung with every process already dead. The stages were joined by in-process streams that simulated backpressure and SIGPIPE. The kernel now provides both.
A stage that fails to start has no child and no second file descriptor, so 2>&1 had nothing to apply to and the reason was discarded. Its consumer never opened the pipe either, leaving the producer writing into nothing until the run was cancelled.
A lone stage was delegated to run(), which takes one stderr sink where a stage has two destinations for it, so a merged command's capture was never ended and whoever drained it waited forever. Closing a sink then waiting for it has the same shape wherever the sink is a duplex: the reader on the other end is the caller, who is waiting for the executor.
Node's stdio is a socketpair rather than a pipe, so a consumer that exits leaving unread bytes makes the kernel reset the connection: the producer's write fails with ECONNRESET and it exits non-zero, where a clean close would have raised SIGPIPE. Over 40 rounds that was SIGPIPE every time on macOS and about one in three on Linux, so a test naming the signal is asserting a coin toss.
A redirect was opened lazily, so a path that could not be written failed on the stream long after the command had run, and that failure was swallowed: the output vanished and the call reported success. Opening before the program starts is the last moment the caller can still be told. Separately, a cwd that was a file passed the existence check and threw at spawn instead, which both escaped as a raw error and left stages already spawned for that pipeline running unwatched.
A capture was collected into one string with nothing limiting it, and `yes` produces about 1.5GB a second, so a single word reached V8's maximum string length in well under a second and the whole call died with a message about string lengths. Reading has to continue past the limit and discard, because a capture that stops being read stalls the process filling it. Separately, stdout and stderr aimed at the same file each opened their own stream at offset zero, so one overwrote the other and half the output went missing while the command reported success.
Validation already refuses the same path written twice, but it runs before any working directory is known, so all it can compare is the two strings. Resolved against the command's own cwd, two spellings turn out to name one file, and two streams on one file each open at offset zero: one overwrites the other and half the output goes missing while the command reports success.
Two spellings of one path, or two links to one file, both end up as a single file with two streams opened on it at offset zero, so one overwrites the other. Canonicalising answers where a path lands, and it answers for a target that does not exist yet, which a redirect target usually does not. It takes the working directory rather than being handed an already-resolved path because expansion has to come first: resolve by hand and a leading ~ becomes a directory name that no later expansion can undo.
@shellicar shellicar added the pkg: claude-core Core utilities — ansi, reflow, rendering label Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working pkg: claude-core Core utilities — ansi, reflow, rendering pkg: claude-sdk-tools The SDK tools package pkg: exec-core Process execution used by the tools

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant