Repository navigation
A worker's async onmessage handler never resumes after its first await — the reply is never posted (OpenCode TUI blocker) #10854
Description
A worker's onmessage handler that awaits anything never resumes: the message is received, the handler starts, and the continuation after the first await never runs, so the reply is never posted. Without an await the same handler works.
This is what leaves the OpenCode v1.18.30 TUI (#10107) painting nothing. Its worker RPC handler is
// packages/opencode/src/util/rpc.ts
export function listen(rpc: Definition) {
onmessage = async (evt) => {
const parsed = JSON.parse(evt.data)
if (parsed.type === "rpc.request") {
const result = await rpc[parsed.method](parsed.input) // ← never resumes
postMessage(JSON.stringify({ type: "rpc.result", result, id: parsed.id }))
}
}
}so every request the TUI makes is received and never answered.
Repro
w.ts:
onmessage = async (e) => {
await Promise.resolve() // any await at all
postMessage("pong:" + e.data)
}main.ts:
const w: any = new Worker("/abs/path/w.ts", { type: "module" })
w.onmessage = (e: any) => { console.log("main got:", e.data); process.exit(0) }
w.postMessage("early")
setTimeout(() => { console.log("TIMEOUT - never replied"); process.exit(1) }, 5000)bun 1.3.14 [7ms] main got: pong:early
perry [5003ms] TIMEOUT - never replied
Every form of await fails, and removing the await fixes it:
| worker handler | bun | perry |
|---|---|---|
async e => { postMessage(...) } (no await) |
replies | replies |
async e => { await Promise.resolve(); postMessage(...) } |
replies | never |
async e => { const r = await rpc.ping(e.data); postMessage(r) } |
replies | never |
async e => { await new Promise(r => setTimeout(r, 1)); postMessage(...) } |
replies | never |
sync e => { postMessage(...) } |
replies | replies |
A microtask, an async function call and a timer all fail, so it is not specific to timers.
Later messages do not drain it either
Posting more messages does not flush the pending continuations — the worker answers none of them:
w.postMessage("first")
setTimeout(() => w.postMessage("second"), 800)
setTimeout(() => w.postMessage("third"), 1600)bun: got pong:first (8ms), pong:second (804ms), pong:third (1603ms)
perry: (nothing at all)
So the worker thread's microtask queue / event loop is never turned again once the module body has finished.
Where it looks like it comes from
crates/perry-stdlib/src/worker_threads.rs — after entry() returns, the thread parks in a blocking receive and calls the handler straight from there:
entry();
let has_message_consumer = MESSAGE_CALLBACK… || MESSAGE_EVENT_CALLBACKS…
|| worker_surface::web_worker_global_handler("onmessage").is_some();
if !has_message_consumer { return; }
loop {
match rx.recv() {
Ok(WorkerCommand::Message(message)) => {
deliver_parent_port_message(&message); // ← calls the JS handler
…deliver_parent_port_message invokes the handler, which for an async function returns a pending promise; the loop then goes straight back to blocking in rx.recv(). Nothing drains the microtask queue or runs a timer turn, so the continuation after the await has no opportunity to execute. Draining the job queue (and servicing timers) after each delivered message — the equivalent of one event-loop turn — is what the main thread does and what the worker seems to be missing.
Severity
This silently breaks any worker whose handler is async, which is the normal shape for a request/response worker protocol: the request arrives, nothing is ever sent back, and there is no error anywhere — no rejection, no exception, no exit. Diagnosing it from the application side is very hard, because the application looks like it is simply waiting on the network.
Verified on perry v0.5.1592, --platform bun and without, macOS arm64.
Activity
proggeramlug commentedon Sep 21, 2026
I tried the obvious fix and it is not sufficient — worth recording, because the shape of the failure says the missing event-loop turn is only half the problem.
What I tried
In
crates/perry-stdlib/src/worker_threads.rs, give the worker a turn between commands: drain after each delivered message and before parking, and replace the unconditional blockingrx.recv()withrecv_timeout()when something is pending (blockingrecv()when nothing is, so an idle worker still costs nothing).That does fix the reported symptom. The reproducers from the issue all pass, with bun-matching timing:
await Promise.resolve()then replyawait rpc.ping(x)then replyawait new Promise(r => setTimeout(r,1))Rpcshape, 3 calls posted before the worker listensWhy it still cannot ship
1. The timer queues are process-global, so the obvious drain steals them. My first version used the
AllowTimerspump (js_promise_run_microtasks).timer.rssays it at the top —//! Uses global Mutex-protected state (not thread_local) so that timersTIMER_QUEUE,CALLBACK_TIMERSandINTERVAL_TIMERSare global mutexes, so anAllowTimersdrain on a worker runs the main thread's timer callbacks on the worker thread, against the worker's globals. A later main-thread timer then died withTypeError: value is not a function. Switching tojs_promise_run_microtasks_await_loop(microtasks/nextTicks only, timers driven separately) avoids that, and the microtask/nextTick queues areperry_thread_local!, so that part is confined correctly.2. Once a worker runs JS between messages at all, the main thread is intermittently corrupted. Even with the timer-free drain, this still fails nondeterministically:
All three replies arrive correctly and match bun, then a later main-thread timer throws
TypeError: value is not a functioninsidemark. Which callback dies moves between runs (1600ms or 3000ms, sometimes none — 3 of 4 runs fail), andPERRY_GC_SCAVENGE_NURSERY_MB=512does not reliably avoid it (still 2 of 4), so it is a race rather than plain nursery pressure. The identical program with a sync worker handler — same three replies, same timers — passes every time, so it is the worker executing JS concurrently that does it, not the message traffic.That hazard is latent today only because a worker currently stops running JS the moment its module body ends: it parks in
rx.recv()and never resumes, which is exactly the bug in this issue. Giving it turns exposes the concurrency problem underneath, so the two have to be fixed together — presumably by making the worker's turns safe with respect to the main thread (the per-thread arena / foreign-address classification work in #10399 is the adjacent piece) or by scheduling them where the main thread is quiesced.I have reverted the patch rather than leave a nondeterministic corruption in the tree. The diff is attached below in case it is useful as a starting point.
attempted patch (reverted)
fix(runtime): a worker takes an event-loop turn between messages (#10854
fix(runtime): a worker_threads Worker claims its own agent id (#10854)
fix(runtime): a worker takes an event-loop turn between messages (#10854
fix(runtime): a worker_threads Worker claims its own agent id (#10854)
fix(runtime): a worker takes an event-loop turn between messages (#10854
fix(runtime): a worker_threads Worker claims its own agent id (#10854)
fix(runtime): a worker takes an event-loop turn between messages (#10854
fix(runtime): a worker_threads Worker claims its own agent id (#10854)
Merge origin/main into turnloop/integration (v0.5.1635)
proggeramlug commentedon Sep 23, 2026
The titled defect is fixed on main: a worker's async
onmessagehandler resumes after its first await and the reply is posted. Landed in #10859 (v0.5.1635) acrossea61b44594("a worker_threads Worker claims its own agent id") and5a1298394b("a worker takes an event-loop turn between messages"), with a regression test on main atcrates/perry/tests/issue_10854_worker_async_onmessage.rs. #10859 identifies this as the actual reason the OpenCode TUI painted nothing.It stayed open because #10859 carried only
Fixes #10399as a keyword and described this one in prose.Scope, so this isn't read as more than it is. #10859's body also recorded a narrower remaining case:
awaitof a timer inside a worker handler, where the timer is run by whichever thread owns the loop while the promise belongs to another thread's queue. Current main looks to cover that too —worker_threads.rs:568callsjs_await_loop_tick_timers()with an owner-filtered comment saying it is what lets anawaitof a timer resume inside a worker — but I have not measured it, and #10354's turnloop migration has since replaced the timer queues with a per-agent store, which changes that mechanism again.So: if the timer case still reproduces, please reopen or file it separately with a fixture, rather than treating it as covered by this issue.