feat(integration,event_loop): add wasm / wasm-gc scheduler-only run_async_main shim#389
feat(integration,event_loop): add wasm / wasm-gc scheduler-only run_async_main shim#389mizchi wants to merge 2 commits into
Conversation
…sync_main shim
run_async_main was defined only for target=native and target=js, so
building any 'async fn main { ... }' for wasm or wasm-gc failed with
'Value run_async_main not found in package moonbitlang/async'.
Pure-coroutine code (pause, with_task_group, aqueue, semaphore,
cond_var) does not depend on the event loop and works on wasm/wasm-gc
already; the missing piece was just the entry point. Add the
scheduler-only entry that mirrors the JS shim, plus a matching
reschedule() in internal/event_loop/unimplemented.mbt that drains
@coroutine.reschedule() until no ready work remains.
IO primitives (sleep, Timer, sockets, fs, etc.) still abort via the
existing unimplemented.mbt stubs -- this PR only closes the gap for
pure-coroutine workloads and host-embedded use cases (browser, V8,
custom IO runtime).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6c5519e5fb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| while !@coroutine.no_more_work() { | ||
| @coroutine.reschedule() |
There was a problem hiding this comment.
Avoid spinning when no runnable coroutine exists
reschedule() loops until @coroutine.no_more_work() is true, but no_more_work() also depends on the internal blocking counter. If a coroutine calls @coroutine.suspend() and no coroutine is ready (for example, waiting for an external callback or a deadlocked wait), this condition stays false while the ready queue is empty, so this loop becomes a tight CPU spin that never yields back to the host. On wasm/wasm-gc this can deadlock embedding scenarios the shim is meant to support, because the host event loop cannot run while this function is busy-looping.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch, fixed in 4c1b564. Switched the guard from !@coroutine.no_more_work() to @coroutine.has_immediately_ready_task() (= !scheduler.run_later.is_empty()). Now the loop drains only immediately-ready work; if all ready coroutines are processed and only blocked ones remain (e.g., suspended on an external host callback), reschedule() returns and yields back to the host. The host can re-enter via run_async_main when new work becomes available, mirroring the spirit of the JS shim (which delegates continuation to set_timeout(0, reschedule)).
…ask to avoid spinning The previous shim looped on '!@coroutine.no_more_work()'. no_more_work checks both 'scheduler.run_later.is_empty()' AND 'scheduler.blocking == 0', so if any coroutine had suspended (e.g. waiting on an external host callback that wasm can not service) while the ready queue was empty, the loop would tight-spin: no_more_work stays false, but @coroutine.reschedule() pops zero coroutines per round. On wasm/wasm-gc this can deadlock embedding scenarios -- the host event loop never runs while this function busy-loops. Gate on has_immediately_ready_task() (= !run_later.is_empty()) so we drain only the work that is actually ready right now. Coroutines that become ready while a sibling is running are still picked up by the loop continuation. When all immediately-ready work is drained, the function returns and yields back to the host, which can re-enter via run_async_main once new work is available.
| while @coroutine.has_immediately_ready_task() { | ||
| @coroutine.reschedule() | ||
| } | ||
| } |
There was a problem hiding this comment.
inline this loop into call sites
Summary
integration.run_async_mainis defined only fortarget="native"andtarget="js". Attempting to build anyasync fn main { ... }for wasm or wasm-gc fails withValue run_async_main not found in package moonbitlang/async.This matters even though wasm/wasm-gc don't get socket / fs / process / timer / signal (they all
abortviasrc/internal/event_loop/unimplemented.mbt). Pure-coroutine code —pause,with_task_group,aqueue,semaphore,cond_var— does not depend on the event loop and works fine on wasm/wasm-gc.Add the missing scheduler-only entry points. Mirrors the existing JS shim:
Plus a matching
reschedule()insrc/internal/event_loop/unimplemented.mbtthat drains@coroutine.reschedule()until no ready work remains.Why bother
Two reasons it's useful even without IO:
wasm-gctarget is the natural deployment for moonbit code running inside a host (browser, V8, runtime with custom IO). Letting that host callasync fn mainwith the standard scheduler — and arrange IO independently — closes the gap.Cross-backend benchmark scenarios (all pure-coroutine, run on native / wasm / wasm-gc / js):
bench-async/cmd/pause_loop/main.mbtbench-async/cmd/cond_var_signal/main.mbtbench-async/cmd/aqueue_throughput/main.mbtbench-async/cmd/semaphore_throughput/main.mbtbench-async/cmd/spawn_wait/main.mbtHeadline from running these five across all four backends: wasm-gc beats native by 3× on
pause_loopand 2× oncond_var_signalbecause the event-loop overhead (epoll_wait+ms_since_epoch+ timer-set scan) drops out; wasm is 3-4× slower than wasm-gc on all five workloads, mirroring the refcount overhead seen elsewhere in the moonbit ecosystem.Test results
moon test --target wasm-gcruns only the tests that already haveunimplemented-friendly fallbacks; those continue to pass:Native and js are also untouched:
Caveats
sleep,Timer::*, sockets, fs, etc. stillabortvia the existingunimplemented.mbtstubs.await async_io_thing()on wasm-gc gets an abort at runtime, not a build-time error. That is consistent with how the current wasm stubs behave.The two-line shim itself is just delegating to existing primitives, so the surface for accidental breakage is small.