a73x

45612e90

fix: a piped mux stays a wall of one; poll births keep list order

a73x   2026-08-28 19:53

Commit message
fix: a piped mux stays a wall of one; poll births keep list order

Two rulings, one file.

RULING 14. `headless` is the one question — can this process cut stripes
at all — asked of the same fd `run` measures. A wall with no terminal now
skips `otherHosts` as well as the pollers, so the specs stop at the entry
host and nothing is left to dial. `673d6d4` gated only the pollers, which
left the file being read and resolved for nothing; this completes it and
points `runAttach`'s "wall of one" comment at where the promise is kept.

The pin is the suite's own: without the gate `make e2e` dies at scenario
5, `via transport: client render diverges from daemon grid`, with the
client's capture carrying a label bar the daemon's grid cannot have.
With it, that scenario and the two after it pass.

RULING 15. `layout.insert` puts a new leaf immediately after its anchor,
so anchoring every birth of one poll at the FOCUS laid a daemon's list of
{b, c} out as c, b — a wall reading back-to-front against the order its
daemon reported, and against the digits `Ctrl-\ 1-9` use. Each birth now
anchors on the previous one; the first still sits beside the focus.

Pinned by `applyHostList: one list's tiles are laid out in the order the
daemon reported them`, which reads the FLATTEN and not the tile array —
the array was always in list order, and the screen was not. Three names,
not two: the first birth roots the tree and the second lands after it
whatever it anchored on, so only the third has a choice. Graded against
the mutation (the anchor never moves): the test fails by name on
`top_b < top_c`.

wallview.zig's docscheck figure is unchanged at 1596 — the new
declaration's rationale is a `//` comment inside the body, where prose
that explains a decision belongs.

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%%;*}