Skip to content

Fix delete-while-iterating in delta ensemble case removal - #14518

Merged
magnesj merged 6 commits into
OPM:devfrom
magnesj:lifetime-delta-ensemble-batch
Aug 14, 2026
Merged

Fix delete-while-iterating in delta ensemble case removal#14518
magnesj merged 6 commits into
OPM:devfrom
magnesj:lifetime-delta-ensemble-batch

Conversation

@magnesj

@magnesj magnesj commented Aug 13, 2026

Copy link
Copy Markdown
Member

Fixes #14517.

Removes the delete-while-iterating hazard behind the crash in #14423, rather than working around it at one call site as #14424 does. Three commits, each independently testable.

Detach never destroys

RimDeltaSummaryEnsemble deleted its derived cases inline, from inside a loop the caller was still walking. rebuildDerivedCases() replaces the m_inUse pooling: it diffs the derived cases against the desired source pairs, keyed on the (case1, case2) pointer pair, reuses matches, creates what is missing, and returns the surplus instead of deleting it. Keying on the pointer pair makes the rebuild idempotent, including right after project load where derived cases arrive from XML with their sources already resolved.

The m_inUse flag conflated pool bookkeeping with owning the source references and the value cache. It also hid the not-in-use cases from allSummaryCases(), which is precisely why the dangling objects were invisible to the rest of the project. setInUse, isInUse, m_inUse, setAllCasesNotInUse, firstCaseNotInUse and deleteCasesNoInUse are gone. Old project files still load, unknown XML keywords are skipped.

Destruction happens at a batch flush

RimSummaryCaseUpdateBatch is a plain scope object, ambient for its scope. Detached cases are handed to it and destroyed when the outermost scope ends, after the dirty delta ensembles have regenerated in dependency order, so a chained delta ensemble sees the final state of its source before anything is freed. A nested batch contributes to the outermost one and never flushes. Orphans are held as caf::PdmPointer, so a case the caller destroyed itself is skipped rather than destroyed twice. With no batch active both contribution points fall back to immediate execution, so call sites that do not open one keep behaving as before.

RicCloseSummaryCaseFeature::deleteSummaryCases opens the outermost batch. It has to be there rather than in removeCases, because it calls caf::PdmObjectHandleTools::deleteObjects( cases ) after removeCases returns — a batch ending with removeCases would still free the orphans while that list refers to them. removeCases opens one of its own for callers that do not, no longer rewrites the caller's list, and takes it by const reference.

Dependency traversal by PDM back-reference

findReferringEnsembles() scanned summaryEnsembles() and recursed with no visited set, so a dependency cycle ran forever, and an ensemble used as both source 1 and source 2 was reported twice. Replaced by dependentDeltaEnsembles(), deltaEnsemblesInUpdateOrder() and wouldCreateDependencyCycle() in RimSummaryEnsembleTools — deduplicated, iterative, topologically ordered, back edges logged rather than traversed. Back references also reach a delta ensemble detached from the project tree, which the ancestor scan did not.

Relation to #14424

The first commit here is a cherry-pick of e16238f331 from #14424, so the regression test it introduced is present; the last commit removes the band-aid itself. The net diff against dev therefore keeps the test and drops the caf::PdmPointer workaround. #14424 can be closed in favour of this. If it merges first instead, the cherry-picked commit drops out on rebase and nothing else changes.

Verification

1033 unit tests pass, from 1028 on the base. RemoveCases_NoDanglingInCallerVector asserts that every entry the caller handed over is still alive inside the batch scope and destroyed exactly once after it exits. Removing just the batch line from that test reproduces the original signature: cases.size() 6 against aliveCount() 4, followed by SEH exception with code 0xc0000005. New tests also cover dependency ordering over chained delta ensembles, cycle termination, back-reference deduplication, rebuild idempotency, and deferred destruction with and without batch nesting.

Known follow-up, not in this PR

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(). It terminates only because names converge, and auto-generated names never converge 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. Cycle prevention in setEnsemble1()/setEnsemble2() is the fix.

Staged plan and the remaining stages: magnesj#1031.

…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.
@magnesj
magnesj requested a review from kriben August 13, 2026 13:35
@magnesj magnesj self-assigned this Aug 13, 2026
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.

@kriben kriben left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@magnesj magnesj changed the title Remove the delete-while-iterating hazard in delta ensemble case removal Fix delete-while-iterating in delta ensemble case removal Aug 14, 2026
@magnesj
magnesj merged commit 0af06ad into OPM:dev Aug 14, 2026
10 checks passed
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.

Remove the delete-while-iterating hazard in delta ensemble case removal

2 participants