45612e90
fix: a piped mux stays a wall of one; poll births keep list order
a73x 2026-08-28 19:53
Commit message
src/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -99,6 +99,16 @@ pub fn resolveHost( | |||
| 99 | return .{ .spelling = spelling, .target = target }; | 99 | return .{ .spelling = spelling, .target = target }; |
| 100 | } | 100 | } |
| 101 | 101 | ||
| 102 | /// Whether this process has a screen to cut stripes on. | ||
| 103 | fn headless(out_fd: std.posix.fd_t) bool { | ||
| 104 | // Everything a wall does beyond its entry tile — other hosts, their | ||
| 105 | // pollers, a saved layout — is stripes, so a `mux` with no measurable | ||
| 106 | // terminal is a wall of ONE and writes what the plain client wrote. | ||
| 107 | // `run` asks this of the same fd as `measured == null`; `runAttach` | ||
| 108 | // asks it before there is a `run` to ask. | ||
| 109 | return interact.ttySize(out_fd) == null; | ||
| 110 | } | ||
| 111 | |||
| 102 | /// The daemon's row floor plus the label-bar arithmetic: a one-tile wall | 112 | /// The daemon's row floor plus the label-bar arithmetic: a one-tile wall |
| 103 | /// draws no bar so its floor is `min_session_rows`; two or more tiles each | 113 | /// draws no bar so its floor is `min_session_rows`; two or more tiles each |
| 104 | /// lose a row to a bar, so each stripe must hold that floor PLUS the bar | 114 | /// lose a row to a bar, so each stripe must hold that floor PLUS the bar |
| @@ -2946,6 +2956,12 @@ fn applyHostList( | |||
| 2946 | changed = true; | 2956 | changed = true; |
| 2947 | } | 2957 | } |
| 2948 | var placed: usize = 0; | 2958 | var placed: usize = 0; |
| 2959 | // The tile the NEXT birth sits beside: the focus for the first, then | ||
| 2960 | // the one just born. `insert` puts a new leaf immediately after its | ||
| 2961 | // anchor, so anchoring every birth at the focus would lay a list of | ||
| 2962 | // {b, c} out as c, b — a wall reading back-to-front against the | ||
| 2963 | // order its daemon reported, and against the digits the chords use. | ||
| 2964 | var anchor = anchorTile(present[0..live.*], shared.sel); | ||
| 2949 | // Stops at the FIRST refusal rather than retrying each name: the | 2965 | // Stops at the FIRST refusal rather than retrying each name: the |
| 2950 | // wall refuses for a reason that holds for the whole list (no slot, | 2966 | // wall refuses for a reason that holds for the whole list (no slot, |
| 2951 | // no room to cut), and this list comes back every second — a | 2967 | // no room to cut), and this list comes back every second — a |
| @@ -2959,13 +2975,14 @@ fn applyHostList( | |||
| 2959 | // The name is this poll's reply buffer until the wall takes | 2975 | // The name is this poll's reply buffer until the wall takes |
| 2960 | // the tile — see `Birth.borrowed`. | 2976 | // the tile — see `Birth.borrowed`. |
| 2961 | .r = .{ .target = h.spec.target, .label = "", .session = births.get(placed) }, | 2977 | .r = .{ .target = h.spec.target, .label = "", .session = births.get(placed) }, |
| 2962 | .from = anchorTile(present[0..live.*], shared.sel), | 2978 | .from = anchor, |
| 2963 | .place = .beside_focus, | 2979 | .place = .beside_focus, |
| 2964 | .creates = false, | 2980 | .creates = false, |
| 2965 | .born_from = null, | 2981 | .born_from = null, |
| 2966 | .host = hi, | 2982 | .host = hi, |
| 2967 | .borrowed = true, | 2983 | .borrowed = true, |
| 2968 | }) orelse break; | 2984 | }) orelse break; |
| 2985 | anchor = at; | ||
| 2969 | spawnPump(&tiles[at]); | 2986 | spawnPump(&tiles[at]); |
| 2970 | changed = true; | 2987 | changed = true; |
| 2971 | } | 2988 | } |
| @@ -3071,6 +3088,12 @@ pub fn runAttach( | |||
| 3071 | // dialled first, so it is tile 0 and the sidecar's anchor, and every | 3088 | // dialled first, so it is tile 0 and the sidecar's anchor, and every |
| 3072 | // other listed host after it. `mux HOST` is the wall zoomed on HOST, | 3089 | // other listed host after it. `mux HOST` is the wall zoomed on HOST, |
| 3073 | // not a wall of one. | 3090 | // not a wall of one. |
| 3091 | // | ||
| 3092 | // ...unless there is no terminal, which is what makes the promise | ||
| 3093 | // above true: `headless` is the same question `run` asks, asked here | ||
| 3094 | // because `otherHosts` runs before `run` exists. On a pipe the specs | ||
| 3095 | // stop at the entry host and `run` starts no poller for it, so this | ||
| 3096 | // process writes exactly what the plain client wrote. | ||
| 3074 | var arena_state = std.heap.ArenaAllocator.init(alloc); | 3097 | var arena_state = std.heap.ArenaAllocator.init(alloc); |
| 3075 | defer arena_state.deinit(); | 3098 | defer arena_state.deinit(); |
| 3076 | const arena = arena_state.allocator(); | 3099 | const arena = arena_state.allocator(); |
| @@ -3081,7 +3104,8 @@ pub fn runAttach( | |||
| 3081 | // stderr, not a notice: this runs before `run` takes the screen. | 3104 | // stderr, not a notice: this runs before `run` takes the screen. |
| 3082 | if (recordHost(arena, target, spelling, path)) |err| | 3105 | if (recordHost(arena, target, spelling, path)) |err| |
| 3083 | std.debug.print("mux: hosts file not updated ({s}): {s}\n", .{ path, hosts.reason(err) }); | 3106 | std.debug.print("mux: hosts file not updated ({s}): {s}\n", .{ path, hosts.reason(err) }); |
| 3084 | otherHosts(arena, &specs, spelling, path, key, idle_ms); | 3107 | if (!headless(std.posix.STDOUT_FILENO)) |
| 3108 | otherHosts(arena, &specs, spelling, path, key, idle_ms); | ||
| 3085 | } | 3109 | } |
| 3086 | return run(alloc, specs.items, .{ | 3110 | return run(alloc, specs.items, .{ |
| 3087 | .focus0 = true, | 3111 | .focus0 = true, |
| @@ -3305,13 +3329,12 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 3305 | .idx = i, | 3329 | .idx = i, |
| 3306 | .self_name = selfSession(spec.target, env_sock, env_session), | 3330 | .self_name = selfSession(spec.target, env_sock, env_session), |
| 3307 | }; | 3331 | }; |
| 3308 | // Not without a terminal. A wall on a pipe is the entry tile and | 3332 | // Not without a terminal (`headless`). A poller here would turn |
| 3309 | // nothing else — one tile whose rect is the whole screen, writing | 3333 | // every scripted `mux --sock S` into a wall of whatever that daemon |
| 3310 | // exactly what the plain client wrote — because there is nothing to | 3334 | // happens to be running: measured on a piped attach to a daemon |
| 3311 | // cut stripes from and no keyboard to steer them with. A poller | 3335 | // holding four sessions, four attaches and a capture that opens |
| 3312 | // here would turn every scripted `mux --sock S` into a wall of | 3336 | // with label bars. |
| 3313 | // whatever that daemon happens to be running. | 3337 | if (measured == null) continue; |
| 3314 | if (!is_tty) continue; | ||
| 3315 | const th = std.Thread.spawn(.{}, pollHost, .{h}) catch continue; | 3338 | const th = std.Thread.spawn(.{}, pollHost, .{h}) catch continue; |
| 3316 | th.detach(); | 3339 | th.detach(); |
| 3317 | } | 3340 | } |
| @@ -4158,6 +4181,41 @@ test "applyHostList: two sessions on an empty wall are two tiles, the first focu | |||
| 4158 | try std.testing.expect(!tiles[1].stripe); | 4181 | try std.testing.expect(!tiles[1].stripe); |
| 4159 | } | 4182 | } |
| 4160 | 4183 | ||
| 4184 | test "applyHostList: one list's tiles are laid out in the order the daemon reported them" { | ||
| 4185 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); | ||
| 4186 | defer arena.deinit(); | ||
| 4187 | const alloc = arena.allocator(); | ||
| 4188 | var shared: Shared = undefined; | ||
| 4189 | stoppedWall(alloc, &shared); | ||
| 4190 | defer if (shared.last_flat) |*f| f.deinit(alloc); | ||
| 4191 | var tiles: [4]Tile = undefined; | ||
| 4192 | var present = [_]bool{false} ** 4; | ||
| 4193 | var live: usize = 0; | ||
| 4194 | defer endPumps(tiles[0..live]); | ||
| 4195 | var table = [_]Host{testHost(&shared, "box", "/tmp/box.sock")}; | ||
| 4196 | // THREE, because two cannot fail: the first birth roots the tree and the | ||
| 4197 | // second lands after it whatever it anchored on. The third is the one | ||
| 4198 | // that has a choice, and anchoring it back at the focus puts it BETWEEN | ||
| 4199 | // its two siblings. | ||
| 4200 | setList(&table[0], "a\nb\nc\n"); | ||
| 4201 | |||
| 4202 | applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0); | ||
| 4203 | |||
| 4204 | try std.testing.expectEqual(@as(usize, 3), live); | ||
| 4205 | // Down the screen in the daemon's own order. The tile ARRAY is in that | ||
| 4206 | // order by construction; what this reads is the flatten, which is where | ||
| 4207 | // an anchor that did not move shows up. | ||
| 4208 | const flat = shared.last_flat orelse return error.NoFlat; | ||
| 4209 | const top_a = (flat.rectOf(0) orelse return error.NoRect).top; | ||
| 4210 | const top_b = (flat.rectOf(1) orelse return error.NoRect).top; | ||
| 4211 | const top_c = (flat.rectOf(2) orelse return error.NoRect).top; | ||
| 4212 | try std.testing.expectEqualStrings("a", tiles[0].r.session); | ||
| 4213 | try std.testing.expectEqualStrings("b", tiles[1].r.session); | ||
| 4214 | try std.testing.expectEqualStrings("c", tiles[2].r.session); | ||
| 4215 | try std.testing.expect(top_a < top_b); | ||
| 4216 | try std.testing.expect(top_b < top_c); | ||
| 4217 | } | ||
| 4218 | |||
| 4161 | test "applyHostList: sessions past the wall's capacity are counted in the notice, not dropped in silence" { | 4219 | test "applyHostList: sessions past the wall's capacity are counted in the notice, not dropped in silence" { |
| 4162 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); | 4220 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); |
| 4163 | defer arena.deinit(); | 4221 | defer arena.deinit(); |
test/e2e_12_panes.sh
| Old | New | ||
|---|---|---|---|
| @@ -100,11 +100,13 @@ set -e | |||
| 100 | echo "e2e FAIL: cursor-ownership: ptyclient leg exited $RC:" | 100 | echo "e2e FAIL: cursor-ownership: ptyclient leg exited $RC:" |
| 101 | cat "$OUT.cupc" "$OUT.cucap.err"; exit 1; } | 101 | cat "$OUT.cupc" "$OUT.cucap.err"; exit 1; } |
| 102 | # WHICH rows tile 3's stripe owns is read off the capture rather than | 102 | # WHICH rows tile 3's stripe owns is read off the capture rather than |
| 103 | # counted: a tile's digit is fixed for its life, but tiles arrive from a | 103 | # counted. A host's list arrives in the daemon's slot order and is laid out |
| 104 | # host's list and are inserted beside the focus, so the third DIGIT is not | 104 | # in that order, so the third digit IS the third stripe down — but that is |
| 105 | # necessarily the third stripe DOWN. Its own label bar's CUP row is the top | 105 | # the birth-order leg's claim (`applyHostList: one list's tiles are laid out |
| 106 | # of its stripe, and the nine rows under that are its content (30 rows over | 106 | # in the order the daemon reported them`), not this one's, and a leg that |
| 107 | # three tiles, one of each ten spent on the bar). | 107 | # hard-codes rows 21-30 fails for whichever reason comes first. Tile 3's own |
| 108 | # label bar's CUP row is the top of its stripe, and the nine rows under that | ||
| 109 | # are its content (30 rows over three tiles, one of each ten on the bar). | ||
| 108 | _bar3=$(grep -ao $'\x1b\\[[0-9][0-9]*;1H\x1b\\[7m 3> ' "$OUT.cucap" | tail -1) | 110 | _bar3=$(grep -ao $'\x1b\\[[0-9][0-9]*;1H\x1b\\[7m 3> ' "$OUT.cucap" | tail -1) |
| 109 | _bar_row=${_bar3#??} | 111 | _bar_row=${_bar3#??} |
| 110 | _bar_row=${_bar_row%%;*} | 112 | _bar_row=${_bar_row%%;*} |