Skip to content

Spring attachments: pair attach/detach is not atomic, and .size() is read outside the lock #431

Description

@drbergman

Two data races remain in the spring-attachment code after #428. Neither can crash — #428 removed the crashing dereference — but both are reachable in any model with a non-zero detachment_rate, and one of them produces physically wrong results silently.

Line numbers are against development at 39be5bd3.


1. Pair attach/detach is not atomic, producing one-way springs

attach_cells_as_spring() and detach_cells_as_spring() each take the unnamed critical twice — once per cell — rather than once for the pair (core/PhysiCell_cell.cpp:3579 and :3593):

void attach_cells_as_spring( Cell* pCell_1, Cell* pCell_2 )
{
	pCell_1->attach_cell_as_spring( pCell_2 );   // critical #1
	pCell_2->attach_cell_as_spring( pCell_1 );   // critical #2
	return; 
}

The lock lives inside Cell::attach_cell_as_spring() (:3376) and Cell::detach_cell_as_spring() (:3417), so another thread can run to completion between the two halves. Both functions are called from dynamic_spring_attachments(), which runs inside the mechanics parallel-for (core/PhysiCell_cell_container.cpp:226), so two threads can be attaching and detaching the same pair concurrently:

T1: A.attach(B)        -> A has B
T2: A.detach(B)        -> A loses B
T2: B.detach(A)        -> not found, no-op
T1: B.attach(A)        -> B has A, A does not have B

The result is a one-way spring: B lists A, A does not list B.

Why this is not cosmetic

standard_elastic_contact_function() applies force to pC1 only (core/PhysiCell_standard_models.cpp:997):

axpy( &(pC1->velocity) , effective_attachment_elastic_constant , displacement ); 

and the force loop iterates each cell's own list (PhysiCell_cell_container.cpp:237). Symmetry of the spring force therefore comes entirely from both cells holding each other. A one-way spring means one cell accelerates toward the other while the other feels nothing — Newton's third law is violated and momentum is not conserved for that pair.

Observed

I added a symmetry audit after the spring pass, counting attachments where B ∈ A.spring_attachments but A ∉ B.spring_attachments, and ran it against a build with #428 applied:

  • A model with detachment_rate = 0 for all cell types: 0 one-way springs across full runs.
  • The same model with detachment_rate = 0.5: 964 and 933 one-way springs in two runs, present in ~12% of mechanics steps, up to 3 at once, each persisting ~10 consecutive steps before self-healing (they clear when the holding cell next detaches).

The detector was validated with a positive control — deliberately removing the second half of attach_cells_as_spring() made it fire on essentially every step (7201 hits), confirming it can see asymmetry when asymmetry exists.

So the failure is real but bounded: transient, low-count, and impossible in models that never detach.

Possible fix

Make the pair operation atomic — one critical section covering both halves, rather than one per cell. That requires care: Cell::attach_cell_as_spring() / detach_cell_as_spring() take the critical themselves and OpenMP criticals are not reentrant, so the lock cannot simply be hoisted around the existing calls. Splitting each into a locked public entry point and an unlocked internal helper would work.


2. .size() is read outside the lock in the attachment half

dynamic_spring_attachments() reads spring-attachment sizes with no lock in three places, while other threads may be inside push_back() / pop_back() on those same vectors:

  • core/PhysiCell_standard_models.cpp:1440pCell->state.spring_attachments.size()
  • :1450pTest->state.spring_attachments.size(), i.e. another cell's vector
  • :1461pCell->state.spring_attachments.size() again
    // check if I have max number of attachments 
    if( pCell->state.spring_attachments.size() >= phenotype.mechanics.maximum_number_of_attachments )
    { return; }

This is a data race in the formal sense. On libstdc++/libc++ vector::size() is end_ - begin_, two pointer loads that a concurrent reallocation updates non-atomically, so a torn read can yield a nonsense length. It cannot crash here — the values are only compared numerically, never used to index — but the comparison can be made against a garbage size. #428 made this function crash-free; it did not make it race-free.

Related: maximum_number_of_attachments is advisory

Line 1450 checks the target's remaining capacity, then attach_cells_as_spring() is called at :1460. Cell::attach_cell_as_spring() re-checks only for duplicates inside the critical (PhysiCell_cell.cpp:3380-3387), never the cap. The check and the mutation are therefore not atomic, and a cell can be pushed past maximum_number_of_attachments by concurrent attachers.

I looked for this specifically and saw zero cap violations in the runs above, so it is unconfirmed in practice — the test model may simply never approach its cap. Flagging it as a latent consequence of the same non-atomicity rather than an observed bug.

Possible fix

Move the capacity test inside Cell::attach_cell_as_spring(), alongside the existing duplicate check, so the decision and the push_back happen under one lock. That subsumes the racy reads at :1450 and :1461, and makes the cap an invariant rather than a hint.


Notes

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions