Skip to content

TypeScript: World.restoreSnapshot() leaks ~1730 bytes per call regardless of world size #990

Description

@jounih

World.restoreSnapshot() leaks a fixed ~1730 bytes of WASM memory per call, even when the returned world is free()d.

The leak is independent of world size, which suggests a fixed-size allocation per restored World rather than anything proportional to the snapshot contents.

(Filed here rather than rapier.js, which is archived and points to typescript/.)

Environment

  • @dimforge/rapier2d-deterministic-compat 0.20.0
  • Node v25.4.0, macOS 15 (darwin/arm64)

Reproduction

// node --expose-gc leak-repro.mjs [cycles] [bodies]
import RAPIER from "@dimforge/rapier2d-deterministic-compat";

const CYCLES = Number(process.argv[2] ?? 4000);
const BODIES = Number(process.argv[3] ?? 40);
await RAPIER.init();

function buildWorld() {
  const world = new RAPIER.World(new RAPIER.Vector2(0, -9.81));
  const ground = world.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(0, -1));
  world.createCollider(RAPIER.ColliderDesc.cuboid(50, 1), ground);
  for (let i = 0; i < BODIES; i++) {
    const b = world.createRigidBody(
      RAPIER.RigidBodyDesc.dynamic().setTranslation((i % 8) * 1.1, 1 + Math.floor(i / 8) * 1.1),
    );
    world.createCollider(RAPIER.ColliderDesc.cuboid(0.5, 0.5).setDensity(1), b);
  }
  for (let i = 0; i < 60; i++) world.step();
  return world;
}

const sample = () => (global.gc(), global.gc(), process.memoryUsage());

const source = buildWorld();
const snapshot = source.takeSnapshot();

// Warm up so one-time allocations are not counted as growth.
for (let i = 0; i < 200; i++) RAPIER.World.restoreSnapshot(snapshot).free();

const before = sample();
for (let i = 0; i < CYCLES; i++) {
  const w = RAPIER.World.restoreSnapshot(snapshot);
  w.step();
  w.free();
}
const after = sample();
console.log(`external: ${((after.external - before.external) / CYCLES).toFixed(1)} B/cycle`);

// Control: step + takeSnapshot on one world, no restore.
const control = buildWorld();
const cBefore = sample();
for (let i = 0; i < CYCLES; i++) { control.step(); control.takeSnapshot(); }
const cAfter = sample();
console.log(`control:  ${((cAfter.external - cBefore.external) / CYCLES).toFixed(1)} B/cycle`);

Results

external is where WASM linear memory shows up in Node's accounting. 2500 cycles each:

bodies snapshot size restoreSnapshot + free control (step + takeSnapshot)
5 7,947 B 1730.2 B/cycle 0.0 B/cycle
40 60,114 B 1730.2 B/cycle 26.2 B/cycle
300 441,562 B 1730.2 B/cycle 180.0 B/cycle

The snapshot size spans 55× while the leaked amount is byte-identical, and the control path — which exercises stepping and serialization but never restores — is essentially flat. Over 4000 cycles at 40 bodies the process also grows ~48 MB RSS and ~240 B/cycle of JS heap.

Why this matters

This shows up in rollback netcode, where restoreSnapshot is called on every correction. At a realistic 1–2 rollbacks/second that is roughly 6–13 MB/hour of unbounded growth — slow, but it never plateaus, so a long session eventually dies. There is no workaround from the JS side: the returned world is freed, and nothing is retained by the caller.

Happy to test a patch or provide more instrumentation if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions