Skip to content

fix process output capture and timeouts - #463

Open
Sayan- wants to merge 12 commits into
mainfrom
hypeship/process-output-lifecycle
Open

Sayan- wants to merge 12 commits into
mainfrom
hypeship/process-output-lifecycle

Conversation

@Sayan-

@Sayan- Sayan- commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

commands can stall when nobody consumes their output, and process exit can race capture and lose final output or the exit event. exec timeouts kill only the direct child and return a retryable 500; spawn does not enforce its timeout.

capture non-TTY stdout/stderr into bounded temporary files independently of readers. cursorless streams keep shared consumption, so reconnects continue unread output without replaying consumed output or exit events. explicit offset=0, saved offsets, and Last-Event-ID opt into independent replay/resume. Last-Event-ID takes precedence over the query cursor so automatic EventSource reconnects do not repeat output. the response visitor writes SSE directly. retained output expires after 10 seconds, including stalled streams; the default limits are 64 MiB per process and 256 MiB total.

exec and spawn enforce deadlines against the process group. exec failures return 422 with a reason and partial output. terminal status is published after capture failures are recorded. manual /kill still forwards the requested signal to the direct process, and TTY sessions no longer allocate unused capture files. inherited pipes have a one-second drain bound; deliberately detached descendants may survive.

validation:

  • after merging current main, the non-e2e Go suite passes with the race detector on Go 1.26.10, including all 28 process regression tests. browser REPL and custom WebMCP tests initially skipped for a missing esbuild binary were rerun after installing it. explicit opt-in Chromium integration tests remain excluded from this local unit run.
  • both browser image builds and the full server unit job pass in CI on 6772b03. the container e2e suite compiles locally; it was not rerun locally for this merge.
  • before this merge, both locally built images passed process lifecycle and WebSocket TTY tests, and the full browser integration suite passed with the race detector: 55 top-level tests passed, two optional tests skipped, zero failures. four simultaneous cursorless readers also received every byte exactly once and one exit event in three image-level trials.
  • reverified on Go 1.26.10 that an already-reaped child returns exit_code: -1 without panicking. os.ProcessState.ExitCode() explicitly handles nil.

remaining validation gap: Linux image checks exercise waitid(WNOWAIT), but compatibility on Unikraft remains unverified. the reviewed upstream implementation may reap an already-exited child despite WNOWAIT, affecting exit status and PID reservation.


Note

High Risk
Changes core remote command execution (process groups, output quotas, timeout/cancel semantics, and HTTP status codes), which can affect any caller relying on prior exec 500 timeouts or unbounded output buffering.

Overview
Reworks process execution so non-TTY stdout/stderr are captured into bounded on-disk buffers whether or not anyone is streaming, with shared per-server quotas (PROCESS_OUTPUT_*, default 64 MiB/process and 256 MiB total) and 10s post-exit retention.

Exec and spawn share a new startProcess lifecycle: process groups for automatic timeout/cancel/shutdown (SIGKILL to the group, 1s pipe drain), Linux waitid(WNOWAIT) so descendants can be signaled before reap, and manual /kill still signals only the direct child. Exec timeouts, output limits, cancel, and capture errors return HTTP 422 with ProcessTerminationReason, partial base64 output, and output_truncated instead of retryable 500s.

SSE streaming is rewritten for replay/resume via offset / Last-Event-ID (with precedence rules), cursorless shared consumption, SSE id cursors, and unblocking stalled streams when retention expires; scale-to-zero middleware now honors handler SetWriteDeadline.

OpenAPI and docs/processes.md document the contracts; broad unit, lifecycle, and container e2e tests cover the behavior.

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

@Sayan-
Sayan- marked this pull request as ready for review October 14, 2026 22:09

@chruffins chruffins left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewed — requesting changes. two correctness bugs and one structural item to address first.

Bugs

  • server/cmd/api/api/process_runner.go:202 — exitCode is published before pending capture failures are drained (:208-220). GET status can return exited without the output_limit/output_error reason or output_truncated: true. SSE and exec wait on doneCh, so they're unaffected. consider draining failures before publishing exitCode; all capture producers have finished at that point, so close(failures) plus a range is safe.
  • server/cmd/api/api/process_runner.go:29 — PTY spawns create a capture file they never use (TTY output never reaches captureOutput, :134), so a PTY spawn fails when PROCESS_OUTPUT_DIR is unusable or disk/fd pressure is high. consider opening capture only on the non-TTY path.
  • server/cmd/api/api/process.go:292 (fallback in child_process_other.go:20-29) — the non-Linux fallback turns INT/HUP into Kill(), where base forwarded the requested signal. non-Linux is dev-only, so low urgency, but the fallback could forward non-KILL signals with Process.Signal.
  • server/cmd/api/api/process_runner.go:201 with process_wait_linux.go:16 — on Unikraft (staging lib/posix-process/wait.c), waitid reaps an already-exited child even with WNOWAIT. the following cmd.Wait() then gets ECHILD, so ProcessState is nil and exit_code reports -1 for fast exits. this also breaks the PID-reuse invariant described at process_wait_linux.go:12-13. not yet confirmed on the deployed runtime; worth an exec of true on a Unikraft build. one possible fix is passing a non-nil infop and reading the status from it.

Structural / Maintainability

  • server/cmd/api/api/process.go:393-450 — each SSE request builds an io.Pipe, a cancel wrapper, and about four goroutines before the response visitor (:465) writes bytes. the visitor could own the replay loop and write directly, which removes the pipe and its goroutines rather than moving them.

Coverage

19 non-generated changed files reviewed statically. server/lib/oapi/oapi.go is generated and was checked for schema consistency only. no tests were run.

@Sayan-
Sayan- requested a review from chruffins October 15, 2026 00:38

@chruffins chruffins left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewed — approving. one structural item below worth doing as a follow-up or before merging if you have time.

Structural / Maintainability

  • server/cmd/api/api/process_runner.go:136-204 — capture completion and capture failure use separate channels, a WaitGroup, a waiter goroutine, two completion flags, and a post-loop drain. each reader could send exactly one result (nil on success) onto a single channel, and the loop would consume those before publishing terminal status. that removes the waiter goroutine, the separate failure queue, the drain pass, and the redundant flags, and the runner's coordination gets much easier to follow.

Coverage

23 changed files reviewed statically at ae4aca7c. server/lib/oapi/oapi.go is generated and was checked for schema consistency only. no tests were run.

@hiroTamada hiroTamada left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewed — no blocking findings. two nonblocking suggestions:

  • server/cmd/api/api/process.go:388–391 — consider letting Last-Event-ID override the query cursor. an EventSource initialized with ?offset=0 otherwise replays everything on automatic reconnect.
  • server/cmd/api/api/process_lifecycle_test.go:208 — consider adding permanent tests for exec request cancellation and spawn shutdown. temporary checks covering both passed.

validation: full non-e2e suite passed with -race; go vet ./... passed. container e2e compiled but wasn't run.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit fcb2ed6. Configure here.

reapProcess(h.cmd)
code := h.cmd.ProcessState.ExitCode()
h.durationMs = int(time.Since(h.started) / time.Millisecond)
h.exitCode = &code

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait failure panics and hangs exec

High Severity

run always dereferences cmd.ProcessState after reapProcess, and ProcessExec waits on doneCh with no timeout. If waitid(WNOWAIT) reaps the child or Wait fails, ProcessState is nil, the runner panics before closing doneCh, and exec hangs while the process stays registered.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fcb2ed6. Configure here.

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.

3 participants