Skip to content

feat(tui): surface worker RPC failures instead of hanging on a blank scr - #42885

Closed
anotheroni wants to merge 3 commits into
anomalyco:devfrom
anotheroni:tui-rpc-error
Closed

anotheroni wants to merge 3 commits into
anomalyco:devfrom
anotheroni:tui-rpc-error

Conversation

@anotheroni

Copy link
Copy Markdown

Covered by new tests in test/util/rpc.test.ts.

Reviewed-by: GPT-5.6 Sol

Issue for this PR

Closes #34981
Related: #41284, #35494

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Previously a throwing RPC handler, a crashed/killed worker, or an
unhandled rejection after the TUI entered alt-screen/raw mode all left
the process hanging on a blank terminal with nothing printed and no
useful exit code.

  • rpc.ts: listen() replies with an error instead of dropping it;
    client() rejects all in-flight and future calls when the worker
    errors, exits unexpectedly, or sends an undeserializable message;
    a postMessage() throw only rejects that one call. Adds
    onDisconnect() for callers with nothing in flight, and
    expectDisconnect() to distinguish our own worker.terminate() from a
    real crash.
  • tui.ts: wires worker disconnects into the TUI's fatal-error path and
    preserves a non-zero exit code instead of always exiting 0.
  • app.tsx: catches unhandledRejection/uncaughtException once alt-screen
    mode is active, restores the terminal, and reports the error.

How did you verify your code works?

Ran opencode with a bad database to verify that I get an error message instead of a blank screen

18 tests in rpc.test.ts covering throwing handlers, worker error/close/
messageerror events, postMessage failures, and (not just a synthetic
EventTarget) a real Bun Worker that crashes on import. Plus 1 new test
in the tui package's app-lifecycle.test.tsx. See the Tests section below
for what each one covers.

Cross-checked against a previous attempt at this same bug (#34974, closed,
never merged) to see if it covered something that this patch didn't.

Tests

Process-level fatal handling (unhandledRejection/uncaughtException)
is only partially covered: the app-lifecycle.test.tsx test invokes
the triggerFatal() callback directly rather than emitting a real
unhandledRejection or uncaughtException event.

packages/opencode/test/util/rpc.test.ts:

  • Tests that a throwing RPC handler in listen() replies with an
    rpc.result error instead of leaving the request to hang.
  • Tests that a thrown value whose message can't be read (e.g. a hostile
    Proxy) still produces a safe fallback error string, rather than
    throwing again inside the error path itself.
  • Tests that a result envelope with error set causes client.call()
    to reject rather than resolve.
  • Tests that a successful result envelope still resolves client.call()
    normally, as a baseline against the error-path tests above.
  • Tests that a synchronous throw from target.postMessage() rejects
    the call immediately instead of leaving it pending forever.
  • Tests that a worker error event rejects any currently in-flight
    call.
  • Tests the same crash-rejects-the-in-flight-call behavior against a
    real Bun Worker (not a mocked EventTarget) running a fixture that
    throws on import, to confirm the mocked tests reflect real behavior.
  • Tests that a worker close event rejects in-flight calls with an
    "exited unexpectedly" error.
  • Tests that a worker messageerror event (an undeserializable message)
    rejects in-flight calls.
  • Tests that a message whose data isn't valid JSON rejects in-flight
    calls instead of throwing uncaught inside onmessage.
  • Tests that a parsed message missing a well-formed type field rejects
    in-flight calls.
  • Tests that a message with an unrecognized type rejects in-flight
    calls.
  • Tests that a call made after the worker has already died rejects
    immediately instead of queueing forever.
  • Tests that onDisconnect fires on a crash even when no call is
    currently pending to reject.
  • Tests that onDisconnect does not fire when worker.terminate()
    follows a call to expectDisconnect(), i.e. our own intentional
    shutdown isn't reported as a crash.
  • Tests that an exit with code 0 is still treated as fatal unless
    expectDisconnect() was called first, since a clean-looking exit
    code doesn't distinguish our own shutdown from an unrelated one.
  • Tests that a crash occurring while a call is in flight still rejects
    it, even if expectDisconnect() is called moments later — guards
    against expectDisconnect() being called too early and swallowing a
    real crash.
  • Tests that a handler registered on onDisconnect after the worker
    has already died is invoked immediately rather than missed.

packages/tui/test/app-lifecycle.test.tsx:

  • Tests that a fatal error raised from within a finalizer (a plugin's
    dispose() call during cleanup) is still printed to stderr and sets
    exit code 1, instead of being lost once teardown has already started.

Screenshots / recordings

Reproduced against a real corrupted opencode.db (a bad "dummy" value where
a session ID was expected). Before this fix, the terminal just sat blank forever
with nothing printed. After:

                  ┃                                                          ┃
                  ┃  Unexpected server error. Check server logs for          ┃
                  ┃  details.                                                ┃
                  ┃                                                          ┃
                     █▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
                     █  █ █  █ █▀▀▀ █  █ █    █  █ █  █ █▀▀▀
                     ▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀


   ┃
   ┃  Ask anything... "What is the tech stack of this project?"
   ┃
   ┃  Build · Big Pickle OpenCode Zen
   ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
   tab agents  ctrl+p commands

            ● Tip Run /connect to add an AI provider and start coding

  /tmp/repo-rw/packages/opencode                                         local

Matching server log line:

level=ERROR message=failed ref=err_1b5e30e4 error="Error: Expected a string starting with \"ses\", got \"dummy\""
cause="... at workspace-routing.ts:222:23 ..."

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

…screen

Covered by new tests in test/util/rpc.test.ts.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Reviewed-by: GPT-5.6 Sol
@Enough1122

Copy link
Copy Markdown

AI code review — automated review for reference; please use your judgment.

  1. packages/opencode/src/util/rpc.ts:96 — any message with an unrecognized type now bricks the whole client (rejectAllPending latches dead), so a routine forward-compat skew — newer worker emitting one extra event type at an older CLI host — turns into total RPC death mid-session rather than a skipped frame — consider warn-and-continue for well-formed-but-unknown types and reserving fatal rejection for malformed envelopes/deserialization failures.

  2. packages/opencode/src/cli/cmd/tui.ts:262 — the pre-onReady disconnect path only prints the error and sets exitCode = 1; nothing terminates the wait, so a worker that crashes during startup still leaves the user staring at a terminal until they Ctrl-C (now with an explanation, which is real progress) — since triggerFatal isn't wired yet at that point, consider rejecting the outer setup promise directly from the early-registered client.onDisconnect.

  3. packages/opencode/src/cli/cmd/tui.ts:233 — await client.call("shutdown", ...) runs before expectDisconnect(), so a fast worker exit between the call and the flag set can still fire disconnect handlers/reject pending during teardown — moving expectDisconnect() above the shutdown call (it only suppresses observation) would close that window entirely.

  4. Nit — packages/opencode/src/util/rpc.ts:66 — new Error(parsed.error) loses any structured detail the worker attached; once this stabilizes consider passing through { message, cause? } objects so callers can branch on error classes (e.g. retryable vs fatal).

Overall: excellent fix for a miserable failure mode — the listen-side error reply alone eliminates the classic "blank screen forever", the client-side lifecycle (dead latch, expectDisconnect, onDisconnect) is thoughtfully designed, and the 247-line test suite plus crashing-worker fixture is exactly the coverage this deserves. Items 1–2 are behavior decisions to confirm; nothing blocking. Thanks!

Oskar Nilsson and others added 2 commits August 23, 2026 08:35
- rpc.ts: unrecognized-but-well-formed message types now fail only the
  matching pending call (or log-and-skip if unaddressed) instead of
  bricking the whole client, so a newer worker's extra event type
  can't take down an older CLI host mid-session.
- tui.ts: interrupt (not abandon) the run() fiber when the worker
  disconnects before onReady, via Effect.runFork + Fiber.interrupt, so
  finalizers restore the terminal before process.exit().
- tui.ts: register the disconnect handler where the early/done race is
  built so a worker that dies during the dynamic imports still replays
  through onDisconnect's dead-state check instead of being dropped.
- tui.ts: keep expectDisconnect() after the shutdown call (not before),
  so a crash mid-shutdown still fast-rejects instead of idling out at
  the 5s timeout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Automated PR Cleanup

Thank you for contributing to opencode.

Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions.

This PR was closed because it matched the following cleanup criteria:

  • The PR was created more than 1 month ago
  • The PR had fewer than 2 positive reactions
  • Positive reactions are counted as thumbs-up, heart, celebration, or rocket reactions on the PR

PRs created within the last month are not affected by this cleanup.

If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate.

Thanks again for taking the time to contribute.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(rpc): pending RPC calls hang forever when target Worker disconnects

2 participants