Skip to content

fix(core): report shell exit only after output is captured - #56321

Merged
rekram1-node merged 7 commits into
devfrom
shell-exit-output
Oct 18, 2026
Merged

rekram1-node merged 7 commits into
devfrom
shell-exit-output

Conversation

@rekram1-node

@rekram1-node rekram1-node commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Shell.finish() set the terminal status (exited / timeout) before awaiting the output pump. During that window, GET /api/shell/:id already reported exited while the output file was still empty or partial, so a client that polls get and then reads output could get nothing back.

Reproduced over HTTP against the in-process server: polling shell.get until non-running, then reading shell.output for printf hello-world, returned "" in 10 of 40 runs.

Fix

Split "how did it end" from "publish that it ended":

  • An ended Deferred records the outcome. The process exit and the timeout timer both complete it, and the first one wins. The timer records the timeout and starts the kill in one step that cannot be interrupted. The kill itself runs detached, so remove and teardown never wait on a dying process.
  • One fiber, after the exit watcher, awaits ended, then waits for captured output to drain, and only then updates info, resolves waiters, publishes shell.exited, and runs retention.
  • time.completed is still the moment the process ended or the timeout fired.

Behavior change: when a timed-out process ignores SIGTERM, get and list keep reporting running until it is force-killed (up to about 3s), and retiming is ignored during that window. Previously status switched to timeout as soon as the timer fired, even though the process was still alive and its output incomplete.

Verification

  • Local scratch tests, not committed:
    • With a deterministic fake process that exits while its last output chunk is held back, get stays running until the chunk arrives. The same check fails on dev.
    • Retiming after a timeout still resolves wait with timeout.
  • HTTP repro: 10/40 empty reads before, 0/40 after. A timeout: 200 command reports timeout after about 220ms. With a process that ignores SIGTERM, time.completed matches when the timeout fired, and remove during the kill returns in a few ms.
  • Core shell tests: same results as dev. The 7 bash-syntax failures on macOS also fail on dev.
  • bun typecheck in packages/core is clean.

@opencode-agent opencode-agent Bot 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.

I found no blocking problems. On the base I reproduced the race with a real sh command: polling shell.get until the shell was no longer running and then reading shell.output gave empty or partial output in 44 of 60 runs. With this change it was 0 of 60. The new retention test fails on the base and passes here. The nearby shell tests (shell, shell-cleanup, shell-retention, tool-shell, session-shell) and the packages/core type check pass.

The finishing flag still makes the exit and timeout paths run finish() only once. The early return in command.timeout avoids interrupting a timeout fiber while it is finishing. wait, result and the shell.exited event behave as before.

Public API: nothing is added, removed or renamed. GET /api/shell/:id and the shell list now report running until captured output is complete, instead of switching as soon as the process exits. That is backwards compatible.

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.

1 participant