feat(tui): surface worker RPC failures instead of hanging on a blank scr
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.resulterror 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
errorset causesclient.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
errorevent rejects any currently in-flight
call. - Tests the same crash-rejects-the-in-flight-call behavior against a
real BunWorker(not a mocked EventTarget) running a fixture that
throws on import, to confirm the mocked tests reflect real behavior. - Tests that a worker
closeevent rejects in-flight calls with an
"exited unexpectedly" error. - Tests that a worker
messageerrorevent (an undeserializable message)
rejects in-flight calls. - Tests that a message whose
dataisn't valid JSON rejects in-flight
calls instead of throwing uncaught insideonmessage. - Tests that a parsed message missing a well-formed
typefield rejects
in-flight calls. - Tests that a message with an unrecognized
typerejects in-flight
calls. - Tests that a call made after the worker has already died rejects
immediately instead of queueing forever. - Tests that
onDisconnectfires on a crash even when no call is
currently pending to reject. - Tests that
onDisconnectdoes not fire whenworker.terminate()
follows a call toexpectDisconnect(), 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 ifexpectDisconnect()is called moments later — guards
againstexpectDisconnect()being called too early and swallowing a
real crash. - Tests that a handler registered on
onDisconnectafter 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