e3f3c7bf
fix: the empty wall the keyboard sees is the one the screen paints
a73x 2026-08-28 19:53
Commit message
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" | ||