a73x

422efd20

test: the picker on a real pty, and the wall that outlives its last tile

a73x   2026-08-28 19:53

Commit message
test: the picker on a real pty, and the wall that outlives its last tile

A wall of two daemons, and each key answered by an oracle outside the
terminal: the birth's is `muxd stats` on the daemon the digit named, the
forget's is `kill -0` on the pid the forgotten host's shell printed
itself, and the Esc's is which session the keys after it reached. The
empty-wall leg gains the popup that now opens over it, judged on the
render — Esc has to leave the one-line text standing, and the box must
not come back over the answer.

The `:` leg becomes the picker's `a`, under ONE `Ctrl-\ s`, because an
`s` typed at an open picker closes it.

Every tty leg whose SOLE session exits gains an Esc and a `d`: on a
terminal that exit now leaves an empty wall with the picker over it
rather than ending mux. The piped legs are untouched, and that is the
point of them — `pipe_waitexit` still reads the shell's own code.

src/wallview.zig
Old New
@@ -366,6 +366,9 @@ const Shared = struct {
366 /// the user is choosing from. Their replicas stay hot the whole time, 366 /// the user is choosing from. Their replicas stay hot the whole time,
367 /// and the close bumps `repaint_gen` so every rect comes back. 367 /// and the close bumps `repaint_gen` so every rect comes back.
368 picker_open: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), 368 picker_open: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
369 /// The popup's last frame, so an unchanged one is not rewritten.
370 /// Keyboard-thread only, like the picker itself.
371 picker_stamp: u64 = 0,
369 }; 372 };
370 373
371 /// The rect a paint used, in the only terms a click can be resolved in. 374 /// The rect a paint used, in the only terms a click can be resolved in.
@@ -736,26 +739,6 @@ fn tileBanner(t: *Tile, text: []const u8) void {
736 paint.paintBanner(t.shared.out_fd, t.rect.cols, shown, t.rect.top + t.shared.label_rows, t.rect.left); 739 paint.paintBanner(t.shared.out_fd, t.rect.cols, shown, t.rect.top + t.shared.label_rows, t.rect.left);
737 } 740 }
738 741
739 /// `tileBanner` undone. The keyboard paints the prompt, and on a tile whose
740 /// pump has died it is also the only thread that can take it off again:
741 /// `paintDeadBarsLocked` redraws the label BAR, one row above this one, so
742 /// an unerased prompt line sits there until the wall comes down.
743 fn tileBannerClear(t: *Tile) void {
744 if (!t.shared.is_tty or t.shared.picker_open.load(.acquire)) return;
745 var buf: [40]u8 = undefined;
746 // Span-bounded ECH, never `\x1b[K`: the cells past this tile's own
747 // width are a rail's and a neighbour's. Saved and restored around it
748 // like `paintBanner`, so no shell's cursor visibly jumps.
749 const text = std.fmt.bufPrint(&buf, "\x1b[s\x1b[{d};{d}H\x1b[0m\x1b[{d}X\x1b[u", .{
750 t.rect.top + t.shared.label_rows + 1,
751 t.rect.left + 1,
752 t.rect.cols,
753 }) catch return;
754 t.shared.paint_mu.lock();
755 defer t.shared.paint_mu.unlock();
756 proto.writeAllFd(t.shared.out_fd, text) catch {};
757 }
758
759 /// Under `paint_mu`: a clear spliced into a 64 KiB OSC 52 write eats the 742 /// Under `paint_mu`: a clear spliced into a 64 KiB OSC 52 write eats the
760 /// paint after it. 743 /// paint after it.
761 fn copySelection( 744 fn copySelection(
@@ -1916,6 +1899,9 @@ fn relayout(
1916 // visible pane, no bar — the plain client's byte stream. 1899 // visible pane, no bar — the plain client's byte stream.
1917 shared.label_rows = if (shared.fullscreen or live <= 1) 0 else 1; 1900 shared.label_rows = if (shared.fullscreen or live <= 1) 0 else 1;
1918 if (shared.is_tty) proto.writeAllFd(shared.out_fd, "\x1b[?25l\x1b[H\x1b[2J") catch {}; 1901 if (shared.is_tty) proto.writeAllFd(shared.out_fd, "\x1b[?25l\x1b[H\x1b[2J") catch {};
1902 // The screen the popup was on has just been cleared, so the next paint
1903 // owes it however unchanged its rows are.
1904 if (shared.picker_open.load(.acquire)) shared.picker_stamp = 0;
1919 if (live == 0) { 1905 if (live == 0) {
1920 paintEmptyWallLocked(shared); 1906 paintEmptyWallLocked(shared);
1921 return; 1907 return;
@@ -2907,6 +2893,10 @@ const picker_row_max: usize = 128;
2907 const picker_min_cols: u16 = 24; 2893 const picker_min_cols: u16 = 24;
2908 const picker_min_rows: u16 = 4; 2894 const picker_min_rows: u16 = 4;
2909 2895
2896 /// One whole popup, written in one go: every row plus its cursor address
2897 /// and its two SGRs, at the table's cap of rows.
2898 const picker_frame_max: usize = (max_tiles + 2) * (picker_row_max + 32) + 8;
2899
2910 /// The picker's rendered rows. Rendered once per paint and read twice — 2900 /// The picker's rendered rows. Rendered once per paint and read twice —
2911 /// by the writer and by the tests — so the box on the screen is the box 2901 /// by the writer and by the tests — so the box on the screen is the box
2912 /// the claims are made about. 2902 /// the claims are made about.
@@ -3083,9 +3073,7 @@ fn pickForget(
3083 /// knows the picker exists: on open, on every key, and on every list that 3073 /// knows the picker exists: on open, on every key, and on every list that
3084 /// lands under it, so the state column is live while the user reads it. 3074 /// lands under it, so the state column is live while the user reads it.
3085 /// 3075 ///
3086 /// `line` is the spelling editor's text when `a` has it open. The notice 3076 /// `line` is the spelling editor's text when `a` has it open.
3087 /// wins the footer over both, because a refusal the user just earned is
3088 /// the one sentence the legend can wait for.
3089 fn paintPicker(shared: *Shared, host_table: []Host, sel: usize, line: ?[]const u8) void { 3077 fn paintPicker(shared: *Shared, host_table: []Host, sel: usize, line: ?[]const u8) void {
3090 if (!shared.is_tty) return; 3078 if (!shared.is_tty) return;
3091 // Before the lock, so a pump already inside `tilePaintBegin` blocks 3079 // Before the lock, so a pump already inside `tilePaintBegin` blocks
@@ -3114,6 +3102,9 @@ fn paintPicker(shared: *Shared, host_table: []Host, sel: usize, line: ?[]const u
3114 if (hi == sel) sel_row = i; 3102 if (hi == sel) sel_row = i;
3115 } 3103 }
3116 if (shown > 0 and sel_row >= shown) first = sel_row - shown + 1; 3104 if (shown > 0 and sel_row >= shown) first = sel_row - shown + 1;
3105 // The notice wins the footer over the legend and over the editor's own
3106 // line: a refusal the user just earned is the one sentence that cannot
3107 // wait for the next keystroke.
3117 var notice_buf: [96]u8 = undefined; 3108 var notice_buf: [96]u8 = undefined;
3118 const notice = takeNoticeLocked(shared, &notice_buf); 3109 const notice = takeNoticeLocked(shared, &notice_buf);
3119 const foot: []const u8 = if (notice.len > 0) 3110 const foot: []const u8 = if (notice.len > 0)
@@ -3122,31 +3113,40 @@ fn paintPicker(shared: *Shared, host_table: []Host, sel: usize, line: ?[]const u
3122 l 3113 l
3123 else 3114 else
3124 " Enter/c new session x forget a add Esc"; 3115 " Enter/c new session x forget a add Esc";
3116
3117 var out: [picker_frame_max]u8 = undefined;
3118 var fbs = std.io.fixedBufferStream(&out);
3119 const wr = fbs.writer();
3125 // The cursor is hidden for as long as the popup owns the screen: a 3120 // The cursor is hidden for as long as the popup owns the screen: a
3126 // caret left blinking in a tile says the keys are going there. 3121 // caret left blinking in a tile says the keys are going there.
3127 proto.writeAllFd(shared.out_fd, "\x1b[?25l") catch {}; 3122 wr.writeAll("\x1b[?25l") catch return;
3128 pickerLine(shared, top, left, w, " hosts"); 3123 pickerLine(wr, top, left, w, " hosts");
3129 if (cramped) return; 3124 if (!cramped) {
3130 var r: u16 = 0; 3125 var r: u16 = 0;
3131 while (r < shown and first + r < body.n) : (r += 1) 3126 while (r < shown and first + r < body.n) : (r += 1)
3132 pickerLine(shared, top + 1 + r, left, w, body.row(first + r)); 3127 pickerLine(wr, top + 1 + r, left, w, body.row(first + r));
3133 pickerLine(shared, top + height - 1, left, w, foot); 3128 pickerLine(wr, top + height - 1, left, w, foot);
3129 }
3130 const frame = fbs.getWritten();
3131 // Nothing changed, nothing written. The pollers report once a second
3132 // per host and every one of them repaints this box; rewriting an
3133 // identical screen at that rate is a terminal that never goes quiet.
3134 const stamp = std.hash.Wyhash.hash(0, frame);
3135 if (stamp == shared.picker_stamp) return;
3136 shared.picker_stamp = stamp;
3137 proto.writeAllFd(shared.out_fd, frame) catch {};
3134 } 3138 }
3135 3139
3136 /// One row of the box: reverse video, padded to the box width. The same 3140 /// One row of the box: reverse video, padded to the box width. The same
3137 /// chrome a label bar and a rail wear, so the popup never reads as a 3141 /// chrome a label bar and a rail wear, so the popup never reads as a
3138 /// session's own output. 3142 /// session's own output.
3139 fn pickerLine(shared: *Shared, row: u16, left: u16, w: u16, text: []const u8) void { 3143 fn pickerLine(wr: anytype, row: u16, left: u16, w: u16, text: []const u8) void {
3140 var buf: [picker_row_max * 2 + 64]u8 = undefined; 3144 wr.print("\x1b[{d};{d}H\x1b[7m", .{ row + 1, left + 1 }) catch return;
3141 var fbs = std.io.fixedBufferStream(&buf);
3142 const out = fbs.writer();
3143 out.print("\x1b[{d};{d}H\x1b[7m", .{ row + 1, left + 1 }) catch return;
3144 const take = @min(text.len, @as(usize, w)); 3145 const take = @min(text.len, @as(usize, w));
3145 out.writeAll(text[0..take]) catch return; 3146 wr.writeAll(text[0..take]) catch return;
3146 var i: usize = take; 3147 var i: usize = take;
3147 while (i < w) : (i += 1) out.writeByte(' ') catch break; 3148 while (i < w) : (i += 1) wr.writeByte(' ') catch break;
3148 out.writeAll("\x1b[0m") catch return; 3149 wr.writeAll("\x1b[0m") catch return;
3149 proto.writeAllFd(shared.out_fd, fbs.getWritten()) catch {};
3150 } 3150 }
3151 3151
3152 /// For a caller already holding `paint_mu`. 3152 /// For a caller already holding `paint_mu`.
@@ -3736,6 +3736,9 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3736 // an empty wall opens it once, and an Esc there has to be able to 3736 // an empty wall opens it once, and an Esc there has to be able to
3737 // leave the empty line showing rather than be re-opened over. 3737 // leave the empty line showing rather than be re-opened over.
3738 var picker_auto = false; 3738 var picker_auto = false;
3739 // Whether the popup was already on the screen when this key arrived,
3740 // so an OPEN can be told from a keystroke inside one.
3741 var picker_shown = false;
3739 // `feed` hands a candidate held across the previous read back ahead of 3742 // `feed` hands a candidate held across the previous read back ahead of
3740 // this chunk, so its room is a whole chunk plus that hold. 3743 // this chunk, so its room is a whole chunk plus that hold.
3741 var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined; 3744 var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined;
@@ -3906,17 +3909,39 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3906 // one-line empty-wall text standing. 3909 // one-line empty-wall text standing.
3907 var picker_opened = false; 3910 var picker_opened = false;
3908 if (presentCount(present[0..live]) == 0) { 3911 if (presentCount(present[0..live]) == 0) {
3909 if (shared.is_tty and !input.prefix.picking and !picker_auto) { 3912 // Not before the opening hosts have answered (`restore_tried`
3913 // is that same moment, or 2s): a wall still dialling is not
3914 // known to be empty, and a popup flashed over a wall that is
3915 // about to have tiles is one the user never asked for.
3916 if (shared.is_tty and restore_tried and !input.prefix.picking and !picker_auto) {
3910 picker_auto = true; 3917 picker_auto = true;
3911 picker_opened = true; 3918 picker_opened = true;
3919 picker_shown = true;
3912 input.prefix.picking = true; 3920 input.prefix.picking = true;
3913 picker_sel = pickerNearest(host_table[0..hosts_live], picker_sel); 3921 picker_sel = pickerNearest(host_table[0..hosts_live], picker_sel);
3914 } 3922 }
3915 } else picker_auto = false; 3923 } else if (picker_auto) {
3924 // A tile arrived under a popup NOBODY asked for: the wall has
3925 // something to show again, so it gets the screen back. One the
3926 // user opened stays open — they are choosing, and a session
3927 // being born on another host is not an answer to that.
3928 picker_auto = false;
3929 input.prefix.picking = false;
3930 picker_shown = false;
3931 shared.picker_open.store(false, .release);
3932 // The screen under the box is about to be redrawn, so the next
3933 // open owes a paint however identical its rows are.
3934 shared.picker_stamp = 0;
3935 relayout(alloc, tiles[0..live], present[0..live], &shared, shared.sel);
3936 }
3916 // On news, not on the tick: the rows have to follow the pollers 3937 // On news, not on the tick: the rows have to follow the pollers
3917 // while the popup is open, and a repaint every 100ms would rewrite 3938 // while the popup is open, and a repaint every 100ms would rewrite
3918 // a screen nothing changed on. 3939 // a screen nothing changed on.
3919 if (input.prefix.picking and !input.prefix.prompting and (picker_opened or host_news or winch)) 3940 // The bell too: a tile that ended relayouts under the box, and the
3941 // cleared screen owes the popup a repaint the pollers must not be
3942 // the first to give it.
3943 if (input.prefix.picking and !input.prefix.prompting and
3944 (picker_opened or host_news or winch or fds[1].revents != 0))
3920 paintPicker(&shared, host_table[0..hosts_live], picker_sel, null); 3945 paintPicker(&shared, host_table[0..hosts_live], picker_sel, null);
3921 if (fds[0].revents == 0) continue; 3946 if (fds[0].revents == 0) continue;
3922 3947
@@ -3938,14 +3963,17 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3938 // Typed AT the session, ahead of the chord, in the same read. 3963 // Typed AT the session, ahead of the chord, in the same read.
3939 if (cmd.forward.len > 0 and z < live and present[z]) sendKeys(&tiles[z], cmd.forward); 3964 if (cmd.forward.len > 0 and z < live and present[z]) sendKeys(&tiles[z], cmd.forward);
3940 var birth_at: ?usize = null; 3965 var birth_at: ?usize = null;
3966 // Opening on a focused tile pre-selects that tile's host: the
3967 // machine the user is already on is the one they mean. Judged
3968 // on the popup's own state, not on `.pick_open`, because one
3969 // read can open the picker and its editor together.
3970 if (!picker_shown) {
3971 if (z < live and present[z]) {
3972 if (tiles[z].host) |hi| picker_sel = hi;
3973 }
3974 picker_sel = pickerNearest(host_table[0..hosts_live], picker_sel);
3975 }
3941 switch (cmd.action) { 3976 switch (cmd.action) {
3942 // The machine the user is already on is the one they mean.
3943 .pick_open => {
3944 if (z < live and present[z]) {
3945 if (tiles[z].host) |hi| picker_sel = hi;
3946 }
3947 picker_sel = pickerNearest(host_table[0..hosts_live], picker_sel);
3948 },
3949 .pick_move => |d| picker_sel = pickerStep(host_table[0..hosts_live], picker_sel, d), 3977 .pick_move => |d| picker_sel = pickerStep(host_table[0..hosts_live], picker_sel, d),
3950 .pick_select => |row| { 3978 .pick_select => |row| {
3951 if (pickerAt(host_table[0..hosts_live], row - 1)) |hi| picker_sel = hi; 3979 if (pickerAt(host_table[0..hosts_live], row - 1)) |hi| picker_sel = hi;
@@ -3988,6 +4016,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3988 }, 4016 },
3989 else => {}, 4017 else => {},
3990 } 4018 }
4019 picker_shown = input.prefix.picking;
3991 if (input.prefix.picking) { 4020 if (input.prefix.picking) {
3992 var line_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined; 4021 var line_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined;
3993 const line: ?[]const u8 = if (input.prefix.prompting) 4022 const line: ?[]const u8 = if (input.prefix.prompting)
@@ -4000,6 +4029,10 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
4000 // and bumps `repaint_gen`, which is the only thing that 4029 // and bumps `repaint_gen`, which is the only thing that
4001 // redraws a tile whose session said nothing meanwhile. 4030 // redraws a tile whose session said nothing meanwhile.
4002 shared.picker_open.store(false, .release); 4031 shared.picker_open.store(false, .release);
4032 // The screen under the box is about to be redrawn, so the next
4033 // open owes a paint however identical its rows are.
4034 shared.picker_stamp = 0;
4035 picker_shown = false;
4003 if (birth_at) |at| 4036 if (birth_at) |at|
4004 focusAnswer(alloc, tiles[0..live], present[0..live], &shared, true, at) 4037 focusAnswer(alloc, tiles[0..live], present[0..live], &shared, true, at)
4005 else 4038 else
@@ -7239,36 +7272,6 @@ test "restore: focus_out maps the saved focus through the spelling match" {
7239 } 7272 }
7240 } 7273 }
7241 7274
7242 test "tileBannerClear: the prompt's row is erased inside the tile, never past it" {
7243 // A tile whose pump has died gets no claim to repaint over a banner,
7244 // so the keyboard takes its own prompt line back off the screen. The
7245 // erase is a span bounded by the tile's rect: line-wide would take the
7246 // rail and the neighbour's cells with it.
7247 const pipe = try std.posix.pipe();
7248 defer std.posix.close(pipe[0]);
7249 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
7250 shared.label_rows = 1;
7251 var t = Tile{
7252 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
7253 .rect = .{ .top = 2, .left = 5, .rows = 5, .cols = 10 },
7254 .shared = &shared,
7255 .idx = 0,
7256 .wake_r = -1,
7257 .wake_w = -1,
7258 };
7259 tileBannerClear(&t);
7260 std.posix.close(pipe[1]);
7261
7262 var out: [256]u8 = undefined;
7263 const n = try std.posix.read(pipe[0], &out);
7264 const got = out[0..n];
7265 // The same corner `tileBanner` writes into, and exactly as many cells
7266 // wide as the tile.
7267 try std.testing.expect(std.mem.indexOf(u8, got, "\x1b[4;6H") != null);
7268 try std.testing.expect(std.mem.indexOf(u8, got, "\x1b[10X") != null);
7269 try std.testing.expect(std.mem.indexOf(u8, got, "\x1b[K") == null);
7270 }
7271
7272 test "tileBanner: a prompt wider than its pane shows the tail inside the pane" { 7275 test "tileBanner: a prompt wider than its pane shows the tail inside the pane" {
7273 // A tile that painted a whole long spelling would run across the rail 7276 // A tile that painted a whole long spelling would run across the rail
7274 // into its neighbour, so the banner is bounded by the tile's rect and 7277 // into its neighbour, so the banner is bounded by the tile's rect and
test/e2e.sh
Old New
@@ -177,8 +177,8 @@ done
177 # one of those and adds a convergence point would be pinning a fact every 177 # one of those and adds a convergence point would be pinning a fact every
178 # leg above already establishes. 178 # leg above already establishes.
179 179
180 [ "$OK_COUNT" = "84" ] || { 180 [ "$OK_COUNT" = "85" ] || {
181 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 84 —" 181 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 85 —"
182 echo " a scenario was added (update the pin) or silently lost" 182 echo " a scenario was added (update the pin) or silently lost"
183 exit 1 183 exit 1
184 } 184 }
test/e2e_09_hosts.sh
Old New
@@ -606,6 +606,11 @@ ok "hosts rm takes the daemon off the wall and ends nothing"
606 # what the ordinary road prints, and on a wall with no session that 606 # what the ordinary road prints, and on a wall with no session that
607 # sentence is a lie about the user's shells. 607 # sentence is a lie about the user's shells.
608 # 608 #
609 # ...and a third, ahead of both: the empty wall OPENS THE PICKER by itself.
610 # A blank screen is no place to act from, and the list of machines is the
611 # only thing left to act on. Esc there leaves the one-line text standing —
612 # once, so the popup does not come straight back over the answer.
613 #
609 # The socket is one nothing has ever bound, and deliberately NOT the 614 # The socket is one nothing has ever bound, and deliberately NOT the
610 # default path: a listed LOCAL daemon is auto-started, which would give 615 # default path: a listed LOCAL daemon is auto-started, which would give
611 # this wall the very tile it is asserting the absence of. 616 # this wall the very tile it is asserting the absence of.
@@ -625,6 +630,9 @@ set +e
625 XDG_STATE_HOME="$HOSTROOM" timeout 40 "$PTYCLIENT" --cols 80 --rows 24 \ 630 XDG_STATE_HOME="$HOSTROOM" timeout 40 "$PTYCLIENT" --cols 80 --rows 24 \
626 --out "$OUT.emcap" --err "$OUT.emcap.err" -- "$MUX" > "$OUT.empc" 2>&1 <<'EOF' 631 --out "$OUT.emcap" --err "$OUT.emcap.err" -- "$MUX" > "$OUT.empc" 2>&1 <<'EOF'
627 expect the wall is empty 20000 632 expect the wall is empty 20000
633 expect Enter/c new session 20000
634 send \x1b
635 settle 500 15000
628 send \x1cd 636 send \x1cd
629 waitexit 1000 637 waitexit 1000
630 EOF 638 EOF
@@ -641,4 +649,162 @@ EMLAST=$(grep -v '^[[:space:]]*$' "$OUT.emcap.err" | tail -1)
641 echo "e2e FAIL: empty wall: leaving an empty wall said" 649 echo "e2e FAIL: empty wall: leaving an empty wall said"
642 echo " '$EMLAST', want 'mux: left the wall'" 650 echo " '$EMLAST', want 'mux: left the wall'"
643 cat "$OUT.emcap.err"; exit 1; } 651 cat "$OUT.emcap.err"; exit 1; }
644 ok "an empty wall says so, and Ctrl-\\ d leaves it at once rather than detaching nothing" 652 # The Esc, judged on the GRID and not on the stream: the popup's rows are
653 # in the capture either way, and only the render says what the user was
654 # still looking at when the wall came down.
655 "$RENDER" --cols 80 --rows 24 < "$OUT.emcap" > "$OUT.emgrid" || {
656 echo "e2e FAIL: empty wall: render oracle failed"; cat "$OUT.emgrid"; exit 1; }
657 grep -q "the wall is empty" "$OUT.emgrid" || {
658 echo "e2e FAIL: empty wall: Esc did not leave the one-line text:"
659 cat "$OUT.emgrid"; exit 1; }
660 grep -q "Enter/c new session" "$OUT.emgrid" && {
661 echo "e2e FAIL: empty wall: the picker came back over the answer:"
662 cat "$OUT.emgrid"; exit 1; }
663 ok "an empty wall opens the picker, Esc leaves the one line, and Ctrl-\\ d leaves at once"
664
665 # ---- the picker: the wall shows sessions, the popup shows machines ------
666 #
667 # `Ctrl-\ s` over a wall of two daemons. Everything a host needs doing to
668 # happens here and nowhere else, so the four keys are asserted in one run
669 # and each against an oracle outside the terminal:
670 #
671 # * Enter births on the SELECTED host — the daemon's own session count,
672 # read back with `muxd stats`, says which machine took it. A birth
673 # driven off a tile could only ever have landed on the focus's daemon.
674 # * `x` forgets a host: the file loses the line and the host's SHELL is
675 # still there, asked of the OS with `kill -0` on the pid the shell
676 # itself printed. "remove is detach" — `hosts rm` ends nothing.
677 # * `a` puts it back, so the file is two lines again.
678 # * Esc closes, and the keys after it reach the focused session — the
679 # popup ate every byte until then, which is what stops a `j` meant for
680 # the rows from landing in somebody's editor.
681 #
682 # Its own daemons and its own state home: the legs above stop theirs, and
683 # a hosts file this leg edits from inside must be nobody else's.
684 PKSOCK1="${TMPDIR:-/tmp}/muxd-e2e-pick1-$$.sock"
685 defer_sock "$PKSOCK1"
686 PKSOCK2="${TMPDIR:-/tmp}/muxd-e2e-pick2-$$.sock"
687 defer_sock "$PKSOCK2"
688 PKSTATE="${TMPDIR:-/tmp}/mux-e2e-pick-state-$$"
689 defer_rm "$PKSTATE"
690 start_daemon "$PKSOCK1" "$OUT.pk1.d" "picker daemon 1 never bound" --shell /bin/sh
691 PK1PID=$DPID
692 start_daemon "$PKSOCK2" "$OUT.pk2.d" "picker daemon 2 never bound" --shell /bin/sh
693 PK2PID=$DPID
694
695 # Daemon 1's shell prints its own pid: the forget below claims the SESSION
696 # outlives the wall line, and only a pid the shell itself reported can be
697 # asked of the process table afterwards.
698 pipe_mux "$OUT.pk1a" "$OUT.pk1a.err" env XDG_STATE_HOME="$PKSTATE" timeout 40 \
699 "$MUX" --sock "$PKSOCK1"
700 pipe_send 'printf "pk-shell-%%s\\n" $$\n'
701 await_out "$OUT.pk1a" "pk-shell-[0-9]" "picker: daemon 1's shell never printed its pid"
702 pipe_detach
703 wait_grid "$PKSOCK1" "pk-shell-[0-9]" "picker: daemon 1's shell pid"
704 # The shell's ECHO of the command comes first and carries the prefix with
705 # no digits behind it, so the pattern demands at least one.
706 PKSHELL=$(grep -o 'pk-shell-[0-9][0-9]*' "$OUT.pk1a" | head -1 | cut -d- -f3)
707 [ -n "$PKSHELL" ] || {
708 echo "e2e FAIL: picker: never read daemon 1's shell pid:"; cat "$OUT.pk1a"; exit 1; }
709 kill -0 "$PKSHELL" 2>/dev/null || {
710 echo "e2e FAIL: picker: daemon 1's shell $PKSHELL is not running before the leg"; exit 1; }
711
712 # Daemon 2's marker, printed before this wall exists: what proves the tile
713 # on the far side of the popup is really daemon 2's and not an echo.
714 pipe_mux "$OUT.pk2a" "$OUT.pk2a.err" env XDG_STATE_HOME="$PKSTATE" timeout 40 \
715 "$MUX" --sock "$PKSOCK2"
716 pipe_send 'printf "pk-two-%%s\\n" mark\n'
717 await_out "$OUT.pk2a" "pk-two-mark" "picker: daemon 2's marker never reached the client"
718 pipe_detach
719 wait_grid "$PKSOCK2" "pk-two-mark" "picker: daemon 2's marker"
720
721 mkdir -p "$PKSTATE/mux"
722 printf -- '--sock %s\n--sock %s\n' "$PKSOCK1" "$PKSOCK2" > "$PKSTATE/mux/hosts"
723 no_saved_tree "$PKSTATE"
724
725 PKBEFORE=$("$MUXD" stats --sock "$PKSOCK2" | tr ' ' '\n' | grep -c '^session$')
726 set +e
727 XDG_STATE_HOME="$PKSTATE" timeout 90 "$PTYCLIENT" --cols 100 --rows 30 \
728 --out "$OUT.pkcap" --err "$OUT.pkcap.err" -- \
729 "$MUX" > "$OUT.pkpc" 2>&1 <<EOF
730 expect pk-two-mark 20000
731 settle 700 20000
732 send \x1cs
733 expect Enter/c new session 15000
734 send 2
735 settle 400 15000
736 send \r
737 expect 3> 20000
738 settle 700 20000
739 send \x1cs
740 expect Enter/c new session 15000
741 send 1
742 settle 400 15000
743 send x
744 settle 900 20000
745 send \x1cs
746 expect Enter/c new session 15000
747 send a--sock\x20
748 settle 300 15000
749 send $PKSOCK1\r
750 settle 700 15000
751 send \x1b
752 settle 700 20000
753 send printf 'pk-after-%s\n' esc\n
754 expect pk-after-esc 15000
755 send \x1cd
756 waitexit 10000
757 EOF
758 RC=$?
759 set -e
760 [ "$RC" -eq 0 ] || {
761 echo "e2e FAIL: picker: ptyclient leg exited $RC:"
762 cat "$OUT.pkpc"; echo "--- stderr ---"; cat "$OUT.pkcap.err"; exit 1; }
763
764 # Both spellings really were on the popup — the rows are the hosts file's
765 # daemons, and a box that listed one of two would still have satisfied the
766 # footer the `expect` waited on.
767 grep -qa -- "$PKSOCK1" "$OUT.pkcap" || {
768 echo "e2e FAIL: picker: daemon 1 was never a row:"; cat "$OUT.pkpc"; exit 1; }
769 grep -qa -- "$PKSOCK2" "$OUT.pkcap" || {
770 echo "e2e FAIL: picker: daemon 2 was never a row:"; cat "$OUT.pkpc"; exit 1; }
771
772 # The birth's oracle is the DAEMON's count, not the screen: row 2 was
773 # selected, so daemon 2 is the one that gained a session.
774 PKAFTER=$("$MUXD" stats --sock "$PKSOCK2" | tr ' ' '\n' | grep -c '^session$')
775 [ "$PKAFTER" -eq "$((PKBEFORE + 1))" ] || {
776 echo "e2e FAIL: picker: daemon 2 holds $PKAFTER sessions, want $((PKBEFORE + 1))"
777 "$MUXD" stats --sock "$PKSOCK2"; "$MUXD" stats --sock "$PKSOCK1"; exit 1; }
778
779 # The forget: the line left the file and came back, and the SESSION never
780 # noticed either. `kill -0` on the shell's own pid, never the daemon's word
781 # for it.
782 kill -0 "$PKSHELL" 2>/dev/null || {
783 echo "e2e FAIL: picker: forgetting daemon 1 killed its shell $PKSHELL"; exit 1; }
784 grep -qxF -- "--sock $PKSOCK1" "$PKSTATE/mux/hosts" || {
785 echo "e2e FAIL: picker: a re-added host is not back in the file:"
786 cat "$PKSTATE/mux/hosts"; exit 1; }
787 [ "$(grep -c . "$PKSTATE/mux/hosts")" = "2" ] || {
788 echo "e2e FAIL: picker: hosts file is not exactly two lines after x then a:"
789 cat "$PKSTATE/mux/hosts"; exit 1; }
790
791 # Esc gave the keyboard back. The needle is looked for in the DAEMONS, so
792 # this says which session the keys reached and not merely that something
793 # painted: the focus was on a daemon-2 tile when the forget took daemon
794 # 1's tiles off the wall.
795 PKWHERE=""
796 for _pk in 0 1; do
797 if dump_session "$PKSOCK2" "$_pk" 2>/dev/null | grep -q "pk-after-esc"; then PKWHERE="$_pk"; fi
798 done
799 [ -n "$PKWHERE" ] || {
800 echo "e2e FAIL: picker: the keys after Esc reached no session on daemon 2:"
801 cat "$OUT.pkpc"; exit 1; }
802 if dump_session "$PKSOCK1" 0 2>/dev/null | grep -q "pk-after-esc"; then
803 echo "e2e FAIL: picker: the keys went to the host the forget took off the wall:"
804 cat "$OUT.pkpc"; exit 1
805 fi
806 assert_stopped "$PKSOCK1" "$PK1PID" "picker" "$OUT.pk1stop"
807 PK1PID=""
808 assert_stopped "$PKSOCK2" "$PK2PID" "picker" "$OUT.pk2stop"
809 PK2PID=""
810 ok "the picker births on the host a digit names, forgets a host without ending it, adds one back, and gives the keyboard back"
test/e2e_10_agent.sh
Old New
@@ -98,6 +98,13 @@ send ssh-add -l\n
98 expect $FP48 15000 98 expect $FP48 15000
99 settle 400 15000 99 settle 400 15000
100 send exit\n 100 send exit\n
101 # On a TERMINAL the last tile's exit leaves an EMPTY WALL — `x` ends a
102 # session, never mux — and the picker opens over it. Esc closes the popup
103 # and `d` is what leaves. A piped mux still exits with the shell's code.
104 settle 700 15000
105 send \x1b
106 settle 300 15000
107 send \x1cd
101 waitexit 10000 108 waitexit 10000
102 EOF 109 EOF
103 RC=$? 110 RC=$?
@@ -414,6 +421,17 @@ expect $FPA 20000
414 send echo flip-now\n 421 send echo flip-now\n
415 expect $FPB 25000 422 expect $FPB 25000
416 send exit\n 423 send exit\n
424 # Empty wall, then the picker: see the first agent leg — and `expect` on
425 # the popup's own legend, never a settle. The picker opens on the host
426 # POLL (up to a second after the last session dies) while a settle fires a
427 # fixed time after the last output byte; a picker that opened between the
428 # settle and the `\x1b` would eat the `d` that follows (every byte is the
429 # popup's while it is up) and this leg would fail at waitexit on a change
430 # nobody made.
431 expect Enter/c new session 20000
432 send \x1b
433 settle 300 20000
434 send \x1cd
417 waitexit 20000 435 waitexit 20000
418 EOF 436 EOF
419 FLIPAPID=$! 437 FLIPAPID=$!
@@ -432,6 +450,15 @@ send echo b-ready\n
432 expect flip-now 30000 450 expect flip-now 30000
433 send ssh-add -l\n 451 send ssh-add -l\n
434 expect $FPB 20000 452 expect $FPB 20000
453 # A's `exit` ends the session both clients hold, so B's tile goes too and
454 # B is left on the same empty wall with the same picker over it. The
455 # legend and not a settle, doubly so here: B is a passive observer, so its
456 # screen is already quiet when A types `exit` and a duration could expire
457 # before the transition this waits for has happened at all.
458 expect Enter/c new session 20000
459 send \x1b
460 settle 300 20000
461 send \x1cd
435 waitexit 20000 462 waitexit 20000
436 EOF 463 EOF
437 RCB=$? 464 RCB=$?
test/e2e_11_select.sh
Old New
@@ -396,6 +396,15 @@ send S=$(date +%s%N); ssh-add -l; R=$?; echo mu""te2=$R ms=$(( ($(date +%s%N)-S)
396 expect mute2= 15000 396 expect mute2= 15000
397 settle 400 15000 397 settle 400 15000
398 send exit\n 398 send exit\n
399 # The last tile's exit leaves an empty wall with the picker over it on a
400 # terminal: Esc closes the popup, `d` leaves. The popup's own legend is
401 # what this waits for — a settle measures silence, and the picker opens on
402 # the host poll, so a duration can expire in the window where the popup is
403 # about to eat the `d`.
404 expect Enter/c new session 15000
405 send \x1b
406 settle 300 15000
407 send \x1cd
399 waitexit 10000 408 waitexit 10000
400 EOF 409 EOF
401 RC=$? 410 RC=$?
test/e2e_13_birth.sh
Old New
@@ -39,14 +39,19 @@ SPDIR="${TMPDIR:-/tmp}/mux-e2e-sp-shim-$$"
39 defer_rm "$SPDIR" 39 defer_rm "$SPDIR"
40 SPINLOG="$SPDIR/dials" 40 SPINLOG="$SPDIR/dials"
41 41
42 # ---- Ctrl-\ : adds a HOST by spelling --------------------------------- 42 # ---- the picker's `a` adds a HOST by spelling --------------------------
43 # 43 #
44 # `mux hosts add` typed from inside. A spelling names a DAEMON: the line 44 # `mux hosts add` typed from inside. A spelling names a DAEMON: the line
45 # goes into the hosts file, a poller starts, and that daemon's sessions 45 # goes into the hosts file, a poller starts, and that daemon's sessions
46 # become tiles — the prompt cannot conjure a session, only ask a machine 46 # become tiles — the editor cannot conjure a session, only ask a machine
47 # what it is running. A bad spelling is a notice and nothing else (the 47 # what it is running. A bad spelling is a notice and nothing else (the
48 # file does not grow). Esc eats the line: the shell never sees it, and the 48 # file does not grow). Esc eats the line: the shell never sees it, and
49 # next keys reach the session again. 49 # after the popup closes the next keys reach the session again.
50 #
51 # The editor lives INSIDE the picker (`Ctrl-\ s`, then `a`), so its Esc
52 # goes back to the rows and a second Esc is what closes the popup. That is
53 # also why the whole spelling round happens under ONE `\x1c s`: an `s`
54 # typed at an open picker closes it.
50 # 55 #
51 # The prompt echoes what is typed, so the added host's witness is not its 56 # The prompt echoes what is typed, so the added host's witness is not its
52 # label on the capture (the echo would match) but daemon B's OWN SHELL's 57 # label on the capture (the echo would match) but daemon B's OWN SHELL's
@@ -97,19 +102,21 @@ XDG_STATE_HOME="$PRSTATE" timeout 90 "$PTYCLIENT" --cols 100 --rows 30 \
97 "$MUX" > "$OUT.prpc" 2>&1 <<EOF 102 "$MUX" > "$OUT.prpc" 2>&1 <<EOF
98 expect pr-origin 20000 103 expect pr-origin 20000
99 settle 700 20000 104 settle 700 20000
100 send \x1c:--sock\x20 105 send \x1csa--sock\x20
101 settle 300 5000 106 settle 300 5000
102 send $SOCK63\r 107 send $SOCK63\r
103 expect pr-bmark 20000 108 settle 400 15000
104 settle 1000 20000 109 send ax#bad name\r
105 send \x1c:x#bad name\r
106 expect [bad host 10000 110 expect [bad host 10000
107 settle 500 15000 111 settle 400 15000
108 send \x1c:-A nosuchhost.invalid\r 112 send a-A nosuchhost.invalid\r
109 expect FlagLikeTarget 10000 113 expect FlagLikeTarget 10000
110 settle 500 15000 114 settle 400 15000
111 send \x1c:zzz\x1b 115 send azzz\x1b
112 settle 500 15000 116 settle 400 15000
117 send \x1b
118 expect pr-bmark 20000
119 settle 700 20000
113 send printf 'pr-after-%s\n' esc\n 120 send printf 'pr-after-%s\n' esc\n
114 expect pr-after-esc 10000 121 expect pr-after-esc 10000
115 settle 500 15000 122 settle 500 15000
@@ -119,7 +126,7 @@ EOF
119 RC=$? 126 RC=$?
120 set -e 127 set -e
121 [ "$RC" -eq 0 ] || { 128 [ "$RC" -eq 0 ] || {
122 echo "e2e FAIL: prompt: ptyclient leg exited $RC (did \\x1c: add the tile?):" 129 echo "e2e FAIL: prompt: ptyclient leg exited $RC (did the picker's a add the tile?):"
123 cat "$OUT.prpc" "$OUT.prcap.err"; exit 1; } 130 cat "$OUT.prpc" "$OUT.prcap.err"; exit 1; }
124 # The oracle, not the byte stream: a banner LEFT on the screen is invisible 131 # The oracle, not the byte stream: a banner LEFT on the screen is invisible
125 # to a grep of the emitted bytes — the `: zzz` that painted is in the 132 # to a grep of the emitted bytes — the `: zzz` that painted is in the
@@ -146,11 +153,11 @@ grep -q -- "-A nosuchhost.invalid \[" "$OUT.prgrid" && {
146 grep -aq ": --sock " "$OUT.prcap" || { 153 grep -aq ": --sock " "$OUT.prcap" || {
147 echo "e2e FAIL: prompt: the prompt line never painted its echo:" 154 echo "e2e FAIL: prompt: the prompt line never painted its echo:"
148 cat "$OUT.prpc"; exit 1; } 155 cat "$OUT.prpc"; exit 1; }
149 # Daemon B is really on this wall: its session got a stripe of its own, 156 # Daemon B is really on this wall: its session got a tile of its own,
150 # naming B's socket, and the focus never left daemon A — the keys typed 157 # naming B's socket, and the focus never left daemon A — the keys typed
151 # after the Esc are in A's session and in nothing of B's. 158 # after the Esc are in A's session and in nothing of B's.
152 # B's own shell's output, on THIS wall's terminal. Not the bar: two 159 # B's own shell's output, on THIS wall's terminal. Not the bar: two
153 # stripes over 100 columns cut a socket path in half, and `labelText` 160 # tiles over 100 columns cut a socket path in half, and `labelText`
154 # spends what is left on the state word. The marker cannot be an echo — 161 # spends what is left on the state word. The marker cannot be an echo —
155 # B printed it before this process existed. 162 # B printed it before this process existed.
156 grep -qa "pr-bmark" "$OUT.prcap" || { 163 grep -qa "pr-bmark" "$OUT.prcap" || {
@@ -181,7 +188,7 @@ D63PID=""
181 assert_stopped "$SOCK63" "$D64PID" "prompt B" "$OUT.prbstop" 188 assert_stopped "$SOCK63" "$D64PID" "prompt B" "$OUT.prbstop"
182 D64PID="" 189 D64PID=""
183 rm -rf "$PRSTATE" 190 rm -rf "$PRSTATE"
184 ok "Ctrl-\\ : adds a host by spelling: its sessions become tiles, recorded, refusals narrated, Esc eats the line" 191 ok "the picker's a adds a host by spelling: its sessions become tiles, recorded, refusals narrated, Esc eats the line"
185 192
186 193
187 # --------------------------------------------------------------------------- 194 # ---------------------------------------------------------------------------