a73x

e5af07b9

test: the stubborn shell is armed before anything ends it

a73x   2026-09-04 10:16

Commit message
test: the stubborn shell is armed before anything ends it

Five tests spawn a shell that ignores SIGTERM and SIGHUP and then end it,
and none of them waited for its `trap` line to run. Until it does, the
SIGTERM that Pty.requestExit sends is fatal, so the session dies at the end
instead of outliving it. Linux's /bin/sh is dash and arms in 2 ms or less;
macOS's is bash 3.2 and took 12 to 21 ms across five spawns, measured under
make check, whose parallel doc and format steps are load that zig build test
does not have.

What that bought was not a failure but a wrong answer: in the upgrade test
the session was reaped 20 ms after its accepted end, so validateUpgrade
found no session mid-hangup and ACCEPTED an upgrade the daemon must refuse.
The Mac failed make check 3 times out of 3 and passed zig build test 3 out
of 3, which is the same race decided by how loaded the box was.

The script now touches a file named for its own pid once the traps are set,
and awaitStubbornArmed waits for it. Per pid, because a test may run several
of these at once and a shared marker would be answered by whichever armed
first. It polls the file rather than the pump, so a daemon on a thread is
not a race.

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

src/server/server_test_attach.zig
Old New
@@ -1498,6 +1498,10 @@ test "Server: the two bounded deadlines read a monotonic clock, not the calendar
1498 defer alloc.free(script); 1498 defer alloc.free(script);
1499 1499
1500 try td.start(.{ .shell = script }); 1500 try td.start(.{ .shell = script });
1501 // The shell has to be STUBBORN before anything ends it: until its `trap`
1502 // line runs, the SIGTERM an accepted end sends is fatal, and a session
1503 // that died there is a session no assertion below is about.
1504 try h.awaitStubbornArmed(&td.srv, &td.tmp, 0, 5000);
1501 1505
1502 // CLOCK_MONOTONIC counts from boot and CLOCK_REALTIME from 1970, so any 1506 // CLOCK_MONOTONIC counts from boot and CLOCK_REALTIME from 1970, so any
1503 // machine that has been up less than a decade separates them by a 1507 // machine that has been up less than a decade separates them by a
src/server/server_test_harness.zig
Old New
@@ -588,15 +588,24 @@ pub fn writeStubbornShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 {
588 // The loop stays as the fallback so a box that cannot mkfifo still gets a 588 // The loop stays as the fallback so a box that cannot mkfifo still gets a
589 // stubborn shell: a fixture that quietly exited here would let the 589 // stubborn shell: a fixture that quietly exited here would let the
590 // bounded-end gates pass for the wrong reason. 590 // bounded-end gates pass for the wrong reason.
591 //
592 // The file it touches on line three is how a caller knows the traps are
593 // ARMED, and `awaitStubbornArmed` is the wait. Nothing else announces it:
594 // this shell prints nothing by design, so a test that ended it the
595 // instant it was spawned was racing the shell's own startup, and the
596 // SIGTERM `requestExit` sends landed while TERM was still fatal. The
597 // name carries `$$` because a test may run several of these at once and
598 // a shared marker would be answered by whichever armed first.
591 const body = try std.fmt.allocPrint(alloc, 599 const body = try std.fmt.allocPrint(alloc,
592 \\#!/bin/sh 600 \\#!/bin/sh
593 \\trap '' TERM HUP 601 \\trap '' TERM HUP
594 \\if mkfifo "{s}/stubborn.fifo" 2>/dev/null; then 602 \\: > "{[d]s}/stubborn-$$.armed"
595 \\ read x < "{s}/stubborn.fifo" 603 \\if mkfifo "{[d]s}/stubborn.fifo" 2>/dev/null; then
604 \\ read x < "{[d]s}/stubborn.fifo"
596 \\fi 605 \\fi
597 \\while :; do sleep 1; done 606 \\while :; do sleep 1; done
598 \\ 607 \\
599 , .{ tmp.path(), tmp.path() }); 608 , .{ .d = tmp.path() });
600 defer alloc.free(body); 609 defer alloc.free(body);
601 try tmp.dir.writeFile(.{ 610 try tmp.dir.writeFile(.{
602 .sub_path = "stubborn.sh", 611 .sub_path = "stubborn.sh",
@@ -606,6 +615,39 @@ pub fn writeStubbornShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 {
606 return std.fmt.allocPrintSentinel(alloc, "{s}/stubborn.sh", .{tmp.path()}, 0); 615 return std.fmt.allocPrintSentinel(alloc, "{s}/stubborn.sh", .{tmp.path()}, 0);
607 } 616 }
608 617
618 /// Wait until the stubborn shell behind session `si` has installed the traps
619 /// that make it stubborn, or fail saying it never did.
620 ///
621 /// Every caller of `writeStubbornShell` needs this before it asks for an end,
622 /// and none of them had it. `Pty.requestExit` closes the master (SIGHUP) and
623 /// sends SIGTERM; the script ignores both ONCE its `trap` line has run, and
624 /// dies to either before that. So a shell spawned and ended within the same
625 /// millisecond was a coin toss, and the two systems do not toss the same
626 /// coin: Linux's /bin/sh is dash and arms in 2 ms or less, macOS's is bash
627 /// 3.2 and took 12 to 21 ms across five spawns (measured 2026-09-04 on macOS
628 /// 26 under `make check`, whose parallel doc and format steps are load that
629 /// `zig build test` does not have).
630 ///
631 /// What that bought was a silent wrong answer rather than a failure: the
632 /// upgrade test's session was reaped 20 ms after its accepted end, so
633 /// `validateUpgrade` found no session mid-hangup and ACCEPTED an upgrade the
634 /// daemon must refuse — the one thing that test exists to prevent, on the
635 /// only OS where the race was reliably lost.
636 ///
637 /// It polls a file rather than the pump, because the shell writes that file
638 /// on its own and a daemon on a thread would make pumping here a race.
639 pub fn awaitStubbornArmed(srv: *Server, tmp: *TmpDir, si: usize, budget_ms: i64) !void {
640 const s = srv.sessions.table[si] orelse return error.NoSuchSessionToArm;
641 var name_buf: [64]u8 = undefined;
642 const name = try std.fmt.bufPrint(&name_buf, "stubborn-{d}.armed", .{s.pty.child});
643 const deadline = std.time.milliTimestamp() + budget_ms;
644 while (std.time.milliTimestamp() < deadline) {
645 if (tmp.dir.access(name, .{})) |_| return else |_| {}
646 std.Thread.sleep(2 * std.time.ns_per_ms);
647 }
648 return error.StubbornShellNeverArmed;
649 }
650
609 // --------------------------------------------------------------------------- 651 // ---------------------------------------------------------------------------
610 // The harness's own primitive. `pumpUntil` is what the sibling files assert 652 // The harness's own primitive. `pumpUntil` is what the sibling files assert
611 // their daemon-side conditions through, so its two outcomes are pinned here 653 // their daemon-side conditions through, so its two outcomes are pinned here
src/server/server_test_session.zig
Old New
@@ -1634,6 +1634,10 @@ test "Server: a keystroke into an ending session does not cost that client its e
1634 defer alloc.free(script); 1634 defer alloc.free(script);
1635 1635
1636 try td.start(.{ .shell = script }); 1636 try td.start(.{ .shell = script });
1637 // The shell has to be STUBBORN before anything ends it: until its `trap`
1638 // line runs, the SIGTERM an accepted end sends is fatal, and a session
1639 // that died there is a session no assertion below is about.
1640 try h.awaitStubbornArmed(&td.srv, &td.tmp, 0, 5000);
1637 1641
1638 const c = try dial.dialAttachNamed(td.sock_path, 80, 24, "typing"); 1642 const c = try dial.dialAttachNamed(td.sock_path, 80, 24, "typing");
1639 defer c.close(); 1643 defer c.close();
@@ -1662,6 +1666,10 @@ test "Server: a repeated end_req does not push the SIGKILL deadline out" {
1662 defer alloc.free(script); 1666 defer alloc.free(script);
1663 1667
1664 try td.start(.{ .shell = script }); 1668 try td.start(.{ .shell = script });
1669 // The shell has to be STUBBORN before anything ends it: until its `trap`
1670 // line runs, the SIGTERM an accepted end sends is fatal, and a session
1671 // that died there is a session no assertion below is about.
1672 try h.awaitStubbornArmed(&td.srv, &td.tmp, 0, 5000);
1665 1673
1666 const c = try dial.dialAttachNamed(td.sock_path, 80, 24, "nag"); 1674 const c = try dial.dialAttachNamed(td.sock_path, 80, 24, "nag");
1667 defer c.close(); 1675 defer c.close();
src/server/server_test_upgrade.zig
Old New
@@ -450,6 +450,10 @@ test "Server: an upgrade asked for during a session's hangup is refused, not att
450 // A real version, so every other check would PASS: without the ending 450 // A real version, so every other check would PASS: without the ending
451 // session this upgrade is one the daemon would go through with. 451 // session this upgrade is one the daemon would go through with.
452 try td.start(.{ .shell = script, .version = "0.0.1-1" }); 452 try td.start(.{ .shell = script, .version = "0.0.1-1" });
453 // The shell has to be STUBBORN before anything ends it: until its `trap`
454 // line runs, the SIGTERM an accepted end sends is fatal, and a session
455 // that died there is a session no assertion below is about.
456 try h.awaitStubbornArmed(&td.srv, &td.tmp, 0, 5000);
453 457
454 // The shell outlives the hangup by the whole grace, so the exec's 458 // The shell outlives the hangup by the whole grace, so the exec's
455 // clearCloexec and the manifest's pty_fd would both see -1. 459 // clearCloexec and the manifest's pty_fd would both see -1.
@@ -602,6 +606,10 @@ test "Server: an accepted end cancels a pending upgrade — the exec never sees
602 defer alloc.free(script); 606 defer alloc.free(script);
603 607
604 try td.start(.{ .shell = script, .version = "0.0.1-1" }); 608 try td.start(.{ .shell = script, .version = "0.0.1-1" });
609 // The shell has to be STUBBORN before anything ends it: until its `trap`
610 // line runs, the SIGTERM an accepted end sends is fatal, and a session
611 // that died there is a session no assertion below is about.
612 try h.awaitStubbornArmed(&td.srv, &td.tmp, 0, 5000);
605 613
606 // Plural: the end takes ONE session and the upgrade would have carried 614 // Plural: the end takes ONE session and the upgrade would have carried
607 // the others, so a guard that only looked at the ending slot's own 615 // the others, so a guard that only looked at the ending slot's own