Skip to content

Fix uninitialized and leaked libRoadrunner instance handle - #425

Open
drbergman wants to merge 3 commits into
MathCancer:developmentfrom
drbergman:fix-librr-uninitialized-handle
Open

Fix uninitialized and leaked libRoadrunner instance handle#425
drbergman wants to merge 3 commits into
MathCancer:developmentfrom
drbergman:fix-librr-uninitialized-handle

Conversation

@drbergman

@drbergman drbergman commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Fix uninitialized and leaked libRoadrunner instance handle

RoadRunnerIntracellular::rrHandle is declared with no initializer, clone() returns a model with no RoadRunner instance, and nothing ever frees one.

A cell that changes type gets a handle that was never created. Only start() creates the instance, and it is called from exactly two places — Cell::divide() and create_cell( Cell_Definition& ). Cell::convert_to_cell_definition() assigns a phenotype and does not, so every cell transformation leaves the cell passing indeterminate memory to the RoadRunner C API.

Nothing is ever freed. freeRRInstance appears nowhere in the tree and the class declares no destructor, so every cell death and every conversion permanently orphans one instance.

The fix

  • clone() calls start(), so it returns a usable model. This matches MaBoSS and dFBA, which both initialize in their own copy constructors — dFBAIntracellular::start() is empty for exactly that reason.
  • A destructor frees rrHandle and result.
  • rrHandle gets an in-class initializer, which covers the pointer-taking constructor too.
  • Copy construction and assignment are deleted; with an owning destructor the implicit ones would double-free.

Because clone() is now self-sufficient, both existing start() calls in core/PhysiCell_cell.cpp are redundant and removed.

Verification

The stock ode_energy sample exercises neither bug — it has no cell death and no type conversion. Extended with a second cell definition carrying the same <intracellular> block, transformations in both directions, and matched birth/death holding ~144 agents:

scenario before after
no conversion, no death 127.9 MB, exit 0 121.4 MB, exit 0
conversions enabled crash 8/8 clean 6/6
death only, 2880 min 530.7 / 578.6 MB 109.1 / 108.8 MB

Also included: unknown SBML species

get_parameter_value() / set_parameter_value() indexed species_result_column_index with operator[], so an unknown species silently resolved to column 0 — reading, or writing, a different species than the caller named. Both now use find() and exit, as validate_SBML_species() already does at setup. See this comment.

Two bugs, both in the lifetime of the RoadRunner instance behind
RoadRunnerIntracellular::rrHandle.

A cell that changes type never gets its model started. Phenotype::operator= deep-copies
the intracellular model through clone(), but clone() returned a model with no RoadRunner
instance -- only start() creates one, and start() was called from exactly two places in
the engine, Cell::divide() and create_cell( Cell_Definition& ).
Cell::convert_to_cell_definition() assigns a phenotype and did not, so every cell
transformation left the cell holding whatever the recycled heap block contained.
rrHandle is declared with no initializer, so that is undefined memory; on glibc the block
comes back with its previous bytes, on Darwin it comes back zeroed.

Nothing was ever freed. freeRRInstance appears nowhere in the tree and the class declared
no destructor, so the implicit one destroyed the STL members and leaked the instance
behind the raw handle. Every cell death and every conversion orphaned one.

Measured with this branch's ode_energy sample extended with a second model-carrying cell
type, transformations in both directions, and matched birth/death holding the population
near 144 agents:

  conversions enabled   before: crash 8/8   after: clean 6/6
                        (5 churn seeds gave 4x SIGSEGV and 1x SIGABRT out of libmalloc)
  death only, 2880 min  before: 530.7/578.6/573.8 MB   after: 105.6/108.6/101.0 MB
  no conversion/death   before: 127.9 MB   after: 121.4 MB   (both exit 0)

The mixed SIGSEGV/SIGABRT pattern matches what an HPC user of this addon reported. Under
lldb the faulting frame is get_parameter_value() with rrHandle == 0x0 and a valid
sbml_filename, inside an OpenMP region.

The fix keeps the instance's whole lifetime inside the addon:

  clone() calls start() on the new model, after the field assignments it already does,
  since start() loads sbml_filename. It therefore hands back a usable model. This matches
  MaBoSS and dFBA, which both re-initialize their engines in their own copy constructors
  -- dFBAIntracellular::start() is empty precisely because of it. Requiring callers to
  call start() afterwards was libRoadrunner's requirement leaking into core.

  A destructor frees rrHandle and result. freeRRInstance and freeRRCData both accept null,
  verified against the shipped library, so no guards are needed.

  rrHandle gets an in-class initializer, which covers the pointer constructor too since it
  has no mem-initializer list.

Because clone() is now self-sufficient, core/PhysiCell_cell.cpp only loses code: both
existing start() calls are redundant and are removed. That also closes the remaining hole
-- the no-argument create_cell() reaches Cell::Cell(), which clones cell_defaults.phenotype
and previously left it unstarted.

The copy operations are deleted, since with an owning destructor the implicit ones would
shallow-copy the handle and double-free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@drbergman
drbergman requested a lite review from Copilot August 7, 2026 12:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@drbergman
drbergman marked this pull request as ready for review August 7, 2026 12:26
drbergman and others added 2 commits August 7, 2026 09:33
Keeps only the part that is not obvious from the code -- that start() has to run
after the field assignments, because it loads sbml_filename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
get_parameter_value() and set_parameter_value() index species_result_column_index
with operator[], the inserting form. An unknown species name silently resolves to
column 0, so a typo -- or a model whose column map was never populated -- reads, and
writes, a different species than the caller named. A read also mutates the map.

Both accessors now look the species up with find() and, on a miss, report and exit.
Exiting rather than warning is deliberate: there is no value to return and nothing
sensible to write, so continuing would feed a fabricated number into the model and
every step after it. validate_SBML_species() already exits for exactly this condition
at setup; it just cannot cover these calls, because it only validates the mappings
declared in the XML and custom code calls the accessors directly with its own strings.

The lookup also moved ahead of getFloatingSpeciesConcentrations(), so a miss no longer
allocates a concentration vector it then discards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@drbergman drbergman Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@rheiland I added these correctness checks in this commit onto PR #425. The rest of this PR should make this obsolete, but before a wrong species name would be given the default value (0) which meant it would do stuff to/with index 0 rather than throw.

@vincent-noel

Copy link
Copy Markdown
Collaborator

It looks good, and I had also noticed this lack of destructor.
However, looking at the test results, it does change something in PhysiBoSS results. I would keep this on hold until I have some time to understand the difference.

@drbergman

drbergman commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, @vincent-noel ! Claude did find something along these lines:
https://drbergman.github.io/physicell_pr_prep_2608/#:~:text=under%20cell%20turnover.-,CONCERNS%20FOUND%20IN%20THE%20DIFF,-CI%20is%20red

Happy to continue working on this with you

@vincent-noel

vincent-noel commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

After having a quick look, and trying to remember my thought process when I initially wrote this:
There are two classes which have intracellular objects: Cell and Cell_Definition.

In Cell_Definition, we won't do actual simulation, so we can have a minimal instantiation (no MaBoSS or RoadRunner object basically).
So I created two functions:

  • clone() do the minimum
  • start() actually finish the instantiation to be ready to simulate

But now that I think about it, no cell definition is created by clone(), only cells. Maybe this was the case back when we had cell definition inheritance ? So maybe it is ok to merge them.

Also, one thing I saw when testing now: Intracellular is cloned twice at cell creation, because phenotype is copied twice

  • First in the Cell constructor, from the default cell definition
  • Then in create_cell(Cell_Definition), from the cell definition in argument

It is already suboptimal, and we could think of fixing this by removing the initialization as default. But in the specific context of the clone() and start() discussion, if start() and clone() are merged now, we would end up calling it twice.

@drbergman

Copy link
Copy Markdown
Collaborator Author

So then as I see it, here's the simplest path forward:

  1. fixing double-clone: pre-existing behavior; suboptimal as it does unnecessary work; fix in separate PR targeted to that. Action: for now, open Issue on it to document
  2. clone(): It is only called in the = operator of Phenotype, meaning we need to work hard to find where it is called; if we call start() within clone(), then worst case, we have cell defs that hold more in memory than we strictly need of them. Action: call start() within dFBA and MaBoSS clone() calls.
  3. overloading =: I'm not a fan of this. It makes it hard to track down where the overload is being used and to identify the side effects baked in to an = call. I'd rather have a constructor built for this or, if we need an instance first before setting values, having a dedicated method. If we make this a function pointer, we could even have the Cell_Definition class override this to a state where it does the limited clone() and not the full start(). Action: None. Feel free to critique this idea.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants