002d1f76
fix(web): headFrame overflowed on a wire-declared length; bound the replay retries
a73x 2026-08-13 12:52
Commit message
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); |