a73x

ce611141

feat: a new tile takes the lowest free digit

a73x   2026-08-28 19:53

Commit message
feat: a new tile takes the lowest free digit

Create 1 2 3, end 2, create again: the wall says 2, not 4. `Ctrl-\ 1-9`
addresses digits, so a digit an ended session kept forever pushed later
births out of the keyboard's reach — and the daemon had already taken the
NAME back, so the wall showed tile 4 holding session 1.

Reuse, not renumbering: `birthTile` takes the lowest slot whose tile has
left AND whose pump has returned (`Tile.pump_done`, the pump's last act).
The slot keeps its doorbell pipe, which is never closed, and gives up the
label and session copies the departed tile owned — `run` now dupes the
entry tile's session so every tile owns both.

`live` stops meaning "how many tiles" and becomes the high-water mark of
slots ever used, so the chord's re-cut test is counted in `present`:
without that the reborn tile sat on a wall nothing re-flattened.

README.md
Old New
@@ -88,7 +88,7 @@ The focused tile has the terminal, and only the prefix is held back:
88 | `Ctrl-\` `-` | split below: new session in a pane below | 88 | `Ctrl-\` `-` | split below: new session in a pane below |
89 | `Ctrl-\` `f` | fullscreen the focused pane (toggle) | 89 | `Ctrl-\` `f` | fullscreen the focused pane (toggle) |
90 | `Ctrl-\` `r` | resize mode: `h`/`l` shrink/grow the focus's width, `k`/`j` its height, one cell per press; Esc leaves silently, any other key leaves and types | 90 | `Ctrl-\` `r` | resize mode: `h`/`l` shrink/grow the focus's width, `k`/`j` its height, one cell per press; Esc leaves silently, any other key leaves and types |
91 | `Ctrl-\` `1`-`9` | focus tile N — the digit on that tile's label bar | 91 | `Ctrl-\` `1`-`9` | focus tile N — the digit on that tile's label bar. A new tile takes the lowest free digit, so end 2 of 1 2 3 and the next session is 2 again |
92 | `Ctrl-\` `x` | end the focused session — the daemon hangs up its shell and every attached client sees the exit. With other clients on it the daemon refuses the first press and says how many; a second `x` within 3 seconds forces it | 92 | `Ctrl-\` `x` | end the focused session — the daemon hangs up its shell and every attached client sees the exit. With other clients on it the daemon refuses the first press and says how many; a second `x` within 3 seconds forces it |
93 | `Ctrl-\` `w` | zoom out to the wall | 93 | `Ctrl-\` `w` | zoom out to the wall |
94 | `Ctrl-\` `s` | the host picker: a popup listing every daemon on the wall with what its last poll said — `N sessions`, `no sessions`, `unreachable`, `connecting`. `j`/`k` or the arrows move, `1`-`9` pick a row, Enter or `c` starts a session on the selected machine and closes, `x` forgets it (the file line goes, the sessions keep running), `a` adds one by spelling — `HOST`, `quic://HOST[:PORT]` or `--sock PATH`, Enter adds and Esc returns to the rows. Esc or `s` closes. The spelling editor takes printable ASCII only, so a path or hostname with any other byte in it goes in the hosts file or on argv instead. While the picker is open every key is its own: nothing reaches a session | 94 | `Ctrl-\` `s` | the host picker: a popup listing every daemon on the wall with what its last poll said — `N sessions`, `no sessions`, `unreachable`, `connecting`. `j`/`k` or the arrows move, `1`-`9` pick a row, Enter or `c` starts a session on the selected machine and closes, `x` forgets it (the file line goes, the sessions keep running), `a` adds one by spelling — `HOST`, `quic://HOST[:PORT]` or `--sock PATH`, Enter adds and Esc returns to the rows. Esc or `s` closes. The spelling editor takes printable ASCII only, so a path or hostname with any other byte in it goes in the hosts file or on argv instead. While the picker is open every key is its own: nothing reaches a session |
@@ -460,10 +460,11 @@ carries a `>` beside it — `1> ` has the focus, `2 ` does not. The digit is
460 the order the tiles joined the wall — which is what `Ctrl-\` `1`-`9` indexes 460 the order the tiles joined the wall — which is what `Ctrl-\` `1`-`9` indexes
461 — not their left-to-right position and not the session name. A pane splits 461 — not their left-to-right position and not the session name. A pane splits
462 beside the focus while its digit is appended, so on a wall you have split or 462 beside the focus while its digit is appended, so on a wall you have split or
463 restored the digits need not run in screen order. Each stays with its tile 463 restored the digits need not run in screen order. A digit stays with its
464 for the whole run: a session that ends leaves a hole in the numbering rather 464 tile for as long as the tile is there: a session that ends leaves a hole
465 than renumbering its neighbours. Past 9 the bar still prints the number, 465 rather than renumbering its neighbours, and the next tile born fills the
466 though no chord reaches it. 466 lowest hole — end 2 of 1 2 3 and the session after it is 2, not 4. Past 9
467 the bar still prints the number, though no chord reaches it.
467 468
468 Focus is client-local: moving it sends nothing on the wire, so `Ctrl-\` `n` 469 Focus is client-local: moving it sends nothing on the wire, so `Ctrl-\` `n`
469 and `Ctrl-\` `h` move between sessions instantly — no reconnect, no 470 and `Ctrl-\` `h` move between sessions instantly — no reconnect, no
docs/decisions.md
Old New
@@ -7338,3 +7338,17 @@ wall, and the user is owed the wall that made.
7338 Tiles pause under it (`Shared.picker_open`, read under `paint_mu`); replicas 7338 Tiles pause under it (`Shared.picker_open`, read under `paint_mu`); replicas
7339 stay hot, the close bumps `repaint_gen`, and the frame is hashed so the 7339 stay hot, the close bumps `repaint_gen`, and the frame is hashed so the
7340 per-host-per-second repaint cannot leave a terminal that never goes quiet. 7340 per-host-per-second repaint cannot leave a terminal that never goes quiet.
7341
7342 ## 2026-08-28 — a new tile takes the lowest free digit
7343
7344 Reuse, not renumbering. `Ctrl-\ 1-9` addresses digits, so a digit an ended
7345 session kept forever pushes later births out of the keyboard's reach, and
7346 renumbering the survivors moves a digit the user had learned. `birthTile`
7347 takes the lowest free slot, the daemon already does the same with names
7348 (`client.nextFreeName`), and digit and name come back together.
7349
7350 A slot is free only once its pump has RETURNED (`Tile.pump_done`, stored
7351 after the bell that reads `t.shared`): `present` alone hands a `*Tile` a
7352 returning thread is still reading to a new tile. Its doorbell pipe stays with
7353 it — never closed, so a fresh one per reuse leaks an fd pair per birth — and
7354 `live` becomes the high-water mark, so growth is counted in `present`.
src/wallview.zig
Old New
@@ -417,6 +417,12 @@ const Tile = struct {
417 /// replica within a poll timeout, and letting the keyboard draw over 417 /// replica within a poll timeout, and letting the keyboard draw over
418 /// that would replace something true with something stale. 418 /// that would replace something true with something stale.
419 alive: std.atomic.Value(bool) = std.atomic.Value(bool).init(true), 419 alive: std.atomic.Value(bool) = std.atomic.Value(bool).init(true),
420 /// Whether this slot's pump thread has RETURNED. Stored as the pump's
421 /// last act — after `alive`, after the bell that reads `t.shared` — and
422 /// it is the half of "free" that `present` cannot say: a birth that
423 /// reused a vanished slot on `!present` alone would overwrite a `*Tile`
424 /// the returning thread is still reading.
425 pump_done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
420 /// Forgotten by `Ctrl-\ x`: off the wall, and off the wire as soon as 426 /// Forgotten by `Ctrl-\ x`: off the wall, and off the wire as soon as
421 /// the pump notices. The pump's answer is to RETURN — which closes its 427 /// the pump notices. The pump's answer is to RETURN — which closes its
422 /// transport and frees the daemon slot — and nothing more: "remove is 428 /// transport and frees the daemon slot — and nothing more: "remove is
@@ -679,8 +685,9 @@ fn paintLabelLocked(t: *Tile) void {
679 // the same width, so labels do not shift as the focus moves. 685 // the same width, so labels do not shift as the focus moves.
680 const arrow: []const u8 = if (t.shared.sel == t.idx) "> " else " "; 686 const arrow: []const u8 = if (t.shared.sel == t.idx) "> " else " ";
681 // Stable for the tile's whole life because `vanishTile` leaves a hole 687 // Stable for the tile's whole life because `vanishTile` leaves a hole
682 // rather than compacting, so a digit is never reused or shifted onto a 688 // rather than compacting: a digit is never SHIFTED onto a neighbour,
683 // neighbour. Past 9 the number still prints, though no chord reaches it. 689 // though a hole is what the next birth takes back (`freeSlot`). Past 9
690 // the number still prints, though no chord reaches it.
684 var marker_buf: [8]u8 = undefined; 691 var marker_buf: [8]u8 = undefined;
685 const marker: []const u8 = std.fmt.bufPrint(&marker_buf, "{d}{s}", .{ t.idx + 1, arrow }) catch arrow; 692 const marker: []const u8 = std.fmt.bufPrint(&marker_buf, "{d}{s}", .{ t.idx + 1, arrow }) catch arrow;
686 // Keystrokes the mailbox had no room for are said where the eye already 693 // Keystrokes the mailbox had no room for are said where the eye already
@@ -1276,6 +1283,10 @@ fn pumpTile(t: *Tile) void {
1276 defer { 1283 defer {
1277 t.alive.store(false, .release); 1284 t.alive.store(false, .release);
1278 ringKeyboard(t.shared); 1285 ringKeyboard(t.shared);
1286 // LAST, after the bell above has finished reading `t.shared`: this
1287 // is what hands the slot to `birthTile`, and nothing may touch the
1288 // tile after it.
1289 t.pump_done.store(true, .release);
1279 } 1290 }
1280 1291
1281 // Per-thread allocator: nothing allocated here crosses threads except 1292 // Per-thread allocator: nothing allocated here crosses threads except
@@ -2249,14 +2260,36 @@ fn presentCount(present: []const bool) usize {
2249 return n; 2260 return n;
2250 } 2261 }
2251 2262
2263 /// The lowest slot a new tile may take back: off the wall AND its pump
2264 /// returned. `Tile.pump_done` is why both halves are needed.
2265 fn freeSlot(tiles: []const Tile, present: []const bool) ?usize {
2266 for (tiles, present, 0..) |*t, p, i| {
2267 if (!p and t.pump_done.load(.acquire)) return i;
2268 }
2269 return null;
2270 }
2271
2272 /// Whether a slot arrives with a doorbell already in it. `kept` is a slot a
2273 /// vanished tile left behind: the pipe is never closed, so digging a fresh
2274 /// one per reuse would leak an fd pair per birth. Nothing else in a reused
2275 /// slot survives — `initTile` overwrites the whole tile.
2276 const Doorbell = enum { fresh, kept };
2277
2252 /// One tile, with the doorbell its pump polls. Split out of `run` because 2278 /// One tile, with the doorbell its pump polls. Split out of `run` because
2253 /// tiles are born in two places now — at startup, and whenever a focus chord 2279 /// tiles are born in two places now — at startup, and whenever a focus chord
2254 /// or the saved wall adds one. 2280 /// or the saved wall adds one.
2255 fn initTile(t: *Tile, r: Resolved, s: layout.Rect, shared: *Shared, idx: usize) !void { 2281 fn initTile(t: *Tile, r: Resolved, s: layout.Rect, shared: *Shared, idx: usize, bell: Doorbell) !void {
2256 // The doorbell, before the pump that polls it exists. Non-blocking at 2282 // The doorbell, before the pump that polls it exists. Non-blocking at
2257 // both ends: the writer must never wedge the keyboard and the reader 2283 // both ends: the writer must never wedge the keyboard and the reader
2258 // must never wedge the pump. 2284 // must never wedge the pump.
2259 const wake = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); 2285 //
2286 // A kept pipe may still hold the byte `vanishTile` rang the departing
2287 // pump with; the bytes carry nothing, so the new pump's first poll
2288 // wakes once for nobody and reads its empty mailbox.
2289 const wake: [2]std.posix.fd_t = switch (bell) {
2290 .fresh => try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }),
2291 .kept => .{ t.wake_r, t.wake_w },
2292 };
2260 t.* = .{ 2293 t.* = .{
2261 .r = r, 2294 .r = r,
2262 .rect = s, 2295 .rect = s,
@@ -2289,6 +2322,10 @@ fn spawnPump(t: *Tile) void {
2289 t.end.store(@intFromEnum(EndReason.no_thread), .release); 2322 t.end.store(@intFromEnum(EndReason.no_thread), .release);
2290 t.alive.store(false, .release); 2323 t.alive.store(false, .release);
2291 ringKeyboard(t.shared); 2324 ringKeyboard(t.shared);
2325 // No thread was ever going to read this slot, so its digit is free
2326 // the moment the tile leaves the wall — without this a spawn that
2327 // failed would hold a slot nothing can ever reuse.
2328 t.pump_done.store(true, .release);
2292 return; 2329 return;
2293 }; 2330 };
2294 th.detach(); 2331 th.detach();
@@ -2343,13 +2380,17 @@ fn birthTile(
2343 shared: *Shared, 2380 shared: *Shared,
2344 b: Birth, 2381 b: Birth,
2345 ) ?usize { 2382 ) ?usize {
2346 if (live.* >= max_tiles) return null; 2383 // The lowest digit a departed tile left behind, before a new one:
2384 // create 1 2 3, end 2, create — and the wall says 2, not 4. Reuse, not
2385 // renumbering: the tiles that stayed keep the digit their user learned.
2386 const reuse = freeSlot(tiles[0..live.*], present[0..live.*]);
2387 if (reuse == null and live.* >= max_tiles) return null;
2347 const new_live = presentCount(present[0..live.*]) + 1; 2388 const new_live = presentCount(present[0..live.*]) + 1;
2348 // "Does it fit" has ONE owner, and it is the tree: insert, flatten, 2389 // "Does it fit" has ONE owner, and it is the tree: insert, flatten,
2349 // and undo the insert when flatten refuses. Row arithmetic here 2390 // and undo the insert when flatten refuses. Row arithmetic here
2350 // capped every terminal at rows/3 panes however wide, because it 2391 // capped every terminal at rows/3 panes however wide, because it
2351 // cannot see that a `.beside` cut spends columns. 2392 // cannot see that a `.beside` cut spends columns.
2352 const at = live.*; 2393 const at = reuse orelse live.*;
2353 switch (b.place) { 2394 switch (b.place) {
2354 .beside_focus => if (shared.tree.root == null) 2395 .beside_focus => if (shared.tree.root == null)
2355 // A wall whose tiles all arrive from a host's list starts with 2396 // A wall whose tiles all arrive from a host's list starts with
@@ -2382,24 +2423,42 @@ fn birthTile(
2382 // latest-wins. `label_rows` is set here so `viewRows` is right from 2423 // latest-wins. `label_rows` is set here so `viewRows` is right from
2383 // the first attach; relayout re-flattens every rect and sets it again. 2424 // the first attach; relayout re-flattens every rect and sets it again.
2384 shared.label_rows = if (new_live > 1) 1 else 0; 2425 shared.label_rows = if (new_live > 1) 1 else 0;
2385 initTile(&tiles[at], b.r, new_rect, shared, at) catch {
2386 shared.tree.remove(@intCast(at));
2387 return null;
2388 };
2389 // Past every refusal: the tile is the wall's now, so the copies a pump 2426 // Past every refusal: the tile is the wall's now, so the copies a pump
2390 // will hold for its whole life are worth making. 2427 // will hold for its whole life are worth making. Made BEFORE the slot
2428 // is overwritten, so the last thing that can fail here still fails
2429 // against a slot that is exactly as the caller found it.
2430 var r = b.r;
2391 if (b.borrowed) { 2431 if (b.borrowed) {
2392 const session = alloc.dupe(u8, b.r.session) catch { 2432 const session = alloc.dupe(u8, b.r.session) catch {
2393 shared.tree.remove(@intCast(at)); 2433 shared.tree.remove(@intCast(at));
2394 return null; 2434 return null;
2395 }; 2435 };
2396 tiles[at].r.session = session; 2436 r.session = session;
2397 tiles[at].r.label = tileLabel(alloc, b.r.target, session) catch { 2437 r.label = tileLabel(alloc, b.r.target, session) catch {
2398 alloc.free(session); 2438 alloc.free(session);
2399 shared.tree.remove(@intCast(at)); 2439 shared.tree.remove(@intCast(at));
2400 return null; 2440 return null;
2401 }; 2441 };
2402 } 2442 }
2443 // The departed tile's copies go with its digit. Every tile owns these
2444 // two — `run` dupes even the entry tile's session for this — so a
2445 // reused slot that kept them would leak one label and one name per
2446 // birth for the wall's whole life.
2447 if (reuse != null) {
2448 alloc.free(tiles[at].r.session);
2449 alloc.free(tiles[at].r.label);
2450 }
2451 initTile(&tiles[at], r, new_rect, shared, at, if (reuse == null) .fresh else .kept) catch {
2452 // Only a `.fresh` doorbell can fail, and it fails before the slot
2453 // is written: there is nothing here to undo but the tree and the
2454 // copies just made.
2455 shared.tree.remove(@intCast(at));
2456 if (b.borrowed) {
2457 alloc.free(r.session);
2458 alloc.free(r.label);
2459 }
2460 return null;
2461 };
2403 // The caller's row of the birth table, and the whole of what separates 2462 // The caller's row of the birth table, and the whole of what separates
2404 // the roads: a chord row inherits its target and agent and CREATES, a 2463 // the roads: a chord row inherits its target and agent and CREATES, a
2405 // poll row joins a session the daemon already has. The pump spawn stays 2464 // poll row joins a session the daemon already has. The pump spawn stays
@@ -2409,7 +2468,10 @@ fn birthTile(
2409 tiles[at].keeps_wall = b.keeps_wall; 2468 tiles[at].keeps_wall = b.keeps_wall;
2410 tiles[at].host = b.host; 2469 tiles[at].host = b.host;
2411 present[at] = true; 2470 present[at] = true;
2412 live.* += 1; 2471 // `live` is the high-water mark of slots ever used, not the tile count:
2472 // a reused digit is already inside it, and growing here would walk the
2473 // keyboard's `tiles[0..live]` off the end of the array.
2474 if (reuse == null) live.* += 1;
2413 return at; 2475 return at;
2414 } 2476 }
2415 2477
@@ -3490,7 +3552,6 @@ fn applyHostList(
3490 // per-name retry is an insert, a flatten and an undo per name per 3552 // per-name retry is an insert, a flatten and an undo per name per
3491 // poll, forever. 3553 // poll, forever.
3492 while (placed < births.len) : (placed += 1) { 3554 while (placed < births.len) : (placed += 1) {
3493 if (live.* >= max_tiles) break;
3494 const at = birthTile(alloc, tiles, present, live, shared, .{ 3555 const at = birthTile(alloc, tiles, present, live, shared, .{
3495 // Joins, never creates: the daemon already has this session, 3556 // Joins, never creates: the daemon already has this session,
3496 // and a sized attach on a live one would resize somebody. 3557 // and a sized attach on a live one would resize somebody.
@@ -3802,14 +3863,18 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3802 if (has_entry) { 3863 if (has_entry) {
3803 const hi = entry.entry_host.?; 3864 const hi = entry.entry_host.?;
3804 const target = host_specs[hi].target; 3865 const target = host_specs[hi].target;
3805 const name = proto.resolveName(entry.entry_session); 3866 // Duped, though argv outlives the process: a tile that ends frees
3867 // these two when its digit is taken back, and a free of argv would
3868 // be the birth after the entry tile's exit, not this line, that
3869 // crashed.
3870 const name = try alloc.dupe(u8, proto.resolveName(entry.entry_session));
3806 const rect = init_flat.rectOf(0) orelse return 2; 3871 const rect = init_flat.rectOf(0) orelse return 2;
3807 try initTile(&tiles[0], .{ 3872 try initTile(&tiles[0], .{
3808 .target = target, 3873 .target = target,
3809 .label = try tileLabel(alloc, target, name), 3874 .label = try tileLabel(alloc, target, name),
3810 .session = name, 3875 .session = name,
3811 .agent = entry.agent, 3876 .agent = entry.agent,
3812 }, rect, &shared, 0); 3877 }, rect, &shared, 0, .fresh);
3813 tiles[0].host = hi; 3878 tiles[0].host = hi;
3814 tiles[0].pre = entry.pre; 3879 tiles[0].pre = entry.pre;
3815 // `mux TARGET` is attach-or-create: the attach carries the rect, 3880 // `mux TARGET` is attach-or-create: the attach carries the rect,
@@ -3962,7 +4027,11 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3962 defer tiles[z].ans_mu.unlock(); 4027 defer tiles[z].ans_mu.unlock();
3963 name = tiles[z].ans; 4028 name = tiles[z].ans;
3964 } 4029 }
3965 const before = live; 4030 // Tiles, not slots: a birth that took a vanished digit
4031 // back leaves `live` — the high-water mark — exactly where
4032 // it was, and a wall that read growth off it would put the
4033 // new tile on a screen nothing re-cut.
4034 const before = presentCount(present[0..live]);
3966 switch (addSessionTile( 4035 switch (addSessionTile(
3967 alloc, 4036 alloc,
3968 tiles, 4037 tiles,
@@ -3975,7 +4044,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3975 )) { 4044 )) {
3976 .moved => |to| { 4045 .moved => |to| {
3977 last_focus = z; 4046 last_focus = z;
3978 focusAnswer(alloc, tiles[0..live], present[0..live], &shared, live > before, to); 4047 focusAnswer(alloc, tiles[0..live], present[0..live], &shared, presentCount(present[0..live]) > before, to);
3979 }, 4048 },
3980 .full => setNotice(&shared, "[no room on the wall for another tile]"), 4049 .full => setNotice(&shared, "[no room on the wall for another tile]"),
3981 .stay => {}, 4050 .stay => {},
@@ -5683,6 +5752,80 @@ test "birthTile: a beside wall admits more panes than rows/3" {
5683 } 5752 }
5684 } 5753 }
5685 5754
5755 test "birthTile: a vanished digit is taken back, and not before its pump returned" {
5756 const alloc = std.testing.allocator;
5757 // Five slots on a wall wide enough for all of them: the claim is which
5758 // digit a birth lands on, so no refusal may decide it instead.
5759 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 200, .rows = 24 }, .is_tty = false };
5760 defer shared.tree.deinit();
5761 try shared.tree.addFirst(0);
5762 var tiles: [5]Tile = undefined;
5763 var present = [_]bool{ true, false, false, false, false };
5764 var live: usize = 1;
5765 const target: client.Target = .{ .sock = "/tmp/a" };
5766 tiles[0] = .{
5767 .r = .{ .target = target, .label = "--sock /tmp/a#0", .session = "0" },
5768 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 200 },
5769 .shared = &shared,
5770 .idx = 0,
5771 .wake_r = -1,
5772 .wake_w = -1,
5773 };
5774 // Every other tile is born the way a host's poll births one, so the
5775 // copies a reuse has to free are the real owned ones.
5776 defer for (tiles[1..], present[1..]) |*t, p| {
5777 if (!p) continue;
5778 alloc.free(t.r.session);
5779 alloc.free(t.r.label);
5780 std.posix.close(t.wake_r);
5781 std.posix.close(t.wake_w);
5782 };
5783 const born = struct {
5784 fn at(a: std.mem.Allocator, ts: []Tile, ps: []bool, lv: *usize, sh: *Shared, tg: client.Target, name: []const u8, host: ?usize) ?usize {
5785 return birthTile(a, ts, ps, lv, sh, .{
5786 .r = .{ .target = tg, .label = "", .session = name },
5787 .from = 0,
5788 .place = .beside_focus,
5789 .creates = true,
5790 .born_from = 0,
5791 .host = host,
5792 .borrowed = true,
5793 });
5794 }
5795 }.at;
5796
5797 try std.testing.expectEqual(@as(?usize, 1), born(alloc, &tiles, &present, &live, &shared, target, "1", 3));
5798 try std.testing.expectEqual(@as(?usize, 2), born(alloc, &tiles, &present, &live, &shared, target, "2", 3));
5799
5800 // The middle tile leaves, carrying a full set of dirt: every one of
5801 // these is a field whose stale value would be a lie about the NEXT
5802 // tile in the slot — a session that was never up, a list that never
5803 // missed it, an `x` nobody pressed, a host that does not own it.
5804 tiles[1].ever_up.store(true, .release);
5805 tiles[1].missed_once = true;
5806 tiles[1].end_armed_until.store(9000, .release);
5807 const doorbell = tiles[1].wake_r;
5808 vanishTile(&tiles, &present, &shared, 1, null);
5809
5810 // Its pump has not returned, so the digit is not free yet: the birth
5811 // appends rather than hand a live thread's `*Tile` to a new tile.
5812 try std.testing.expectEqual(@as(?usize, 3), born(alloc, &tiles, &present, &live, &shared, target, "3", 3));
5813
5814 tiles[1].pump_done.store(true, .release);
5815 try std.testing.expectEqual(@as(?usize, 1), born(alloc, &tiles, &present, &live, &shared, target, "1", null));
5816 // Reuse, not growth: the wall is four tiles wide, not five.
5817 try std.testing.expectEqual(@as(usize, 4), live);
5818 try std.testing.expectEqual(@as(usize, 1), tiles[1].idx);
5819 try std.testing.expect(!tiles[1].ever_up.load(.acquire));
5820 try std.testing.expect(!tiles[1].missed_once);
5821 try std.testing.expectEqual(@as(i64, 0), tiles[1].end_armed_until.load(.acquire));
5822 try std.testing.expectEqual(@as(?usize, null), tiles[1].host);
5823 try std.testing.expect(!tiles[1].pump_done.load(.acquire));
5824 // The doorbell is the slot's, not the tile's: a fresh pipe per reuse
5825 // would leak the fd pair the old one is never closed on.
5826 try std.testing.expectEqual(doorbell, tiles[1].wake_r);
5827 }
5828
5686 test "labelText: the state word survives truncation at every width" { 5829 test "labelText: the state word survives truncation at every width" {
5687 var buf: [256]u8 = undefined; 5830 var buf: [256]u8 = undefined;
5688 // A label longer than any bar, so truncation is what is under test and 5831 // A label longer than any bar, so truncation is what is under test 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" = "85" ] || { 180 [ "$OK_COUNT" = "86" ] || {
181 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 85 —" 181 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 86 —"
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_13_birth.sh
Old New
@@ -38,6 +38,13 @@ defer_rm "$SPSTATE"
38 SPDIR="${TMPDIR:-/tmp}/mux-e2e-sp-shim-$$" 38 SPDIR="${TMPDIR:-/tmp}/mux-e2e-sp-shim-$$"
39 defer_rm "$SPDIR" 39 defer_rm "$SPDIR"
40 SPINLOG="$SPDIR/dials" 40 SPINLOG="$SPDIR/dials"
41 # The lowest-free-digit leg: one daemon of its own, because the claim is
42 # about which digits a WALL has spare and a socket shared with another leg
43 # would let that leg's sessions decide how many tiles there are.
44 SOCK74="${TMPDIR:-/tmp}/muxd-e2e-lfdigit-$$.sock"
45 defer_sock "$SOCK74"
46 LFSTATE="${TMPDIR:-/tmp}/mux-e2e-lfdigit-state-$$"
47 defer_rm "$LFSTATE"
41 48
42 # ---- the picker's `a` adds a HOST by spelling -------------------------- 49 # ---- the picker's `a` adds a HOST by spelling --------------------------
43 # 50 #
@@ -556,3 +563,109 @@ assert_stopped "$SOCK68" "$D68PID" "refusal-spin" "$OUT.spstop"
556 D68PID="" 563 D68PID=""
557 rm -rf "$SPSTATE" "$SPDIR" 564 rm -rf "$SPSTATE" "$SPDIR"
558 ok "a refusal the birth cannot fix backs off instead of spinning" 565 ok "a refusal the birth cannot fix backs off instead of spinning"
566
567 # ---- a new tile takes the lowest free digit -----------------------------
568 #
569 # Create 1 2 3, end 2, create again: the wall says 2, not 4. The digits are
570 # what `Ctrl-\ 1-9` addresses, so a digit an ended session kept forever
571 # would push every later birth out of the keyboard's reach — and the daemon
572 # has already taken the NAME back (`client.nextFreeName`), so a wall that
573 # appended would be showing tile 4 holding session 1.
574 #
575 # Reuse, not renumbering: the tiles that stayed keep the digits their user
576 # learned, which is why this leg asserts 2 came back rather than that the
577 # wall reads 1 2 3 from the left.
578 #
579 # ONE ptyclient run, because the claim is about a LIVE wall taking a hole
580 # back: a second client would build its tiles from the poll and land on
581 # 1 2 by counting, testing nothing at all.
582 #
583 # The convergence points are relayout's screen clear — `\x1cc` re-cuts the
584 # wall and so does the tile leaving it. `\x1c2` emits none of its own
585 # (`setFocus` repaints bars, it does not clear), which is what makes the
586 # clear after the `x` the dead tile's refocus and not the focus move's.
587 #
588 # The four seconds after that clear are the daemon's LIST, not a guess at
589 # how long a shell takes to die: `x` leaves a dead tile narrating its exit
590 # and the tile goes on the next poll a second later. A `c` pressed before
591 # that finds the dying tile still wearing the name the daemon has already
592 # freed, and merely focuses it — which reads here as `lfd-back` never
593 # arriving, never as a green run.
594 #
595 # The clear after the LAST `\x1cc` is the other half of the claim: a birth
596 # that takes a digit back leaves the slot high-water mark where it was, so
597 # a wall that read "did it grow?" off that mark would seat the new tile on
598 # a screen nothing re-cut.
599 start_daemon "$SOCK74" "$OUT.lfd.d" "lowest-free-digit daemon never bound" --shell /bin/sh
600 D74PID=$DPID
601
602 set +e
603 XDG_STATE_HOME="$LFSTATE" timeout 90 "$PTYCLIENT" --cols 100 --rows 30 \
604 --out "$OUT.lfdcap" --err "$OUT.lfdcap.err" -- \
605 "$MUX" --sock "$SOCK74" > "$OUT.lfdpc" 2>&1 <<'EOF'
606 expect \x1b[?1049h 15000
607 settle 500 15000
608 send printf 'lfd-%s\n' one\n
609 expect lfd-one 15000
610 settle 400 15000
611 send \x1cc
612 expect \x1b[2J 15000
613 settle 500 15000
614 send printf 'lfd-%s\n' two\n
615 expect lfd-two 15000
616 settle 400 15000
617 send \x1cc
618 expect \x1b[2J 15000
619 settle 500 15000
620 send printf 'lfd-%s\n' three\n
621 expect lfd-three 15000
622 settle 400 15000
623 send \x1c2
624 settle 700 15000
625 send \x1cx
626 expect \x1b[2J 25000
627 settle 4000 25000
628 send \x1cc
629 expect \x1b[2J 20000
630 settle 500 15000
631 send printf 'lfd-%s\n' back\n
632 expect lfd-back 15000
633 settle 700 15000
634 send \x1cd
635 waitexit 10000
636 EOF
637 RC=$?
638 set -e
639 [ "$RC" -eq 0 ] || {
640 echo "e2e FAIL: lowest-free-digit: ptyclient leg exited $RC (did the x chord"
641 echo " end the middle session, and did the birth after it land?):"
642 cat "$OUT.lfdpc"; exit 1; }
643 # The oracle, not the byte stream: every digit this leg typed its way
644 # through is somewhere in the capture, so only the final grid says which
645 # ones a human was still looking at.
646 "$RENDER" --cols 100 --rows 30 < "$OUT.lfdcap" > "$OUT.lfdgrid" || {
647 echo "e2e FAIL: lowest-free-digit: render oracle failed"
648 cat "$OUT.lfdgrid"; exit 1; }
649 grep -q -- " 2> --sock" "$OUT.lfdgrid" || {
650 echo "e2e FAIL: lowest-free-digit: no tile on the final wall wears digit 2:"
651 cat "$OUT.lfdgrid"; exit 1; }
652 grep -q -- " 4>" "$OUT.lfdgrid" && {
653 echo "e2e FAIL: lowest-free-digit: a three-tile wall wearing digit 4 — the"
654 echo " birth appended past the hole instead of taking it back:"
655 cat "$OUT.lfdgrid"; exit 1; }
656 wait_sessions "$SOCK74" 3 "lowest-free-digit: the daemon should hold 0, 1 and 2"
657 # The digit and the name agree, asked of the DAEMON rather than of the wall
658 # that would be reporting on its own arithmetic: session 1 is back, and it
659 # is a NEW one — the ended session's output is not in it.
660 timeout 20 "$MUXA" capture --sock "$SOCK74" --session 1 > "$OUT.lfdsess1" 2>&1
661 grep -q "lfd-back" "$OUT.lfdsess1" || {
662 echo "e2e FAIL: lowest-free-digit: session 1 does not hold what was typed at"
663 echo " the reborn tile:"
664 cat "$OUT.lfdsess1"; exit 1; }
665 grep -q "lfd-two" "$OUT.lfdsess1" && {
666 echo "e2e FAIL: lowest-free-digit: session 1 still holds the ENDED session's"
667 echo " output, so nothing was ended and nothing was reborn:"
668 cat "$OUT.lfdsess1"; exit 1; }
669 assert_stopped "$SOCK74" "$D74PID" "lowest-free-digit" "$OUT.lfdstop"
670 D74PID=""
671 ok "a new tile takes the lowest free digit, and the daemon takes the name back"