Pool-resident objects resolve through their pool, not a cached base - #144
Pool-resident objects resolve through their pool, not a cached base#144rmrsk wants to merge 15 commits into
Conversation
Records the design agreed for the next two PRs before any code is written, so the reasoning is reviewable rather than reconstructed from the diffs later. PR 1 removes freeze()/bind() from every user workflow. Pool::reserve can grow, which moves the base, which invalidates a cached base; today that is handled by sealing the pool before anything may be queried. That lifecycle is only about pointer hygiene, but it leaks into every workflow that keeps building -- the three-pool workaround in Examples/MeshSDF and the two-pass loop in Parser::readIntoMesh both exist solely because of it. The fix is for pool-resident types to carry both a host-only Pool* and the resolved base: host lookups go through the pool and are immune to growth, device lookups use the base that deviceView() rebased. bind(), the explicit-base overloads and the host role of boundView() all disappear with it, and freeze() is reduced to meaning what it says -- a precondition for mirror(), which users can reasonably be asked to understand. PR 2 ports the BVH: SharedPtrStorage purged in favour of two POD policies (Value, and a new Index), the three arrays moved onto Pool-backed PODVectors, and the seven near-identical copies of the pruneTraverse loop factored into one core before an eighth is added for the device. The file also records what was rejected and why (a handle/descriptor split; reserving address space so the base never moves; leaving it to user-side assertions), since those are the parts most likely to be re-proposed. It is a working document: fold what remains true into PORTING.md and delete it once both PRs land. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PORTING.md is the authoritative status document, so an in-flight design doc that it never mentions is discoverable only by luck. Adds the pointer, notes that the pool-ergonomics change precedes the BVH port, and says plainly that the pointer is temporary. Also corrects the BVH roadmap entry, which still listed two questions PLAN.md now answers: SharedPtrStorage is dropped in favour of Value and Index, and the traversal stack depth differs by entry point. Leaving them listed as open would have put this page in direct contradiction with the plan. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pool::reserve can grow the block, which moves it, which invalidated any base an object had cached. That was handled by freeze(): seal the pool, then bind() each object to the now-stable base. The cost was that an object could not be queried until the whole pool was sealed, so any workflow that kept building collided with a lifecycle decision that was only ever about pointer hygiene -- visible in the tree as Examples/MeshSDF using three pools, and Parser::readIntoMesh(files, pool) being "deliberately not a loop over the single-file overload". A Pool now publishes its base through a heap-resident PoolControl, and pool-resident objects hold a pointer to that rather than to the base itself, re-reading it on every access. A grow is therefore invisible to them, and freeze() survives only as the precondition for mirror() -- a rule that justifies itself, rather than a precondition on asking a question. The control block, rather than a plain Pool*, is what keeps a Pool movable. MeshT previously cached the block address, and Pool's move constructor steals that address verbatim, so a mesh survived its pool being moved; pointing the mesh at the Pool object would have silently broken std::vector<Pool> reallocation, pools held as class members, and factories returning a pool alongside its geometry -- all of which work against the current code. The control block's address does not change when the Pool moves, so that property is preserved. This deletes, from DCEL::MeshT: bind(), boundView(), every explicit-base overload (public and protected), and the deepCopy(srcBase, dstPool) form. In their place, rebasedView(const Pool&) is the single crossing point. It takes the Pool rather than a bare base so it can check that the target really is a mirror of the mesh's own pool and large enough to hold its arrays, and it uses isDeviceAccessible() as a *discriminator* rather than an assertion: a device-accessible target yields a view holding a plain base (a kernel cannot follow a control block), while a host target yields one holding that pool's control block, so host-to-host rebasing keeps working and stays growth-immune. That in turn makes a null control block mean "device view" and nothing else, so base()'s assertions are exact in both directions rather than best-effort. Two consequences that must be documented rather than enforced, and now are (Pool::reserve's Doxygen, PODVector's class block, MeshT's accessors, and MemoryModel.rst): - A resolved address -- a reference from getVertex/getEdge/getFace, a PODSpan from bind() -- must not outlive the next reserve() on its pool. The freeze state machine used to make this unreachable for users. Carry elements across a reserve by value and write them back through a fresh accessor call. - Querying while another thread reserves from the same pool is a data race; pools are not internally synchronized. Also fixes a latent use-after-free: deepCopy(srcBase, dstPool) documented dstPool as possibly being the source's own pool, but cached srcBase across three reserves that could move the block out from under it. Pool gains id()/mirrorOf()/control(), PODVector gains endByte(), and MeshT gains isAttachedTo(). mirrorOf() names the root of a mirror chain rather than the immediate source, so staging through pinned memory on the way to a device still validates against the pool the geometry was built in. Soup::reconcilePairEdgesDCEL loses its now-unused Pool parameter, the multi-file readIntoMesh/readIntoPackedBVH overloads become plain loops, and Examples/MeshSDF collapses to one pool. Tests: 313 unit tests pass under debug and debug-san, 319 under release-test, and all 11 examples run. New cases cover what was previously untestable -- query across a grow, a mesh surviving its Pool being moved (including a std::vector<Pool> reallocation, which destroys the source), deepCopy into the same pool, snapshot-and-write-back across a grow, and a MeshT-level host-to-host rebasedView, which exercises the rebase invariant on real geometry in CI without a GPU. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@claude review |
|
Claude finished @rmrsk's task in 2m 52s —— View job Review: Pool-resident objects resolve through their pool, not a cached base
Overall this is a well-executed, well-tested redesign. The Finding: stale doc comments claim the
|
Summary
Background
Pool::reservecan grow the block, which moves it, which invalidates any base an object has cached. Until now that was handled byfreeze(): seal the pool, thenbind()each object to the now-stable base.The cost was that an object could not be queried until the whole pool was sealed, so any workflow that keeps building — read ten STLs, deep-copy one, translate a few, read another — collided with a lifecycle decision that was only ever about pointer hygiene. The workarounds were already in the tree:
Examples/MeshSDFused three pools becauseFlatMeshSDF/MeshSDF's constructors froze, andParser::readIntoMesh(files, pool)was "deliberately not a loop over the single-file overload" for the same reason.This is step 1 of the roadmap in
PORTING.md; the design was agreed inPLAN.mdbefore any code was written, and the BVH port (step 2) depends on it.Solution
A
Poolnow publishes its base through a heap-residentPoolControl, and pool-resident objects hold a pointer to that rather than to the base itself, re-reading it on every access. A grow is therefore invisible to them, andfreeze()survives only as the precondition formirror()— a rule that justifies itself ("you cannot copy a block that might still move") rather than a precondition on asking a question.Why a control block rather than a
Pool*.MeshTpreviously cached the block address, andPool's move constructor steals that address verbatim, so a mesh survived its pool being moved. Pointing the mesh at thePoolobject would have silently brokenstd::vector<Pool>reallocation, pools held as class members, and factories returning a pool alongside its geometry — all of which work against the current code, none of which would have drawn a reviewer's eye in the diff. The control block's address does not change when thePoolmoves, so that property is preserved.What this deletes from
DCEL::MeshT:bind(),boundView(), every explicit-base overload (public and protected), and thedeepCopy(srcBase, dstPool)form. The accessor surface roughly halves.The one crossing point is now
rebasedView(const Pool&). It takes thePoolrather than a bare base so it can check that the target really is a mirror of the mesh's own pool and is large enough to hold its arrays, and it usesisDeviceAccessible()as a discriminator rather than an assertion:That second branch is what makes a null control block mean "device view" and nothing else, so
base()'s assertions are exact in both directions rather than best-effort: a non-null control block on device means a host descriptor reached a kernel directly, and a null one on the host means a device view is being dereferenced there. It also keeps the rebase invariant testable on real geometry without a GPU, which matters because CI has no device.A latent use-after-free is fixed on the way:
deepCopy(srcBase, dstPool)documenteddstPoolas possibly being the source's own pool, but cachedsrcBaseacross three reserves that could move the block out from under it. The new form re-resolves on every read, so it is correct by construction.Supporting additions:
Pool::id()/mirrorOf()/control(),PODVector::endByte(),MeshT::isAttachedTo().mirrorOf()names the root of a mirror chain rather than the immediate source, so staging through pinned memory on the way to a device still validates against the pool the geometry was built in.Side-effects
Two properties are no longer enforced by the type system and must be carried by documentation instead. Both are now documented on
Pool::reserve, onPODVector's class block, onMeshT's reference-returning accessors, and inMemoryModel.rst:reserve. A reference fromgetVertex/getEdge/getFace, or aPODSpanfrombind(), points into a block that a growingreservedeallocates. The freeze state machine previously made this unreachable for users; allowing mid-build queries makes it reachable. The rule is resolve, use, discard — carry elements across areserveby value and write them back through a fresh accessor call. Everything returning by value (signedDistance(),getAllVertexCoordinates(), …) is unaffected.Also note that the "mirror a host descriptor into a kernel" mistake goes from structurally impossible (#140) to possible-but-asserted. The assertions are exact, and every realistic error path is caught at the point of the mistake rather than as a garbage kernel result — but
EBGEOMETRY_EXPECTcompiles out in the release presets, so this is a debug-time guarantee rather than a type-level one.API changes visible to users:
Soup::reconcilePairEdgesDCELloses its now-unusedPoolparameter; the multi-filereadIntoMesh/readIntoPackedBVHoverloads became plain loops over the single-file form;Examples/MeshSDFand the AMReXPaintEBintegration collapse to one pool and drop their freeze/bind calls.Alternative solutions
Pool*and resolvem_pool->base(). Simpler, and was the original plan — rejected because it regressesPoolmovability as described above, in ways anEBGEOMETRY_EXPECTcannot catch (a moved-from pool that has since been destroyed is a read of freed memory, and assertions are off in release).Poolimmovable. Airtight, butmirror()returns by value, and it would stop users putting pools in containers at all.Pool&plus a POD descriptor that crosses. Preserves the type-level guarantee, but the cost is not "two types and an API break": everyFaceT/EdgeT/VertexTmethod takes aconst Mesh&, so a second mesh type means templating the whole DCEL element API. That is the actual reason for accepting the weaker guarantee here.viewInanddeviceViewfunctions. Collapsed into one once the host branch was made to follow the target's control block — the accessibility predicate then selects behaviour rather than rejecting it, and no choice is pushed onto the caller.Testing
313unit tests pass underdebuganddebug-san(ASan/UBSan),319underrelease-test, and all11examples run to completion. Every pre-commit hook passes, including clang-tidy, Doxygen (warnings as errors), and both Sphinx builds.New cases cover what was previously untestable:
Poolbeing moved, including astd::vector<Pool>reallocation, which destroys the source object rather than merely emptying it (the case aPool*design fails);deepCopyinto the same pool, i.e. the latent use-after-free above;MeshT-level host-to-hostrebasedView, which exercises the rebase invariant on real geometry in CI without a GPU;Poolidentity and transitivemirrorOfacross a two-hop chain, and an abort on reserving from a moved-from pool.Not verified: the CUDA/HIP
[gpu]paths were not run on a device for this PR.MeshT::rebasedView's device branch is exercised byTestDCEL's existing[gpu]case, which needs a local GPU run before this is merged.Reviewer checklist (to be completed by a human)
@claude review.