a73x

4ab43004

test: the nag test pins the grace it is named for

a73x   2026-09-04 10:16

Commit message
test: the nag test pins the grace it is named for

Its only assertion was that the shell is dead, and a shell that died to the
FIRST SIGTERM is dead too. So a fixture that let that happen passed the test
while the SIGKILL deadline it is named for was never reached — not
hypothetical, it is what a readiness wait on the wrong session did here
twice, and the only visible difference was the suite running faster.

The duration is the pin now, off a wall clock started at the accepted end
rather than the loop's nominal turn counter. Living past term_grace_ms is
what proves the shell ignored every TERM and left to the SIGKILL. The upper
bound at 4x catches a deadline that moved a few turns; one that moved
forever is already caught by the loop's budget and the !alive above, so it
is deliberately loose rather than a timing gate — measured against a 500 ms
grace, 661 ms on Linux and 668 ms on macOS, so a bound at 2x would be a
flake waiting for a slower box. Both failures name themselves and print the
number.

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

src/server/server_test_harness.zig
Old New
@@ -1,6 +1,6 @@
1 const std = @import("std"); 1 const std = @import("std");
2 const Engine = @import("term").engine.Engine; 2 const Engine = @import("term").engine.Engine;
3 const Pty = @import("pty").Pty; 3 pub const Pty = @import("pty").Pty;
4 const proto = @import("term").protocol; 4 const proto = @import("term").protocol;
5 const replica_mod = @import("term").replica; 5 const replica_mod = @import("term").replica;
6 const quic = @import("quic"); 6 const quic = @import("quic");
src/server/server_test_session.zig
Old New
@@ -8,6 +8,7 @@ const xdg = @import("xdg");
8 const TmpDir = @import("testtmp").TmpDir; 8 const TmpDir = @import("testtmp").TmpDir;
9 const h = @import("server_test_harness.zig"); 9 const h = @import("server_test_harness.zig");
10 const dial = h.dial; 10 const dial = h.dial;
11 const Pty = h.Pty;
11 const srv_mod = @import("server.zig"); 12 const srv_mod = @import("server.zig");
12 const SessionTable = @import("server_sessions.zig").SessionTable; 13 const SessionTable = @import("server_sessions.zig").SessionTable;
13 const Server = srv_mod.Server; 14 const Server = srv_mod.Server;
@@ -1665,6 +1666,9 @@ test "Server: a repeated end_req does not push the SIGKILL deadline out" {
1665 const r = (try awaitFrame(alloc, &td.srv, c.handle, .end_reply, 200)) orelse return error.NoEndReply; 1666 const r = (try awaitFrame(alloc, &td.srv, c.handle, .end_reply, 200)) orelse return error.NoEndReply;
1666 defer r.deinit(alloc); 1667 defer r.deinit(alloc);
1667 try std.testing.expect((proto.parseEndReply(r.payload) orelse return error.BadEndReply).accepted); 1668 try std.testing.expect((proto.parseEndReply(r.payload) orelse return error.BadEndReply).accepted);
1669 // The clock the assertions below read, started where the daemon started
1670 // its own: `endSession` arms the deadline when it accepts.
1671 const t_end = std.time.milliTimestamp();
1668 1672
1669 // An observer nagging faster than the grace: if each accept restarted the 1673 // An observer nagging faster than the grace: if each accept restarted the
1670 // clock, the shell would outlive every deadline it was ever given. 1674 // clock, the shell would outlive every deadline it was ever given.
@@ -1679,5 +1683,39 @@ test "Server: a repeated end_req does not push the SIGKILL deadline out" {
1679 try td.srv.pumpOnce(20); 1683 try td.srv.pumpOnce(20);
1680 std.Thread.sleep(60 * std.time.ns_per_ms); 1684 std.Thread.sleep(60 * std.time.ns_per_ms);
1681 } 1685 }
1686 const outlived_ms = std.time.milliTimestamp() - t_end;
1682 try std.testing.expect(!alive(pid)); 1687 try std.testing.expect(!alive(pid));
1688
1689 // A dead shell is only half the claim, and it was the only half asserted:
1690 // a shell that died to the FIRST SIGTERM is dead too, and a fixture that
1691 // let that happen passed this test while the deadline it is named for was
1692 // never reached. That is not hypothetical — it is what a wait on the wrong
1693 // session did here, twice, and the difference was visible only as the
1694 // suite running faster. So the duration is the pin now. A wall clock and
1695 // not the loop counter above, which counts nominal turns rather than time.
1696 //
1697 // The LOWER bound is the real one: living past the grace is what proves
1698 // the shell ignored every TERM and left to the SIGKILL. The upper bound
1699 // catches a deadline that moved a few turns; a deadline that moved
1700 // FOREVER is already caught by the loop's own budget and the `!alive`
1701 // above, so this one is deliberately loose rather than a timing gate.
1702 // Measured 2026-09-04 against a 500 ms grace: 661 ms on Linux and 668 ms
1703 // on macOS under `make check`. A bound at 2x would sit 330 ms above that,
1704 // which is a flake waiting for a slower box.
1705 if (outlived_ms < Pty.term_grace_ms) {
1706 std.debug.print(
1707 "the shell died {d} ms after the accepted end, INSIDE the {d} ms grace: " ++
1708 "it did not ignore the TERM, so the SIGKILL deadline was never reached\n",
1709 .{ outlived_ms, Pty.term_grace_ms },
1710 );
1711 return error.ShellDiedInsideTheGrace;
1712 }
1713 if (outlived_ms >= 4 * Pty.term_grace_ms) {
1714 std.debug.print(
1715 "the shell lived {d} ms after the accepted end, past 4x the {d} ms grace: " ++
1716 "the nagging looks to have pushed the deadline out\n",
1717 .{ outlived_ms, Pty.term_grace_ms },
1718 );
1719 return error.GraceDeadlineMoved;
1720 }
1683 } 1721 }