e5af07b9
test: the stubborn shell is armed before anything ends it
a73x 2026-09-04 10:16
Commit message
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 |