Uh oh!
There was an error while loading. Please reload this page.
Release, don't destroy: held DM handles and data views survive the MeshVariable rebuild (#492) - #536
Conversation
…build (#492) Creating a variable on a mesh that already has fields rebuilds mesh.dm; the old DM and vectors were eagerly destroy()-ed. petsc4py destroy() zeroes the handle of the wrapper object itself, and mesh.dm / var.vec hand out that same wrapper, so any user-captured handle was blinded -- the next call on it dereferenced a NULL handle (SIGSEGV on optimized PETSc, issue #492). The freed vector pages also left user-held numpy .data views silently reading and writing freed memory, the delayed heap-corruption crash behind the PR #488 Linux CI segfault. Drop the references instead and let PETSc refcounting free each object with its last holder: captured handles stay valid (stale), and memory behaviour is unchanged when nobody holds them -- measured RSS over repeated rebuild+solve cycles is identical with and without the eager destroy (solver-side holders are clones of mesh.dm, never mesh.dm). The rebuild's restore loop now also clears _data_cache/_array_cache alongside _canonical_data (matching the mesh.adapt path) so UW3 never hands back a view of the released vectors. mesh._lvec release composes with the existing lazy rebuild in update_lvec(). Underworld development team with AI support from Claude Code
test_0858_dm_rebuild_lifecycle.py: the issue reproducer (a captured mesh.dm handle stays valid and the mesh solves on the rebuilt DM), refcount hygiene with a 20-cycle RSS bound (7.1 MB measured, 50 MB bound), .data view refresh after rebuild, and the test_0842-shaped adapt-child arm/solve/teardown sequence from the CI detonation story. The pre-fix SIGSEGV (design-probe subprocess exit -11) is cited in the docstring rather than executed; the observable asserts fail cleanly on the unfixed build (held handle zeroed) -- verified before the fix. ptest_0011_dm_rebuild_held_handles.py + mpi_runner.sh entries: the same contract on every rank; verified at np2 and np4. Underworld development team with AI support from Claude Code
…492) State the rule explicitly in the governing document: raw numpy views of variable data do not survive creating another variable on the same mesh and must be re-read (UW3 invalidates every cache it hands out; views captured by user code cannot be reached). Captured PETSc handles (mesh.dm, var.vec) get the gentler post-#492 contract: valid but stale. Underworld development team with AI support from Claude Code
There was a problem hiding this comment.
Pull request overview
This PR fixes a PETSc DM/Vec lifecycle bug triggered by MeshVariable-driven DM rebuilds: previously _setup_ds() explicitly destroy()-ed the old DM and vectors, which could blind user-held petsc4py wrappers (segfault) and leave NumPy views pointing at freed buffers (heap corruption). The change shifts to releasing references and strengthens cache invalidation so held handles remain stale-but-valid and UW3 won’t re-serve views of released buffers.
Changes:
- Update DM-rebuild path to release (not
destroy()) the old DM and variable vectors, preserving safety for captured petsc4py handles. - Ensure MeshVariable rebuild restores preserved data while eagerly invalidating all data/array caches to prevent stale views.
- Add serial + MPI regression tests for held-handle survival and view refresh; document the updated contract in
data-access.md.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
src/underworld3/discretisation/discretisation_mesh_variables.py | Stop destroying old DM/Vecs during DM rebuild; restore data and invalidate caches to avoid stale views/use-after-free. |
tests/test_0858_dm_rebuild_lifecycle.py | New lifecycle/regression tests for held DM handles, memory behavior across rebuild loops, and view refresh semantics. |
tests/parallel/ptest_0011_dm_rebuild_held_handles.py | New MPI reproducer asserting the held-handle contract holds on every rank and solve remains correct. |
tests/parallel/mpi_runner.sh | Add the new MPI test to the parallel runner at np=2 and np=4. |
docs/developer/subsystems/data-access.md | Document contract for raw NumPy views vs captured PETSc handles across MeshVariable-triggered DM rebuilds. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| rss0 = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024**2 | ||
| for i in range(20): | ||
| uw.discretisation.MeshVariable(f"w{i}", mesh, 1, degree=1) | ||
| gc.collect() | ||
| rss1 = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024**2 | ||
| assert rss1 - rss0 < 50.0, f"RSS grew {rss1 - rss0:.1f} MB over 20 rebuilds" |
…on the adapt child Two test robustness items from the #536 review. np.shares_memory on the refreshed view was allocator-dependent - the released buffer is freed before its identically-sized replacement allocates, so glibc block recycling could fail the assert spuriously on exactly the platform it guards; object identity plus value correctness assert the same contract without dereferencing the dangling view. And the adapt-child arm now solves its first pass under preconditioner="fmg" so the custom-P coarse chain from the original detonation sequence is genuinely constructed - under "auto" a single-field solver declines to GAMG and the chain was never built. Underworld development team with AI support from Claude Code
lmoresi
commented
Aug 12, 2026
Adversarial review — PR #536 (release, don't destroy: DM rebuild lifecycle)Branch VerdictApprove. No merge-blockers. The one-line-shaped core change does exactly what the MERGE-BLOCKERSNone. Evidence1. Leak reality — object counts, not just RSS (clean)The PR's 20-cycle/50 MB RSS bound is loose, so we measured the sharp thing: a
Identical to the object on both builds, at both N. The release path destroys 2. The |
lmoresi
commented
Aug 12, 2026
Response commit 5afc44d takes the review's two moderate hardenings: the view test asserts object identity + preserved values instead of Underworld development team with AI support from Claude Code |
Release, don't destroy: MeshVariable DM rebuild keeps captured handles valid
Fixes#492.
What was wrong
Creating a MeshVariable on a mesh that already has fields rebuilds
mesh.dm(a finalized PETSc Section cannot be extended in place through petsc4py — we
verified
addField+clearDS+createDSleaves the stale Sectioninstalled and
setLocalSection(None)is not bindable). The rebuild itself isfine; the defect was that
_setup_dseagerlydestroy()-ed the old DM andthe old variable vectors. That produced two distinct crash classes:
The blinded wrapper (the reported SIGSEGV).
mesh.dmis a plainattribute, so a user-captured handle is the same petsc4py wrapper object
the rebuild called
.destroy()on. petsc4py zeroes that wrapper's handle,and the next call on it is a NULL-handle dereference — an immediate
segfault on optimized PETSc (validity macros compiled out). Reproduced
deterministically as subprocess exit −11 in the design probes. There was
no user-side defence: even a pre-emptive
incRef()leaves the wrapperblinded (and leaks the DM).
Stale views over freed vector buffers (the delayed CI detonation).
.datais a numpy view into_lvec.array. The rebuild destroyed everyvariable's vectors, so any view captured beforehand silently read and
wrote freed pages. On glibc a stale write corrupts allocator metadata and
the process dies much later at an unrelated allocation — the deterministic
Linux-only segfault that hit PR Adapt-on-top: closure-free edge_split engine, reconnection repair, and interface-pinned relaxation #488's CI in
test_0844, two files afterthe arming in
test_0842(macOS was allocator-lucky; Guard Malloc wasclean because the pages were genuinely freed and legitimately reused).
What the holder inventory found (and exonerated)
We instrumented every candidate captor of the old DM. All PETSc-side holders
are properly reference-counted and none of them hold
mesh.dmat all:solvers clone the hierarchy (
clone_dm_hierarchy), so SNES/KSP/PCMG levelDMs are clones; the adapt-child coarse chain (
_custom_mg_coarse_meshes)holds Python
Meshobjects whose coarse/fine links live on the solverclones — the suspected dangling coarse-chain links never dangled. The
DMInterpolation cache stores no DM (it is passed per call). Pre-solve the old
DM's refcount is exactly 1 (the mesh's own wrapper); after a solve it is 2
(
mesh._lvec). The eager destroy therefore genuinely freed the C objectwhile Python-side aliases (the user's wrapper, numpy views) still pointed at
it.
The fix
_setup_dsnow drops its references instead of destroying: the old DM,the old variable
_lvec/_gvec, andmesh._lvecdie with their lastholder via PETSc refcounting. A captured handle stays valid — stale, but
safe to query — which is the contract the issue asked for ("at worst a
wrong answer from an out-of-date object, never a crash").
_data_cache/_array_cachealongside
_canonical_data(matching themesh.adaptpath), so UW3 cannever hand back a view of the released vectors.
mesh._lvecrelease composes with the existing lazy rebuild:update_lvec()recreates it from the new DM on next access.Why this does not leak
With the destroy gone, the old wrapper loses its last Python reference at the
end of
_setup_dsand petsc4py's dealloc frees the C object — unlesssomeone still holds it, which is the point. Measured RSS over 12
rebuild+solve cycles: baseline 305.6→327.8 MB, no-destroy 303.7→325.9 MB —
identical +22.2 MB growth (solver/JIT machinery, in both modes). A
rebuild-only loop grows 7.1 MB over 20 cycles (the 20 real variables' FE
bookkeeping), bounded at 50 MB in the regression test.
Contract change (documented in data-access.md)
np.asarray(var.data), keptvar.data/var.arrayreferences) do not survive creating another variable on the same mesh —
UW3 cannot reach them; re-read after any variable creation. Property access
is always safe (self-validating cache, now backed by eager invalidation).
mesh.dm,var.vec) get the gentler contract:they stay valid but stale, and should also be re-read.
Tests
tests/test_0858_dm_rebuild_lifecycle.py(level_1/tier_a; adapt-child caselevel_2): the Creating a MeshVariable destroys the previous mesh.dm; a held handle segfaults (use-after-free) #492 reproducer (held handle survives + solve works on the
rebuilt DM), refcount hygiene + 20-cycle RSS bound, view refresh, and the
test_0842-shaped adapt-child arm/solve/teardown sequence. Negative
control: all three lifecycle tests fail cleanly on the unfixed build
(held handle zeroed —
assert 0 != 0), pass post-fix. The pre-fix SIGSEGVitself (probe exit −11) is cited in the docstring rather than executed in
CI.
tests/parallel/ptest_0011_dm_rebuild_held_handles.py(+ mpi_runner.shentries): the reproducer contract on every rank; verified np2 and np4.
full
level_1 and tier_agate 0 failed.Underworld development team with AI support from Claude Code