a73x

a617d351

test: three daemon fixtures stop assuming Linux timing and sizes

a73x   2026-09-03 19:44

Commit message
test: three daemon fixtures stop assuming Linux timing and sizes

The scrollback fixture read the ECHO of the command it typed. macOS's
/bin/sh is bash, whose readline redraws the input line when SIGWINCH
arrives, and the resize from the second attach lands while that line is
being echoed — so the prompt was erased and every column moved. Every
output row carries the same marker now and the selections read a row well
past the echo, so no assertion depends on the shell's redraw. The two
attaches are also awaited in the order they were dialled, since the daemon
is on its own thread here and "B attached last" was a hope.

The unread-observer fixture capped the socket at 4 KB, which buys 1792
bytes of room on Linux and the full 4096 on Darwin, and the burst's replies
fit inside the latter. Both ends are capped now, at 1 KB, which lands near
1024 on each — Linux charges AF_UNIX bytes to the sender's send buffer and
Darwin to the receiver's receive buffer.

probeEmptiedDaemon wrote "die" as soon as the daemon answered its attach.
The snapshot proves the daemon seated the client, never that the script
behind the pty reached its first read, and a line written before that sits
in the tty queue for a shell that flushes it on startup — so the session
outlived a test whose whole subject is the session ending. One round trip
through the script closes the race, which is open on Linux too.

The QUIC routing test read its probe socket once, immediately. Loopback UDP
is not synchronous everywhere: Linux hands the datagram over inside the
sendto and Darwin does not, so the Retry arrived after the read looking for
it. probeGotAnything takes a budget now, and a caller asserting ABSENCE
passes 0 and keeps the immediate read it always did.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SakwJEwD9dXBoRP5kWbemW

src/server/quic_server.zig
Old New
@@ -1392,19 +1392,19 @@ test "Listener: a packet addressed to any advertised CID reaches its connection"
1392 try std.posix.getsockname(probe, @ptrCast(&probe_addr), &probe_len); 1392 try std.posix.getsockname(probe, @ptrCast(&probe_addr), &probe_len);
1393 1393
1394 var pkt: [1300]u8 = undefined; 1394 var pkt: [1300]u8 = undefined;
1395 try std.testing.expect(!probeGotAnything(probe)); 1395 try std.testing.expect(!probeGotAnything(probe, 0));
1396 1396
1397 // Addressed to a CID this connection advertised: delivered, not 1397 // Addressed to a CID this connection advertised: delivered, not
1398 // answered. 1398 // answered.
1399 buildInitial(&pkt, sec.data[0..sec.datalen]); 1399 buildInitial(&pkt, sec.data[0..sec.datalen]);
1400 setup.l.route(&pkt, &probe_addr, probe_len); 1400 setup.l.route(&pkt, &probe_addr, probe_len);
1401 try std.testing.expect(!probeGotAnything(probe)); 1401 try std.testing.expect(!probeGotAnything(probe, 0));
1402 1402
1403 // Addressed to nobody: `accept` answers with a Retry, which is what 1403 // Addressed to nobody: `accept` answers with a Retry, which is what
1404 // every migrated packet used to get. 1404 // every migrated packet used to get.
1405 buildInitial(&pkt, &stranger_cid); 1405 buildInitial(&pkt, &stranger_cid);
1406 setup.l.route(&pkt, &probe_addr, probe_len); 1406 setup.l.route(&pkt, &probe_addr, probe_len);
1407 try std.testing.expect(probeGotAnything(probe)); 1407 try std.testing.expect(probeGotAnything(probe, 2000));
1408 } 1408 }
1409 1409
1410 /// A syntactically valid, cryptographically meaningless Initial packet 1410 /// A syntactically valid, cryptographically meaningless Initial packet
@@ -1432,8 +1432,15 @@ fn buildInitial(pkt: *[1300]u8, dcid: []const u8) void {
1432 std.mem.writeInt(u16, pkt[i..][0..2], rest | 0x4000, .big); 1432 std.mem.writeInt(u16, pkt[i..][0..2], rest | 0x4000, .big);
1433 } 1433 }
1434 1434
1435 fn probeGotAnything(fd: std.posix.fd_t) bool { 1435 fn probeGotAnything(fd: std.posix.fd_t, budget_ms: i32) bool {
1436 var buf: [2048]u8 = undefined; 1436 var buf: [2048]u8 = undefined;
1437 var fds = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }};
1438 // A budget, because loopback UDP is not synchronous everywhere: Linux
1439 // hands the datagram over inside the sendto, so an immediate recv finds
1440 // it, and Darwin does not — the Retry this test is waiting for arrived
1441 // after the read that was looking for it. A caller asserting ABSENCE
1442 // passes 0 and keeps the immediate read it always did.
1443 if (budget_ms > 0) _ = std.posix.poll(&fds, budget_ms) catch return false;
1437 const n = std.posix.recv(fd, &buf, 0) catch return false; 1444 const n = std.posix.recv(fd, &buf, 0) catch return false;
1438 return n > 0; 1445 return n > 0;
1439 } 1446 }
src/server/server_test_attach.zig
Old New
@@ -539,20 +539,28 @@ test "Server: scrollback fetch is per-client and independent" {
539 const b = try dial.dialAttach(td.sock_path, 100, 30); 539 const b = try dial.dialAttach(td.sock_path, 100, 30);
540 defer b.close(); 540 defer b.close();
541 541
542 // Both attaches must have REACHED the pty before anything is typed. 542 // B attached LAST, and this waits for the daemon to have processed each
543 // `seatClient` resizes before it answers, so a state frame on each fd is 543 // attach in the order they were dialled — a state frame only exists once
544 // proof that the SIGWINCH for that attach is already out. Without the 544 // `seatClient` has run for that fd. The daemon is on its own thread here,
545 // wait, macOS's /bin/sh is bash and its readline takes the resize MID-ECHO 545 // so without the waits A's attach could be seated after B's and the grid
546 // and redraws the input line over itself — carriage return, erase to end 546 // would be at A's 80x24, which is the opposite of what the selections
547 // of line, prompt again — so the history row this test selects from lost 547 // below are about.
548 // its first characters and every column after them moved. Linux's dash has
549 // no readline and redraws nothing, which is why the race only ever showed
550 // on the Mac.
551 _ = try h.firstStateFrame(alloc, a.handle, 10_000); 548 _ = try h.firstStateFrame(alloc, a.handle, 10_000);
552 _ = try h.firstStateFrame(alloc, b.handle, 10_000); 549 _ = try h.firstStateFrame(alloc, b.handle, 10_000);
553 550
554 // Typed by B; both clients see the history it produces. 551 // Typed by B; both clients see the history it produces. Every output row
555 try proto.writeFrame(b.handle, .input, "seq 1 100\n"); 552 // carries the SAME marker, and the selections below read a row well down
553 // the list, so no assertion here depends on where the shell's own echo of
554 // the command landed or on what it looked like. That was not a spare
555 // precaution: macOS's /bin/sh is bash, whose readline redraws the input
556 // line whenever SIGWINCH arrives — carriage return, erase to end of line,
557 // prompt again — and the resize from the second attach arrives while that
558 // line is being echoed. Measured on macOS 26, the request that reads
559 // ">>seq 1 100" on Linux read "seq 1 100" there, and moving the target one
560 // row down landed on the echo instead of past it. Which column a
561 // REDRAWN input line starts in is the shell's business; a row the shell
562 // printed is the same on both.
563 try proto.writeFrame(b.handle, .input, "seq 1 100 | sed 's/.*/xxSELECTEDxx/'\n");
556 564
557 for ([_]std.posix.fd_t{ a.handle, b.handle }) |fd| { 565 for ([_]std.posix.fd_t{ a.handle, b.handle }) |fd| {
558 try std.testing.expect(try awaitHistoryRows(alloc, fd, 50, 10_000)); 566 try std.testing.expect(try awaitHistoryRows(alloc, fd, 50, 10_000));
@@ -564,8 +572,8 @@ test "Server: scrollback fetch is per-client and independent" {
564 // claim A's size nor disclose the reply to B. 572 // claim A's size nor disclose the reply to B.
565 const exact = proto.encodeSelectionReq(.{ 573 const exact = proto.encodeSelectionReq(.{
566 .id = 77, 574 .id = 77,
567 .anchor = .{ .row = 0, .col = 2 }, 575 .anchor = .{ .row = 10, .col = 2 },
568 .active = .{ .row = 0, .col = 10 }, 576 .active = .{ .row = 10, .col = 9 },
569 }); 577 });
570 const invalid = proto.encodeSelectionReq(.{ 578 const invalid = proto.encodeSelectionReq(.{
571 .id = 78, 579 .id = 78,
@@ -574,13 +582,13 @@ test "Server: scrollback fetch is per-client and independent" {
574 }); 582 });
575 const one = proto.encodeSelectionReq(.{ 583 const one = proto.encodeSelectionReq(.{
576 .id = 79, 584 .id = 79,
577 .anchor = .{ .row = 1, .col = 0 }, 585 .anchor = .{ .row = 10, .col = 0 },
578 .active = .{ .row = 1, .col = 0 }, 586 .active = .{ .row = 10, .col = 0 },
579 }); 587 });
580 const sentinel = proto.encodeSelectionReq(.{ 588 const sentinel = proto.encodeSelectionReq(.{
581 .id = 80, 589 .id = 80,
582 .anchor = .{ .row = 0, .col = 2 }, 590 .anchor = .{ .row = 10, .col = 2 },
583 .active = .{ .row = 0, .col = 10 }, 591 .active = .{ .row = 10, .col = 9 },
584 }); 592 });
585 try proto.writeFrame(a.handle, .selection_req, &exact); 593 try proto.writeFrame(a.handle, .selection_req, &exact);
586 try proto.writeFrame(a.handle, .selection_req, &invalid); 594 try proto.writeFrame(a.handle, .selection_req, &invalid);
@@ -598,7 +606,7 @@ test "Server: scrollback fetch is per-client and independent" {
598 0 => { 606 0 => {
599 try std.testing.expectEqual(@as(u32, 77), reply.id); 607 try std.testing.expectEqual(@as(u32, 77), reply.id);
600 try std.testing.expectEqual(proto.SelectionStatus.ok, reply.status); 608 try std.testing.expectEqual(proto.SelectionStatus.ok, reply.status);
601 try std.testing.expectEqualStrings("seq 1 100", reply.text); 609 try std.testing.expectEqualStrings("SELECTED", reply.text);
602 }, 610 },
603 1 => { 611 1 => {
604 try std.testing.expectEqual(@as(u32, 78), reply.id); 612 try std.testing.expectEqual(@as(u32, 78), reply.id);
@@ -608,7 +616,7 @@ test "Server: scrollback fetch is per-client and independent" {
608 2 => { 616 2 => {
609 try std.testing.expectEqual(@as(u32, 79), reply.id); 617 try std.testing.expectEqual(@as(u32, 79), reply.id);
610 try std.testing.expectEqual(proto.SelectionStatus.ok, reply.status); 618 try std.testing.expectEqual(proto.SelectionStatus.ok, reply.status);
611 try std.testing.expectEqualStrings("1", reply.text); 619 try std.testing.expectEqualStrings("x", reply.text);
612 }, 620 },
613 3 => { 621 3 => {
614 // Reaching this request proves the malformed frame before 622 // Reaching this request proves the malformed frame before
@@ -617,7 +625,7 @@ test "Server: scrollback fetch is per-client and independent" {
617 // assertion. 625 // assertion.
618 try std.testing.expectEqual(@as(u32, 80), reply.id); 626 try std.testing.expectEqual(@as(u32, 80), reply.id);
619 try std.testing.expectEqual(proto.SelectionStatus.ok, reply.status); 627 try std.testing.expectEqual(proto.SelectionStatus.ok, reply.status);
620 try std.testing.expectEqualStrings("seq 1 100", reply.text); 628 try std.testing.expectEqualStrings("SELECTED", reply.text);
621 }, 629 },
622 else => unreachable, 630 else => unreachable,
623 } 631 }
@@ -1404,8 +1412,13 @@ test "Server: an observer that never reads its replies is dropped, not allowed t
1404 // send buffer, while Darwin hands them to the peer and charges the 1412 // send buffer, while Darwin hands them to the peer and charges the
1405 // RECEIVER's receive buffer, so capping only the daemon's end left the 1413 // RECEIVER's receive buffer, so capping only the daemon's end left the
1406 // Mac with room for the whole burst and no drop to grade. 1414 // Mac with room for the whole burst and no drop to grade.
1415 // 1 KB and not 4 KB: the replies to this burst are a few bytes each, so
1416 // the cap has to sit below their total on BOTH systems, and what a given
1417 // request buys differs — measured 2026-09-03, asking for 4096 leaves
1418 // 1792 bytes of room on Linux and the full 4096 on Darwin, which the
1419 // whole burst fitted inside. Asking for 1024 leaves about 1024 on each.
1407 const fd = td.srv.observers[0].?.fd; 1420 const fd = td.srv.observers[0].?.fd;
1408 const small: c_int = 4096; 1421 const small: c_int = 1024;
1409 try std.posix.setsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.SNDBUF, std.mem.asBytes(&small)); 1422 try std.posix.setsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.SNDBUF, std.mem.asBytes(&small));
1410 try std.posix.setsockopt(peer.handle, std.posix.SOL.SOCKET, std.posix.SO.RCVBUF, std.mem.asBytes(&small)); 1423 try std.posix.setsockopt(peer.handle, std.posix.SOL.SOCKET, std.posix.SO.RCVBUF, std.mem.asBytes(&small));
1411 1424
@@ -1416,10 +1429,19 @@ test "Server: an observer that never reads its replies is dropped, not allowed t
1416 for (0..400) |_| try proto.appendFrame(&burst, alloc, .sessions_req, ""); 1429 for (0..400) |_| try proto.appendFrame(&burst, alloc, .sessions_req, "");
1417 try proto.writeAllFd(peer.handle, burst.items); 1430 try proto.writeAllFd(peer.handle, burst.items);
1418 1431
1432 // Pumps, plural, and bounded: one pump answers at most
1433 // `max_observer_frames_per_pump` of the burst, and how many of those
1434 // replies it takes to fill a socket is the kernel's business — Darwin's
1435 // smallest usable buffer holds more of them than Linux's. What is being
1436 // graded is that the daemon DROPS this peer rather than waiting on it,
1437 // and that no pump along the way blocked.
1419 const t0 = std.time.milliTimestamp(); 1438 const t0 = std.time.milliTimestamp();
1420 try td.srv.pumpOnce(20); 1439 for (0..64) |_| {
1440 try td.srv.pumpOnce(20);
1441 if (td.srv.observers[0] == null) break;
1442 }
1421 const spent = std.time.milliTimestamp() - t0; 1443 const spent = std.time.milliTimestamp() - t0;
1422 // The claim: the pump came back. A blocking write here never returns 1444 // The claim: every pump came back. A blocking write here never returns
1423 // until the peer reads, and no session on the box is served meanwhile. 1445 // until the peer reads, and no session on the box is served meanwhile.
1424 try std.testing.expect(spent < 5_000); 1446 try std.testing.expect(spent < 5_000);
1425 try std.testing.expect(td.srv.observers[0] == null); 1447 try std.testing.expect(td.srv.observers[0] == null);
src/server/server_test_session.zig
Old New
@@ -958,6 +958,24 @@ fn probeEmptiedDaemon(alloc: std.mem.Allocator, sock_path: []const u8) !void {
958 const snap = (try awaitFrameOn(alloc, c1.handle, .snapshot, 4000)) orelse 958 const snap = (try awaitFrameOn(alloc, c1.handle, .snapshot, 4000)) orelse
959 return error.NoFirstSnapshot; 959 return error.NoFirstSnapshot;
960 snap.deinit(alloc); 960 snap.deinit(alloc);
961
962 // The liveness half, and not a nicety: the snapshot proves the DAEMON
963 // seated this client, never that the script behind the pty has reached
964 // its first `read`. A line written before it does sits in the tty's input
965 // queue, and a shell that touches the terminal on startup discards it —
966 // so `die` was echoed, never read, and the session outlived a test whose
967 // whole subject is the session ending. Seen on macOS, where /bin/sh is
968 // bash and the script's startup is longer; the same race is open on
969 // Linux and merely lost less often. One round trip through the script
970 // closes it.
971 var rep = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
972 defer rep.deinit();
973 try proto.writeFrame(c1.handle, .input, "alive\n");
974 if (!try h.awaitReplicaText(alloc, c1.handle, 8000, .{
975 .replica = rep,
976 .needle = "echo:alive",
977 })) return error.ShellNeverRead;
978
961 try proto.writeFrame(c1.handle, .input, "die 7\n"); 979 try proto.writeFrame(c1.handle, .input, "die 7\n");
962 980
963 try waitStats(alloc, sock_path, "sessions=0"); 981 try waitStats(alloc, sock_path, "sessions=0");