a73x

d6f7c2af

feat: Ctrl-\ X ends the focused pane's session through its own pump

a73x   2026-09-04 15:46

Commit message
feat: Ctrl-\ X ends the focused pane's session through its own pump

`Ctrl-\ X` asks the focused pane's daemon to end that pane's session, with
the daemon's existing two-step: a session other clients hold is refused
with the count and a second X inside three seconds forces it.

It rides the tile's own pump rather than dialling a side connection. The
pump is already attached to the session, so the frame goes out on the link
the pane is reading anyway, and the daemon — which excludes the asking
client from the "others hold it" count — sees one fewer holder. A session
nobody but this pane holds therefore ends on the first press, where the
picker's `x`, asking over a connection of its own, needs two. The picker
dials separately because the row it stands on may have no pane here; a
focused tile always does. All the machinery already existed and nothing
reached it: `SwitchIntent.end`/`.end_force`, `Tile.ask`, the pump's
`end_req` write and its `end_reply` arm, `intentForEnd` and `onEndReply`.
This is the keypress that stores the intent.

Nothing is vanished on the keypress. The pane leaves the way an ended
session's pane always leaves — the shell's `exit_status`, then the poll
that no longer names it — so a refused end costs the wall nothing.

`Ctrl-\ x` is unchanged: it takes the pane off this wall, writes the
layout without it, and leaves the session running for whoever else holds
it. The chord action it dispatched through was called `.end_session`,
which had not been true since the key became a removal; it is
`.remove_pane` now, and the new one is `.end_focused`. The pump's refusal
banner names `X` rather than `x`, because sending the user to `x` would
remove the pane and leave the shell they meant to end running on no wall
of theirs.

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

CLAUDE.md
Old New
@@ -245,22 +245,33 @@ own. Test fixtures in `test/`:
245 daemon REFUSES (`Tile.keeps_wall`, which `born_from` cannot say — the 245 daemon REFUSES (`Tile.keeps_wall`, which `born_from` cannot say — the
246 entry tile carries the same null) — a piped `mux` still exits with the 246 entry tile carries the same null) — a piped `mux` still exits with the
247 shell's code, because scripts read it. 247 shell's code, because scripts read it.
248 - **`Ctrl-\ x` removes a pane and ends nothing; ending is the picker's, and 248 - **`Ctrl-\ x` removes a pane and ends nothing; ending has two doors —
249 the daemon owns its two-step.** `wallview.removePane` sets the notice 249 `Ctrl-\ X` through the focused tile's own pump and the picker's `x`
250 through a side connection — and the daemon owns the two-step behind
251 both.** `wallview.removePane` sets the notice
250 `[pane removed - the session is still on its daemon]` BEFORE the vanish 252 `[pane removed - the session is still on its daemon]` BEFORE the vanish
251 (the vanish hands the focus on, and the incoming pump's claim is what 253 (the vanish hands the focus on, and the incoming pump's claim is what
252 paints the sentence), tells the pump to detach — it writes `.detach` on 254 paints the sentence), tells the pump to detach — it writes `.detach` on
253 its way out and the transport close behind it is the fallback — vanishes 255 its way out and the transport close behind it is the fallback — vanishes
254 the tile, relayouts and persists. Every pane removes the same way, 256 the tile, relayouts and persists. Every pane removes the same way,
255 including one that never came up. ENDING is `wall_picker.pickEnd`: 257 including one that never came up. ENDING is `end_req` `0x11` /
256 `end_req` `0x11` / `end_reply` `0x94`, observer verbs, over 258 `end_reply` `0x94`, observer verbs, asked one of two ways. `Ctrl-\ X` on
259 a tile stores `wallview.intentForEnd` into that tile's `ask` and rings
260 its pump, which puts the frame on the connection the pane is already
261 attached to (`wall_pump`), reads the answer back through
262 `wallview.onEndReply` and banners the refusal; the daemon excludes the
263 asking client from the count, so a session only this pane holds ends on
264 the FIRST press. Nothing is vanished on that keypress — the pane leaves
265 when the shell does. `wall_picker.pickEnd` is the other door, over
257 `client.endSession` on a SIDE connection, because the session may have no 266 `client.endSession` on a SIDE connection, because the session may have no
258 pane here to ask through. That connection dials `HostSpec.poll_target` — 267 pane here to ask through — which is also why its count includes this
268 wall's own pane. That connection dials `HostSpec.poll_target` —
259 the poller's batch recipe, never the interactive one, since an end must 269 the poller's batch recipe, never the interactive one, since an end must
260 not start a daemon and must not reach for a terminal. The daemon refuses 270 not start a daemon and must not reach for a terminal. The daemon refuses
261 a first `x` on a session other clients hold and answers the count; 271 a first press on a session other clients hold and answers the count; the
262 `Shared.pick_end` arms 3 s per host AND name for the forcing second 272 two doors keep SEPARATE 3 s arms, each keyed to what its own second press
263 press, and every OTHER refusal arms nothing, because only "others 273 lands on — `Tile.end_armed_until` on the tile, `Shared.pick_end` per host
274 AND name — and every OTHER refusal arms nothing, because only "others
264 attached" is a question a second press answers. An accepted end is 275 attached" is a question a second press answers. An accepted end is
265 BOUNDED — SIGKILL past `Pty.term_grace_ms` — so no shell can refuse to 276 BOUNDED — SIGKILL past `Pty.term_grace_ms` — so no shell can refuse to
266 die, and `mux d upgrade` is refused while any session is ending rather 277 die, and `mux d upgrade` is refused while any session is ending rather
README.md
Old New
@@ -80,7 +80,9 @@ It never adds a pane. `Ctrl-\` `s` lists your daemons, Enter on one lists
80 its sessions with who else holds each, Enter on a session adds it as a 80 its sessions with who else holds each, Enter on a session adds it as a
81 pane, `c` starts a new one there, `x` ends one (asking first when someone 81 pane, `c` starts a new one there, `x` ends one (asking first when someone
82 else holds it), Esc backs out a level. On the wall, `Ctrl-\` `x` takes the 82 else holds it), Esc backs out a level. On the wall, `Ctrl-\` `x` takes the
83 focused pane off this wall and ends nothing. 83 focused pane off this wall and ends nothing, while `Ctrl-\` `X` ends the
84 focused pane's session on its daemon — the shift is the whole difference
85 between the key you can undo and the key that kills a shell.
84 86
85 `mux` on a machine that has never run it records your own daemon (the line 87 `mux` on a machine that has never run it records your own daemon (the line
86 `--sock <default>`, starting it if nothing is listening) and opens one pane 88 `--sock <default>`, starting it if nothing is listening) and opens one pane
@@ -122,7 +124,8 @@ The focused tile has the terminal, and only the prefix is held back:
122 | `Ctrl-\` `f` | fullscreen the focused pane (toggle) | 124 | `Ctrl-\` `f` | fullscreen the focused pane (toggle) |
123 | `Ctrl-\` `r` | resize mode: `h`/`l` shrink/grow the focus's width, `k`/`j` its height, one cell per press; Esc leaves silently, any other key leaves and types | 125 | `Ctrl-\` `r` | resize mode: `h`/`l` shrink/grow the focus's width, `k`/`j` its height, one cell per press; Esc leaves silently, any other key leaves and types |
124 | `Ctrl-\` `1`-`9` | focus pane N — the digit on that pane's label bar. A new pane takes the lowest free digit, so remove 2 of 1 2 3 and the next pane is 2 again | 126 | `Ctrl-\` `1`-`9` | focus pane N — the digit on that pane's label bar. A new pane takes the lowest free digit, so remove 2 of 1 2 3 and the next pane is 2 again |
125 | `Ctrl-\` `x` | take the focused pane off this wall — the layout is written without it and the session keeps running on its daemon for whoever else holds it. Ending a session is the picker's `x` | 127 | `Ctrl-\` `x` | take the focused pane off this wall — the layout is written without it and the session keeps running on its daemon for whoever else holds it |
128 | `Ctrl-\` `X` | end the focused pane's session on its daemon, asked over the pane's own connection. A session someone else holds is refused with the count — `[1 other attached - X again to end]` — and a second `X` within three seconds forces it; a session only this pane holds ends on the first press. The pane leaves when the shell does |
126 | `Ctrl-\` `w` | zoom out to the wall | 129 | `Ctrl-\` `w` | zoom out to the wall |
127 | `Ctrl-\` `s` | the picker, and the only door a session comes onto the wall through. Level one is every daemon in your hosts file with what its last poll said — `N sessions`, `no sessions`, `unreachable`, `connecting`. `j`/`k` or the arrows move, `1`-`9` pick a row, Enter opens that daemon's SESSIONS, `c` starts a new session there and adds it, `x` forgets the host (the file line, its poller and its panes go; the sessions keep running), `a` adds a host by spelling — `HOST`, `quic://HOST[:PORT]` or `--sock PATH`, Enter adds and Esc returns to the rows. Level two is one row per session that daemon has, reading `on this wall` when it is already a pane and `K client(s)` for everyone holding it, this wall's own pane counted: Enter adds it as a pane (or just zooms to it), `c` still births, `x` ends the session with the daemon's two-step, Esc backs out to the hosts. `s` or `Ctrl-C` closes the whole popup from either level, while Esc backs out one level at a time — and an arrow key split across two reads reads as a bare Escape, so at level one it closes too. The spelling editor takes printable ASCII only, so a path or hostname with any other byte in it goes in the hosts file or on argv instead. While the picker is open every key is its own: nothing reaches a session | 130 | `Ctrl-\` `s` | the picker, and the only door a session comes onto the wall through. Level one is every daemon in your hosts file with what its last poll said — `N sessions`, `no sessions`, `unreachable`, `connecting`. `j`/`k` or the arrows move, `1`-`9` pick a row, Enter opens that daemon's SESSIONS, `c` starts a new session there and adds it, `x` forgets the host (the file line, its poller and its panes go; the sessions keep running), `a` adds a host by spelling — `HOST`, `quic://HOST[:PORT]` or `--sock PATH`, Enter adds and Esc returns to the rows. Level two is one row per session that daemon has, reading `on this wall` when it is already a pane and `K client(s)` for everyone holding it, this wall's own pane counted: Enter adds it as a pane (or just zooms to it), `c` still births, `x` ends the session with the daemon's two-step, Esc backs out to the hosts. `s` or `Ctrl-C` closes the whole popup from either level, while Esc backs out one level at a time — and an arrow key split across two reads reads as a bare Escape, so at level one it closes too. The spelling editor takes printable ASCII only, so a path or hostname with any other byte in it goes in the hosts file or on argv instead. While the picker is open every key is its own: nothing reaches a session |
128 | `Shift+PageUp` / `Shift+PageDown` | scrollback, a screen at a time (any other key returns to live) | 131 | `Shift+PageUp` / `Shift+PageDown` | scrollback, a screen at a time (any other key returns to live) |
@@ -143,15 +146,25 @@ anywhere else is real and running and on no wall of yours until you add it.
143 `x` on the wall is a wall edit and only that: the pane goes, the layout is 146 `x` on the wall is a wall edit and only that: the pane goes, the layout is
144 written without it, the client says goodbye to that session, and the shell 147 written without it, the client says goodbye to that session, and the shell
145 behind it keeps running for whoever else holds it — the rail reads 148 behind it keeps running for whoever else holds it — the rail reads
146 `[pane removed - the session is still on its daemon]`. ENDING a session is 149 `[pane removed - the session is still on its daemon]`.
147 the picker's `x` at the session level, which is where you can see who else 150
148 holds it first. Because ending interrupts anyone else watching, the daemon 151 ENDING a session has two doors, and both go through the same two-step on
149 refuses a first `x` on a session with other clients attached and says how 152 the daemon. `Ctrl-\` `X` ends the FOCUSED pane's session, asked over that
150 many — `[work: 1 other attached - x again to end]` — and a second `x` on 153 pane's own connection. The picker's `x` at the session level ends any
151 the same row within three seconds forces it. A shell that ignores the 154 session the daemon has, pane or no pane, which is what you want when you
152 hangup is killed half a second later; there is no session that cannot be 155 can see who else holds it first. Because ending interrupts anyone else
153 ended. That count on the row is EVERY holder including this wall's own 156 watching, the daemon refuses a first press on a session with other clients
154 pane, so ending a session you alone have open is two presses. 157 attached and says how many — `[work: 1 other attached - x again to end]` —
158 and a second press on the same tile or the same row within three seconds
159 forces it. A shell that ignores the hangup is killed half a second later;
160 there is no session that cannot be ended.
161
162 The two doors count holders differently, because the daemon does not count
163 the client that is asking. The picker asks over a connection of its own, so
164 the count on the row is EVERY holder including this wall's own pane, and
165 ending a session you alone have open there is two presses. `Ctrl-\` `X`
166 asks over the pane itself, so that pane is not one of the others and a
167 session nobody else holds ends on the first press.
155 168
156 Dropping a whole MACHINE is not a chord either: from inside the wall it is 169 Dropping a whole MACHINE is not a chord either: from inside the wall it is
157 the picker's `x` at the host level (`Ctrl-\` `s`, then `x`), and from the 170 the picker's `x` at the host level (`Ctrl-\` `s`, then `x`), and from the
src/tui/interact.zig
Old New
@@ -82,10 +82,15 @@ pub const PrefixFilter = struct {
82 // The digit pressed (1-9): `Ctrl-\ 3` focuses tile 3. Four bits, 82 // The digit pressed (1-9): `Ctrl-\ 3` focuses tile 3. Four bits,
83 // because nine values do not fit in three. 83 // because nine values do not fit in three.
84 focus: u4, 84 focus: u4,
85 /// `Ctrl-\ x`: end the focused session on its daemon. Not "remove 85 /// `Ctrl-\ x`: take the focused pane off THIS wall. The session is
86 /// this tile" — the wall shows what the daemons have, so a tile 86 /// left running on its daemon for whoever else holds it, so the key
87 /// leaves when the session does. 87 /// is reversible — the picker puts the pane back.
88 end_session, 88 remove_pane,
89 /// `Ctrl-\ X`: ask the focused pane's own daemon to END its session.
90 /// The shift is the whole difference between the reversible key and
91 /// the one that kills a shell, which is why they are two actions and
92 /// not one with a flag.
93 end_focused,
89 focus_dir: Dir, 94 focus_dir: Dir,
90 split_right, 95 split_right,
91 split_below, 96 split_below,
@@ -400,7 +405,8 @@ pub const PrefixFilter = struct {
400 }, 405 },
401 'w' => return .{ .forward = buf[0..kept], .action = .wall }, 406 'w' => return .{ .forward = buf[0..kept], .action = .wall },
402 '1'...'9' => return .{ .forward = buf[0..kept], .action = .{ .focus = @intCast(b - '0') } }, 407 '1'...'9' => return .{ .forward = buf[0..kept], .action = .{ .focus = @intCast(b - '0') } },
403 'x' => return .{ .forward = buf[0..kept], .action = .end_session }, 408 'x' => return .{ .forward = buf[0..kept], .action = .remove_pane },
409 'X' => return .{ .forward = buf[0..kept], .action = .end_focused },
404 else => {}, 410 else => {},
405 } 411 }
406 continue; 412 continue;
@@ -2898,12 +2904,20 @@ test "interact: Ctrl-\\ 1 is focus, x ends the session, w is the wall, n/p walk
2898 var g: PrefixFilter = .{}; 2904 var g: PrefixFilter = .{};
2899 var ex = "\x1cx".*; 2905 var ex = "\x1cx".*;
2900 const b = g.feed(&ex); 2906 const b = g.feed(&ex);
2901 // `x` ends the session on the daemon. It used to forget a line in the 2907 // `x` takes the pane off this wall and asks the daemon nothing.
2902 // wall file; the wall lists daemons now, and there is no per-session 2908 try std.testing.expectEqual(PrefixFilter.Action.remove_pane, b.action);
2903 // line left for a key to remove.
2904 try std.testing.expectEqual(PrefixFilter.Action.end_session, b.action);
2905 try std.testing.expectEqualStrings("", b.forward); 2909 try std.testing.expectEqualStrings("", b.forward);
2906 2910
2911 // Shift-x is a DIFFERENT action, not the same one reached by a second
2912 // spelling: `x` is the reversible key and `X` ends a session on its
2913 // daemon, so a table that folded the two would make every removal a
2914 // kill. Neither byte is forwarded — both end the chord.
2915 var gx: PrefixFilter = .{};
2916 var exx = "\x1cX".*;
2917 const bx = gx.feed(&exx);
2918 try std.testing.expectEqual(PrefixFilter.Action.end_focused, bx.action);
2919 try std.testing.expectEqualStrings("", bx.forward);
2920
2907 // The three keys that must not have moved under `x`'s feet: `w` is 2921 // The three keys that must not have moved under `x`'s feet: `w` is
2908 // still the wall and `n`/`p` are still the walk, so a user who learned 2922 // still the wall and `n`/`p` are still the walk, so a user who learned
2909 // the row learned it once. 2923 // the row learned it once.
src/tui/wall_pump.zig
Old New
@@ -715,10 +715,16 @@ pub fn pumpTile(t: *Tile) void {
715 core.banner(if (r.others == 0) 715 core.banner(if (r.others == 0)
716 endRefusal(r.reason) 716 endRefusal(r.reason)
717 else 717 else
718 std.fmt.bufPrint(&b, "[{d} other{s} attached - x again to end]", .{ 718 // `X`, the key that asked: this banner is
719 // the tile chord's, and the picker's row
720 // says `x` for the key the popup uses.
721 // Naming the wrong one sends the user to
722 // `Ctrl-\ x`, which removes the pane and
723 // leaves the session they meant to end.
724 std.fmt.bufPrint(&b, "[{d} other{s} attached - X again to end]", .{
719 r.others, 725 r.others,
720 if (r.others == 1) "" else "s", 726 if (r.others == 1) "" else "s",
721 }) catch "[others attached - x again to end]"); 727 }) catch "[others attached - X again to end]");
722 } 728 }
723 }, 729 },
724 .selection_reply => copySelection(t, alloc, &core, frame.payload), 730 .selection_reply => copySelection(t, alloc, &core, frame.payload),
src/tui/wall_test_wall.zig
Old New
@@ -136,7 +136,7 @@ test "setNoticeIdle: a standing condition waits — a refusal the user just earn
136 try std.testing.expectEqualStrings("[+2 not shown]", wv.takeNotice(&shared, &buf)); 136 try std.testing.expectEqualStrings("[+2 not shown]", wv.takeNotice(&shared, &buf));
137 } 137 }
138 138
139 test "x on a tile whose daemon refused arms a second press, and the window closes after 3 s" { 139 test "X on a tile whose daemon refused arms a second press, and the window closes after 3 s" {
140 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; 140 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
141 defer shared.tree.deinit(); 141 defer shared.tree.deinit();
142 var t: Tile = .{ 142 var t: Tile = .{
@@ -147,14 +147,14 @@ test "x on a tile whose daemon refused arms a second press, and the window close
147 .wake_r = -1, 147 .wake_r = -1,
148 .wake_w = -1, 148 .wake_w = -1,
149 }; 149 };
150 // A first `x` is never a force: the daemon is the one that knows who 150 // A first `X` is never a force: the daemon is the one that knows who
151 // else is attached, so the client asks before it insists. 151 // else is attached, so the client asks before it insists.
152 try std.testing.expectEqual(client.SwitchIntent.end, wv.intentForEnd(&t, 1000)); 152 try std.testing.expectEqual(client.SwitchIntent.end, wv.intentForEnd(&t, 1000));
153 wv.onEndReply(&t, .{ .accepted = false, .others = 2, .reason = "others attached" }, 1000); 153 wv.onEndReply(&t, .{ .accepted = false, .others = 2, .reason = "others attached" }, 1000);
154 try std.testing.expectEqual(@as(i64, 1000 + wv.end_arm_ms), t.end_armed_until.load(.acquire)); 154 try std.testing.expectEqual(@as(i64, 1000 + wv.end_arm_ms), t.end_armed_until.load(.acquire));
155 try std.testing.expectEqual(client.SwitchIntent.end_force, wv.intentForEnd(&t, 2500)); 155 try std.testing.expectEqual(client.SwitchIntent.end_force, wv.intentForEnd(&t, 2500));
156 156
157 // The window SHUTS. Without this an `x` typed minutes later, on a 157 // The window SHUTS. Without this an `X` typed minutes later, on a
158 // session that has picked up watchers since, kills them without asking. 158 // session that has picked up watchers since, kills them without asking.
159 try std.testing.expectEqual(client.SwitchIntent.end, wv.intentForEnd(&t, 4001)); 159 try std.testing.expectEqual(client.SwitchIntent.end, wv.intentForEnd(&t, 4001));
160 160
@@ -327,7 +327,7 @@ test "birthTile: a vanished digit is taken back, and not before its pump returne
327 // The middle tile leaves, carrying a full set of dirt: every one of 327 // The middle tile leaves, carrying a full set of dirt: every one of
328 // these is a field whose stale value would be a lie about the NEXT 328 // these is a field whose stale value would be a lie about the NEXT
329 // tile in the slot — a session that was never up, a list that never 329 // tile in the slot — a session that was never up, a list that never
330 // missed it, an `x` nobody pressed, a host that does not own it. 330 // missed it, an `X` nobody pressed, a host that does not own it.
331 tiles[1].ever_up.store(true, .release); 331 tiles[1].ever_up.store(true, .release);
332 tiles[1].missed_once = true; 332 tiles[1].missed_once = true;
333 tiles[1].end_armed_until.store(9000, .release); 333 tiles[1].end_armed_until.store(9000, .release);
src/tui/wallview.zig
Old New
@@ -430,7 +430,7 @@ pub const Tile = struct {
430 /// to put on the wire — `client.SwitchIntent` as a u8, taken with a swap 430 /// to put on the wire — `client.SwitchIntent` as a u8, taken with a swap
431 /// so one keystroke asks one question. The PUMP asks: it owns the link. 431 /// so one keystroke asks one question. The PUMP asks: it owns the link.
432 ask: std.atomic.Value(u8) = std.atomic.Value(u8).init(0), 432 ask: std.atomic.Value(u8) = std.atomic.Value(u8).init(0),
433 /// Until when a second `Ctrl-\ x` on this tile FORCES the end. Written 433 /// Until when a second `Ctrl-\ X` on this tile FORCES the end. Written
434 /// by the pump from the daemon's refusal, read by the keyboard on the 434 /// by the pump from the daemon's refusal, read by the keyboard on the
435 /// next press — atomic because those are two threads, and a torn i64 is 435 /// next press — atomic because those are two threads, and a torn i64 is
436 /// a force nobody asked for. 436 /// a force nobody asked for.
@@ -548,14 +548,16 @@ fn stepWhere(present: []const bool, tiles: ?[]const Tile, sel: usize, forward: b
548 return null; 548 return null;
549 } 549 }
550 550
551 /// How long a refusal leaves the second `x` armed. Long enough to read the 551 /// How long a refusal leaves the second press armed — `Ctrl-\ X` on a tile,
552 /// `x` on a picker row. Long enough to read the
552 /// count and decide, short enough that the decision is about the session on 553 /// count and decide, short enough that the decision is about the session on
553 /// the screen and not one the user has since walked away from. 554 /// the screen and not one the user has since walked away from.
554 pub const end_arm_ms: i64 = 3000; 555 pub const end_arm_ms: i64 = 3000;
555 556
556 /// Which `x` this is. The wall's own `Ctrl-\ x` removes a pane and asks 557 /// Which press this is. `Ctrl-\ x` removes a pane and asks the daemon
557 /// the daemon nothing (`removePane`); this is the picker's end key, whose 558 /// nothing (`removePane`); `Ctrl-\ X` is the end key, and this is the
558 /// arming the pump reads back through `onEndReply`. 559 /// intent its keypress posts to the tile's pump. The arming behind it is
560 /// written by the pump from the daemon's answer, through `onEndReply`.
559 pub fn intentForEnd(t: *const Tile, now: i64) client.SwitchIntent { 561 pub fn intentForEnd(t: *const Tile, now: i64) client.SwitchIntent {
560 // The client never DECIDES to force: the daemon is the only side that 562 // The client never DECIDES to force: the daemon is the only side that
561 // knows who else is attached, so this only remembers being told to ask 563 // knows who else is attached, so this only remembers being told to ask
@@ -2561,7 +2563,33 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2561 setFocus(tiles[0..live], &shared, idx - 1); 2563 setFocus(tiles[0..live], &shared, idx - 1);
2562 } 2564 }
2563 }, 2565 },
2564 .end_session => if (z < w.live.* and present[z]) { 2566 .end_focused => if (z < w.live.* and present[z]) {
2567 // Asked through THIS tile's own pump, not a side connection:
2568 // the pump is already attached to the session, so the daemon
2569 // excludes it from the "others hold it" count and the answer
2570 // comes back on the link the tile is already reading. The
2571 // picker dials its own connection because the row it stands
2572 // on may have no pane here; a focused tile always does.
2573 //
2574 // Nothing is vanished on the keypress. The pane leaves the
2575 // way an ended session's pane always leaves — the shell's
2576 // `exit_status`, then the poll that no longer names it — so
2577 // a refused end costs the wall nothing.
2578 tiles[z].ask.store(
2579 @intFromEnum(intentForEnd(&tiles[z], std.time.milliTimestamp())),
2580 .release,
2581 );
2582 if (ringLive(&tiles[z])) {
2583 wall_host.pokeHost(w.hosts, &tiles[z]);
2584 } else {
2585 // No pump, so no link to ask on. A `gone` pane's session
2586 // is already off its daemon and an unreachable one's
2587 // cannot be reached to be ended.
2588 setNotice(&shared, no_live_here);
2589 showRefusal(tiles[0..live], &shared, z);
2590 }
2591 },
2592 .remove_pane => if (z < w.live.* and present[z]) {
2565 removePane(w, z); 2593 removePane(w, z);
2566 // A piped wall of one has nowhere to leave the tile off: 2594 // A piped wall of one has nowhere to leave the tile off:
2567 // scripts read the code, and the bare-chord abort says the 2595 // scripts read the code, and the bare-chord abort says the
test/e2e_12_panes.sh
Old New
@@ -1224,3 +1224,158 @@ assert_stopped "$SOCK62" "$D63PID" "gone-panes" "$OUT.gpstop2"
1224 D63PID="" 1224 D63PID=""
1225 rm -rf "$GPSTATE" 1225 rm -rf "$GPSTATE"
1226 ok "a rebooted daemon's panes wear gone and the cut never moves; Enter revives, x dismisses" 1226 ok "a rebooted daemon's panes wear gone and the cut never moves; Enter revives, x dismisses"
1227
1228 # --- Ctrl-\ X ends the focused pane's session, x still only removes it ---
1229 # The chord's whole point is that it asks over the PANE'S OWN connection,
1230 # which the picker's `x` cannot do (its row may have no pane here). Two
1231 # consequences this leg grades, and they are why it is not a copy of the
1232 # picker's end leg in 09_hosts:
1233 #
1234 # * the daemon excludes the asking client from the "others hold it"
1235 # count, so a session only this wall's pane holds ends on ONE press
1236 # where the picker's `x` on the same session needs two.
1237 # * the refusal banner names `X`, the key that asked. A banner that said
1238 # `x` would send the user to the key that removes the pane and leaves
1239 # the shell running — the exact session they meant to end, now on no
1240 # wall of theirs.
1241 #
1242 # Three panes on a daemon of its own, and two shells whose pids they printed
1243 # themselves before any wall existed: `kill -0` on those pids is the oracle
1244 # for every claim here, because a wall that says nothing and a daemon that
1245 # reaped nothing look identical from the grid.
1246 SOCK63="${TMPDIR:-/tmp}/muxd-e2e-endchord-$$.sock"
1247 defer_sock "$SOCK63"
1248 ECSTATE="${TMPDIR:-/tmp}/mux-e2e-endchord-state-$$"
1249 defer_rm "$ECSTATE"
1250 start_daemon "$SOCK63" "$OUT.ec.d" "end-chord daemon never bound" --shell /bin/sh
1251 D64PID=$DPID
1252
1253 # Sessions b and c, each made by a throwaway attach whose shell prints its
1254 # own pid. The shell's ECHO of the command carries the prefix with no
1255 # digits behind it, so the pattern demands at least one (the picker leg's
1256 # reason, and the same spelling).
1257 pipe_mux "$OUT.ecb" "$OUT.ecb.err" env XDG_STATE_HOME="$ECSTATE" timeout 60 \
1258 "$MUX" --sock "$SOCK63" --session b
1259 pipe_send 'printf "ecb-%%s\\n" $$\n'
1260 await_out "$OUT.ecb" "ecb-[0-9]" "end-chord: session b's shell never printed its pid"
1261 pipe_detach "end-chord: session b's maker"
1262 ECB=$(grep -o 'ecb-[0-9][0-9]*' "$OUT.ecb" | head -1 | cut -d- -f2)
1263 [ -n "$ECB" ] || {
1264 echo "e2e FAIL: end-chord: never read session b's shell pid:"; cat "$OUT.ecb"; exit 1; }
1265
1266 pipe_mux "$OUT.ecc" "$OUT.ecc.err" env XDG_STATE_HOME="$ECSTATE" timeout 60 \
1267 "$MUX" --sock "$SOCK63" --session c
1268 pipe_send 'printf "ecc-%%s\\n" $$\n'
1269 await_out "$OUT.ecc" "ecc-[0-9]" "end-chord: session c's shell never printed its pid"
1270 pipe_detach "end-chord: session c's maker"
1271 ECC=$(grep -o 'ecc-[0-9][0-9]*' "$OUT.ecc" | head -1 | cut -d- -f2)
1272 [ -n "$ECC" ] || {
1273 echo "e2e FAIL: end-chord: never read session c's shell pid:"; cat "$OUT.ecc"; exit 1; }
1274
1275 for _ec in "$ECB" "$ECC"; do
1276 kill -0 "$_ec" 2>/dev/null || {
1277 echo "e2e FAIL: end-chord: shell $_ec is not running before the leg"; exit 1; }
1278 done
1279 wait_sessions "$SOCK63" 3 "end-chord: the daemon should hold 0, b and c"
1280
1281 mkdir -p "$ECSTATE/mux"
1282 printf -- '--sock %s\n' "$SOCK63" > "$ECSTATE/mux/hosts"
1283 # Three leaves, so the pane that goes has neighbours to be told apart from
1284 # the wall going, and so ending the second of them never leaves an empty
1285 # wall — an empty wall opens the picker, which would eat the detach chord.
1286 seed_layout "$ECSTATE" stacked \
1287 "--sock $SOCK63#0" "--sock $SOCK63#b" "--sock $SOCK63#c"
1288
1289 # The other holder of session b, attached before the wall exists and silent
1290 # throughout: what makes the first press a refusal rather than an end.
1291 pipe_mux "$OUT.echold" "$OUT.echold.err" env XDG_STATE_HOME="$ECSTATE" timeout 120 \
1292 "$MUX" --sock "$SOCK63" --session b
1293 pipe_send 'printf "echold-%%s\\n" pin\n'
1294 await_out "$OUT.echold" "echold-pin" "end-chord: the other holder never got session b's grid"
1295
1296 set +e
1297 # Run 1: ONE press on a session someone else holds. The banner is the
1298 # daemon's count in this client's words, and the shell must outlive it —
1299 # a first press that ended anything is the regression this run exists for.
1300 XDG_STATE_HOME="$ECSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 30 \
1301 --out "$OUT.eccap1" --err "$OUT.eccap1.err" -- \
1302 "$MUX" > "$OUT.ecpc1" 2>&1 <<'EOF'
1303 settle 2500 30000
1304 send \x1c2
1305 settle 700 20000
1306 send \x1cX
1307 expect 1 other attached - X again to end 20000
1308 settle 700 20000
1309 send \x1cd
1310 waitexit 15000
1311 EOF
1312 RC=$?
1313 set -e
1314 rc0 "end-chord: run 1's ptyclient exited $RC:" "$OUT.ecpc1" "$OUT.eccap1.err"
1315 # The OS, not the wall's own word: the refusal refused.
1316 kill -0 "$ECB" 2>/dev/null || {
1317 echo "e2e FAIL: end-chord: one X ended session b's shell $ECB, which another"
1318 echo " client was holding — the daemon's first refusal did nothing:"
1319 cat "$OUT.ecpc1"; exit 1; }
1320 wait_sessions "$SOCK63" 3 "end-chord: a refused end must leave every session up"
1321
1322 set +e
1323 # Run 2, both halves of the contract in one wall:
1324 #
1325 # * pane 2 (session b, held by the pipe client too): X, the refusal, then
1326 # a second X inside the 3 s window forces the end.
1327 # * pane 3 (session c, held by nobody but this pane): ONE X ends it,
1328 # because the daemon does not count the client that is asking.
1329 #
1330 # The second X follows the `expect` and not a timer, so it lands the moment
1331 # the refusal is on the screen — well inside `wallview.end_arm_ms`. The arm
1332 # is per-tile and per-process, so run 1's is not carried in: run 2 asks and
1333 # is refused again before it forces.
1334 XDG_STATE_HOME="$ECSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 30 \
1335 --out "$OUT.eccap2" --err "$OUT.eccap2.err" -- \
1336 "$MUX" > "$OUT.ecpc2" 2>&1 <<'EOF'
1337 settle 2500 30000
1338 send \x1c2
1339 settle 700 20000
1340 send \x1cX
1341 expect 1 other attached - X again to end 20000
1342 send \x1cX
1343 settle 2500 25000
1344 send \x1c3
1345 settle 700 20000
1346 send \x1cX
1347 settle 2500 25000
1348 send \x1cd
1349 waitexit 15000
1350 EOF
1351 RC=$?
1352 set -e
1353 rc0 "end-chord: run 2's ptyclient exited $RC:" "$OUT.ecpc2" "$OUT.eccap2.err"
1354 # The OS on both shells. wait_pid_gone allows two seconds, which is four
1355 # times `Pty.term_grace_ms` — past the grace the daemon SIGKILLs, so a
1356 # shell still here is a session that was never ended.
1357 wait_pid_gone "$ECB" "end-chord: session b's shell after the second X"
1358 wait_pid_gone "$ECC" "end-chord: session c's shell after its single X"
1359 # The other holder was hung up on with the session's status in hand. 128 is
1360 # what a shell killed by a signal reports (pty.zig checkExited), and it is
1361 # the one number that says the DAEMON ended the session rather than the
1362 # client noticing a closed socket.
1363 pipe_waitexit "end-chord: the other holder of session b" 128
1364 # The daemon agrees, and kept the one session nobody asked about.
1365 wait_sessions "$SOCK63" 1 "end-chord: only 0 should be left"
1366 # The screen, judged on the GRID: a bar is in the stream either way, and
1367 # only the render says what was still there when the wall came down.
1368 "$RENDER" --cols 80 --rows 30 < "$OUT.eccap2" > "$OUT.ecgrid" || {
1369 echo "e2e FAIL: end-chord: render oracle failed"; cat "$OUT.ecgrid"; exit 1; }
1370 for _ec in "$SOCK63#b" "$SOCK63#c"; do
1371 grep -q -- "--sock $_ec" "$OUT.ecgrid" && {
1372 echo "e2e FAIL: end-chord: '$_ec' is still a pane on the final wall:"
1373 cat "$OUT.ecgrid"; exit 1; }
1374 done
1375 grep -q -- "--sock $SOCK63#0" "$OUT.ecgrid" || {
1376 echo "e2e FAIL: end-chord: the pane nobody ended lost its place on the wall:"
1377 cat "$OUT.ecgrid"; exit 1; }
1378 assert_stopped "$SOCK63" "$D64PID" "end-chord" "$OUT.ecstop"
1379 D64PID=""
1380 rm -rf "$ECSTATE"
1381 ok "Ctrl-\\ X ends the focused pane's session: refused while another client holds it, forced by the second press, and one press when the pane is the only holder"