feat: add wasm backend via native sidecar process - #42
Conversation
Make the library work on the wasm backend (moonrun), which has no libc/syscall access, by proxying terminal syscalls to a small C sidecar process over a binary protocol on loopback TCP: - sidecar/tty_sidecar.c: single-file C99 sidecar (isatty, raw mode, window size, SIGWINCH resize events on Unix, raw INPUT_RECORD streaming on Windows); restores terminal state on disconnect - sidecar/PROTOCOL.md: wire protocol spec (handshake + framed ops on a control connection, push-only event connection) - internal/sidecar: MoonBit protocol encoder/decoder and cancellation-safe EventStream; livetest sub-package compiles the real sidecar and exercises it end to end - *_wasm.mbt: wasm Tty implementation — extracts the embedded sidecar binary for the host (linux/x86_64, macos/aarch64, windows/x86_64), spawns it, and routes isatty/raw-mode/window-size/resize/input through it; Windows input reuses the win32 record decoding via a new RecordRouter shared with the native EventReader - tools/build_sidecar: MoonBit build tool that cross-compiles the sidecar with zig cc (downloaded and cached on demand) and regenerates sidecar_binaries_wasm.mbt - BREAKING: Tty::open moved to the native-only @tty/open sub-package (returns a Terminal wrapper), because moon has no per-target imports and @async/raw_fd is native-only - CI: wasm check on all platforms, wasm tests on Unix Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 85d197a0ef
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let dir = @async/fs.tmpdir(prefix="tty-sidecar-test") | ||
| let binary = "\{dir}/tty-sidecar" | ||
| let code = @async/process.run("cc", [ | ||
| "-std=c99", |
There was a problem hiding this comment.
Define POSIX feature macros for the C99 build
On Ubuntu/glibc, this exact cc -std=c99 command fails because strict ISO C99 hides struct sigaction, sigemptyset, and SA_RESTART unless a POSIX feature-test macro is defined. I ran the command against this commit and it failed at install_sigwinch, so the Ubuntu moon test CI job cannot reach the new livetests. Define an appropriate _POSIX_C_SOURCE before the C headers or compile this test as GNU C99.
AGENTS.md reference: AGENTS.md:L86-L91
Useful? React with 👍 / 👎.
| if (!has_original_state) { | ||
| original_state = current; | ||
| original_input_slot = payload[0]; | ||
| original_output_slot = payload[1]; | ||
| has_original_state = 1; |
There was a problem hiding this comment.
Capture raw state for each nesting level
When wasm callers nest with_raw_mode, only the outer call's cooked state is captured because has_original_state remains set forever. The inner leave_raw_mode therefore restores cooked mode while the outer callback is still executing, and later sessions also restore the first-ever snapshot rather than the state captured on entry. This violates the documented invariant in docs/architecture.md that nested callers restore their own captured states; the sidecar needs per-entry state/refcount semantics rather than one permanent snapshot.
AGENTS.md reference: AGENTS.md:L13-L15
Useful? React with 👍 / 👎.
| if self.console_reader is Some(reader) { | ||
| return reader | ||
| } | ||
| self.watch() | ||
| let reader = @win32.StreamEventReader(self.events) |
There was a problem hiding this comment.
Serialize Windows console-reader initialization
On Windows wasm, two concurrent Tty::stdio() or Tty::new() calls can both observe console_reader == None, suspend in watch(), and then create different StreamEventReaders over the same mutable EventStream. Reads through those handles can concurrently mutate the shared frame buffer, causing lost or corrupted console events. Protect this lazy initialization with a lock and ensure every caller receives the same reader.
Useful? React with 👍 / 👎.
| pub fn Tty::close(self : Self) -> Unit { | ||
| match self.backend { | ||
| Win32Console(reader) => reader.close() | ||
| Ansi(_) => () |
There was a problem hiding this comment.
Keep the shared Windows event reader alive across Tty closes
On Windows wasm, reader is the process-wide instance cached by Sidecar::console_event_reader, so closing one Tty closes the router queues for every other Tty and for all subsequently created handles, which receive the same already-closed reader. Their next read_event returns None and fails even though their stdio remains open. The shared reader needs ownership/reference management or must live for the sidecar's lifetime.
Useful? React with 👍 / 👎.
| if (op_set_state(payload[0], payload[1], &raw) < 0) { | ||
| return send_err(req_id, last_error()) < 0 ? -1 : 0; | ||
| } | ||
| state_dirty = 1; |
There was a problem hiding this comment.
Roll back partially applied Windows raw mode
On Windows, op_set_state changes the input console mode before setting the output mode. If the output SetConsoleMode fails, this branch returns an error before setting state_dirty, so neither LEAVE_RAW nor disconnect cleanup restores the input mode and the terminal can remain raw after a failed enter_raw_mode. Roll back to current on failure, as the native implementation does, or mark restoration necessary before applying either mode.
Useful? React with 👍 / 👎.
The root package kept only the protocol-to-Tty mapping; the sidecar transport and lifecycle (Sidecar handle, launch/handshake/singleton, embedded binaries, stdio slot mapping) now live in the new internal/sidecar/client package. Root imports for process/socket/utf8 move with it; tools/build_sidecar regenerates the blob at its new path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2d86ee3040
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (console_thread == NULL) { | ||
| console_thread = | ||
| CreateThread(NULL, 0, console_input_thread, NULL, 0, NULL); | ||
| } |
There was a problem hiding this comment.
Report failures to start the console reader thread
On Windows wasm, if CreateThread fails—for example under memory or handle exhaustion—start_watching leaves console_thread as NULL, but the OP_WATCH handler still acknowledges success and the client permanently marks watching as enabled. No task then consumes INPUT_RECORDs, so subsequent Tty::read_event calls can wait indefinitely without retrying initialization. Check the returned handle and propagate GetLastError through the WATCH response.
Useful? React with 👍 / 👎.
Summary
Makes the library work on the wasm backend (wasm1 under moonrun), which has no libc/syscall access, by proxying terminal syscalls to a small native sidecar process over a binary protocol on loopback TCP.
sidecar/tty_sidecar.c— single-file C99 sidecar: isatty, enter/leave raw mode, window size; SIGWINCH-driven resize events on Unix (coalesced on a non-blocking socket so a slow parent can never stall it); rawINPUT_RECORDstreaming from a dedicated console thread on Windows (full input fidelity, no VT fallback). Captures original termios/console modes on first raw-mode entry and restores them on disconnect, so a crashed parent never leaves the terminal broken.sidecar/PROTOCOL.md— the wire spec: token handshake, framed request/response ops on a control connection, push-only event connection.internal/sidecar— MoonBitRequest::encode/Response::readand a cancellation-safeEventStream; thelivetestsub-package compiles the real.cwith the hostccand exercises handshake/ops/shutdown end to end.*_wasm.mbt— the wasmTty: extracts the embedded sidecar binary for the host (linux/x86_64, macos/aarch64, windows/x86_64), spawns it orphaned with inherited stdio, addresses terminals by stdio slot (hostFds are opaque and can't cross the process boundary), and routes isatty / raw mode / window size / resize / Windows console input through it. Windows input reuses the existing win32 decoding via a newRecordRoutershared with the nativeEventReader.tools/build_sidecar— MoonBit build tool: cross-compiles the sidecar for all three targets withzig cc(downloading and caching zig on demand) and regeneratessidecar_binaries_wasm.mbt.moon check --target wasmon all platforms;moon test --target wasmon Unix.Breaking change
Tty::open(the/dev/tty/CONIN$path) moved to the new native-only@tty/opensub-package as@tty_open.open(), returning aTerminalwrapper. moon has no per-target imports, so the root package could not keep its@async/raw_fddependency (native-only) and still check on wasm. The protocol reserves opcode space to bring this to wasm later.Validation
moon check --deny-warnclean on native and wasm;moon fmt/moon infocurrent🤖 Generated with Claude Code