a641fbae
fix: the picker asks the daemon to end a session instead of grading its list
a73x 2026-09-03 05:20
Commit message
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(); |