Make the attack link symmetric, following spring-attachment semantics - #422
Make the attack link symmetric, following spring-attachment semantics#422drbergman wants to merge 6 commits into
Conversation
phenotype.cell_interactions.pAttackTarget is the only long-lived Cell* in the
object model with no owner and no mirror. delete_cell scrubs attached_cells,
spring_attachments and state.neighbors, but pAttackTarget was introduced after
that audit and was never added to it.
standard_cell_cell_interactions does release an attack when it sees
pTarget->phenotype.death.dead, so an attacker normally detaches long before its
target is removed. That test cannot fire when the target is freed with no
intervening mechanics step, which happens whenever a death phase exits on its
first advance_cycle: Cell::advance_bundled_phenotype_functions calls
check_for_death and then advance_cycle in the same invocation, so a cell can go
alive -> dead -> flagged_for_removal -> freed inside one phenotype update. The
attacker then reads, and writes through, freed memory on its next step.
ingest_cell and fuse_cell of a live cell, and direct delete_cell/die() from user
code, reach the same state by other routes.
Give Cell_State an attacked_by list mirroring pAttackTarget, maintained the way
the two halves of a spring attachment are:
- add_attacker / remove_attacker maintain one half
- remove_all_attackers / remove_self_from_attacked tear down both
- begin_attack / end_attack keep pAttackTarget, attacked_by and the spring in
lockstep, mirroring attach_cells_as_spring / detach_cells_as_spring
The list lives in state rather than phenotype so that it survives
convert_to_cell_definition, which replaces phenotype wholesale, and is not
copied to daughters by divide.
Teardown is hooked at every site that already calls
remove_all_spring_attachments: ~Cell, divide, both delete_cell variants,
ingest_cell, fuse_cell and lyse_cell. lyse_cell was additionally missing
remove_all_spring_attachments and set flag_for_removal before dead = true,
unlike ingest_cell and fuse_cell; both brought into line.
Two cells can attack each other and attach_cell_as_spring dedupes, so a mutual
attack shares one spring. end_attack now drops it only once neither direction
needs it.
The MultiCellDS resume path restores both halves of the link.
Adding a member to Cell_State changes sizeof(Cell), and the Makefiles declare no
header dependencies, so a clean build is required after taking this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR makes cell “attack” relationships symmetric and teardown-safe by introducing a reverse index (Cell_State::attacked_by) that mirrors phenotype.cell_interactions.pAttackTarget, and by centralizing attack start/stop logic to keep pointer, reverse list, and spring attachment consistent.
Changes:
- Add
Cell_State::attacked_byplus new APIs (add_attacker,remove_attacker,remove_all_attackers,remove_self_from_attacked) and free functions (begin_attack,end_attack) to manage the bidirectional attack link. - Update
standard_cell_cell_interactionsto usebegin_attack/end_attackinstead of directly mutatingpAttackTargetand spring attachments. - Hook attack teardown into major lifecycle/teardown paths (destructor, divide, delete variants, ingest/fuse/lyse, transformation) and restore attack links on MultiCellDS resume.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| modules/PhysiCell_MultiCellDS.cpp | Restores attack relationships when recreating simulation state. |
| core/PhysiCell_standard_models.cpp | Switches attack start/end to centralized begin/end helpers. |
| core/PhysiCell_cell.h | Adds new state (attacked_by) and declares new attack management APIs. |
| core/PhysiCell_cell.cpp | Implements bidirectional attack bookkeeping and adds teardown calls across cell lifecycle events. |
Suppressed comments (2)
core/PhysiCell_cell.cpp:3537
- remove_self_from_attacked() reads pTarget->phenotype.cell_interactions.pAttackTarget to decide whether to detach the shared spring. During standard_cell_cell_interactions(), pTarget's pAttackTarget can be modified concurrently by another thread, so this cross-thread read introduces a data race.
pTarget->remove_attacker( this );
// mutual attackers share one spring, so only drop it once both are done
if( pTarget->phenotype.cell_interactions.pAttackTarget != this )
{ detach_cells_as_spring( this , pTarget ); }
core/PhysiCell_cell.cpp:3599
- end_attack() reads pTarget->phenotype.cell_interactions.pAttackTarget to decide whether to detach the shared spring. Since standard_cell_cell_interactions() runs in a parallel loop and pTarget can concurrently start/end an attack, this read can race with a write on another thread.
pTarget->remove_attacker( pAttacker );
// two cells can attack each other, and attach_cell_as_spring dedupes, so the
// two attacks share ONE spring. Only drop it once nobody still needs it.
if( pTarget->phenotype.cell_interactions.pAttackTarget != pAttacker )
{ detach_cells_as_spring( pAttacker , pTarget ); }
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Two issues raised by Copilot on MathCancer#422. remove_all_attackers() walked state.attacked_by with no synchronisation while add_attacker / remove_attacker mutate it under an omp critical. It is reachable from ingest_cell / fuse_cell inside the parallel interactions loop, so a concurrent push_back could reallocate the vector mid-iteration. It cannot simply take the critical for the duration of the loop: detach_cells_as_spring() takes the same unnamed critical, and OpenMP criticals are not reentrant, so that would deadlock. Instead swap the list out under the lock and walk the local copy. The same review flagged the mutual-attack check in end_attack and remove_self_from_attacked, which read another cell's pAttackTarget with no synchronisation. Ask the equivalent question of our own attacked_by list instead, under the lock, via the new Cell::is_attacked_by(). "pTarget attacks me" and "pTarget is in my attacked_by" are the same statement given the invariant this change maintains. The MultiCellDS resume path restored pAttackTarget and attacked_by but not the attack spring, leaving a resumed run with an attack and no spring behind it -- which is exactly the inconsistency the rest of this change exists to prevent. Use begin_attack() so all three records are restored together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
core/PhysiCell_cell.cpp:3552
- remove_self_from_attacked() clears this cell’s pAttackTarget and then calls into the target (remove_attacker / detach) without any synchronization on pAttackTarget. This function is invoked on cells other than the current OpenMP iteration owner (e.g., ingest_cell()/fuse_cell() call pCell_to_eat->remove_self_from_attacked() inside the parallel standard_cell_cell_interactions loop), so this becomes a cross-thread write to pAttackTarget and can race with that cell’s own reads of pAttackTarget during its update.
To avoid undefined behavior, pAttackTarget needs consistent synchronization for both readers and writers (e.g., a dedicated named omp critical guarding pAttackTarget access, or an atomic/handle-based design).
Cell* pTarget = phenotype.cell_interactions.pAttackTarget;
if( pTarget == NULL )
{ return; }
phenotype.cell_interactions.pAttackTarget = NULL;
pTarget->remove_attacker( this );
"make sure ot remove adhesions" -> "to". Pre-existing, but it sits in the hunk this branch touches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…#59) * Make the attack link symmetric, following spring-attachment semantics phenotype.cell_interactions.pAttackTarget is the only long-lived Cell* in the object model with no owner and no mirror. delete_cell scrubs attached_cells, spring_attachments and state.neighbors, but pAttackTarget was never added to that list, so a target that dies while being attacked leaves its attacker holding a freed pointer. This makes the attack link look like a spring attachment: two halves, maintained together, torn down wherever springs are torn down. Cell_State gains attacked_by as the mirror of pAttackTarget; add_attacker / remove_attacker maintain one half and remove_all_attackers / remove_self_from_attacked tear down both; the free functions begin_attack / end_attack keep the attacker's pointer, the target's list and the spring in lockstep. Teardown is hooked at every site that already calls remove_all_spring_attachments(). Mirror of MathCancer#422 for my-physicell. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Fix a typo in an adjacent comment "make sure ot remove adhesions" -> "to". Pre-existing, but it sits in the hunk this branch touches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The block tested phenotype.cell_interactions.pAttackTarget for NULL and then loaded it again into a local. Those are two separate loads of a field another thread can clear: remove_all_attackers() nulls an attacker's pAttackTarget when its target is eaten, fused or lysed, and that runs from standard_cell_cell_interactions inside the mechanics parallel-for. If the clear lands between the test and the reload, the local is NULL and attack_cell() dereferences it -- it only guards against attacking itself. Take one snapshot and use it throughout, so the block either sees a target it can safely act on or sees NULL and does nothing. This narrows rather than removes the underlying data race: the field is a plain Cell* read and written across threads. Making that formally race-free needs either the read under the same critical as the write, which serialises the mechanics hot path, or std::atomic<Cell*>, which changes the type of a public field. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ingest_cell, fuse_cell and lyse_cell each flag their victim for removal and then immediately tear down its attachments, springs and attack links. All three run from standard_cell_cell_interactions, inside the mechanics parallel-for, so that teardown mutates other cells' state from a thread that does not own them. That is a real race, not a theoretical one. remove_all_spring_attachments walks state.spring_attachments by index with no lock, while attach_cell_as_spring and detach_cell_as_spring edit that same vector under the unnamed critical. detach is swap-and-pop: if the writer removes an entry below the walker's cursor, the tail element is moved into a slot the walker has already passed and never gets detached, leaving a neighbour holding a pointer to a cell that is about to be freed. A push_back that reallocates is worse. Instrumenting the walk to publish which cell each thread is tearing down, and checking for a collision in the locked mutators, finds this on stock interaction-sample at 8 threads in 3 of 6 seeds -- and on pristine development, so it predates the attack link entirely. The work was already redundant: flag_for_removal() queues the cell, and the serial cells_ready_to_die -> die() -> delete_cell() pass does the same four steps. Dropping the in-loop copies removes every unlocked walk from a parallel region; what remains there are locked mutations, which are mutually serialised. Teardowns walked halves, from ~1500 to ~900. Verified: 0 collisions in 6 of 6 seeds, and single-threaded output is bit-identical -- no cells.mat or microenvironment file differs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The snapshot comment named remove_all_attackers() running from ingest_cell/fuse_cell/lyse_cell as the thing that could clear the field mid-block. Deferring teardown to the serial removal pass removed that path, so the comment described a mechanism no longer in the tree. State what is actually true, and why the single load is kept anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
163d7c1 to
649dc13
Compare
The block tested phenotype.cell_interactions.pAttackTarget for NULL and then loaded it again into a local. Those are two separate loads of a field another thread can clear: remove_all_attackers() nulls an attacker's pAttackTarget when its target is eaten, fused or lysed, and that runs from standard_cell_cell_interactions inside the mechanics parallel-for. If the clear lands between the test and the reload, the local is NULL and attack_cell() dereferences it -- it only guards against attacking itself. Take one snapshot and use it throughout. Follow-up to #59, which introduced the cross-thread write. Mirror of MathCancer#422's 6c79881. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The block tested phenotype.cell_interactions.pAttackTarget for NULL and then loaded it again into a local. Those are two separate loads of a field another thread can clear: remove_all_attackers() nulls an attacker's pAttackTarget when its target is eaten, fused or lysed, and that runs from standard_cell_cell_interactions inside the mechanics parallel-for. If the clear lands between the test and the reload, the local is NULL and attack_cell() dereferences it -- it only guards against attacking itself. Take one snapshot and use it throughout. Follow-up to #59, which introduced the cross-thread write. Mirror of MathCancer#422's 6c79881. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
ingest_cell, fuse_cell and lyse_cell each flag their victim for removal and then immediately tear down its attachments, springs and attack links, from inside the mechanics parallel-for. That mutates other cells' state from a thread that does not own them. remove_all_spring_attachments walks state.spring_attachments by index with no lock, while attach_cell_as_spring and detach_cell_as_spring edit that same vector under the unnamed critical. detach is swap-and-pop, so a removal below the walker's cursor moves the tail element into a slot already passed and it never gets detached -- leaving a neighbour holding a pointer to a cell about to be freed. The work was already redundant: flag_for_removal() queues the cell and the serial cells_ready_to_die -> die() -> delete_cell() pass does the same steps. Dropping the in-loop copies removes every unlocked walk from a parallel region. Mirror of the same commit on MathCancer#422. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Summary
This builds directly on @vincent-noel's #397, which diagnosed this bug and got the shape of the fix right. His write-up names the three root causes precisely, and the reverse-index idea here is his — a target that knows who is attacking it can clean up in O(#attackers) instead of scanning every cell. I've kept that idea and changed where the list lives and how consistently it's maintained.
The underlying problem, restated:
phenotype.cell_interactions.pAttackTargetis the only long-livedCell*in the object model with no owner and no mirror.delete_cellscrubsattached_cells,spring_attachmentsandstate.neighbors— the Dec 2024 audit in #342 — butpAttackTargetwas introduced five months earlier and was never added to that list. Grepcore/forpAttackTarget: zero hits inPhysiCell_cell.cpp.The approach here is to make the attack link look exactly like a spring attachment: two halves, maintained together, torn down wherever springs are torn down.
What this changes
Cell_Stategainsstd::vector<Cell*> attacked_by, the mirror ofpAttackTarget.add_attacker/remove_attackermaintain one half, exactly asattach_cell_as_spring/detach_cell_as_springdo.remove_all_attackers/remove_self_from_attackedtear down both.begin_attack/end_attack, mirroringattach_cells_as_spring/detach_cells_as_spring. They keep all three records — the attacker'spAttackTarget, the target'sattacked_by, and the spring — in lockstep, so a caller cannot set one without the others.~Cell,divide, bothdelete_cellvariants, andconvert_to_cell_definition.ingest_cell,fuse_cellandlyse_cellno longer tear down inline. They already callflag_for_removal(), and the serialcells_ready_to_die->die()->delete_cell()pass does the same work off-thread. Those three run inside the mechanics parallel-for, andremove_all_spring_attachments()/remove_all_attached_cells()walk their vector unlocked whileattach_cell_as_spring/detach_cell_as_springedit it under the critical -- a race that predates this PR and is measurable on pristinedevelopment(3 of 6 seeds on stockinteraction-sampleat 8 threads; 0 of 6 once deferred, with bit-identical single-threaded output).standard_cell_cell_interactionsnow callsbegin_attack/end_attackinstead of writingpAttackTargetinline. The guard that stops a cell looking for a new target while already attacking is unchanged and still upstream of the call.How it differs from #397
Stated as differences, not defects — several of these are cases #397 simply didn't set out to cover, and some only became visible from running it.
1. The reverse list lives in
Cell_State, notCell_Interactions.This is the substantive difference, and it removes two failure modes structurally rather than with extra code:
convert_to_cell_definitiondoesphenotype = cd.phenotype. A phenotype-residentattacked_byis wiped by that assignment while the attackers keep livepAttackTargets, reopening the original dangling pointer.stateis explicitly left untouched by transformation (there's a comment to that effect at the end of the function), so a state-resident list survives.Cell::dividedoeschild->phenotype = phenotype. That is a value copy, so a phenotype-residentattacked_bygives the daughter its own vector holding the sameCell*values — valid pointers to the cells attacking the parent. It bites becauseremove_self_from_attackers()clears unconditionally:with no
pCell->...pAttackTarget == thistest, so deleting the daughter aborts every attack aimed at the parent. Adding that test would defuse it (the version here has one). Keeping the list instateavoids the situation entirely:copy_datadoesn't touchstateand nothing else assignschild->state, so the daughter's list is default-constructed empty.2. Both directions are maintained.
#397 has
remove_self_from_attackers()— target deleted, clear the attackers. This adds the inverse,remove_self_from_attacked()— attacker deleted, deregister from its target. Without it, deleting an attacker leaves a stale pointer in the victim'sattacked_by, and the laterremove_self_from_attackers()executespCell->...pAttackTarget = NULLthrough that freed pointer — a write, where the current bug is a read.There's a guaranteed trigger rather than a hypothetical one: an attacker that enters a death model can never run its own end-of-attack erase, because
standard_cell_cell_interactionsreturns early for dead callers. That's #395.3. Hooked at every teardown site, not only
delete_cell(int).~Cell()'s salvage path,divide,delete_cell_originalandconvert_to_cell_definitionall need it too. #342 added the neighbour scrub to bothdelete_celland~Cell(); this follows the same convention.ingest_cell,fuse_cellandlyse_cellare covered through the removal queue rather than inline, for the thread-safety reason above.4. Paired begin/end functions rather than inline maintenance.
#397 maintains
attacked_byinline at the two call sites instandard_cell_cell_interactions. Bundling the three records intobegin_attack/end_attackmeans a future call site can't set the pointer and forget the list, and it matches how springs are already handled.5. Mutual attacks share one spring.
Found by running it, not by reading.
attach_cell_as_springdedupes, so when A and B attack each other there is a single spring between them. Ending either attack removes it, stranding the other.end_attackandremove_self_from_attackednow only drop the spring once neither direction still needs it. Currentdevelopmenthas this too — it unconditionally callsdetach_cells_as_springat attack end.6. Side effects on adjacent issues.
stateplacement plus thedivideteardown.convert_to_cell_definitionhook. That PR is still the clearer statement of the transformation problem and I'd rather it land on its own merits; this just makes the teardown unconditional.lyse_cellsetflag_for_removal()beforedead = true, unlikeingest_cellandfuse_cell. Brought into line. Its missing spring teardown is now moot, since all three defer to the removal queue.What this deliberately does not do
add_potentialsis symmetric by construction — the sum of both cells' reaches — so asymmetry comes from bookkeeping (theis_movable/is_out_of_domaingate on the refresh, and stalemax_cell_interactive_distance_in_voxel), not from geometry.attack_cellwrite.attack_cellwritesdamageandtotal_attack_timethrough the target pointer with no liveness check. That's safe once the pointer can't dangle, but it's still an unguarded write.begin_attackcan land between A'sis_attacked_bycheck and itsdetach_cells_as_spring— B finds the pair already spring-linked so adds nothing, then A tears the spring down and B is left attacking A without one. Closing it needs the check and the detach under a single lock, which the non-reentrant unnamed critical makes awkward; note the unconditional detach ondevelopmentloses that spring in every mutual attack, threads or not.pAttackTargetstill bypasses the invariant. Custom modules that write the field directly get no protection. A dedicatedAttacktype owning both endpoints would close that off properly, and would also remove the special case indynamic_spring_attachmentsthat has to sniffpAttackTargetto avoid randomly detaching an attack spring. That's a bigger change and a separate conversation.Related
Cycle_Modelfixed_duration; independent bug, but it is what makes the zero-mechanics-step removal reachable from configs that asked for a fixed durationHappy to fold this into #397 as commits there, or co-author, or close this and let Vincent take whichever parts are useful — the diagnosis was his and I don't want to fragment the work.