Skip to content

feat: add wasm backend via native sidecar process - #42

Open
tonyfettes wants to merge 2 commits into
mainfrom
wasm-sidecar-backend
Open

feat: add wasm backend via native sidecar process#42
tonyfettes wants to merge 2 commits into
mainfrom
wasm-sidecar-backend

Conversation

@tonyfettes

Copy link
Copy Markdown
Contributor

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); raw INPUT_RECORD streaming 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 — MoonBit Request::encode / Response::read and a cancellation-safe EventStream; the livetest sub-package compiles the real .c with the host cc and exercises handshake/ops/shutdown end to end.
  • *_wasm.mbt — the wasm Tty: 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 (host Fds 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 new RecordRouter shared with the native EventReader.
  • tools/build_sidecar — MoonBit build tool: cross-compiles the sidecar for all three targets with zig cc (downloading and caching zig on demand) and regenerates sidecar_binaries_wasm.mbt.
  • CImoon check --target wasm on all platforms; moon test --target wasm on Unix.

Breaking change

Tty::open (the /dev/tty / CONIN$ path) moved to the new native-only @tty/open sub-package as @tty_open.open(), returning a Terminal wrapper. moon has no per-target imports, so the root package could not keep its @async/raw_fd dependency (native-only) and still check on wasm. The protocol reserves opcode space to bring this to wasm later.

Validation

  • moon check --deny-warn clean on native and wasm; moon fmt / moon info current
  • Native tests 203/203; wasm tests 124/124 under moonrun
  • pty smoke test 6/6 on both targets: isatty=true, initial size 30x100, raw mode on, live resize to 40x120 delivered via sidecar event, key decode, clean exit with no stray sidecar process and the Unix temp binary unlinked

🤖 Generated with Claude Code

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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread sidecar/tty_sidecar.c
Comment on lines +629 to +633
if (!has_original_state) {
original_state = current;
original_input_slot = payload[0];
original_output_slot = payload[1];
has_original_state = 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +241 to +245
if self.console_reader is Some(reader) {
return reader
}
self.watch()
let reader = @win32.StreamEventReader(self.events)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread tty_wasm.mbt
Comment on lines +65 to +68
pub fn Tty::close(self : Self) -> Unit {
match self.backend {
Win32Console(reader) => reader.close()
Ansi(_) => ()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread sidecar/tty_sidecar.c
Comment on lines +638 to +641
if (op_set_state(payload[0], payload[1], &raw) < 0) {
return send_err(req_id, last_error()) < 0 ? -1 : 0;
}
state_dirty = 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread sidecar/tty_sidecar.c
Comment on lines +456 to +459
if (console_thread == NULL) {
console_thread =
CreateThread(NULL, 0, console_input_thread, NULL, 0, NULL);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant