d6f7c2af
feat: Ctrl-\ X ends the focused pane's session through its own pump
a73x 2026-09-04 15:46
Commit message
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" | ||