a73x

e3f3c7bf

fix: the empty wall the keyboard sees is the one the screen paints

a73x   2026-08-28 19:53

Commit message
fix: the empty wall the keyboard sees is the one the screen paints

Review round 1 on the stripe removal.

`live` is the slot high-water: `birthTile` increments it and `vanishTile`
clears `present` without touching it, so a wall that opened with tiles and
then lost them all has `live > 0` and no tile. `relayout` counts the
`present` roll and paints "the wall is empty"; the keyboard guard counted
`live` and did not fire, so `Ctrl-\ d` took the ordinary road: 400 ms
waiting for a `detach_ack` from a pump that has returned, and then
`detached (session still running)` printed on a wall with no session. The
guard now counts what the screen counted.

`awaitDetach` gets back the early return the stripe check used to give it,
spelled for what actually matters — the pump is the only thread that sets
`detach_ack`, so one that has ended can only run the bound out.

`Ctrl-\ c` and the split chords on an empty wall say why nothing happened
rather than ringing a dead pump. The sentence goes through `setNotice` like
every other refusal, but an empty wall has no pump to paint a banner, so
`emptyWallHint` takes it and the empty line says it — once, because every
relayout repaints that line.

Also: the caller that decides whether to auto-start a listed local daemon
described its failure as an `[unreachable]` stripe. The rationale is
stronger now, not weaker — the alternative is a wall with nothing on it at
all — but the mechanism it named is gone.

The e2e leg is the guard's first coverage of any kind: a hosts file naming
a socket nothing has bound opens a wall with no tile, and `Ctrl-\ d` has to
leave it within a second saying `mux: left the wall`. The suite pin goes
82 -> 83.

src/cli/mux_main.zig
Old New
@@ -514,11 +514,12 @@ fn wallOfHosts(alloc: std.mem.Allocator) !u8 {
514 } 514 }
515 515
516 // A LISTED local daemon is auto-started too. It dies on every reboot 516 // A LISTED local daemon is auto-started too. It dies on every reboot
517 // while its line lives on, and a wall that paints the user's own 517 // while its line lives on, and the wall shows live sessions only — so
518 // machine `[unreachable]` until they find some other shell to start a 518 // a user whose only host is their own stopped daemon opens on an
519 // daemon in is the empty-file case with one line in front of it. 519 // ENTIRELY empty wall until they find some other shell to start one
520 // Failure is not a refusal: the wall still opens, that host is one 520 // in. That is the empty-file case with one line in front of it.
521 // stripe, and the stripe keeps redialling. 521 // Failure is not a refusal: the wall still opens and that host's
522 // poller keeps redialling, so a daemon started elsewhere shows up.
522 if (sockpath.defaultSockPath(arena) catch null) |sock| { 523 if (sockpath.defaultSockPath(arena) catch null) |sock| {
523 if (localNeedsStart(&h, sock)) _ = try ensureLocalDaemon(alloc, sock); 524 if (localNeedsStart(&h, sock)) _ = try ensureLocalDaemon(alloc, sock);
524 } 525 }
src/wallview.zig
Old New
@@ -1825,6 +1825,15 @@ fn focusAnswer(
1825 if (shared.fullscreen) relayout(alloc, tiles, present, shared, shared.sel); 1825 if (shared.fullscreen) relayout(alloc, tiles, present, shared, shared.sel);
1826 } 1826 }
1827 1827
1828 /// TAKEN, not read: an empty wall has no pump to paint a notice as a
1829 /// banner, and every relayout would repaint a sentence left on this line.
1830 fn emptyWallHint(shared: *Shared) []const u8 {
1831 if (shared.notice_len == 0) return "Ctrl-\\ s to pick a host - Ctrl-\\ d to leave";
1832 const said = shared.notice[0..shared.notice_len];
1833 shared.notice_len = 0;
1834 return said;
1835 }
1836
1828 /// The whole screen an empty wall gets: one line, at the top, saying the 1837 /// The whole screen an empty wall gets: one line, at the top, saying the
1829 /// wall is empty and the way out. A blank terminal with no cursor reads as 1838 /// wall is empty and the way out. A blank terminal with no cursor reads as
1830 /// hung, and the last `x` is precisely when the user needs to be told that 1839 /// hung, and the last `x` is precisely when the user needs to be told that
@@ -1837,7 +1846,7 @@ fn paintEmptyWallLocked(shared: *Shared) void {
1837 shared.size.cols, 1846 shared.size.cols,
1838 "", 1847 "",
1839 "the wall is empty", 1848 "the wall is empty",
1840 "Ctrl-\\ s to pick a host - Ctrl-\\ d to leave", 1849 emptyWallHint(shared),
1841 ); 1850 );
1842 var out: [512]u8 = undefined; 1851 var out: [512]u8 = undefined;
1843 var fbs = std.io.fixedBufferStream(&out); 1852 var fbs = std.io.fixedBufferStream(&out);
@@ -1944,8 +1953,9 @@ fn relayout(
1944 1953
1945 /// A tile leaves the wall: off the `present` roll, off the tree, and its 1954 /// A tile leaves the wall: off the `present` roll, off the tree, and its
1946 /// pump told to return. The ONE owner — the chord and the poll's diff both 1955 /// pump told to return. The ONE owner — the chord and the poll's diff both
1947 /// come here, which is why the focus hand-off can only be written once. `to` overrides where a focused tile's focus goes; null 1956 /// come here, which is why the focus hand-off can only be written once.
1948 /// takes the next present tile. The caller re-cuts. 1957 /// `to` overrides where a focused tile's focus goes; null takes the next
1958 /// present tile. The caller re-cuts.
1949 fn vanishTile(tiles: []Tile, present: []bool, shared: *Shared, i: usize, to: ?usize) void { 1959 fn vanishTile(tiles: []Tile, present: []bool, shared: *Shared, i: usize, to: ?usize) void {
1950 present[i] = false; 1960 present[i] = false;
1951 tiles[i].gone.store(true, .release); 1961 tiles[i].gone.store(true, .release);
@@ -2602,6 +2612,10 @@ fn awaitingSession(t: *Tile) ?State {
2602 /// corpse for the session. Bounded so a wedged pump cannot hold the terminal 2612 /// corpse for the session. Bounded so a wedged pump cannot hold the terminal
2603 /// hostage; the process exit closes the socket either way. 2613 /// hostage; the process exit closes the socket either way.
2604 fn awaitDetach(t: *Tile, shared: *Shared) void { 2614 fn awaitDetach(t: *Tile, shared: *Shared) void {
2615 // The pump is the only thread that sets `detach_ack`, so one that has
2616 // already returned can only run the wait out: 400ms of a wall that has
2617 // said goodbye.
2618 if (!t.alive.load(.acquire)) return;
2605 const deadline = std.time.milliTimestamp() + 400; 2619 const deadline = std.time.milliTimestamp() + 400;
2606 while (!t.detach_ack.load(.acquire)) { 2620 while (!t.detach_ack.load(.acquire)) {
2607 const left = deadline - std.time.milliTimestamp(); 2621 const left = deadline - std.time.milliTimestamp();
@@ -3503,15 +3517,29 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3503 3517
3504 const z = shared.sel; 3518 const z = shared.sel;
3505 const cmd = input.prefix.feed(b[0..n]); 3519 const cmd = input.prefix.feed(b[0..n]);
3506 // A wall with no tile at all — every host with nothing live on it, 3520 // An EMPTY wall — no tile present, whether none was ever born or
3507 // or the first poll still out — has no `tiles[z]` to command: that 3521 // the last one has left — has no `tiles[z]` to command, and every
3508 // slot has never been written, and every branch below reads one. 3522 // branch below reads one. `live` cannot answer this: it is the slot
3509 // Leaving is all there is to do, and the empty screen says so. 3523 // high-water and never falls, so it still counts a tile that
3510 if (live == 0) { 3524 // vanished. The `present` roll is what `relayout` counted when it
3511 if (cmd.action == .detach) { 3525 // decided to paint the empty line, and the screen and the keyboard
3512 exit_code = 0; 3526 // have to agree on which wall this is.
3513 exit_msg = "mux: left the wall"; 3527 //
3514 break :keys; 3528 // No sidecar on the way out: an empty wall has no tree worth
3529 // remembering, and writing one would erase the layout the user
3530 // last left on a wall that had tiles.
3531 if (presentCount(present[0..live]) == 0) {
3532 switch (cmd.action) {
3533 .detach => {
3534 exit_code = 0;
3535 exit_msg = "mux: left the wall";
3536 break :keys;
3537 },
3538 .new_session, .split_right, .split_below => {
3539 setNotice(&shared, "[no session to birth beside - Ctrl-\\ s picks a host]");
3540 relayout(alloc, tiles[0..live], present[0..live], &shared, shared.sel);
3541 },
3542 else => {},
3515 } 3543 }
3516 continue; 3544 continue;
3517 } 3545 }
@@ -4043,6 +4071,46 @@ test "applyReadyLists: a host added after the wall opened gets its sessions, and
4043 try std.testing.expectEqualStrings("late", tiles[0].r.session); 4071 try std.testing.expectEqualStrings("late", tiles[0].r.session);
4044 } 4072 }
4045 4073
4074 test "emptyWallHint: a chord an empty wall refuses is said there, and said once" {
4075 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
4076 const way_out = "Ctrl-\\ s to pick a host - Ctrl-\\ d to leave";
4077 try std.testing.expectEqualStrings(way_out, emptyWallHint(&shared));
4078
4079 setNotice(&shared, "[no session to birth beside - Ctrl-\\ s picks a host]");
4080 shared.paint_mu.lock();
4081 defer shared.paint_mu.unlock();
4082 // An empty wall has no pump, and a notice is painted by the pump that
4083 // claims the terminal — so this line is the only place it can be said.
4084 try std.testing.expectEqualStrings(
4085 "[no session to birth beside - Ctrl-\\ s picks a host]",
4086 emptyWallHint(&shared),
4087 );
4088 // Once: every relayout repaints the empty line, and a sentence that
4089 // outlived the keystroke that earned it would never leave the screen.
4090 try std.testing.expectEqualStrings(way_out, emptyWallHint(&shared));
4091 }
4092
4093 test "awaitDetach: a pump that has already returned is not waited on" {
4094 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
4095 defer arena.deinit();
4096 var shared: Shared = undefined;
4097 stoppedWall(arena.allocator(), &shared);
4098 var t: Tile = .{
4099 .r = .{ .target = .{ .sock = "/tmp/a" }, .label = "--sock /tmp/a", .session = "0" },
4100 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
4101 .shared = &shared,
4102 .idx = 0,
4103 .wake_r = -1,
4104 .wake_w = -1,
4105 .alive = std.atomic.Value(bool).init(false),
4106 };
4107 // The pump is the only thread that sets `detach_ack`, and this one has
4108 // returned: the wait can only ever run out its 400ms bound.
4109 const began = std.time.milliTimestamp();
4110 awaitDetach(&t, &shared);
4111 try std.testing.expect(std.time.milliTimestamp() - began < 100);
4112 }
4113
4046 test "endAsk: x asks only a tile with a live pump — a dead one gets a sentence instead" { 4114 test "endAsk: x asks only a tile with a live pump — a dead one gets a sentence instead" {
4047 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; 4115 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4048 defer shared.tree.deinit(); 4116 defer shared.tree.deinit();
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" = "82" ] || { 180 [ "$OK_COUNT" = "83" ] || {
181 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 82 —" 181 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 83 —"
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
@@ -587,3 +587,58 @@ DH1PID=""
587 assert_stopped "$SOCKH2" "$DH2PID" "hosts rm" "$OUT.h2stop2" 587 assert_stopped "$SOCKH2" "$DH2PID" "hosts rm" "$OUT.h2stop2"
588 DH2PID="" 588 DH2PID=""
589 ok "hosts rm takes the daemon off the wall and ends nothing" 589 ok "hosts rm takes the daemon off the wall and ends nothing"
590
591 # ---- an empty wall is a place you can stand in, and leave ---------------
592 #
593 # The wall shows live sessions and nothing else, so a wall whose only host
594 # has nothing live on it has no TILE — not a bar, not a placeholder. That
595 # is a screen the keyboard has to survive: `tiles[shared.sel]` is a slot
596 # nothing has ever written here, so the run refuses every chord rather
597 # than reading one off it, and `Ctrl-\ d` is the one it honours.
598 #
599 # Two claims, and the second is the one that catches a fall-through:
600 #
601 # * the client leaves within a SECOND of the key. The ordinary detach
602 # waits up to 400ms for a pump's ack, and on this wall there is no
603 # pump to ack — a `d` that took the ordinary road would stall for the
604 # whole bound before saying anything.
605 # * it says `mux: left the wall`. `detached (session still running)` is
606 # what the ordinary road prints, and on a wall with no session that
607 # sentence is a lie about the user's shells.
608 #
609 # 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
611 # this wall the very tile it is asserting the absence of.
612 hostroom empty
613 EMPTYSOCK="${TMPDIR:-/tmp}/muxd-e2e-neverbound-$$.sock"
614 # Registered although nothing here binds it — this leg ASSERTS nothing
615 # does. The regression it guards against is a wall auto-starting a daemon
616 # on a listed local path, and that failure leaves a live daemon on exactly
617 # this socket: unregistered, the trap's residue guard unlinks the path from
618 # under it instead of asking `muxd stop` first.
619 defer_sock "$EMPTYSOCK"
620 mkdir -p "$HOSTROOM/mux"
621 printf -- '--sock %s\n' "$EMPTYSOCK" > "$HOSTROOM/mux/hosts"
622 [ ! -e "$EMPTYSOCK" ] || {
623 echo "e2e FAIL: empty wall: $EMPTYSOCK exists, so the wall may get a tile"; exit 1; }
624 set +e
625 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'
627 expect the wall is empty 20000
628 send \x1cd
629 waitexit 1000
630 EOF
631 RC=$?
632 set -e
633 [ "$RC" -eq 0 ] || {
634 echo "e2e FAIL: empty wall: Ctrl-\\ d did not leave within a second (exit $RC):"
635 cat "$OUT.empc"; echo "--- stderr ---"; cat "$OUT.emcap.err"; exit 1; }
636 # mux's own last word, off its stderr — ptyclient gives the client a real
637 # stderr file, so this is the process's sentence and not something the
638 # screen happened to be holding.
639 EMLAST=$(grep -v '^[[:space:]]*$' "$OUT.emcap.err" | tail -1)
640 [ "$EMLAST" = "mux: left the wall" ] || {
641 echo "e2e FAIL: empty wall: leaving an empty wall said"
642 echo " '$EMLAST', want 'mux: left the wall'"
643 cat "$OUT.emcap.err"; exit 1; }
644 ok "an empty wall says so, and Ctrl-\\ d leaves it at once rather than detaching nothing"