1aba332b
feat: webhub tile pump — envelope, frame messages, backoff shared with the CLI
a73x 2026-08-13 10:33
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -213,6 +213,7 @@ pub fn build(b: *std.Build) void { | |||
| 213 | .target = target, | 213 | .target = target, |
| 214 | .optimize = optimize, | 214 | .optimize = optimize, |
| 215 | }); | 215 | }); |
| 216 | webhub_mod.addImport("protocol", protocol_mod); | ||
| 216 | 217 | ||
| 217 | // The socket path's identity and the right to bind it: the stale-socket | 218 | // The socket path's identity and the right to bind it: the stale-socket |
| 218 | // claim and the dev+ino record teardown compares against. A leaf — it | 219 | // claim and the dev+ino record teardown compares against. A leaf — it |
| @@ -301,6 +302,10 @@ pub fn build(b: *std.Build) void { | |||
| 301 | client_mod.addImport("proxy", proxy_mod); | 302 | client_mod.addImport("proxy", proxy_mod); |
| 302 | client_mod.addImport("paint", paint_mod); | 303 | client_mod.addImport("paint", paint_mod); |
| 303 | 304 | ||
| 305 | // Late-bound: webhub is defined before client in this file, but the | ||
| 306 | // import graph only needs both to exist by the time the exes compile. | ||
| 307 | webhub_mod.addImport("client", client_mod); | ||
| 308 | |||
| 304 | const mux_mod = b.createModule(.{ | 309 | const mux_mod = b.createModule(.{ |
| 305 | .root_source_file = b.path("src/mux_main.zig"), | 310 | .root_source_file = b.path("src/mux_main.zig"), |
| 306 | .target = target, | 311 | .target = target, |
| @@ -493,7 +498,7 @@ pub fn build(b: *std.Build) void { | |||
| 493 | // found no libraries and no explanation. | 498 | // found no libraries and no explanation. |
| 494 | if (mod == server_mod or mod == quic_mod or mod == quic_server_mod or | 499 | if (mod == server_mod or mod == quic_mod or mod == quic_server_mod or |
| 495 | mod == exe_mod or mod == client_mod or mod == mux_mod or | 500 | mod == exe_mod or mod == client_mod or mod == mux_mod or |
| 496 | mod == quic_client_mod) linkQuic(b, t, quic); | 501 | mod == quic_client_mod or mod == webhub_mod) linkQuic(b, t, quic); |
| 497 | test_step.dependOn(&b.addRunArtifact(t).step); | 502 | test_step.dependOn(&b.addRunArtifact(t).step); |
| 498 | } | 503 | } |
| 499 | 504 | ||
src/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -1600,7 +1600,7 @@ fn reconnect( | |||
| 1600 | var backoff_ms: u64 = 0; | 1600 | var backoff_ms: u64 = 0; |
| 1601 | while (true) { | 1601 | while (true) { |
| 1602 | if (drainStdinForQuit(stdin_fd, backoff_ms)) return false; | 1602 | if (drainStdinForQuit(stdin_fd, backoff_ms)) return false; |
| 1603 | backoff_ms = if (backoff_ms == 0) 200 else @min(backoff_ms * 2, 2000); | 1603 | backoff_ms = nextBackoffMs(backoff_ms); |
| 1604 | 1604 | ||
| 1605 | var fresh = Transport.open(alloc, quiet, null, std.posix.STDIN_FILENO) catch |err| { | 1605 | var fresh = Transport.open(alloc, quiet, null, std.posix.STDIN_FILENO) catch |err| { |
| 1606 | // Ctrl-\ during the handshake is the same answer as Ctrl-\ | 1606 | // Ctrl-\ during the handshake is the same answer as Ctrl-\ |
| @@ -1628,6 +1628,24 @@ fn reconnect( | |||
| 1628 | } | 1628 | } |
| 1629 | } | 1629 | } |
| 1630 | 1630 | ||
| 1631 | /// The reconnect pacing, M7's numbers: iteration zero waits not at all | ||
| 1632 | /// (see reconnect's comment for the measurement), then 200ms doubling to | ||
| 1633 | /// a 2s cap — pacing a flapping link without ever giving up. Pure so the | ||
| 1634 | /// muxweb hub runs the SAME schedule against its tiles; the deliberate | ||
| 1635 | /// no-retry-cap is part of the contract. | ||
| 1636 | pub fn nextBackoffMs(prev: u64) u64 { | ||
| 1637 | return if (prev == 0) 200 else @min(prev * 2, 2000); | ||
| 1638 | } | ||
| 1639 | |||
| 1640 | test "reconnect backoff: 0 then 200 doubling to the 2s cap, never beyond" { | ||
| 1641 | try std.testing.expectEqual(@as(u64, 200), nextBackoffMs(0)); | ||
| 1642 | try std.testing.expectEqual(@as(u64, 400), nextBackoffMs(200)); | ||
| 1643 | try std.testing.expectEqual(@as(u64, 800), nextBackoffMs(400)); | ||
| 1644 | try std.testing.expectEqual(@as(u64, 1600), nextBackoffMs(800)); | ||
| 1645 | try std.testing.expectEqual(@as(u64, 2000), nextBackoffMs(1600)); | ||
| 1646 | try std.testing.expectEqual(@as(u64, 2000), nextBackoffMs(2000)); | ||
| 1647 | } | ||
| 1648 | |||
| 1631 | fn devNull() !std.posix.fd_t { | 1649 | fn devNull() !std.posix.fd_t { |
| 1632 | return std.posix.open("/dev/null", .{ .ACCMODE = .WRONLY }, 0); | 1650 | return std.posix.open("/dev/null", .{ .ACCMODE = .WRONLY }, 0); |
| 1633 | } | 1651 | } |
src/protocol.zig
| Old | New | ||
|---|---|---|---|
| @@ -36,6 +36,12 @@ pub const MsgType = enum(u8) { | |||
| 36 | 36 | ||
| 37 | pub const max_payload = 16 * 1024 * 1024; | 37 | pub const max_payload = 16 * 1024 * 1024; |
| 38 | 38 | ||
| 39 | /// One type byte + u32 LE payload length. The writers below spell the 5 | ||
| 40 | /// inline in their fixed-size buffers; this name exists for READERS that | ||
| 41 | /// delimit frames out of a buffer they did not fill (the daemon's | ||
| 42 | /// pushInbound, the hub's WebSocket messages). | ||
| 43 | pub const frame_header_len = 5; | ||
| 44 | |||
| 39 | pub const Frame = struct { | 45 | pub const Frame = struct { |
| 40 | type: MsgType, | 46 | type: MsgType, |
| 41 | payload: []u8, | 47 | payload: []u8, |
src/webhub.zig
| Old | New | ||
|---|---|---|---|
| @@ -10,6 +10,8 @@ | |||
| 10 | //! authenticated by ssh like everything else in this project. | 10 | //! authenticated by ssh like everything else in this project. |
| 11 | 11 | ||
| 12 | const std = @import("std"); | 12 | const std = @import("std"); |
| 13 | const proto = @import("protocol"); | ||
| 14 | const client = @import("client"); | ||
| 13 | 15 | ||
| 14 | pub const default_port: u16 = 7681; | 16 | pub const default_port: u16 = 7681; |
| 15 | 17 | ||
| @@ -77,6 +79,172 @@ pub fn route(assets: Assets, path: []const u8) ?Asset { | |||
| 77 | } | 79 | } |
| 78 | 80 | ||
| 79 | // --------------------------------------------------------------------------- | 81 | // --------------------------------------------------------------------------- |
| 82 | // The wire between hub and browser (M-web Task 6): WebSocket binary | ||
| 83 | // messages, one envelope byte. 0x00 + mux protocol frame verbatim, both | ||
| 84 | // directions; 0x01 + UTF-8 JSON control message, hub→browser only. That | ||
| 85 | // is the whole vocabulary — anything the mux protocol learns to say | ||
| 86 | // later transits untouched. | ||
| 87 | |||
| 88 | pub const env_frame: u8 = 0x00; | ||
| 89 | pub const env_control: u8 = 0x01; | ||
| 90 | |||
| 91 | pub const TileState = enum { connecting, up, reconnecting, gone }; | ||
| 92 | |||
| 93 | /// The full control MESSAGE — envelope byte included — ready for one | ||
| 94 | /// writeMessage. Comptime because the vocabulary is closed. | ||
| 95 | pub fn controlMessage(comptime s: TileState) []const u8 { | ||
| 96 | return &[1]u8{env_control} ++ "{\"state\":\"" ++ @tagName(s) ++ "\"}"; | ||
| 97 | } | ||
| 98 | |||
| 99 | pub const ParsedFrame = struct { t: proto.MsgType, payload: []const u8 }; | ||
| 100 | |||
| 101 | pub const FrameMsgError = error{ | ||
| 102 | BadEnvelope, | ||
| 103 | ShortFrame, | ||
| 104 | LengthMismatch, | ||
| 105 | Oversize, | ||
| 106 | }; | ||
| 107 | |||
| 108 | /// One browser→hub WebSocket message → one mux frame. The hub parses the | ||
| 109 | /// 5-byte header (it must — the WS leg is message-delimited while the | ||
| 110 | /// daemon leg is a byte stream) and NEVER the payload; the spec's | ||
| 111 | /// amendment 2 is this function's contract. The length must match the | ||
| 112 | /// message exactly: WebSocket already delimits, so a trailing byte is a | ||
| 113 | /// bug upstream, not framing to resynchronize. | ||
| 114 | pub fn parseFrameMessage(msg: []const u8) FrameMsgError!ParsedFrame { | ||
| 115 | if (msg.len < 1 or msg[0] != env_frame) return error.BadEnvelope; | ||
| 116 | const f = msg[1..]; | ||
| 117 | if (f.len < proto.frame_header_len) return error.ShortFrame; | ||
| 118 | const len = std.mem.readInt(u32, f[1..5], .little); | ||
| 119 | if (len > proto.max_payload) return error.Oversize; | ||
| 120 | if (f.len - proto.frame_header_len != len) return error.LengthMismatch; | ||
| 121 | // MsgType is non-exhaustive BY DESIGN, so an unknown type byte is a | ||
| 122 | // valid value that transits untouched — the proxy thesis holding for | ||
| 123 | // vocabulary the protocol has not learned yet. | ||
| 124 | return .{ .t = @enumFromInt(f[0]), .payload = f[proto.frame_header_len..] }; | ||
| 125 | } | ||
| 126 | |||
| 127 | /// One thread per tile, and blocking is the design: the tile's Transport | ||
| 128 | /// is private to this thread, so Transport.readFrame's blocking read | ||
| 129 | /// (fatal to a multiplexing hub) is simply correct here. A slow browser | ||
| 130 | /// stalls only its own tile (v1 stance: the daemon side is protected by | ||
| 131 | /// its own 8 MiB pending cap; the hub's upstream reads just stall). | ||
| 132 | /// | ||
| 133 | /// The hub owns reconnection; the browser owns re-attach. On transport | ||
| 134 | /// death this narrates `reconnecting`, re-dials on the CLI's own backoff | ||
| 135 | /// schedule (client.nextBackoffMs — no retry cap, deliberately), then | ||
| 136 | /// narrates `up`; the browser's replica quotes have_seq/have_epoch in a | ||
| 137 | /// fresh attach and M7's snapshot-vs-delta resolution does the rest. | ||
| 138 | pub fn pumpTile( | ||
| 139 | alloc: std.mem.Allocator, | ||
| 140 | ws: *std.http.Server.WebSocket, | ||
| 141 | ws_fd: std.posix.fd_t, | ||
| 142 | target_in: client.Target, | ||
| 143 | ) void { | ||
| 144 | var target = target_in; | ||
| 145 | // No terminal to spam and a control channel that already narrates: | ||
| 146 | // the one fallback line the CLI allows itself is quieted here, the | ||
| 147 | // same way reconnect() quiets retries. | ||
| 148 | if (target == .hand) target.hand.report_fallback = false; | ||
| 149 | |||
| 150 | ws.writeMessage(@constCast(controlMessage(.connecting)), .binary) catch return; | ||
| 151 | var transport = dialLoop(alloc, target, ws, ws_fd) orelse return; | ||
| 152 | defer transport.close(); | ||
| 153 | ws.writeMessage(@constCast(controlMessage(.up)), .binary) catch return; | ||
| 154 | |||
| 155 | while (true) { | ||
| 156 | var fds = [_]std.posix.pollfd{ | ||
| 157 | .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 158 | .{ .fd = ws_fd, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 159 | }; | ||
| 160 | _ = std.posix.poll(&fds, transport.timeoutMs(100)) catch return; | ||
| 161 | transport.service(); | ||
| 162 | |||
| 163 | // Daemon → browser. The `.quic` disjunct is the CLI's lesson | ||
| 164 | // verbatim: there frames can arrive from the stream layer with | ||
| 165 | // the socket never going readable. | ||
| 166 | if (fds[0].revents != 0 or transport.link == .quic) frames: { | ||
| 167 | while (true) { | ||
| 168 | const incoming = transport.readFrame(alloc) catch return; | ||
| 169 | const frame = switch (incoming) { | ||
| 170 | .frame => |f| f, | ||
| 171 | .incomplete => break :frames, | ||
| 172 | .closed => { | ||
| 173 | ws.writeMessage(@constCast(controlMessage(.reconnecting)), .binary) catch return; | ||
| 174 | transport.close(); | ||
| 175 | transport = dialLoop(alloc, target, ws, ws_fd) orelse return; | ||
| 176 | ws.writeMessage(@constCast(controlMessage(.up)), .binary) catch return; | ||
| 177 | break :frames; | ||
| 178 | }, | ||
| 179 | }; | ||
| 180 | defer frame.deinit(alloc); | ||
| 181 | var hdr: [proto.frame_header_len]u8 = undefined; | ||
| 182 | hdr[0] = @intFromEnum(frame.type); | ||
| 183 | std.mem.writeInt(u32, hdr[1..5], @intCast(frame.payload.len), .little); | ||
| 184 | var vecs = [_][]const u8{ &.{env_frame}, &hdr, frame.payload }; | ||
| 185 | ws.writeMessageVec(&vecs, .binary) catch return; | ||
| 186 | // Only the socket link carries the guarantee that one | ||
| 187 | // readable event is one frame; QUIC may have buffered | ||
| 188 | // more, so drain until .incomplete. | ||
| 189 | if (transport.link != .quic) break :frames; | ||
| 190 | } | ||
| 191 | } | ||
| 192 | |||
| 193 | // Browser → daemon. One WS message is one frame; drain what the | ||
| 194 | // reader has already buffered too, or it waits for a poll the | ||
| 195 | // socket will never signal. | ||
| 196 | if (fds[1].revents != 0) { | ||
| 197 | while (true) { | ||
| 198 | const msg = ws.readSmallMessage() catch return; | ||
| 199 | if (msg.opcode == .binary or msg.opcode == .text) { | ||
| 200 | if (parseFrameMessage(msg.data)) |parsed| { | ||
| 201 | transport.writeFrame(parsed.t, parsed.payload) catch { | ||
| 202 | ws.writeMessage(@constCast(controlMessage(.reconnecting)), .binary) catch return; | ||
| 203 | transport.close(); | ||
| 204 | transport = dialLoop(alloc, target, ws, ws_fd) orelse return; | ||
| 205 | ws.writeMessage(@constCast(controlMessage(.up)), .binary) catch return; | ||
| 206 | }; | ||
| 207 | } else |_| { | ||
| 208 | // Not a frame message: dropped, deliberately — | ||
| 209 | // the browser side is ours, so this is a bug's | ||
| 210 | // signature, and killing every tile for it would | ||
| 211 | // make the page unusable exactly when debugging. | ||
| 212 | } | ||
| 213 | } | ||
| 214 | if (ws.input.bufferedLen() == 0) break; | ||
| 215 | } | ||
| 216 | } | ||
| 217 | } | ||
| 218 | } | ||
| 219 | |||
| 220 | /// Dial with the CLI's backoff schedule until the transport opens or the | ||
| 221 | /// browser hangs up (null). While waiting out a backoff the WS is | ||
| 222 | /// watched: a message that arrives with no transport to carry it is | ||
| 223 | /// dropped (the browser re-attaches on `up` anyway), a dead WS ends the | ||
| 224 | /// tile. | ||
| 225 | fn dialLoop( | ||
| 226 | alloc: std.mem.Allocator, | ||
| 227 | target: client.Target, | ||
| 228 | ws: *std.http.Server.WebSocket, | ||
| 229 | ws_fd: std.posix.fd_t, | ||
| 230 | ) ?client.Transport { | ||
| 231 | var backoff_ms: u64 = 0; | ||
| 232 | while (true) { | ||
| 233 | if (client.Transport.open(alloc, target, null, -1)) |t| { | ||
| 234 | return t; | ||
| 235 | } else |_| {} | ||
| 236 | backoff_ms = client.nextBackoffMs(backoff_ms); | ||
| 237 | // The backoff doubles as the WS liveness window. | ||
| 238 | var fds = [_]std.posix.pollfd{ | ||
| 239 | .{ .fd = ws_fd, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 240 | }; | ||
| 241 | const n = std.posix.poll(&fds, @intCast(backoff_ms)) catch return null; | ||
| 242 | if (n > 0 and fds[0].revents != 0) { | ||
| 243 | const msg = ws.readSmallMessage() catch return null; | ||
| 244 | _ = msg; // dropped: nothing to carry it yet | ||
| 245 | } | ||
| 246 | } | ||
| 247 | } | ||
| 80 | 248 | ||
| 81 | test "origin: exactly our two spellings pass, everything else refuses" { | 249 | test "origin: exactly our two spellings pass, everything else refuses" { |
| 82 | const cases = [_]struct { origin: ?[]const u8, port: u16, want: bool }{ | 250 | const cases = [_]struct { origin: ?[]const u8, port: u16, want: bool }{ |
| @@ -134,3 +302,41 @@ test "routes: the three assets with their content types, 404 for the rest" { | |||
| 134 | try std.testing.expectEqual(@as(?Asset, null), route(assets, "/ws/0")); | 302 | try std.testing.expectEqual(@as(?Asset, null), route(assets, "/ws/0")); |
| 135 | try std.testing.expectEqual(@as(?Asset, null), route(assets, "/../src/main.zig")); | 303 | try std.testing.expectEqual(@as(?Asset, null), route(assets, "/../src/main.zig")); |
| 136 | } | 304 | } |
| 305 | |||
| 306 | test "control messages: the closed vocabulary, envelope included" { | ||
| 307 | try std.testing.expectEqualStrings("\x01{\"state\":\"connecting\"}", controlMessage(.connecting)); | ||
| 308 | try std.testing.expectEqualStrings("\x01{\"state\":\"up\"}", controlMessage(.up)); | ||
| 309 | try std.testing.expectEqualStrings("\x01{\"state\":\"reconnecting\"}", controlMessage(.reconnecting)); | ||
| 310 | try std.testing.expectEqualStrings("\x01{\"state\":\"gone\"}", controlMessage(.gone)); | ||
| 311 | } | ||
| 312 | |||
| 313 | test "frame messages: exact framing in, everything else named" { | ||
| 314 | // Types spelled via the enum so the test cannot drift from the wire. | ||
| 315 | const input_byte: u8 = @intFromEnum(proto.MsgType.input); | ||
| 316 | const good = [_]u8{ 0x00, input_byte, 2, 0, 0, 0, 'h', 'i' }; | ||
| 317 | const parsed = try parseFrameMessage(&good); | ||
| 318 | try std.testing.expectEqual(proto.MsgType.input, parsed.t); | ||
| 319 | try std.testing.expectEqualStrings("hi", parsed.payload); | ||
| 320 | |||
| 321 | // Empty payload is legal (detach sends one). | ||
| 322 | const detach_byte: u8 = @intFromEnum(proto.MsgType.detach); | ||
| 323 | const empty = [_]u8{ 0x00, detach_byte, 0, 0, 0, 0 }; | ||
| 324 | try std.testing.expectEqual(proto.MsgType.detach, (try parseFrameMessage(&empty)).t); | ||
| 325 | |||
| 326 | // Each refusal by name. | ||
| 327 | try std.testing.expectError(error.BadEnvelope, parseFrameMessage(&[_]u8{})); | ||
| 328 | try std.testing.expectError(error.BadEnvelope, parseFrameMessage(&[_]u8{ 0x01, 'x' })); | ||
| 329 | try std.testing.expectError(error.ShortFrame, parseFrameMessage(&[_]u8{ 0x00, input_byte, 1, 0 })); | ||
| 330 | // Length says 3, message carries 2. | ||
| 331 | try std.testing.expectError(error.LengthMismatch, parseFrameMessage(&[_]u8{ 0x00, input_byte, 3, 0, 0, 0, 'h', 'i' })); | ||
| 332 | // A trailing byte is upstream's bug, not framing to resync. | ||
| 333 | try std.testing.expectError(error.LengthMismatch, parseFrameMessage(&[_]u8{ 0x00, input_byte, 1, 0, 0, 0, 'h', 'i' })); | ||
| 334 | // Length field claims more than max_payload. | ||
| 335 | var oversize = [_]u8{ 0x00, input_byte, 0, 0, 0, 0 }; | ||
| 336 | std.mem.writeInt(u32, oversize[2..6], proto.max_payload + 1, .little); | ||
| 337 | try std.testing.expectError(error.Oversize, parseFrameMessage(&oversize)); | ||
| 338 | // MsgType is non-exhaustive by design: a type byte from a future | ||
| 339 | // protocol PARSES and transits untouched rather than dying here. | ||
| 340 | const future = try parseFrameMessage(&[_]u8{ 0x00, 0x40, 0, 0, 0, 0 }); | ||
| 341 | try std.testing.expectEqual(@as(u8, 0x40), @intFromEnum(future.t)); | ||
| 342 | } | ||