Skip to content

feat(integration,event_loop): add wasm / wasm-gc scheduler-only run_async_main shim#389

Open
mizchi wants to merge 2 commits into
moonbitlang:mainfrom
mizchi:pr-wasm-gc-shim
Open

feat(integration,event_loop): add wasm / wasm-gc scheduler-only run_async_main shim#389
mizchi wants to merge 2 commits into
moonbitlang:mainfrom
mizchi:pr-wasm-gc-shim

Conversation

@mizchi

@mizchi mizchi commented May 23, 2026

Copy link
Copy Markdown
Contributor

Summary

integration.run_async_main is defined only for target="native" and target="js". Attempting to build any async fn main { ... } for wasm or wasm-gc fails with Value 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 abort via src/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:

#cfg(target="wasm-gc")
#doc(hidden)
pub fn run_async_main(main : async () -> Unit) -> Unit {
  let _ = @coroutine.spawn(main)
  @event_loop.reschedule()
}

Plus a matching reschedule() in src/internal/event_loop/unimplemented.mbt that drains @coroutine.reschedule() until no ready work remains.

Why bother

Two reasons it's useful even without IO:

  1. Embedding. The wasm-gc target is the natural deployment for moonbit code running inside a host (browser, V8, runtime with custom IO). Letting that host call async fn main with the standard scheduler — and arrange IO independently — closes the gap.
  2. Cross-backend benchmarks of the scheduler / aqueue / cond_var / semaphore. With this shim in place, the same coroutine workload can be measured on all four backends.

Cross-backend benchmark scenarios (all pure-coroutine, run on native / wasm / wasm-gc / js):

Headline from running these five across all four backends: wasm-gc beats native by 3× on pause_loop and 2× on cond_var_signal because 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-gc runs only the tests that already have unimplemented-friendly fallbacks; those continue to pass:

moonbitlang/async/aqueue              2 / 2 pass
moonbitlang/async/semaphore           1 / 1 pass
moonbitlang/async/cond_var            0 / 0 pass
moonbitlang/async/internal/coroutine  1 / 1 pass

Native and js are also untouched:

moonbitlang/async                       87 / 87 pass (native)
moonbitlang/async/aqueue                35 / 35 pass (native)
moonbitlang/async/semaphore              6 /  6 pass (native)
moonbitlang/async/cond_var               5 /  5 pass (native)
moonbitlang/async/internal/coroutine     1 /  1 pass (native)
moonbitlang/async/internal/event_loop    7 /  7 pass (native)
moonbitlang/async/internal/time          3 /  3 pass (native)

Caveats

  • This shim has no real event loop. sleep, Timer::*, sockets, fs, etc. still abort via the existing unimplemented.mbt stubs.
  • A user calling 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.

…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).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +46 to +47
while !@coroutine.no_more_work() {
@coroutine.reschedule()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inline this loop into call sites

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants