Skip to content

fix(rapier): don't panic on JNI calls referencing removed physics bodies - #1432

Closed
Bazzirovan wants to merge 2 commits into
ryanhcode:mainfrom
Bazzirovan:fix/rapier-jni-panic-guards
Closed

fix(rapier): don't panic on JNI calls referencing removed physics bodies#1432
Bazzirovan wants to merge 2 commits into
ryanhcode:mainfrom
Bazzirovan:fix/rapier-jni-panic-guards

Conversation

@Bazzirovan

@Bazzirovan Bazzirovan commented Aug 3, 2026

Copy link
Copy Markdown

What happened

I run a dedicated NeoForge 1.21.1 server (Windows) with Sable, and my server kept dying during normal play. Two crash signatures, same underlying cause:

  • ServerHangWatchdog reports where a tick took 60000+ seconds, with the stuck frame in Rapier3D.step(Native Method)
  • NullPointerException on the Java side (e.g. RopePhysicsObject.setFirstSegmentLength) that followed a native abort

Root cause

The Rust JNI bridge panics when a call references a physics object that is already gone: rigid_bodies[&id], .expect("No rigid body for id"), .expect("No kinematic contraption with given ID!"), and a few .unwrap()s on removed collider info.

A panic in Rust that crosses the JNI boundary doesn't unwind cleanly — it kills the whole JVM. On my server this triggered whenever a sublevel unloaded (or unloaded and re-created quickly) while a JNI call still referenced a stale ID. The most common trigger in practice was rope endpoints: a rope anchored to a sublevel body whose collider had already been removed from the scene.

Fix

Replaced the panicking lookups with Option guards, keeping the existing behavior for everything that resolves correctly:

  • get_rigid_body / get_rigid_body_mut: return Option instead of expect()
  • getPose: fall back to the identity pose if the body is missing
  • removeSubLevel / removeBox / removeKinematicContraption: no-op when the entry is missing
  • setMassProperties, teleportObject, wakeUpObject, velocity/force applicators: no-op when the body is missing
  • createKinematicContraption: drop the stale mount reference instead of panicking
  • add*Constraint: return an invalid handle (-1) when an endpoint is missing. On the Java side isConstraintValid(-1) resolves to false, assertValid() guards mutation, and the pipeline turns it into a failed constraint creation rather than a crash. The sentinel was chosen as -1 (not 0) because the first valid impulse joint naturally receives handle 0 (index 0, generation 0), making 0 ambiguous as an error value; -1 is impossible as a native handle
  • rope::tick: drop rope attachments whose endpoint collider is no longer registered, same as the existing handling for joints that vanish
  • rope JNI entry points (queryRope, removeRope, setRopeFirstSegmentLength, removeRopePointAtStart, addRopePointAtStart, wakeUpRope, setRopeAttachment): no-op when the rope id is stale or the attachment's sub-level body is gone, instead of unwrapping and aborting the JVM
  • setCenterOfMass, setLocalBounds, addChunk (sub-level collider upload): no-op when the level collider with that id has already been removed from the scene (race: SubLevelPhysicsSystem.onStatsChanged ticking after the collider was unloaded)

The -1 ground-body special case in the constraint functions is untouched.

Verification

Rebuilt the Windows natives with these changes and ran the result on the live server. Before the patch: repeated RopeHandle NPE crashes and watchdog freezes. After: several days of uptime with ropes, sublevel ships and constraints in regular use, no crashes. I also built on the Rust side to make sure nothing else breaks (cargo build clean).

Scope note: I only touched the paths that actually crashed on my server — the dispatcher/contact-event code that indexes colliders during a step is left alone since those colliders are still alive while the step runs.

Files changed:

  • sable_rapier/src/main/rust/rapier/src/lib.rs
  • sable_rapier/src/main/rust/rapier/src/contraptions.rs
  • sable_rapier/src/main/rust/rapier/src/boxes.rs
  • sable_rapier/src/main/rust/rapier/src/joints.rs
  • sable_rapier/src/main/rust/rapier/src/rope.rs

Replace expect()/index panics in the rapier JNI bridge with Option guards.
A Rust panic crossing the JNI boundary aborts the whole JVM, which on a
dedicated server manifests as instant shutdowns or ServerHangWatchdog
freezes whenever a rigid body, kinematic contraption or joint endpoint
was already removed (e.g. sublevel unload races) but is still referenced
by a pending JNI call.

- get_rigid_body(_mut): return Option instead of expect()
- getPose: fall back to identity pose instead of panicking
- removeSubLevel/removeBox/removeKinematicContraption: no-op if missing
- setMassProperties/teleport/wakeUp/velocities/forces: no-op if missing
- createKinematicContraption: drop mount reference if mount id is stale
- add*Constraint: return invalid handle (-1) if an endpoint is missing
- rope tick: drop rope attachments whose endpoint collider is gone

The invalid handle was changed from 0 to -1 because the first valid
impulse joint naturally receives handle 0 (index 0, generation 0),
making 0 ambiguous as a failure sentinel.  -1 is impossible as a
native handle (requires an index of all-ones) and the Java side
already guards it via assertValid / isConstraintValid checks.

Verified on a live dedicated server (Windows): rope/sublevel crashes and
native step hangs are gone after rebuilding the natives with this patch.
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


Bazzirovan seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@Bazzirovan
Bazzirovan force-pushed the fix/rapier-jni-panic-guards branch from 2501d98 to 11c9684 Compare August 3, 2026 18:47
@ryanhcode

Copy link
Copy Markdown
Owner

I'm not a fan of lots of stuff here, like falling back to the identity pose if the body is missing, or just blindly covering up any places where we're still referencing removed bodies. Also not fond of AI generated contributions.

What is the actual crash you were encountering? I'd like to fix the callsite that is keeping hold of removed bodies rather than covering this up in the natives.

@ryanhcode ryanhcode self-assigned this Aug 3, 2026
@Bazzirovan
Bazzirovan force-pushed the fix/rapier-jni-panic-guards branch 2 times, most recently from 688be31 to b10ec4c Compare August 3, 2026 21:08
setCenterOfMass, setLocalBounds and addChunk could panic (Option::unwrap on a None value) when the Java side still held a stale sub-level id after the level collider had already been removed from the physics scene, aborting the whole JVM (non-unwinding panic). Replace the unwraps with early-return no-ops, matching the guards used for ropes and joints.
@Bazzirovan Bazzirovan closed this Aug 3, 2026
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.

3 participants