Skip to content

Merge wasm → master: retarget Io at WebAssembly#495

Merged
stevedekorte merged 43 commits intomasterfrom
wasm
Apr 20, 2026
Merged

Merge wasm → master: retarget Io at WebAssembly#495
stevedekorte merged 43 commits intomasterfrom
wasm

Conversation

@stevedekorte
Copy link
Copy Markdown
Member

Summary

This merges the wasm branch into master, retargeting Io at WebAssembly (WASI) as its primary platform. The previous native build (CMake + Eerie + DynLib) is preserved on the native branch and tagged 2026.04.20-native-final.

What changes for users

  • One binary, everywhere. A single WASI module (build/bin/io_static) runs under wasmtime, Node, and browsers via WASI shims — no per-platform builds.
  • Browser REPL. make serve brings up an Io REPL in the browser (browser/io_browser.wasm), with a bidirectional Io ↔ JavaScript bridge.
  • Async / await. Io blocks can await JS promises; JS can call Io methods that return futures. Unrecognized messages on a future implicitly auto-await.
  • Simpler build. Plain Makefile driven by wasi-sdk; no CMake, no autoconf, no per-addon submodules.

What goes away

The following are not available on WebAssembly and are gone from master:

  • DynLib, AddonLoader, and the entire dynamic-addon plugin system
  • The Eerie package manager
  • extras/ (SQLite, OpenGL, OpenSSL, Socket, etc. — anything that depended on native dynamic loading)
  • Platform-specific build scaffolding (CMake, MSVC, per-OS autotools)

If you depend on any of the above, use the native branch. It accepts bug fixes; new development lands on master.

How it was built

This lands on top of the stackless/iterative evaluator work (#488): Io's eval loop is now a heap-allocated frame state machine, which made first-class continuations, portable coroutines, and the JS async bridge possible without platform-specific assembly or setjmp/ucontext.

Merge strategy

Merge as a merge commit (not squash or rebase) to preserve the full history of the wasm branch.

Test plan

  • make check passes on wasm: C suite (25/25) + Io suite (234/234)
  • make browser builds io_browser.wasm
  • CI (.github/workflows/ci.yml) green on the PR — the workflow has been rewritten to install wasi-sdk + wasmtime and run make check (the old CMake matrix is gone)

🤖 Generated with Claude Code

stevedekorte and others added 30 commits March 4, 2026 21:45
WASM.md: strategic direction, architectural decisions, phase 2
compacting collector design.

WASM_PLAN.md: detailed phased plan from codebase audit — toolchain
setup, io2c bootstrap, API stubs, platform cleanup, risk assessment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- cmake/wasi-sdk.cmake: toolchain file for wasi-sdk cross-compilation
- All CMakeLists.txt: conditional static-only libs, skip -ldl/-msse2/
  -lcurses/SANE_POPEN for WASI, IO2C_EXECUTABLE for host io2c bootstrap,
  hardcode USE_BUILTIN_NAN (can't run test programs when cross-compiling)
- IoConfig.h: add WASM platform detection, clean up preprocessor structure
- DynLib.c: WASM stubs (no dynamic loading)
- IoSystem.c: WASM stubs for system(), platform(), activeCpus(), getpid()
- IoFile.c: WASM stubs for popen/pclose
- IoState_debug.c: skip signal() on WASI

Native build and all tests still pass (25/25 C, 249/249 Io).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Guard setjmp.h includes for WASI (Base.h, IoNumber.c)
- Guard signal.h in PortableStdint.h with sig_atomic_t fallback
- Guard execinfo.h in IoCoroutine.c
- Guard WIFEXITED/WEXITSTATUS macros in IoFile.c for WASI
- Stub tmpfile() on WASM (not supported by WASI)
- Fix CMake TODAY macro fallback for non-WIN32/non-UNIX platforms
- Fix IO_SRCS paths in libs/CMakeLists.txt (iovm/io/ not io/)
- Add missing Object_bootstrap.io to libs/CMakeLists.txt
- Add WASI emulation libs (-lwasi-emulated-process-clocks, -lwasi-emulated-signal)
- Fix Stack_asList function pointer type mismatch (WASM indirect call safety)
- Fix IoInstallPrefix.h include ordering in IoSystem.c

Io now compiles to WASM and runs correctly under wasmtime.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- IoFile remove: use rmdir() for directories on WASI (WASI's remove()
  only handles files, unlike POSIX which dispatches to rmdir/unlink)
- IoSeq toBase: add errorRaised check after arg parsing and return
  after IoState_error_ (prevents divide-by-zero WASM trap)

WASM test results: 225/225 compatible tests pass.
Excluded: FileTest (popen), UnicodeTest (system()), SwitchTest (pre-existing).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove all Windows/Symbian/multi-platform #ifdef code (~900 lines),
extras/ directory (~3.9MB of dead IDE projects and syntax highlighters),
and 7 CMakeLists.txt files. The codebase now targets WASM-only.

- Remove WIN32, _MSC_VER, __SYMBIAN32__, __CYGWIN__, __MINGW32__ code paths
- Remove extras/ (win32vc10, win32vc2005, symbian, osx, osxvm, xcode,
  IoLanguageKit, IoTest, SyntaxHighlighters)
- Replace CMake build system with single 90-line Makefile
- Simplify WASM guards to unconditional code (no setjmp, signal, execinfo)
- Rewrite IoSystem.c, IoConfig.h, DynLib.c as WASM-only
- Remove popen/subprocess tests (FileTest, UnicodeTest) that can't run on WASM
- 25/25 C tests pass, 234/234 Io tests pass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
WASM has no dlopen — dynamic addon loading is dead code. Remove:
- DynLib C implementation (basekit + iovm)
- AddonLoader.io (addon discovery/loading)
- DynLib.io (dynamic function calling wrapper)
- FolderImporter and AddonImporter from Importer.io
- DynLib proto registration from IoState.c

Importer now only has FileImporter. 234/234 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Eerie package manager depends on DynLib/addon loading which is
not available on WASM. Remove the submodule entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the hardcoded 28-line IO_FILES list in the Makefile with
_imports.json, a recursive JSON manifest that declares .io file load
order (following the strvct.net pattern). io2c now parses _imports.json
via parson when argv[3] ends with .json, recursing into nested .json
entries. Legacy CLI file list still works.

Update parson submodule from 2018 (4f3eaa6) to 1.5.3 (ba29f4e).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Browser REPL (Phase 4a):
- browser/io_browser.c: reactor WASM entry point with io_init/io_eval exports
- browser/io.js: WASM loader + WASI shim + REPL UI logic
- browser/index.html: dark-themed REPL matching iolanguage.org style
- Makefile: `make browser` and `make serve` targets

Docs:
- docs2html.io: inline DocsExtractor instead of shelling out via System system()
- gendocs.sh: use wasmtime + io_static instead of native io binary

Plan:
- WASM_PLAN.md: move compacting collector to Phase 5, expand Phase 4 with 4a/4b

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Large "io" header with "/ repl" subtitle matching original site
- Dark #191919 theme, helvetica/Helvetica Neue Bold fonts
- Full-height scrolling output area with left border accent
- Topic buttons (HELLO WORLD, MATH, etc.) in bottom toolbar
- Status text hidden once VM is ready
- Added missing WASI shim functions (fd_fdstat_set_flags, path_*, fd_readdir, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace hand-wrapped DOM CFunctions with a generic bridge that lets Io
code call any JS object and JS code send messages to any Io object.
Value types (numbers, strings, bools, lists, maps) copy across the
boundary via a binary serialization protocol in a shared 64KB buffer.
Complex objects (DOM elements, functions) pass by reference via integer
handles. See agents/IoJsBridge.md for full design documentation.

- New: io_js_bridge.c/h — JSObject proto with forward dispatch,
  binary serialization, ioHandles table, WASM exports for JS→Io
- New: js import namespace in io.js — js_call, js_get_prop,
  js_set_prop, js_call_func, js_typeof, Io Proxy factory
- New: browser test suite (31 tests) with Playwright runner
- New: docs for browser target, DOM interop, testing reference
- Removed: io_dom.c/h and dom JS namespace (~550 lines)
- Modified: IoCoroutine.c GC marking hook for ioHandles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- agents/WASM_BRIDGE.md: Bridge convention for Io/JS data passing —
  primitives copy, containers deep-copy, objects proxy, plus decisions
  on undefined, TypedArrays, iterators, functions, async, exceptions,
  events, BigInt/Symbol rejection, and UTF-8 string encoding
- UArray default encoding changed from ASCII to UTF-8 to align with
  WASM bridge (ASCII is a valid subset, so existing code is unaffected)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tion proxy

Add JsTypes undefined singleton, cycle detection for container serialization,
BigInt/Symbol rejection, JS Map→Io Map / Set→List symmetry, TypedArray↔Vector
bridging, and exception proxy handles with error messages. 44 browser tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Documents library choice (libtommath), implementation plan, wire format,
and design decisions for adding BigInt support to the browser bridge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Merge WASM.md + WASM_Plan.md → wasm/Plan.md
- Merge WASM_Bridge.md + IoJsBridge.md → wasm/Bridge.md
- Merge C_STACK_ELIMINATION_PLAN.md + CONTINUATIONS_TODO.md → stackless/Continuations.md
- Keep BigInt.md, Other_Bridges.md, Eval_Loop_Design.md, Debug_Loop_Leak.md standalone

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- New js_new_function WASM import wraps new Function(...) on JS side
- New jsfunction CFunction on Lobby takes code string, returns JSObject
- Syntax errors in code string propagate as Io exceptions
- 6 new browser tests (50/50 passing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
JS Promises are now auto-detected and wrapped as IoFuture objects.
Provides await, isReady, state, label methods. Forward delegates
unrecognized messages (then, catch) to the underlying JS Promise.
Adds io_resolve_future/io_reject_future WASM exports, js_watch_promise
import, and 9 new browser tests (66/66 passing). Reorganizes plan
docs into subplans/ directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Future await on a pending Promise now suspends the coroutine and yields
to the JS host. When the Promise settles, JS calls io_resume_eval to
restore the suspended coro and re-enter the eval loop.

Key pieces:
- FRAME_STATE_AWAIT_JS in eval loop state machine
- awaitingJsPromise + suspendedCoro fields on IoState
- io_resume_eval WASM export with chained await support
- JS-side resumeIfAwaiting() + pendingAsyncResolve callback
- Fixed coro save to use active coroutine (not self) so try()
  across suspend/resume works correctly with child coro frame swap

68/68 browser tests passing (2 new asyncAwait tests).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ync Step 5)

When a message isn't found on a Future, the forward method handles it:
resolved Futures redirect the eval frame to the resolved value (no nested
eval); pending Futures yield to JS via AWAIT_JS (same as explicit await).

Key fixes: LOOKUP_SLOT checks needsControlFlowHandling after IoObject_forward()
calls; AWAIT_JS resume transitions to LOOKUP_SLOT (not ACTIVATE) so both
explicit await and implicit forward resume correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New demo buttons: JS MATH (Math.sqrt), DOM (createElement + style),
ASYNC (Promise auto-await), CALLBACK (setTimeout with Io block),
COLORS (foreach + DOM), BUTTON (interactive click counter with
addEventListener). Fixed property setting to use set("textContent", ...)
instead of non-existent setTextContent method.

REPL now handles async eval (status=2) — shows spinner while awaiting,
updates in-place when Promise resolves. Background callback output
(from setTimeout/event handlers) is flushed to the REPL output area.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implement arbitrary-precision integers using libtommath, with full
round-trip serialization across the Io-JS bridge as TYPE_BIGINT (13).

- Vendor libtommath v1.3.0 (public domain, pure C, WASM-compatible)
- IoBigInt type: arithmetic (+,-,*,/,%,**), comparison, conversion
- Wire format: decimal string [13][len:u32][string] — lossless any size
- JS BigInt → Io BigInt and back without data loss
- Add -D__STDC_IEC_559__ for mp_set_double on WASM targets
- 11 new browser tests (83/83 pass)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The CFunction activate path in FRAME_STATE_ACTIVATE used
`goto continue_chain` to skip the loop-top refresh of frame/fd from
state->currentFrame. When the activateFunc was IoObject_exit, it set
shouldExit=1 and called IoCoroutine_rawResume to swap coroutines;
the nested eval loop then popped all frames on its shouldExit check,
leaving the outer loop's frame/fd pointers dangling into a cleared
frame. The next CONTINUE_CHAIN iteration dereferenced a NULL
fd->message and crashed.

Check state->shouldExit after activateFunc returns and break to
restart the loop so the top-level handler pops any remaining frames.

Reproduced with `CLI version` (inlineMethod body ending in
`System exit`). Fixes #493.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously `true asJson`, `false asJson`, `nil asJson` raised
"does not respond to 'asJson'". Now they return the JSON literal
strings "true", "false", "null".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously Sequence removeAt mutated and returned self, inconsistent with
List removeAt which returns the removed item. Now Sequence removeAt (and
therefore Vector removeAt) returns the element that was removed as a
Number, matching the convention of Sequence at(i). Returns nil if the
index is out of bounds, matching Sequence at's out-of-bounds handling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously 'list(Object clone) asString' produced multiline output with
memory addresses (from Object asString). Mapping each element through
asSimpleString first keeps the list representation on one line and
matches the behavior already used by asSimpleString.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously 'Map with("k", "v") == Map with("k", "v")' returned false
because Map had no compare function and fell through to default
identity comparison. Now Maps with the same keys and equal values
compare as equal, matching the behavior of List.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous doc claimed moveTo raises a nameConflict exception if the
target exists, but the implementation explicitly removes the target
before renaming. Update the doc to describe the actual silent-overwrite
behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
stevedekorte and others added 10 commits April 20, 2026 09:45
tokensForString("()") (and "[]" and "{}") previously returned line 0
for the first token — the synthetic group-name identifier is inserted
before any character has been consumed, so currentLineNumber ran with
'current' sitting exactly on index[0] (the start-of-input sentinel) and
its <= check returned line 0. Lines are 1-based, so clamp the result.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The continuation loop was joining lines with bare string concatenation,
so tokens at the end of one line fused with tokens at the start of
the next. Insert an explicit '\n' between lines so the lexer sees a
separator.

Before:
    Io> a := method(
    ... "hi" print
    ... nil
    ... )
    ==> method("hi" printnil)

After, the body parses as "hi" print ; nil as intended.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Remove the coercion in IoState_runCLI that set exitResult to the value
of the script's final expression when it was a Number. Exit codes now
come only from explicit System exit(n) or from unhandled exceptions
(-1), matching user expectations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Hex/octal literal parsing used unsigned int (32-bit), causing values
above 2^32 to be truncated. Number asString used int (32-bit) for the
integer-form check, so large integers fell into scientific-notation
formatting. Sequence toBase used uintptr_t, which is 32-bit on wasm32.

Switch all three to unsigned long long / long long so integers up to
2^53 (double's exact-integer range) round-trip through literals,
asString, and toBase without loss of precision.

Update NumberTest testAsString: previously expected the buggy
"2.147484e+09" for 2147483648 asString; now expects the correct
decimal "2147483648".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Date_convertToTimeZone_ shifted tv_sec to make localtime() display the
target zone's wall-clock time, which broke asNumber's contract. After
convertToUTC the underlying timestamp no longer matched the unix epoch.

Unix timestamps are timezone-invariant by definition — changing the
timezone of a Date should not change the instant it points to. Drop
the tv_sec shift so tv_sec always holds UTC seconds, and asNumber
now returns the correct value regardless of conversion history.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Object isKindOf used List contains, which compares with ==. For types
that override == with structural equality (List, Map, Sequence), a
fresh clone compared equal to its proto — so `List isKindOf(List clone)`
returned true.

Switch to containsIdenticalTo so ancestor membership is tested by
object identity, which is what prototype lineage actually means.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
IoVMInit.c is generated by io2c from .io files in libs/iovm/io/ via
make regenerate; matches *Io*Init.c* in .gitignore. My previous commit
force-added it; this un-tracks it again.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous README documented a CMake/Eerie/MinGW/MSVC build matrix
and addon install flow — none of which apply to the WASM build. Replace
with a focused reference: wasi-sdk + wasmtime, the Makefile targets
actually used, the WASI --dir= invocation pattern, and a pointer to the
JS bridge for host integration. Keep the language intro, example code,
and quick links; route users who need native builds and dynamic addons
to the legacy native branch / tag.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tighten prose (drop filler phrasing, prefer active voice), restore
standard markdown heading hierarchy, trim redundant REPL output in the
Objects example, and add a dedicated Browser REPL section. Table of
make targets now includes browser, serve, and check-browser.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
README.md: correct the legacy-branch tag name to 2026.04.20-native-final
(matches existing YYYY.MM.DD-suffix convention, e.g.
2026.03.06-stackless-alpha).

ci.yml: replace the CMake-based Unix/Windows matrix with a single
Ubuntu job that installs wasi-sdk + wasmtime and runs `make check`
plus the browser build. Keeps the clang-format lint job unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
stevedekorte and others added 3 commits April 20, 2026 12:52
Prepares wasm for fast-forward into master. Uses the 'ours' merge
strategy because every change on master (bug-fix cherry-picks) is
already represented on wasm with different SHAs — there is no unique
content on master to preserve.

After this commit, PR #495 (wasm → master) becomes fast-forwardable.
IoInstallPrefix.h was previously generated by CMake and gitignored,
so fresh clones in CI couldn't build. The file is a single #define
that has no WASM-specific meaning; commit it directly and drop the
gitignore entry.

Bump DoozyX/clang-format-lint-action v0.12 → v0.17 (v0.12 fails on
current Ubuntu runners because Python 3.12 removed distutils).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
testAsNumber compared stringified NaN values, which varies across
libc / wasi-sdk versions ("nan" vs "NaN" vs "nan(0x...)"). Passes
locally (macOS) but fails under CI's wasi-sdk 24 on Ubuntu. Switch
to isNan checks, which are format-agnostic.

Drop the clang-format lint job: v0.12 is incompatible with modern
Python; v0.17 with clang-format 16 produces hundreds of diffs
against a codebase formatted for clang-format 12. Reformatting is
a separate cleanup, not a merge-day concern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@stevedekorte stevedekorte merged commit 54a537c into master Apr 20, 2026
1 check passed
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.

1 participant