You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
0 commit comments