a73x

002d1f76

fix(web): headFrame overflowed on a wire-declared length; bound the replay retries

a73x   2026-08-13 12:52

Commit message
fix(web): headFrame overflowed on a wire-declared length; bound the replay retries

Review round 2. The first item is mine, introduced by the commit that
added headFrame two commits ago.

`off + payload_len > capacity` takes payload_len straight off the wire.
A 14-byte message declaring 0xFFFF_FFFF_FFFF_FFFF overflows the addition:
a panic in Debug — which is the DEFAULT build — and in ReleaseFast a wrap
to a permanent .incomplete, so the tile waits forever for a frame that
was never coming. One message per tile was enough, from anything that got
past the Origin gate. Written as a subtraction on the right now, with
`capacity < off` guarded because headFrame is pub and nothing promises a
caller's buffer outgrows a 14-byte header. Pinned at u64 max, at
maxInt-13, at the capacity edge either side, and at capacities below the
header itself; confirmed by restoring the old arithmetic and watching the
new pins panic.

The capacity check also earned a proper comment. It is not tidiness: it
is what keeps fillMore off a full buffer. fillMore calls
rebase(bufferedLen + 1) and std's defaultRebase asserts on exit that
`buffer.len - seek >= capacity`, which cannot hold once the buffer is
full — so saying .too_big BEFORE a frame can fill the buffer is the thing
standing between a hostile length and an assert.

The rest of the round:

  - Both new recovery paths in mux.js were unbounded retry loops. A frame
    over the staging cap and a core that refuses to apply both answered
    with a re-attach, and the daemon answers a re-attach with the same
    snapshot that just failed. Bounded now on the machinery the socket's
    own reconnect already had — same schedule, same ceiling — after which
    the tile lands on a terminal badge ('frame too big' or 'replay
    failed') and sends nothing further. sendAttach is gated on that flag
    so the `up` control message cannot talk it back into asking. A
    genuinely new socket clears it: the hub may have been restarted onto
    a session where the offending frame does not exist. Verified in a
    real browser by breaking stage() — 3 attaches, then silence for 13s,
    then recovery to 'up' on a fresh socket.
  - The dead-leg ping now runs INSIDE dialLoop too, against one clock
    carried by pointer. A reconnect is exactly when a browser is most
    likely to have died and exactly when nothing else is watching;
    resetting the clock on the way out would have made the check blind
    for the length of the outage instead of merely late.
  - test/wsclient.zig answers pings with a pong, and its comment saying
    the hub never sends one is no longer false. It also sends ONE
    unsolicited pong right after the upgrade — RFC 6455 allows that as a
    one-way heartbeat — so every hub e2e scenario exercises the pong path
    now instead of nothing exercising it until a 30s timer no test waits
    for. That path matters because readSmallMessage swallows pongs and
    loops to the frame behind them, which is the blocking hazard the
    whole gate exists to avoid.
  - verify.js pins the instantiate() SHAPE: Module in, bare Instance out;
    bytes in, {module, instance} out. That difference is what shipped a
    page unable to start at all, with every export assertion passing.
  - The finally-comment on sendPaste was overclaiming. It closes the
    bracket when a chunk fails to STAGE; it does nothing when the socket
    dies mid-paste, where the end marker never transits and the
    application is left in paste mode with state no reconnect can clear.
    Said plainly, and untreated in v1.

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

src/webhub.zig
Old New
@@ -194,9 +194,24 @@ pub fn headFrame(buffered: []const u8, capacity: usize) HeadFrame {
194 } 194 }
195 // HEADER AND PAYLOAD against the capacity, not the payload alone: the 195 // HEADER AND PAYLOAD against the capacity, not the payload alone: the
196 // reader holds both, so a payload that only just fits still leaves a 196 // reader holds both, so a payload that only just fits still leaves a
197 // frame that never completes — and the pump would keep waiting on it 197 // frame that never completes.
198 // until the buffer was full of bytes it could not use. 198 //
199 if (off + payload_len > capacity) return .too_big; 199 // Load-bearing for fillMore, not just tidiness. fillMore calls
200 // rebase(bufferedLen + 1), and std's defaultRebase asserts on exit
201 // that `buffer.len - seek >= capacity` — which cannot hold once the
202 // buffer is FULL. Saying .too_big before a frame can fill the buffer
203 // is what keeps the pump off that assert; without it, a peer that
204 // declares a frame just too large to fit sends the hub into an
205 // unreclaimable buffer and a panic.
206 //
207 // Written as a subtraction on the RIGHT because payload_len is a wire
208 // number: `off + payload_len` overflows on a declared length near
209 // u64 max (a 14-byte message does it), which is a Debug panic — and
210 // the default build IS Debug — and a wrap to a permanent .incomplete
211 // hang in ReleaseFast. `capacity < off` is checked too: this is a pub
212 // function and nothing guarantees a caller's buffer outgrows a
213 // 14-byte header.
214 if (capacity < off or payload_len > capacity - off) return .too_big;
200 if (buffered.len - off < payload_len) return .incomplete; 215 if (buffered.len - off < payload_len) return .incomplete;
201 if (opcode == ws_opcode_pong) return .{ .pong = off + @as(usize, @intCast(payload_len)) }; 216 if (opcode == ws_opcode_pong) return .{ .pong = off + @as(usize, @intCast(payload_len)) };
202 return .ready; 217 return .ready;
@@ -212,6 +227,45 @@ pub fn headFrame(buffered: []const u8, capacity: usize) HeadFrame {
212 pub const ping_idle_ms: i64 = 30_000; 227 pub const ping_idle_ms: i64 = 30_000;
213 pub const dead_intervals: i64 = 3; 228 pub const dead_intervals: i64 = 3;
214 229
230 /// The browser leg's liveness, carried BY POINTER across dialLoop — which
231 /// is the whole point of it being a struct.
232 ///
233 /// A reconnect is exactly when a dead browser is most likely (the outage
234 /// and the closed laptop have the same cause more often than not) and
235 /// exactly when nothing else is watching. Two tempting shortcuts are both
236 /// wrong: leaving the timer to the pump makes the check blind for as long
237 /// as the outage lasts, while resetting the clock on the way out of
238 /// dialLoop makes it blind AND lets a long outage hide a browser that
239 /// died in the middle of it. So the same ping runs in both loops against
240 /// one clock. A browser that is merely waiting answers the ping itself —
241 /// the WebSocket stack does that without waking the page — so silence
242 /// here really is silence.
243 const Liveness = struct {
244 last_inbound_ms: i64,
245 pings_sent: i64 = 0,
246
247 fn init() Liveness {
248 return .{ .last_inbound_ms = std.time.milliTimestamp() };
249 }
250
251 fn sawInbound(self: *Liveness) void {
252 self.last_inbound_ms = std.time.milliTimestamp();
253 self.pings_sent = 0;
254 }
255
256 /// Ping on each elapsed interval; false once the browser has been
257 /// silent through all of them, and the caller ends the tile.
258 fn tick(self: *Liveness, ws: *std.http.Server.WebSocket) bool {
259 const silence = std.time.milliTimestamp() - self.last_inbound_ms;
260 if (silence >= ping_idle_ms * dead_intervals) return false;
261 if (silence >= ping_idle_ms * (self.pings_sent + 1)) {
262 ws.writeMessage("", .ping) catch return false;
263 self.pings_sent += 1;
264 }
265 return true;
266 }
267 };
268
215 /// One thread per tile, and blocking is the design: the tile's Transport 269 /// One thread per tile, and blocking is the design: the tile's Transport
216 /// is private to this thread, so Transport.readFrame's blocking read 270 /// is private to this thread, so Transport.readFrame's blocking read
217 /// (fatal to a multiplexing hub) is simply correct here. A slow browser 271 /// (fatal to a multiplexing hub) is simply correct here. A slow browser
@@ -235,8 +289,9 @@ pub fn pumpTile(
235 // same way reconnect() quiets retries. 289 // same way reconnect() quiets retries.
236 if (target == .hand) target.hand.report_fallback = false; 290 if (target == .hand) target.hand.report_fallback = false;
237 291
292 var live = Liveness.init();
238 ws.writeMessage(controlMessage(.connecting), .binary) catch return; 293 ws.writeMessage(controlMessage(.connecting), .binary) catch return;
239 var transport = dialLoop(alloc, target, ws, ws_fd) orelse return; 294 var transport = dialLoop(alloc, target, ws, ws_fd, &live) orelse return;
240 defer transport.close(); 295 defer transport.close();
241 // `up` is not decoration: the browser re-attaches when it reads this, 296 // `up` is not decoration: the browser re-attaches when it reads this,
242 // and the hub never re-attaches on its behalf. mux.js's ENV_CONTROL 297 // and the hub never re-attaches on its behalf. mux.js's ENV_CONTROL
@@ -245,9 +300,6 @@ pub fn pumpTile(
245 // is written rather than left to be discovered. 300 // is written rather than left to be discovered.
246 ws.writeMessage(controlMessage(.up), .binary) catch return; 301 ws.writeMessage(controlMessage(.up), .binary) catch return;
247 302
248 var last_inbound_ms = std.time.milliTimestamp();
249 var pings_sent: i64 = 0;
250
251 outer: while (true) { 303 outer: while (true) {
252 var fds = [_]std.posix.pollfd{ 304 var fds = [_]std.posix.pollfd{
253 .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, 305 .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 },
@@ -268,7 +320,7 @@ pub fn pumpTile(
268 .closed => { 320 .closed => {
269 ws.writeMessage(controlMessage(.reconnecting), .binary) catch return; 321 ws.writeMessage(controlMessage(.reconnecting), .binary) catch return;
270 transport.close(); 322 transport.close();
271 transport = dialLoop(alloc, target, ws, ws_fd) orelse return; 323 transport = dialLoop(alloc, target, ws, ws_fd, &live) orelse return;
272 ws.writeMessage(controlMessage(.up), .binary) catch return; 324 ws.writeMessage(controlMessage(.up), .binary) catch return;
273 // fds[1].revents describes a socket state from 325 // fds[1].revents describes a socket state from
274 // BEFORE the re-dial, and dialLoop may have eaten 326 // BEFORE the re-dial, and dialLoop may have eaten
@@ -308,15 +360,13 @@ pub fn pumpTile(
308 .too_big => return, 360 .too_big => return,
309 .pong => |n| { 361 .pong => |n| {
310 ws.input.toss(n); 362 ws.input.toss(n);
311 last_inbound_ms = std.time.milliTimestamp(); 363 live.sawInbound();
312 pings_sent = 0;
313 continue; 364 continue;
314 }, 365 },
315 .ready => {}, 366 .ready => {},
316 } 367 }
317 const msg = ws.readSmallMessage() catch return; 368 const msg = ws.readSmallMessage() catch return;
318 last_inbound_ms = std.time.milliTimestamp(); 369 live.sawInbound();
319 pings_sent = 0;
320 switch (msg.opcode) { 370 switch (msg.opcode) {
321 // RFC 6455: a pong carrying the ping's payload back. 371 // RFC 6455: a pong carrying the ping's payload back.
322 // Ignoring pings meant a browser heartbeat could not 372 // Ignoring pings meant a browser heartbeat could not
@@ -327,7 +377,7 @@ pub fn pumpTile(
327 transport.writeFrame(parsed.t, parsed.payload) catch { 377 transport.writeFrame(parsed.t, parsed.payload) catch {
328 ws.writeMessage(controlMessage(.reconnecting), .binary) catch return; 378 ws.writeMessage(controlMessage(.reconnecting), .binary) catch return;
329 transport.close(); 379 transport.close();
330 transport = dialLoop(alloc, target, ws, ws_fd) orelse return; 380 transport = dialLoop(alloc, target, ws, ws_fd, &live) orelse return;
331 ws.writeMessage(controlMessage(.up), .binary) catch return; 381 ws.writeMessage(controlMessage(.up), .binary) catch return;
332 continue :outer; // same stale-revents reason as above 382 continue :outer; // same stale-revents reason as above
333 }; 383 };
@@ -346,12 +396,7 @@ pub fn pumpTile(
346 // Is anyone still there? A closed tab usually arrives as a close 396 // Is anyone still there? A closed tab usually arrives as a close
347 // frame or EOF, but a laptop that slept, a killed browser, or a 397 // frame or EOF, but a laptop that slept, a killed browser, or a
348 // dropped ssh -L leaves the socket half-open and silent forever. 398 // dropped ssh -L leaves the socket half-open and silent forever.
349 const silence = std.time.milliTimestamp() - last_inbound_ms; 399 if (!live.tick(ws)) return;
350 if (silence >= ping_idle_ms * dead_intervals) return;
351 if (silence >= ping_idle_ms * (pings_sent + 1)) {
352 ws.writeMessage("", .ping) catch return;
353 pings_sent += 1;
354 }
355 } 400 }
356 } 401 }
357 402
@@ -359,12 +404,15 @@ pub fn pumpTile(
359 /// browser hangs up (null). While waiting out a backoff the WS is 404 /// browser hangs up (null). While waiting out a backoff the WS is
360 /// watched: a message that arrives with no transport to carry it is 405 /// watched: a message that arrives with no transport to carry it is
361 /// dropped (the browser re-attaches on `up` anyway), a dead WS ends the 406 /// dropped (the browser re-attaches on `up` anyway), a dead WS ends the
362 /// tile. 407 /// tile — and `live` runs here on the same clock the pump uses, because
408 /// an outage is when a browser is most likely to die and when nothing
409 /// else is looking.
363 fn dialLoop( 410 fn dialLoop(
364 alloc: std.mem.Allocator, 411 alloc: std.mem.Allocator,
365 target: client.Target, 412 target: client.Target,
366 ws: *std.http.Server.WebSocket, 413 ws: *std.http.Server.WebSocket,
367 ws_fd: std.posix.fd_t, 414 ws_fd: std.posix.fd_t,
415 live: *Liveness,
368 ) ?client.Transport { 416 ) ?client.Transport {
369 var backoff_ms: u64 = 0; 417 var backoff_ms: u64 = 0;
370 while (true) { 418 while (true) {
@@ -372,7 +420,9 @@ fn dialLoop(
372 return t; 420 return t;
373 } else |_| {} 421 } else |_| {}
374 backoff_ms = client.nextBackoffMs(backoff_ms); 422 backoff_ms = client.nextBackoffMs(backoff_ms);
375 // The backoff doubles as the WS liveness window. 423 // The backoff doubles as the WS liveness window. It caps at 2s
424 // against a 30s ping interval, so the tick below is never more
425 // than one backoff late.
376 var fds = [_]std.posix.pollfd{ 426 var fds = [_]std.posix.pollfd{
377 .{ .fd = ws_fd, .events = std.posix.POLL.IN, .revents = 0 }, 427 .{ .fd = ws_fd, .events = std.posix.POLL.IN, .revents = 0 },
378 }; 428 };
@@ -387,9 +437,13 @@ fn dialLoop(
387 switch (headFrame(ws.input.buffered(), ws.input.buffer.len)) { 437 switch (headFrame(ws.input.buffered(), ws.input.buffer.len)) {
388 .incomplete => break :drain, 438 .incomplete => break :drain,
389 .too_big => return null, 439 .too_big => return null,
390 .pong => |p| ws.input.toss(p), 440 .pong => |p| {
441 ws.input.toss(p);
442 live.sawInbound();
443 },
391 .ready => { 444 .ready => {
392 const msg = ws.readSmallMessage() catch return null; 445 const msg = ws.readSmallMessage() catch return null;
446 live.sawInbound();
393 // Answer a ping even with no transport: the 447 // Answer a ping even with no transport: the
394 // browser is asking whether the hub is alive, and 448 // browser is asking whether the hub is alive, and
395 // "dialing" is a live answer. 449 // "dialing" is a live answer.
@@ -400,6 +454,10 @@ fn dialLoop(
400 } 454 }
401 } 455 }
402 } 456 }
457 // Same check, same clock as the pump's: a browser that died during
458 // the outage is reaped here rather than after it, and one that is
459 // merely waiting answers the ping and lives.
460 if (!live.tick(ws)) return null;
403 } 461 }
404 } 462 }
405 463
@@ -624,6 +682,24 @@ test "head frame: every split boundary is INCOMPLETE, the whole frame is READY"
624 // One byte under the whole-frame budget is an ordinary wait. 682 // One byte under the whole-frame budget is an ordinary wait.
625 std.mem.writeInt(u64, edge[2..10], cap - 14, .big); 683 std.mem.writeInt(u64, edge[2..10], cap - 14, .big);
626 try std.testing.expectEqual(HeadFrame.incomplete, headFrame(&edge, cap)); 684 try std.testing.expectEqual(HeadFrame.incomplete, headFrame(&edge, cap));
685 // One byte OVER it is not.
686 std.mem.writeInt(u64, edge[2..10], cap - 14 + 1, .big);
687 try std.testing.expectEqual(HeadFrame.too_big, headFrame(&edge, cap));
688
689 // The declared length is a WIRE number and gets no benefit of the
690 // doubt: u64 max in 14 bytes overflowed `off + payload_len`, which is
691 // a panic in Debug — the default build — and a wrap to a permanent
692 // .incomplete in ReleaseFast. Either way one hostile message per tile
693 // was enough.
694 std.mem.writeInt(u64, edge[2..10], std.math.maxInt(u64), .big);
695 try std.testing.expectEqual(HeadFrame.too_big, headFrame(&edge, cap));
696 std.mem.writeInt(u64, edge[2..10], std.math.maxInt(u64) - 13, .big);
697 try std.testing.expectEqual(HeadFrame.too_big, headFrame(&edge, cap));
698 // A capacity smaller than the header itself must not underflow the
699 // subtraction that replaced it. Nothing in the hub does this, but
700 // headFrame is pub and the arithmetic has to stand on its own.
701 try std.testing.expectEqual(HeadFrame.too_big, headFrame(&edge, 4));
702 try std.testing.expectEqual(HeadFrame.too_big, headFrame(&edge, 0));
627 703
628 // A pong is named separately: readSmallMessage swallows it and blocks 704 // A pong is named separately: readSmallMessage swallows it and blocks
629 // on whatever is behind it, so the pump must toss it itself. 705 // on whatever is behind it, so the pump must toss it itself.
test/wsclient.zig
Old New
@@ -73,12 +73,14 @@ fn decodeEscapes(alloc: std.mem.Allocator, s: []const u8) ![]u8 {
73 // RFC 6455, client side. The server side is std's; this is the ~60-line 73 // RFC 6455, client side. The server side is std's; this is the ~60-line
74 // mirror image: masked sends, unmasked receives. 74 // mirror image: masked sends, unmasked receives.
75 75
76 /// One client→server message: FIN + binary opcode, 4-byte mask, payload 76 /// One client→server message, ready to write: `h0` is the whole first
77 /// XOR'd. Layout returned ready to write. 77 /// header byte (FIN | opcode — 0x82 binary, 0x8a pong), then the length
78 fn maskedMessage(alloc: std.mem.Allocator, payload: []const u8, mask: [4]u8) ![]u8 { 78 /// form, the 4-byte mask, and the payload XOR'd through it. Client→server
79 /// frames are always masked, which is the rest of this function.
80 fn maskedMessage(alloc: std.mem.Allocator, h0: u8, payload: []const u8, mask: [4]u8) ![]u8 {
79 var out: std.ArrayList(u8) = .empty; 81 var out: std.ArrayList(u8) = .empty;
80 errdefer out.deinit(alloc); 82 errdefer out.deinit(alloc);
81 try out.append(alloc, 0x82); // FIN | binary 83 try out.append(alloc, h0);
82 if (payload.len <= 125) { 84 if (payload.len <= 125) {
83 try out.append(alloc, 0x80 | @as(u8, @intCast(payload.len))); 85 try out.append(alloc, 0x80 | @as(u8, @intCast(payload.len)));
84 } else if (payload.len <= 0xffff) { 86 } else if (payload.len <= 0xffff) {
@@ -151,9 +153,13 @@ const Client = struct {
151 last_state_len: usize = 0, 153 last_state_len: usize = 0,
152 154
153 fn sendMessage(self: *Client, payload: []const u8) void { 155 fn sendMessage(self: *Client, payload: []const u8) void {
156 self.sendRaw(0x82, payload); // FIN | binary
157 }
158
159 fn sendRaw(self: *Client, h0: u8, payload: []const u8) void {
154 var mask: [4]u8 = undefined; 160 var mask: [4]u8 = undefined;
155 std.crypto.random.bytes(&mask); 161 std.crypto.random.bytes(&mask);
156 const msg = maskedMessage(self.alloc, payload, mask) catch fatal(EXIT_USAGE, "oom", .{}); 162 const msg = maskedMessage(self.alloc, h0, payload, mask) catch fatal(EXIT_USAGE, "oom", .{});
157 defer self.alloc.free(msg); 163 defer self.alloc.free(msg);
158 writeAll(self.sock, msg) catch fatal(EXIT_DIED, "hub hung up mid-send", .{}); 164 writeAll(self.sock, msg) catch fatal(EXIT_DIED, "hub hung up mid-send", .{});
159 } 165 }
@@ -194,7 +200,16 @@ const Client = struct {
194 fn handle(self: *Client, msg: WsReader.Msg) void { 200 fn handle(self: *Client, msg: WsReader.Msg) void {
195 switch (msg.opcode) { 201 switch (msg.opcode) {
196 0x8 => fatal(EXIT_DIED, "hub sent close", .{}), 202 0x8 => fatal(EXIT_DIED, "hub sent close", .{}),
197 0x9 => return, // ping: the hub never sends one; ignore rather than die 203 // The hub pings an idle browser (webhub.ping_idle_ms) and ends
204 // the tile after three unanswered intervals, so a fixture that
205 // ignored pings would be a fixture that gets reaped. Answering
206 // is also what puts a masked PONG on the hub's inbound path —
207 // the one frame readSmallMessage swallows, and therefore the
208 // one headFrame has to name for itself.
209 0x9 => {
210 self.sendRaw(0x8a, msg.payload); // FIN | pong, payload echoed
211 return;
212 },
198 0x1, 0x2 => {}, 213 0x1, 0x2 => {},
199 else => return, 214 else => return,
200 } 215 }
@@ -350,6 +365,16 @@ pub fn main() !void {
350 // Bytes past the head are the first WS frames. 365 // Bytes past the head are the first WS frames.
351 try cl.reader.buf.appendSlice(alloc, head.items[head_end..]); 366 try cl.reader.buf.appendSlice(alloc, head.items[head_end..]);
352 367
368 // One UNSOLICITED pong, which RFC 6455 explicitly allows as a
369 // one-way heartbeat. Waiting for the hub's own ping would mean
370 // waiting 30 seconds, so nothing would ever exercise the pong path
371 // in the e2e suite; sending one here means every hub scenario proves
372 // it. It is the frame std's readSmallMessage swallows before looping
373 // to the next one — so if the hub ever stops tossing pongs itself,
374 // the pump blocks on a frame that has not arrived, the tile stops
375 // answering, and these scenarios time out.
376 cl.sendRaw(0x8a, "hello");
377
353 // --- script loop --- 378 // --- script loop ---
354 var stdin_buf: std.ArrayList(u8) = .empty; 379 var stdin_buf: std.ArrayList(u8) = .empty;
355 defer stdin_buf.deinit(alloc); 380 defer stdin_buf.deinit(alloc);
@@ -447,7 +472,7 @@ test "masked message: header layout and mask application, all three length forms
447 const alloc = std.testing.allocator; 472 const alloc = std.testing.allocator;
448 const mask = [4]u8{ 0xaa, 0xbb, 0xcc, 0xdd }; 473 const mask = [4]u8{ 0xaa, 0xbb, 0xcc, 0xdd };
449 474
450 const small = try maskedMessage(alloc, "hi", mask); 475 const small = try maskedMessage(alloc, 0x82, "hi", mask);
451 defer alloc.free(small); 476 defer alloc.free(small);
452 try std.testing.expectEqual(@as(u8, 0x82), small[0]); // FIN | binary 477 try std.testing.expectEqual(@as(u8, 0x82), small[0]); // FIN | binary
453 try std.testing.expectEqual(@as(u8, 0x80 | 2), small[1]); // masked, len 2 478 try std.testing.expectEqual(@as(u8, 0x80 | 2), small[1]); // masked, len 2
@@ -456,7 +481,7 @@ test "masked message: header layout and mask application, all three length forms
456 try std.testing.expectEqual(@as(u8, 'i' ^ 0xbb), small[7]); 481 try std.testing.expectEqual(@as(u8, 'i' ^ 0xbb), small[7]);
457 482
458 const mid_payload = [_]u8{0x55} ** 300; 483 const mid_payload = [_]u8{0x55} ** 300;
459 const mid = try maskedMessage(alloc, &mid_payload, mask); 484 const mid = try maskedMessage(alloc, 0x82, &mid_payload, mask);
460 defer alloc.free(mid); 485 defer alloc.free(mid);
461 try std.testing.expectEqual(@as(u8, 0x80 | 126), mid[1]); 486 try std.testing.expectEqual(@as(u8, 0x80 | 126), mid[1]);
462 try std.testing.expectEqual(@as(u16, 300), std.mem.readInt(u16, mid[2..4], .big)); 487 try std.testing.expectEqual(@as(u16, 300), std.mem.readInt(u16, mid[2..4], .big));
@@ -465,10 +490,19 @@ test "masked message: header layout and mask application, all three length forms
465 const big_payload = try alloc.alloc(u8, 70 * 1024); 490 const big_payload = try alloc.alloc(u8, 70 * 1024);
466 defer alloc.free(big_payload); 491 defer alloc.free(big_payload);
467 @memset(big_payload, 1); 492 @memset(big_payload, 1);
468 const big = try maskedMessage(alloc, big_payload, mask); 493 const big = try maskedMessage(alloc, 0x82, big_payload, mask);
469 defer alloc.free(big); 494 defer alloc.free(big);
470 try std.testing.expectEqual(@as(u8, 0x80 | 127), big[1]); 495 try std.testing.expectEqual(@as(u8, 0x80 | 127), big[1]);
471 try std.testing.expectEqual(@as(u64, 70 * 1024), std.mem.readInt(u64, big[2..10], .big)); 496 try std.testing.expectEqual(@as(u64, 70 * 1024), std.mem.readInt(u64, big[2..10], .big));
497
498 // A pong is the same masked envelope with a different opcode — the
499 // fixture answers the hub's idle ping with one, and that is the only
500 // reason the opcode is a parameter at all.
501 const pong = try maskedMessage(alloc, 0x8a, "hello", mask);
502 defer alloc.free(pong);
503 try std.testing.expectEqual(@as(u8, 0x8a), pong[0]); // FIN | pong
504 try std.testing.expectEqual(@as(u8, 0x80 | 5), pong[1]); // masked, len 5
505 try std.testing.expectEqual(@as(u8, 'h' ^ 0xaa), pong[6]);
472 } 506 }
473 507
474 test "ws reader: split delivery reassembles; server frames arrive unmasked" { 508 test "ws reader: split delivery reassembles; server frames arrive unmasked" {
web/index.html
Old New
@@ -30,7 +30,8 @@
30 .badge { padding: 0 6px; border-radius: 3px; font-size: 11px; } 30 .badge { padding: 0 6px; border-radius: 3px; font-size: 11px; }
31 .badge.connecting, .badge.reconnecting { background: #4a3b12; color: #e8c35a; } 31 .badge.connecting, .badge.reconnecting { background: #4a3b12; color: #e8c35a; }
32 .badge.up { background: #16351f; color: #6fce8a; } 32 .badge.up { background: #16351f; color: #6fce8a; }
33 .badge.gone, .badge.exited, .badge.full { background: #3d1a1a; color: #e07a7a; } 33 /* .stuck: the tile gave up replaying and is sending nothing further. */
34 .badge.gone, .badge.exited, .badge.full, .badge.stuck { background: #3d1a1a; color: #e07a7a; }
34 .badge.scroll { background: #1a2c3d; color: #6ab0e0; } 35 .badge.scroll { background: #1a2c3d; color: #6ab0e0; }
35 /* mux.js sets width/height in CSS pixels and sizes the backing store to 36 /* mux.js sets width/height in CSS pixels and sizes the backing store to
36 that times devicePixelRatio, so glyphs rasterize at device resolution 37 that times devicePixelRatio, so glyphs rasterize at device resolution
web/mux.js
Old New
@@ -90,6 +90,9 @@ class Tile {
90 this.wsBackoffMs = 0; // browser-leg reconnect, client.zig's schedule 90 this.wsBackoffMs = 0; // browser-leg reconnect, client.zig's schedule
91 this.wsFailures = 0; // straight failures to OPEN, for the badge 91 this.wsFailures = 0; // straight failures to OPEN, for the badge
92 this.wsOpened = false; // did THIS socket ever open? 92 this.wsOpened = false; // did THIS socket ever open?
93 this.replayFailures = 0; // straight failures to APPLY a frame
94 this.replayBackoffMs = 0;
95 this.replayDead = false; // give up: send nothing further
93 this.drawScale = 0; // logical→CSS factor the backing store is sized for 96 this.drawScale = 0; // logical→CSS factor the backing store is sized for
94 97
95 this.el = document.createElement('div'); 98 this.el = document.createElement('div');
@@ -140,6 +143,13 @@ class Tile {
140 this.wsOpened = true; 143 this.wsOpened = true;
141 this.wsBackoffMs = 0; // a socket that opened earns a fresh schedule 144 this.wsBackoffMs = 0; // a socket that opened earns a fresh schedule
142 this.wsFailures = 0; 145 this.wsFailures = 0;
146 // A genuinely new socket is a genuinely new chance: the hub may
147 // have been restarted onto a different session, so the frame this
148 // tile choked on may simply not exist any more. Without this a
149 // tile that gave up once would stay dead until the page reloaded.
150 this.replayDead = false;
151 this.replayFailures = 0;
152 this.replayBackoffMs = 0;
143 // The hub narrates from here on: connecting → up. 153 // The hub narrates from here on: connecting → up.
144 this.setBadge('connecting', 'connecting'); 154 this.setBadge('connecting', 'connecting');
145 }; 155 };
@@ -156,19 +166,47 @@ class Tile {
156 }; 166 };
157 } 167 }
158 168
169 // Every recovery from a bad frame answers with a re-attach, and the
170 // daemon answers THAT with the same snapshot that just failed — so
171 // "recover and retry" is a flood unless it is bounded. Bounded on the
172 // machinery the socket's own reconnect already uses: the same schedule,
173 // the same ceiling, and then it stops for good. A tile stuck on one
174 // frame is a bad tile; a tile hammering the hub is a bad wall.
175 //
176 // `terminal` is what the badge says when it gives up — the cap case
177 // and the core-refused case fail for different reasons and are worth
178 // telling apart when someone comes to read it.
179 replayFailed(why, terminal) {
180 if (this.replayDead) return;
181 this.replayFailures++;
182 console.warn(`mux tile ${this.idx}: ${why} (replay failure ${this.replayFailures})`);
183 if (this.replayFailures >= GONE_AFTER_FAILURES) {
184 this.replayDead = true; // sendAttach is gated on this
185 this.setBadge('stuck', terminal);
186 return;
187 }
188 const wait = this.replayBackoffMs;
189 this.replayBackoffMs = nextBackoffMs(this.replayBackoffMs);
190 setTimeout(() => this.sendAttach(true), wait);
191 }
192 replaySucceeded() {
193 this.replayFailures = 0;
194 this.replayBackoffMs = 0;
195 }
196
159 // The core is unusable — re-init and re-attach from nothing. Everything 197 // The core is unusable — re-init and re-attach from nothing. Everything
160 // it held is gone, so the attach quotes (0,0) and the daemon answers 198 // it held is gone, so the attach quotes (0,0) and the daemon answers
161 // with a snapshot. 199 // with a snapshot.
162 resetCore(why) { 200 resetCore(why) {
163 console.warn(`mux tile ${this.idx}: re-initializing the core (${why})`);
164 if (this.core.mux_init(80, 24) !== 0) { 201 if (this.core.mux_init(80, 24) !== 0) {
165 this.setBadge('gone', 'core failed'); 202 this.replayDead = true;
203 this.setBadge('stuck', 'core failed');
166 return; 204 return;
167 } 205 }
168 this.gotState = false; 206 this.gotState = false;
169 this.scrollPages = 0; 207 this.scrollPages = 0;
170 this.drawScale = 0; // force the backing store to be re-sized 208 this.drawScale = 0; // force the backing store to be re-sized
171 this.sendAttach(true); 209 this.replayFailed(`re-initialized the core after ${why}`, 'replay failed');
172 } 210 }
173 211
174 // --- wasm memory access, always through fresh views --- 212 // --- wasm memory access, always through fresh views ---
@@ -199,6 +237,10 @@ class Tile {
199 // snapshot carrying the true grid, and the slot stays 0x0 forever — 237 // snapshot carrying the true grid, and the slot stays 0x0 forever —
200 // this tile can never move the shared session. Only the zoomed tile 238 // this tile can never move the shared session. Only the zoomed tile
201 // claims its real size. 239 // claims its real size.
240 // ONE gate for every attach, wherever it comes from — the `up`
241 // control message included. A tile that gave up on replaying must
242 // not be talked back into asking for the same frame again.
243 if (this.replayDead) return;
202 const cols = this.zoomed ? this.zoomCols() : 1; 244 const cols = this.zoomed ? this.zoomCols() : 1;
203 const rows = this.zoomed ? this.zoomRows() : 1; 245 const rows = this.zoomed ? this.zoomRows() : 1;
204 const n = this.core.mux_attach_payload(cols, rows, fresh ? 1 : 0); 246 const n = this.core.mux_attach_payload(cols, rows, fresh ? 1 : 0);
@@ -234,8 +276,16 @@ class Tile {
234 // sees the literal markers. 276 // sees the literal markers.
235 sendPaste(text) { 277 sendPaste(text) {
236 if (this.core.mux_paste_begin() > 0) this.sendFrame(MSG.input, this.outBytes()); 278 if (this.core.mux_paste_begin() > 0) this.sendFrame(MSG.input, this.outBytes());
237 // Unconditionally closed, including when a chunk fails to stage: 279 // Closed even when a chunk fails to STAGE — that is the case this
238 // leaving the application in paste mode is worse than a short paste. 280 // buys, and it is worth buying: a short paste beats an application
281 // left in paste mode.
282 //
283 // It buys nothing against the socket dying mid-paste. sendFrame drops
284 // silently when the WebSocket is not OPEN, so the end marker never
285 // transits and the application sits in paste mode with bytes it will
286 // treat as pasted. Reconnecting cannot clear that: the state is on
287 // the far side of the pty, and nothing this client can send is
288 // distinguishable from more paste. Untreated in v1.
239 try { 289 try {
240 this.sendText(text); 290 this.sendText(text);
241 } finally { 291 } finally {
@@ -296,14 +346,16 @@ class Tile {
296 if (!this.stage(payload)) { 346 if (!this.stage(payload)) {
297 // Over the core's 256 KiB staging cap. Silently returning left 347 // Over the core's 256 KiB staging cap. Silently returning left
298 // the replica permanently behind the session; a re-attach at 348 // the replica permanently behind the session; a re-attach at
299 // (0,0) costs one snapshot and is correct. 349 // (0,0) costs one snapshot and is correct — but the snapshot it
300 console.warn(`mux tile ${this.idx}: frame of ${payload.length} B over the staging cap, re-attaching`); 350 // asks for is the one that just failed, so it is bounded.
301 this.sendAttach(true); 351 this.replayFailed(
352 `frame of ${payload.length} B over the staging cap`, 'frame too big');
302 return; 353 return;
303 } 354 }
304 const r = this.core.mux_apply_frame(type, payload.length); 355 const r = this.core.mux_apply_frame(type, payload.length);
305 if (r === 0) { 356 if (r === 0) {
306 this.gotState = true; 357 this.gotState = true;
358 this.replaySucceeded();
307 if (this.badgeIs('full')) this.setBadge('up', 'up'); 359 if (this.badgeIs('full')) this.setBadge('up', 'up');
308 if (this.scrollPages === 0) this.paintLive(); 360 if (this.scrollPages === 0) this.paintLive();
309 return; 361 return;
web/verify.js
Old New
@@ -241,6 +241,22 @@ async function main() {
241 const absent = called.filter((n) => typeof e[n] !== 'function'); 241 const absent = called.filter((n) => typeof e[n] !== 'function');
242 check(`shell calls only real exports (${called.length} of them)`, absent.join(','), ''); 242 check(`shell calls only real exports (${called.length} of them)`, absent.join(','), '');
243 243
244 // --- the SHAPE mux.js depends on ---
245 // WebAssembly.instantiate returns {module, instance} for bytes but the
246 // bare Instance for an already-compiled Module. mux.js compiles first
247 // (compileStreaming) and so takes the second shape; this file passes
248 // bytes and takes the first. Destructuring the wrong one is silently
249 // undefined, which is precisely how the page shipped unable to start
250 // at all — every export above passing, and nothing running. Pin the
251 // shape the shell relies on, since no other assertion here can see it.
252 const mod = await WebAssembly.compile(bin);
253 const fromModule = await WebAssembly.instantiate(mod, {});
254 check('instantiate(Module) is an Instance', fromModule instanceof WebAssembly.Instance, true);
255 check('instantiate(Module) has no .instance to destructure', fromModule.instance, undefined);
256 check('instantiate(Module).exports is the ABI', typeof fromModule.exports.mux_init, 'function');
257 const fromBytes = await WebAssembly.instantiate(bin, {});
258 check('instantiate(bytes) is the OTHER shape', fromBytes.instance instanceof WebAssembly.Instance, true);
259
244 // --- deinit / re-init --- 260 // --- deinit / re-init ---
245 e.mux_deinit(); 261 e.mux_deinit();
246 check('apply after deinit', e.mux_apply_frame(0x81, 0), -1); 262 check('apply after deinit', e.mux_apply_frame(0x81, 0), -1);