Skip to content

sync_removals: skip removal events when a fresh handle is live#691

Open
svaterlaus wants to merge 2 commits into
dimforge:masterfrom
svaterlaus:fix/sync-removals-context-migration
Open

sync_removals: skip removal events when a fresh handle is live#691
svaterlaus wants to merge 2 commits into
dimforge:masterfrom
svaterlaus:fix/sync-removals-context-migration

Conversation

@svaterlaus
Copy link
Copy Markdown

Summary
Fix sync_removals deleting a freshly-reinserted Rapier body/collider/joint when an entity is migrated between RapierContextSimulations.

Background
When RapierPhysicsPlugin runs in a non-PostUpdate schedule (e.g. .in_fixed_schedule()), sync_removals is registered twice:

  • once inside PhysicsSet::SyncBackend in the configured schedule, and
  • once in PostUpdate as a safety net for events that would otherwise be missed (see the comment above the second registration in plugin.rs).

Each registration owns an independent RemovedComponents cursor, so both copies see the same removal event.

The bug
A common cross-context migration pattern is to atomically swap an entity's RapierContextEntityLink and remove its RapierRigidBodyHandle / RapierColliderHandle. Between the two sync_removals runs, init_rigid_bodies / init_colliders correctly create a fresh body/collider in the new context's entity2body / entity2collider. The PostUpdate copy then re-reads the original removal event, walks all contexts via find_context, finds the entity in the new context's map, and deletes the freshly-inserted instance — leaving the entity with a live handle component but no body/collider in any rapier set, so it stops being integrated.

Fix
Add a live_handles query and short-circuit each removal-event loop when the entity still has the corresponding handle component. A live handle means a fresh instance was inserted after the original removal (init_rigid_bodies / init_colliders / init_joints), and that instance must not be deleted by the stale event. The orphan-handling paths and the standard "user removed handle and meant it" path are unchanged.

Applied to bodies, colliders, impulse joints, and multibody joints for parity.

Test
Adds body_survives_context_migration_via_handle_swap in multiple_rapier_contexts::test. It runs the plugin in .in_fixed_schedule() mode (so both sync_removals registrations are live), spawns a RigidBody::Dynamic body, spawns a second RapierContextSimulation, performs the link-swap + handle-removal pattern, and asserts that after a few app updates the body lives in the new context's entity2body, is gone from the original context, and that the freshly-attached RapierRigidBodyHandle matches the new context's mapping.

Confirmed the test fails with the expected assertion message when the fix is reverted. Full bevy_rapier2d and bevy_rapier3d --lib suites pass on the branch.

svaterlaus added 2 commits May 7, 2026 11:53
When `RapierPhysicsPlugin` runs in a non-`PostUpdate` schedule (e.g.
`.in_fixed_schedule()`), `sync_removals` is registered twice — once in
`PhysicsSet::SyncBackend` and once as a `PostUpdate` safety net for
events that would otherwise be missed. Each registration maintains an
independent `RemovedComponents` cursor and re-processes the same
removal events.

This breaks cross-context migration. When a system removes
`RapierRigidBodyHandle` and swaps `RapierContextEntityLink`,
`init_rigid_bodies` correctly creates a fresh body in the new context's
`entity2body` between the two `sync_removals` runs. The PostUpdate copy
then re-reads the original removal event, walks all contexts via
`find_context`, finds the entity in the *new* context's `entity2body`,
and deletes the freshly-inserted body — leaving the entity with a live
`RapierRigidBodyHandle` component but no body in any rapier set, so it
is no longer integrated.

Add a `live_handles` query and short-circuit each removal-event loop
when the entity still has the corresponding handle component. A live
handle means a fresh instance was inserted after the original removal
(`init_rigid_bodies` / `init_colliders` / `init_joints`), and that
instance must not be deleted by the stale event. The orphan-handling
paths and the standard "user removed handle and meant it" path are
unchanged.

Applied to bodies, colliders, impulse joints, and multibody joints for
parity.
Add `body_survives_context_migration_via_handle_swap` to the
`multiple_rapier_contexts::test` module.

The test runs `RapierPhysicsPlugin::<NoUserData>::default().in_fixed_schedule()`
so that `sync_removals` is registered both inside `PhysicsSet::SyncBackend`
and as the `PostUpdate` safety net. It spawns a `RigidBody::Dynamic` body
in the default context, spawns a second `RapierContextSimulation`, and
performs the context-migration pattern: insert a new
`RapierContextEntityLink` and remove the entity's `RapierRigidBodyHandle`
and `RapierColliderHandle` in the same step. After a few app updates it
asserts the body is registered in the new context's `entity2body`,
absent from the old context, and that the freshly-attached
`RapierRigidBodyHandle` matches the new context's mapping.

Without the live-handle guard in `sync_removals`, the PostUpdate copy
re-processes the same removal event with its own cursor, walks all
contexts via `find_context`, and deletes the body that
`init_rigid_bodies` just inserted in the new context — failing the
"body must live in the new context after migration" assertion.
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