Skip to content

feat: Temporary PR to review changes to compile libDispatch to Wasm - #2

Closed
krodak wants to merge 4 commits into
mainfrom
krodak/libdispatch-wasm
Closed

feat: Temporary PR to review changes to compile libDispatch to Wasm#2
krodak wants to merge 4 commits into
mainfrom
krodak/libdispatch-wasm

Conversation

@krodak

@krodak krodak commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Add cooperative libdispatch support for single-threaded WASI

This is a draft for internal comparison before any upstream submission. It is not an upstream pull request description yet.

Problem

Single-threaded wasm32-unknown-wasip1 has no worker threads, manager thread, or blocking synchronization primitive. libdispatch therefore cannot use its existing pthread or event-loop backends, which leaves Swift programs targeting this ABI without the Dispatch module and its queue, timer, group, semaphore, and overlay APIs.

This change adds a WASI-specific cooperative backend while leaving native platforms unchanged behind __wasi__ and CMake platform gates.

Architecture

The new event_wasi.c backend replaces worker and manager threads with an explicit pending-work drain on the sole WASI thread. Queue and event-loop pokes mark root, main, or manager work pending. When no drain is active, the backend drains eagerly. Work enqueued by a running item is deferred until the outer drain regains control.

Each drain step processes one category in this order:

  1. Due timers
  2. Manager queue work
  3. Main queue work
  4. One root-queue item, starting at the highest QoS and rotating among roots that remain pending

Root turns are bounded so a queue that continually refills itself cannot keep lower-QoS work
from running. A newly formed pending set still starts at the highest QoS.

Timers continue to use libdispatch's generic timer heap. The WASI backend tracks the nearest armed deadline so waits and dispatch_main() can sleep until useful work is due.

Wall-clock deadlines are converted to uptime deadlines when armed. They fire normally under WASI, but a later host wall-clock adjustment does not reposition an already armed timer.

The lock and semaphore shims pump runnable work and timers while waiting. They fail loudly when an infinite wait cannot make progress instead of hanging the WebAssembly instance.

The CMake toolchain requires CMake 3.31 or newer, builds against a Swift WASI SDK, selects static libraries, configures the WASI event backend, and propagates the required signal, memory-mapping, and process-ID emulation libraries to consumers. A WASI-specific module map supplies the same transitive dependencies to ClangImporter and Swift clients. The Swift Dispatch overlay is built as a static WASI module.

Semantics And Limitations

  • This targets single-threaded wasm32-unknown-wasip1. Configurations with WebAssembly atomics or _REENTRANT are rejected at compile time.
  • There is no parallel execution. Submitted work runs cooperatively on one thread, and queue pokes may drain before the submitting call returns.
  • FIFO queue ordering, serial queue exclusion, barriers, groups, queue-specific data, dispatch_once, dispatch_apply, timers, dispatch_after, and finite semaphore waits are supported within that cooperative model.
  • QoS determines where a new root scan starts. Pending roots then take fair turns. QoS does not create execution priority or preemption on the host.
  • C read, write, and signal dispatch sources are link-available but terminate with a named diagnostic when registered. The C DISPATCH_SOURCE_TYPE_PROC declaration has no linkable WASI definition because the process source type is implemented only by the kevent backend.
  • Swift process and vnode source APIs are compiled out for WASI. Swift read/write source factories and DispatchIO remain visible but fail loudly when they reach unsupported file-descriptor registration.
  • Timer sources remain supported. Wall timers are converted to uptime when armed, so later host wall-clock adjustments do not reposition an armed timer.
  • A non-timed semaphore wait from inside a drained work item cannot yield to another item on the same thread, so it terminates with a deadlock diagnostic. A finite nested wait sleeps until its own deadline and returns timed out.
  • dispatch_main() owns the sole thread and drains pending work and timers. Once fully idle, it traps with dispatch_main(): no runnable work on single-threaded WASI instead of blocking forever.
  • The build is static. Required BlocksRuntime and WASI emulation archives are propagated to clean C and Swift consumers.

Test Plan

Prerequisites are a matching Swift 6.3.2 release toolchain and wasm32-unknown-wasip1 SDK, CMake 3.31 or newer, Ninja 1.10 or newer, and Node.js 19.8 or newer.

export SWIFT_WASI_TOOLCHAIN_PATH="$HOME/Library/Developer/Toolchains/swift-6.3.2-RELEASE.xctoolchain"
export SWIFT_WASI_SDK_PATH="$HOME/Library/org.swift.swiftpm/swift-sdks/swift-6.3.2-RELEASE_wasm.artifactbundle/swift-6.3.2-RELEASE_wasm/wasm32-unknown-wasip1"

cmake -S . -B build-wasi-c -G Ninja \
  -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/WASI.cmake \
  -DSWIFT_WASI_TOOLCHAIN_PATH="$SWIFT_WASI_TOOLCHAIN_PATH" \
  -DSWIFT_WASI_SDK_PATH="$SWIFT_WASI_SDK_PATH" \
  -DBUILD_TESTING=ON
cmake --build build-wasi-c
ctest --test-dir build-wasi-c --output-on-failure

Result with Swift 6.3.2: 28/28 tests passed. This includes thirteen existing libdispatch tests that are compatible with the single-threaded target. WASI-specific coverage checks queue behavior, a deferred concurrent-queue barrier backlog, initial QoS ordering, fairness across self-replenishing roots, uptime and wall-clock timers, synchronization policy, file-descriptor and signal source failures, useful and immediately idle dispatch_main() paths, a clean C consumer, and negative runner checks for wrong mode, a missing marker, and a missing diagnostic.

cmake -S . -B build-wasi-swift -G Ninja \
  -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/WASI.cmake \
  -DSWIFT_WASI_TOOLCHAIN_PATH="$SWIFT_WASI_TOOLCHAIN_PATH" \
  -DSWIFT_WASI_SDK_PATH="$SWIFT_WASI_SDK_PATH" \
  -DENABLE_SWIFT=YES \
  -DBUILD_TESTING=ON
cmake --build build-wasi-swift
ctest --test-dir build-wasi-swift --output-on-failure

Result with Swift 6.3.2: 30/30 tests passed. The Swift-enabled run adds clean CMake and autolink-only Swift consumers.

Proposed Clean Commit Series

  1. Runtime and build: add the cooperative WASI backend, synchronization policy, toolchain configuration, and transitive static dependencies.
  2. Swift overlay: build and expose the static Dispatch module for WASI with unsupported process and vnode APIs compiled out.
  3. Tests: add watchdog-bounded runtime policy, ordering, timer, dispatch_main(), runner, and clean-consumer coverage.
  4. Upstream tests: run the single-thread-compatible libdispatch suite under a configurable WASI runtime.

Alternatives Considered

Wait For wasip1-threads

The threaded ABI would preserve libdispatch's normal execution model, but it is a different deployment target and is not available in every WASI runtime. Waiting also leaves current single-threaded WASI programs without Dispatch. This backend rejects threaded builds so a future threads port can use the existing worker model rather than inheriting cooperative semantics.

DispatchAsync

DispatchAsync can provide a Swift-level scheduling API, but it does not supply the C libdispatch ABI or the Dispatch overlay expected by existing Swift code. Adopting it would require application and dependency changes instead of making the standard module available for this target.

Separate Shim Package

A compatibility package could implement a subset of Dispatch outside this repository. It would duplicate API and behavioral contracts, complicate ClangImporter integration, and make transitive static linking the responsibility of each consumer. Keeping the platform backend in libdispatch lets the existing queue, object, timer, and overlay implementations remain the source of truth.

Review Questions

  • Is cooperative, eager draining acceptable for the explicitly single-threaded WASI target, or should submission remain deferred until a threaded ABI is the minimum baseline?
  • Are the fail-fast policies for unsupported source classes and impossible waits preferable to returning an unsupported error or trapping at source creation?
  • Should the WASI toolchain remain in this repository, or should it be supplied by the Swift SDK build instead?
  • Is a WASI-specific module map the right place to carry static BlocksRuntime and emulation-library autolinks?
  • Which additional native regression builders or WASI runtime implementations should be covered before an upstream submission?

@krodak krodak changed the title feat: Temporary PR to view krodak's changes to compile libDispatch to Wasm feat: Temporary PR to review changes to compile libDispatch to Wasm Aug 14, 2026
krodak added 4 commits August 14, 2026 16:13
Register the single-thread-compatible subset of the upstream bsdtests
suite for WASI instead of relying only on the bespoke tests/wasm suite.
Test binaries are executed by a new WASI_TEST_RUNNER cache variable
(default wasmtime); when the runner is absent the tests are registered
but disabled so configuration still succeeds. bsdtestharness is not
built for WASI because posix_spawn does not exist there; the runner
propagates the guest exit code instead.

Compiling bsdtests and the tests for wasm32-wasip1 needs __wasi__ arms
next to the existing __unix__ guards (WASI clang does not define
__unix__), the generic_unix_port.h shims, a WASI-safe failure exit
status (WASI rejects 0xff), and stubs for the large-file helpers since
wasi-libc has no mkstemp.

11 of the 20 default DISPATCH_C_TESTS plus dispatch_c99 and
dispatch_plusplus pass under wasmtime. The remaining 9 need concurrent
worker threads or file-descriptor sources and are excluded with the
reason documented in tests/CMakeLists.txt.
@scottmarchant

scottmarchant commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Comparison: PR #1 vs PR #2 vs PR #3 (libdispatch → Wasm/WASI)

(Updated after two review rounds on #3: a full-branch review with fixes, then an adversarial probe round from the #2 side with a hardening series. Every row is verified empirically — built and executed. Cross-posted on #1, #2, and #3.)

All three branches were built for wasm32-unknown-wasip1 on macOS arm64 and their test suites run. Toolchain: swift.org 6.3.3 toolchain + Swift 6.3.3 Wasm SDK (#2/#3), wasi-sdk 33 (#1), wasmtime 47, Node 26, CMake 4.4 (floor verified at exactly 3.31.0). #3's event-source and re-entrancy behavior additionally verified under WasmKit (built from source) and @bjorn3/browser_wasi_shim; native neutrality proven with Linux container builds (identical 23/23 upstream tests on main and #3).

Branch summary

#1 (feat/scottm/libDispatchWasm) #2 (krodak/libdispatch-wasm) #3 (feat/scottm/libDispatchWasmV2)
Runtime design defer-everything: work runs only under dispatch_main(); blocking traps cooperative eager drain; blocking waits drain other work #2's runtime + fd/signal event sources + poke-defer brackets: pokes never eager-drain beneath caller-held locks (blocking waits still pump there, by documented contract)
Diff vs main +874 / −26 (36 files) +2,148 / −75 (55 files) +4,861 / −81 (76 files; stacked on #2)
ctest 3/3 30/30 52/52
Swift overlay manual out-of-tree recipe, not in CI CMake-integrated, autolink modulemap, consumer tests same as #2

Dispatch API support

Legend: ✅ works · ⚠️ works with caveats · ⏱ contract violation (returns instantly instead of waiting) · 💥 traps · ❌ broken/unsupported.

Queues & submission

API #1 #2 #3
Queue create/attrs/label/target/specifics, global & main queues
dispatch_async / barrier_async ⚠️ nothing runs until dispatch_main() ✅ eager ✅ eager; pinned by test
dispatch_sync / barrier_sync ✅ inline; re-entrancy detection unreliable (tid bug) ✅ inline; but a poke from inside a sync body eager-drains under the held barrier locksync(qA){async(qB){sync(qA)}} crashes where threaded platforms complete ✅ inline; pokes inside sync bodies, dispatch_once initializers, dispose, and set_specific defer and flush after the critical section — that program now completes (tested on 3 runtimes)
dispatch_after, timers (uptime & wall) ✅ (wall deadline movable by host clock) ✅ (wall anchored at arm; tested)
dispatch_apply ✅ inline-serial ✅ inline-serial ✅ inline-serial
dispatch_main ⚠️ busy-spins at 100 % CPU when idle ✅ traps loudly when idle forever ✅ parks in host poll on armed timers and fd sources; a signal-source-only park still traps as truly idle (in-process raise() cannot reach a parked sole thread — deliberate, documented)
QoS order & fairness ❌ inverted + starvation ✅ tested
dispatch_assert_queue / _not ❌ inverted by tid-encoding bug ✅ + regression test

Synchronization

API #1 #2 #3
dispatch_once ⚠️ initializer that submits work eager-drains under the once gate (drained item re-entering the once crashes; threaded platforms wait) ✅ deferred under the gate
Semaphore create/signal/uncontended wait
semaphore_wait(FOREVER) 💥 traps ✅ drains cooperatively ✅ also wakes on fd events (tested)
semaphore_wait(timeout) ⏱ instant ✅ honors deadline ✅ + regression test for the timeout-vs-signal race (see the "working as designed" note in #3's description)
group_wait (timed / FOREVER) ⏱ instant / ❌ returns −1 ✅ / ✅ ✅ / ✅
dispatch_block_wait ⏱ instant
Provable single-thread deadlocks 💥 (all blocking) 💥 named diagnostics; contended unfair lock/once gate spins silently at 100 % CPU (empty _dispatch_thread_switch) 💥 named diagnostics everywhere — the lock/gate spin is now a named crash

Sources

API #1 #2 #3
Timer sources
User-data sources
READ / WRITE fd sources ❌ silently never fires 💥 traps at registration ✅ via poll_oneoff fd subscriptions; armed-source count now unbounded (growable poll set; previously capped at 64 with a load-dependent trap)
SIGNAL sources ❌ silent 💥 traps ✅ in-process raise() semantics, count-accurate; registration saves and unregistration restores the app's own signal() disposition (previously reset to SIG_DFL, so the app's next raise() terminated the process); SIG_ERR checked
PROC / VNODE / Mach / memory-pressure ❌ — no WASI facility exists
fd closed while source armed (n/a — sources never fire) (n/a) 💥 named crash on both harvest shapes (per-subscription POLLNVAL and wasmtime's whole-call EBADF), each pinned by a limited-host runner mode. Deliberate: kqueue logs a vanish, epoll silently never fires again. Caveat: on Node, the guest closing an armed stdio fd aborts the host inside libuv before libdispatch can see it
pipe EOF with an un-canceled source (n/a) (n/a) ✅ readable-at-EOF delivered per the Darwin contract (handler observes the 0-byte read and cancels); a handler that never cancels on a host without the poll hangup flag becomes a named crash via a windowed rate guard instead of a silent ~500k-fires/sec spin

Data & I/O

API #1 #2 #3
DispatchData (all operations)
dispatch_read/write, DispatchIO on regular files ✅ verified ✅ verified ✅ verified
Same on non-regular fds (streams) ❌ hangs silently 💥 traps ✅ tested: a DispatchIO stream channel on stdin completes a read whose payload arrives 250 ms into the wait

Test coverage

Suite #1 #2 #3
Upstream libdispatch tests 13 13
Focused WASI semantics tests 3 bespoke 13 32#2's set + the earlier contract/event-source/review-regression tests + the probe-round additions: async-and-wait (privdata funnel), pipe-EOF in both host shapes, close-while-armed, group wait woken by an fd source, payload-asserted regular-file IO
Swift consumer tests (in ctest) 0 (2 manual) 2 2
Runner self-checks 3 5 (early-guest-exit robustness; a limited-host mode that denies fd poll subscriptions, pinning the ENOTSUP capability crash)
Total in CI 3 30 52

Harness note (#3): Node is now optional — without it (or below 19.8) everything still builds and all tests register as visible-but-DISABLED, the same shape as the missing-WASI_TEST_RUNNER path (#1's design, adopted per the two-ports report).

Runtime support

Capability wasmtime 47 Node node:wasi WasmKit browser_wasi_shim uwasi
Timers / queues / sync (all three PRs) ⚠️ busy-wait sleeps, single-subscription ❌ no poll_oneoff
fd read/write sources (#3) ✅ (regular files via always-ready path) ✅ (bounded poll slices dodge its infinite-timeout host trap) ❌ loud named crash at registration ❌ same
Signal sources (#3) ✅ (pure libc)

Review round (what changed since the previous version of this comment)

Two rounds. First, a high-effort review of #3's full diff vs main (i.e. including the #2 base) produced 10 findings; each was reproduced with a test before fixing. Fixed: eager-drain-under-held-locks (the biggest semantic gap vs threaded platforms — see the sync row above), signal disposition save/restore, the 64-fd poll cap, the signal-pending latch protocol, a 63-line main-queue-drain fork (now hoisted to a shared function with DISPATCH_COCOA_COMPAT-gated divergences), silent lock-contention spins (now named crashes), and test-runner robustness. Two findings were refuted by their own reproduction tests — most instructively, the timed-semaphore "dropped signal": the low-level early-timeout path exists, but upstream's _dispatch_semaphore_wait_slow re-checks the semaphore value on timeout and delivers the signal — pre-existing upstream defense-in-depth working as designed (details in #3's description). No pre-existing upstream libdispatch bug needed fixing.

Second, an adversarial probe round from the #2 side found one bypassed funnel in the poke-defer fix (dispatch_async_and_wait with a dispatch_block_create block — what Swift's asyncAndWait(execute:) produces — crashed with an internal diagnostic; fixed with the same bracket plus a four-shape regression test) and hardened three edges: the pipe-EOF hot loop became a named crash, close-while-armed got its named diagnostic on wasmtime's whole-call-EBADF shape, and the signal-restore path checks SIG_ERR. It also independently re-verified the "working as designed" semaphore analysis and the Darwin-identity of the main-queue-drain hoist, and added tests for two behaviors previously verified only by probe. 46/46 became 52/52, independently reproduced on both sides.

Bottom line

#1 is the minimal seed but breaks contracts on APIs it nominally supports. #2 made the supported set behave per spec and the unsupported set fail loudly; the review round found its one systemic gap — eager drains running client code beneath caller-held locks — plus a silent-spin path and the signal/cap issues, all inherited by and now fixed in #3. #3 is #2's design carried to completion: fd and signal sources work, the re-entrancy divergence from threaded platforms is confined to documented, pinned-by-test semantics, and every failure mode is a named crash. The remaining unsupported surface (process/vnode/memory-pressure/Mach, cross-process signals, true parallelism) is bounded by WASI itself.

🤖 Generated with Claude Code

@scottmarchant

Copy link
Copy Markdown
Collaborator

Closing: this port's runtime was adopted as the foundation of the combined branch (#3), which stacks the event sources, review-round fixes, and hardening series on top and now shows the full change set against main. #3 is the single source of truth going forward; upstream-facing slices will be cut from it one at a time. See the comparison comment above for the full evaluation record.

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