24397e0d
refactor: one owner for "the frame at the front of this buffer"
a73x 2026-08-29 10:01
Commit message
src/cli/muxa.zig
| Old | New | ||
|---|---|---|---|
| @@ -526,18 +526,17 @@ const Conn = struct { | |||
| 526 | const q = &self.link.quic; | 526 | const q = &self.link.quic; |
| 527 | while (true) { | 527 | while (true) { |
| 528 | q.cl.pump(); | 528 | q.cl.pump(); |
| 529 | while (try frameFrom(alloc, q.cl.inbound())) |got| { | 529 | while (try proto.takeFrame(alloc, &q.cl.in)) |frame| { |
| 530 | q.cl.consume(got.consumed); | 530 | if (frame.type == want) return frame; |
| 531 | if (got.frame.type == want) return got.frame; | 531 | defer frame.deinit(alloc); |
| 532 | defer got.frame.deinit(alloc); | 532 | // The socket arm's classification again rather than a shared |
| 533 | // The socket arm's rule again rather than a shared helper: | 533 | // helper: that arm answers `DaemonGone` on a closed stream |
| 534 | // the two arms own their frame differently (`got.frame` | 534 | // where this one answers `ConnectionLost`, so the walk that |
| 535 | // paired with `consume`, against a plain `frame`), so there | 535 | // reaches these two lines is not the same walk. |
| 536 | // is no cursor for one reader to hand the other. | 536 | if (frame.type == .snapshot) self.saw_snapshot = true; |
| 537 | if (got.frame.type == .snapshot) self.saw_snapshot = true; | 537 | if (frame.type == .exit_status) { |
| 538 | if (got.frame.type == .exit_status) { | ||
| 539 | if (!self.saw_snapshot) return error.AttachRefused; | 538 | if (!self.saw_snapshot) return error.AttachRefused; |
| 540 | self.session_exit = if (got.frame.payload.len >= 1) got.frame.payload[0] else null; | 539 | self.session_exit = if (frame.payload.len >= 1) frame.payload[0] else null; |
| 541 | return error.SessionExited; | 540 | return error.SessionExited; |
| 542 | } | 541 | } |
| 543 | } | 542 | } |
| @@ -574,84 +573,6 @@ const Conn = struct { | |||
| 574 | } | 573 | } |
| 575 | }; | 574 | }; |
| 576 | 575 | ||
| 577 | /// Copies: the caller consumes the bytes it points at. | ||
| 578 | fn frameFrom( | ||
| 579 | alloc: std.mem.Allocator, | ||
| 580 | buf: []const u8, | ||
| 581 | ) !?struct { frame: proto.Frame, consumed: usize } { | ||
| 582 | const d = try proto.delimitFrame(buf) orelse return null; | ||
| 583 | const payload = try alloc.alloc(u8, d.payload.len); | ||
| 584 | errdefer alloc.free(payload); | ||
| 585 | @memcpy(payload, d.payload); | ||
| 586 | return .{ | ||
| 587 | .frame = .{ .type = d.type, .payload = payload }, | ||
| 588 | .consumed = d.consumed, | ||
| 589 | }; | ||
| 590 | } | ||
| 591 | |||
| 592 | test "frameFrom: a partial tail is not a frame and not an error" { | ||
| 593 | const alloc = std.testing.allocator; | ||
| 594 | |||
| 595 | // Nothing, and less than a header: the two shapes a datagram that | ||
| 596 | // carried the start of a frame leaves behind. | ||
| 597 | try std.testing.expect(try frameFrom(alloc, "") == null); | ||
| 598 | try std.testing.expect(try frameFrom(alloc, &[_]u8{ 0x0a, 1, 0 }) == null); | ||
| 599 | |||
| 600 | // A whole header whose payload is still in flight. This is the case a | ||
| 601 | // blocking read would have sat on: the length is known, the bytes are | ||
| 602 | // not here, and the answer is to wait rather than to read. | ||
| 603 | const partial = [_]u8{ @intFromEnum(proto.MsgType.input), 4, 0, 0, 0, 'a', 'b' }; | ||
| 604 | try std.testing.expect(try frameFrom(alloc, &partial) == null); | ||
| 605 | |||
| 606 | // The same bytes, completed. | ||
| 607 | const whole = [_]u8{ @intFromEnum(proto.MsgType.input), 4, 0, 0, 0, 'a', 'b', 'c', 'd' }; | ||
| 608 | const got = (try frameFrom(alloc, &whole)).?; | ||
| 609 | defer got.frame.deinit(alloc); | ||
| 610 | try std.testing.expectEqual(proto.MsgType.input, got.frame.type); | ||
| 611 | try std.testing.expectEqualStrings("abcd", got.frame.payload); | ||
| 612 | try std.testing.expectEqual(@as(usize, 9), got.consumed); | ||
| 613 | } | ||
| 614 | |||
| 615 | test "frameFrom: two frames in one buffer, walked by consumed" { | ||
| 616 | const alloc = std.testing.allocator; | ||
| 617 | // What a single datagram routinely carries: the push we skip and the | ||
| 618 | // reply we asked for. A walk that stopped after one would leave the | ||
| 619 | // answer sitting in the buffer while the deadline ran out. | ||
| 620 | var buf: std.ArrayList(u8) = .empty; | ||
| 621 | defer buf.deinit(alloc); | ||
| 622 | try proto.appendFrame(&buf, alloc, .pty_mode, &[_]u8{0}); | ||
| 623 | try proto.appendFrame(&buf, alloc, .status_reply, "xy"); | ||
| 624 | |||
| 625 | const first = (try frameFrom(alloc, buf.items)).?; | ||
| 626 | defer first.frame.deinit(alloc); | ||
| 627 | try std.testing.expectEqual(proto.MsgType.pty_mode, first.frame.type); | ||
| 628 | |||
| 629 | const second = (try frameFrom(alloc, buf.items[first.consumed..])).?; | ||
| 630 | defer second.frame.deinit(alloc); | ||
| 631 | try std.testing.expectEqual(proto.MsgType.status_reply, second.frame.type); | ||
| 632 | try std.testing.expectEqualStrings("xy", second.frame.payload); | ||
| 633 | try std.testing.expectEqual(buf.items.len, first.consumed + second.consumed); | ||
| 634 | |||
| 635 | // An empty payload is a frame like any other — `status_req` and | ||
| 636 | // `detach` are nothing else — and must not read as "nothing yet". | ||
| 637 | var empty: std.ArrayList(u8) = .empty; | ||
| 638 | defer empty.deinit(alloc); | ||
| 639 | try proto.appendFrame(&empty, alloc, .detach, ""); | ||
| 640 | const none = (try frameFrom(alloc, empty.items)).?; | ||
| 641 | defer none.frame.deinit(alloc); | ||
| 642 | try std.testing.expectEqual(@as(usize, proto.frame_header_len), none.consumed); | ||
| 643 | } | ||
| 644 | |||
| 645 | test "frameFrom: a length no frame can carry is refused, not allocated" { | ||
| 646 | const alloc = std.testing.allocator; | ||
| 647 | // The peer chose this number. Reading on would mean allocating against | ||
| 648 | // it; the daemon's own walk refuses the same bound the same way. | ||
| 649 | var hdr: [proto.frame_header_len]u8 = undefined; | ||
| 650 | hdr[0] = @intFromEnum(proto.MsgType.input); | ||
| 651 | std.mem.writeInt(u32, hdr[1..5], proto.max_payload + 1, .little); | ||
| 652 | try std.testing.expectError(error.FrameTooLarge, frameFrom(alloc, &hdr)); | ||
| 653 | } | ||
| 654 | |||
| 655 | test "graceMs: flat over a socket, RTT-derived over QUIC, and capped" { | 576 | test "graceMs: flat over a socket, RTT-derived over QUIC, and capped" { |
| 656 | const alloc = std.testing.allocator; | 577 | const alloc = std.testing.allocator; |
| 657 | const local = Conn{ .link = .{ .fd = -1 }, .alloc = alloc }; | 578 | const local = Conn{ .link = .{ .fd = -1 }, .alloc = alloc }; |
src/client/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -657,19 +657,13 @@ pub const Transport = struct { | |||
| 657 | .quic => |cl| { | 657 | .quic => |cl| { |
| 658 | // Death is checked after the pump, so bytes that arrived in the | 658 | // Death is checked after the pump, so bytes that arrived in the |
| 659 | // same pass as the close are still delivered before the tear. | 659 | // same pass as the close are still delivered before the tear. |
| 660 | const buf = cl.inbound(); | 660 | const got = proto.takeFrame(alloc, &cl.in) catch |err| switch (err) { |
| 661 | if (buf.len < 5) return if (cl.dead) .closed else .incomplete; | 661 | // Not a transport event, and it stays loud. |
| 662 | const len = std.mem.readInt(u32, buf[1..5], .little); | 662 | error.OutOfMemory => return err, |
| 663 | if (len > proto.max_payload) return .closed; | 663 | else => return .closed, |
| 664 | if (buf.len < 5 + len) return if (cl.dead) .closed else .incomplete; | ||
| 665 | const payload = try alloc.alloc(u8, len); | ||
| 666 | @memcpy(payload, buf[5 .. 5 + len]); | ||
| 667 | const frame: proto.Frame = .{ | ||
| 668 | .type = @enumFromInt(buf[0]), | ||
| 669 | .payload = payload, | ||
| 670 | }; | 664 | }; |
| 671 | cl.consume(5 + len); | 665 | if (got) |frame| return .{ .frame = frame }; |
| 672 | return .{ .frame = frame }; | 666 | return if (cl.dead) .closed else .incomplete; |
| 673 | }, | 667 | }, |
| 674 | .fd, .pipe => { | 668 | .fd, .pipe => { |
| 675 | const frame = (proto.readFrame(alloc, self.conn.r) catch |err| switch (err) { | 669 | const frame = (proto.readFrame(alloc, self.conn.r) catch |err| switch (err) { |
src/client/quic_client.zig
| Old | New | ||
|---|---|---|---|
| @@ -410,35 +410,8 @@ pub const Client = struct { | |||
| 410 | if (n > 0) self.drain(); | 410 | if (n > 0) self.drain(); |
| 411 | return n; | 411 | return n; |
| 412 | } | 412 | } |
| 413 | |||
| 414 | /// Stream bytes received and not yet consumed. | ||
| 415 | pub fn inbound(self: *const Client) []const u8 { | ||
| 416 | return self.in.items; | ||
| 417 | } | ||
| 418 | |||
| 419 | /// Drop `n` bytes off the front of the inbound buffer. | ||
| 420 | pub fn consume(self: *Client, n: usize) void { | ||
| 421 | self.in.replaceRangeAssumeCapacity(0, @min(n, self.in.items.len), &.{}); | ||
| 422 | } | ||
| 423 | }; | 413 | }; |
| 424 | 414 | ||
| 425 | test "Client.consume: takes from the front and keeps the rest" { | ||
| 426 | const alloc = std.testing.allocator; | ||
| 427 | var cl: Client = .{ .alloc = alloc, .fd = -1, .out = .{ .buf = &.{} } }; | ||
| 428 | defer cl.in.deinit(alloc); | ||
| 429 | |||
| 430 | try cl.in.appendSlice(alloc, "abcdefgh"); | ||
| 431 | cl.consume(3); | ||
| 432 | try std.testing.expectEqualStrings("defgh", cl.inbound()); | ||
| 433 | cl.consume(0); | ||
| 434 | try std.testing.expectEqualStrings("defgh", cl.inbound()); | ||
| 435 | // Consuming more than is there is not an error: a frame walk that asked | ||
| 436 | // for a payload it had already been handed would otherwise corrupt the | ||
| 437 | // buffer rather than say so. | ||
| 438 | cl.consume(99); | ||
| 439 | try std.testing.expectEqualStrings("", cl.inbound()); | ||
| 440 | } | ||
| 441 | |||
| 442 | // Plain, not recursive: this module reaches the QUIC stack's @cImport, and | 415 | // Plain, not recursive: this module reaches the QUIC stack's @cImport, and |
| 443 | // a recursive walk would force-analyze the entire wolfSSL/ngtcp2 namespace. | 416 | // a recursive walk would force-analyze the entire wolfSSL/ngtcp2 namespace. |
| 444 | test { | 417 | test { |
src/engine/protocol.zig
| Old | New | ||
|---|---|---|---|
| @@ -103,6 +103,21 @@ pub fn delimitFrame(buf: []const u8) !?Delimited { | |||
| 103 | }; | 103 | }; |
| 104 | } | 104 | } |
| 105 | 105 | ||
| 106 | /// The frame at the front of `buf`, or null while only part of one is | ||
| 107 | /// here — the ordinary state of a byte stream, never an error. An error | ||
| 108 | /// is a length no frame can carry, and every caller answers it the same | ||
| 109 | /// way: it has a connection, and drops it. | ||
| 110 | pub fn takeFrame(alloc: std.mem.Allocator, buf: *std.ArrayList(u8)) !?Frame { | ||
| 111 | const d = (try delimitFrame(buf.items)) orelse return null; | ||
| 112 | // Copied out and `buf` shifted BEFORE the caller dispatches, because | ||
| 113 | // a handler can reallocate `buf` under a slice into it; the payload | ||
| 114 | // is then the caller's frame to free. | ||
| 115 | const payload = try alloc.alloc(u8, d.payload.len); | ||
| 116 | @memcpy(payload, d.payload); | ||
| 117 | buf.replaceRangeAssumeCapacity(0, d.consumed, &.{}); | ||
| 118 | return .{ .type = d.type, .payload = payload }; | ||
| 119 | } | ||
| 120 | |||
| 106 | pub fn writeFrame(fd: std.posix.fd_t, t: MsgType, payload: []const u8) !void { | 121 | pub fn writeFrame(fd: std.posix.fd_t, t: MsgType, payload: []const u8) !void { |
| 107 | var hdr: [5]u8 = undefined; | 122 | var hdr: [5]u8 = undefined; |
| 108 | hdr[0] = @intFromEnum(t); | 123 | hdr[0] = @intFromEnum(t); |
| @@ -1203,6 +1218,81 @@ test "appendFrame concatenates frames the way a queue would" { | |||
| 1203 | try std.testing.expectEqualSlices(u8, &.{7}, f2.payload); | 1218 | try std.testing.expectEqualSlices(u8, &.{7}, f2.payload); |
| 1204 | } | 1219 | } |
| 1205 | 1220 | ||
| 1221 | test "takeFrame: a partial tail is not a frame and not an error" { | ||
| 1222 | const alloc = std.testing.allocator; | ||
| 1223 | var buf: std.ArrayList(u8) = .empty; | ||
| 1224 | defer buf.deinit(alloc); | ||
| 1225 | |||
| 1226 | // Nothing, and less than a header: the two shapes a datagram that | ||
| 1227 | // carried the start of a frame leaves behind. | ||
| 1228 | try std.testing.expect(try takeFrame(alloc, &buf) == null); | ||
| 1229 | try buf.appendSlice(alloc, &[_]u8{ 0x0a, 1, 0 }); | ||
| 1230 | try std.testing.expect(try takeFrame(alloc, &buf) == null); | ||
| 1231 | |||
| 1232 | // A whole header whose payload is still in flight. This is the case a | ||
| 1233 | // blocking read would have sat on: the length is known, the bytes are | ||
| 1234 | // not here, and the answer is to wait rather than to read. | ||
| 1235 | buf.clearRetainingCapacity(); | ||
| 1236 | const partial = [_]u8{ @intFromEnum(MsgType.input), 4, 0, 0, 0, 'a', 'b' }; | ||
| 1237 | try buf.appendSlice(alloc, &partial); | ||
| 1238 | try std.testing.expect(try takeFrame(alloc, &buf) == null); | ||
| 1239 | // A null answer consumed nothing: the partial tail is still there, | ||
| 1240 | // byte for byte, for the bytes that complete it. | ||
| 1241 | try std.testing.expectEqualSlices(u8, &partial, buf.items); | ||
| 1242 | |||
| 1243 | // The same bytes, completed. | ||
| 1244 | try buf.appendSlice(alloc, "cd"); | ||
| 1245 | const got = (try takeFrame(alloc, &buf)).?; | ||
| 1246 | defer got.deinit(alloc); | ||
| 1247 | try std.testing.expectEqual(MsgType.input, got.type); | ||
| 1248 | try std.testing.expectEqualStrings("abcd", got.payload); | ||
| 1249 | try std.testing.expectEqual(@as(usize, 0), buf.items.len); | ||
| 1250 | } | ||
| 1251 | |||
| 1252 | test "takeFrame: two frames in one buffer, walked one call at a time" { | ||
| 1253 | const alloc = std.testing.allocator; | ||
| 1254 | // What a single datagram routinely carries: the push we skip and the | ||
| 1255 | // reply we asked for. A walk that stopped after one would leave the | ||
| 1256 | // answer sitting in the buffer while the deadline ran out. | ||
| 1257 | var buf: std.ArrayList(u8) = .empty; | ||
| 1258 | defer buf.deinit(alloc); | ||
| 1259 | try appendFrame(&buf, alloc, .pty_mode, &[_]u8{0}); | ||
| 1260 | try appendFrame(&buf, alloc, .status_reply, "xy"); | ||
| 1261 | |||
| 1262 | const first = (try takeFrame(alloc, &buf)).?; | ||
| 1263 | defer first.deinit(alloc); | ||
| 1264 | try std.testing.expectEqual(MsgType.pty_mode, first.type); | ||
| 1265 | |||
| 1266 | const second = (try takeFrame(alloc, &buf)).?; | ||
| 1267 | defer second.deinit(alloc); | ||
| 1268 | try std.testing.expectEqual(MsgType.status_reply, second.type); | ||
| 1269 | try std.testing.expectEqualStrings("xy", second.payload); | ||
| 1270 | try std.testing.expectEqual(@as(usize, 0), buf.items.len); | ||
| 1271 | |||
| 1272 | // An empty payload is a frame like any other — `status_req` and | ||
| 1273 | // `detach` are nothing else — and must not read as "nothing yet". | ||
| 1274 | var empty: std.ArrayList(u8) = .empty; | ||
| 1275 | defer empty.deinit(alloc); | ||
| 1276 | try appendFrame(&empty, alloc, .detach, ""); | ||
| 1277 | const none = (try takeFrame(alloc, &empty)).?; | ||
| 1278 | defer none.deinit(alloc); | ||
| 1279 | try std.testing.expectEqual(MsgType.detach, none.type); | ||
| 1280 | try std.testing.expectEqual(@as(usize, 0), empty.items.len); | ||
| 1281 | } | ||
| 1282 | |||
| 1283 | test "takeFrame: a length no frame can carry is refused, not allocated" { | ||
| 1284 | const alloc = std.testing.allocator; | ||
| 1285 | // The peer chose this number. Reading on would mean allocating against | ||
| 1286 | // it; every caller answers the refusal by dropping the connection. | ||
| 1287 | var buf: std.ArrayList(u8) = .empty; | ||
| 1288 | defer buf.deinit(alloc); | ||
| 1289 | try buf.append(alloc, @intFromEnum(MsgType.input)); | ||
| 1290 | var len: [4]u8 = undefined; | ||
| 1291 | std.mem.writeInt(u32, &len, max_payload + 1, .little); | ||
| 1292 | try buf.appendSlice(alloc, &len); | ||
| 1293 | try std.testing.expectError(error.FrameTooLarge, takeFrame(alloc, &buf)); | ||
| 1294 | } | ||
| 1295 | |||
| 1206 | test "scrollback request encode/decode round trip" { | 1296 | test "scrollback request encode/decode round trip" { |
| 1207 | const req = try decodeScrollbackReq(&encodeScrollbackReq(70000, 24)); | 1297 | const req = try decodeScrollbackReq(&encodeScrollbackReq(70000, 24)); |
| 1208 | try std.testing.expectEqual(@as(u32, 70000), req.start); | 1298 | try std.testing.expectEqual(@as(u32, 70000), req.start); |
src/server/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -1868,21 +1868,6 @@ pub const Server = struct { | |||
| 1868 | } | 1868 | } |
| 1869 | } | 1869 | } |
| 1870 | 1870 | ||
| 1871 | /// The frame at the front of `buf`, or null while only part of one is | ||
| 1872 | /// here — the ordinary state of a byte stream, never an error. An error | ||
| 1873 | /// is a length no frame can carry, and every caller answers it the same | ||
| 1874 | /// way: it has a connection, and drops it. | ||
| 1875 | fn takeFrame(alloc: std.mem.Allocator, buf: *std.ArrayList(u8)) !?proto.Frame { | ||
| 1876 | const d = (try proto.delimitFrame(buf.items)) orelse return null; | ||
| 1877 | // Copied out and `buf` shifted BEFORE the caller dispatches, because | ||
| 1878 | // a handler can reallocate `buf` under a slice into it; the payload | ||
| 1879 | // is then the caller's frame to free. | ||
| 1880 | const payload = try alloc.alloc(u8, d.payload.len); | ||
| 1881 | @memcpy(payload, d.payload); | ||
| 1882 | buf.replaceRangeAssumeCapacity(0, d.consumed, &.{}); | ||
| 1883 | return .{ .type = d.type, .payload = payload }; | ||
| 1884 | } | ||
| 1885 | |||
| 1886 | /// Feed bytes that arrived for client `i`, extracting whole frames as | 1871 | /// Feed bytes that arrived for client `i`, extracting whole frames as |
| 1887 | /// they complete. Every transport lands here — a socket read and a QUIC | 1872 | /// they complete. Every transport lands here — a socket read and a QUIC |
| 1888 | /// stream chunk from the shared UDP socket alike — so that from the | 1873 | /// stream chunk from the shared UDP socket alike — so that from the |
| @@ -1903,7 +1888,7 @@ pub const Server = struct { | |||
| 1903 | 1888 | ||
| 1904 | while (true) { | 1889 | while (true) { |
| 1905 | if (self.clients[i] == null) return; // a handler dropped it | 1890 | if (self.clients[i] == null) return; // a handler dropped it |
| 1906 | const frame = takeFrame(self.alloc, &self.clients[i].?.inbound) catch { | 1891 | const frame = proto.takeFrame(self.alloc, &self.clients[i].?.inbound) catch { |
| 1907 | self.dropClient(i); | 1892 | self.dropClient(i); |
| 1908 | return; | 1893 | return; |
| 1909 | } orelse return; // header or payload still coming | 1894 | } orelse return; // header or payload still coming |
| @@ -2499,7 +2484,7 @@ pub const Server = struct { | |||
| 2499 | fn drainObserver(self: *Server, i: usize) void { | 2484 | fn drainObserver(self: *Server, i: usize) void { |
| 2500 | for (0..max_observer_frames_per_pump) |_| { | 2485 | for (0..max_observer_frames_per_pump) |_| { |
| 2501 | const o = if (self.observers[i]) |*p| p else return; | 2486 | const o = if (self.observers[i]) |*p| p else return; |
| 2502 | const frame = takeFrame(self.alloc, &o.inbound) catch { | 2487 | const frame = proto.takeFrame(self.alloc, &o.inbound) catch { |
| 2503 | self.dropObserver(i); | 2488 | self.dropObserver(i); |
| 2504 | return; | 2489 | return; |
| 2505 | } orelse return; | 2490 | } orelse return; |