Skip to content

Commit de50c53

Browse files
authored
fix(pmt,ssz): harden chunked-leaf and zero-copy tree-view memory safety (#400)
## Motivation A review of the chunked-leaf packing and zero-copy tree-view paths surfaced a handful of memory-safety issues. ## Description - **Composite `set`/`push`/`setValue` ownership.** Make them caller-retains-on-failure (the std/Ghostty model): `chunks.set` no longer deinits the passed view on its own reservation OOM, and `setValue`/`pushValue` carry an `errdefer` over the view they build. Fixes a double-free in `load_state` (`applyModifiedValidators` / `appendNewValidators`), where the caller's `errdefer` and `set`'s self-free both ran on the `ensureUnusedCapacity` OOM path. - **ChunkedLeaf root recompute.** `getRoot`'s `.chunked_leaf` arm uses a reused Pool scratch field + `computeRoot` instead of `computeRootAllocating`, removing the only `@panic("OOM")` in `src/` (aborted the Node.js host on OOM) and the per-recompute malloc/free on the hashTreeRoot path. A Pool field rather than a stack buffer because `getRoot` recurses to tree depth (~47 on a mainnet validators path), and chunked_leaf is a recursion leaf so one shared scratch is always safe. - **`sumTargetUnslashedBalanceIncrements`.** Assert `participations.len == validators.len`; the zero-copy validator pointer slice turns a cross-list length mismatch into a garbage-pointer dereference. - **`ContainerTreeView.deserialize`.** Add the `errdefer pool.unref(root)` its two siblings already carry, so an `init` OOM no longer strands the deserialized subtree. - **Delete dead `fillToLength` / `fillToDepth`.** Pool-corrupting on first use, zero callers, superseded by `fillWithContents`. - **`ChunkedLeaf.computeRoot` trailing-zero assert.** Assert chunks past `len` are zero — a violated invariant would silently hash stale data into a wrong (consensus-divergent) root. - **`getChunkedLeafPtr` exclusive-ownership assert.** Assert `refCount() == 0` before handing out a mutable blob pointer; in-place mutation of a shared node corrupts every tree referencing it. - **List `setLength` → `growTo`, grow-only.** New positions read as zero (the data subtree is already the virtual zero subtree), so growing is O(1) and correct by construction; a bare length *cut* only rewrites the length mix-in, leaving stale chunk data in the merkleized root — a silent wrong hashTreeRoot. Now asserted (`new_length >= _len`) and documented: shrinking must go through `sliceTo`. All production callers grow (upgrade-to-altair); the one shrink user (the loadState trim test generator) now truncates a value-level state, keeping the test fixture independent of `sliceTo`, which loadState itself uses to trim. - **`ContainerTreeView.getFieldRoot` per-call pool-node leak.** On a dirty basic field it built a temporary node from the cached value and never unref'd it — one orphaned pool slot per call, invisible to leak detectors (`Pool.deinit` frees every in-use slot on teardown). Mirrors the fix its `StructContainerTreeView` sibling already carries: copy the hash into a per-field backing store, unref the node, return a pointer into the store. Pinned by a `getNodesInUse`-baseline test (10 calls leaked 10 slots before; baseline-stable after). - **Cloning a dirty tree view — two latent bugs.** A transfer-clone deliberately *drops* uncommitted writes (the rc-0 staged nodes are exclusively owned and can't be shared in the refcount model). The composite path handles this correctly; the basic-list path had two gaps. (1) **Leak:** `TreeViewState.clone` dropped the staged `children_nodes` entries *without* `unref`, orphaning a pool slot (and any chunked_leaf blob) per dropped write — invisible to leak detectors because `Pool.deinit` frees every in-use slot on teardown; now caught by a `getNodesInUse` baseline. (2) **`_len` skew:** the clone kept the uncommitted `_len`, so a dropped push left length N+1 over an N-element tree → wrong root on commit; the clone now reflects the committed length. Both latent (callers commit before cloning). - **`StructContainerTreeView.clone` semantics.** It committed the source first, so uncommitted writes survived into both views and `clone()` mutated the source's root — the opposite of every other view's drop semantics. It now clones the committed state and drops uncommitted writes (from the source too on transfer). - **`ProofFixture` dangling Pool (sync-committee witness tests).** The fixture returned its `Pool` by value after handing `&pool` to the views, leaving them pointing at a dead stack frame; the tests passed only by stack-layout luck. The fixture now initializes in place. - **Allocator-lane routing.** Two transient buffers (the chunked-leaf serialize Id scratch, the compact-multiproof arena) allocated from the page-allocator lane reserved for the pool's node columns; they now use the general allocator lane.
1 parent ed05a99 commit de50c53

15 files changed

Lines changed: 444 additions & 188 deletions

src/persistent_merkle_tree/ChunkedLeaf.zig

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
//! array + length) referenced by one `.chunked_leaf` Node. Self-contained,
55
//! ref-counted via the Pool's Node ref count, copy-on-write on mutation.
66
const std = @import("std");
7-
const Allocator = std.mem.Allocator;
87
const hashing = @import("hashing");
98
const hash = hashing.hash;
109

@@ -32,13 +31,14 @@ len: u16,
3231
/// padding. Each reduction is one batched `hash()` call so hashtree's
3332
/// SIMD lanes stay saturated.
3433
///
35-
/// `scratch` is a caller-supplied K/2-element buffer. `computeRootAllocating`
36-
/// wraps this with a per-call `allocator.alignedAlloc` + free.
34+
/// `scratch` is a caller-supplied K/2-element buffer, so root computation is allocation-free
35+
/// and infallible.
3736
///
3837
/// First round reads `chunks` directly into `scratch` (avoids the
3938
/// in-place mutation that `hashing.merkleize` would require on `*const
4039
/// chunks`). Later rounds halve in-place on `scratch`.
4140
pub fn computeRoot(self: *const ChunkedLeaf, scratch: *align(64) [K / 2][32]u8, out: *[32]u8) void {
41+
for (self.chunks[self.len..]) |*chunk| std.debug.assert(std.mem.allEqual(u8, chunk, 0));
4242
hash(scratch[0..], self.chunks[0..]) catch unreachable;
4343

4444
var width: usize = K / 2;
@@ -49,14 +49,6 @@ pub fn computeRoot(self: *const ChunkedLeaf, scratch: *align(64) [K / 2][32]u8,
4949
out.* = scratch[0];
5050
}
5151

52-
/// `computeRoot` wrapper that owns the scratch via `allocator`.
53-
pub fn computeRootAllocating(self: *const ChunkedLeaf, allocator: Allocator, out: *[32]u8) void {
54-
const scratch_slice = allocator.alignedAlloc([32]u8, .@"64", K / 2) catch @panic("OOM");
55-
defer allocator.free(scratch_slice);
56-
const scratch_arr: *align(64) [K / 2][32]u8 = @ptrCast(scratch_slice.ptr);
57-
self.computeRoot(scratch_arr, out);
58-
}
59-
6052
const Node = @import("Node.zig");
6153

6254
test "computeRoot for all-zero chunked_leaf equals getZeroHash(k_log2)" {

src/persistent_merkle_tree/Node.zig

Lines changed: 6 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,9 @@ pub const Pool = struct {
261261
allocator: Allocator,
262262
nodes: std.MultiArrayList(Node).Slice,
263263
next_free_node: Id,
264+
// Reused scratch for chunked_leaf root recompute: single-threaded, and chunked_leaf is a leaf
265+
// of getRoot's recursion, so at most one computeRoot uses it at a time.
266+
chunked_leaf_scratch: [ChunkedLeaf.K / 2][32]u8 align(64),
264267

265268
pub const InitOptions = struct {
266269
page_allocator: Allocator = std.heap.page_allocator,
@@ -278,6 +281,7 @@ pub const Pool = struct {
278281
.allocator = opts.allocator,
279282
.nodes = undefined,
280283
.next_free_node = @enumFromInt(max_depth),
284+
.chunked_leaf_scratch = undefined,
281285
};
282286

283287
var list = std.MultiArrayList(Node).empty;
@@ -776,7 +780,7 @@ pub const Id = enum(u32) {
776780
}
777781
const storage = chunkedLeafPtr(pool.nodes.items(.payload), idx);
778782
var hash: [32]u8 = undefined;
779-
storage.computeRootAllocating(pool.allocator, &hash);
783+
storage.computeRoot(&pool.chunked_leaf_scratch, &hash);
780784
roots[idx] = hash;
781785
return &roots[idx];
782786
},
@@ -822,6 +826,7 @@ pub const Id = enum(u32) {
822826
pub fn getChunkedLeafPtr(node_id: Id, pool: *Pool) Error!*ChunkedLeaf {
823827
const idx = @intFromEnum(node_id);
824828
if (pool.nodes.items(.state)[idx].kind() != .chunked_leaf) return Error.InvalidNode;
829+
std.debug.assert(pool.nodes.items(.state)[idx].refCount() == 0);
825830
return chunkedLeafPtr(pool.nodes.items(.payload), idx);
826831
}
827832

@@ -1530,92 +1535,6 @@ pub const Id = enum(u32) {
15301535
}
15311536
};
15321537

1533-
/// Fill a view to the specified depth, returning the new root node id.
1534-
pub fn fillToDepth(pool: *Pool, bottom: Id, depth: Depth) Error!Id {
1535-
var d = depth;
1536-
var node = bottom;
1537-
while (d > 0) : (d -= 1) {
1538-
node = try pool.createBranch(node, node);
1539-
}
1540-
1541-
return node;
1542-
}
1543-
1544-
/// Fill a view to the specified length and depth, returning the new root node id.
1545-
pub fn fillToLength(pool: *Pool, leaf: Id, depth: Depth, length: usize) Error!Id {
1546-
const max_length = @as(Gindex.Uint, 1) << depth;
1547-
if (length > max_length) {
1548-
return Error.InvalidLength;
1549-
}
1550-
1551-
// fill a full view to the specified depth
1552-
var node_id = try fillToDepth(pool, leaf, depth);
1553-
1554-
// if the requested length is the same as the max length, return the node
1555-
if (length == max_length) {
1556-
return node_id;
1557-
}
1558-
1559-
// otherwise, traverse down to the specified length
1560-
const gindex: Gindex = @enumFromInt(max_length | length);
1561-
const path_len = gindex.pathLen();
1562-
var path = gindex.toPath();
1563-
1564-
var parents_buf: [max_depth]Id = undefined;
1565-
var lefts_buf: [max_depth]Id = undefined;
1566-
var rights_buf: [max_depth]Id = undefined;
1567-
1568-
const path_parents = parents_buf[0..path_len];
1569-
const path_lefts = lefts_buf[0..path_len];
1570-
const path_rights = rights_buf[0..path_len];
1571-
1572-
const states = pool.nodes.items(.state);
1573-
const payloads = pool.nodes.items(.payload);
1574-
1575-
for (0..path_len - 1) |i| {
1576-
const idx = @intFromEnum(node_id);
1577-
const k = states[idx].kind();
1578-
if (noChildKind(node_id, k)) {
1579-
return Error.InvalidNode;
1580-
}
1581-
const c = childrenOf(node_id, k, payloads);
1582-
if (path.left()) {
1583-
path_lefts[i] = path_parents[i + 1];
1584-
path_rights[i] = c.right;
1585-
node_id = c.left;
1586-
} else {
1587-
path_lefts[i] = c.left;
1588-
path_rights[i] = path_parents[i + 1];
1589-
node_id = c.right;
1590-
}
1591-
path.next();
1592-
}
1593-
1594-
// and rebind with zero(0)
1595-
{
1596-
const idx = @intFromEnum(node_id);
1597-
const k = states[idx].kind();
1598-
if (noChildKind(node_id, k)) return Error.InvalidNode;
1599-
const c = childrenOf(node_id, k, payloads);
1600-
if (path.left()) {
1601-
path_lefts[path_len - 1] = @enumFromInt(0);
1602-
path_rights[path_len - 1] = c.right;
1603-
} else {
1604-
path_lefts[path_len - 1] = c.left;
1605-
path_rights[path_len - 1] = @enumFromInt(0);
1606-
}
1607-
}
1608-
1609-
// and rebind with zero(0)
1610-
try pool.rebind(
1611-
path_parents,
1612-
path_lefts,
1613-
path_rights,
1614-
);
1615-
1616-
return path_parents[0];
1617-
}
1618-
16191538
/// Fill a view with the specified contents, returning the new root node id.
16201539
///
16211540
/// Note: contents is mutated.

src/persistent_merkle_tree/node_test.zig

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const Depth = @import("hashing").Depth;
55

66
const Node = @import("Node.zig");
77
const Gindex = @import("gindex.zig").Gindex;
8+
const ChunkedLeaf = @import("ChunkedLeaf.zig");
89

910
// Allocate until the pool is full, so the next request has to grow (and fail). Returns the filler.
1011
fn drainPoolToFull(pool: *Node.Pool, out: *std.ArrayList(Node.Id)) !void {
@@ -125,6 +126,22 @@ test "Node.State predicates" {
125126
_ = free_state.nextFree();
126127
}
127128

129+
test "chunked_leaf getRoot recomputes without touching the pool allocator" {
130+
var counter = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = std.math.maxInt(usize) });
131+
var pool = try Node.Pool.init(.{ .page_allocator = std.testing.allocator, .allocator = counter.allocator(), .pool_size = 16 });
132+
defer pool.deinit();
133+
134+
var chunks: [ChunkedLeaf.K][32]u8 align(64) = undefined;
135+
for (&chunks, 0..) |*c, i| c.* = [_]u8{@intCast(i & 0xff)} ** 32;
136+
137+
const node = try pool.createChunkedLeaf(&chunks, ChunkedLeaf.K);
138+
defer pool.unref(node);
139+
140+
const allocs_before = counter.alloc_index;
141+
_ = node.getRoot(&pool); // root starts lazy → this recomputes
142+
try std.testing.expectEqual(allocs_before, counter.alloc_index);
143+
}
144+
128145
test "Pool" {
129146
const allocator = std.testing.allocator;
130147
var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 10 });

src/persistent_merkle_tree/proof.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -564,7 +564,7 @@ pub fn createNodeFromCompactMultiProof(
564564
leaves: [][32]u8,
565565
descriptor: []const u8,
566566
) (Node.Error || Error)!Node.Id {
567-
var arena = std.heap.ArenaAllocator.init(pool.page_allocator);
567+
var arena = std.heap.ArenaAllocator.init(pool.allocator);
568568
defer arena.deinit();
569569
const temp_allocator = arena.allocator();
570570

src/ssz/tree_view/array_basic.zig

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,46 @@ test "TreeView vector element roundtrip" {
174174
try std.testing.expectEqualSlices(u64, &expected, &roundtrip);
175175
}
176176

177+
test "TreeView vector chunked_leaf roundtrip across the chunked_leaf boundary" {
178+
const allocator = std.testing.allocator;
179+
var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 });
180+
defer pool.deinit();
181+
182+
const Uint64 = UintType(64);
183+
const VectorType = FixedVectorType(Uint64, 276, .{ .chunked_leaf = true });
184+
185+
var original: VectorType.Type = undefined;
186+
for (&original, 0..) |*e, i| e.* = i;
187+
188+
const root_node = try VectorType.tree.fromValue(&pool, &original);
189+
var view = try VectorType.TreeView.init(allocator, &pool, root_node);
190+
defer view.deinit();
191+
192+
try std.testing.expectEqual(@as(u64, 0), try view.get(0));
193+
try std.testing.expectEqual(@as(u64, 255), try view.get(255));
194+
try std.testing.expectEqual(@as(u64, 256), try view.get(256));
195+
try std.testing.expectEqual(@as(u64, 275), try view.get(275));
196+
197+
try view.set(255, 999);
198+
try view.set(256, 1000);
199+
try view.commit();
200+
201+
var expected = original;
202+
expected[255] = 999;
203+
expected[256] = 1000;
204+
205+
var expected_root: [32]u8 = undefined;
206+
try VectorType.hashTreeRoot(&expected, &expected_root);
207+
208+
var actual_root: [32]u8 = undefined;
209+
try view.hashTreeRootInto(&actual_root);
210+
try std.testing.expectEqualSlices(u8, &expected_root, &actual_root);
211+
212+
var roundtrip: VectorType.Type = undefined;
213+
try VectorType.tree.toValue(view.getRoot(), &pool, &roundtrip);
214+
try std.testing.expectEqualSlices(u64, &expected, &roundtrip);
215+
}
216+
177217
test "TreeView vector getAll fills provided buffer" {
178218
const allocator = std.testing.allocator;
179219
var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 256 });

src/ssz/tree_view/chunks.zig

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -442,20 +442,16 @@ pub fn CompositeChunks(
442442
return child_ptr;
443443
}
444444

445-
/// Takes ownership of `value` (and deinits it if a reservation fails). Deinits whatever
446-
/// child was cached for `index`, so any earlier get()/getReadonly() of it is now invalid.
447-
/// Pass a view you own — never a get()/getReadonly() pointer for this same index, or a
448-
/// failed set would deinit a view the cache still holds (double-free).
445+
/// Takes ownership of `value` only on success; on any error `value` is left untouched for
446+
/// the caller to free. Deinits whatever child was cached for `index`, so any earlier
447+
/// get()/getReadonly() of it is now invalid. Pass a view you own — never a
448+
/// get()/getReadonly() pointer for this same index, or the displaced-old deinit would free a
449+
/// view the cache still holds (double-free).
449450
pub fn set(self: *Self, index: usize, value: ElementPtr) !void {
450451
const gindex = Gindex.fromDepth(chunk_depth, index);
451-
// Reserve before storing so neither store can fail. A failure mid-store would drop
452-
// `value` (we own it now, the caller won't free it) or leave `changed` and
453-
// `children_data` out of sync.
454-
{
455-
errdefer value.deinit();
456-
try self.state.changed.ensureUnusedCapacity(self.state.allocator, 1);
457-
try self.children_data.ensureUnusedCapacity(self.state.allocator, 1);
458-
}
452+
// Reserve first so the commit below cannot fail.
453+
try self.state.changed.ensureUnusedCapacity(self.state.allocator, 1);
454+
try self.children_data.ensureUnusedCapacity(self.state.allocator, 1);
459455
self.state.changed.putAssumeCapacity(gindex, {});
460456
const opt_old_data = self.children_data.fetchPutAssumeCapacity(gindex, value);
461457
if (opt_old_data) |old_data_value| {
@@ -505,12 +501,11 @@ pub fn CompositeChunks(
505501
/// Set a child from an SSZ value type.
506502
pub fn setValue(self: *Self, index: usize, value: *const Value) !void {
507503
const root = try ST.Element.tree.fromValue(self.state.pool, value);
508-
// Free `root` only if init fails. Once init succeeds, `set` owns `child_view` on every
509-
// path, so we must not deinit it here; that would double-free if set later fails.
510504
const child_view = Element.init(self.state.allocator, self.state.pool, root) catch |err| {
511505
self.state.pool.unref(root);
512506
return err;
513507
};
508+
errdefer child_view.deinit();
514509
try self.set(index, child_view);
515510
}
516511

0 commit comments

Comments
 (0)