Skip to content

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

  1. Architecture Overview
  2. How Asyncify Works
  3. The 11 Bugs
  4. Build Configuration
  5. Termcap/Terminfo Stub
  6. JavaScript Harness
  7. Post-Build Patches to Emscripten Output
  8. 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:

StateValueMeaning
Normal0Ordinary execution
Unwinding1Saving the WASM call stack to memory
Rewinding2Restoring the WASM call stack from memory
Disabled3Asyncify turned off

The suspend/resume cycle

1. Normal: C code calls read() → fd_read → get_char
2. Normal: get_char calls Asyncify.handleSleep(wakeUp => { ... })
3. Normal→Unw: handleSleep calls _asyncify_start_unwind()
WASM returns up the call stack, saving state to memory
4. Unwinding: All JS functions in the call chain see state=1
They must return without side effects
5. (idle): Main thread is free. Browser can render, handle events.
6. Event: User presses key → wakeUp(byte) called via setTimeout
7. Unw→Rew: wakeUp sets state=Rewinding, calls _asyncify_start_rewind()
Then calls doRewind() which re-enters the WASM entry point
8. Rewinding: WASM re-executes from the top, but Asyncify fast-forwards
through the saved call stack until it reaches the sleep point
9. Rewinding: When handleSleep is called again during Rewind, it:
- Sets state = Normal
- Calls _asyncify_stop_rewind()
- Returns the saved handleSleepReturnValue
10. Normal: Execution continues from where it suspended

Critical rules

  1. wakeUp must use setTimeout: setTimeout(() => wakeUp(val), 0). Without this, doRewind runs with compiled code still on the stack, which Asyncify forbids.

  2. Code paths must match: During Rewind, every JS function in the call chain re-executes from the top. If a function called handleSleep during Normal, it MUST call handleSleep during Rewind too. Any conditional that skips handleSleep during Rewind will leave Asyncify stuck in Rewinding state forever.

  3. Return null during 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.

  4. Check currData, not state: After callMain() returns, maybeStopUnwind() resets state to 0 even if an unwind is in progress. Check Asyncify.currData !== null to 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:

asyncify-overrides.js
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:

Terminal window
if test "$cross_compiling" = yes; then
vim_cv_terminfo=yes # <-- assumes terminfo when can't test
fi

You 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:

Terminal window
emcc -c termcap_stub.c -o termcap_stub.o
emar rcs libtermcap.a termcap_stub.o
cd src/src && rm -f vim.js vim.wasm && make

Build 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=0

Where 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:

FlagPurpose
ASYNCIFYEnable the Asyncify transform
ASYNCIFY_STACK_SIZE=10485761MB 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_FILESYSTEMInclude Emscripten’s filesystem (MEMFS) even if not auto-detected
MODULARIZE=1Wrap output in a factory function (required for ES module import)
EXPORT_ES6=1Output as ES6 module with export default
EXIT_RUNTIME=1Run cleanup when program exits
ALLOW_MEMORY_GROWTH=1Let WASM memory grow beyond initial allocation
EXPORTED_RUNTIME_METHODSMake FS, TTY, Asyncify etc. accessible from JS
STACK_SIZE=41943044MB C stack (vim uses significant stack depth)
ASSERTIONS=0Disable runtime assertions for performance

Configure flags for vim

Terminal window
emconfigure ./configure \
--with-features=normal \
--disable-gui \
--without-x \
--disable-netbeans \
--disable-channel \
--disable-terminal \
--disable-gpm \
--disable-sysmouse

Environment variables

Terminal window
export EM_NODE_JS=$(which node) # Required: Homebrew node may have broken ICU

Termcap/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

Terminal window
emcc -c termcap_stub.c -o termcap_stub.o
emar rcs libtermcap.a termcap_stub.o

Place 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() → Promise

deliver() 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. Returns null during Unwinding to break the read loop cleanly.
  • put_char: Forwards each byte to term.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. 1-byte TTY read (Bug 4): Replace TTY.stream_ops.read to return exactly 1 byte per call.
  2. Conditional POLLIN (Bug 6): Replace stdin’s poll to report readable only when inputQueue.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 ops
TTY.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. Run
module.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

Terminal window
# Apply after each `make`:
cp src/src/vim.js vim-browser/vim.js
cp src/src/vim.wasm vim-browser/vim.wasm
# Patch 1: exitJS
sed -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.js with fd_read__async: true and add --js-library to LDFLAGS
  • Set ASYNCIFY_IMPORTS to include all blocking syscalls in the call chain
  • Use ASYNCIFY_ADVISE to verify instrumentation coverage
  • Set ASYNCIFY_STACK_SIZE large enough (start with 1MB, increase if you get overflows)
  • Export FS, TTY, Asyncify, callMain, ENV via EXPORTED_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) in tgoto/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.read to return 1 byte (not a buffer-filling loop)
  • Implement get_char with Asyncify.handleSleep for blocking reads
  • Return null from get_char when Asyncify.state === 1 (Unwinding)
  • Return null from get_char after handleSleep if state just became 1
  • Override stdin poll to return POLLIN only when data is queued
  • Use setTimeout(() => wakeUp(val), 0) — never call wakeUp synchronously

Post-build patches

  • Guard exitJS() with if (!Asyncify.currData)
  • Patch ___syscall__newselect to 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

SymptomLikely cause
RuntimeError: unreachableMissing ASYNCIFY_IMPORTS entry or fd_read__async
Page freezes completelyselect() busy-loop (Bug 7)
Stuck in Rewinding (state=2)Rewind path skips handleSleep (Bug 8)
No screen outputMissing termcap capabilities (Bug 9)
Garbled escape sequencesWrong termcap format — terminfo vs termcap (Bug 10)
Input ignored after first byteTTY read loop drains Asyncify (Bug 4)
Program exits immediatelyexitJS called during unwind (Bug 2)

Porting Rust Programs

Rust programs can target two WASM backends:

  1. Emscripten (wasm32-unknown-emscripten) — for TUI/interactive programs that need raw TTY, termios, or select/poll. Slots into the wasmExec() / createWasmBin() pipeline. The ratatui-counter package demonstrates this.

  2. WASI (wasm32-wasip1) — for CLI tools that read stdin, write stdout, and process files. Simpler: no glue file, no MEMFS, WASI syscalls route directly to proc.fs.*. See Porting WASI Programs below.

This section covers the Emscripten path. The ratatui-counter package demonstrates it end-to-end.

Target and toolchain

Terminal window
rustup target add wasm32-unknown-emscripten
source $EMSDK/emsdk_env.sh # Emscripten must be on PATH
cargo build --target wasm32-unknown-emscripten --release

Cargo invokes emcc as the linker. Emscripten flags are passed via .cargo/config.toml (linker flags) and build.rs (for --js-library paths):

.cargo/config.toml
[target.wasm32-unknown-emscripten]
rustflags = [
"-C", "link-args=-sASYNCIFY -sASYNCIFY_STACK_SIZE=1048576 ...",
]
// build.rs — pass --js-library with absolute path
fn 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

FindingDetail
No panic = "abort"Emscripten’s precompiled core crate requires the unwind panic strategy. Remove panic = "abort" from [profile.release].
No wasi_snapshot_preview1.fd_readThe Emscripten target uses the env module namespace, not WASI. Omit WASI-qualified imports from ASYNCIFY_IMPORTS.
ASYNCIFY_IMPORTS for RustUse: [fd_read,fd_write,__syscall_ioctl,__asyncjs__*,invoke_*]. Add __syscall__newselect if the program uses select()/poll().
build.rs for --js-librarycargo:rustc-link-arg works for passing --js-library to the Emscripten linker. Use CARGO_MANIFEST_DIR for absolute path resolution.
cfmakeraw may be missingUse manual termios flag manipulation instead of libc::cfmakeraw() for Emscripten target.
Output file namingCargo outputs crate-name.js (hyphens) and crate_name.wasm (underscores). The build.sh must handle both.
Binary sizeWith 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/LINES env 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 TermGuard for alternate screen / raw mode cleanup on panic

Checklist for Rust WASM ports

In addition to the C checklist above:

  • Cargo.toml: default-features = false for crates with platform-specific backends
  • Cargo.toml: Do NOT set panic = "abort" — Emscripten requires unwind
  • .cargo/config.toml: Emscripten linker flags in [target.wasm32-unknown-emscripten].rustflags
  • build.rs: Pass --js-library asyncify-overrides.js with absolute path
  • build.sh: Handle Cargo’s output naming (hyphens in .js, underscores in .wasm)
  • Verify cargo check --target wasm32-unknown-emscripten before full build
  • Package: use createWasmBin() with .mjs extension (rename .js glue 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.sh

Porting 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

NeedUse
Raw TTY / curses / termiosEmscripten
select() / poll()Emscripten
Standard CLI (stdin/stdout/files)WASI
Simplest possible portWASI

Quick Start

  1. Write the Rust program (standard wasm32-wasip1 target):
src/main.rs
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
for arg in &args[1..] {
println!("{}", arg);
}
}
  1. Compile to wasm32-wasip1:
Terminal window
rustup target add wasm32-wasip1
cargo build --target wasm32-wasip1 --release
  1. Post-process with wasm-opt —asyncify (required — all proc.fs.* is async):
Terminal window
tools/wasi-asyncify.sh target/wasm32-wasip1/release/my-tool.wasm my-tool.async.wasm

The 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.

  1. 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 } }
}
  1. 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

AspectEmscriptenWASI
File I/OMEMFS copy-in → program runs → sync-backproc.fs.* called directly by WASI imports
AsyncifyBuilt into Emscripten compiler (-sASYNCIFY)Post-processing via wasm-opt --asyncify
Asyncify driverEmscripten’s Asyncify wrapperRawAsyncify class (binaryen ABI)
Glue file.mjs requiredNone
Asset loadingcreateWasmBin(source, wasm, glue, opts)createWasiBin(source, wasm, opts)
ExecutorwasmExec()wasiExec()
TTY / termiosFull supportNot available (WASI has no termios)

Known limitations

  • No TTY support. WASI wasi_snapshot_preview1 has 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 echo need wasm-opt --asyncify because fd_write calls proc.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: target wasm32-wasip1 (add via rustup target add wasm32-wasip1)
  • build.sh: cargo build --target wasm32-wasip1 --release
  • build.sh: Post-process with tools/wasi-asyncify.sh
  • index.ts: Use createWasiBin() (not createWasmBin())
  • package.json: fishbowl.assets lists 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