From 6c5519e5fb8b240730eb98916d4761b351ce13fe Mon Sep 17 00:00:00 2001 From: mizchi Date: Sat, 23 May 2026 19:10:51 +0900 Subject: [PATCH 1/2] feat(integration,event_loop): add wasm / wasm-gc scheduler-only run_async_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). --- src/integration.mbt | 24 +++++++++++++++++++++++ src/internal/event_loop/unimplemented.mbt | 9 +++++++++ 2 files changed, 33 insertions(+) diff --git a/src/integration.mbt b/src/integration.mbt index 896cdf37..c7ff1ded 100644 --- a/src/integration.mbt +++ b/src/integration.mbt @@ -41,3 +41,27 @@ pub fn run_async_main(main : async () -> Unit) -> Unit { let _ = @coroutine.spawn(main) @event_loop.reschedule() } + +///| +/// Scheduler-only entry for wasm / wasm-gc. The async library does not +/// provide IO on these targets (`sleep`, `Timer`, sockets, fs, etc. all +/// `abort` via the existing `unimplemented.mbt` stubs), but pure +/// coroutine code (`pause`, `with_task_group`, `aqueue`, `semaphore`, +/// `cond_var`) does not depend on the event loop and works fine — this +/// shim just drains the ready queue. +#coverage.skip +#cfg(target="wasm") +#doc(hidden) +pub fn run_async_main(main : async () -> Unit) -> Unit { + let _ = @coroutine.spawn(main) + @event_loop.reschedule() +} + +///| +#coverage.skip +#cfg(target="wasm-gc") +#doc(hidden) +pub fn run_async_main(main : async () -> Unit) -> Unit { + let _ = @coroutine.spawn(main) + @event_loop.reschedule() +} diff --git a/src/internal/event_loop/unimplemented.mbt b/src/internal/event_loop/unimplemented.mbt index d3f8c5c2..a43c1ffa 100644 --- a/src/internal/event_loop/unimplemented.mbt +++ b/src/internal/event_loop/unimplemented.mbt @@ -38,3 +38,12 @@ pub fn Timer::cancel(timer : Timer) -> Unit { ignore(timer) abort("`Timer::cancel()` is unimplemented on WASM backend") } + +///| +/// WASM stub for the scheduler-only `reschedule` entry point. Mirrors +/// the JS shim — no IO, just drains ready coroutines until none remain. +pub fn reschedule() -> Unit { + while !@coroutine.no_more_work() { + @coroutine.reschedule() + } +} From 4c1b5641f2a0659c9edea5f73ccd749aac933d06 Mon Sep 17 00:00:00 2001 From: mizchi Date: Sat, 23 May 2026 19:39:34 +0900 Subject: [PATCH 2/2] fix(event_loop): wasm reschedule must gate on has_immediately_ready_task 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. --- src/internal/event_loop/unimplemented.mbt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/internal/event_loop/unimplemented.mbt b/src/internal/event_loop/unimplemented.mbt index a43c1ffa..c5f4937c 100644 --- a/src/internal/event_loop/unimplemented.mbt +++ b/src/internal/event_loop/unimplemented.mbt @@ -40,10 +40,16 @@ pub fn Timer::cancel(timer : Timer) -> Unit { } ///| -/// WASM stub for the scheduler-only `reschedule` entry point. Mirrors -/// the JS shim — no IO, just drains ready coroutines until none remain. +/// WASM stub for the scheduler-only `reschedule` entry point. Drains +/// immediately-ready coroutines (including any wake-ups produced by +/// running coroutines themselves) and then returns. Gating on +/// `has_immediately_ready_task` rather than `no_more_work` ensures +/// blocked coroutines (`blocking > 0`, e.g. suspended on an external +/// host callback) do NOT cause this function to tight-spin: when there +/// is nothing ready to run we exit and yield back to the host, which +/// can re-enter via `run_async_main` once new work is available. pub fn reschedule() -> Unit { - while !@coroutine.no_more_work() { + while @coroutine.has_immediately_ready_task() { @coroutine.reschedule() } }