Skip to content

feat: Add host-driven WASI event loop experiment - #8

Merged
krodak merged 3 commits into
feat/scottm/libDispatchWasmV2from
feat/wasi-host-event-loop-driver
Sep 4, 2026
Merged

krodak merged 3 commits into
feat/scottm/libDispatchWasmV2from
feat/wasi-host-event-loop-driver

Conversation

@krodak

@krodak krodak commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Overview

This draft tests the event-loop model suggested by akmorrison on upstream PR swiftlang#953. The existing WASI backend drains work from a queue poke. In the registered mode introduced here, a poke requests one later host callback, dispatch_async returns, and the host calls back into Dispatch to perform work.

The experiment is opt-in and targets the combined WASI branch so we can compare both models without rewriting the existing suite. Unregistered builds keep the current eager behavior. The SPI names and callback shape are open for discussion.

Current and registered execution models

Host callback and timer lifecycle

Review focus

  • Is callback registration the right boundary between libdispatch and an embedded runtime?
  • Should the host tell perform whether a scheduler callback or timer caused the turn, or should the SPI hide that distinction?
  • Is a drain-phase budget useful for this proof, given that a manager or main-queue phase can drain a captured snapshot?
  • If this direction holds, registered host-loop mode would replace eager draining and remove the poke-defer hooks from the upstream design.

Links

What changed

  • private/private.h - adds an experimental WASI-only SPI for scheduler registration, step-budgeted perform calls, and relative timer delays.
  • src/event/event_wasi.c - coalesces host wakeups, routes registered pokes out to the host, preserves eager draining when unregistered, and distinguishes scheduler turns from timer turns so one cannot consume the other's latch.
  • tests/wasm/host-event-loop.c and tests/wasm/run-wasi-host-event-loop-test.mjs - add a Node-driven WASI reactor that proves later-turn execution, initial wakeup coalescing, timer-only progress, retained scheduler turns, and named failures for inline callbacks and late registration.
  • docs/wasi-host-event-loop/ - documents the experiment and includes the Mermaid sources and rendered PNG diagrams.

Fd readiness remains outside this experiment. Perform can harvest readiness already visible to WASI, but an idle JavaScript host still needs runtime-specific notification when an fd becomes ready. Blocking waits also retain the combined branch's current cooperative behavior.

Tested

  • cmake -S . -B build-wasi -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/WASI.cmake -DSWIFT_WASI_TOOLCHAIN_PATH="$HOME/Library/Developer/Toolchains/swift-6.3.3-RELEASE.xctoolchain" -DSWIFT_WASI_SDK_PATH="$HOME/Library/org.swift.swiftpm/swift-sdks/swift-6.3.3-RELEASE_wasm.artifactbundle/swift-6.3.3-RELEASE_wasm/wasm32-unknown-wasip1" -DENABLE_SWIFT=YES -DBUILD_TESTING=ON
  • cmake --build build-wasi
  • ctest --test-dir build-wasi --output-on-failure
  • ctest --test-dir build-wasi -R '^dispatch_wasi_host_event_loop$' --repeat until-fail:200 --output-on-failure

@krodak
krodak requested a review from scottmarchant September 3, 2026 16:19
@krodak krodak self-assigned this Sep 3, 2026
scottmarchant and others added 2 commits September 3, 2026 20:02
A poke now only records pending work. It never drains on the
submitting stack. Pending work runs at a pump point: a top-level
blocking wait, dispatch_main(), or a host turn through the registered
scheduler. This gives every queue the semantics the Darwin main queue
has. It is the direction akmorrison asked for on
swiftlang#953.

A poke never runs client code under a caller-held lock now, so the
poke-defer hooks have no purpose. This removes the eight hook sites,
the sync funnel split, the hook macros, and the defer depth counter.
The shared files return to their upstream shape at those sites.

Registration no longer has to happen before the first Dispatch use.
Work recorded before registration is handed to the scheduler at
registration. A pump that stops with runnable work left, for example a
satisfied top-level wait, hands that work to a registered host too.
Pokes from inside a drain or a harvest still do not request a turn:
the running pump picks the work up, and perform decides after its last
step.

Tests: eager-drain.c becomes deferred-submission.c and pins the new
contract. barrier-order.c, qos-order.c, and root-fairness.c pump
through dispatch_main() and exit from their last block.
sync-nested-async.c, async-and-wait.c, specific-destructor.c, smoke.c,
nested-wait.c, nested-timed-wait.c, unsupported-source.c, and
signal-disposition.c add a pump where they relied on eager progress.
The late-registration reactor test becomes a positive test: work
submitted before registration requests exactly one turn at
registration and runs on it.

Verified with the WASI suite: 57 of 57 with the Swift 6.3.1 toolchain
and SDK, including the two Swift consumer tests. The four host event
loop tests pass 50 repeats.

Co-authored-by: Krzysztof Rodak <krodak.konta@gmail.com>
Rewrite the WASI semantics section of the test README around the pump
points and the Darwin main-queue comparison. Describe the host loop
SPI as the model, not as an experiment. Update the wait-pumping audit:
the set_specific finding is no longer a hazard, and the bracket hooks
are gone. Update the design note and its test list.

Replace the execution-models PNG with an inline Mermaid block. The PNG
showed the removed eager mode, and GitHub renders Mermaid blocks in
Markdown. The host-turn lifecycle PNG is still accurate and stays.

Co-authored-by: Krzysztof Rodak <krodak.konta@gmail.com>
@scottmarchant

Copy link
Copy Markdown
Collaborator

Hi @krodak, thanks for this. It maps well onto what akmorrison asked for, and it is the direction we are taking.

I pushed two commits on top of yours (457a699 and c32b54a). They make your model the only submission model instead of an opt-in. Revert either one if you disagree with any part of it.

What changed:

  • A poke only records pending work now. It never drains on the submitting stack. Pending work runs at a pump point: a top-level blocking wait, dispatch_main(), or a host turn through your _dispatch_wasi_event_loop_perform().
  • The eight poke-defer hook sites, the sync funnel split, and the defer depth counter are gone. A poke never runs client code under a caller-held lock, so the hooks have no purpose. The shared files return to their upstream shape at those sites.
  • Registration no longer has to happen before the first Dispatch use. Work recorded before registration is handed to the scheduler at registration. A pump that stops with runnable work left, for example a satisfied or timed-out wait, hands that work to the host too.
  • Your SPI shape is unchanged: the drain-phase budget and the consumes_scheduled_turn flag stay as you wrote them.
  • Tests: eager-drain.c became deferred-submission.c. Eleven focused tests gained a pump where they relied on eager progress. The late-registration reactor test is now a positive test, and a new reactor test covers registration from inside a running work item. The suite is 57 of 57 with the Swift 6.3.1 toolchain and SDK. The host loop tests pass 50 repeats.
  • Docs: the README semantics section, the wait-pumping audit, and your design note now describe this as the model. The stale execution-model PNG became an inline Mermaid block.

One design rule is worth stating: on one thread every queue behaves like the Darwin main queue. Work runs when control returns to the host or during a Dispatch blocking wait. Code that submits work and then spins, sleeps, or blocks in a plain read() sees no progress, exactly as it would on the Darwin main thread.

My plan from here:

  1. If you agree, please merge this PR into feat: Full combined set of changes to add Wasm support to libDispatch #3. I will rerun the suite there.
  2. I re-scope feat: prepare shared code for cooperative single-threaded event backends swiftlang/swift-corelibs-libdispatch#953 in place: drop the hooks, keep the main-queue drain hoist. That is 14 lines in one file.
  3. I reply to akmorrison: agree, map the model onto the CFRunLoop _4CF seam, and ask whether a registered scheduler callback plus a next-deadline query is an acceptable WASI arm of dispatch_runloop_handle_t.
  4. Platform glue follows: the JavaScriptKit runtime hookup and the Khasm bootstrap registration, so consumer code stays the same on JS hosts.
  5. Fork PR Prepare shared code for cooperative single-threaded event backends #5 gets the same re-scope or a pointer and a close.

Not done yet: a Linux neutrality build on this branch. The shared-code change is a return to upstream text, so the risk is low, but I will run it before swiftlang#953 moves.

@scottmarchant scottmarchant left a comment

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.

It's probably fine to merge these pngs and diagrams to #3, but I'll scrub them out when I update the upstream PR.

I pushed some updated, there is a comment by AI about the changes and my plan. Please feel free to look through and revise

@krodak
krodak marked this pull request as ready for review September 4, 2026 07:53
@krodak
krodak merged commit dbf44d6 into feat/scottm/libDispatchWasmV2 Sep 4, 2026
@krodak

krodak commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

@scottmarchant changes looks good, merged to #3 , let me know if you needed any help with propagating these to upstream PR and 🤞🏻 for further review process 😄

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