Porting C Programs to fishbowl WASM Packages
How to take a C program, compile it with Emscripten, and ship it as a fishbowl package. Covers both simple line-mode tools and interactive raw-TTY programs.
Quick Start
A minimal WASM package has four files:
Note: WASM/Emscripten ports live under
ports/(outside the pnpm workspace). Imports from core use the@fishnet/core/...package specifier (no.jsextensions). Theports/test-helpers/directory re-exportscreateWasmBin/createWasiBinfrom@fishnet/core/runtime/wasm/.
ports/my-tool/ src/main.c # C source build.sh # emcc build script index.ts # Extension factory + testSpec my-tool.wasm # generated my-tool.mjs # generatedThe Two Modes
Every WASM program falls into one of two categories. The mode determines your build flags, runtime behavior, and testing approach.
Line Mode
For programs that read all stdin upfront, process it, and write to stdout. Filters, compilers, one-shot tools.
How it works: Before main() runs, wasmExec pre-buffers all available stdin into a Uint8Array (with a 50ms timeout to avoid hanging on interactive TTYs). Emscripten’s synchronous module.stdin() callback feeds bytes from this buffer. callMain() runs the program to completion. No Asyncify needed.
When to use: The program does not call tcsetattr(), does not do character-at-a-time reads, does not call select()/poll().
Raw TTY Mode
For interactive programs that read one keystroke at a time: editors, shells, TUI applications.
How it works: wasmExec installs a custom TTY bridge via installTTYBridge(). Each read(STDIN_FILENO, &c, 1) call in C suspends the WASM program via Asyncify, waits for a byte from the InputQueue, then resumes. Output goes through put_char() one byte at a time.
When to use: The program calls tcsetattr() to enter raw mode, or does single-character blocking reads.
Build Flags
Line Mode (no Asyncify)
emcc src/main.c -o my-tool.mjs \ -sMODULARIZE -sEXPORT_NAME=createModule -sEXPORT_ES6=1 \ -sEXPORTED_RUNTIME_METHODS='["callMain","FS","ENV"]' \ -sFORCE_FILESYSTEM -sALLOW_MEMORY_GROWTH -sEXIT_RUNTIME=1 -O2Raw TTY Mode (Asyncify)
emcc src/main.c -o my-tool.mjs \ -sMODULARIZE -sEXPORT_NAME=createModule -sEXPORT_ES6=1 \ -sEXPORTED_RUNTIME_METHODS='["callMain","FS","ENV","TTY","Asyncify"]' \ -sASYNCIFY -sASYNCIFY_STACK_SIZE=65536 \ -sASYNCIFY_IMPORTS='["wasi_snapshot_preview1.fd_read","fd_read","fd_write","__asyncjs__*"]' \ -sFORCE_FILESYSTEM -sALLOW_MEMORY_GROWTH -sEXIT_RUNTIME=1 -O2Key additions for raw TTY:
TTYandAsyncifyinEXPORTED_RUNTIME_METHODS-sASYNCIFYenables suspend/resume-sASYNCIFY_STACK_SIZE=65536allocates stack for async state-sASYNCIFY_IMPORTSlists which JS imports may suspend
Raw TTY Mode with select()/poll()
If your program calls select() or poll() before reading (the pattern vim uses to check for pending input), you need two additional things:
emcc src/main.c -o my-tool.mjs \ # ... same as raw TTY above, plus: -sASYNCIFY_IMPORTS='["wasi_snapshot_preview1.fd_read","fd_read","fd_write","__asyncjs__*","__syscall__newselect"]' \ --js-library ../test-helpers/asyncify-select.js \ # ... rest of flags- Add
__syscall__newselecttoASYNCIFY_IMPORTS - Link against
asyncify-select.js(see The select() Problem)
Flag Reference
| Flag | Purpose |
|---|---|
-sMODULARIZE | Wrap output in a factory function |
-sEXPORT_NAME=createModule | Name of the factory |
-sEXPORT_ES6=1 | ESM export (required for our loader) |
-sEXPORTED_RUNTIME_METHODS | Which Emscripten internals to expose |
-sASYNCIFY | Enable Asyncify suspend/resume |
-sASYNCIFY_STACK_SIZE=65536 | 64KB for async state (increase if stack overflows) |
-sASYNCIFY_IMPORTS | JS functions that may trigger suspension |
-sFORCE_FILESYSTEM | Include Emscripten’s MEMFS even if not auto-detected |
-sALLOW_MEMORY_GROWTH | Dynamic WASM memory growth |
-sEXIT_RUNTIME=1 | Run atexit handlers, flush stdio |
-O2 | Optimization level (use -O0 -g for debugging) |
Environment Note
On macOS with Homebrew, Emscripten’s Node detection can break. Set:
export EM_NODE_JS=$(which node)Writing index.ts
Using createWasmBin (recommended)
The createWasmBin helper handles lazy-loading, caching, Node/browser detection, and wiring up wasmExec:
import type { Extension } from '@fishnet/core/kernel/types'import type { AssetResolver } from '@fishnet/core/runtime/pkg/types'import type { TestSpec } from '../test-helpers/test-spec'import { createWasmBin } from '../test-helpers/create-wasm-bin'
export default function (resolver?: AssetResolver): Extension { const bin = createWasmBin(resolver ?? import.meta.url, 'my-tool.wasm', 'my-tool.mjs', { ttyMode: 'raw', // or omit for 'line' (default) preloadPaths: ['/tmp'], // directories/files to bridge into MEMFS env: { TERM: 'xterm' }, // extra env vars for the C program }) return { bins: { 'my-tool': bin } }}The factory accepts an optional AssetResolver so the package works both host-side (.use() with import.meta.url) and guest-side (pkg install from an HTTP registry with preloaded assets). createWasmBin is called inside the factory so it receives the resolver at call time.
createWasmBin options:
| Option | Type | Default | Purpose |
|---|---|---|---|
ttyMode | 'raw' | 'line' | 'line' | Input handling mode |
preloadPaths | string[] | [] | Paths to copy from fishbowl FS into MEMFS before main() |
env | Record<string, string> | undefined | Extra environment variables |
Seeding Files
If your package needs files to exist in the fishbowl filesystem (config files, runtime data), declare them in the Extension:
const files = { '/usr/share/my-tool/config': 'default_setting=true', '/tmp/scratch.txt': 'initial content',}
export default function (resolver?: AssetResolver): Extension { const bin = createWasmBin(resolver ?? import.meta.url, 'my-tool.wasm', 'my-tool.mjs', { ttyMode: 'raw', preloadPaths: derivePreloadPaths(files), }) return { bins: { 'my-tool': bin }, files, // seeded into fishbowl FS on install }}derivePreloadPaths(files) extracts both file paths and their parent directories from the files map. For { '/tmp/scratch.txt': '...' }, it returns ['/tmp/scratch.txt', '/tmp'].
Manual Asset Loading (complex packages)
For packages like vim that need custom argv parsing, dynamic preload paths, or directory pre-creation, bypass createWasmBin and call wasmExec directly. See packages/vim/index.ts for the pattern.
File Preload and Sync-Back
WASM programs run inside Emscripten’s MEMFS — a separate in-memory filesystem from fishbowl’s. The wasmExec runtime bridges them:
Before main(): fishbowl FS ──preload──> Emscripten MEMFS (program reads/writes here)After main(): fishbowl FS <──sync-back── Emscripten MEMFSPreload Phase
For each path in preloadPaths:
- If it’s a file: read from fishbowl FS, write to MEMFS, snapshot the bytes
- If it’s a directory: create in MEMFS, recursively preload all children
Sync-Back Phase
After the program exits:
- Compare each preloaded file against its snapshot — write modified files back to fishbowl FS
- Scan preloaded directories for new files — write those back too
- If a preloaded file was deleted in MEMFS, remove it from fishbowl FS
Common Preload Patterns
| Program Type | Preload Paths |
|---|---|
| Editor | /tmp, $HOME, file being edited, parent dir of file |
| Language runtime | /tmp, runtime library directory |
| Filter with config | Config file path |
| Simple tool | None needed |
Critical Lesson: Preload Directories, Not Just Files
This was Bug 2 from the vim porting effort. Vim’s $VIMRUNTIME directory was seeded into the fishbowl FS but never added to preloadPaths. The files existed in fishbowl FS but were invisible to the WASM program because they never reached MEMFS.
Rule: If your C program reads files at runtime, those files (or their parent directories) must be in preloadPaths.
Writing Tests
TestSpec
Every package exports a testSpec() function describing how to verify it:
export function testSpec(): TestSpec { return { description: 'What this test verifies', bin: 'my-tool', ttyMode: 'raw', // must match createWasmBin args: ['--flag', 'file.txt'], // argv after the bin name ttyInput: [ // for raw TTY mode { bytes: 'hello', delayMs: 20 }, { bytes: '\x13', delayMs: 20 }, // Ctrl-S { bytes: '\x11' }, // Ctrl-Q ], stdin: 'input data\n', // for line mode (mutually exclusive with ttyInput) expectStdout: ['EXPECTED_OUTPUT'], // substrings that must appear in stdout expectStderr: ['EXPECTED_ERROR'], // substrings that must appear in stderr expectExit: 0, // expected exit code expectFiles: { // verify file contents after exit '/tmp/output.txt': 'expected content', '/tmp/log.txt': /pattern/, // regex also works }, customAssert: async (ctx) => { // escape hatch for complex validation const content = await ctx.readFile('/tmp/data.bin') assert(content.length > 0) }, }}Test Runner Auto-Discovery
The test runner (test/wasm/packages.test.ts) automatically discovers packages:
- Scans
packages/for directories (excludingtest-helpersandvim) - Imports each
index.ts, callstestSpec() - Skips packages without compiled
.wasmfiles - For each package: builds a system, runs
pkg install, spawns the bin, validates output
ttyInput Timing
For raw TTY tests, ttyInput entries are fed as bytes with optional delays:
ttyInput: [ { bytes: 'Hi', delayMs: 20 }, // type two chars after 20ms { bytes: '\x1b[A', delayMs: 20 }, // arrow up (3-byte escape seq) { bytes: '\x13' }, // Ctrl-S (no delay) { bytes: '\x11' }, // Ctrl-Q]Each bytes string is converted to raw byte values via charCodeAt(). Delays simulate real typing cadence, which matters for programs that process input between keystrokes.
Common Control Bytes
| Byte | Hex | Name | Common Use |
|---|---|---|---|
\x03 | 0x03 | Ctrl-C | Interrupt |
\x04 | 0x04 | Ctrl-D | EOF |
\x0c | 0x0c | Ctrl-L | Redraw |
\x11 | 0x11 | Ctrl-Q | Quit |
\x13 | 0x13 | Ctrl-S | Save |
\x1b[A | ESC [ A | Arrow Up | Cursor movement |
\x1b[B | ESC [ B | Arrow Down | |
\x1b[C | ESC [ C | Arrow Right | |
\x1b[D | ESC [ D | Arrow Left | |
\x7f | 0x7F | DEL | Backspace |
The Asyncify Bridge In Detail
Understanding the TTY bridge is essential for debugging interactive WASM programs.
The Problem
C programs do blocking I/O: read(STDIN_FILENO, &c, 1) blocks until a byte arrives. But JavaScript is single-threaded and non-blocking. Emscripten’s Asyncify solves this by saving the entire WASM call stack, returning to JavaScript, and later restoring the stack to resume from where it left off.
The Pipeline
C: read(fd, &c, 1) → Emscripten: fd_read → TTY.stream_ops.read → tty-bridge: get_char(tty) → InputQueue.available() > 0? Return byte immediately → Otherwise: Asyncify.handleSleep(wakeUp => { InputQueue.waitForByte().then(byte => wakeUp(byte)) }) → WASM stack saved, control returns to JS event loop → ... byte arrives from xterm/stdin ... → wakeUp fires → WASM stack restored → read() returnsAsyncify States
| State | Value | Meaning |
|---|---|---|
| Normal | 0 | WASM executing normally |
| Unwinding | 1 | Saving the call stack (suspending) |
| Rewinding | 2 | Restoring the call stack (resuming) |
Every get_char, poll, and ___syscall__newselect override must handle all three states correctly. Getting this wrong causes the bugs described below.
The 1-Byte Read Override
Emscripten’s default TTY.stream_ops.read calls get_char() in a loop to fill the buffer. With Asyncify, the second get_char() call triggers a new suspension before the first one completes — a double-unwind crash.
Fix: install1ByteRead() overrides TTY.stream_ops.read to return exactly 1 byte per call. Each byte gets its own clean Asyncify suspend/resume cycle.
This is installed automatically by installTTYBridge().
The Async Poll Override
Emscripten’s default stream_ops.poll always returns POLLIN | POLLOUT (stdin is always “readable”). Programs that call select()/poll() before reading would busy-loop.
Fix: installAsyncPoll() overrides stdin’s poll with timeout-aware behavior:
| Timeout | Behavior | Use Case |
|---|---|---|
0 | Non-blocking: return immediately. POLLIN_POLLOUT if data available, POLLOUT_ONLY if not. Safe during any Asyncify state (no handleSleep). | select() Phase 1 readiness check, vim Escape detection |
>0 | Races waitForAvailable() against setTimeout(N). Returns POLLIN_POLLOUT if data arrives first, POLLOUT_ONLY on timeout. | select() with finite non-zero timeout |
-1 | Blocks forever via waitForAvailable() + Asyncify handleSleep. | select() with NULL timeout |
Uses waitForAvailable() (non-consuming) instead of waitForByte() to preserve byte ordering in the InputQueue.
This is installed automatically by installTTYBridge().
The select() Problem
Emscripten’s default ___syscall__newselect (which backs the C select() function) calls stream.stream_ops.poll() inside a loop over file descriptors. When our async poll override suspends via handleSleep, the unwind/rewind cycle passes through ___syscall__newselect in a way that doesn’t cleanly restore JS-side loop state. This manifests as hangs or crashes.
This was Bugs 7-8 from the vim porting effort.
The Fix: asyncify-select.js
packages/test-helpers/asyncify-select.js provides a drop-in replacement for ___syscall__newselect that works with Asyncify. It restructures the function into four phases:
- Phase 1 — Check: Non-blocking scan of all fds via
poll(stream, 0)for immediate readiness. Skipped during Asyncify Rewind. - Phase 2 — Return early: If any fd is ready, return immediately (no Asyncify needed).
- Phase 3 — Timeout: Compute timeout_ms from the timeval struct. Zero timeout = pure poll, return 0 immediately (critical for vim’s Escape key detection).
- Phase 4 — Wait: For non-zero timeouts, call
poll(stdinStream, timeout_ms)which delegates toinstallAsyncPoll’s timeout-awarehandleSleep. After wakeUp, re-check all fds withpoll(0)and build the result.
The key differences from the default:
handleSleepis called viapoll()at the top level, not nested inside a loop- Asyncify Rewind skips Phase 1/2 (they’d return early since data arrived), reaching Phase 4 naturally
- Asyncify Unwind is detected after the Phase 4 poll call (return immediately, discarded by Asyncify)
- Zero-timeout select returns 0 without touching Asyncify at all
When You Need It
Link asyncify-select.js if your C program calls any of:
select()pselect()- Any function that internally calls
___syscall__newselect
You do NOT need it for programs that only do blocking read() without checking readiness first.
Known Bugs and Their Test Coverage
These bugs were discovered during the vim porting effort. Each one has a dedicated test package that prevents regression.
Bug: argv Dropped in Raw TTY Mode
Root cause: runWithAsyncify originally called module._main(0, 0), bypassing Emscripten’s argument construction. All argv was lost.
Fix: runWithAsyncify now sets module.noExitRuntime = true and uses module.callMain(args), which properly constructs argc/argv on the WASM heap.
Test: test-argv-raw — Prints argc and argv in raw TTY mode. Verifies ARGV[1]=--flag, ARGV[2]=file.txt.
Bug: Escape Sequences Corrupted by 1-Byte Bridge
Root cause: Arrow keys send 3-byte sequences (\x1b[A). Each byte triggers a separate Asyncify cycle. If the suspend/resume state leaked between cycles, the second or third byte would be lost or corrupted.
Fix: The 1-byte read override and get_char state machine handle each byte independently. The null return during Unwinding prevents phantom zero bytes from entering the buffer.
Test: test-escape-seq — Sends all four arrow keys as escape sequences, verifies each is parsed correctly across three suspend/resume cycles.
Bug: select() Hangs with Asyncify
Root cause: Default ___syscall__newselect calls poll() in a loop. Our async poll suspends via handleSleep inside that loop. The Asyncify rewind re-enters the JS function from the top, but JS local variables (loop index, accumulators) reset — the rewind path doesn’t match the unwind path.
Fix: asyncify-select.js restructures the function to call handleSleep at the top level.
Test: test-select-poll — Calls select() then read() in a loop (the vim pattern). Sends 3 bytes with delays, verifies all arrive through select+read.
Bug: select() with Zero Timeout Always Reports Stdin Ready
Root cause: Three interacting issues conspired to make vim’s Escape key detection fail:
-
DEFAULT_POLLMASK for TTY streams. In
asyncify-select.jsPhase 1, TTY streams were special-cased to always useSYSCALLS.DEFAULT_POLLMASK(which includesPOLLIN), bypassing the actual poll override. This meant select() always reported stdin as readable regardless of whether bytes were actually in the InputQueue. -
poll() had no non-blocking mode.
installAsyncPollignored the timeout parameter entirely. When called with timeout=0 (pure poll), it still suspended via AsyncifyhandleSleepif no input was available, rather than returning immediately withPOLLOUT_ONLY. -
poll() consumed bytes to detect readiness. The poll override used
waitForByte()to wait for input, which consumed the byte from the queue. It then pushed the byte back withpush(), but this appended it after any other bytes that arrived in the same chunk — corrupting multi-byte escape sequence ordering.
Symptom: After reading 0x1B (Escape), vim calls select() with a zero timeout to check if more escape sequence bytes follow. With the bug, select() always returned “stdin readable,” causing vim to call read() which blocked indefinitely waiting for bytes that would never come (bare Escape has no follow-up).
Fix (4 files):
-
asyncify-select.js: Phase 1 now callspoll(stream, 0)for all streams (including TTY) instead of hardcodingDEFAULT_POLLMASK. Phase 3 distinguishes zero timeout (return 0 immediately) from non-zero finite (wait via Asyncify with setTimeout race). Asyncify state handling: Unwind check at top, Rewind skips Phase 1/2 so execution naturally reaches Phase 4’shandleSleep, Unwind check after Phase 4’s poll call. -
tty-bridge.tsinstallAsyncPoll: Respects timeout parameter:0= non-blocking (returns immediately, safe during any Asyncify state),>0= raceswaitForAvailable()againstsetTimeout,-1= blocks forever. The timeout=0 path is checked BEFORE Asyncify state, making it safe to call from within select() during Rewind. -
tty-bridge.tsInputQueue: AddedwaitForAvailable()method that resolves when data appears without consuming any bytes. This replaces the consume-then-push-back pattern in poll, preserving byte ordering for multi-byte sequences. -
runtime.tspipeStdinToInput: Changed fromread(1)toread(4096)so multi-byte escape sequences (e.g.,\x1b[A) arrive atomically in the InputQueue. The underlying Readable may still return 1 byte per call (e.g., browser raw mode), but when multiple bytes are available (e.g., test TTY stdin), they’re delivered as a chunk.
Asyncify state machine in asyncify-select.js — the key insight:
During Asyncify Rewind, the entire __syscall__newselect function is replayed. Phase 1’s poll(stream, 0) uses the timeout=0 fast path (no handleSleep), so it doesn’t interfere with the outer select’s Rewind. But Phase 1 would find data ready (since data arriving is what triggered the Rewind), and Phase 2 would return early — preventing execution from reaching Phase 4’s handleSleep that needs to complete the Rewind. The fix: skip Phase 1/2 during Rewind (rewindActive flag), letting execution flow to Phase 4 where poll(stdinStream, timeout_ms) calls handleSleep to properly complete the Rewind cycle. After Rewind completes, Phase 4’s re-check loop builds the correct fd sets using poll(stream, 0).
Test: test-select-timeout — The exact vim Escape detection pattern: sends bare 0x1B (with 200ms gap), then \x1b[A (as one chunk), then a regular key. Verifies BARE_ESC, ESC_SEQ:0x5b,0x41, and KEY:0x78 are correctly distinguished.
Bug: Heavy Output Corrupts Asyncify State
Root cause: Programs that write large amounts to stdout between reads (like vim’s screen redraws) could overflow Asyncify’s stack or corrupt state if put_char interacted with the suspend machinery.
Fix: put_char is synchronous and doesn’t touch Asyncify state. Output and input are cleanly separated.
Test: test-interleaved-io — Writes 24 rows × 80 columns of output, then blocks on read, repeating 3+ times. Verifies each round-trip completes.
Bug: Preloaded Directory Not Reaching MEMFS
Root cause: Files seeded into the fishbowl FS via Extension.files were never preloaded into Emscripten’s MEMFS. The WASM program couldn’t see them.
Fix: Include the parent directory (or the files themselves) in preloadPaths. Use derivePreloadPaths(files) for automatic extraction.
Test: test-dir-tree — Preloads a nested directory tree (/data/config/, /data/docs/), verifies reads, modifies existing files, creates new files and subdirectories, and validates everything syncs back.
Browser Integration
Raw TTY in the Browser
The browser demo (demo/web/main.ts) uses xterm.js for terminal I/O via the xtermStdio() platform adapter (src/adapters/xterm.ts). Raw TTY mode is handled through the Readable.setRawMode?() capability — no global hooks are needed.
When wasmExec enters raw mode, it calls proc.stdin.setRawMode(true). The xterm adapter responds by switching term.onData from readline dispatch to pushing raw bytes into an AsyncQueue<number>. On setRawMode(false), it closes the byte queue and reverts to readline dispatch.
// Platform adapter pattern — no globalThis hooksconst { stdin, stdout, stderr, getTermSize } = xtermStdio(term, { prompt: ps1 })const handle = await sys.spawn('sh', ['sh'], { stdin, stdout, stderr, getTermSize })See architecture/adapters/README.md for the full adapter design.
Adding Packages to the Demo
WASM packages can be added to the demo via catalog (pre-bundled, no network) or HTTP registry (fetched at runtime). See architecture/packages/wasm-registry.md for the HTTP registry approach.
For catalog (offline/embedded):
- Import the package factory in
demo/web/main.ts - Add a
.catalog()entry to the builder chain
import myTool from '../../ports/my-tool/index'// ... .catalog('my-tool', myTool as () => Extension)For HTTP registry:
- Add
package.jsonwithfishbowl.assetsto the package directory - Use
generate-registryto build the bundle and transform the glue - Add the package entry (with
assetsfield) todemo/web/public/registry/index.json
Either way, the package becomes installable via pkg install my-tool in the browser shell.
Cookbook
Minimal Line-Mode Package
A program that reads stdin, transforms it, writes to stdout:
src/main.c:
#include <stdio.h>int main() { char buf[1024]; while (fgets(buf, sizeof(buf), stdin)) { printf("PROCESSED: %s", buf); } return 0;}build.sh:
#!/bin/bashset -euo pipefailcd "$(dirname "$0")"emcc src/main.c -o my-filter.mjs \ -sMODULARIZE -sEXPORT_NAME=createModule -sEXPORT_ES6=1 \ -sEXPORTED_RUNTIME_METHODS='["callMain","FS","ENV"]' \ -sFORCE_FILESYSTEM -sALLOW_MEMORY_GROWTH -sEXIT_RUNTIME=1 -O2index.ts:
import type { Extension } from '@fishnet/core/kernel/types'import type { AssetResolver } from '@fishnet/core/runtime/pkg/types'import type { TestSpec } from '../test-helpers/test-spec'import { createWasmBin } from '../test-helpers/create-wasm-bin'
export default function (resolver?: AssetResolver): Extension { const bin = createWasmBin(resolver ?? import.meta.url, 'my-filter.wasm', 'my-filter.mjs') return { bins: { 'my-filter': bin } }}
export function testSpec(): TestSpec { return { description: 'Reads stdin, writes transformed output', bin: 'my-filter', stdin: 'hello\nworld\n', expectStdout: ['PROCESSED: hello', 'PROCESSED: world'], expectExit: 0, }}Interactive Raw-TTY Editor
A program that enters raw mode, processes keystrokes, saves files:
src/main.c:
#include <stdio.h>#include <unistd.h>#include <termios.h>
int main(int argc, char *argv[]) { struct termios old, raw; tcgetattr(STDIN_FILENO, &old); raw = old; raw.c_lflag &= ~(ICANON | ECHO); raw.c_cc[VMIN] = 1; raw.c_cc[VTIME] = 0; tcsetattr(STDIN_FILENO, TCSANOW, &raw);
unsigned char c; while (read(STDIN_FILENO, &c, 1) == 1) { if (c == 0x11) break; // Ctrl-Q: quit char buf[32]; int n = snprintf(buf, sizeof(buf), "KEY:0x%02x\n", c); write(STDOUT_FILENO, buf, n); }
write(STDOUT_FILENO, "DONE\n", 5); tcsetattr(STDIN_FILENO, TCSANOW, &old); return 0;}build.sh: Use Raw TTY Mode flags.
index.ts:
import type { Extension } from '@fishnet/core/kernel/types'import type { AssetResolver } from '@fishnet/core/runtime/pkg/types'import type { TestSpec } from '../test-helpers/test-spec'import { createWasmBin } from '../test-helpers/create-wasm-bin'
export default function (resolver?: AssetResolver): Extension { const bin = createWasmBin(resolver ?? import.meta.url, 'my-editor.wasm', 'my-editor.mjs', { ttyMode: 'raw', }) return { bins: { 'my-editor': bin } }}
export function testSpec(): TestSpec { return { description: 'Raw TTY keystroke processing', bin: 'my-editor', ttyMode: 'raw', ttyInput: [ { bytes: 'ab', delayMs: 20 }, { bytes: '\x11' }, // Ctrl-Q ], expectStdout: ['KEY:0x61', 'KEY:0x62', 'DONE'], expectExit: 0, }}Porting WASI Programs (Rust or C via wasi-sdk)
For CLI tools that don’t need terminal control, WASI is the simpler path. No Emscripten glue, no MEMFS — WASI syscalls route directly to proc.fs.*.
Compiling Rust to wasm32-wasip1
rustup target add wasm32-wasip1cargo build --target wasm32-wasip1 --releaseNo Emscripten toolchain needed. Standard Rust wasm32-wasip1 target works out of the box.
Running wasi-asyncify.sh
All WASI binaries must be post-processed with wasm-opt --asyncify because every proc.fs.* method is async. The tools/wasi-asyncify.sh script handles this:
tools/wasi-asyncify.sh target/wasm32-wasip1/release/my-tool.wasm my-tool.async.wasmThe script passes all wasi_snapshot_preview1.* imports that may suspend (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, sched_yield, and others) to wasm-opt --asyncify.
At runtime, the RawAsyncify class (src/runtime/wasm/wasi-asyncify.ts) drives the binaryen Asyncify ABI — unwind, await async work, rewind, resume — in a loop until _start returns without suspending.
WASI Package Structure
A minimal WASI package has three files:
packages/my-wasi-tool/ index.ts # Extension factory my-tool.wasm # wasm32-wasip1 binary (asyncified) package.json # npm metadata + fishbowl fieldNo .mjs glue, no build.sh for Emscripten flags, no termcap stubs.
Writing index.ts for WASI
import type { Extension } from '@fishnet/core/kernel/types'import type { AssetResolver } from '@fishnet/core/runtime/pkg/types'import type { TestSpec } from '../test-helpers/test-spec'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 } }}
export function testSpec(): TestSpec { return { description: 'WASI tool basic functionality', bin: 'my-tool', stdin: 'hello\n', expectStdout: ['hello'], expectExit: 0, }}createWasiBin follows the same dual-overload pattern as createWasmBin:
- Host-side:
createWasiBin(import.meta.url, 'my-tool.wasm', opts?) - Guest-side:
createWasiBin(resolver, 'my-tool.wasm', opts?)
createWasiBin options:
| Option | Type | Default | Purpose |
|---|---|---|---|
env | Record<string, string> | undefined | Extra environment variables merged on top of proc.env |
preopen | string[] | ['/'] | Pre-opened directory paths for WASI filesystem access |
WASI package.json
{ "name": "@fishnet/my-wasi-tool", "version": "1.0.0", "main": "./index.ts", "fishbowl": { "type": "extension", "bins": ["my-tool"], "assets": ["my-tool.wasm"] }}Note: only .wasm in assets — no .mjs glue file.
WASI Build Script Pattern
#!/bin/bashset -euo pipefailcd "$(dirname "$0")"
# 1. Compile to wasm32-wasip1cargo build --target wasm32-wasip1 --release
# 2. Post-process with AsyncifyRELEASE_DIR="target/wasm32-wasip1/release"../../tools/wasi-asyncify.sh \ "$RELEASE_DIR/my-tool.wasm" \ my-tool.async.wasm
# 3. Copy to package directoryDEST="../../packages/my-wasi-tool"mkdir -p "$DEST"cp my-tool.async.wasm "$DEST/my-tool.wasm"WASI Known Limitations
| Limitation | Detail |
|---|---|
| No TTY support | WASI has no termios, ioctl, or raw mode. Use Emscripten for TUI apps. |
| No sockets | wasi_snapshot_preview1 has no networking. |
| No threads | Single-threaded execution only. |
| No select/poll | WASI has poll_oneoff but no select(). Programs using select() need Emscripten. |
| Asyncify always required | Even simple programs need wasm-opt --asyncify because all proc.fs.* is async. |
WASI vs Emscripten Quick Reference
| Aspect | Emscripten | WASI |
|---|---|---|
| Factory | createWasmBin(source, wasm, glue, opts) | createWasiBin(source, wasm, opts) |
| Executor | wasmExec() | wasiExec() |
| File I/O | Copy-in/copy-out via MEMFS | Direct proc.fs.* routing via WasiHost |
| Asyncify | Compiled in (-sASYNCIFY) | Post-processed (wasm-opt --asyncify) |
| Asyncify driver | Emscripten’s Asyncify wrapper | RawAsyncify (binaryen ABI) |
| Glue file | .mjs (required) | None |
| TTY | Full (termios, ioctl) | Not supported |
| Rust target | wasm32-unknown-emscripten | wasm32-wasip1 |
Debugging
Asyncify Crashes: RuntimeError: unreachable
This almost always means a function in the WASM→JS call chain is suspending via handleSleep but isn’t listed in ASYNCIFY_IMPORTS. Check:
- Which JS import is calling
handleSleep? - Is that import’s name in
-sASYNCIFY_IMPORTS? - Use
-sASYNCIFY_ADVISEto see which functions Emscripten thinks need instrumentation.
Program Hangs (No Output)
- Is
ttyModecorrect? A raw-mode program compiled without-sASYNCIFYwill hang on the firstread(). - Are preload paths correct? Missing files cause silent
fopen()failures. - Is
select()involved? If so, you needasyncify-select.js. - Build with
-O0 -gfor better stack traces.
Wrong Exit Code
Emscripten’s exit() throws an ExitStatus exception. wasmExec and runWithAsyncify catch this and extract the status code. If you see unexpected exit codes, check that your C program calls exit() or returns from main() — not abort().
Files Not Syncing Back
- Is the file’s parent directory in
preloadPaths? - Did the C program actually close the file? (
fclose()/close()) - New files only sync back if they’re in a preloaded directory. A file created in
/foo/bar/won’t sync unless/foo/baror/foowas preloaded.