src/client/wasm_core.zig
Ref: Size: 22.3 KiB History
//! The browser replica core: Grid + Replica + ClientCore + shared input compiled
//! to wasm32-freestanding. The JS shell is glue; every decision is on this side.
//!
//! FRAME-driven, not byte-driven: the host stages one payload and calls
//! `mux_apply_frame`, so the core sees the same replay the CLI client does.
//!
//! THE JS GOTCHA: the wasm allocator grows linear memory, and growth DETACHES
//! every cached ArrayBuffer view. JS must re-read `exports.memory.buffer` after
//! every call that can allocate — which is any of them.
//!
//! Wire safety: a bad length or type is a return code, never a trap.
const std = @import("std");
const builtin = @import("builtin");
const grid_mod = @import("term").grid;
const Grid = grid_mod.Grid;
const Replica = @import("term").replica.Replica;
const input = @import("input");
const proto = @import("term").protocol;
const client_core = @import("client_core.zig");
/// std.heap.wasm_allocator grows linear memory with @wasmMemoryGrow and
/// needs no libc, no syscalls, no host imports.
const alloc = std.heap.wasm_allocator;
/// No host import to log through, so a panic can only trap.
pub const panic = std.debug.FullPanic(struct {
fn f(_: []const u8, _: ?usize) noreturn {
@trap();
}
}.f);
/// std's default logFn writes to stderr, which on wasm32-freestanding
/// drags in posix.writev/lseek and std.Thread: without this no-op the
/// build fails inside std.
pub const std_options: std.Options = .{
.logFn = struct {
fn f(
comptime _: std.log.Level,
comptime _: @Type(.enum_literal),
comptime _: []const u8,
_: anytype,
) void {}
}.f,
};
const Core = struct {
grid: *Grid,
rep: Replica,
client: client_core.ClientCore = .{},
/// Borrows from input_buf. It is valid only until the host next stages
/// a frame, exactly like ClientCore's payload-borrowing result.
clipboard: client_core.ClipboardSet = .{ .target = 0, .base64 = &.{} },
/// Borrows input_buf: valid only until the host next stages or writes
/// input, or starts another request.
selection: proto.SelectionReply = .{
.id = 0,
.status = .unavailable,
.history_rows = 0,
.text = &.{},
},
/// Geometry the readout buffers are sized for; follows the replica's grid.
cols: u16,
rows: u16,
/// Per-row damage since the last mux_read_viewport. Snapshots and
/// grid moves mark everything; deltas mark their row headers plus
/// the cursor's old and new rows (the renderer draws the cursor).
dirty: []bool,
/// Scratch list the last read filled: the row indices it repainted.
dirty_list: []u32,
dirty_count: u32 = 0,
/// Packed cells, 4 u32 per cell (see mux_viewport_ptr).
viewport: []u32,
cursor_row: u16 = 0,
/// Scrollback view: the rows of the last fetched history chunk, decoded.
/// Never touches the live replica.
scroll_rows: ?[]grid_mod.Row = null,
};
var core: ?*Core = null;
/// Bytes in (frame payloads, paste bytes) cross through this staging
/// buffer: one memcpy from JS, no malloc protocol to get wrong. It must fit
/// both a full snapshot and the protocol's largest selection reply.
var input_buf: [@max(256 * 1024, proto.selection_reply_prefix_len + proto.selection_text_max)]u8 = undefined;
/// Variable-length results out (encoded keys, attach payloads, dumps).
var output_buf: [64 * 1024]u8 = undefined;
var output_len: u32 = 0;
const ClientAction = enum(i32) {
ignored = 0,
terminal_modes = 1,
clipboard = 2,
bell = 3,
selection = 4,
};
fn clearSelectionResult(c: *Core) void {
c.selection = .{ .id = 0, .status = .unavailable, .history_rows = 0, .text = &.{} };
}
fn clearBorrowedInputResults(c: *Core) void {
c.clipboard = .{ .target = 0, .base64 = &.{} };
clearSelectionResult(c);
}
// ---------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------
fn teardown(c: *Core) void {
freeScrollRows(c);
alloc.free(c.viewport);
alloc.free(c.dirty_list);
alloc.free(c.dirty);
c.grid.deinit();
alloc.destroy(c);
}
fn freeScrollRows(c: *Core) void {
if (c.scroll_rows) |rows| grid_mod.freeRows(alloc, rows);
c.scroll_rows = null;
}
fn allocGridBufs(c: *Core, cols: u16, rows: u16) !void {
const cells = @as(usize, cols) * rows;
const viewport = try alloc.alloc(u32, cells * 4);
errdefer alloc.free(viewport);
const dirty = try alloc.alloc(bool, rows);
errdefer alloc.free(dirty);
const dirty_list = try alloc.alloc(u32, rows);
errdefer alloc.free(dirty_list);
c.viewport = viewport;
c.dirty = dirty;
c.dirty_list = dirty_list;
c.cols = cols;
c.rows = rows;
@memset(c.dirty, true);
}
export fn mux_init(cols: u32, rows: u32) i32 {
if (core) |c| {
teardown(c);
core = null;
}
if (cols < 1 or cols > 4096 or rows < 1 or rows > 4096) return -2;
const c = alloc.create(Core) catch return -1;
c.* = .{
.grid = undefined,
.rep = undefined,
.cols = 0,
.rows = 0,
.dirty = &.{},
.dirty_list = &.{},
.viewport = &.{},
};
c.grid = Grid.init(alloc, @intCast(cols), @intCast(rows)) catch {
alloc.destroy(c);
return -2;
};
c.rep = Replica.init(alloc, c.grid);
allocGridBufs(c, @intCast(cols), @intCast(rows)) catch {
c.grid.deinit();
alloc.destroy(c);
return -1;
};
core = c;
return 0;
}
export fn mux_deinit() void {
const c = core orelse return;
teardown(c);
core = null;
}
// ---------------------------------------------------------------------
// Staging and replay
// ---------------------------------------------------------------------
export fn mux_input_ptr() [*]u8 {
if (core) |c| clearBorrowedInputResults(c);
return &input_buf;
}
export fn mux_input_cap() u32 {
return input_buf.len;
}
/// Apply one staged frame payload; only snapshot and delta are replay frames.
/// 0 painted, 1 RESYNC-NEEDED (re-attach quoting 0,0), -1 uninitialized,
/// -2 over the staging cap, -3 not a replay frame or a grid the core refuses.
export fn mux_apply_frame(msg_type: u32, len: u32) i32 {
const c = core orelse return -1;
clearBorrowedInputResults(c);
if (len > input_buf.len) return -2;
if (msg_type > 0xff) return -3;
const t = std.meta.intToEnum(proto.MsgType, @as(u8, @intCast(msg_type))) catch return -3;
if (t != .snapshot and t != .delta) return -3;
const payload = input_buf[0..len];
const cursor_before = c.rep.grid.cursor;
const applied = c.rep.apply(t, payload) catch |err| switch (err) {
// A snapshot that will not decode proves nothing and paints nothing.
error.BadPayload => return -3,
// The destructive one: the grid has been cleared and the seq adopted
// before the bad row. Same -3, and mux.js resets the core on any
// negative return, so the host never reads from the blanked replica.
error.SnapshotAborted => return -3,
// Allocation failure, or a grid the daemon named that is beyond us.
else => return -3,
};
if (applied == .resync) return 1;
// Damage bookkeeping.
if (c.rep.grid.cols != c.cols or c.rep.grid.rows != c.rows) {
// The grid moved: reallocate the readout for the new geometry
// (everything is dirty by construction).
const old_viewport = c.viewport;
const old_dirty = c.dirty;
const old_list = c.dirty_list;
allocGridBufs(c, c.rep.grid.cols, c.rep.grid.rows) catch {
// Nothing here is stale — `allocGridBufs` is all-or-nothing, so the
// Core is self-consistent at the OLD geometry. What broke is the
// agreement with the HOST: `mux_cols`/`mux_rows` have moved while the
// readout buffer has not, so painting through the -1 builds a
// DataView over a smaller allocation. -1 is fatal: re-init.
return -1;
};
alloc.free(old_viewport);
alloc.free(old_dirty);
alloc.free(old_list);
} else switch (t) {
.snapshot => @memset(c.dirty, true),
.delta => {
var it = proto.deltaRowIterator(payload);
while (it.next() catch null) |row| {
if (row.row < c.rows) c.dirty[row.row] = true;
}
// The renderer draws the cursor; both its old and new rows
// need repainting even when no content there changed.
if (cursor_before.y < c.rows) c.dirty[cursor_before.y] = true;
const cur = c.rep.grid.cursor;
if (cur.y < c.rows) c.dirty[cur.y] = true;
},
else => unreachable,
}
return 0;
}
/// Decode one staged daemon frame through the same semantic core used by
/// the native client. Clipboard and selection bytes remain borrowed from
/// input_buf until the host stages or writes the next input; getters never
/// copy or allocate them.
export fn mux_client_frame(msg_type: u32, len: u32) i32 {
const c = core orelse return -1;
clearBorrowedInputResults(c);
if (len > input_buf.len) return -2;
if (msg_type > 0xff) return @intFromEnum(ClientAction.ignored);
// MsgType is deliberately non-exhaustive, so every u8 is a valid enum
// value. Unknown wire bytes reach ClientCore and are ignored safely.
const t: proto.MsgType = @enumFromInt(@as(u8, @intCast(msg_type)));
return switch (c.client.receive(t, input_buf[0..len])) {
.ignored => @intFromEnum(ClientAction.ignored),
.state => |state| switch (state) {
.terminal_modes => @intFromEnum(ClientAction.terminal_modes),
},
.effect => |effect| switch (effect) {
.clipboard_set => |clipboard| blk: {
c.clipboard = clipboard;
break :blk @intFromEnum(ClientAction.clipboard);
},
.bell => @intFromEnum(ClientAction.bell),
},
.reply => |reply| switch (reply) {
.selection => |selection| blk: {
c.selection = selection;
break :blk @intFromEnum(ClientAction.selection);
},
},
};
}
export fn mux_bracketed_paste() u32 {
const c = core orelse return 0;
return @intFromBool(c.client.terminal_modes.bracketed_paste);
}
export fn mux_clipboard_target() u32 {
const c = core orelse return 0;
return c.clipboard.target;
}
export fn mux_clipboard_ptr() [*]const u8 {
const c = core orelse return &input_buf;
if (c.clipboard.base64.len == 0) return &input_buf;
return c.clipboard.base64.ptr;
}
export fn mux_clipboard_len() u32 {
const c = core orelse return 0;
return @intCast(c.clipboard.base64.len);
}
/// Invalid u16 columns refuse without disturbing an existing pending id.
export fn mux_selection_request(
id: u32,
anchor_row: u32,
anchor_col: u32,
active_row: u32,
active_col: u32,
) i32 {
output_len = 0;
const c = core orelse return -1;
clearSelectionResult(c);
if (anchor_col > std.math.maxInt(u16) or active_col > std.math.maxInt(u16)) return -3;
const payload = c.client.beginSelection(.{
.id = id,
.anchor = .{ .row = anchor_row, .col = @intCast(anchor_col) },
.active = .{ .row = active_row, .col = @intCast(active_col) },
});
@memcpy(output_buf[0..payload.len], &payload);
output_len = payload.len;
return @intCast(payload.len);
}
/// wasm exposes this u32 to JS as an i32; normalize with >>> 0 before
/// comparing.
export fn mux_selection_id() u32 {
const c = core orelse return 0;
return c.selection.id;
}
/// Lower than the requester sampled means a page was evicted: absolute
/// rows now name different lines; discard.
export fn mux_selection_history_rows() u32 {
const c = core orelse return 0;
return c.selection.history_rows;
}
export fn mux_selection_status() u32 {
const c = core orelse return @intFromEnum(proto.SelectionStatus.unavailable);
return @intFromEnum(c.selection.status);
}
export fn mux_selection_ptr() [*]const u8 {
const c = core orelse return &input_buf;
if (c.selection.text.len == 0) return &input_buf;
return c.selection.text.ptr;
}
export fn mux_selection_len() u32 {
const c = core orelse return 0;
return @intCast(c.selection.text.len);
}
/// For a canvas the host lost, a scroll-mode exit, or first paint after
/// tab restore.
export fn mux_mark_all_dirty() void {
const c = core orelse return;
@memset(c.dirty, true);
}
// ---------------------------------------------------------------------
// Attach / resume coordinates
// ---------------------------------------------------------------------
/// The whole 20-byte attach payload into the output buffer, so JS never
/// hand-assembles a u64. `cols`/`rows` are what this client CLAIMS, encoded as
/// given — a size refused here would be a second opinion on server.zig's rule.
/// Quotes the replica's resume coordinates; `fresh=1` quotes (0,0) instead.
export fn mux_attach_payload(cols: u32, rows: u32, fresh: u32) i32 {
const c = core orelse return -1;
if (cols > 0xffff or rows > 0xffff) return -3;
const args = if (fresh != 0)
Replica.AttachArgs{ .have_seq = 0, .have_epoch = 0 }
else
c.rep.attachArgs();
const payload = proto.encodeAttach(
@intCast(cols),
@intCast(rows),
args.have_seq,
args.have_epoch,
);
@memcpy(output_buf[0..payload.len], &payload);
output_len = payload.len;
return @intCast(payload.len);
}
// ---------------------------------------------------------------------
// Geometry, cursor, mode
// ---------------------------------------------------------------------
export fn mux_cols() u32 {
const c = core orelse return 0;
return c.rep.grid.cols;
}
export fn mux_rows() u32 {
const c = core orelse return 0;
return c.rep.grid.rows;
}
export fn mux_cursor_x() u32 {
const c = core orelse return 0;
return c.rep.grid.cursor.x;
}
export fn mux_cursor_y() u32 {
const c = core orelse return 0;
return c.rep.grid.cursor.y;
}
export fn mux_history_rows() u32 {
const c = core orelse return 0;
return c.rep.history_rows;
}
/// Screen-space start row for scroll page N (replica.zig's math).
export fn mux_scroll_start(pages_up: u32, view_rows: u32) u32 {
const c = core orelse return 0;
// The browser client still scrolls a page at a time; the replica counts
// rows because the CLI's wheel does not. Saturating, because these
// numbers come from JS.
return c.rep.scrollStart(std.math.mul(u32, pages_up, view_rows) catch std.math.maxInt(u32));
}
// ---------------------------------------------------------------------
// Input encoding
// ---------------------------------------------------------------------
/// `input.Key` by `@intFromEnum`, a table JS mirrors: char=0, enter=1, tab=2,
/// backspace=3, escape=4, up=5, down=6, left=7, right=8, home=9, end=10,
/// insert=11, delete=12, page_up=13, page_down=14, f1..f12=15..26. `mods` is
/// bit0 shift, bit1 alt, bit2 ctrl. Returns bytes written, -3 on an unknown key.
export fn mux_key_encode(key: u32, cp: u32, mods: u32) i32 {
const k = std.meta.intToEnum(input.Key, key) catch return -3;
if (cp > 0x10ffff) return -3;
const ev = input.Event{
.key = k,
.cp = @intCast(cp),
.mods = .{
.shift = mods & 1 != 0,
.alt = mods & 2 != 0,
.ctrl = mods & 4 != 0,
},
};
var buf: [input.max_seq_len]u8 = undefined;
const seq = input.encode(ev, &buf);
@memcpy(output_buf[0..seq.len], seq);
output_len = @intCast(seq.len);
return @intCast(seq.len);
}
/// `len` staged bytes out UNCHANGED. An IME's finished composition is
/// TYPING, not paste: bracketing would tell the application a human did
/// not write it. Paste body chunks pass through here between the markers.
export fn mux_text_encode(len: u32) i32 {
if (core) |c| clearBorrowedInputResults(c);
if (len > input_buf.len or len > output_buf.len) return -2;
@memcpy(output_buf[0..len], input_buf[0..len]);
output_len = len;
return @intCast(len);
}
export fn mux_paste_begin() i32 {
return pasteMarker(input.paste_begin);
}
export fn mux_paste_end() i32 {
return pasteMarker(input.paste_end);
}
/// Each marker on its own: a paste too big for one message is still ONE
/// paste, and wrapping every chunk would put a paste-END mid-text — vim
/// acts on it there. Nothing at all without bracketed paste.
fn pasteMarker(marker: []const u8) i32 {
const c = core orelse {
output_len = 0;
return 0;
};
if (!c.client.terminal_modes.bracketed_paste) {
output_len = 0;
return 0;
}
@memcpy(output_buf[0..marker.len], marker);
output_len = @intCast(marker.len);
return @intCast(marker.len);
}
// ---------------------------------------------------------------------
// Flat viewport readout
// ---------------------------------------------------------------------
/// cols*rows cells, 4 u32 each, row-major; paintRow packs them. Valid
/// until the next call that can grow memory.
export fn mux_viewport_ptr() [*]const u32 {
const c = core orelse return &empty_viewport;
return c.viewport.ptr;
}
/// What an uninitialized core hands out instead of a null pointer JS
/// would happily index into.
var empty_viewport: [4]u32 = .{ 0, 0, 0, 0 };
/// Repaint the DIRTY rows into the viewport buffer, clear the dirty set,
/// and return how many rows were repainted; mux_dirty_row(i) names them.
export fn mux_read_viewport() u32 {
const c = core orelse return 0;
c.dirty_count = 0;
for (c.dirty, 0..) |d, y| {
if (!d) continue;
paintRow(c, c.rep.grid, @intCast(y));
c.dirty_list[c.dirty_count] = @intCast(y);
c.dirty_count += 1;
}
@memset(c.dirty, false);
return c.dirty_count;
}
export fn mux_dirty_row(i: u32) u32 {
const c = core orelse return 0;
if (i >= c.dirty_count) return 0;
return c.dirty_list[i];
}
fn paintRow(c: *Core, g: *const Grid, y: u16) void {
paintRowFrom(c, g.row(y), y);
}
/// One row into the readout buffer JS reads. The colours arrive packed the
/// way the page already decodes them, so nothing is repacked here.
fn paintRowFrom(c: *Core, r: *const grid_mod.Row, y: u16) void {
var x: u16 = 0;
while (x < c.cols) : (x += 1) {
const base = (@as(usize, y) * c.cols + x) * 4;
if (x >= r.cells.len) {
c.viewport[base] = 0;
c.viewport[base + 1] = 0;
c.viewport[base + 2] = 0;
c.viewport[base + 3] = 0;
continue;
}
const cell = r.cells[x];
// The first codepoint of the cell's text; a cell that carries none
// is a blank, which the page draws as an empty cell.
c.viewport[base] = cp: {
if (cell.text_len == 0) break :cp 0;
const text = r.textOf(cell);
const len = std.unicode.utf8ByteSequenceLength(text[0]) catch break :cp 0;
if (len > text.len) break :cp 0;
break :cp std.unicode.utf8Decode(text[0..len]) catch 0;
};
c.viewport[base + 1] = cell.style.fg;
c.viewport[base + 2] = cell.style.bg;
const wide: u32 = switch (cell.wide) {
.narrow => 0,
.wide => 1 << 16,
.spacer_tail, .spacer_head => 1 << 17,
};
// The flag word JS decodes: ghostty's u16 style flags (bit 0 bold,
// 1 italic, 2 faint, 3 blink, 4 inverse, 5 invisible,
// 6 strikethrough, 7 overline, bits 8-10 underline style), then
// wide << 16 and spacer << 17 from the switch above.
c.viewport[base + 3] = @as(u32, cell.style.flags) | wide;
}
}
// ---------------------------------------------------------------------
// Scrollback view (the last fetched chunk, decoded; the live replica is
// never touched)
// ---------------------------------------------------------------------
/// Decode `len` staged bytes: a WHOLE scrollback_chunk payload, echoed
/// header included, because the row count lives in that header and the rows
/// are only self-delimiting once you know how many there are. Replaces
/// whatever the last chunk left. Returns 0, -1 uninit or undecodable,
/// -2 overflow.
export fn mux_scroll_feed(len: u32) i32 {
const c = core orelse return -1;
clearBorrowedInputResults(c);
if (len > input_buf.len) return -2;
if (len < 6) return -1;
const count = std.mem.readInt(u16, input_buf[4..6], .little);
const rows = grid_mod.decodeRows(alloc, input_buf[6..len], count, c.cols) catch return -1;
freeScrollRows(c);
c.scroll_rows = rows;
return 0;
}
/// Paint the WHOLE scratch viewport into the shared viewport buffer and
/// mark everything dirty for the next live read (leaving scroll mode must
/// repaint from the replica). Returns rows painted.
export fn mux_read_scroll_viewport() u32 {
const c = core orelse return 0;
const rows = c.scroll_rows orelse return 0;
var y: u16 = 0;
// A chunk shorter than the viewport is a page near the top of history;
// the rows past it are blank rather than whatever the live grid holds.
while (y < c.rows) : (y += 1) {
if (y < rows.len) paintRowFrom(c, &rows[y], y) else paintRowFrom(c, &blank_row, y);
}
@memset(c.dirty, true);
return c.rows;
}
/// A row with no cells at all: every column past its end reads as blank.
const blank_row: grid_mod.Row = .{ .cells = &.{} };
// ---------------------------------------------------------------------
// Diagnostics
// ---------------------------------------------------------------------
export fn mux_output_ptr() [*]const u8 {
return &output_buf;
}
export fn mux_output_len() u32 {
return output_len;
}
/// Plain-text dump of the live viewport (verify.js's referee). The same text
/// `mux d dump` prints, save for a row's trailing spaces: a client holds none
/// and never did, and `Grid.dumpPlain` says why.
export fn mux_dump_plain() i32 {
const c = core orelse return -1;
output_len = 0;
const text = c.rep.grid.dumpPlain(alloc) catch return -2;
defer alloc.free(text);
if (text.len > output_buf.len) return -3;
@memcpy(output_buf[0..text.len], text);
output_len = @intCast(text.len);
return @intCast(text.len);
}
comptime {
if (!builtin.target.cpu.arch.isWasm()) {
@compileError("wasm_core.zig is the wasm32 target's root; build it via the mux_core step");
}
}