The wasm adapters implement the checked-in Backend contract, including its
revision-2 native capability methods, clock/deadline/timeout hooks and empty-wait counters.
Driver remains the only completion/token arbiter. Backends never dispatch user
callbacks from inside turn. The WASI 0.3 provider is experimental, with the
remaining promotion blockers below. See the root lane report for actual results;
compilation or an ignored/filtered test is not a runtime pass.
| Target | Provider | Rust pin | Runtime/tools |
|---|---|---|---|
| wasm32-wasip2 | backend::wasi_p2::WasiP2 |
nightly-2026-08-20; stable checked | Wasmtime 46.0.0 |
| wasm32-wasip3 | backend::wasi_p3::WasiP3, feature wasi-p3-experimental |
nightly-2026-09-07, only this target | Wasmtime 46.0.0 |
| wasm32-unknown-unknown | backend::web::Web |
nightly-2026-08-20; stable checked | wasm-bindgen 0.2.108, wasm-pack 0.15.0, Node 26.5.1, Chrome and Firefox |
The p3 pin comes from the wave-1 wasm report. A diagnostic build with
nightly-2026-09-13 did not fix the canonical stack-context issue, so it was not
adopted. The repository's default toolchain and seven-day dependency soak remain
unchanged. scripts/ci/soak.py also checks already-locked versions. The exact
wasip2 1.0.3 (WASI 0.2.9) and wasip3 0.8.0 (WASI 0.3.0) bindings are intentionally
pinned: hand-lowered canonical layouts must be re-audited when bindings change.
Wasmtime and wasm-pack releases are SHA-256 pinned in scripts/ci/tools.json.
Use bash scripts/ci/install-wasmtime.sh and
python3 scripts/ci/install-tools.py wasm-pack. install-web-tools.py copies the
registry-checksummed wasm-bindgen-cli 0.2.108 source into a normal isolated Cargo
project under .tools, resolves under the pinned seven-day policy, then builds
with --locked. This avoids a separate unsoaked cargo install resolution.
CI places the resulting binaries on PATH. Local setup:
python3 scripts/ci/install-wasm-toolchain.py
source .tools/wasm-env.sh
python3 scripts/ci/install-web-tools.py
export PATH="$PWD/.tools/bin:$PWD/.tools/wasm-bindgen-source/target/release:$PATH"
export WASM_PACK_CACHE="$PWD/.tools/wasm-pack-cache"
python3 scripts/ci/run-tests.py wasi --target wasm32-wasip2
python3 scripts/ci/run-tests.py wasi --target wasm32-wasip3
python3 scripts/ci/run-tests.py web
python3 scripts/ci/run-tests.py nodeThe WASI runner grants loopback/network access and sets a 120-second execution
limit. WASI contract and allocation binaries are checked independently for
positive test counts with all features (including the executor); allocations use release builds with the same zero threshold.
The semantic binaries receive an explicit 36-byte stdin fixture, and must read it
through turnloop, write to both output streams and close all three handles.
P3's debug custom allocator currently traps before the test harness starts (see
below). Browser runs are attempted separately, so a Chrome failure cannot hide
Firefox. Each run owns a fresh ephemeral-port HTTP/WebSocket fixture and asserts
fetch, AbortController disconnect, connected socket and echoed byte counters.
Fixtures are terminated even on failure. No spike is substituted for production
contract execution. CI retains failures in the required wasi/web fan-in jobs.
Each turn executes cached runnable I/O, then at most one wasi:io/poll.poll with
an optional monotonic-clock deadline pollable. Idle sockets retain the requested
positive deadline after WouldBlock. Deadline pollables are dropped at turn end;
socket/stream subscriptions are owned persistently and dropped before their
parents. There is no periodic timer tick and no retry-until-ready wait loop.
Operation queues are intrusive, preallocated per direction. Partial writes and
inline writev segments retain offsets; flush acknowledges the total write, not
just the current segment. Cancel unlinks an operation and queues its terminal
acknowledgement. Close releases a transport only after that acknowledgement and
after core has appended Closed to the host's output.
Generated p2 poll, read, and UDP receive bindings allocate returned lists.
The adapter uses reusable input handles, owners, result indices and aligned
canonical return storage. A scoped cabi_realloc override lowers these specific
synchronous imports into preallocated storage; returned pointers are copied/read
within the scope and never converted to owning Vecs. All unrelated canonical
allocations use the ordinary Rust allocator. Large TCP reads progress in bounded
scratch-sized chunks. UDP preserves one datagram, with normal buffer truncation.
The allocation gate checks provided/pooled TCP transfers, accepts, timer batches, backlogged cancel/close, 100 UDP datagrams (6,400 bytes) and 20 actual deadline expiries. Every measured subject asserts its own bytes/events before zero counts.
The provider owns one persistent canonical waitable set. Each operation owns a fixed return area and keeps stream buffers alive until return or synchronous cancellation acknowledgement. TCP uses raw stream/future vtables rather than building executor futures per operation. Accept/read/write directions are FIFO; cancelling an armed head makes its queued successor runnable. EOF and shutdown results are cached because a canonical future can be consumed only once.
Raw async socket imports and wait-set operations preserve the caller's shadow
stack context through the pinned compiler's env.__wasm_get_stack_pointer and
env.__wasm_set_stack_pointer imports. In emitted p3 components those map to
canon context.get/set 0. Without restoration across the wait-set step,
release-mode UDP return-list lowering left context 0 at zero and the next Rust
stack access trapped. The release UDP and full I/O regressions now pass with
restoration; debug-only tests had failed to expose this. This is part of the
experimental ABI surface, not a portable assumption about future toolchains.
Strict host-progress/cancellation boundedness and ABI portability remain unproven. See the precise upstream requirements. The backend remains opt-in; passing no-spin does not remove that feature gate.
The allocation source was host canonical lowering of each received list<u8>,
not address conversion or the provided/pool output buffer. In release, scoped
cabi_realloc redirects raw async socket/wait-set return lists into retained
64 KiB slots. There is one reservation per live UDP socket (one receive head per
socket), retained to the high-water mark until the last backend drops. All loops
on the agent share the arena because host progress can return another loop's
subtask. Busy slots are never reset at turn boundaries. Returned bytes are
copied to the requested output and the slot is released only on consumption or
acknowledged cancellation; error text is also consumed without generated String
destruction. Unrelated imports and oversized exceptional error strings use the
ordinary allocator. The measured steady path remains strictly zero allocations.
Regression subjects cover the original 100 datagrams/6,400 bytes, 320 concurrent IPv6 datagrams with provided/pooled outputs (0 through 8,192 bytes), 320 real cancellations, output capacity one, two loops, pending drop and surviving-loop reuse. A direct canonical test initializes two full 65,536-byte lists, verifies both contents survive scope/owner changes, and asserts reuse only after release. The wire maximum is 8 KiB because a direct host socket probe accepts 8 KiB but rejects 16/60 KiB with EMSGSIZE on this Mac; canonical capacity is tested directly.
p3 allocation gates are release-only. The pinned compiler lowers the custom
GlobalAlloc harness's unoptimized allocator entry using context-slot-0 stack
storage. The get-arguments canonical realloc enters with that slot zero before
Rust's harness starts; unoptimized stack access traps. Release inlining avoids
that startup path. Backend stack restoration cannot repair a pre-main entry.
The custom canonical allocator is likewise release-only; debug uses std's default
owned lists. There is no debug zero-allocation claim. The required WASI CI job
runs debug semantic/no-spin contracts, release semantic/precision contracts,
release canonical-storage unit tests, and release allocation gates, requiring
positive counts for every binary. The zero threshold is unchanged.
BSON 3.1.0 requires rand 0.9; its newest compatible release 0.9.5 (2026-07-11) still uses rand_core 0.9/getrandom 0.3.4. getrandom 0.3.4 (2025-10-14) is the last 0.3 release and has no p3 backend. Soak-eligible getrandom 0.4.3 (2026-06-17) has p3 support, but cannot satisfy that dependency's 0.3 requirement. Registry API and the exact locked manifests were checked on 2026-09-14; no dependency was updated and no soak exception was used.
.cargo/config.toml sets --cfg getrandom_backend="custom" only for
wasm32-wasip3. The explicit turnloop-wasi-random linkage crate supplies the
documented custom-backend symbol,
__getrandom_v03_custom, which both locked getrandom 0.3.4 and 0.4.3 declare.
The scalar wasi:random/random@0.3.0.get-random-u64 import initializes every
requested byte, including unaligned tails, with no list allocation. The shim is
linked by the three protocol consumers and shared once in a final binary;
MongoDB remains in the full p3 workspace. It introduces no event-loop dependency
into the sans-IO crates. Other targets are unaffected.
Downstream p3 applications must set that target rustflag themselves (Cargo does not inherit dependency config), link the shim, and avoid a second custom symbol. Missing WASI random support is a link/host capability failure, never a predictable fallback. Tests generate 754 bytes through both getrandom generations, reject zero/constant long samples, generate two distinct BSON ObjectIds, and measure 200 additional real entropy fills at zero allocations.
Wasmtime 46.0.0 on macOS arm64, release, twenty 250 µs waits per process.
crates/turnloop-contract/examples/wasi_timer_baseline.rs deliberately uses no
turnloop types or calls: p2 subscribes/polls the monotonic-clock pollable; p3 uses
the same async-lowered monotonic-clock wait with a single raw waitable set (p3
removed pollables). Every wait checks it did not return early.
| Measurement | Median lateness |
|---|---|
| Bare p2 | 919,416 ns |
| Bare p3 | 907,125 ns |
| turnloop p2, same runtime | 1,062,667 ns |
| turnloop p3, same runtime | 973,666 ns |
Raw bare p2 lateness, nanoseconds:
[2389375, 911375, 924542, 920625, 2046875, 936792, 925125, 903625, 904000, 2030958, 906833, 920250, 919416, 909917, 2046125, 915625, 902208, 894500, 894583, 908292].
Raw bare p3 lateness, nanoseconds:
[262291, 1293542, 2020166, 894375, 892167, 897875, 909250, 2049500, 906917, 907125, 918042, 906875, 906125, 2039958, 920167, 956666, 903917, 891667, 2027125, 895166].
Raw turnloop p2 lateness, nanoseconds:
[2075125, 1068625, 1051083, 1047375, 1062625, 1073708, 1047333, 2286833, 1030083, 2289292, 1048167, 1076417, 2353792, 1025292, 2239583, 1039500, 1062667, 1069916, 1038333, 1040375].
Raw turnloop p3 lateness, nanoseconds:
[910750, 906208, 919292, 2067416, 973666, 990000, 918333, 2066166, 925791, 918583, 930125, 921000, 2062833, 959958, 924458, 1121584, 2050917, 979083, 1042708, 1023542].
The bare programs reproduce the ~1 ms host lateness; the observed additional turnloop p3 median is ~67 µs, not a millisecond floor. Per the integrator's decision, DESIGN §7.4/§7.6 now describe host-dependent wake precision (Wasmtime ≈1 ms). The WASI release gate uses median ≤2 ms, giving measured host scheduling headroom; native's <500 µs gate is unchanged. Debug excludes precision sampling but still runs timer semantics and turns per expiry ≤2, zero-event waits ≤1. CI prints both baseline and driver raw samples to make host changes visible. Deadline representation remains nanoseconds; no backend floors or spins were introduced.
Reproduce each baseline with the target's pin and runner, for example:
CARGO_TARGET_WASM32_WASIP3_RUNNER=scripts/ci/wasmtime-runner.sh cargo +nightly-2026-09-07 run --locked -p turnloop-contract --release --all-features --target wasm32-wasip3 --example wasi_timer_baseline
python3 scripts/ci/run-tests.py wasi --target wasm32-wasip3Install Loop::set_schedule_turn with a JS function retained by the host. The
function should call the owner's Rust export which performs turn(Timeout::Now)
and dispatches returned completions. Use loop.now() for deadlines; it is based
on performance.now(). HostCallback mode arms one rechecked host timer for the
current earliest deadline and removes it on cancellation. queueMicrotask
coalesces wakes, and an epoch suppresses stale scheduled dispatch after a turn.
No callback runs synchronously during a driver method. The loop never blocks
the browser main thread: even After(Duration::ZERO) is rejected with Unsupported.
fetch(url, buffer, token) opens a fetch handle and reads one complete successful
HTTP response. websocket(url, token) opens a browser WebSocket and completes
Connected. Subsequent reads/writes exchange whole binary messages. A response or
message exceeding the provided/pool buffer completes with ResourceLimit. There
is one outstanding read per web handle; a second is rejected with WouldBlock.
Multishot reads and writev are Unsupported. Host identity plus the complete
64-bit generational OpId guards callbacks; core retains all 64 token bits.
Cancelling fetch invalidates the operation before AbortController.abort, so late
promise rejection cannot affect a reused slot. Drop invalidates host callbacks
and clears timers/resources.
The zero-allocation web gate counts Rust guest allocations around synchronous submission/turn paths: 100 real 64-byte WebSocket exchanges plus posts and timer cancel/close, with actual byte/completion assertions. JavaScript fetch bodies, Uint8Arrays, Promise callbacks and browser networking/GC allocate on the host. They are not counted as zero, nor does this gate promise a bounded browser heap. The message queue is bounded in message count by operation capacity. Fetch uses the browser response-body API and buffers a response before the size check. This host allocation boundary requires explicit design acknowledgement for a stronger process-wide no-allocation promise.
loop.worker_poster(power_of_two_capacity) returns a JS descriptor containing
buffer (SharedArrayBuffer), capacity and producerSource (SharedPoster class
source for bootstrapping a Worker). Only one ring attaches to a loop. The producer
class can also be imported from backend/web/host.js via a bundled application;
applications with strict CSP should bundle the class rather than evaluate source.
Transfer the descriptor's buffer by structured clone, not by transferring Rust
linear memory. The owner drains into its ordinary Poster as (Token, Payload::U64).
post(tokenBigInt, valueBigInt) returns false on contention, full or closed; the
producer retains the values and may retry on its own scheduling turn.
The ring uses Atomics for a producer lock, published head/tail, wake sequence and
parked handshake. Producer notification is coalesced. The consumer uses
Atomics.waitAsync, drains at most capacity records per call, keeps records when
the core queue is full, and resumes on the next owner turn. It never uses a timed
polling tick or blocks the main thread. Drop marks the ring closed and wakes its
waiter; delayed resolution cannot invoke the dropped Rust callback. Consumers
must not run two loop owners over the same descriptor.
Browsers must serve the document over HTTPS (or localhost), with:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
crossOriginIsolated, SharedArrayBuffer and Atomics.waitAsync must be available;
otherwise creation returns Unsupported. Subresources must satisfy COEP using
same origin, CORS or suitable CORP headers. The wasm-bindgen-test server sends
isolation headers. Node's Worker test uses worker_threads without DOM APIs. The
same test runs in isolated browsers, fills the ring to prove rejection, then
checks 2,000 unique full-width messages from two real workers and no idle wakes.
WASI p2 and p3 implement open_stdio with owned wasi:cli stream endpoints.
Reads, writes, writev, cancellation and shutdown use the same direction queues,
provided/pooled buffers and bounded waits as sockets. P2 drops subscriptions
before stream resources; p3 uses CLI-specific result futures and waits for stream
shutdown acknowledgement. Closing owned endpoints leaves the host standard
streams usable. Native AF_UNIX, handle passing, processes, signals and TTY
mode/window operations explicitly return Unsupported; the WASI terminal API
has detection but no portable mode/size query. Web stdio is also Unsupported.
WaitCondition and external_wait work on the single owning WASI/web agent.
A lazily initialized registry reserves 16,384 slots during condition construction,
retains storage at its used high-water mark, and publishes into each waiting
loop's reserved completion queue. No helper thread or per-wait allocation is
created. Initial inequality, same-value notify, value changes, exact deadlines,
cancel/stop, output backpressure and loop-drop cleanup have executable contracts.
The earliest wait deadline participates in next_deadline() and web host timer
scheduling. Expiry publication during a turn does not leave a spurious wake for
the next turn. WASI cannot accept OS-thread producers; a host must return control
to the guest agent to mutate/notify conditions.
With web-worker, loop.worker_wait_condition(&condition, capacity) returns
another bounded SharedArrayBuffer descriptor. Its producerSource class exposes
store(valueBigInt) and notify(), each returning false on full/contended/closed.
Accepted records apply in FIFO order when the owner drains the queue; admission
is asynchronous and is not an acknowledgement that the owner applied the value.
This is a host message bridge, not shared Rust linear memory. It uses the same
Atomics publication/parking protocol as Worker Poster, with an independent ring
per condition descriptor. Descriptor count is bounded by max_operations, and
storage/closures live until the owning driver drops. A descriptor can notify a
condition registered on several loops on the same agent. Cross-origin isolation
requirements are identical to Worker Poster. Drop closes the ring before releasing
the callback; delayed Worker messages cannot access released Rust state.
The executor feature runs on each of these targets. WASI contracts exercise
real TCP buffers, sleeps/timeouts and task cancellation without OS threads; the
release allocation binary also runs the native shared executor I/O/sleep gate.
Web tests run byte-verified WebSocket tasks, sleeps/timeouts and explicit join
cancellation using only host-scheduled turn(Now). Their synchronous Rust turn
paths have a zero-allocation gate; JavaScript/runtime allocations remain outside
that measurement. A separate Worker test delivers 64 condition completions to two
loops, alternates changed/same values, and checks full-ring rejection and closure.
Both external-wait and ordinary timer tests retain all sixty no-spin expiries.
These are platform exclusions, not successful tests. WASI UDP, release precision and release allocation subjects remain mandatory.
| Contract family | WASI p2/p3 | Web/Node |
|---|---|---|
| TCP connect/listen/accept, UDP, writev, shutdown | Exercised; reuse-port Unsupported. wasi:sockets has no Nagle control, so TcpOpts::nodelay, AcceptDefaults::nodelay and SocketOption::NoDelay are all reported Unsupported rather than accepted and dropped |
Native socket cases excluded: platform lacks raw sockets. Actual Unsupported results tested; fetch/WebSocket byte paths replace transport workloads |
| Socket options on live handles (issue #34) | Keep-alive (enable + idle/interval/count), send/receive buffer sizes and the unicast hop limit round-trip through wasi:sockets on clients, accepted connections and UDP; ListenOpts::accept_defaults.keep_alive reaches each accepted socket. Nagle, linger, IPv6-only, broadcast and multicast have no interface and report Unsupported for both set and get |
Excluded: a host fetch/WebSocket has no socket behind it. Every option and every getter kind is asserted Unsupported on a live WebSocket handle |
| Blocking pool / native cross-thread wake and 8-peer posting | Excluded: these single-agent targets cannot spawn OS threads; Running-notify/post behavior exercised | Native threads/pool excluded and Unsupported tested; two actual JS Workers exercise SAB posting |
| Native detach/attach transfer | Excluded: WASI resource transfer Unsupported (accept attaches owned transport internally) | Excluded: browser transport transfer Unsupported, tested |
| Integration fd/event, POSIX signal EINTR, kqueue/epoll/IOCP specifics | Excluded: runtime-owned integration, no native fd | Excluded: HostCallback and zero OS waits |
| Native sub-ms timer ceiling | Release uses measured host bound ≤2 ms; debug semantics/no-spin remain required | Native ceiling excluded: browser timers are clamped. Host-clock tests require no early firing, bounded lateness, 60 actual expiries and one scheduled turn each |
| Real Postgres/MySQL/Redis/SMTP/MongoDB server integration | Outside backend contract scope; existing native protocol fixtures remain separate | Raw-socket protocol integration excluded by platform capability; sans-IO protocol crates still cross-check |
For browser reruns outside the sandbox, use the setup/PATH above and:
python3 scripts/ci/run-tests.py web --browser chrome
python3 scripts/ci/run-tests.py web --browser firefoxLinux x86_64 CI installs Chrome for Testing 153.0.8010.36 and exactly matching
chromedriver, Firefox 155.0.1 and geckodriver 0.37.1. URLs and SHA-256 pins
are committed in scripts/ci/browsers.json. install-browsers.py verifies each
archive before extracting, checks executable versions on Linux and writes
absolute binary paths. Firefox's SHA256SUMS and geckodriver's GitHub release
asset digest were independently matched; Chrome/driver digests were computed
from the official Chrome for Testing HTTPS archives.
python3 scripts/ci/install-browsers.py # Linux x86_64
python3 scripts/ci/install-browsers.py --verify-only # verify/extract without execution on other hostsBrowserDriver starts and owns each pinned driver, polls its /status with a
15-second bound, supplies an explicit browser binary in capabilities, and points
wasm-bindgen-test at that driver. It does not treat normal driver stderr as a
startup failure. wasm-pack --mode no-install receives the exact driver path.
Driver stdout/stderr (Chrome verbose, Gecko trace) stay in .tools/browser-logs/
and are printed on any test/fixture/startup failure. Cleanup terminates the
owned process group, including browser children. Chrome and Firefox are each
attempted, and zero executed browser tests cause failure even if compilation or
Node tests passed. Node remains a separately executed suite with positive subject counts.
The integrator reproduced ChromeDriver SIGKILL on this Mac outside the sandbox, with matching 153.0.8010.36 versions: this is a host restriction, not a sandbox diagnosis. Real browser execution remains UNRUN here and required on GitHub's Linux runner. Linux binary checksum/extraction verification and mock WebDriver lifecycle tests do not count as browser test execution.