Skip to content

fix(process): don't drop process output and the exit event from streams - #451

Open
TanishGudise wants to merge 1 commit into
kernel:mainfrom
TanishGudise:fix/process-stream-drain
Open

TanishGudise wants to merge 1 commit into
kernel:mainfrom
TanishGudise:fix/process-stream-drain

Conversation

@TanishGudise

@TanishGudise TanishGudise commented Oct 12, 2026 •

Copy link
Copy Markdown

Checklist


Problem

GET /process/{process_id}/stdout/stream can end without the last part of a process's output and without the exit event (#450). There are two causes:

  1. The stream goroutine selects on outCh and doneCh together, so once the process has exited it can return while events are still queued.
  2. The waiter calls cmd.Wait(), which closes the StdoutPipe/StderrPipe read ends as soon as the process exits, discarding output the readers haven't read, and then queues exit ahead of the readers' last chunks.

Changes (server/cmd/api/api/process.go)

  • startWithOutputPipes creates the stdout/stderr pipes with os.Pipe, so the API owns the read ends and cmd.Wait doesn't close them.
  • The waiter still reports exited from cmd.Wait right away. It then lets the readers drain the pipes before closing them and queuing exit, so exit comes after all output. The wait before the pipes are closed is bounded by processOutputDrainTimeout (2 s), because a child the process started in the background can keep the pipes open. After that, it still waits for a reader blocked on a full queue, as main's blocking exit send already did.
  • After doneCh closes, the stream goroutine sends whatever is still queued before it ends.

Behavior changes:

  • When a background child keeps the pipes open after the process exits (for example sh -c "server &", or a shell killed while a child it started is still running), the exit event now arrives up to 2 s after the exit instead of right away. status still shows exited immediately.
  • If output is still unread when the 2 s run out (a background child holding the pipes, or more than a full queue of 256 chunks still unread because no stream is open or it is behind), the pipes are closed and what is left in them is dropped. Today that happens as soon as the process exits.
  • In both cases above, scale-to-zero is re-enabled after that wait instead of at exit.

Tests

New tests in process_test.go, Linux (golang:1.26.4 container), 5 runs each:

Test main drain only pipes only this PR
TestProcessStdoutStream_AfterExitDeliversQueuedEvents (200 short processes, stream opened after each exits) fails 5/5 fails 5/5 fails 5/5 passes 5/5
TestProcessStdoutStream_DeliversAllOutputBeforeExit fails 5/5 fails 1/5 fails 5/5 passes 5/5
TestProcessSpawn_BackgroundChildDoesNotHoldExit passes 5/5 passes 5/5 passes 5/5 (2.0 s) passes 5/5 (2.0 s)

"Drain only" is main plus the stream change; "pipes only" is this PR without it. The third test guards the bounded wait: the shell exits while a background sleep 30 holds the pipes. It isn't aimed at these bugs, but it occasionally fails on main too, when the stream ends without the exit event.

Repro sets from #450, against kernel-images-api built for linux/arm64 and called over HTTP on an internal Docker network, three sets each:

Case main this PR
Spawn, then stream (echo x3; exit 7), 100 runs: exit missing / output incomplete 27, 18, 26 / 5, 3, 6 0, 0, 0 / 0, 0, 0
Spawn, then stream (seq 1 3000; exit 3), 50 runs: exit missing / output incomplete 32, 27, 26 / 16, 22, 21 0, 0, 0 / 0, 0, 0

go vet ./... and go test -race $(go list ./... | grep -v /e2e$) on Linux give the same results as main. The only failure on both is TestSetFilteredFailsWhenItCannotPersist, which can't pass as root in my container. I didn't run make test-runtime (the Node daemon is unchanged) or the e2e suite: the published images are amd64-only and my Docker VM is arm64.

🤖 Generated with Claude Code


Note

Medium Risk
Changes async process lifecycle, pipe I/O ordering, and SSE delivery timing; bounded drain can still drop unread output or delay exit events when background children hold pipes.

Overview
Fixes process stdout SSE streams ending without trailing output or the exit event (#450).

Non-PTY spawns now use startWithOutputPipes (os.Pipe instead of StdoutPipe/StderrPipe) so cmd.Wait no longer closes pipe read ends while readers are still catching up. The exit waiter drains stdout/stderr readers (up to processOutputDrainTimeout, 2s) before closing FDs and enqueueing exit, so output events precede exit; scale-to-zero re-enable moves to after that drain. ProcessStdoutStream flushes any events still on outCh after doneCh closes instead of stopping immediately.

Tradeoff: background children that keep pipes open can delay the exit SSE event by up to ~2s; status still reports exited right away.

Adds regression tests for post-exit streaming, large buffered output, and background-child pipe behavior.

Reviewed by Cursor Bugbot for commit 58fc796. Bugbot is set up for automated code reviews on this repo. Configure here.

A /process/{id}/stdout/stream client could miss the end of a process's
output and its exit event:

- The stream goroutine selected on outCh and doneCh. Once the process
  exited both were ready, so it could return with output and the exit
  event still queued.
- The waiter called cmd.Wait, which closes cmd.StdoutPipe/StderrPipe read
  ends as soon as the process exits, discarding output the readers had not
  read yet, and then queued the exit event ahead of the readers' last
  chunks.

The API now owns the read ends of the output pipes, the waiter lets the
readers drain them before closing them and queuing the exit event (the
wait before closing is bounded by processOutputDrainTimeout, since a
background child can hold the pipes open), and the stream delivers
whatever is queued before it ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Process output stream can miss the end of the output and the exit event

1 participant