TypeScript: World.restoreSnapshot() leaks ~1730 bytes per call regardless of world size
- Dominant language
- Rust
- Stars
- 5.7k
- Forks
- 387
- Avg merge
- 4d 23h
- Merged PRs (30d)
- 6
Description
`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
```js
// 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.
Contributor guide
Research direction
Reproduce the leak with the provided Node script using World.restoreSnapshot() and compare it with the control path. Start in the TypeScript bindings and the Rust/WASM implementation behind World.restoreSnapshot; done means repeated restore-and-free cycles no longer grow WASM memory while the control remains essentially flat.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, rust, typescript, wasm
- Domain
- computer-graphics, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100