a73x

a641fbae

fix: the picker asks the daemon to end a session instead of grading its list

a73x   2026-09-03 05:20

Commit message
fix: the picker asks the daemon to end a session instead of grading its list

countsHolds refused the press whenever no row carried a '# holds' line,
on the premise that the counting and the end_req arm shipped together.
The released v0.0.1-16 daemon answers end_req and sends no holds line, so
the gate refused an end that works. The ask goes on the wire and
error.Timeout - the only thing a daemon with no arm can produce - is what
becomes '[daemon too old to end a session]'.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

CLAUDE.md
Old New
@@ -238,10 +238,13 @@ own. Test fixtures in `test/`:
238 attached" is a question a second press answers. An accepted end is 238 attached" is a question a second press answers. An accepted end is
239 BOUNDED — SIGKILL past `Pty.term_grace_ms` — so no shell can refuse to 239 BOUNDED — SIGKILL past `Pty.term_grace_ms` — so no shell can refuse to
240 die, and `mux d upgrade` is refused while any session is ending rather 240 die, and `mux d upgrade` is refused while any session is ending rather
241 than carry a masterless pty across the exec. A daemon that sends no holds 241 than carry a masterless pty across the exec. The press is ASKED on the
242 line is refused up front with `[daemon too old to end a session]`: it 242 wire whatever the row says: a daemon that answers nothing spends the
243 answers an unknown frame with silence, so the press would spend the whole 243 reply budget and its `error.Timeout` becomes `[daemon too old to end a
244 reply budget with the wall frozen. An ended session's pane leaves on the 244 session]`, which is the only thing the absence of an arm can look like.
245 The missing `# holds` line is NOT that gate — the released v0.0.1-16
246 daemon answers `end_req` and sends no holds line, so refusing on the
247 count refused a daemon that works. An ended session's pane leaves on the
245 next list, not on the keypress. 248 next list, not on the keypress.
246 - **A QUIC client says goodbye.** Every QUIC connection takes one of the 249 - **A QUIC client says goodbye.** Every QUIC connection takes one of the
247 daemon's eight client slots at the handshake, attached or not, and the 250 daemon's eight client slots at the handshake, attached or not, and the
docs/decisions.md
Old New
@@ -7953,10 +7953,14 @@ have no pane on this wall to ask through, and it dials `HostSpec.poll_target`
7953 — the poller's batch recipe, with ssh's `BatchMode=yes`, a connect timeout 7953 — the poller's batch recipe, with ssh's `BatchMode=yes`, a connect timeout
7954 and `asked` false. Never the interactive target: an end must not start a 7954 and `asked` false. Never the interactive target: an end must not start a
7955 daemon, and a password prompt going to `/dev/tty` under the popup would park 7955 daemon, and a password prompt going to `/dev/tty` under the popup would park
7956 the keyboard thread in the TCP retry schedule. A daemon that sends no holds 7956 the keyboard thread in the TCP retry schedule. The press goes on the WIRE whatever the row
7957 line is refused up front (`[daemon too old to end a session]`) rather than 7957 says: a daemon with no `end_req` arm answers an unknown frame with silence,
7958 on the wire, because such a box answers an unknown frame with silence and 7958 so `endSession`'s `error.Timeout` is what "too old" looks like and becomes
7959 the press would spend the whole reply budget with the wall frozen. Only 7959 `[daemon too old to end a session]`. This was a `# holds` gate first, on the
7960 reasoning that the counting and the arm shipped together — measured wrong on
7961 2026-09-02: `git show v0.0.1-16:src/server/server.zig` has five `end_req`
7962 mentions and no `sessions_holds` at all, so the released daemon answers the
7963 end and sends no count, and the gate refused a box that works. Only
7960 "others attached" arms the 3 s force window (`Shared.pick_end`, per host and 7964 "others attached" arms the 3 s force window (`Shared.pick_end`, per host and
7961 name); every other refusal shows the daemon's own reason and arms nothing, 7965 name); every other refusal shows the daemon's own reason and arms nothing,
7962 since arming there would leave the next `x` forcing an end nobody said was 7966 since arming there would leave the next `x` forcing an end nobody said was
src/tui/wall_picker.zig
Old New
@@ -382,18 +382,6 @@ fn sessionAt(h: *Host, row: usize, out: *[proto.session_name_max]u8) ?[]const u8
382 return nameAt(h.poll.snapshot(&list_buf), row, out); 382 return nameAt(h.poll.snapshot(&list_buf), row, out);
383 } 383 }
384 384
385 /// Whether this daemon counts holders at all — whether ANY row it listed
386 /// carries a `# holds` line. The counting and the `end_req` arm arrived in
387 /// the same release, so a daemon that says nothing about holders is one
388 /// that will answer an `end_req` with silence.
389 fn countsHolds(list: []const u8) bool {
390 var it = proto.sessionsIter(list);
391 while (it.next()) |name| {
392 if (proto.parseSessionsHolds(list, name) != null) return true;
393 }
394 return false;
395 }
396
397 /// The second level: one row per session the host's last answer named. 385 /// The second level: one row per session the host's last answer named.
398 /// "on this wall" when the layout already has it, and the holder count when 386 /// "on this wall" when the layout already has it, and the holder count when
399 /// the daemon is new enough to say (`proto.parseSessionsHolds`); an old 387 /// the daemon is new enough to say (`proto.parseSessionsHolds`); an old
@@ -507,14 +495,6 @@ pub fn pickEnd(w: Wall, host: usize, row: usize, now: i64) void {
507 wv.setNotice(w.shared, "[no session on that row]"); 495 wv.setNotice(w.shared, "[no session on that row]");
508 return; 496 return;
509 }; 497 };
510 // Refused HERE and not on the wire: a daemon with no `end_req` arm
511 // answers nothing at all, so the press would spend the whole reply
512 // budget with the wall frozen before saying anything. Same sentence the
513 // pump's own chord uses, because it is the same fact about the box.
514 if (!countsHolds(list)) {
515 wv.setNotice(w.shared, "[daemon too old to end a session]");
516 return;
517 }
518 const armed = w.shared.pick_end.armedFor(host, name, now); 498 const armed = w.shared.pick_end.armedFor(host, name, now);
519 // The POLLER's recipe, never the row's own target: an end must not 499 // The POLLER's recipe, never the row's own target: an end must not
520 // start a daemon and must not reach for a terminal. `poll_target` 500 // start a daemon and must not reach for a terminal. `poll_target`
@@ -523,6 +503,16 @@ pub fn pickEnd(w: Wall, host: usize, row: usize, now: i64) void {
523 // keyboard thread in the TCP retry schedule with a password prompt 503 // keyboard thread in the TCP retry schedule with a password prompt
524 // going to /dev/tty under the popup. 504 // going to /dev/tty under the popup.
525 const out = client.endSession(w.alloc, h.spec.poll_target, name, armed) catch |e| { 505 const out = client.endSession(w.alloc, h.spec.poll_target, name, armed) catch |e| {
506 // A daemon with no `end_req` arm answers an unknown frame with
507 // silence, so the budget running out IS the answer: the box is too
508 // old, in the sentence the pump's own chord uses. Asked on the wire
509 // rather than guessed from the absence of a `# holds` line — the
510 // released v0.0.1-16 daemon answers `end_req` and sends no holds
511 // line, and refusing it up front refused a daemon that works.
512 if (e == error.Timeout) {
513 wv.setNotice(w.shared, "[daemon too old to end a session]");
514 return;
515 }
526 var buf: [96]u8 = undefined; 516 var buf: [96]u8 = undefined;
527 wv.setNotice(w.shared, std.fmt.bufPrint( 517 wv.setNotice(w.shared, std.fmt.bufPrint(
528 &buf, 518 &buf,
src/tui/wall_test_picker.zig
Old New
@@ -1062,26 +1062,88 @@ test "pickEnd: the first x arms with the count, the second forces, and a refusal
1062 try std.testing.expectEqual([3]bool{ false, true, false }, fake.force[0..3].*); 1062 try std.testing.expectEqual([3]bool{ false, true, false }, fake.force[0..3].*);
1063 } 1063 }
1064 1064
1065 test "pickEnd: a daemon that counts no holders is too old to end a session, and is not dialled" { 1065 /// A daemon that accepts the connection and answers NOTHING: what a box
1066 /// with no `end_req` arm does with a frame type it does not know. The
1067 /// connection has to stay OPEN — a close would be a transport error, and
1068 /// the fact under test is the reply budget running out.
1069 const SilentFake = struct {
1070 listener: std.net.Server,
1071 stop: std.atomic.Value(bool) = .init(false),
1072
1073 fn serve(self: *SilentFake) void {
1074 const conn = self.listener.accept() catch return;
1075 defer conn.stream.close();
1076 while (!self.stop.load(.acquire)) std.Thread.sleep(20 * std.time.ns_per_ms);
1077 }
1078 };
1079
1080 test "pickEnd: a daemon that answers nothing is the too-old sentence, and the holds line is not the gate" {
1081 const alloc = std.testing.allocator;
1082 var tmp = try TmpDir.make();
1083 defer tmp.cleanup();
1084 const sp = try std.fmt.allocPrint(alloc, "{s}/silent.sock", .{tmp.path()});
1085 defer alloc.free(sp);
1086 const addr = try std.net.Address.initUnix(sp);
1087 var fake = SilentFake{ .listener = try addr.listen(.{}) };
1088 defer fake.listener.deinit();
1089 const th = try std.Thread.spawn(.{}, SilentFake.serve, .{&fake});
1090
1066 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; 1091 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
1067 defer shared.tree.deinit(); 1092 defer shared.tree.deinit();
1068 var table = [_]Host{ 1093 var table = [_]Host{
1069 fixture.testHost(&shared, "new", "/tmp/mux-no-such-socket-a"), 1094 fixture.testHost(&shared, "new", "/tmp/mux-no-such-socket-a"),
1070 fixture.testHost(&shared, "old", "/tmp/mux-no-such-socket-b"), 1095 fixture.testHost(&shared, "old", sp),
1071 }; 1096 };
1072 fixture.setList(&table[0], "0\n# holds 0 1"); 1097 fixture.setList(&table[0], "0\n# holds 0 1");
1073 // No `# holds` line anywhere: the counting and the `end_req` arm shipped 1098 // No `# holds` line: the absence used to refuse the press up front, and
1074 // together, so this daemon would answer the press with silence. 1099 // that was wrong about a real box — the released v0.0.1-16 daemon
1100 // answers `end_req` and sends no holds line. The verdict comes off the
1101 // WIRE now, so this list says nothing about whether the end is asked.
1075 fixture.setList(&table[1], "0\nwork\n"); 1102 fixture.setList(&table[1], "0\nwork\n");
1076 const w = fixture.hostWall(&shared, &table); 1103 const w = fixture.hostWall(&shared, &table);
1077 var buf: [96]u8 = undefined; 1104 var buf: [96]u8 = undefined;
1078 wall_picker.pickEnd(w, 1, 0, 1000); 1105 wall_picker.pickEnd(w, 1, 0, 1000);
1106 fake.stop.store(true, .release);
1107 th.join();
1079 // The pump's own wording for the same fact about the box, and no arm: 1108 // The pump's own wording for the same fact about the box, and no arm:
1080 // nothing was asked, so there is nothing to press again for. 1109 // nothing was answered, so there is nothing to press again for.
1081 try std.testing.expectEqualStrings("[daemon too old to end a session]", wv.takeNotice(&shared, &buf)); 1110 try std.testing.expectEqualStrings("[daemon too old to end a session]", wv.takeNotice(&shared, &buf));
1082 try std.testing.expect(!shared.pick_end.armedFor(1, "0", 1500)); 1111 try std.testing.expect(!shared.pick_end.armedFor(1, "0", 1500));
1083 } 1112 }
1084 1113
1114 test "pickEnd: a daemon with no holds line that ANSWERS ends the session" {
1115 const alloc = std.testing.allocator;
1116 var tmp = try TmpDir.make();
1117 defer tmp.cleanup();
1118 const sp = try std.fmt.allocPrint(alloc, "{s}/end16.sock", .{tmp.path()});
1119 defer alloc.free(sp);
1120 const addr = try std.net.Address.initUnix(sp);
1121 var fake = EndFake{
1122 .listener = try addr.listen(.{}),
1123 .replies = &.{.{ .accepted = true, .others = 0, .reason = proto.end_reason.accepted }},
1124 };
1125 defer fake.listener.deinit();
1126 const th = try std.Thread.spawn(.{}, EndFake.serve, .{&fake});
1127
1128 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
1129 defer shared.tree.deinit();
1130 var table = [_]Host{
1131 fixture.testHost(&shared, "nowhere", "/tmp/mux-no-such-socket-d"),
1132 fixture.testHost(&shared, "box", sp),
1133 };
1134 // A v0.0.1-16 daemon's list, verbatim in shape: names and the meta
1135 // line, no holds. It has the `end_req` arm all the same, and refusing
1136 // it for the missing count refused an end that works.
1137 fixture.setList(&table[1], "0\nwork\n# mux 0.0.1-16");
1138 const w = fixture.hostWall(&shared, &table);
1139 var buf: [96]u8 = undefined;
1140 wall_picker.pickEnd(w, 1, 1, 1000);
1141 th.join();
1142 try std.testing.expectEqual(@as(usize, 1), fake.n);
1143 try std.testing.expect(!fake.force[0]);
1144 try std.testing.expectEqualStrings("[ending work on box]", wv.takeNotice(&shared, &buf));
1145 }
1146
1085 test "pickEnd: a row the list does not have asks nothing" { 1147 test "pickEnd: a row the list does not have asks nothing" {
1086 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; 1148 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
1087 defer shared.tree.deinit(); 1149 defer shared.tree.deinit();