a73x

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");
    }
}