Skip to content

Robust lifetime model for delta ensembles, stages 1-3 - #1042

Closed
magnesj wants to merge 6 commits into
devfrom
lifetime-delta-ensemble-batch
Closed

magnesj wants to merge 6 commits into
devfrom
lifetime-delta-ensemble-batch

Conversation

@magnesj

@magnesj magnesj commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Stages 1–3 of the staged redesign in #1031. Together they retire the use-after-free behind OPM#14423 structurally, rather than working around it at one call site. Stages 4–7, which cover the silent-wrong-state defects, are not included.

Part of #1031.

Base

The first commit is a cherry-pick of e16238f331, the band-aid from the open OPM#14424, so the regression test it introduced is present for stage 3 to build on. Stage 3 then removes the band-aid itself. If OPM#14424 merges upstream first, that commit drops out on rebase and nothing else changes.

Stage 1 — cycle-safe dependency traversal

Delta ensembles were found by scanning the summary case main collection, and their dependents visited by unguarded recursion. A dependency cycle, constructible through the UI, made that recursion run forever, and an ensemble used as both source 1 and source 2 was reported twice.

RimSummaryEnsembleTools gains dependentDeltaEnsembles(), deltaEnsemblesInUpdateOrder() and wouldCreateDependencyCycle(). The traversal follows the PDM back references, deduplicates, is iterative with visited and on-path sets, and returns delta ensembles in topological order. Back edges are logged rather than traversed. RimDeltaSummaryEnsemble::findReferringEnsembles() is deleted and its four call sites converted.

Stage 2 — declarative rebuild, setInUse pooling deleted

Derived cases were pooled through an m_inUse flag that conflated pool bookkeeping with owning the source references and the value cache. allSummaryCases() hid the not-in-use cases from the rest of the project, and cases handed out of the pool never got nameChanged connected, so a renamed source case never propagated.

Replaced by desiredSourceCasePairs(), a pure computation of the pairs the ensemble should have, and rebuildDerivedCases(), which diffs that against the existing derived cases keyed on the (case1, case2) pointer pair. Matching cases are reused, missing ones created, surplus ones detached and returned to the caller instead of deleted in place. Keying on the pointer pair makes the rebuild idempotent, including right after project load. setInUse, isInUse, m_inUse, setAllCasesNotInUse, firstCaseNotInUse and deleteCasesNoInUse are gone; old project files still load, since unknown XML keywords are skipped.

Stage 3 — RimSummaryCaseUpdateBatch

RicCloseSummaryCaseFeature::deleteSummaryCases holds a case list across removeCases and deletes it afterwards, while removeCases made a delta ensemble destroy derived cases that are themselves in that list.

RimSummaryCaseUpdateBatch is a plain scope object, ambient for its scope. Removal only detaches and hands the cases to the batch; the outermost scope flushes, regenerating dirty delta ensembles in dependency order first, then destroying the orphans. A nested batch contributes to the outermost one and never flushes. Orphans are held as guarded pointers, so a case the caller already destroyed is skipped rather than destroyed twice. Both contribution points fall back to immediate execution when no batch is active.

Deviation from the plan

The plan puts the outermost batch in removeCases. That does not work: deleteSummaryCases calls caf::PdmObjectHandleTools::deleteObjects( cases ) after removeCases has returned, so a batch ending there frees the orphans while that list still refers to them and the crash survives. A batch is therefore opened in both places, and nesting makes the feature's the outermost. removeCases no longer rewrites the caller's list and now takes it by const reference.

The regression test could not be kept verbatim for the same reason — its aliveCount == cases.size() assertion tests the write-back, which is the band-aid. It is renamed to RemoveCases_NoDanglingInCallerVector, as the plan's own test table anticipated, and asserts the invariant the batch buys instead: inside the batch scope every entry the caller handed over is still alive, and after the scope exits each has been destroyed exactly once. Removing just the batch line from that test on this branch reproduces the original signature exactly, cases.size() 6 against aliveCount() 4 followed by SEH exception with code 0xc0000005.

Follow-up spotted along the way

A dependency cycle stack-overflows in auto-name generation, independently of the traversal fixed here: RimSummaryEnsemble::updateName ends in caseNameChanged.send(), which reaches RimSummaryCaseMainCollection::onCaseNameChanged and back into updateSummaryEnsembleNames(), terminating only because names converge — which they never do when A's name derives from B's and B's from A's. DependencyOrder_CycleTerminates closes its cycle by writing the PdmPtrField directly for that reason. Stage 7 already plans cycle prevention in the setters and should cover it.

…semble

Removing a source case makes a delta ensemble recreate and delete its derived cases. Those cases are part of the list being removed, leaving dangling pointers that crash in PdmObjectHandle::prepareForDelete(). Use guarded pointers and return only the surviving cases.
Delta ensembles were located by scanning the summary case main collection for objects referring to a given ensemble, and the dependent ensembles were visited by unguarded recursion. A dependency cycle, which is constructible through the UI, made that recursion run forever, and an ensemble used as both source 1 and source 2 was reported twice.

Add dependentDeltaEnsembles(), deltaEnsemblesInUpdateOrder() and wouldCreateDependencyCycle() to RimSummaryEnsembleTools. The traversal uses the PDM back references, deduplicates, is iterative with visited and on-path sets, and returns the delta ensembles in topological order so a delta ensemble is always visited before the delta ensembles using it as a source. Back edges are logged instead of traversed.

Reimplement updateDependentDeltaEnsembles on top of the new traversal and replace RimDeltaSummaryEnsemble::findReferringEnsembles() with dependentDeltaEnsembles() at its four call sites. Back references also find a delta ensemble that is detached from the project tree, which the previous ancestor scan did not.
Derived cases were pooled through an m_inUse flag on RimDeltaSummaryCase. Every regeneration marked all cases not in use, which also severed their source references and cleared their caches, then handed them back out one by one and deleted whatever was left over. The flag conflated pool bookkeeping with owning the source references, allSummaryCases() hid the not-in-use cases from the rest of the project, and cases taken from the pool were pushed straight into m_cases without connecting nameChanged, so a renamed source case never propagated to the derived case.

Replace the pooling with desiredSourceCasePairs(), a pure computation of the source case pairs the ensemble should have, and rebuildDerivedCases(), which diffs that against the existing derived cases keyed on the source case pointer pair. Matching cases are reused, missing ones are created, and surplus ones are detached and returned to the caller instead of being deleted in place. Keying on the pointer pair makes the rebuild idempotent, also right after project load where the derived cases arrive from XML with their sources already resolved.

Add the protected RimSummaryEnsemble::addCaseWithoutDependencyUpdate(), used both by addCase() and by the rebuild, so a derived case gets nameChanged connected without triggering the dependent-ensemble notification that the rebuild is already performing itself.

Remove setAllCasesNotInUse(), firstCaseNotInUse(), deleteCasesNoInUse(), RimDeltaSummaryCase::setInUse()/isInUse() and the m_inUse field, and add clearSourceCases() for the one thing setInUse(false) was actually needed for. Old project files keep loading, unknown XML keywords are skipped. The activeOnly parameter of allDerivedCases() is gone and RimDeltaSummaryEnsemble no longer overrides allSummaryCases().
Closing all summary cases while a delta ensemble is present crashed with a use-after-free. RicCloseSummaryCaseFeature::deleteSummaryCases holds a case list across removeCases and deletes it afterwards, while removeCases made the delta ensemble rebuild and destroy derived cases that are themselves part of that list. The previous fix rewrote the caller list with the surviving cases, which stopped the crash but left the hazard in place for any other caller holding a case list across a removal.

Add RimSummaryCaseUpdateBatch, a plain scope object that is ambient for the duration of its scope. Removal now only detaches, and hands the detached cases to the batch. The outermost scope flushes, regenerating the dirty delta ensembles in dependency order first so a chained delta ensemble sees the final state of its source, then destroying the orphans with caf::PdmObjectHandleTools::deleteObjects. A nested batch contributes to the outermost one and never flushes. Orphans are held as guarded pointers, so a case the caller destroyed itself is skipped instead of being destroyed twice. Both contribution points fall back to immediate execution when no batch is active, so call sites that do not open one keep behaving as before.

Open a batch in RimSummaryCaseMainCollection::removeCases and in RicCloseSummaryCaseFeature::deleteSummaryCases, which is the outermost of the two and therefore keeps the detached cases alive across its own deleteObjects call. removeCases no longer rewrites the caller list, so it now takes it by const reference.
RimDeltaSummaryEnsemble-Test.cpp and RimSummaryCaseMainCollection-Test.cpp each defined an identical createMockCase() in an anonymous namespace. A unity build concatenates the two translation units, which merges the two anonymous namespaces into one and makes the second definition a redefinition, breaking the build with C2084.

Move the factory to RimMockSummaryCase.h, the header both tests already include for the mock case itself, and drop both local copies.
The project is a global object shared by all tests, and the MSW export tests loaded a project without closing it. The two summary cases of that project stayed in the summary case main collection, and made RimSummaryCaseMainCollection.RemoveCases_NoDanglingInCallerVector fail on Windows only. Google Test runs the test suites in link order, and the MSW suite runs before the summary suite on Windows and after it on Linux.

Add a test event listener reporting a failure for the test leaving cases, ensembles, well paths or views behind in the project. The project is closed as well, so the tests running after the offending one are unaffected.
@magnesj magnesj closed this Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant