From 0c0544653cef95062c14a9c5538c41937ee3e095 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Thu, 12 Nov 2026 03:00:19 -0700 Subject: [PATCH] fix(physics): a disposed game's rapier setup resolves instead of failing the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD-860's start gate made `rapier().setup` return its cleanup instead of `ready`, so nothing awaits the Wasm init any more: when a reload or a stop races it, the generation check threw into an unhandled rejection and the page reported it as a boot failure. That is what the 0.3.13 release proof hit once the retired `render` key stopped failing first — `TN_PLAYTEST_BRIDGE_MISSING ... rapier() was disposed before it initialized` — on both starter and platformer, in 24 s and 35 s. The superseded generation now installs nothing and resolves, reporting `TN_PHYSICS_SETUP_SUPERSEDED`; `recast()` skips installing into a context that is gone. A live game still fails closed (`TN_PHYSICS_NOT_READY`, and a `recast()` without a `rapier()` still throws). Verified: packages/physics 36 files / 280 tests (a new spec pins the dispose-races-init path: nothing installed, `runtime.rapier` untouched, the marker reported), `pnpm quality`, `pnpm typecheck`. --- packages/physics/__tests__/plugin.spec.ts | 30 +++++++++++++++++++++++ packages/physics/src/navigation/index.ts | 5 +++- packages/physics/src/plugin.ts | 14 ++++++++--- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/physics/__tests__/plugin.spec.ts b/packages/physics/__tests__/plugin.spec.ts index be84e3054a..223729bdd1 100644 --- a/packages/physics/__tests__/plugin.spec.ts +++ b/packages/physics/__tests__/plugin.spec.ts @@ -70,6 +70,36 @@ describe("rapier plugin", () => { plugin.dispose?.(ctx); }); + it("resolves a setup whose game was disposed first, installing nothing and reporting it", async () => { + await RAPIER.init(); + const plugin = rapier(); + const ctx = { physics: undefined } as unknown as ICtx, IPhysicsContext>; + const gates: Promise[] = []; + const runtime = { + fixedStep: () => 0, + holdStart: (gate: Promise) => { + gates.push(gate); + return gate; + }, + observations: { contribute: () => () => undefined, contributions: () => [] }, + rapier: null, + seed: 1, + step: 1 / 60, + tick: () => 0, + } satisfies IGamePluginRuntime; + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + plugin.setup?.(ctx, runtime); + plugin.dispose?.(ctx); + + // A reload during the Wasm load must not become a boot failure on the page. + await expect(Promise.all(gates)).resolves.toBeDefined(); + expect(ctx.physics).toBeUndefined(); + expect(runtime.rapier).toBeNull(); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/TN_PHYSICS_SETUP_SUPERSEDED/u)); + warn.mockRestore(); + }); + it("should register the actual Rapier runtime version", async () => { await RAPIER.init(); const plugin = rapier(); diff --git a/packages/physics/src/navigation/index.ts b/packages/physics/src/navigation/index.ts index ce9cead67f..2ee98d4d1c 100644 --- a/packages/physics/src/navigation/index.ts +++ b/packages/physics/src/navigation/index.ts @@ -57,13 +57,16 @@ export function recast(): NavigationPlugin { // Like rapier(), Recast initializes while the scene loads and holds `enter()` until it is up. setup: (ctx: ICtx, IPhysicsContext>, runtime) => { // rapier() leaves its readiness here (`plugin.ts` PHYSICS_READY); this bundle has no copy of it. - const physics: Promise | undefined = Reflect.get( + const physics: Promise | undefined = Reflect.get( ctx, Symbol.for("threenative.physics.ready"), ); if (physics === undefined) throw new Error("recast() requires rapier() earlier in the plugins array."); const ready = Promise.all([physics, initialize()]).then(([context]) => { + // The page that asked for navigation is gone; installing into it would leak, and rejecting + // would report a boot failure the disposed game never had. + if (context === undefined) return; const empty = emptyNavigation(); navigation = { agents: new Set(), diff --git a/packages/physics/src/plugin.ts b/packages/physics/src/plugin.ts index ceb5b1c9b7..9252ab5e7b 100644 --- a/packages/physics/src/plugin.ts +++ b/packages/physics/src/plugin.ts @@ -213,9 +213,17 @@ export function rapier(options: IPhysicsOptions = {}): PhysicsPlugin { setup: (ctx: ICtx, IPhysicsContext>, runtime) => { gameContext = ctx; const generation = ++generations; - const ready = backend.initialize().then(() => { - if (generation !== generations) - throw new Error("rapier() was disposed before it initialized."); + const ready = backend.initialize().then((): IPhysicsContext | undefined => { + // A reload or a stop raced Rapier's Wasm. The generation that asked is gone, so nothing is + // installed — and nothing rejects: with the start gate, nothing awaits `ready` any more, so + // a rejection here reached the page as a boot failure it never had (the 0.3.13 release proof + // stopped on exactly that, PRD-860). + if (generation !== generations) { + console.warn( + "TN_PHYSICS_SETUP_SUPERSEDED: rapier initialized after its game was disposed; nothing was installed.", + ); + return undefined; + } const selected = backend.createSimulation(options); simulation = selected; if (runtime !== undefined) runtime.rapier = selected.version;