Skip to content

A worker's async onmessage handler never resumes after its first await — the reply is never posted (OpenCode TUI blocker) #10854

Closed

Description

@proggeramlug

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

  1. proggeramlug commented on Sep 21, 2026

    @proggeramlug
    ContributorAuthor

    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 blocking rx.recv() with recv_timeout() when something is pending (blocking recv() 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:

    case before with the patch bun
    await Promise.resolve() then reply never 5ms 7ms
    await rpc.ping(x) then reply never 4ms 7ms
    await new Promise(r => setTimeout(r,1)) never 7ms 8ms
    OpenCode's full Rpc shape, 3 calls posted before the worker listens never 1503ms 1505ms

    Why it still cannot ship

    1. The timer queues are process-global, so the obvious drain steals them. My first version used the AllowTimers pump (js_promise_run_microtasks). timer.rs says it at the top —

    //! Uses global Mutex-protected state (not thread_local) so that timers
    

    TIMER_QUEUE, CALLBACK_TIMERS and INTERVAL_TIMERS are global mutexes, so an AllowTimers drain 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 with TypeError: value is not a function. Switching to js_promise_run_microtasks_await_loop (microtasks/nextTicks only, timers driven separately) avoids that, and the microtask/nextTick queues are perry_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:

    const mark = (m: string) => process.stderr.write(`[${Date.now() - t0}ms] ${m}\n`)
    const w: any = new Worker("w-await-micro.ts", { type: "module" })   // async onmessage
    w.onmessage = (e: any) => mark("main got: " + e.data)
    w.postMessage("first")
    setTimeout(() => { mark("posting second"); w.postMessage("second") }, 800)
    setTimeout(() => { mark("posting third");  w.postMessage("third")  }, 1600)
    setTimeout(() => { mark("done"); process.exit(0) }, 3000)
    

    All three replies arrive correctly and match bun, then a later main-thread timer throws TypeError: value is not a function inside mark. Which callback dies moves between runs (1600ms or 3000ms, sometimes none — 3 of 4 runs fail), and PERRY_GC_SCAVENGE_NURSERY_MB=512 does 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)
    diff --git a/crates/perry-stdlib/src/worker_threads.rs b/crates/perry-stdlib/src/worker_threads.rs
    index 5dac30fb2b..e2164a7ade 100644
    --- a/crates/perry-stdlib/src/worker_threads.rs
    +++ b/crates/perry-stdlib/src/worker_threads.rs
    @@ -540,6 +540,72 @@ fn string_coerce(value: f64) -> f64 {
         f64::from_bits(JSValue::string_ptr(ptr).bits())
     }
     
    +/// #10854: a worker thread needs real event-loop turns between messages.
    +///
    +/// `deliver_parent_port_message` calls the JS handler synchronously. When that
    +/// handler is `async` — the ordinary shape for a request/response worker
    +/// protocol — it returns a pending promise, and without a turn the continuation
    +/// after its first `await` never runs, so the reply is never posted. OpenCode's
    +/// `Rpc.listen` is exactly this shape (`const result = await rpc[method](input)`
    +/// before `postMessage`), which left its whole TUI waiting on a worker that had
    +/// received every request and answered none.
    +fn pump_worker_event_loop() {
    +    // Drain this thread's microtask/nextTick queues ONLY.
    +    //
    +    // It must not be the AllowTimers pump: `timer.rs` keeps `TIMER_QUEUE`,
    +    // `CALLBACK_TIMERS` and `INTERVAL_TIMERS` in global mutexes rather than
    +    // thread-locals, so an AllowTimers drain here runs the MAIN thread's timer
    +    // callbacks on this worker thread, against this thread's globals. That
    +    // corrupts them: a later main-thread timer calling `process.stderr.write`
    +    // died with "value is not a function" until this was narrowed to
    +    // microtasks. The microtask/nextTick queues are `perry_thread_local!`, so
    +    // draining those is confined to this worker.
    +    //
    +    // Bounded so a job queue that re-arms itself cannot wedge the worker here.
    +    for _ in 0..4096 {
    +        if perry_runtime::promise::js_promise_run_microtasks_await_loop() == 0 {
    +            break;
    +        }
    +    }
    +}
    +
    +/// How long the worker may block waiting for its next command.
    +///
    +/// `None` means "nothing is pending, block until a command arrives", so an idle
    +/// worker costs exactly what it did before this fix. Otherwise the wait is
    +/// capped by the nearest timer deadline so a pending `await` of a timer is
    +/// serviced on time without polling in between.
    +fn worker_wait_budget() -> Option<std::time::Duration> {
    +    // The timer queues are process-global (see `pump_worker_event_loop`), so
    +    // these answers cover every thread's timers, not just this worker's. That
    +    // is fine for a wake-up hint: a timer the MAIN thread owns is run by the
    +    // main thread, but when it resolves a promise this worker is awaiting, the
    +    // continuation lands in this thread's microtask queue and only a turn here
    +    // can run it. Waking to check is cheap — the drain has an empty fast path.
    +    let pending = perry_runtime::timer::js_timer_has_pending() != 0
    +        || perry_runtime::timer::js_callback_timer_has_pending() != 0
    +        || perry_runtime::timer::js_interval_timer_has_pending() != 0;
    +    if !pending {
    +        // Nothing anywhere: block, so an idle worker costs exactly what it did
    +        // before this fix.
    +        return None;
    +    }
    +    let mut soonest: Option<f64> = None;
    +    for ms in [
    +        perry_runtime::timer::js_timer_next_deadline(),
    +        perry_runtime::timer::js_callback_timer_next_deadline(),
    +        perry_runtime::timer::js_interval_timer_next_deadline(),
    +    ] {
    +        if ms >= 0.0 {
    +            soonest = Some(soonest.map_or(ms, |best: f64| best.min(ms)));
    +        }
    +    }
    +    // Cap the wait so a far-off deadline still leaves the worker responsive,
    +    // and floor it so a 60fps main loop cannot turn this into a spin.
    +    let ms = soonest.unwrap_or(5.0).clamp(1.0, 5.0) as u64;
    +    Some(std::time::Duration::from_millis(ms))
    +}
    +
     fn queue_worker_threads_microtask() {
         perry_runtime::closure::js_register_closure_arity(
             worker_threads_channels_microtask as *const u8,
    @@ -1277,14 +1343,33 @@ pub extern "C" fn js_worker_threads_worker_new(entry_ptr: i64, options: f64) ->
                         return;
                     }
                     loop {
    -                    match rx.recv() {
    -                        Ok(WorkerCommand::Message(message)) => {
    +                    // #10854: give the worker an event-loop turn before parking,
    +                    // so a continuation after `await` in the previous handler can
    +                    // run and post its reply.
    +                    pump_worker_event_loop();
    +                    let command = match worker_wait_budget() {
    +                        Some(budget) => match rx.recv_timeout(budget) {
    +                            Ok(command) => command,
    +                            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
    +                            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
    +                                break 'reload
    +                            }
    +                        },
    +                        // Nothing pending: block, so an idle worker costs nothing.
    +                        None => match rx.recv() {
    +                            Ok(command) => command,
    +                            Err(_) => break 'reload,
    +                        },
    +                    };
    +                    match command {
    +                        WorkerCommand::Message(message) => {
                                 deliver_parent_port_message(&message);
    +                            pump_worker_event_loop();
                                 if CURRENT_WORKER_CLOSE_REQUESTED.with(Cell::get) {
                                     return;
                                 }
                             }
    -                        Ok(WorkerCommand::Reload) => {
    +                        WorkerCommand::Reload => {
                                 MESSAGE_CALLBACK.with(|cb| *cb.borrow_mut() = None);
                                 MESSAGE_EVENT_CALLBACKS.with(|cbs| cbs.borrow_mut().clear());
                                 CLOSE_CALLBACK.with(|cb| *cb.borrow_mut() = None);
    @@ -1292,20 +1377,20 @@ pub extern "C" fn js_worker_threads_worker_new(entry_ptr: i64, options: f64) ->
                                 worker_surface::install_web_worker_globals();
                                 continue 'reload;
                             }
    -                        Ok(WorkerCommand::DirectMessage {
    +                        WorkerCommand::DirectMessage {
                                 message,
                                 source_thread_id,
                                 ack,
    -                        }) => {
    +                        } => {
                                 let result =
                                     direct_message::deliver_worker_message(&message, source_thread_id);
    +                            pump_worker_event_loop();
                                 let _ = ack.send(result);
                             }
    -                        Ok(WorkerCommand::Terminate) => {
    +                        WorkerCommand::Terminate => {
                                 exit_code = 1;
                                 break 'reload;
                             }
    -                        Err(_) => break 'reload,
                         }
                     }
                 }
    
  2. proggeramlug commented on Sep 23, 2026

    @proggeramlug
    ContributorAuthor

    The titled defect is fixed on main: a worker's async onmessage handler resumes after its first await and the reply is posted. Landed in #10859 (v0.5.1635) across ea61b44594 ("a worker_threads Worker claims its own agent id") and 5a1298394b ("a worker takes an event-loop turn between messages"), with a regression test on main at crates/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 #10399 as 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: await of 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:568 calls js_await_loop_tick_timers() with an owner-filtered comment saying it is what lets an await of 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions