Porting Interactive C Programs to WASM with Emscripten Asyncify
Technical findings from porting vim to run in the browser with full interactive TTY support. These findings apply to any C program that uses blocking I/O (read, select, poll) and terminal escape sequences.
Table of Contents
- Architecture Overview
- How Asyncify Works
- The 11 Bugs
- Build Configuration
- Termcap/Terminfo Stub
- JavaScript Harness
- Post-Build Patches to Emscripten Output
- Checklist for New Ports
Architecture Overview
┌──────────────────────────────────────────────────────┐│ Browser ││ ┌──────────┐ ┌───────────┐ ┌───────────────┐ ││ │ xterm.js │◄──►│ JS Harness│◄──►│ vim.wasm │ ││ │ (render) │ │ (TTY ops) │ │ (Asyncify) │ ││ └──────────┘ └───────────┘ └───────────────┘ ││ ▲ ▲ ▲ ││ │ │ │ ││ User input Custom get_char/ Emscripten FS ││ via keyboard put_char, poll, (MEMFS + TTY) ││ select sleep │└──────────────────────────────────────────────────────┘Key insight: Emscripten’s Asyncify compiler transform allows WASM code to
suspend execution mid-function (unwind the WASM stack), yield to the browser
event loop, and resume later (rewind). This lets blocking C calls like read()
become asynchronous JavaScript promises.
The challenge is that Emscripten’s runtime (FS, TTY, syscall implementations)
was not designed with Asyncify in mind. Several runtime functions make
assumptions that break when get_char or select suspends. Each of the 11
bugs below is a place where this mismatch causes problems.
How Asyncify Works
Asyncify has four states:
| State | Value | Meaning |
|---|---|---|
| Normal | 0 | Ordinary execution |
| Unwinding | 1 | Saving the WASM call stack to memory |
| Rewinding | 2 | Restoring the WASM call stack from memory |
| Disabled | 3 | Asyncify turned off |
The suspend/resume cycle
1. Normal: C code calls read() → fd_read → get_char2. Normal: get_char calls Asyncify.handleSleep(wakeUp => { ... })3. Normal→Unw: handleSleep calls _asyncify_start_unwind() WASM returns up the call stack, saving state to memory4. Unwinding: All JS functions in the call chain see state=1 They must return without side effects5. (idle): Main thread is free. Browser can render, handle events.6. Event: User presses key → wakeUp(byte) called via setTimeout7. Unw→Rew: wakeUp sets state=Rewinding, calls _asyncify_start_rewind() Then calls doRewind() which re-enters the WASM entry point8. Rewinding: WASM re-executes from the top, but Asyncify fast-forwards through the saved call stack until it reaches the sleep point9. Rewinding: When handleSleep is called again during Rewind, it: - Sets state = Normal - Calls _asyncify_stop_rewind() - Returns the saved handleSleepReturnValue10. Normal: Execution continues from where it suspendedCritical rules
-
wakeUpmust usesetTimeout:setTimeout(() => wakeUp(val), 0). Without this,doRewindruns with compiled code still on the stack, which Asyncify forbids. -
Code paths must match: During Rewind, every JS function in the call chain re-executes from the top. If a function called
handleSleepduring Normal, it MUST callhandleSleepduring Rewind too. Any conditional that skipshandleSleepduring Rewind will leave Asyncify stuck in Rewinding state forever. -
Return
nullduring Unwinding: JS functions called from WASM during Unwinding must return quickly without side effects. The WASM stack is being saved; any work done will be lost and repeated during Rewind. -
Check
currData, notstate: AftercallMain()returns,maybeStopUnwind()resetsstateto 0 even if an unwind is in progress. CheckAsyncify.currData !== nullto know if the program suspended.
The 11 Bugs
These bugs were discovered in order during the porting effort. Each one blocked progress until resolved. They fall into three categories: Emscripten runtime issues, termcap issues, and Asyncify interaction issues.
Category 1: Emscripten Runtime
Bug 1: fd_read not marked as async
Symptom: RuntimeError: unreachable when get_char calls handleSleep.
Root cause: Emscripten’s fd_read implementation in libwasi.js lacks the
fd_read__async: true decorator. Without it, Asyncify doesn’t instrument
fd_read, so unwinding from inside it triggers an unreachable instruction.
This is not a bug — Emscripten intentionally doesn’t mark fd_read as async
because most use cases (reading from MEMFS) are synchronous. The __async
annotation adds Asyncify overhead, so it’s opt-in.
Fix: Create a --js-library override file:
addToLibrary({ fd_read__async: true,});Add to LDFLAGS: --js-library asyncify-overrides.js
This uses Emscripten’s addToLibrary mechanism to add the __async decorator
to the existing fd_read function without redefining it. The build system
merges the decorator into LibraryManager.library, and jsifier.mjs picks
it up during code generation, emitting _fd_read.isAsync = true.
Do NOT patch libwasi.js directly — that’s fragile and breaks on emsdk
updates. The --js-library approach is the intended mechanism.
Scope: Any interactive WASM program that does blocking reads via Asyncify
needs this override.
Bug 2: callMain() sets ABORT=true via exitJS()
Symptom: After callMain() returns (due to Asyncify unwind), ABORT is
true. When wakeUp fires, handleSleep’s callback checks if (ABORT) return
and silently drops the rewind.
Root cause: callMain() calls exitJS(ret, implicit=true) after _main
returns. During an Asyncify unwind, _main returns early (saving state), but
exitJS doesn’t know about Asyncify — it just sees a return and shuts down.
Fix: Patch vim.js after each build:
// Before:exitJS(ret, /* implicit = */ true);// After:if (!Asyncify.currData) { exitJS(ret, /* implicit = */ true); }Scope: Affects any WASM module using both callMain() and Asyncify.
Bug 3: ASYNCIFY_IMPORTS needs correct module-qualified names
Symptom: RuntimeError: unreachable at various points in the call chain.
Root cause: WASM imports live in modules (wasi_snapshot_preview1, env).
The ASYNCIFY_IMPORTS list must match the actual import names, which for some
functions include invoke_* wildcards.
Fix: Use the correct import pattern in LDFLAGS:
-sASYNCIFY_IMPORTS=["fd_read","fd_sync","__syscall__newselect","__syscall_ioctl","invoke_*"]Use ASYNCIFY_ADVISE flag to discover which functions need instrumentation.
Category 2: TTY Read Loop
Bug 4: Emscripten’s TTY read loop calls get_char in a tight loop
Symptom: After Asyncify delivers one byte, vim never processes it. A second
get_char call immediately triggers a new unwind while still inside the same
fd_read.
Root cause: TTY.stream_ops.read has a while loop that calls get_char
repeatedly to fill the buffer. After Rewind delivers byte 1, the loop calls
get_char again. This second call triggers a new unwind, but WASM never got
to process the first byte.
Fix: Override TTY.stream_ops.read to return exactly 1 byte:
TTY.stream_ops.read = function(stream, buffer, offset, length, pos) { if (!stream.tty || !stream.tty.ops.get_char) { throw new FS.ErrnoError(60); } var result = stream.tty.ops.get_char(stream.tty); if (result === undefined && length > 0) { throw new FS.ErrnoError(6); // EAGAIN } if (result === null || result === undefined) return 0; buffer[offset] = result; if (stream.node) stream.node.atime = Date.now(); return 1;};Why 1 byte: With a 1-byte read, fd_read returns to WASM in Normal state.
WASM processes the byte, then calls fd_read again for the next one. Each
byte gets its own clean Asyncify cycle.
Bug 5: get_char returns 0 during Unwinding
Symptom: Buffer fills with zero bytes. Vim receives garbage input.
Root cause: handleSleep returns 0 by default (the return value before
wakeUp is called). During Unwinding, get_char returns this 0, which the
read loop treats as a valid byte (NUL character).
Fix: Return null (not 0) from get_char when Asyncify.state === 1
(Unwinding), and also after handleSleep if the state just transitioned to
Unwinding:
get_char(tty) { if (inputQueue.length > 0) return inputQueue.shift(); if (Asyncify.state === 1) return null; // Unwinding const result = Asyncify.handleSleep((wakeUp) => { ... }); if (Asyncify.state === 1) return null; // Just started unwinding return result;}Category 3: select/poll
Bug 6: Default DEFAULT_POLLMASK always reports stdin readable
Symptom: Vim interrupts screen drawing to check for input, triggering an
Asyncify sleep before any screen content is written.
Root cause: Emscripten’s default poll mask for TTY streams is 5
(POLLIN | POLLOUT), meaning stdin always appears readable. Vim’s screen
drawing code calls select() to check for typeahead; always-readable stdin
causes vim to abort drawing and try to read.
Fix: Override stdin’s stream_ops.poll to return POLLIN only when the
input queue has data:
const stdinStream = FS.getStream(0);stdinStream.stream_ops = { ...stdinStream.stream_ops, poll(stream, timeout) { return (inputQueue.length > 0 ? 1 : 0) | 4; // POLLIN conditional, POLLOUT always },};Bug 7: ___syscall__newselect busy-loops, freezing the main thread
Symptom: Page is completely frozen (DevTools times out, no rendering).
Root cause: Emscripten’s ___syscall__newselect implementation calls
stream.poll() synchronously and returns immediately. When poll says “not
readable”, select returns 0 fds ready. Vim loops and calls select again
immediately. This creates a tight synchronous busy-loop — the main thread
never yields, so the browser can’t render or handle events.
Fix: Patch ___syscall__newselect in vim.js to call Asyncify.handleSleep
when no fds are ready, sleeping for up to 50ms:
// After computing total, before return:if (total === 0 && Asyncify.state === 0) { var sleepMs = 50; if (timeout) { var _tv_sec = HEAP32[((timeout)>>2)]; var _tv_usec = HEAP32[(((timeout)+(4))>>2)]; var _tms = (_tv_sec + _tv_usec / 1000000) * 1000; if (_tms === 0) sleepMs = 0; else sleepMs = Math.min(sleepMs, _tms); } if (sleepMs > 0) { return Asyncify.handleSleep(function(wakeUp) { setTimeout(function() { wakeUp(0); }, sleepMs); }); }}This works because __syscall__newselect is already in ASYNCIFY_IMPORTS.
Bug 8: Rewind path mismatch in ___syscall__newselect
Symptom: Asyncify stuck in Rewinding state (state=2) forever after the
first select sleep.
Root cause: During Rewind, WASM re-enters ___syscall__newselect from the
top. The function re-executes: iterates fds, calls poll, computes total. Then
it hits the sleep patch, which checks Asyncify.state === 0. But state is 2
(Rewinding), so handleSleep is skipped and the function returns total
directly. WASM expected to re-enter handleSleep to complete the rewind
cycle — it never does, and Asyncify is stuck.
Fix: Add an explicit Rewinding handler before the Normal-state sleep:
if (Asyncify.state === 2) { // Rewinding: complete the rewind by re-entering handleSleep return Asyncify.handleSleep(function(wakeUp) { setTimeout(function() { wakeUp(total); }, 0); });}During Rewind, handleSleep doesn’t call startAsync — it resets state to
Normal, frees currData, and returns handleSleepReturnValue. The startAsync
callback is irrelevant but must be provided.
General principle: Any JS function that conditionally calls handleSleep
must ensure the same branch is taken during Rewind. The safest pattern:
if (Asyncify.state === 2) { return Asyncify.handleSleep(() => {}); // complete rewind}if (shouldSleep && Asyncify.state === 0) { return Asyncify.handleSleep((wakeUp) => { ... });}Category 4: Termcap/Terminfo
Bug 9: Empty termcap stubs produce no terminal output
Symptom: putCharCount stays at 9 after startup. Vim writes only the
initial terminal mode-setting escapes, then nothing.
Root cause: Initial termcap stub returned empty strings for all
capabilities. Without cm (cursor motion), cl (clear screen), ce
(clear to end of line), etc., vim cannot construct any screen output.
Fix: Implement a full xterm-256color termcap table with ~40 string
capabilities, 4 numeric capabilities, and 6 boolean flags. See
termcap_stub.c for the complete table.
Bug 10: tgoto() doesn’t handle terminfo %p1%d format
Symptom: Screen output contains literal %p21H instead of cursor
positioning sequences.
Root cause: When cross-compiling, vim’s configure can’t run the
tgoto("%p1%d", 0, 1) test binary, so it defaults to vim_cv_terminfo=yes.
This makes vim use its built-in terminal definitions, which use terminfo-style
format strings like \E[%i%p1%d;%p2%dH instead of termcap-style \E[%i%d;%dH.
The %p1 operator means “push parameter 1 onto the stack” and %d means
“pop and print as decimal”. Our tgoto() only handled %d (sequential params)
and treated %p as an unknown format, outputting it literally.
Fix: Add %p handling to both tgoto() and tparm():
case 'p': cap++; if (*cap == '1') pushed = params[0] + increment; else if (*cap == '2') pushed = params[1] + increment; else pushed = 0; break;case 'd': { int val; if (pushed >= 0) { val = pushed; // terminfo: use pushed value pushed = -1; } else { val = params[pi++]; // termcap: sequential val += increment; } snprintf(out, end - out, "%d", val); break;}Key discovery: The cross-compile default is buried in configure:
if test "$cross_compiling" = yes; then vim_cv_terminfo=yes # <-- assumes terminfo when can't testfiYou can override this with vim_cv_terminfo=no when running configure, but
it’s simpler to support both formats in tgoto.
Bug 11: Termcap stubs must be linked as a real library
Symptom: make says “already up to date” after changing termcap_stub.c.
Root cause: Vim’s Makefile doesn’t know about termcap_stub.c — it’s
outside the dependency tree. The termcap stub is compiled to libtermcap.a
and linked via -ltermcap.
Fix: After rebuilding libtermcap.a, delete vim.js and vim.wasm before
running make:
emcc -c termcap_stub.c -o termcap_stub.oemar rcs libtermcap.a termcap_stub.ocd src/src && rm -f vim.js vim.wasm && makeBuild Configuration
Emscripten compile flags (LDFLAGS)
LDFLAGS = \ --js-library asyncify-overrides.js \ -sASYNCIFY \ -sASYNCIFY_STACK_SIZE=1048576 \ '-sASYNCIFY_IMPORTS=["fd_read","fd_sync","__syscall__newselect","__syscall_ioctl","invoke_*"]' \ -sFORCE_FILESYSTEM \ -sMODULARIZE=1 \ -sEXPORT_ES6=1 \ -sEXIT_RUNTIME=1 \ -sALLOW_MEMORY_GROWTH=1 \ '-sEXPORTED_RUNTIME_METHODS=["FS","callMain","ENV","TTY","Asyncify"]' \ -sSTACK_SIZE=4194304 \ -sASSERTIONS=0Where asyncify-overrides.js contains:
// Mark fd_read as async-capable for Asyncify.// Emscripten doesn't do this by default because most reads are synchronous.addToLibrary({ fd_read__async: true,});Flag explanations:
| Flag | Purpose |
|---|---|
ASYNCIFY | Enable the Asyncify transform |
ASYNCIFY_STACK_SIZE=1048576 | 1MB for Asyncify’s unwind/rewind data (default 4096 is too small for vim) |
ASYNCIFY_IMPORTS=[...] | Functions that may suspend. Must include all syscalls in the blocking path |
FORCE_FILESYSTEM | Include Emscripten’s filesystem (MEMFS) even if not auto-detected |
MODULARIZE=1 | Wrap output in a factory function (required for ES module import) |
EXPORT_ES6=1 | Output as ES6 module with export default |
EXIT_RUNTIME=1 | Run cleanup when program exits |
ALLOW_MEMORY_GROWTH=1 | Let WASM memory grow beyond initial allocation |
EXPORTED_RUNTIME_METHODS | Make FS, TTY, Asyncify etc. accessible from JS |
STACK_SIZE=4194304 | 4MB C stack (vim uses significant stack depth) |
ASSERTIONS=0 | Disable runtime assertions for performance |
Configure flags for vim
emconfigure ./configure \ --with-features=normal \ --disable-gui \ --without-x \ --disable-netbeans \ --disable-channel \ --disable-terminal \ --disable-gpm \ --disable-sysmouseEnvironment variables
export EM_NODE_JS=$(which node) # Required: Homebrew node may have broken ICUTermcap/Terminfo Stub
The stub provides three things:
1. Capability tables (xterm-256color compatible)
String capabilities (~40 entries): cursor motion (cm), clear screen
(cl), clear to EOL (ce), insert/delete lines and chars, scroll regions,
text attributes (bold, underline, reverse), cursor visibility, alternate
screen, colors, key definitions (arrows, F-keys, etc.).
Numeric capabilities: co=80 (columns), li=24 (lines), Co=256
(colors), pa=32767 (color pairs).
Boolean capabilities: am (auto margins), xn (newline glitch),
mi/ms (safe to move in insert/standout mode), ut (background color
erase), km (has meta key).
2. Parameter substitution (tgoto and tparm)
Must handle both formats:
- Termcap:
%d= next sequential param,%i= increment,%+c,%. - Terminfo:
%p1= push param 1,%p2= push param 2,%d= pop & print
3. Output (tputs)
Skip leading padding specifications (digits, ., *), then call putc for
each character. Real terminals used padding delays; xterm.js doesn’t need them.
Building
emcc -c termcap_stub.c -o termcap_stub.oemar rcs libtermcap.a termcap_stub.oPlace libtermcap.a in a directory on the library search path (e.g., the
same directory as -L in LDFLAGS).
JavaScript Harness
The browser harness (index.html) has these responsibilities:
Input pipeline
xterm.js onData → deliver(byte) → inputQueue[] │get_char() ← inputQueue.shift() ◄────┘ │ └── if queue empty: Asyncify.handleSleep → waitForByte() → Promisedeliver() either resolves a pending waitForByte promise or pushes to the
queue. This ensures no bytes are lost between Asyncify cycles.
Custom TTY ops
Installed on TTY device makedev(5, 0):
get_char: Returns from queue synchronously, or sleeps via Asyncify. Returnsnullduring Unwinding to break the read loop cleanly.put_char: Forwards each byte toterm.write().ioctl_tcgets/tcsets: Stores/returns termios state in JS (vim uses this to switch between cooked and raw mode).ioctl_tiocgwinsz: Returns[term.rows, term.cols]from xterm.js.
Critical overrides
- 1-byte TTY read (Bug 4): Replace
TTY.stream_ops.readto return exactly 1 byte per call. - Conditional POLLIN (Bug 6): Replace stdin’s
pollto report readable only wheninputQueue.length > 0.
Module initialization sequence
const module = await createVimModule({ noInitialRun: true });const Asyncify = module.Asyncify;const FS = module.FS;const TTY = module.TTY;
// 1. Install custom TTY opsTTY.ttys[FS.makedev(5, 0)].ops = customTtyOps;
// 2. Override TTY.stream_ops.read (1-byte)// 3. Override stdin poll// 4. Create filesystem entries (files, dirs)// 5. Set ENV (HOME, TERM, VIM, VIMRUNTIME)
// 6. Runmodule.callMain(['-u', 'NONE', '/tmp/test.txt']);if (Asyncify.currData) { const result = await Asyncify.whenDone();}Post-Build Patches to Emscripten Output
Two patches must be applied to vim.js after each rebuild:
Patch 1: exitJS guard
// Find:exitJS(ret, /* implicit = */ true);// Replace with:if (!Asyncify.currData) { exitJS(ret, /* implicit = */ true); }Prevents callMain from aborting the runtime when vim is merely suspended
via Asyncify.
Patch 2: select() Asyncify sleep
In ___syscall__newselect, before return total, insert:
// Complete Rewind cycle (MUST come first)if (Asyncify.state === 2) { return Asyncify.handleSleep(function(wakeUp) { setTimeout(function() { wakeUp(total); }, 0); });}// Sleep when no fds ready (prevent busy-loop)if (total === 0 && Asyncify.state === 0) { var sleepMs = 50; if (timeout) { var _tv_sec = HEAP32[((timeout)>>2)]; var _tv_usec = HEAP32[(((timeout)+(4))>>2)]; var _tms = (_tv_sec + _tv_usec / 1000000) * 1000; if (_tms === 0) sleepMs = 0; else sleepMs = Math.min(sleepMs, _tms); } if (sleepMs > 0) { return Asyncify.handleSleep(function(wakeUp) { setTimeout(function() { wakeUp(0); }, sleepMs); }); }}Automation
# Apply after each `make`:cp src/src/vim.js vim-browser/vim.jscp src/src/vim.wasm vim-browser/vim.wasm
# Patch 1: exitJSsed -i '' 's/exitJS(ret, \/\* implicit = \*\/ true);/if (!Asyncify.currData) { exitJS(ret, \/* implicit = *\/ true); }/' vim-browser/vim.js
# Patch 2: select sleep (more complex — use a script or manual edit)Checklist for New Ports
When porting a new interactive C program to WASM with Asyncify:
Build setup
- Create
asyncify-overrides.jswithfd_read__async: trueand add--js-libraryto LDFLAGS - Set
ASYNCIFY_IMPORTSto include all blocking syscalls in the call chain - Use
ASYNCIFY_ADVISEto verify instrumentation coverage - Set
ASYNCIFY_STACK_SIZElarge enough (start with 1MB, increase if you get overflows) - Export
FS,TTY,Asyncify,callMain,ENVviaEXPORTED_RUNTIME_METHODS
Termcap (if the program uses curses/termcap)
- Write a termcap stub with capabilities for your target terminal (xterm-256color)
- Handle both
%d(termcap) and%p1%d(terminfo) intgoto/tparm - If cross-compiling, check if the program defaults to terminfo format
- Compile to static library:
emcc -c stub.c && emar rcs libfoo.a stub.o
JavaScript harness
- Override
TTY.stream_ops.readto return 1 byte (not a buffer-filling loop) - Implement
get_charwith Asyncify.handleSleep for blocking reads - Return
nullfromget_charwhenAsyncify.state === 1(Unwinding) - Return
nullfromget_charafterhandleSleepif state just became 1 - Override stdin poll to return
POLLINonly when data is queued - Use
setTimeout(() => wakeUp(val), 0)— never call wakeUp synchronously
Post-build patches
- Guard
exitJS()withif (!Asyncify.currData) - Patch
___syscall__newselectto sleep via Asyncify when no fds ready - Include Rewind handler (state===2) in the select patch
- After rebuilding, delete old .js/.wasm before
make(force relink)
Testing
- Verify program renders screen content (putCharCount >> 0)
- Verify keyboard input works (getCharCount increments on keypress)
- Verify the page is responsive (not frozen by busy-loop)
- Check Asyncify state returns to Normal (0) after key processing
- Test program exit (
:wq, Ctrl-Q, etc.) — Asyncify state should be 0, currData null
Common failure modes
| Symptom | Likely cause |
|---|---|
RuntimeError: unreachable | Missing ASYNCIFY_IMPORTS entry or fd_read__async |
| Page freezes completely | select() busy-loop (Bug 7) |
| Stuck in Rewinding (state=2) | Rewind path skips handleSleep (Bug 8) |
| No screen output | Missing termcap capabilities (Bug 9) |
| Garbled escape sequences | Wrong termcap format — terminfo vs termcap (Bug 10) |
| Input ignored after first byte | TTY read loop drains Asyncify (Bug 4) |
| Program exits immediately | exitJS called during unwind (Bug 2) |
Porting Rust Programs
Rust programs can target two WASM backends:
-
Emscripten (
wasm32-unknown-emscripten) — for TUI/interactive programs that need raw TTY, termios, or select/poll. Slots into thewasmExec()/createWasmBin()pipeline. The ratatui-counter package demonstrates this. -
WASI (
wasm32-wasip1) — for CLI tools that read stdin, write stdout, and process files. Simpler: no glue file, no MEMFS, WASI syscalls route directly toproc.fs.*. See Porting WASI Programs below.
This section covers the Emscripten path. The ratatui-counter package demonstrates it end-to-end.
Target and toolchain
rustup target add wasm32-unknown-emscriptensource $EMSDK/emsdk_env.sh # Emscripten must be on PATHcargo build --target wasm32-unknown-emscripten --releaseCargo invokes emcc as the linker. Emscripten flags are passed via
.cargo/config.toml (linker flags) and build.rs (for --js-library paths):
[target.wasm32-unknown-emscripten]rustflags = [ "-C", "link-args=-sASYNCIFY -sASYNCIFY_STACK_SIZE=1048576 ...",]// build.rs — pass --js-library with absolute pathfn main() { let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); println!("cargo:rustc-link-arg=--js-library"); println!("cargo:rustc-link-arg={dir}/asyncify-overrides.js");}Rust-specific findings
| Finding | Detail |
|---|---|
No panic = "abort" | Emscripten’s precompiled core crate requires the unwind panic strategy. Remove panic = "abort" from [profile.release]. |
No wasi_snapshot_preview1.fd_read | The Emscripten target uses the env module namespace, not WASI. Omit WASI-qualified imports from ASYNCIFY_IMPORTS. |
ASYNCIFY_IMPORTS for Rust | Use: [fd_read,fd_write,__syscall_ioctl,__asyncjs__*,invoke_*]. Add __syscall__newselect if the program uses select()/poll(). |
build.rs for --js-library | cargo:rustc-link-arg works for passing --js-library to the Emscripten linker. Use CARGO_MANIFEST_DIR for absolute path resolution. |
cfmakeraw may be missing | Use manual termios flag manipulation instead of libc::cfmakeraw() for Emscripten target. |
| Output file naming | Cargo outputs crate-name.js (hyphens) and crate_name.wasm (underscores). The build.sh must handle both. |
| Binary size | With opt-level = "z", lto = true, strip = true, codegen-units = 1: a ratatui TUI app produces a ~297KB .wasm binary. |
Ratatui / TUI programs
Ratatui’s default backend (crossterm) requires threading and /dev/tty, which don’t
work in Emscripten. Instead, implement a custom Backend that:
- Writes ANSI escape codes to stdout via
std::io::stdout() - Reads input via
libc::read(STDIN_FILENO, ..., 1)(triggers Asyncify) - Gets terminal size from
COLUMNS/LINESenv vars (set by fishbowl)
The JsUnixBackend in wasm-builds/ratatui-counter/src/backend.rs is a reusable
reference implementation (~200 lines). Key optimizations:
- Stateful SGR tracking (only emits style changes, not reset-per-cell)
- Unified
write_color()for fg/bg (base offset arithmetic, not duplicated match arms) - RAII
TermGuardfor alternate screen / raw mode cleanup on panic
Checklist for Rust WASM ports
In addition to the C checklist above:
-
Cargo.toml:default-features = falsefor crates with platform-specific backends -
Cargo.toml: Do NOT setpanic = "abort"— Emscripten requiresunwind -
.cargo/config.toml: Emscripten linker flags in[target.wasm32-unknown-emscripten].rustflags -
build.rs: Pass--js-library asyncify-overrides.jswith absolute path -
build.sh: Handle Cargo’s output naming (hyphens in.js, underscores in.wasm) - Verify
cargo check --target wasm32-unknown-emscriptenbefore full build - Package: use
createWasmBin()with.mjsextension (rename.jsglue in build.sh)
Files Reference
wasm-builds/├── vim/│ ├── asyncify-overrides.js # fd_read__async annotation (--js-library)│ ├── termcap_stub.c # Termcap/terminfo implementation│ ├── libtermcap.a # Compiled termcap library│ ├── test_termcap.c # Standalone termcap test│ └── src/ # Vim source tree (configured for emcc)│ └── src/│ ├── auto/config.mk # Build config (LDFLAGS, CC=emcc)│ └── auto/config.h # Feature detection results├── vim-browser/│ ├── index.html # Browser harness (xterm.js + JS bridge)│ ├── vim.js # Emscripten output (with patches applied)│ └── vim.wasm # Compiled WASM binary├── ratatui-counter/│ ├── Cargo.toml # Rust crate (ratatui + libc, no defaults)│ ├── .cargo/config.toml # Emscripten linker flags│ ├── build.rs # --js-library path injection│ ├── asyncify-overrides.js # fd_read__async annotation│ ├── build.sh # Build + copy artifacts to packages/│ └── src/│ ├── main.rs # Counter app (raw mode, alternate screen)│ ├── backend.rs # JsUnixBackend — ratatui Backend via ANSI│ └── input.rs # Raw stdin key reader + escape parsing└── micro-editor/ ├── editor.c # Minimal C TTY test program └── index.html # Simple harness (good starting point)
wasi-builds/├── echo/│ ├── src/main.rs # Rust echo implementation│ ├── Cargo.toml # Standard Rust crate (wasm32-wasip1)│ └── build.sh # cargo build + wasi-asyncify.sh├── cat/│ ├── src/main.rs # Rust cat implementation│ ├── Cargo.toml│ └── build.sh└── env/ ├── src/main.rs # Rust env implementation ├── Cargo.toml └── build.shPorting WASI Programs
For CLI tools that don’t need terminal control, WASI (wasm32-wasip1) is simpler than Emscripten. No glue file, no MEMFS copy-in/copy-out — WASI syscalls route directly to proc.fs.* via the WasiHost adapter.
When to use WASI vs Emscripten
| Need | Use |
|---|---|
| Raw TTY / curses / termios | Emscripten |
| select() / poll() | Emscripten |
| Standard CLI (stdin/stdout/files) | WASI |
| Simplest possible port | WASI |
Quick Start
- Write the Rust program (standard
wasm32-wasip1target):
use std::env;fn main() { let args: Vec<String> = env::args().collect(); for arg in &args[1..] { println!("{}", arg); }}- Compile to wasm32-wasip1:
rustup target add wasm32-wasip1cargo build --target wasm32-wasip1 --release- Post-process with wasm-opt —asyncify (required — all
proc.fs.*is async):
tools/wasi-asyncify.sh target/wasm32-wasip1/release/my-tool.wasm my-tool.async.wasmThe wasi-asyncify.sh script passes all relevant wasi_snapshot_preview1.* imports to wasm-opt --asyncify, including fd_read, fd_write, path_open, fd_readdir, path_filestat_get, fd_close, path_create_directory, path_remove_directory, path_unlink_file, path_rename, poll_oneoff, and sched_yield.
- Create the package (
ports/my-wasi-tool/index.ts):
import type { Extension } from '@fishnet/core/kernel/types'import type { AssetResolver } from '@fishnet/core/runtime/pkg/types'import { createWasiBin } from '../test-helpers/create-wasm-bin'
export default function (resolver?: AssetResolver): Extension { const bin = createWasiBin(resolver ?? import.meta.url, 'my-tool.wasm') return { bins: { 'my-tool': bin } }}- Declare assets in package.json:
{ "name": "@fishnet/my-wasi-tool", "version": "1.0.0", "main": "./index.ts", "fishbowl": { "type": "extension", "bins": ["my-tool"], "assets": ["my-tool.wasm"] }}How WASI differs from Emscripten at runtime
| Aspect | Emscripten | WASI |
|---|---|---|
| File I/O | MEMFS copy-in → program runs → sync-back | proc.fs.* called directly by WASI imports |
| Asyncify | Built into Emscripten compiler (-sASYNCIFY) | Post-processing via wasm-opt --asyncify |
| Asyncify driver | Emscripten’s Asyncify wrapper | RawAsyncify class (binaryen ABI) |
| Glue file | .mjs required | None |
| Asset loading | createWasmBin(source, wasm, glue, opts) | createWasiBin(source, wasm, opts) |
| Executor | wasmExec() | wasiExec() |
| TTY / termios | Full support | Not available (WASI has no termios) |
Known limitations
- No TTY support. WASI
wasi_snapshot_preview1has no termios or ioctl. Programs that need raw mode or cursor control must use Emscripten. - No sockets. WASI preview1 has no networking APIs.
- No threads. Single-threaded execution only.
- Asyncify is always required. Even trivial programs like
echoneedwasm-opt --asyncifybecausefd_writecallsproc.fs.write()which is async.
Future: JSPI
JS Promise Integration (JSPI) will eventually replace Asyncify for WASI binaries. JSPI lets WASM imports return Promises natively, eliminating the need for wasm-opt --asyncify post-processing. Browser support: Chrome 137+, Firefox 139+. When JSPI is widely available, WASI packages can ship un-asyncified .wasm binaries and the runtime will detect and use JSPI automatically.
Checklist for WASI ports
-
Cargo.toml: targetwasm32-wasip1(add viarustup target add wasm32-wasip1) -
build.sh:cargo build --target wasm32-wasip1 --release -
build.sh: Post-process withtools/wasi-asyncify.sh -
index.ts: UsecreateWasiBin()(notcreateWasmBin()) -
package.json:fishbowl.assetslists only.wasm(no.mjs) - Verify the program does NOT need raw TTY (if it does, use Emscripten)
- Test:
vitest test/wasm/passes with the new package