6fe188fc
fix: review round 1 — an unborrowed birth's copies have one owner
a73x 2026-08-28 19:53
Commit message
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -7342,13 +7342,11 @@ per-host-per-second repaint cannot leave a terminal that never goes quiet. | |||
| 7342 | ## 2026-08-28 — a new tile takes the lowest free digit | 7342 | ## 2026-08-28 — a new tile takes the lowest free digit |
| 7343 | 7343 | ||
| 7344 | Reuse, not renumbering. `Ctrl-\ 1-9` addresses digits, so a digit an ended | 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 | 7345 | session kept forever pushes later births out of the keyboard's reach, while |
| 7346 | renumbering the survivors moves a digit the user had learned. `birthTile` | 7346 | renumbering the survivors moves a digit the user had learned. The daemon |
| 7347 | takes the lowest free slot, the daemon already does the same with names | 7347 | already reuses names, so digit and name come back together. |
| 7348 | (`client.nextFreeName`), and digit and name come back together. | 7348 | |
| 7349 | 7349 | A slot is free only once its pump has RETURNED (`Tile.pump_done`): `present` | |
| 7350 | A slot is free only once its pump has RETURNED (`Tile.pump_done`, stored | 7350 | alone hands a `*Tile` a returning thread is still reading to a new tile. Its |
| 7351 | after the bell that reads `t.shared`): `present` alone hands a `*Tile` a | 7351 | doorbell pipe stays with it — never closed, so a fresh one per reuse leaks an |
| 7352 | returning thread is still reading to a new tile. Its doorbell pipe stays with | 7352 | fd pair — and `live` becomes the high-water mark, growth counted in `present`. |
| 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 | ||
|---|---|---|---|
| @@ -2367,6 +2367,11 @@ const Birth = struct { | |||
| 2367 | // The poll finds a name in the buffer it is about to reuse and must not | 2367 | // The poll finds a name in the buffer it is about to reuse and must not |
| 2368 | // pay for a copy — nor leave one behind — when the wall refuses the | 2368 | // pay for a copy — nor leave one behind — when the wall refuses the |
| 2369 | // tile; `r.label` is rebuilt here rather than passed. | 2369 | // tile; `r.label` is rebuilt here rather than passed. |
| 2370 | // | ||
| 2371 | // FALSE is an ownership claim, not an optimisation: the tile takes | ||
| 2372 | // `label` and `session` verbatim, and whichever birth takes its digit | ||
| 2373 | // back frees both with `alloc`. Hand it a literal and the abort is that | ||
| 2374 | // later birth, arbitrarily far from the caller that made the mistake. | ||
| 2370 | borrowed: bool = false, | 2375 | borrowed: bool = false, |
| 2371 | }; | 2376 | }; |
| 2372 | 2377 | ||
| @@ -2498,7 +2503,10 @@ fn addSessionTile( | |||
| 2498 | // never returns on the success path), which is what lets a pump hold | 2503 | // never returns on the success path), which is what lets a pump hold |
| 2499 | // these slices for as long as it lives. | 2504 | // these slices for as long as it lives. |
| 2500 | const session = alloc.dupe(u8, want) catch return .full; | 2505 | const session = alloc.dupe(u8, want) catch return .full; |
| 2501 | const label = tileLabel(alloc, target, want) catch return .full; | 2506 | const label = tileLabel(alloc, target, want) catch { |
| 2507 | alloc.free(session); | ||
| 2508 | return .full; | ||
| 2509 | }; | ||
| 2502 | const at = birthTile(alloc, tiles, present, live, shared, .{ | 2510 | const at = birthTile(alloc, tiles, present, live, shared, .{ |
| 2503 | // The offer is inherited from the tile this one grew out of. Same | 2511 | // The offer is inherited from the tile this one grew out of. Same |
| 2504 | // target, so `-A` exposes nothing the user has not already exposed | 2512 | // target, so `-A` exposes nothing the user has not already exposed |
| @@ -2511,7 +2519,13 @@ fn addSessionTile( | |||
| 2511 | .creates = true, | 2519 | .creates = true, |
| 2512 | .born_from = from, | 2520 | .born_from = from, |
| 2513 | .host = tiles[from].host, | 2521 | .host = tiles[from].host, |
| 2514 | }) orelse return .full; | 2522 | }) orelse { |
| 2523 | // A refused wall keeps nothing, so neither may the copies made for | ||
| 2524 | // it: `birthTile` took ownership only by returning a slot. | ||
| 2525 | alloc.free(session); | ||
| 2526 | alloc.free(label); | ||
| 2527 | return .full; | ||
| 2528 | }; | ||
| 2515 | spawnPump(&tiles[at]); | 2529 | spawnPump(&tiles[at]); |
| 2516 | return .{ .moved = at }; | 2530 | return .{ .moved = at }; |
| 2517 | } | 2531 | } |
| @@ -3960,9 +3974,6 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 3960 | // this chunk, so its room is a whole chunk plus that hold. | 3974 | // this chunk, so its room is a whole chunk plus that hold. |
| 3961 | var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined; | 3975 | var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined; |
| 3962 | var mouse_filter: interact.MouseFilter = .{}; | 3976 | var mouse_filter: interact.MouseFilter = .{}; |
| 3963 | // Where `Ctrl-\ l` goes back to. Keyboard-thread state: no pump reads | ||
| 3964 | // it, and no lock guards it, because nothing else writes it. | ||
| 3965 | var last_focus: ?usize = null; | ||
| 3966 | // A vanish sentence deferred to the normal screen: printed after raw | 3977 | // A vanish sentence deferred to the normal screen: printed after raw |
| 3967 | // mode is restored, alongside the exit message below. | 3978 | // mode is restored, alongside the exit message below. |
| 3968 | var vanish_msg: ?[]const u8 = null; | 3979 | var vanish_msg: ?[]const u8 = null; |
| @@ -4043,7 +4054,6 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 4043 | tiles[z].pending_place, | 4054 | tiles[z].pending_place, |
| 4044 | )) { | 4055 | )) { |
| 4045 | .moved => |to| { | 4056 | .moved => |to| { |
| 4046 | last_focus = z; | ||
| 4047 | focusAnswer(alloc, tiles[0..live], present[0..live], &shared, presentCount(present[0..live]) > before, to); | 4057 | focusAnswer(alloc, tiles[0..live], present[0..live], &shared, presentCount(present[0..live]) > before, to); |
| 4048 | }, | 4058 | }, |
| 4049 | .full => setNotice(&shared, "[no room on the wall for another tile]"), | 4059 | .full => setNotice(&shared, "[no room on the wall for another tile]"), |
| @@ -4058,7 +4068,6 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 4058 | if (endedTile(tiles[0..live], present[0..live], &shared)) |ended| { | 4068 | if (endedTile(tiles[0..live], present[0..live], &shared)) |ended| { |
| 4059 | switch (endAction(tiles, present, live, ended, stdin_open, shared.is_tty)) { | 4069 | switch (endAction(tiles, present, live, ended, stdin_open, shared.is_tty)) { |
| 4060 | .refocus => |to| { | 4070 | .refocus => |to| { |
| 4061 | last_focus = ended; | ||
| 4062 | if (!tiles[to].alive.load(.acquire)) | 4071 | if (!tiles[to].alive.load(.acquire)) |
| 4063 | setNotice(&shared, "[focus on a dead tile - no live neighbour]"); | 4072 | setNotice(&shared, "[focus on a dead tile - no live neighbour]"); |
| 4064 | setFocus(tiles[0..live], &shared, to); | 4073 | setFocus(tiles[0..live], &shared, to); |
| @@ -4336,7 +4345,6 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 4336 | if (ev.at > seg_start) | 4345 | if (ev.at > seg_start) |
| 4337 | sendKeys(&tiles[shared.sel], cmd.forward[seg_start..ev.at]); | 4346 | sendKeys(&tiles[shared.sel], cmd.forward[seg_start..ev.at]); |
| 4338 | seg_start = ev.at; | 4347 | seg_start = ev.at; |
| 4339 | last_focus = shared.sel; | ||
| 4340 | setFocus(tiles[0..live], &shared, hit); | 4348 | setFocus(tiles[0..live], &shared, hit); |
| 4341 | } | 4349 | } |
| 4342 | } | 4350 | } |
| @@ -4397,7 +4405,6 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 4397 | // every session every host has, so asking one daemon for a | 4405 | // every session every host has, so asking one daemon for a |
| 4398 | // ring would walk its slice and skip the rest of the wall. | 4406 | // ring would walk its slice and skip the rest of the wall. |
| 4399 | if (walkTiles(present[0..live], z, cmd.action == .next_session)) |to| { | 4407 | if (walkTiles(present[0..live], z, cmd.action == .next_session)) |to| { |
| 4400 | last_focus = z; | ||
| 4401 | focusAnswer(alloc, tiles[0..live], present[0..live], &shared, false, to); | 4408 | focusAnswer(alloc, tiles[0..live], present[0..live], &shared, false, to); |
| 4402 | } | 4409 | } |
| 4403 | }, | 4410 | }, |
| @@ -4406,7 +4413,6 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 4406 | if (flat) |f| { | 4413 | if (flat) |f| { |
| 4407 | if (layout.neighbor(f, @intCast(z), dirOf(d))) |nb| { | 4414 | if (layout.neighbor(f, @intCast(z), dirOf(d))) |nb| { |
| 4408 | if (nb < live and present[nb] and nb != z) { | 4415 | if (nb < live and present[nb] and nb != z) { |
| 4409 | last_focus = z; | ||
| 4410 | if (shared.fullscreen) | 4416 | if (shared.fullscreen) |
| 4411 | focusAnswer(alloc, tiles[0..live], present[0..live], &shared, false, nb) | 4417 | focusAnswer(alloc, tiles[0..live], present[0..live], &shared, false, nb) |
| 4412 | else | 4418 | else |
| @@ -4424,7 +4430,6 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 4424 | }, | 4430 | }, |
| 4425 | .focus => |idx| { | 4431 | .focus => |idx| { |
| 4426 | if (idx > 0 and idx <= live and present[idx - 1] and idx - 1 != z) { | 4432 | if (idx > 0 and idx <= live and present[idx - 1] and idx - 1 != z) { |
| 4427 | last_focus = z; | ||
| 4428 | if (shared.fullscreen) | 4433 | if (shared.fullscreen) |
| 4429 | focusAnswer(alloc, tiles[0..live], present[0..live], &shared, false, idx - 1) | 4434 | focusAnswer(alloc, tiles[0..live], present[0..live], &shared, false, idx - 1) |
| 4430 | else | 4435 | else |
| @@ -5737,9 +5742,15 @@ test "birthTile: a beside wall admits more panes than rows/3" { | |||
| 5737 | .place = .beside_focus, | 5742 | .place = .beside_focus, |
| 5738 | .creates = true, | 5743 | .creates = true, |
| 5739 | .born_from = n - 2, | 5744 | .born_from = n - 2, |
| 5745 | // Borrowed, so the tile gets copies of its own: these are literals, | ||
| 5746 | // and `Birth.borrowed = false` promises the slot's next reuse two | ||
| 5747 | // slices it may free. | ||
| 5748 | .borrowed = true, | ||
| 5740 | }) orelse return error.TestUnexpectedResult; | 5749 | }) orelse return error.TestUnexpectedResult; |
| 5741 | defer std.posix.close(tiles[at].wake_r); | 5750 | defer std.posix.close(tiles[at].wake_r); |
| 5742 | defer std.posix.close(tiles[at].wake_w); | 5751 | defer std.posix.close(tiles[at].wake_w); |
| 5752 | defer alloc.free(tiles[at].r.session); | ||
| 5753 | defer alloc.free(tiles[at].r.label); | ||
| 5743 | try std.testing.expectEqual(@as(usize, n), shared.tree.count()); | 5754 | try std.testing.expectEqual(@as(usize, n), shared.tree.count()); |
| 5744 | // Admitted AND habitable: every pane keeps the full height and clears | 5755 | // Admitted AND habitable: every pane keeps the full height and clears |
| 5745 | // the column floor, which is what makes the refusal wrong. | 5756 | // the column floor, which is what makes the refusal wrong. |
| @@ -5826,6 +5837,75 @@ test "birthTile: a vanished digit is taken back, and not before its pump returne | |||
| 5826 | try std.testing.expectEqual(doorbell, tiles[1].wake_r); | 5837 | try std.testing.expectEqual(doorbell, tiles[1].wake_r); |
| 5827 | } | 5838 | } |
| 5828 | 5839 | ||
| 5840 | test "birthTile: the digit's next tile frees the copies an unborrowed birth handed over" { | ||
| 5841 | const alloc = std.testing.allocator; | ||
| 5842 | // `Birth.borrowed = false` hands the tile two slices outright, and the | ||
| 5843 | // birth that takes its digit back is what frees them. The testing | ||
| 5844 | // allocator is the whole oracle: a slice it never handed out aborts the | ||
| 5845 | // free, and one nobody frees is reported leaked — which is what a | ||
| 5846 | // caller passing a literal and a reuse that kept the old copies each | ||
| 5847 | // look like. | ||
| 5848 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 200, .rows = 24 }, .is_tty = false }; | ||
| 5849 | defer shared.tree.deinit(); | ||
| 5850 | try shared.tree.addFirst(0); | ||
| 5851 | var tiles: [3]Tile = undefined; | ||
| 5852 | var present = [_]bool{ true, false, false }; | ||
| 5853 | var live: usize = 1; | ||
| 5854 | const target: client.Target = .{ .sock = "/tmp/a" }; | ||
| 5855 | tiles[0] = .{ | ||
| 5856 | .r = .{ .target = target, .label = "--sock /tmp/a#0", .session = "0" }, | ||
| 5857 | .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 200 }, | ||
| 5858 | .shared = &shared, | ||
| 5859 | .idx = 0, | ||
| 5860 | .wake_r = -1, | ||
| 5861 | .wake_w = -1, | ||
| 5862 | }; | ||
| 5863 | // The chord's road, verbatim: the caller allocates, `birthTile` takes | ||
| 5864 | // the slices as they are. | ||
| 5865 | const handed = struct { | ||
| 5866 | fn birth( | ||
| 5867 | a: std.mem.Allocator, | ||
| 5868 | ts: []Tile, | ||
| 5869 | ps: []bool, | ||
| 5870 | lv: *usize, | ||
| 5871 | sh: *Shared, | ||
| 5872 | tg: client.Target, | ||
| 5873 | name: []const u8, | ||
| 5874 | ) ?usize { | ||
| 5875 | const session = a.dupe(u8, name) catch return null; | ||
| 5876 | const label = tileLabel(a, tg, session) catch { | ||
| 5877 | a.free(session); | ||
| 5878 | return null; | ||
| 5879 | }; | ||
| 5880 | return birthTile(a, ts, ps, lv, sh, .{ | ||
| 5881 | .r = .{ .target = tg, .label = label, .session = session }, | ||
| 5882 | .from = 0, | ||
| 5883 | .place = .beside_focus, | ||
| 5884 | .creates = true, | ||
| 5885 | .born_from = 0, | ||
| 5886 | }); | ||
| 5887 | } | ||
| 5888 | }.birth; | ||
| 5889 | |||
| 5890 | const first = handed(alloc, &tiles, &present, &live, &shared, target, "1") orelse | ||
| 5891 | return error.TestUnexpectedResult; | ||
| 5892 | try std.testing.expectEqual(@as(usize, 1), first); | ||
| 5893 | defer if (present[1]) { | ||
| 5894 | alloc.free(tiles[1].r.session); | ||
| 5895 | alloc.free(tiles[1].r.label); | ||
| 5896 | std.posix.close(tiles[1].wake_r); | ||
| 5897 | std.posix.close(tiles[1].wake_w); | ||
| 5898 | }; | ||
| 5899 | |||
| 5900 | vanishTile(&tiles, &present, &shared, 1, null); | ||
| 5901 | tiles[1].pump_done.store(true, .release); | ||
| 5902 | // The same digit, so the free under test is one this birth really did. | ||
| 5903 | try std.testing.expectEqual( | ||
| 5904 | @as(?usize, 1), | ||
| 5905 | handed(alloc, &tiles, &present, &live, &shared, target, "2"), | ||
| 5906 | ); | ||
| 5907 | } | ||
| 5908 | |||
| 5829 | test "labelText: the state word survives truncation at every width" { | 5909 | test "labelText: the state word survives truncation at every width" { |
| 5830 | var buf: [256]u8 = undefined; | 5910 | var buf: [256]u8 = undefined; |
| 5831 | // A label longer than any bar, so truncation is what is under test and | 5911 | // A label longer than any bar, so truncation is what is under test and |
test/e2e_13_birth.sh
| Old | New | ||
|---|---|---|---|
| @@ -581,16 +581,21 @@ ok "a refusal the birth cannot fix backs off instead of spinning" | |||
| 581 | # 1 2 by counting, testing nothing at all. | 581 | # 1 2 by counting, testing nothing at all. |
| 582 | # | 582 | # |
| 583 | # The convergence points are relayout's screen clear — `\x1cc` re-cuts the | 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 | 584 | # wall. `\x1c2` emits none of its own (`setFocus` repaints bars, it does not |
| 585 | # (`setFocus` repaints bars, it does not clear), which is what makes the | 585 | # clear), which is what makes the clear after the `x` the ended tile's and |
| 586 | # clear after the `x` the dead tile's refocus and not the focus move's. | 586 | # not the focus move's. |
| 587 | # | 587 | # |
| 588 | # The four seconds after that clear are the daemon's LIST, not a guess at | 588 | # The four seconds after that clear are a DURATION and not a convergence |
| 589 | # how long a shell takes to die: `x` leaves a dead tile narrating its exit | 589 | # point, because there is nothing left to converge on: measured on this |
| 590 | # and the tile goes on the next poll a second later. A `c` pressed before | 590 | # harness, `x` yields exactly ONE clear, and a second `expect \x1b[2J` in |
| 591 | # that finds the dying tile still wearing the name the daemon has already | 591 | # place of this settle times out. The tile's departure is not separately |
| 592 | # freed, and merely focuses it — which reads here as `lfd-back` never | 592 | # announced, so the wait is the daemon's LIST — `x` leaves a dead tile |
| 593 | # arriving, never as a green run. | 593 | # narrating its exit and the tile goes on the next poll a second later. |
| 594 | # | ||
| 595 | # A `c` pressed before that finds the dying tile still wearing the name the | ||
| 596 | # daemon has already freed and merely focuses it, which reads here as | ||
| 597 | # `lfd-back` never arriving and as the three assertions below failing — | ||
| 598 | # never as a green run. That is what makes a duration acceptable here. | ||
| 594 | # | 599 | # |
| 595 | # The clear after the LAST `\x1cc` is the other half of the claim: a birth | 600 | # 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 | 601 | # that takes a digit back leaves the slot high-water mark where it was, so |