diff --git a/CLAUDE.md b/CLAUDE.md index f8390e74c..d6a915cfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,8 @@ Requires [wasi-sdk](https://github.com/WebAssembly/wasi-sdk) and [wasmtime](http make # build build/bin/io_static (WASM binary) make test # build build/bin/test_iterative_eval make check # run both test suites with wasmtime +make component # build build/bin/io_component.wasm (WASI 0.2 component, wasm32-wasip2) +make check-component # run the Io test suite against the component make clean # remove build artifacts make regenerate # regenerate IoVMInit.c from .io files ``` diff --git a/Makefile b/Makefile index 26333a3b1..1c30f174b 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,8 @@ # make regenerate Regenerate IoVMInit.c from .io files # make browser Build browser/io_browser.wasm (reactor module) # make serve Serve browser REPL on localhost:8000 +# make component Build io_component.wasm (WASI 0.2 component, wasm32-wasip2) +# make check-component Run the Io test suite against the component WASI_SDK ?= $(HOME)/wasi-sdk BUILD := build @@ -55,7 +57,7 @@ IO_SOURCES := $(wildcard libs/iovm/io/*.io) # --- Targets --- -.PHONY: all test check clean regenerate browser serve check-browser +.PHONY: all test check clean regenerate browser serve check-browser component check-component all: $(BINDIR)/io_static @@ -94,6 +96,28 @@ $(OBJDIR)/%.o: %.c @mkdir -p $(dir $@) $(CC) $(CFLAGS) -c -o $@ $< +# --- Component (WASI 0.2, wasm32-wasip2) --- +# +# Same sources compiled for the wasm32-wasip2 triple, linked through +# wasm-component-ld into a WebAssembly component speaking the WASI 0.2 +# interfaces. Objects live in a separate tree because the triple differs. + +P2_OBJDIR := $(BUILD)/obj-p2 +P2_CFLAGS := --target=wasm32-wasip2 $(CFLAGS) +P2_OBJS := $(patsubst %.c,$(P2_OBJDIR)/%.o,$(ALL_SRCS)) + +component: $(BINDIR)/io_component.wasm + +$(BINDIR)/io_component.wasm: $(P2_OBJDIR)/tools/source/main.o $(P2_OBJS) | $(BINDIR) + $(CC) $(P2_CFLAGS) -o $@ $^ $(LDFLAGS) + +$(P2_OBJDIR)/%.o: %.c + @mkdir -p $(dir $@) + $(CC) $(P2_CFLAGS) -c -o $@ $< + +check-component: $(BINDIR)/io_component.wasm + wasmtime --dir=. --dir=/tmp $(BINDIR)/io_component.wasm libs/iovm/tests/correctness/run.io + # --- Browser (reactor module) --- BROWSER_DIR := browser diff --git a/agents/WASI_ASYNC_PLAN.md b/agents/WASI_ASYNC_PLAN.md new file mode 100644 index 000000000..3aad163a9 --- /dev/null +++ b/agents/WASI_ASYNC_PLAN.md @@ -0,0 +1,95 @@ +# WASI 0.3 Async Integration Plan + +Status: **prep work landed, 0.3 integration blocked on toolchain** (June 2026) + +Reference: [WASI 0.3 announcement](https://bytecodealliance.org/articles/WASI-0.3) + +## What WASI 0.3 changes + +WASI 0.3 makes async native to the component model. The canonical ABI gains +three first-class constructs: + +- `future` — replaces the `pollable` resource +- `stream` — replaces `input-stream` / `output-stream` +- async functions — replaces the 0.2 `start-foo`/`finish-foo`/`subscribe` pattern + +Scheduling is completion-based (io_uring/IOCP style): the host runtime owns a +single shared event loop and drives task scheduling across components. The +design explicitly accommodates stackless coroutine runtimes — which is what +this VM is. + +## What has landed (works today, WASI 0.1/0.2) + +1. **WASI 0.2 component build** (`make component`, `make check-component`). + `build/bin/io_component.wasm` is a real component (layer-1 binary) built + via wasi-sdk's `wasm32-wasip2` target and `wasm-component-ld`. The full + correctness suite passes against it under wasmtime 42. + +2. **Scheduler timer queue** (`Scheduler addTimerAt/addTimer/removeTimerFor/ + wakeExpiredTimers/idleUntilNextTimer` in `libs/iovm/io/Exception.io`). + `Object wait` parks the current coroutine on the timer queue instead of + busy-yielding. Expired timers re-enter the run queue on every + `yield`/`pause`. When nothing is runnable but timers are pending, the VM + blocks in **one** host wait until the nearest deadline + (`Scheduler idleUntilNextTimer` → `System sleep`). + + That single idle point is the WASI 0.3 seam: under 0.3 it awaits a host + future instead of sleeping. + +3. **Dead-ancestor walk in the eval loop** (`IoState_iterative.c`, empty-frame + handler). When a coroutine finishes, the eval loop resumes its + `parentCoroutine`. Timer wakeups let a coroutine outlive the coroutine + that started it, so the walk now skips dead ancestors (no saved frames) + to find the nearest resumable one. Without this, a finishing coroutine + whose starter already finished hit the `nestedEvalDepth > 0` early-return + and stranded every parked coroutine. + +## VM contracts to preserve (learned the hard way) + +- **Spurious wakeups are normal.** A finished child coroutine resumes its + parent directly, bypassing the scheduler. Anything that parks a coroutine + must use condition-variable semantics: loop, re-check the condition + (deadline, future resolved, …), re-park if unsatisfied. `Object wait` does + this; a future `awaitHostFuture` must too. +- **Timer entries can go stale.** A coroutine woken by something other than + its timer must remove its entry (`Scheduler removeTimerFor`), or + `wakeExpiredTimers` will later re-enqueue a coroutine that is already + running or dead — and `resume` on a dead coroutine restarts its body. +- **Coroutine bodies resolve chain heads against `runTarget`.** `coroDoLater` + sets `runTarget := self`, so method locals are NOT visible to chain-head + messages (argument evaluation does see them, which makes failures look + intermittent). Use `coroDo`/`coroFor` (runTarget = sender) for bodies that + capture locals. Misuse shows up as a swallowed Importer "does not respond + to" exception that silently kills the coroutine. + +## The 0.3 integration, when unblocked + +Target shape: a `HostFuture` primitive owned by C, with the scheduler extended +from "timers only" to "timers + host futures". + +1. `Scheduler idleUntilNextTimer` generalizes to `Scheduler idle`: collect the + nearest timer deadline and all pending host futures, and make one blocking + host call (`waitable-set.wait` in the 0.3 canonical ABI) instead of + `System sleep`. +2. `File read`/`write` and any socket primitive lower onto `stream`; the + calling coroutine parks on the paired completion future and re-enters the + run queue when the host completes it. No Asyncify, no transform: parking + is the same heap-frame operation as a coroutine switch. +3. `@`/`@@` actor futures (`Actor.io`) optionally back `Future setResult` with + host-future completion so an actor awaiting host I/O consumes no VM + scheduling at all. +4. Export the VM's eval entry as an async component function so embedders can + call Io code without blocking their event loop (service chaining). + +## Upgrade triggers (re-check before starting) + +- **wasmtime ≥ 46 installed** (0.3.0 interfaces, async on by default). + Local machine has 42.0.1 — `wasmtime --version`. +- **wasi-sdk / wit-bindgen C support for 0.3 async** (`future`/`stream` + lowering and `waitable-set` intrinsics from C). At the time of writing, + guest toolchain support was rolling out for Rust/Go/JS/Python first; + wasi-sdk 25 has no 0.3 target. +- **jco 0.3 support** if the browser target should share the same model. + +When both land: regenerate nothing — start from `Scheduler idle` (step 1) and +keep `System sleep` as the fallback for hosts without 0.3. diff --git a/docs/Book/images/Introduction_old.png b/docs/Book/images/Introduction_old.png new file mode 100644 index 000000000..7803891cf Binary files /dev/null and b/docs/Book/images/Introduction_old.png differ diff --git a/docs/Book/images/_originals/Appendix.jpg b/docs/Book/images/_originals/Appendix.jpg new file mode 100644 index 000000000..96598151e Binary files /dev/null and b/docs/Book/images/_originals/Appendix.jpg differ diff --git a/docs/Book/images/_originals/Concurrency.jpg b/docs/Book/images/_originals/Concurrency.jpg new file mode 100644 index 000000000..84a938b35 Binary files /dev/null and b/docs/Book/images/_originals/Concurrency.jpg differ diff --git a/docs/Book/images/_originals/Control Flow.jpg b/docs/Book/images/_originals/Control Flow.jpg new file mode 100644 index 000000000..b291f4c06 Binary files /dev/null and b/docs/Book/images/_originals/Control Flow.jpg differ diff --git a/docs/Book/images/_originals/Introduction.v1.jpg b/docs/Book/images/_originals/Introduction.v1.jpg new file mode 100644 index 000000000..d0a4c248d Binary files /dev/null and b/docs/Book/images/_originals/Introduction.v1.jpg differ diff --git a/docs/Book/images/_originals/Introduction.v2.jpeg b/docs/Book/images/_originals/Introduction.v2.jpeg new file mode 100644 index 000000000..b337d8121 Binary files /dev/null and b/docs/Book/images/_originals/Introduction.v2.jpeg differ diff --git a/docs/Book/images/_originals/Objects.jpg b/docs/Book/images/_originals/Objects.jpg new file mode 100644 index 000000000..e998ef110 Binary files /dev/null and b/docs/Book/images/_originals/Objects.jpg differ diff --git a/docs/Book/images/_originals/Primitives.jpg b/docs/Book/images/_originals/Primitives.jpg new file mode 100644 index 000000000..f1d0fac74 Binary files /dev/null and b/docs/Book/images/_originals/Primitives.jpg differ diff --git a/docs/Book/images/_originals/Syntax.jpg b/docs/Book/images/_originals/Syntax.jpg new file mode 100644 index 000000000..11c1fe5ff Binary files /dev/null and b/docs/Book/images/_originals/Syntax.jpg differ diff --git a/docs/Book/images/_unused/Cover.jpg b/docs/Book/images/_unused/Cover.jpg new file mode 100644 index 000000000..81b6f48ad Binary files /dev/null and b/docs/Book/images/_unused/Cover.jpg differ diff --git a/docs/Book/images/_unused/Grid.png b/docs/Book/images/_unused/Grid.png new file mode 100644 index 000000000..5f56f24c7 Binary files /dev/null and b/docs/Book/images/_unused/Grid.png differ diff --git a/docs/Book/images/_unused/Header.jpg b/docs/Book/images/_unused/Header.jpg new file mode 100644 index 000000000..5b6ef0c52 Binary files /dev/null and b/docs/Book/images/_unused/Header.jpg differ diff --git a/docs/Technical Notes/WASM/Browser Target/index.html b/docs/Technical Notes/WASM/Browser Target/index.html index bc3cc6ecc..c55995eb0 100644 --- a/docs/Technical Notes/WASM/Browser Target/index.html +++ b/docs/Technical Notes/WASM/Browser Target/index.html @@ -31,6 +31,6 @@ │ └────────────────────────────────────────┘ │ └──────────────────────────────────────────────┘

The WASM module is built with -mexec-model=reactor (no main()). JS calls exported functions:

ExportPurpose
io_init()Initialize the Io VM (call once)
io_get_input_buf()Pointer to 64KB input buffer
io_eval_input()Evaluate code written to input buffer
io_get_output()Pointer to output string
io_get_output_len()Length of output

WASI Shim

The browser has no filesystem or OS. io.js provides a minimal WASI shim:

  • stdout/stderr — captured to a JS string
  • clockperformance.now() in nanoseconds
  • filesystem — all path operations return ENOTCAPABLE
  • proc_exit — throws a JS Error
  • randomcrypto.getRandomValues()

Files

FilePurpose
browser/io_browser.cReactor entry point: init, eval, output capture
browser/io_dom.cDOM and Element proto implementation
browser/io_dom.hHeader for DOM protos
browser/io.jsWASI shim, DOM bridge, WASM loader, REPL UI
browser/index.htmlREPL page
browser/test.htmlAutomated test page
browser/run_tests.mjsHeadless Playwright test runner
- + diff --git a/docs/Technical Notes/WASM/DOM Interop/index.html b/docs/Technical Notes/WASM/DOM Interop/index.html index bcc67b54f..b77530062 100644 --- a/docs/Technical Notes/WASM/DOM Interop/index.html +++ b/docs/Technical Notes/WASM/DOM Interop/index.html @@ -45,6 +45,6 @@ DOM body → dom_getBody() → registerHandle(document.body) → 7 el tagName → dom_getTagName(7) → handles.get(7).tagName → "BODY"

String Passing

  • C→JS: (pointer, length) pairs. JS reads UTF-8 from WASM linear memory.
  • JS→C: JS writes into a C-exported 64KB buffer (dom_buf), null-terminates, returns length. C reads the buffer as a C string.

GC Integration

When the Io garbage collector frees an Element object, the Element's freeFunc calls dom_release(handle), which removes the entry from the JS handle map. This prevents the JS-side Map from growing without bound.

Limitations

  • Max 256 results from querySelectorAll, children (fixed-size buffer on stack)
  • Max 64KB per string transfer (shared buffer size)
  • No events — event listeners are deferred to a future phase
  • No fetch/async — network requests are deferred to a future phase
  • Inline styles onlygetStyle/setStyle operate on element.style, not computed styles
- + diff --git a/docs/Technical Notes/WASM/_index.json b/docs/Technical Notes/WASM/_index.json index e21ba30c6..48c0e653d 100644 --- a/docs/Technical Notes/WASM/_index.json +++ b/docs/Technical Notes/WASM/_index.json @@ -7,10 +7,18 @@ "title": "About", "body": "

Historically the Io VM was a native C binary with a per-platform build matrix (macOS, Linux, Windows, BSD), platform-specific coroutine assembly, and a native addon model that compiled C extensions against the host toolchain. That model works, but every new platform multiplies the work: new assembly for coroutines, new build recipes, new binary artifacts, new ways for addons to break.

Compiling the VM to WebAssembly collapses that matrix to a single portable module. The same io_static.wasm runs under wasmtime, Node.js, and directly in the browser, with no platform-specific code paths in the VM itself.

Why it matters

  • One binary, every host — a single WASM module replaces the per-OS and per-architecture build matrix. No cross-compilation toolchains, no CI jobs for each target, no separate releases. If your environment has a WASM runtime, it can run Io.
  • Runs in the browser — the same VM that runs on the command line loads as a script tag. Io programs get direct access to the DOM, fetch, Web Audio, WebGL — any capability the host page exposes — without a separate “web Io” fork.
  • Bidirectional Io↔JavaScript bridge — the old native-addon model is replaced by a symmetric bridge: Io can call any JavaScript function and receive JS objects as Io values; JavaScript can call Io methods and pass JS values as arguments. One mechanism replaces what used to require a per-library C addon.
  • Access to the JavaScript ecosystem — through the bridge, Io programs can reach the roughly two million packages on npm and every Web API the browser exposes. The classic Io distribution shipped a few dozen hand-written addons covering networking, databases, graphics, crypto, and serialization; the JS ecosystem already covers all of those, plus machine learning, 3D rendering, audio synthesis, protocol implementations, cloud SDKs, and much more — without anyone writing a line of binding code. Io inherits decades of JavaScript library work as a side effect of the port.
  • Embeddable by design — a WASM module is an embeddable artifact. Native apps can host Io through wasmtime or wasmer; server-side JS can host it through Node; the browser hosts it directly. Embedding no longer means linking C libraries and matching ABIs.
  • Sandboxed by default — WASM modules only see the capabilities their host grants. File-system and network access flow through WASI or host-supplied JS, not raw syscalls, so an Io program can’t silently reach parts of the system the host didn’t intend to expose.
  • Forces a cleaner core — the WASM target doesn’t expose the native C stack, which ruled out the old ucontext/setjmp coroutine implementations and motivated the stackless evaluator. The discipline that came with the port left the VM smaller, more portable, and easier to reason about.

Trade-offs are real: the WASM target is early-access, JIT throughput depends on the host runtime, and some classic native addons (notably anything linking C libraries) don’t carry over — their roles are now filled by JavaScript libraries reached through the bridge.

" }, + { + "type": "ContentText", + "title": "WASI 0.3", + "body": "

In 2026 the Bytecode Alliance shipped WASI 0.3, whose headline change is that async is now native to WebAssembly components. The component model's canonical ABI gains three first-class constructs — stream<T>, future<T>, and async functions — and the host runtime takes over scheduling with a single shared, completion-based event loop (in the style of io_uring and IOCP) instead of each component polling readiness through pollable handles. Wasmtime 46 ships these interfaces with async enabled by default, and jco is bringing the same model to JavaScript hosts.

Why this fits Io unusually well

  • Blocking I/O stops blocking the VM — today's build targets WASI preview1, where every read and write is synchronous. A WASM module has a single thread of execution, so one coroutine waiting on I/O stalls every other actor in the VM. Under WASI 0.3 the host event loop owns the wait: a coroutine that performs I/O can be parked on a future<T> while the scheduler runs other coroutines, then resumed when the host completes the operation.
  • The stackless evaluator already paid the entry fee — most language runtimes need the Asyncify transform (which inflates code size and slows execution) or compiler-level async/await to suspend mid-call inside WASM. Io's evaluator keeps all execution state in heap-allocated frames, so suspending on a host future is the same operation as an ordinary coroutine switch — no binary transform, no annotations. WASI 0.3 was explicitly designed to accommodate both stackful and stackless coroutine runtimes; Io is in the second camp by construction.
  • Actors map directly onto host futures — Io's @ (futureSend) and @@ (asyncSend) already give programs a future-based concurrency surface. A WASI 0.3 future<T> is the host-level version of the same idea, so an Io future awaiting a network response could be backed one-to-one by a host future and consume no VM scheduling at all until completion.
  • Streams replace the polling dance — WASI 0.2 I/O required a three-step start/finish/subscribe pattern over pollables. 0.3 collapses that into stream<T> values paired with completion futures that can finally distinguish “stream closed” from “stream failed.” Io's File and any future socket primitives map onto these cleanly.
  • The VM as a component — packaging io_static.wasm as a WebAssembly component gives it typed, language-neutral interfaces. Components compose in-process (“service chaining”), so an Io component could sit in a pipeline next to components written in Rust or Go with nanosecond rather than millisecond call overhead — a substantial upgrade to the embedding story above.

The migration path is incremental rather than architectural, and the first two steps have landed. The VM now also builds as a real WASI 0.2 component (make component, via wasi-sdk's wasm32-wasip2 target) that passes the full correctness suite under wasmtime. And the scheduler gained a timer queue: wait parks the calling coroutine instead of busy-spinning, timed coroutines run concurrently, and when nothing is runnable the VM blocks in a single host wait until the nearest deadline — the one idle point where a WASI 0.3 future<T> will be awaited instead. The remaining step — backing that idle point and the I/O primitives with 0.3 futures and streams — is gated on host and C-toolchain support (Wasmtime 46 and future<T>/stream<T> lowering from C), a change the Bytecode Alliance describes as “entirely mechanical” on the interface side.

" + }, { "type": "ContentCards", "columns": 2, - "items": ["Browser Target", "DOM Interop"] + "items": [ + "Browser Target", + "DOM Interop" + ] } ] } diff --git a/docs/Technical Notes/WASM/index.html b/docs/Technical Notes/WASM/index.html index 1ae611ceb..c6c1ebc17 100644 --- a/docs/Technical Notes/WASM/index.html +++ b/docs/Technical Notes/WASM/index.html @@ -8,8 +8,8 @@ -

Running Io in the browser via WebAssembly.

About

Historically the Io VM was a native C binary with a per-platform build matrix (macOS, Linux, Windows, BSD), platform-specific coroutine assembly, and a native addon model that compiled C extensions against the host toolchain. That model works, but every new platform multiplies the work: new assembly for coroutines, new build recipes, new binary artifacts, new ways for addons to break.

Compiling the VM to WebAssembly collapses that matrix to a single portable module. The same io_static.wasm runs under wasmtime, Node.js, and directly in the browser, with no platform-specific code paths in the VM itself.

Why it matters

  • One binary, every host — a single WASM module replaces the per-OS and per-architecture build matrix. No cross-compilation toolchains, no CI jobs for each target, no separate releases. If your environment has a WASM runtime, it can run Io.
  • Runs in the browser — the same VM that runs on the command line loads as a script tag. Io programs get direct access to the DOM, fetch, Web Audio, WebGL — any capability the host page exposes — without a separate “web Io” fork.
  • Bidirectional Io↔JavaScript bridge — the old native-addon model is replaced by a symmetric bridge: Io can call any JavaScript function and receive JS objects as Io values; JavaScript can call Io methods and pass JS values as arguments. One mechanism replaces what used to require a per-library C addon.
  • Access to the JavaScript ecosystem — through the bridge, Io programs can reach the roughly two million packages on npm and every Web API the browser exposes. The classic Io distribution shipped a few dozen hand-written addons covering networking, databases, graphics, crypto, and serialization; the JS ecosystem already covers all of those, plus machine learning, 3D rendering, audio synthesis, protocol implementations, cloud SDKs, and much more — without anyone writing a line of binding code. Io inherits decades of JavaScript library work as a side effect of the port.
  • Embeddable by design — a WASM module is an embeddable artifact. Native apps can host Io through wasmtime or wasmer; server-side JS can host it through Node; the browser hosts it directly. Embedding no longer means linking C libraries and matching ABIs.
  • Sandboxed by default — WASM modules only see the capabilities their host grants. File-system and network access flow through WASI or host-supplied JS, not raw syscalls, so an Io program can’t silently reach parts of the system the host didn’t intend to expose.
  • Forces a cleaner core — the WASM target doesn’t expose the native C stack, which ruled out the old ucontext/setjmp coroutine implementations and motivated the stackless evaluator. The discipline that came with the port left the VM smaller, more portable, and easier to reason about.

Trade-offs are real: the WASM target is early-access, JIT throughput depends on the host runtime, and some classic native addons (notably anything linking C libraries) don’t carry over — their roles are now filled by JavaScript libraries reached through the bridge.

+

Running Io in the browser via WebAssembly.

About

Historically the Io VM was a native C binary with a per-platform build matrix (macOS, Linux, Windows, BSD), platform-specific coroutine assembly, and a native addon model that compiled C extensions against the host toolchain. That model works, but every new platform multiplies the work: new assembly for coroutines, new build recipes, new binary artifacts, new ways for addons to break.

Compiling the VM to WebAssembly collapses that matrix to a single portable module. The same io_static.wasm runs under wasmtime, Node.js, and directly in the browser, with no platform-specific code paths in the VM itself.

Why it matters

  • One binary, every host — a single WASM module replaces the per-OS and per-architecture build matrix. No cross-compilation toolchains, no CI jobs for each target, no separate releases. If your environment has a WASM runtime, it can run Io.
  • Runs in the browser — the same VM that runs on the command line loads as a script tag. Io programs get direct access to the DOM, fetch, Web Audio, WebGL — any capability the host page exposes — without a separate “web Io” fork.
  • Bidirectional Io↔JavaScript bridge — the old native-addon model is replaced by a symmetric bridge: Io can call any JavaScript function and receive JS objects as Io values; JavaScript can call Io methods and pass JS values as arguments. One mechanism replaces what used to require a per-library C addon.
  • Access to the JavaScript ecosystem — through the bridge, Io programs can reach the roughly two million packages on npm and every Web API the browser exposes. The classic Io distribution shipped a few dozen hand-written addons covering networking, databases, graphics, crypto, and serialization; the JS ecosystem already covers all of those, plus machine learning, 3D rendering, audio synthesis, protocol implementations, cloud SDKs, and much more — without anyone writing a line of binding code. Io inherits decades of JavaScript library work as a side effect of the port.
  • Embeddable by design — a WASM module is an embeddable artifact. Native apps can host Io through wasmtime or wasmer; server-side JS can host it through Node; the browser hosts it directly. Embedding no longer means linking C libraries and matching ABIs.
  • Sandboxed by default — WASM modules only see the capabilities their host grants. File-system and network access flow through WASI or host-supplied JS, not raw syscalls, so an Io program can’t silently reach parts of the system the host didn’t intend to expose.
  • Forces a cleaner core — the WASM target doesn’t expose the native C stack, which ruled out the old ucontext/setjmp coroutine implementations and motivated the stackless evaluator. The discipline that came with the port left the VM smaller, more portable, and easier to reason about.

Trade-offs are real: the WASM target is early-access, JIT throughput depends on the host runtime, and some classic native addons (notably anything linking C libraries) don’t carry over — their roles are now filled by JavaScript libraries reached through the bridge.

WASI 0.3

In 2026 the Bytecode Alliance shipped WASI 0.3, whose headline change is that async is now native to WebAssembly components. The component model's canonical ABI gains three first-class constructs — stream<T>, future<T>, and async functions — and the host runtime takes over scheduling with a single shared, completion-based event loop (in the style of io_uring and IOCP) instead of each component polling readiness through pollable handles. Wasmtime 46 ships these interfaces with async enabled by default, and jco is bringing the same model to JavaScript hosts.

Why this fits Io unusually well

  • Blocking I/O stops blocking the VM — today's build targets WASI preview1, where every read and write is synchronous. A WASM module has a single thread of execution, so one coroutine waiting on I/O stalls every other actor in the VM. Under WASI 0.3 the host event loop owns the wait: a coroutine that performs I/O can be parked on a future<T> while the scheduler runs other coroutines, then resumed when the host completes the operation.
  • The stackless evaluator already paid the entry fee — most language runtimes need the Asyncify transform (which inflates code size and slows execution) or compiler-level async/await to suspend mid-call inside WASM. Io's evaluator keeps all execution state in heap-allocated frames, so suspending on a host future is the same operation as an ordinary coroutine switch — no binary transform, no annotations. WASI 0.3 was explicitly designed to accommodate both stackful and stackless coroutine runtimes; Io is in the second camp by construction.
  • Actors map directly onto host futures — Io's @ (futureSend) and @@ (asyncSend) already give programs a future-based concurrency surface. A WASI 0.3 future<T> is the host-level version of the same idea, so an Io future awaiting a network response could be backed one-to-one by a host future and consume no VM scheduling at all until completion.
  • Streams replace the polling dance — WASI 0.2 I/O required a three-step start/finish/subscribe pattern over pollables. 0.3 collapses that into stream<T> values paired with completion futures that can finally distinguish “stream closed” from “stream failed.” Io's File and any future socket primitives map onto these cleanly.
  • The VM as a component — packaging io_static.wasm as a WebAssembly component gives it typed, language-neutral interfaces. Components compose in-process (“service chaining”), so an Io component could sit in a pipeline next to components written in Rust or Go with nanosecond rather than millisecond call overhead — a substantial upgrade to the embedding story above.

The migration path is incremental rather than architectural, and the first two steps have landed. The VM now also builds as a real WASI 0.2 component (make component, via wasi-sdk's wasm32-wasip2 target) that passes the full correctness suite under wasmtime. And the scheduler gained a timer queue: wait parks the calling coroutine instead of busy-spinning, timed coroutines run concurrently, and when nothing is runnable the VM blocks in a single host wait until the nearest deadline — the one idle point where a WASI 0.3 future<T> will be awaited instead. The remaining step — backing that idle point and the I/O primitives with 0.3 futures and streams — is gated on host and C-toolchain support (Wasmtime 46 and future<T>/stream<T> lowering from C), a change the Bytecode Alliance describes as “entirely mechanical” on the interface side.

- + diff --git a/docs/Technical Notes/index.html b/docs/Technical Notes/index.html index 428448848..326fa2203 100644 --- a/docs/Technical Notes/index.html +++ b/docs/Technical Notes/index.html @@ -12,6 +12,6 @@ - + diff --git a/libs/iovm/io/Exception.io b/libs/iovm/io/Exception.io index 64ee59afa..22247becc 100644 --- a/libs/iovm/io/Exception.io +++ b/libs/iovm/io/Exception.io @@ -49,15 +49,80 @@ Scheduler := Object clone do( //doc Scheduler setYieldingCoros(aListOfCoros) Sets the list of yielding Coroutine objects. yieldingCoros ::= List clone - //doc Scheduler timers The List of active timers. + //doc Scheduler timers The List of active timers, each a list(wakeTime, coroutine), sorted by wakeTime. //doc Scheduler setTimers(aListOfTimers) Sets the list of active timers. timers ::= List clone //doc Scheduler currentCoroutine Returns the currently running coroutine. currentCoroutine := method(Coroutine currentCoroutine) + addTimerAt := method(wakeTime, coro, + /*doc Scheduler addTimerAt(wakeTime, aCoroutine) + Parks aCoroutine off the run queue, to be moved back onto it + once wakeTime (seconds since the epoch) has passed. + Replaces any existing timer for the same coroutine. Returns self. + */ + removeTimerFor(coro) + entry := list(wakeTime, coro) + i := 0 + timers foreach(t, if(t at(0) > wakeTime, break); i = i + 1) + timers atInsert(i, entry) + self + ) + + addTimer := method(s, coro, + /*doc Scheduler addTimer(seconds, aCoroutine) + Parks aCoroutine off the run queue, to be moved back onto it + once at least seconds have elapsed. Returns self. + */ + addTimerAt(Date clone now asNumber + s, coro) + ) + + removeTimerFor := method(coro, + /*doc Scheduler removeTimerFor(aCoroutine) + Removes any timer entries for aCoroutine. Used when a parked + coroutine is resumed by something other than its timer (e.g. a + finished child coroutine resuming its parent). Returns self. + */ + timers selectInPlace(t, t at(1) != coro) + self + ) + + wakeExpiredTimers := method( + /*doc Scheduler wakeExpiredTimers + Moves coroutines whose timer deadlines have passed back onto + the run queue, in deadline order. Returns self. + */ + if(timers isEmpty, return self) + now := Date clone now asNumber + while(timers isEmpty not and(timers first at(0) <= now), + yieldingCoros appendIfAbsent(timers removeFirst at(1)) + ) + self + ) + + idleUntilNextTimer := method( + /*doc Scheduler idleUntilNextTimer + Called when no coroutine is runnable but timers are pending: + performs a single blocking host wait until the nearest timer + deadline, then wakes expired timers. This is the VM's only + idle point — under WASI 0.1/0.2 it is a host sleep; under + WASI 0.3 it would await a host future instead. Returns self. + */ + if(timers isEmpty, return self) + remaining := timers first at(0) - Date clone now asNumber + if(remaining > 0, System sleep(remaining)) + wakeExpiredTimers + ) + waitForCorosToComplete := method( - while(yieldingCoros size > 0, yield) + //doc Scheduler waitForCorosToComplete Runs the scheduler until no coroutines are runnable and no timers are pending. + loop( + wakeExpiredTimers + if(yieldingCoros size > 0, yield; continue) + if(timers size > 0, idleUntilNextTimer; continue) + break + ) ) ) @@ -137,13 +202,20 @@ Coroutine do( yield := method( /*doc Coroutine yield Yields to another coroutine in the yieldingCoros queue. - Does nothing if yieldingCoros is empty. + Coroutines whose timer deadlines have passed are moved back + onto the queue first. Stale entries for finished coroutines + are dropped (resuming one would restart its body). Does + nothing if yieldingCoros is empty. */ //showYielding("yield") //writeln("Coro ", self uniqueId, " yielding - yieldingCoros = ", yieldingCoros size) + Scheduler wakeExpiredTimers if(yieldingCoros isEmpty, return) yieldingCoros append(self) next := yieldingCoros removeFirst + while(next != self and(next isFinished), + next = yieldingCoros removeFirst + ) if(next == self, return) //writeln(Scheduler currentCoroutine label, " yield - ", next label, " resume") if(next, next resume) @@ -166,20 +238,33 @@ Coroutine do( pause := method( /*doc Coroutine pause Removes current coroutine from the yieldingCoros queue and - yields to another coro. System exit is executed if no coros left. + yields to another coro. If nothing is runnable but timers are + pending, the VM idles (a single blocking host wait) until the + nearest timer deadline. System exit is executed if no + coros and no timers are left.
You can resume a coroutine using either resume or resumeLater message. */ yieldingCoros remove(self) if(isCurrent, - next := yieldingCoros removeFirst - if(next, - next resume - , - Exception raise("Scheduler: nothing left to resume so we are exiting") - writeln("Scheduler: nothing left to resume so we are exiting") - self showStack - System exit + loop( + Scheduler wakeExpiredTimers + while(yieldingCoros isEmpty and(Scheduler timers isEmpty not), + Scheduler idleUntilNextTimer + ) + next := yieldingCoros removeFirst + if(next isNil, + Exception raise("Scheduler: nothing left to resume so we are exiting") + writeln("Scheduler: nothing left to resume so we are exiting") + self showStack + System exit + ) + if(next == self, return) + if(next isFinished not, + next resume + return + ) + // stale entry for a finished coroutine — drop it and retry ) , yieldingCoros remove(self) @@ -299,21 +384,28 @@ Coroutine do( Object wait := method(s, /*doc Object wait(s) - Pauses current coroutine for at least s seconds. + Pauses current coroutine for at least s seconds without + blocking other coroutines: the coroutine is parked on Scheduler's + timer queue and another runnable coroutine (if any) is resumed. + When nothing is runnable, the VM performs a single blocking host + wait until the nearest timer deadline.
Note: current coroutine may wait much longer than designated number of seconds depending on circumstances. */ - - //writeln("Scheduler yieldingCoros size = ", Scheduler yieldingCoros size) - if(Scheduler yieldingCoros isEmpty, - //writeln("System sleep") - System sleep(s) - , - //writeln("Object wait") - endDate := Date clone now + Duration clone setSeconds(s) - loop(endDate isPast ifTrue(break); yield) - ) + if(s <= 0, yield; return self) + coro := Scheduler currentCoroutine + wakeTime := Date clone now asNumber + s + // A finished child coroutine resumes its parent directly, so pause can + // return before the deadline. Re-park for the remaining time until the + // deadline has actually passed (condition-variable semantics). + loop( + if(wakeTime <= Date clone now asNumber, break) + Scheduler addTimerAt(wakeTime, coro) + coro pause + Scheduler removeTimerFor(coro) + ) + self ) Message do( diff --git a/libs/iovm/source/IoCoroutine.c b/libs/iovm/source/IoCoroutine.c index f949ae8d9..b82050098 100644 --- a/libs/iovm/source/IoCoroutine.c +++ b/libs/iovm/source/IoCoroutine.c @@ -99,6 +99,7 @@ IoCoroutine *IoCoroutine_proto(void *state) { DATA(self)->stopStatus = MESSAGE_STOP_STATUS_NORMAL; DATA(self)->returnValue = NULL; DATA(self)->frameDepth = 0; + DATA(self)->hasFinished = 0; return self; } @@ -116,6 +117,7 @@ void IoCoroutine_protoFinish(IoCoroutine *self) { {"main", IoCoroutine_main}, {"resume", IoCoroutine_resume}, {"isCurrent", IoCoroutine_isCurrent}, + {"isFinished", IoCoroutine_isFinished}, {"currentCoroutine", IoCoroutine_currentCoroutine}, {"implementation", IoCoroutine_implementation}, {"setMessageDebugging", IoCoroutine_setMessageDebugging}, @@ -146,6 +148,7 @@ IoCoroutine *IoCoroutine_rawClone(IoCoroutine *proto) { DATA(self)->stopStatus = MESSAGE_STOP_STATUS_NORMAL; DATA(self)->returnValue = NULL; DATA(self)->frameDepth = 0; + DATA(self)->hasFinished = 0; return self; } @@ -783,6 +786,7 @@ void IoCoroutine_rawRun(IoCoroutine *self) { DATA(self)->frameStack = NULL; DATA(self)->stopStatus = MESSAGE_STOP_STATUS_NORMAL; DATA(self)->returnValue = NULL; + DATA(self)->hasFinished = 0; IoState_setCurrentCoroutine_(state, self); state->currentFrame = NULL; @@ -858,6 +862,7 @@ void IoCoroutine_rawRun(IoCoroutine *self) { DATA(self)->frameStack = NULL; DATA(self)->stopStatus = MESSAGE_STOP_STATUS_NORMAL; DATA(self)->returnValue = NULL; + DATA(self)->hasFinished = 0; // Switch to child coroutine #ifdef DEBUG_CORO_EVAL @@ -982,6 +987,7 @@ void IoCoroutine_try(IoCoroutine *self, IoObject *target, IoObject *locals, DATA(self)->frameStack = NULL; DATA(self)->stopStatus = MESSAGE_STOP_STATUS_NORMAL; DATA(self)->returnValue = NULL; + DATA(self)->hasFinished = 0; // Switch to try coroutine IoState_setCurrentCoroutine_(state, self); @@ -1147,6 +1153,7 @@ IoObject *IoCoroutine_rawResume(IoCoroutine *self) { return self; } IoCoroutine_rawSetParentCoroutine_(self, current); + DATA(self)->hasFinished = 0; IoEvalFrame *frame = IoState_pushFrame_(state); IoEvalFrameData *fd = FRAME_DATA(frame); fd->message = runMessage; @@ -1190,6 +1197,16 @@ IO_METHOD(IoCoroutine, isCurrent) { return v; } +IO_METHOD(IoCoroutine, isFinished) { + /*doc Coroutine isFinished + Returns true if the receiver's body has run to completion. A finished + coroutine must not be resumed (resume would restart its body); the + scheduler uses this to drop stale run-queue entries. + */ + + return IOBOOL(self, DATA(self)->hasFinished); +} + IO_METHOD(IoCoroutine, currentCoroutine) { /*doc Coroutine currentCoroutine Returns currently running coroutine in Io state. diff --git a/libs/iovm/source/IoCoroutine.h b/libs/iovm/source/IoCoroutine.h index 99563ce13..803afea52 100644 --- a/libs/iovm/source/IoCoroutine.h +++ b/libs/iovm/source/IoCoroutine.h @@ -51,6 +51,10 @@ typedef struct { IoObject *returnValue; // Per-coroutine return value int frameDepth; // Saved frame depth (avoids O(n) recalc on switch) int debuggingOn; + int hasFinished; // Body ran to completion. Distinguishes a + // finished coro (no frames) from a + // never-started one, so stale run-queue + // entries don't restart its body. } IoCoroutineData; IOVM_API IoCoroutine *IoCoroutine_proto(void *state); @@ -133,6 +137,7 @@ IOVM_API IO_METHOD(IoCoroutine, implementation); IOVM_API IO_METHOD(IoCoroutine, run); IOVM_API IO_METHOD(IoCoroutine, callStack); IOVM_API IO_METHOD(IoCoroutine, isCurrent); +IOVM_API IO_METHOD(IoCoroutine, isFinished); // runTarget diff --git a/libs/iovm/source/IoState_iterative.c b/libs/iovm/source/IoState_iterative.c index cd490a2d0..28a11f229 100644 --- a/libs/iovm/source/IoState_iterative.c +++ b/libs/iovm/source/IoState_iterative.c @@ -287,6 +287,21 @@ IoObject *IoState_evalLoop_(IoState *state) { IoCoroutine *current = state->currentCoroutine; IoCoroutine *parent = IoCoroutine_rawParentCoroutine(current); + // An empty frame stack means this coroutine's body ran to + // completion. Mark it so the scheduler (Coroutine pause/yield) + // can drop stale run-queue entries instead of restarting it. + ((IoCoroutineData *)IoObject_dataPointer(current))->hasFinished = 1; + + // Walk up past dead ancestors (no saved frames). A coroutine + // can finish after its parent already finished — e.g. scheduler + // timer wakeups resume coros whose starting coro is long dead — + // leaving dead links in the parentCoroutine chain. Resume the + // nearest ancestor that still has frames to run. + while (parent && ISCOROUTINE(parent) && + ((IoCoroutineData *)IoObject_dataPointer(parent))->frameStack == NULL) { + parent = IoCoroutine_rawParentCoroutine(parent); + } + // Check if this is a child coro started via coro swap that has // finished. The parent's saved frameStack will have a // CORO_WAIT_CHILD or CORO_YIELDED frame. We must check diff --git a/libs/iovm/source/IoSystem.c b/libs/iovm/source/IoSystem.c index 53034b064..da331ce76 100644 --- a/libs/iovm/source/IoSystem.c +++ b/libs/iovm/source/IoSystem.c @@ -222,9 +222,12 @@ IO_METHOD(IoObject, activeCpus) { /*cdoc System IoObject_sleep(self, locals, m) Blocks the current thread for the requested number of seconds by chunking into sub-second usleep calls (usleep's POSIX argument must -be less than 1,000,000). Under WASM this is a real synchronous pause, -since there is no scheduler to yield to — long sleeps stall the whole -runtime. +be less than 1,000,000). Under WASM this is a real synchronous pause +that stalls the whole runtime, so it should only run when nothing is +runnable: Scheduler idleUntilNextTimer uses it as the VM's single idle +point, and Object wait parks coroutines on the scheduler's timer queue +instead of calling this directly. Under WASI 0.3 this idle point would +await a host future instead of sleeping. */ IO_METHOD(IoObject, sleep) { /*doc System sleep(secondsNumber) diff --git a/libs/iovm/tests/correctness/SchedulerTimerTest.io b/libs/iovm/tests/correctness/SchedulerTimerTest.io new file mode 100644 index 000000000..f2fb55483 --- /dev/null +++ b/libs/iovm/tests/correctness/SchedulerTimerTest.io @@ -0,0 +1,65 @@ +SchedulerTimerTest := UnitTest clone do( + // Object wait parks the current coroutine on Scheduler's timer queue + // instead of busy-spinning, so waiting coroutines run concurrently and + // the VM idles in a single host sleep when nothing is runnable. + // + // coroDo (not coroDoLater) is used because it runs the body in the + // sender's context, so the coroutines can see the test method's locals. + + testWaitReturnsAfterDeadline := method( + t := Date clone now asNumber + wait(0.05) + elapsed := Date clone now asNumber - t + assertTrue(elapsed >= 0.04) + assertTrue(Scheduler timers isEmpty) + ) + + testWaitsRunConcurrently := method( + s := Sequence clone + t := Date clone now asNumber + coroDo(wait(0.12); s appendSeq("slow.")) + coroDo(wait(0.04); s appendSeq("fast.")) + wait(0.2) + elapsed := Date clone now asNumber - t + assertEquals("fast.slow.", s asString) + // concurrent waits take ~max(0.12, 0.04, 0.2), not the 0.36 sum + assertTrue(elapsed < 0.33) + assertTrue(Scheduler timers isEmpty) + ) + + testTimerWakesDuringYields := method( + s := Sequence clone + coroDo(wait(0.05); s appendSeq("timer.")) + deadline := Date clone now asNumber + 1 + while(s size == 0 and(Date clone now asNumber < deadline), + yield + ) + s appendSeq("main.") + assertEquals("timer.main.", s asString) + ) + + testFinishAfterStarterDied := method( + // Regression test for the dead-ancestor walk in the eval loop: + // c1's pause starts c2, so c2's parentCoroutine is c1. c1 finishes + // first, so when c2 finishes its parent chain has a dead link and + // the eval loop must walk past it to resume this coroutine. + s := Sequence clone + c1 := coroFor(wait(0.03); s appendSeq("c1.")) + c2 := coroFor(wait(0.08); s appendSeq("c2.")) + Scheduler yieldingCoros append(c1) + Scheduler yieldingCoros append(c2) + wait(0.15) + assertEquals("c1.c2.", s asString) + assertTrue(Scheduler timers isEmpty) + ) + + testActorCanWait := method( + o := Object clone + o s := Sequence clone + o job := method(wait(0.03); s appendSeq("done."); s) + f := o @job + // touching the future's proxy blocks this coroutine until the + // actor's wait completes via the scheduler's idle path + assertEquals("done.", f asString) + ) +) diff --git a/llms-full.txt b/llms-full.txt index 73b08824c..60c61e622 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -6225,6 +6225,19 @@ Compiling the VM to **WebAssembly** collapses that matrix to a single portable m - **Forces a cleaner core** — the WASM target doesn’t expose the native C stack, which ruled out the old ucontext/setjmp coroutine implementations and motivated the stackless evaluator. The discipline that came with the port left the VM smaller, more portable, and easier to reason about. Trade-offs are real: the WASM target is early-access, JIT throughput depends on the host runtime, and some classic native addons (notably anything linking C libraries) don’t carry over — their roles are now filled by JavaScript libraries reached through the bridge. +## WASI 0.3 + +In 2026 the Bytecode Alliance shipped [WASI 0.3](https://bytecodealliance.org/articles/WASI-0.3), whose headline change is that **async is now native to WebAssembly components**. The component model's canonical ABI gains three first-class constructs — `stream`, `future`, and async functions — and the host runtime takes over scheduling with a single shared, completion-based event loop (in the style of `io_uring` and IOCP) instead of each component polling readiness through `pollable` handles. Wasmtime 46 ships these interfaces with async enabled by default, and `jco` is bringing the same model to JavaScript hosts. + +### Why this fits Io unusually well + +- **Blocking I/O stops blocking the VM** — today's build targets WASI preview1, where every read and write is synchronous. A WASM module has a single thread of execution, so one coroutine waiting on I/O stalls every other actor in the VM. Under WASI 0.3 the host event loop owns the wait: a coroutine that performs I/O can be parked on a `future` while the scheduler runs other coroutines, then resumed when the host completes the operation. +- **The stackless evaluator already paid the entry fee** — most language runtimes need the Asyncify transform (which inflates code size and slows execution) or compiler-level async/await to suspend mid-call inside WASM. Io's evaluator keeps all execution state in heap-allocated frames, so suspending on a host future is the same operation as an ordinary coroutine switch — no binary transform, no annotations. WASI 0.3 was explicitly designed to accommodate both stackful and stackless coroutine runtimes; Io is in the second camp by construction. +- **Actors map directly onto host futures** — Io's `@` (futureSend) and `@@` (asyncSend) already give programs a future-based concurrency surface. A WASI 0.3 `future` is the host-level version of the same idea, so an Io future awaiting a network response could be backed one-to-one by a host future and consume no VM scheduling at all until completion. +- **Streams replace the polling dance** — WASI 0.2 I/O required a three-step `start`/`finish`/`subscribe` pattern over pollables. 0.3 collapses that into `stream` values paired with completion futures that can finally distinguish “stream closed” from “stream failed.” Io's `File` and any future socket primitives map onto these cleanly. +- **The VM as a component** — packaging `io_static.wasm` as a WebAssembly component gives it typed, language-neutral interfaces. Components compose in-process (“service chaining”), so an Io component could sit in a pipeline next to components written in Rust or Go with nanosecond rather than millisecond call overhead — a substantial upgrade to the embedding story above. +The migration path is incremental rather than architectural, and the first two steps have landed. The VM now also builds as a real **WASI 0.2 component** (`make component`, via wasi-sdk's `wasm32-wasip2` target) that passes the full correctness suite under wasmtime. And the scheduler gained a **timer queue**: `wait` parks the calling coroutine instead of busy-spinning, timed coroutines run concurrently, and when nothing is runnable the VM blocks in a single host wait until the nearest deadline — the one idle point where a WASI 0.3 `future` will be awaited instead. The remaining step — backing that idle point and the I/O primitives with 0.3 futures and streams — is gated on host and C-toolchain support (Wasmtime 46 and `future`/`stream` lowering from C), a change the Bytecode Alliance describes as “entirely mechanical” on the interface side. + - Browser Target - DOM Interop