a73x

544ce219

refactor: the wasm ABI drops what nothing reads, and drains what nothing may

a73x   2026-08-13 14:18

Commit message
refactor: the wasm ABI drops what nothing reads, and drains what nothing may

M17 simplify round, core/shell batch: the Zig side. No behaviour change
in the browser — three exports leave, and the pty replies they existed
to expose are now dropped where they are made.

  - mux_apply_frame drains the replica's own pty replies (DSR and
    friends) and throws them away. The daemon is authoritative and
    answers the application itself, so forwarding these would double
    every reply — nothing in the browser may ever read them, and unread
    they accumulated without bound for the life of the page. Drained on
    every path out of a replay. The reply behaviour itself stays pinned
    natively in engine.zig, where it matters.

  - mux_take_pty_output goes with it: a diagnostic export whose only
    consumer was verify.js asserting the accumulation it now cannot
    have.

  - mux_attach_seq/mux_attach_epoch go too. The shell has never called
    them — mux_attach_payload exists precisely so JS never
    hand-assembles a u64 — so they were an interface with one reader,
    the test. verify.js reads both fields out of the payload it
    already decodes, which is the interface the page depends on.

  - keymap: cursorKey and ss3Key differed in one byte, the unmodified
    intro ('[' vs 'O'); modified, both spell the same CSI parameter
    form. One introKey, two one-line wrappers keeping their docs. The
    22-key × 7-modifier table test passes unchanged.

  - wasm_core's failed-grid-move comment named the wrong hazard.
    Nothing goes stale: allocGridBufs is all-or-nothing and the Core
    stays self-consistent at the old geometry. What breaks is the
    agreement with the HOST — mux_cols/mux_rows report the grid that
    MOVED while the readout buffer is the old one's, so a host that
    paints through the -1 builds an oversized DataView. The comment
    says that now, because the -1-is-fatal contract rests on it.

  - verify.js: Object.is compares BigInts by value, so the bigint
    branch in check() was a no-op.

make test green (74 checks, down from 76: the two removed are the
mux_take_pty_output pair); make e2e green (23 scenarios / 35
convergence points, unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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