Skip to content

fix(exec): report real command exit codes instead of a synthetic -1 - #1882

Merged
bobleer merged 2 commits into
GCWing:mainfrom
bobleer:bob/remote-workspace-exit-code-9e0506
Jul 30, 2026
Merged

fix(exec): report real command exit codes instead of a synthetic -1#1882
bobleer merged 2 commits into
GCWing:mainfrom
bobleer:bob/remote-workspace-exit-code-9e0506

Conversation

@bobleer

@bobleer bobleer commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Remote workspace commands that ran fine were reported to the model as exit_code: -1.

run_ssh_channel treated SSH_MSG_CHANNEL_EOF as the end of the channel:

Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break,

EOF only means the peer will send no more data. RFC 4254 §6.10 does not fix the ordering of the exit-status request, and OpenSSH's server_loop2 flushes EOF from channel_after_poll() before it reaps the child and sends the status — so for any short-lived command the status arrives after EOF and was thrown away. workspace_pipe_owner then turned that unknown status into a synthetic -1, which the model cannot distinguish from a real failure.

The one-shot path (execute_command_internal) already drained until the stream ended, which is why shell probing and env snapshots kept reporting correct codes — the two paths simply disagreed.

Auditing the rest of the exec surface turned up several related defects, including an independent one in the local path.

Type and Areas

Type: bug fix

Areas: Rust core — remote SSH workspace exec, local terminal exec, tool result rendering

Motivation / Impact

Every non-TTY remote workspace command reported exit_code: -1 and Process exited with code -1. to the agent, so a successful command looked like a failure. Agents cannot reason about command success in a remote workspace at all today.

Remote

  • run_ssh_channel / remote_pty_owner: keep draining after EOF until CHANNEL_CLOSE, with a bounded 5s grace so a server that goes quiet cannot wedge the owner. Both break immediately once the status arrives, so the normal path adds no latency.
  • An unknown status is now reported as unknown (null) instead of a fabricated -1.
  • exit-signal maps to the conventional 128 + signal status (was _ => -1). execute_command_internal previously discarded exit-signal entirely and fell through to -1; it now records it too.
  • A supervised docker exec child killed by a signal has ExitStatus::code() == None; that is mapped the same way instead of being lost.

Local (independent defect found while auditing)

request_control takes the terminator, so the control sender is dropped as soon as the first interrupt or kill is queued. The control_rx.recv() select branch in spawn_pipe_process had no guard, so it then resolved to None instantly and forever. With biased ordering that starved the reader-done branch, and the loop never reached its exit condition:

  • the session stayed open with no exit code ever reported, and
  • it re-signalled an already-dead process group in a hot loop.

The branch is now disarmed once the channel closes, and local signal deaths also report 128 + signal. This fixes five terminal-core tests that were failing on main.

Rendering

A completed process with no reported code rendered as Process status unavailable.; it now says Process exited, but no exit code was reported by the transport. No exit code is ever invented.

Verification

Fully tested. AI-assisted (Claude Code).

New regression coverage — an in-process russh server drives the channel owner through the orderings real servers use, so this no longer depends on having a live host:

  • exit_status_sent_after_eof_is_still_reported — reproduces the bug: None (→ -1) before the fix, Some(7) after
  • exit_status_sent_before_eof_is_reported, exit_signal_sent_after_eof_maps_to_a_conventional_status, missing_exit_status_stays_unknown
  • pipe_owner_reports_{successful,failing}_process_exit_code, ..._after_large_output, ..._signal_death_as_conventional_status
  • local_process_signal_death_reports_a_conventional_status, ssh_exit_signals_map_to_conventional_wait_statuses
  • pipe_exec_reports_signal_death_as_a_conventional_status (local)
  • command_response_says_an_exited_process_had_no_reported_exit_code
cargo test -p bitfun-services-integrations --features remote-ssh-concrete --lib remote_ssh::   # 98 passed
cargo test -p terminal-core --lib                                                             # 91 passed (incl. 5 previously failing)
cargo test -p tool-runtime --lib                                                              # 120 passed
cargo check --workspace --all-targets --exclude bitfun-desktop --exclude bitfun-relay-server  # clean
cargo clippy -p terminal-core -p tool-runtime -p bitfun-services-integrations --all-targets   # no new lints

Two exclusions above are pre-existing failures unrelated to this change, listed under Reviewer Notes.

Reviewer Notes

Why the grace window. Waiting for CHANNEL_CLOSE costs nothing in practice because it follows the status immediately; the 5s grace only bounds a server that sends EOF and then goes silent without closing. Previously that case broke instantly, so the only behaviour change there is a bounded delay before reporting the same unknown status.

Local PTY path left as-is. portable_pty::ExitStatus hard-maps signal death to code = 1 and does not expose the signal name, so tty=true local commands cannot distinguish "exited 1" from "killed by signal". It reports a plausible non-zero rather than -1, so I left it rather than guessing; worth a separate look if it matters.

Also fixed: unrelated Frontend Build CI failure. main's own tip commit (93ba4ece7, the base of this PR) already failed CI at Run web UI tests: PersistenceModule.test.ts threw Cannot access 'saveSessionTurn' before initialization. vi.mock factories are hoisted above ordinary module-scope const declarations, so the vi.fn() instances referenced inside the factory were still in their temporal dead zone. Switched to vi.hoisted, the pattern already used elsewhere in this codebase (e.g. initializeLsp.test.ts). Confirmed the full pnpm --dir src/web-ui exec vitest run suite is green: 359/359 files, 2377/2377 tests. Unrelated to the exec/exit-code fix above; included here only because it was blocking this PR's CI.

Two flaky pre-existing fixtures. control_interrupt_kills_running_pipe_process_group_after_grace and control_kill_closes_pipe_session_after_parent_exit_with_descendant_pipes fail roughly 2 in 12 full-suite runs on a loaded machine, always on the setup assertion (first.session_id) before any control action is sent — their 500ms/700ms windows are too tight under parallel load. 0/15 failures in isolation and 0/6 with --test-threads=1. Not introduced here (they failed 100% of the time before this fix, at the later assertion), but they could use longer windows.

Pre-existing issues found while verifying, not touched:

  • src/apps/relay-server/tests/library_compat.rs:26AppState initializer is missing the page_browser_auth field, so that test target does not compile.
  • bitfun-desktop requires src/mobile-web/dist to exist, so cargo check --workspace fails before the web build.
  • src/crates/services/services-core/src/json_store.rs:346 — deny-level clippy::suspicious_open_options (create(true) without truncate), which blocks clippy for every crate depending on it.

Checklist

  • This PR is focused and does not include secrets, temporary prompts, generated scratch files, or unrelated artifacts.
  • Relevant verification is recorded above, or skipped checks are explained.
  • User-facing strings, docs, and locales are updated where applicable.

bobleer added 2 commits July 29, 2026 22:46
Remote workspace commands finished successfully but were reported to the
model as `exit_code: -1`.

`run_ssh_channel` treated `SSH_MSG_CHANNEL_EOF` as the end of the channel.
EOF only means the peer will send no more data; RFC 4254 6.10 does not fix
the ordering of the `exit-status` request, and OpenSSH flushes EOF from its
channel loop before it reaps the child and reports the status. Breaking on
EOF therefore discarded the exit code of nearly every short-lived command,
and `workspace_pipe_owner` then turned the resulting unknown status into a
synthetic `-1` that is indistinguishable from a real failure. The one-shot
path in `execute_command_internal` already drained until the stream ended,
which is why shell probing and env snapshots kept working.

Both SSH channel owners now keep draining after EOF until CHANNEL_CLOSE,
with a bounded grace so a server that goes quiet cannot wedge them, and
they break immediately once the status arrives so the normal path adds no
latency. An unknown status is reported as unknown rather than as -1, and
`exit-signal` maps to the conventional `128 + signal` status in the channel
owners and in the one-shot path, which previously dropped it entirely.
Signal deaths of a supervised `docker exec` child map the same way instead
of losing `ExitStatus::code() == None`.

The local pipe path had an independent defect. `request_control` takes the
terminator, so the control sender is dropped as soon as the first interrupt
or kill is queued; the unguarded `control_rx.recv()` select branch then
resolved to `None` instantly and forever. With `biased` ordering that
starved the reader-done branch, so the loop never reached its exit
condition: the session stayed open with no exit code while re-signalling a
dead process group in a hot loop. The branch is now disarmed once closed,
and local signal deaths also report `128 + signal`.

Finally, a completed process with no reported code rendered as "Process
status unavailable"; it now says so explicitly.
vi.mock factories are hoisted above ordinary module-scope const
declarations, so the referenced vi.fn() instances were still in their
temporal dead zone when the factory ran, throwing
"Cannot access 'saveSessionTurn' before initialization" and failing the
whole suite. Use vi.hoisted, matching the pattern already used elsewhere
in this codebase (e.g. initializeLsp.test.ts), so the mocks are created
before vi.mock needs them.
@bobleer
bobleer merged commit ffa096c into GCWing:main Jul 30, 2026
7 checks passed
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