ce611141
feat: a new tile takes the lowest free digit
a73x 2026-08-28 19:53
Commit message
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" | ||