Skip to content

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 .js extensions). The ports/test-helpers/ directory re-exports createWasmBin/createWasiBin from @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 # generated

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

Terminal window
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 -O2

Raw TTY Mode (Asyncify)

Terminal window
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 -O2

Key additions for raw TTY:

  • TTY and Asyncify in EXPORTED_RUNTIME_METHODS
  • -sASYNCIFY enables suspend/resume
  • -sASYNCIFY_STACK_SIZE=65536 allocates stack for async state
  • -sASYNCIFY_IMPORTS lists 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:

Terminal window
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
  1. Add __syscall__newselect to ASYNCIFY_IMPORTS
  2. Link against asyncify-select.js (see The select() Problem)

Flag Reference

FlagPurpose
-sMODULARIZEWrap output in a factory function
-sEXPORT_NAME=createModuleName of the factory
-sEXPORT_ES6=1ESM export (required for our loader)
-sEXPORTED_RUNTIME_METHODSWhich Emscripten internals to expose
-sASYNCIFYEnable Asyncify suspend/resume
-sASYNCIFY_STACK_SIZE=6553664KB for async state (increase if stack overflows)
-sASYNCIFY_IMPORTSJS functions that may trigger suspension
-sFORCE_FILESYSTEMInclude Emscripten’s MEMFS even if not auto-detected
-sALLOW_MEMORY_GROWTHDynamic WASM memory growth
-sEXIT_RUNTIME=1Run atexit handlers, flush stdio
-O2Optimization level (use -O0 -g for debugging)

Environment Note

On macOS with Homebrew, Emscripten’s Node detection can break. Set:

Terminal window
export EM_NODE_JS=$(which node)

Writing index.ts

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:

OptionTypeDefaultPurpose
ttyMode'raw' | 'line''line'Input handling mode
preloadPathsstring[][]Paths to copy from fishbowl FS into MEMFS before main()
envRecord<string, string>undefinedExtra 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 MEMFS

Preload Phase

For each path in preloadPaths:

  1. If it’s a file: read from fishbowl FS, write to MEMFS, snapshot the bytes
  2. If it’s a directory: create in MEMFS, recursively preload all children

Sync-Back Phase

After the program exits:

  1. Compare each preloaded file against its snapshot — write modified files back to fishbowl FS
  2. Scan preloaded directories for new files — write those back too
  3. If a preloaded file was deleted in MEMFS, remove it from fishbowl FS

Common Preload Patterns

Program TypePreload Paths
Editor/tmp, $HOME, file being edited, parent dir of file
Language runtime/tmp, runtime library directory
Filter with configConfig file path
Simple toolNone 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:

  1. Scans packages/ for directories (excluding test-helpers and vim)
  2. Imports each index.ts, calls testSpec()
  3. Skips packages without compiled .wasm files
  4. 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

ByteHexNameCommon Use
\x030x03Ctrl-CInterrupt
\x040x04Ctrl-DEOF
\x0c0x0cCtrl-LRedraw
\x110x11Ctrl-QQuit
\x130x13Ctrl-SSave
\x1b[AESC [ AArrow UpCursor movement
\x1b[BESC [ BArrow Down
\x1b[CESC [ CArrow Right
\x1b[DESC [ DArrow Left
\x7f0x7FDELBackspace

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() returns

Asyncify States

StateValueMeaning
Normal0WASM executing normally
Unwinding1Saving the call stack (suspending)
Rewinding2Restoring 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:

TimeoutBehaviorUse Case
0Non-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
>0Races waitForAvailable() against setTimeout(N). Returns POLLIN_POLLOUT if data arrives first, POLLOUT_ONLY on timeout.select() with finite non-zero timeout
-1Blocks 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:

  1. Phase 1 — Check: Non-blocking scan of all fds via poll(stream, 0) for immediate readiness. Skipped during Asyncify Rewind.
  2. Phase 2 — Return early: If any fd is ready, return immediately (no Asyncify needed).
  3. Phase 3 — Timeout: Compute timeout_ms from the timeval struct. Zero timeout = pure poll, return 0 immediately (critical for vim’s Escape key detection).
  4. Phase 4 — Wait: For non-zero timeouts, call poll(stdinStream, timeout_ms) which delegates to installAsyncPoll’s timeout-aware handleSleep. After wakeUp, re-check all fds with poll(0) and build the result.

The key differences from the default:

  • handleSleep is called via poll() 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:

  1. DEFAULT_POLLMASK for TTY streams. In asyncify-select.js Phase 1, TTY streams were special-cased to always use SYSCALLS.DEFAULT_POLLMASK (which includes POLLIN), bypassing the actual poll override. This meant select() always reported stdin as readable regardless of whether bytes were actually in the InputQueue.

  2. poll() had no non-blocking mode. installAsyncPoll ignored the timeout parameter entirely. When called with timeout=0 (pure poll), it still suspended via Asyncify handleSleep if no input was available, rather than returning immediately with POLLOUT_ONLY.

  3. 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 with push(), 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):

  1. asyncify-select.js: Phase 1 now calls poll(stream, 0) for all streams (including TTY) instead of hardcoding DEFAULT_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’s handleSleep, Unwind check after Phase 4’s poll call.

  2. tty-bridge.ts installAsyncPoll: Respects timeout parameter: 0 = non-blocking (returns immediately, safe during any Asyncify state), >0 = races waitForAvailable() against setTimeout, -1 = blocks forever. The timeout=0 path is checked BEFORE Asyncify state, making it safe to call from within select() during Rewind.

  3. tty-bridge.ts InputQueue: Added waitForAvailable() 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.

  4. runtime.ts pipeStdinToInput: Changed from read(1) to read(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 hooks
const { 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):

  1. Import the package factory in demo/web/main.ts
  2. Add a .catalog() entry to the builder chain
import myTool from '../../ports/my-tool/index'
// ...
.catalog('my-tool', myTool as () => Extension)

For HTTP registry:

  1. Add package.json with fishbowl.assets to the package directory
  2. Use generate-registry to build the bundle and transform the glue
  3. Add the package entry (with assets field) to demo/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/bash
set -euo pipefail
cd "$(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 -O2

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

Terminal window
rustup target add wasm32-wasip1
cargo build --target wasm32-wasip1 --release

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

Terminal window
tools/wasi-asyncify.sh target/wasm32-wasip1/release/my-tool.wasm my-tool.async.wasm

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

No .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:

OptionTypeDefaultPurpose
envRecord<string, string>undefinedExtra environment variables merged on top of proc.env
preopenstring[]['/']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/bash
set -euo pipefail
cd "$(dirname "$0")"
# 1. Compile to wasm32-wasip1
cargo build --target wasm32-wasip1 --release
# 2. Post-process with Asyncify
RELEASE_DIR="target/wasm32-wasip1/release"
../../tools/wasi-asyncify.sh \
"$RELEASE_DIR/my-tool.wasm" \
my-tool.async.wasm
# 3. Copy to package directory
DEST="../../packages/my-wasi-tool"
mkdir -p "$DEST"
cp my-tool.async.wasm "$DEST/my-tool.wasm"

WASI Known Limitations

LimitationDetail
No TTY supportWASI has no termios, ioctl, or raw mode. Use Emscripten for TUI apps.
No socketswasi_snapshot_preview1 has no networking.
No threadsSingle-threaded execution only.
No select/pollWASI has poll_oneoff but no select(). Programs using select() need Emscripten.
Asyncify always requiredEven simple programs need wasm-opt --asyncify because all proc.fs.* is async.

WASI vs Emscripten Quick Reference

AspectEmscriptenWASI
FactorycreateWasmBin(source, wasm, glue, opts)createWasiBin(source, wasm, opts)
ExecutorwasmExec()wasiExec()
File I/OCopy-in/copy-out via MEMFSDirect proc.fs.* routing via WasiHost
AsyncifyCompiled in (-sASYNCIFY)Post-processed (wasm-opt --asyncify)
Asyncify driverEmscripten’s Asyncify wrapperRawAsyncify (binaryen ABI)
Glue file.mjs (required)None
TTYFull (termios, ioctl)Not supported
Rust targetwasm32-unknown-emscriptenwasm32-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:

  1. Which JS import is calling handleSleep?
  2. Is that import’s name in -sASYNCIFY_IMPORTS?
  3. Use -sASYNCIFY_ADVISE to see which functions Emscripten thinks need instrumentation.

Program Hangs (No Output)

  1. Is ttyMode correct? A raw-mode program compiled without -sASYNCIFY will hang on the first read().
  2. Are preload paths correct? Missing files cause silent fopen() failures.
  3. Is select() involved? If so, you need asyncify-select.js.
  4. Build with -O0 -g for 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

  1. Is the file’s parent directory in preloadPaths?
  2. Did the C program actually close the file? (fclose() / close())
  3. New files only sync back if they’re in a preloaded directory. A file created in /foo/bar/ won’t sync unless /foo/bar or /foo was preloaded.