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

Closed
AN
2mo ago
+545-74

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