a617d351
test: three daemon fixtures stop assuming Linux timing and sizes
a73x 2026-09-03 19:44
Commit message
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"); |