544ce219
refactor: the wasm ABI drops what nothing reads, and drains what nothing may
a73x 2026-08-13 14:18
Commit message
src/keymap.zig
| Old | New | ||
|---|---|---|---|
| @@ -160,16 +160,22 @@ fn altable(mods: Mods, base: []const u8, buf: []u8) []const u8 { | |||
| 160 | return buf[0 .. n + base.len]; | 160 | return buf[0 .. n + base.len]; |
| 161 | } | 161 | } |
| 162 | 162 | ||
| 163 | /// The two three-byte intro forms, which differ ONLY in the intro byte | ||
| 164 | /// and only while unmodified: modified, both spell the same CSI | ||
| 165 | /// parameter form (SS3 has nowhere to put a parameter). | ||
| 166 | fn introKey(mods: Mods, intro: u8, final: u8, buf: []u8) []const u8 { | ||
| 167 | if (mods.any()) | ||
| 168 | return std.fmt.bufPrint(buf, "\x1b[1;{d}{c}", .{ mods.param(), final }) catch unreachable; | ||
| 169 | buf[0] = 0x1b; | ||
| 170 | buf[1] = intro; | ||
| 171 | buf[2] = final; | ||
| 172 | return buf[0..3]; | ||
| 173 | } | ||
| 174 | |||
| 163 | /// CSI letter form: ESC [ <final>, or ESC [ 1 ; <mods> <final> when | 175 | /// CSI letter form: ESC [ <final>, or ESC [ 1 ; <mods> <final> when |
| 164 | /// modified. | 176 | /// modified. |
| 165 | fn cursorKey(mods: Mods, final: u8, buf: []u8) []const u8 { | 177 | fn cursorKey(mods: Mods, final: u8, buf: []u8) []const u8 { |
| 166 | if (!mods.any()) { | 178 | return introKey(mods, '[', final, buf); |
| 167 | buf[0] = 0x1b; | ||
| 168 | buf[1] = '['; | ||
| 169 | buf[2] = final; | ||
| 170 | return buf[0..3]; | ||
| 171 | } | ||
| 172 | return std.fmt.bufPrint(buf, "\x1b[1;{d}{c}", .{ mods.param(), final }) catch unreachable; | ||
| 173 | } | 179 | } |
| 174 | 180 | ||
| 175 | /// CSI tilde form: ESC [ <n> ~, or ESC [ <n> ; <mods> ~ when modified. | 181 | /// CSI tilde form: ESC [ <n> ~, or ESC [ <n> ; <mods> ~ when modified. |
| @@ -182,13 +188,7 @@ fn tildeKey(mods: Mods, n: u8, buf: []u8) []const u8 { | |||
| 182 | /// SS3 form for F1-F4: ESC O <final> unmodified, CSI 1 ; <mods> <final> | 188 | /// SS3 form for F1-F4: ESC O <final> unmodified, CSI 1 ; <mods> <final> |
| 183 | /// modified (SS3 has nowhere to put a parameter). | 189 | /// modified (SS3 has nowhere to put a parameter). |
| 184 | fn ss3Key(mods: Mods, final: u8, buf: []u8) []const u8 { | 190 | fn ss3Key(mods: Mods, final: u8, buf: []u8) []const u8 { |
| 185 | if (!mods.any()) { | 191 | return introKey(mods, 'O', final, buf); |
| 186 | buf[0] = 0x1b; | ||
| 187 | buf[1] = 'O'; | ||
| 188 | buf[2] = final; | ||
| 189 | return buf[0..3]; | ||
| 190 | } | ||
| 191 | return std.fmt.bufPrint(buf, "\x1b[1;{d}{c}", .{ mods.param(), final }) catch unreachable; | ||
| 192 | } | 192 | } |
| 193 | 193 | ||
| 194 | pub const paste_begin = "\x1b[200~"; | 194 | pub const paste_begin = "\x1b[200~"; |
src/wasm_core.zig
| Old | New | ||
|---|---|---|---|
| @@ -187,6 +187,13 @@ export fn mux_apply_frame(msg_type: u32, len: u32) i32 { | |||
| 187 | // Engine resize failure: the grid the daemon named is beyond us. | 187 | // Engine resize failure: the grid the daemon named is beyond us. |
| 188 | else => return -3, | 188 | else => return -3, |
| 189 | }; | 189 | }; |
| 190 | // Drain and DROP. Replaying can make the replica answer for itself | ||
| 191 | // (DSR and friends), but the daemon is authoritative and answers the | ||
| 192 | // application already — forwarding these would double every reply, so | ||
| 193 | // nothing in the browser may ever read them. Unread, they are an | ||
| 194 | // accumulation with no bound; this is the one place that closes it. | ||
| 195 | // (engine.zig pins the replies themselves, where they matter.) | ||
| 196 | c.rep.eng.clearPtyOutput(); | ||
| 190 | if (applied == .resync) return 1; | 197 | if (applied == .resync) return 1; |
| 191 | 198 | ||
| 192 | // Damage bookkeeping. | 199 | // Damage bookkeeping. |
| @@ -197,8 +204,15 @@ export fn mux_apply_frame(msg_type: u32, len: u32) i32 { | |||
| 197 | const old_dirty = c.dirty; | 204 | const old_dirty = c.dirty; |
| 198 | const old_list = c.dirty_list; | 205 | const old_list = c.dirty_list; |
| 199 | allocGridBufs(c, c.rep.grid.cols, c.rep.grid.rows) catch { | 206 | allocGridBufs(c, c.rep.grid.cols, c.rep.grid.rows) catch { |
| 200 | // Readout buffers gone stale is unrecoverable in-place; the | 207 | // Nothing here is stale — allocGridBufs is all-or-nothing and |
| 201 | // host treats it as fatal and re-inits. | 208 | // publishes no buffer until it has them all, so the Core is |
| 209 | // still self-consistent at the OLD geometry. What broke is | ||
| 210 | // the agreement with the HOST: mux_cols/mux_rows report | ||
| 211 | // rep.grid, which has moved, while the readout buffer is the | ||
| 212 | // old grid's. A host that paints through the -1 builds a | ||
| 213 | // DataView of cols*rows*16 bytes over a smaller allocation. | ||
| 214 | // Hence the contract: -1 here is fatal, re-init and re-attach | ||
| 215 | // (never retry in place). | ||
| 202 | return -1; | 216 | return -1; |
| 203 | }; | 217 | }; |
| 204 | alloc.free(old_viewport); | 218 | alloc.free(old_viewport); |
| @@ -233,16 +247,6 @@ export fn mux_mark_all_dirty() void { | |||
| 233 | // Attach / resume coordinates | 247 | // Attach / resume coordinates |
| 234 | // --------------------------------------------------------------------- | 248 | // --------------------------------------------------------------------- |
| 235 | 249 | ||
| 236 | export fn mux_attach_seq() u64 { | ||
| 237 | const c = core orelse return 0; | ||
| 238 | return c.rep.attachArgs().have_seq; | ||
| 239 | } | ||
| 240 | |||
| 241 | export fn mux_attach_epoch() u64 { | ||
| 242 | const c = core orelse return 0; | ||
| 243 | return c.rep.attachArgs().have_epoch; | ||
| 244 | } | ||
| 245 | |||
| 246 | /// The whole 20-byte attach payload into the output buffer, so JS never | 250 | /// The whole 20-byte attach payload into the output buffer, so JS never |
| 247 | /// hand-assembles a u64. `cols`/`rows` are what this client claims — a | 251 | /// hand-assembles a u64. `cols`/`rows` are what this client claims — a |
| 248 | /// wall tile passes 1x1 (the passivity contract), the zoomed tile its | 252 | /// wall tile passes 1x1 (the passivity contract), the zoomed tile its |
| @@ -526,20 +530,6 @@ export fn mux_dump_plain() i32 { | |||
| 526 | return @intCast(text.len); | 530 | return @intCast(text.len); |
| 527 | } | 531 | } |
| 528 | 532 | ||
| 529 | /// Replica-side terminal replies (DSR etc). DIAGNOSTIC ONLY: the daemon | ||
| 530 | /// is authoritative and answers the application itself; forwarding these | ||
| 531 | /// would double every reply. Cleared on read. | ||
| 532 | export fn mux_take_pty_output() i32 { | ||
| 533 | const c = core orelse return -1; | ||
| 534 | output_len = 0; | ||
| 535 | const data = c.rep.eng.ptyOutput(); | ||
| 536 | if (data.len > output_buf.len) return -3; | ||
| 537 | @memcpy(output_buf[0..data.len], data); | ||
| 538 | output_len = @intCast(data.len); | ||
| 539 | c.rep.eng.clearPtyOutput(); | ||
| 540 | return @intCast(data.len); | ||
| 541 | } | ||
| 542 | |||
| 543 | comptime { | 533 | comptime { |
| 544 | if (!builtin.target.cpu.arch.isWasm()) { | 534 | if (!builtin.target.cpu.arch.isWasm()) { |
| 545 | @compileError("wasm_core.zig is the wasm32 target's root; build it via the mux_core step"); | 535 | @compileError("wasm_core.zig is the wasm32 target's root; build it via the mux_core step"); |
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -19,7 +19,7 @@ const wasmPath = process.argv[2] || | |||
| 19 | 19 | ||
| 20 | let passed = 0, failed = 0; | 20 | let passed = 0, failed = 0; |
| 21 | function check(name, got, want) { | 21 | function check(name, got, want) { |
| 22 | const ok = typeof want === 'bigint' ? got === want : Object.is(got, want); | 22 | const ok = Object.is(got, want); // SameValue compares BigInts by value |
| 23 | if (ok) { passed++; } | 23 | if (ok) { passed++; } |
| 24 | else { failed++; console.error(`FAIL ${name}: got ${got}, want ${want}`); } | 24 | else { failed++; console.error(`FAIL ${name}: got ${got}, want ${want}`); } |
| 25 | } | 25 | } |
| @@ -80,6 +80,16 @@ async function main() { | |||
| 80 | }; | 80 | }; |
| 81 | }; | 81 | }; |
| 82 | 82 | ||
| 83 | // The resume coordinates as the only reader of them has them: the | ||
| 84 | // attach payload. There are no scalar accessors for seq/epoch — the | ||
| 85 | // shell never wanted them (it cannot hand-assemble a u64 anyway), so | ||
| 86 | // the payload IS the interface, and what asserts on it is this. | ||
| 87 | const resumeArgs = () => { | ||
| 88 | e.mux_attach_payload(1, 1, 0); | ||
| 89 | const b = outBytes(); | ||
| 90 | return { seq: b.readBigUInt64LE(4), epoch: b.readBigUInt64LE(12) }; | ||
| 91 | }; | ||
| 92 | |||
| 83 | // --- init --- | 93 | // --- init --- |
| 84 | check('init', e.mux_init(80, 24), 0); | 94 | check('init', e.mux_init(80, 24), 0); |
| 85 | check('cols', e.mux_cols(), 80); | 95 | check('cols', e.mux_cols(), 80); |
| @@ -99,8 +109,8 @@ async function main() { | |||
| 99 | '\x1b[1;31mhello\x1b[0m world', | 109 | '\x1b[1;31mhello\x1b[0m world', |
| 100 | ); | 110 | ); |
| 101 | check('apply snapshot', e.mux_apply_frame(0x81, stage(snap)), 0); | 111 | check('apply snapshot', e.mux_apply_frame(0x81, stage(snap)), 0); |
| 102 | check('seq adopted', e.mux_attach_seq(), 7n); | 112 | check('seq adopted', resumeArgs().seq, 7n); |
| 103 | check('epoch adopted', e.mux_attach_epoch(), 0xabcdn); | 113 | check('epoch adopted', resumeArgs().epoch, 0xabcdn); |
| 104 | check('history', e.mux_history_rows(), 3); | 114 | check('history', e.mux_history_rows(), 3); |
| 105 | check('snapshot dirties all', e.mux_read_viewport(), 24); | 115 | check('snapshot dirties all', e.mux_read_viewport(), 24); |
| 106 | check('cell h', cell(0, 0).cp, 'h'.codePointAt(0)); | 116 | check('cell h', cell(0, 0).cp, 'h'.codePointAt(0)); |
| @@ -116,7 +126,7 @@ async function main() { | |||
| 116 | [[0, '\x1b[7myo\x1b[0m'], [2, 'row two']], | 126 | [[0, '\x1b[7myo\x1b[0m'], [2, 'row two']], |
| 117 | ); | 127 | ); |
| 118 | check('apply delta', e.mux_apply_frame(0x87, stage(delta)), 0); | 128 | check('apply delta', e.mux_apply_frame(0x87, stage(delta)), 0); |
| 119 | check('seq advanced', e.mux_attach_seq(), 8n); | 129 | check('seq advanced', resumeArgs().seq, 8n); |
| 120 | check('history follows', e.mux_history_rows(), 4); | 130 | check('history follows', e.mux_history_rows(), 4); |
| 121 | const ndirty = e.mux_read_viewport(); | 131 | const ndirty = e.mux_read_viewport(); |
| 122 | const dirtyRows = []; | 132 | const dirtyRows = []; |
| @@ -140,7 +150,7 @@ async function main() { | |||
| 140 | const bad = deltaPayload({ seq: 9, history: 4, cx: 0, cy: 0 }, [[0, 'x']]); | 150 | const bad = deltaPayload({ seq: 9, history: 4, cx: 0, cy: 0 }, [[0, 'x']]); |
| 141 | bad.writeUInt16LE(2, 16); // row_count claims 2 | 151 | bad.writeUInt16LE(2, 16); // row_count claims 2 |
| 142 | check('resync', e.mux_apply_frame(0x87, stage(bad)), 1); | 152 | check('resync', e.mux_apply_frame(0x87, stage(bad)), 1); |
| 143 | check('resync holds seq', e.mux_attach_seq(), 8n); | 153 | check('resync holds seq', resumeArgs().seq, 8n); |
| 144 | 154 | ||
| 145 | // --- bad frames --- | 155 | // --- bad frames --- |
| 146 | check('not replay frame', e.mux_apply_frame(0x88, 1), -3); | 156 | check('not replay frame', e.mux_apply_frame(0x88, 1), -3); |
| @@ -212,15 +222,9 @@ async function main() { | |||
| 212 | check('live untouched: full repaint queued', e.mux_read_viewport(), 30); | 222 | check('live untouched: full repaint queued', e.mux_read_viewport(), 30); |
| 213 | check('live cell back', cell(0, 0).cp, 0x6f22); | 223 | check('live cell back', cell(0, 0).cp, 0x6f22); |
| 214 | 224 | ||
| 215 | // --- diagnostics: dump matches, pty replies readable and cleared --- | 225 | // --- diagnostics: the dump is the referee --- |
| 216 | e.mux_dump_plain(); | 226 | e.mux_dump_plain(); |
| 217 | check('dump starts', outBytes().toString('utf8').startsWith('漢字'), true); | 227 | check('dump starts', outBytes().toString('utf8').startsWith('漢字'), true); |
| 218 | stage(Buffer.from('\x1b[6n')); // DSR: replica answers locally (diagnostic only) | ||
| 219 | e.mux_apply_frame(0x81, stage(snapshotPayload( | ||
| 220 | { seq: 12, history: 0, cols: 100, rows: 30, epoch: 0xabcdn }, '\x1b[6n'))); | ||
| 221 | const n = e.mux_take_pty_output(); | ||
| 222 | check('pty reply present', n > 0, true); | ||
| 223 | check('pty reply cleared', e.mux_take_pty_output(), 0); | ||
| 224 | 228 | ||
| 225 | // --- a real-sized snapshot fits the staging buffer --- | 229 | // --- a real-sized snapshot fits the staging buffer --- |
| 226 | // 100x30 grid fully painted with styled cells is well under 256K; the | 230 | // 100x30 grid fully painted with styled cells is well under 256K; the |