a73x

762d350f

test: the stubborn-shell wait names the session the test ends

a73x   2026-09-04 10:16

Commit message
test: the stubborn-shell wait names the session the test ends

awaitStubbornArmed took a slot and every call site passed 0, but three of
the five tests end a session their own dialAttachNamed created — "typing",
"nag" and "a" — each a different shell with a different pid from the default
session Server.init spawned. Waiting on the default proved nothing about the
shell being ended and left those three racing exactly as before.

The repeated-end test was the worst of them: if "nag" dies to the first
pre-trap SIGTERM, the loop never runs and expect(!alive(pid)) passes without
the SIGKILL deadline the test is named for ever being reached. A green check
that had stopped checking.

The helper takes a wire name now and resolves it through sessions.find, so
the marker it waits for is written by the shell that is about to be ended.
"" resolves to the default session, so one spelling covers every site, and
the three named waits sit after their attach's snapshot, which is the
earliest point the session exists to be found.

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
@@ -1500,8 +1500,9 @@ test "Server: the two bounded deadlines read a monotonic clock, not the calendar
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` 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 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. 1503 // that died there is a session no assertion below is about. Named for the
1504 try h.awaitStubbornArmed(&td.srv, &td.tmp, 0, 5000); 1504 // session this test ENDS, which is the shell whose traps matter.
1505 try h.awaitStubbornArmed(&td.srv, &td.tmp, "", 5000);
1505 1506
1506 // CLOCK_MONOTONIC counts from boot and CLOCK_REALTIME from 1970, so any 1507 // CLOCK_MONOTONIC counts from boot and CLOCK_REALTIME from 1970, so any
1507 // machine that has been up less than a decade separates them by a 1508 // machine that has been up less than a decade separates them by a
src/server/server_test_harness.zig
Old New
@@ -615,8 +615,18 @@ pub fn writeStubbornShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 {
615 return std.fmt.allocPrintSentinel(alloc, "{s}/stubborn.sh", .{tmp.path()}, 0); 615 return std.fmt.allocPrintSentinel(alloc, "{s}/stubborn.sh", .{tmp.path()}, 0);
616 } 616 }
617 617
618 /// Wait until the stubborn shell behind session `si` has installed the traps 618 /// Wait until the stubborn shell behind the session `wire_name` spells has
619 /// that make it stubborn, or fail saying it never did. 619 /// installed the traps that make it stubborn, or fail saying it never did.
620 ///
621 /// By NAME, and the name must be the one the caller is about to END. Three of
622 /// these tests end a session their own `dialAttachNamed` created, which is a
623 /// different shell with a different pid from the default session `Server.init`
624 /// spawned; a wait that named the default proved nothing about the shell being
625 /// ended and left the race exactly where it was. Worse in the repeated-end
626 /// test, where a "nag" that died to the first pre-trap SIGTERM passes
627 /// `expect(!alive(pid))` without the SIGKILL deadline it is named for ever
628 /// being reached. `""` resolves to the default session, so one spelling
629 /// covers every site.
620 /// 630 ///
621 /// Every caller of `writeStubbornShell` needs this before it asks for an end, 631 /// 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 632 /// and none of them had it. `Pty.requestExit` closes the master (SIGHUP) and
@@ -636,7 +646,8 @@ pub fn writeStubbornShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 {
636 /// 646 ///
637 /// It polls a file rather than the pump, because the shell writes that file 647 /// 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. 648 /// 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 { 649 pub fn awaitStubbornArmed(srv: *Server, tmp: *TmpDir, wire_name: []const u8, budget_ms: i64) !void {
650 const si = srv.sessions.find(wire_name) orelse return error.NoSuchSessionToArm;
640 const s = srv.sessions.table[si] orelse return error.NoSuchSessionToArm; 651 const s = srv.sessions.table[si] orelse return error.NoSuchSessionToArm;
641 var name_buf: [64]u8 = undefined; 652 var name_buf: [64]u8 = undefined;
642 const name = try std.fmt.bufPrint(&name_buf, "stubborn-{d}.armed", .{s.pty.child}); 653 const name = try std.fmt.bufPrint(&name_buf, "stubborn-{d}.armed", .{s.pty.child});
src/server/server_test_session.zig
Old New
@@ -1634,14 +1634,15 @@ 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);
1641 1637
1642 const c = try dial.dialAttachNamed(td.sock_path, 80, 24, "typing"); 1638 const c = try dial.dialAttachNamed(td.sock_path, 80, 24, "typing");
1643 defer c.close(); 1639 defer c.close();
1644 (try awaitFrame(alloc, &td.srv, c.handle, .snapshot, 400) orelse return error.NoState).deinit(alloc); 1640 (try awaitFrame(alloc, &td.srv, c.handle, .snapshot, 400) orelse return error.NoState).deinit(alloc);
1641 // The shell has to be STUBBORN before anything ends it: until its `trap`
1642 // line runs, the SIGTERM an accepted end sends is fatal, and a session
1643 // that died there is a session no assertion below is about. Named for the
1644 // session this test ENDS, which is the shell whose traps matter.
1645 try h.awaitStubbornArmed(&td.srv, &td.tmp, "typing", 5000);
1645 1646
1646 var rq: [proto.end_req_max_len]u8 = undefined; 1647 var rq: [proto.end_req_max_len]u8 = undefined;
1647 try proto.writeFrame(c.handle, .end_req, proto.encodeEndReq(&rq, false, "typing")); 1648 try proto.writeFrame(c.handle, .end_req, proto.encodeEndReq(&rq, false, "typing"));
@@ -1666,14 +1667,15 @@ test "Server: a repeated end_req does not push the SIGKILL deadline out" {
1666 defer alloc.free(script); 1667 defer alloc.free(script);
1667 1668
1668 try td.start(.{ .shell = script }); 1669 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);
1673 1670
1674 const c = try dial.dialAttachNamed(td.sock_path, 80, 24, "nag"); 1671 const c = try dial.dialAttachNamed(td.sock_path, 80, 24, "nag");
1675 defer c.close(); 1672 defer c.close();
1676 (try awaitFrame(alloc, &td.srv, c.handle, .snapshot, 400) orelse return error.NoState).deinit(alloc); 1673 (try awaitFrame(alloc, &td.srv, c.handle, .snapshot, 400) orelse return error.NoState).deinit(alloc);
1674 // The shell has to be STUBBORN before anything ends it: until its `trap`
1675 // line runs, the SIGTERM an accepted end sends is fatal, and a session
1676 // that died there is a session no assertion below is about. Named for the
1677 // session this test ENDS, which is the shell whose traps matter.
1678 try h.awaitStubbornArmed(&td.srv, &td.tmp, "nag", 5000);
1677 1679
1678 const pid = shellPidOf(&td.srv, "nag"); 1680 const pid = shellPidOf(&td.srv, "nag");
1679 var rq: [proto.end_req_max_len]u8 = undefined; 1681 var rq: [proto.end_req_max_len]u8 = undefined;
src/server/server_test_upgrade.zig
Old New
@@ -452,8 +452,9 @@ test "Server: an upgrade asked for during a session's hangup is refused, not att
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` 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 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. 455 // that died there is a session no assertion below is about. Named for the
456 try h.awaitStubbornArmed(&td.srv, &td.tmp, 0, 5000); 456 // session this test ENDS, which is the shell whose traps matter.
457 try h.awaitStubbornArmed(&td.srv, &td.tmp, "", 5000);
457 458
458 // The shell outlives the hangup by the whole grace, so the exec's 459 // The shell outlives the hangup by the whole grace, so the exec's
459 // clearCloexec and the manifest's pty_fd would both see -1. 460 // clearCloexec and the manifest's pty_fd would both see -1.
@@ -606,10 +607,6 @@ test "Server: an accepted end cancels a pending upgrade — the exec never sees
606 defer alloc.free(script); 607 defer alloc.free(script);
607 608
608 try td.start(.{ .shell = script, .version = "0.0.1-1" }); 609 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);
613 610
614 // Plural: the end takes ONE session and the upgrade would have carried 611 // Plural: the end takes ONE session and the upgrade would have carried
615 // the others, so a guard that only looked at the ending slot's own 612 // the others, so a guard that only looked at the ending slot's own
@@ -617,6 +614,11 @@ test "Server: an accepted end cancels a pending upgrade — the exec never sees
617 const ca = try dial.dialAttachNamed(td.sock_path, 80, 24, "a"); 614 const ca = try dial.dialAttachNamed(td.sock_path, 80, 24, "a");
618 defer ca.close(); 615 defer ca.close();
619 (try awaitFrame(alloc, &td.srv, ca.handle, .snapshot, 400) orelse return error.NoState).deinit(alloc); 616 (try awaitFrame(alloc, &td.srv, ca.handle, .snapshot, 400) orelse return error.NoState).deinit(alloc);
617 // The shell has to be STUBBORN before anything ends it: until its `trap`
618 // line runs, the SIGTERM an accepted end sends is fatal, and a session
619 // that died there is a session no assertion below is about. Named for the
620 // session this test ENDS, which is the shell whose traps matter.
621 try h.awaitStubbornArmed(&td.srv, &td.tmp, "a", 5000);
620 622
621 try td.tmp.dir.writeFile(.{ 623 try td.tmp.dir.writeFile(.{
622 .sub_path = "fakemux.sh", 624 .sub_path = "fakemux.sh",