15b5fbcc
fix: collectLeafIds propagates the allocation failure it was swallowing
claude 2026-09-04 13:22
Commit message
src/client/layout.zig
| Old | New | ||
|---|---|---|---|
| @@ -282,7 +282,7 @@ pub const Tree = struct { | |||
| 282 | if (focus) |fid| { | 282 | if (focus) |fid| { |
| 283 | var ids: std.ArrayListUnmanaged(u8) = .{}; | 283 | var ids: std.ArrayListUnmanaged(u8) = .{}; |
| 284 | defer ids.deinit(self.alloc); | 284 | defer ids.deinit(self.alloc); |
| 285 | collectLeafIds(self.alloc, self.root, &ids); | 285 | try collectLeafIds(self.alloc, self.root, &ids); |
| 286 | for (ids.items, 0..) |id, k| { | 286 | for (ids.items, 0..) |id, k| { |
| 287 | if (id == fid) { | 287 | if (id == fid) { |
| 288 | try writer.print("focus {d}\n", .{k}); | 288 | try writer.print("focus {d}\n", .{k}); |
| @@ -314,12 +314,16 @@ pub const Tree = struct { | |||
| 314 | /// containers collapse. Removals ALL happen before any rewrite, so old ids | 314 | /// containers collapse. Removals ALL happen before any rewrite, so old ids |
| 315 | /// stay addressable through the removal pass; the rewrite is one pass, so a | 315 | /// stay addressable through the removal pass; the rewrite is one pass, so a |
| 316 | /// new id cannot collide with a not-yet-rewritten old one. | 316 | /// new id cannot collide with a not-yet-rewritten old one. |
| 317 | pub fn remapLeaves(self: *Tree, map: []const ?u8) void { | 317 | /// |
| 318 | /// `error.OutOfMemory` leaves the tree UNTOUCHED: the only allocation is | ||
| 319 | /// the id list, and it is taken before the first mutation, so a caller that | ||
| 320 | /// gives up on the error is giving up on a tree it never modified. | ||
| 321 | pub fn remapLeaves(self: *Tree, map: []const ?u8) std.mem.Allocator.Error!void { | ||
| 318 | // Collect ids first because removal mutates the tree and may | 322 | // Collect ids first because removal mutates the tree and may |
| 319 | // collapse containers, invalidating node pointers. | 323 | // collapse containers, invalidating node pointers. |
| 320 | var ids: std.ArrayListUnmanaged(u8) = .{}; | 324 | var ids: std.ArrayListUnmanaged(u8) = .{}; |
| 321 | defer ids.deinit(self.alloc); | 325 | defer ids.deinit(self.alloc); |
| 322 | collectLeafIds(self.alloc, self.root, &ids); | 326 | try collectLeafIds(self.alloc, self.root, &ids); |
| 323 | 327 | ||
| 324 | // Pass 1: removals. All null-mapped leaves are removed before any | 328 | // Pass 1: removals. All null-mapped leaves are removed before any |
| 325 | // id rewrite, so the old ids remain addressable throughout. | 329 | // id rewrite, so the old ids remain addressable throughout. |
| @@ -732,12 +736,17 @@ fn countLeadingSpaces(line: []const u8) usize { | |||
| 732 | return i; | 736 | return i; |
| 733 | } | 737 | } |
| 734 | 738 | ||
| 735 | fn collectLeafIds(alloc: std.mem.Allocator, node: ?*const Node, ids: *std.ArrayListUnmanaged(u8)) void { | 739 | /// Depth-first leaf ids, in encounter order. The allocation failure is |
| 740 | /// PROPAGATED, never swallowed: a short list is not a smaller tree, it is a | ||
| 741 | /// wrong answer about this one. Dropping a leaf here made `serialize` write a | ||
| 742 | /// `focus K` naming a different pane, and made `remapLeaves` skip a removal so | ||
| 743 | /// a pane the wall no longer has stayed in the saved tree. | ||
| 744 | fn collectLeafIds(alloc: std.mem.Allocator, node: ?*const Node, ids: *std.ArrayListUnmanaged(u8)) std.mem.Allocator.Error!void { | ||
| 736 | const n = node orelse return; | 745 | const n = node orelse return; |
| 737 | switch (n.*) { | 746 | switch (n.*) { |
| 738 | .leaf => |t| ids.append(alloc, t) catch {}, | 747 | .leaf => |t| try ids.append(alloc, t), |
| 739 | .container => |c| { | 748 | .container => |c| { |
| 740 | for (c.children.items) |child| collectLeafIds(alloc, child, ids); | 749 | for (c.children.items) |child| try collectLeafIds(alloc, child, ids); |
| 741 | }, | 750 | }, |
| 742 | } | 751 | } |
| 743 | } | 752 | } |
| @@ -1324,7 +1333,7 @@ test "remapLeaves: null removes, containers collapse, ids rewrite in one pass" { | |||
| 1324 | try t.splitBelow(1, 2); | 1333 | try t.splitBelow(1, 2); |
| 1325 | // Leaf 1 has no wall line; 0 and 2 map to tiles 2 and 0 (a swap, the | 1334 | // Leaf 1 has no wall line; 0 and 2 map to tiles 2 and 0 (a swap, the |
| 1326 | // collision-prone case a two-pass rewrite gets wrong). | 1335 | // collision-prone case a two-pass rewrite gets wrong). |
| 1327 | t.remapLeaves(&[_]?u8{ 2, null, 0 }); | 1336 | try t.remapLeaves(&[_]?u8{ 2, null, 0 }); |
| 1328 | try std.testing.expectEqual(@as(usize, 2), t.count()); | 1337 | try std.testing.expectEqual(@as(usize, 2), t.count()); |
| 1329 | const f = try t.flatten(alloc, 24, 80, .{ .rows = 2, .cols = 3 }, null); | 1338 | const f = try t.flatten(alloc, 24, 80, .{ .rows = 2, .cols = 3 }, null); |
| 1330 | defer f.deinit(alloc); | 1339 | defer f.deinit(alloc); |
| @@ -1333,6 +1342,58 @@ test "remapLeaves: null removes, containers collapse, ids rewrite in one pass" { | |||
| 1333 | try std.testing.expect(f.rectOf(1) == null); | 1342 | try std.testing.expect(f.rectOf(1) == null); |
| 1334 | } | 1343 | } |
| 1335 | 1344 | ||
| 1345 | test "serialize: a failed leaf-id collection refuses rather than misnaming the focus" { | ||
| 1346 | const alloc = std.testing.allocator; | ||
| 1347 | var t = Tree.init(alloc); | ||
| 1348 | defer t.deinit(); | ||
| 1349 | try t.addFirst(0); | ||
| 1350 | try t.splitRight(0, 1); | ||
| 1351 | try t.splitBelow(1, 2); // encounter order: 0, 1, 2 | ||
| 1352 | const spellings = [_][]const u8{ "a", "b", "c" }; | ||
| 1353 | var buf: std.ArrayListUnmanaged(u8) = .{}; | ||
| 1354 | defer buf.deinit(alloc); | ||
| 1355 | |||
| 1356 | // The tree is built through the test allocator and only the id list is | ||
| 1357 | // taken through the failing one, so the failure lands on exactly the | ||
| 1358 | // allocation under test. `buf` keeps the test allocator: the node lines | ||
| 1359 | // are written before the id walk and are not what this pins. | ||
| 1360 | var failing = std.testing.FailingAllocator.init(alloc, .{ .fail_index = 0 }); | ||
| 1361 | t.alloc = failing.allocator(); | ||
| 1362 | const err = t.serialize(&spellings, 2, buf.writer(alloc)); | ||
| 1363 | t.alloc = alloc; | ||
| 1364 | |||
| 1365 | try std.testing.expectError(error.OutOfMemory, err); | ||
| 1366 | // Not "focus 0" or a missing focus line pointing the next attach at the | ||
| 1367 | // wrong pane: a truncated walk finds leaf 2 at no index, or at the index | ||
| 1368 | // of whichever leaf survived the truncation. | ||
| 1369 | try std.testing.expect(std.mem.indexOf(u8, buf.items, "focus") == null); | ||
| 1370 | } | ||
| 1371 | |||
| 1372 | test "remapLeaves: a failed leaf-id collection leaves the tree untouched" { | ||
| 1373 | const alloc = std.testing.allocator; | ||
| 1374 | var t = Tree.init(alloc); | ||
| 1375 | defer t.deinit(); | ||
| 1376 | try t.addFirst(0); | ||
| 1377 | try t.splitRight(0, 1); | ||
| 1378 | try t.splitBelow(1, 2); | ||
| 1379 | |||
| 1380 | var failing = std.testing.FailingAllocator.init(alloc, .{ .fail_index = 0 }); | ||
| 1381 | t.alloc = failing.allocator(); | ||
| 1382 | const err = t.remapLeaves(&[_]?u8{ 2, null, 0 }); | ||
| 1383 | t.alloc = alloc; | ||
| 1384 | |||
| 1385 | try std.testing.expectError(error.OutOfMemory, err); | ||
| 1386 | // All three leaves still there under their original ids: the collection | ||
| 1387 | // is taken before the first removal, so the caller that gives up gets the | ||
| 1388 | // tree it handed in, not one with an arbitrary prefix of the map applied. | ||
| 1389 | try std.testing.expectEqual(@as(usize, 3), t.count()); | ||
| 1390 | const f = try t.flatten(alloc, 24, 80, .{ .rows = 2, .cols = 3 }, null); | ||
| 1391 | defer f.deinit(alloc); | ||
| 1392 | try std.testing.expect(f.rectOf(0) != null); | ||
| 1393 | try std.testing.expect(f.rectOf(1) != null); | ||
| 1394 | try std.testing.expect(f.rectOf(2) != null); | ||
| 1395 | } | ||
| 1396 | |||
| 1336 | test "parse: a zero-weight child degrades to null, not a divide by zero" { | 1397 | test "parse: a zero-weight child degrades to null, not a divide by zero" { |
| 1337 | const alloc = std.testing.allocator; | 1398 | const alloc = std.testing.allocator; |
| 1338 | // Root cells are read and discarded, so only a non-root zero is a | 1399 | // Root cells are read and discarded, so only a non-root zero is a |
src/tui/wall_layout.zig
| Old | New | ||
|---|---|---|---|
| @@ -474,7 +474,11 @@ fn seedAttempt( | |||
| 474 | parsed.deinit(alloc); | 474 | parsed.deinit(alloc); |
| 475 | return null; | 475 | return null; |
| 476 | } | 476 | } |
| 477 | parsed.tree.remapLeaves(map); | 477 | parsed.tree.remapLeaves(map) catch { |
| 478 | plan.deinit(alloc); | ||
| 479 | parsed.deinit(alloc); | ||
| 480 | return null; | ||
| 481 | }; | ||
| 478 | if (base == 1 and entry_at == null) { | 482 | if (base == 1 and entry_at == null) { |
| 479 | const anchor: u8 = if (focus) |f| @intCast(f) else @intCast(base); | 483 | const anchor: u8 = if (focus) |f| @intCast(f) else @intCast(base); |
| 480 | parsed.tree.insert(anchor, 0) catch { | 484 | parsed.tree.insert(anchor, 0) catch { |