Skip to content

OOM: WASM unreachable trap Errors leak ~8MB each, retained by V8 Global handles #401

Description

@ZLJ599

OOM (Out of Memory) when repeatedly loading/unloading scenes — WASM RuntimeError: unreachable trap Errors are retained by V8 Global handles

Summary

On a low-RAM machine (8 GB), repeatedly entering and exiting Gaussian splatting scenes (Scene A → list page → Scene B → list page → Scene A, ~10–20 cycles) causes the browser tab to crash with an out-of-memory white screen. Chrome DevTools itself crashes and closes.

Root cause: when spark's WASM decode worker throws a RuntimeError: unreachable trap (WASM unreachable instruction = Rust panic), V8 generates an Error.stack string containing the entire WASM module as base64 (~1.5 MB → ~8 MB after formatting). These Error objects are retained by V8's rejection tracking / DevTools console (Global handles), permanently pinning that ~8 MB string. Over multiple scene switches, dozens accumulate → hundreds of MB → OOM.

Environment

  • @sparkjsdev/spark v2.1.0
  • three.js r180
  • Chrome (V8) on Windows, 8 GB RAM machine
  • Paged .rad LOD streaming scenes

Reproduction

  1. Open a page with a SparkRenderer + paged .rad SplatMesh.
  2. Navigate into the scene, then back to the list page (component unmounts → host calls renderer.dispose() + forceContextLoss()).
  3. Enter a different scene, back, repeat with the first scene.
  4. After ~10–20 cycles on an 8 GB machine, the tab crashes to a white screen. DevTools crashes too.

Note: spark does not expose a "terminate the whole worker pool" API, so the host needs one to reclaim WASM linear memory on unmount. I added workerPool.terminateAll() via a globalThis.__sparkTerminateWorkerPool hook (see Bonus section). The OOM reproduces with or without this termination — the trap leaks regardless; termination just makes each cycle's WASM memory reclaimable while the trap-string leak accumulates separately.

Root cause analysis

1. When does the WASM trap occur?

The paged .rad streaming decode path runs inside the worker (jsContent embedded source, decodeBytesUrlchunked branch):

while (true) {
  const nextChunk = await readNextChunk;   // wait for main thread to push next chunk
  decoder.push(nextChunk);                  // WASM decode (ChunkDecoder.push)
}
const decoded = decoder.finish();           // WASM finish (ChunkDecoder.finish)

When the worker is terminated mid-decode (host calls worker.terminate() on unmount), or when edge-case splat data is encountered, the Rust ChunkDecoder can be left in a half-decoded state. The next push()/finish() triggers a Rust panic → compiled to WASM unreachableRuntimeError: unreachable.

2. Why does a single trap cost ~8 MB?

V8 formats WASM exception stacks as:

RuntimeError: unreachable
    at data:application/wasm;base64,<entire WASM module base64 ~1.5 MB>

After string formatting, each Error.stack balloons to ~8 MB (the spark WASM module is ~1.5 MB of base64, expanded with stack-frame formatting).

3. How does the trap Error propagate and get retained?

Full propagation chain (line references are to dist/spark.module.js v2.1.0):

  1. The trap is thrown inside the worker's onMessage handler (the try { result = await handler(args, ...) } block, ~line 959 of the embedded jsContent).
  2. The worker's catch catches it (~line 961) and tries to ship it back:
    } catch (error) {
      console.warn(`Worker error: ${error}`);
      self.postMessage({ id, error }, { transfer: getTransferable(error) });
    }
  3. self.postMessage({ id, error }) attempts to structured-clone the Error with its ~8 MB stack. On a memory-constrained machine, the clone itself fails → throws DOMException: Failed to execute 'postMessage' on 'DedicatedWorkerGlobalScope': Data cannot be cloned, out of memory. This DOMException escapes the catch (an exception thrown inside a catch is not re-caught by the same catch) → becomes an uncaught worker error.
  4. If the clone does succeed, the Error reaches the main-thread SplatWorker.onMessage (~line 2533), which calls promise.reject(error).
  5. If the promise chain has no catch → unhandledrejection.
  6. V8's rejection tracking + DevTools console retain the Error object in Global handles, permanently pinning the ~8 MB stack string. event.preventDefault() on unhandledrejection alone is not enough — V8 still holds the Error until the stack string reference is explicitly broken.

4. Heap snapshot evidence

DevTools heap snapshots (problem state vs normal state, same session):

Problem state Normal state
Total self_size 1038 MB 626 MB
system / JSArrayBufferData 637 MB / 156 instances 594 MB / 102 instances
RuntimeError: unreachable at data:application/wasm;base64,… string nodes 363 MB / 46 instances 0

Reverse-edge BFS to GC roots:

(Global handles) → object Error → .<symbol>(stack) → string "RuntimeError…wasm;base64,…"

One target was explicitly labeled <49 / DevTools console>, confirming the Error is retained by V8/DevTools.

46 instances × ~8 MB = 363 MB, plus 54 extra ArrayBuffers (~43 MB). This is the ~400 MB delta between the problem and normal states — enough to tip an 8 GB machine into OOM after a dozen cycles.

What I patched (and what I think the real upstream fix should be)

I had to patch four spots via patch-package to fully stop the leak. I'm sharing these so the upstream fix can be more targeted — most of this shouldn't be necessary if spark handles the trap Error at the source.

Patch 1 (the real fix): Worker-side onMessage catch — clear error.stack BEFORE postMessage

This is the critical one. The Error OOMs during postMessage structured-clone on the worker side, so main-thread fixes arrive too late. The stack must be cleared inside the worker, before postMessage.

Embedded jsContent, onMessage catch block (~line 961):

// Original:
} catch (error) {
  console.warn(`Worker error: ${error}`);
  self.postMessage({ id, error }, { transfer: getTransferable(error) });
}

// Patched:
} catch (error) {
  try {
    if (error instanceof Error && typeof error.stack === "string"
        && /unreachable/i.test(error.message || error.stack)) {
      error.stack = "";
      error.message = (error.message || "").slice(0, 200);
    }
  } catch (_) {}
  // (removed the console.warn to keep the worker console quiet;
  //  the trap is already surfaced via the rejected promise on the main thread)
  try {
    self.postMessage({ id, error }, { transfer: getTransferable(error) });
  } catch (_) {
    // clone failed (e.g. out of memory) — send a minimal Error instead of crashing the worker
    try {
      self.postMessage({ id, error: new Error("worker postMessage clone failed: " + (error && error.message || String(error)).slice(0, 200)) });
    } catch (_) {}
  }
}

Suggested upstream fix: do the stack clearing + postMessage try/catch here in spark itself. Ideally also avoid emitting data:application/wasm;base64,<whole module> in the stack frame — a module-name + function-offset frame would be tiny and more useful for debugging.

Patch 2: Main-thread SplatWorker.onMessage — clear error.stack before reject

Belt-and-suspenders for the case where the Error does reach the main thread. _SplatWorker.onMessage (~line 2533):

onMessage(event) {
  const { id, result, error, status } = event.data;
  const promise = this.messages[id];
  // [PATCH] clear WASM trap stack before reject
  try {
    if (error instanceof Error && typeof error.stack === "string"
        && /unreachable/i.test(error.message || error.stack)) {
      error.stack = "";
      error.message = (error.message || "").slice(0, 200);
    }
  } catch (_) {}
  if (promise) { /* original reject/resolve logic */ }
}

Patch 3: WorkerWrapper / WorkerWrapper$1 — intercept worker error event

The original worker.addEventListener("error", () => { revokeObjectURL(objURL) }) only revokes the URL and does not call preventDefault(). Trap Errors that escape the onMessage catch (e.g. the DOMException from a failed clone) bubble to the main-thread global → DevTools console retains them.

Added a neutralizeWorkerError helper that detects WASM traps, clears event.error.stack, then preventDefault() + stopPropagation(). Also added the missing error listener to the data: URL fallback branch (which originally had none):

const neutralizeWorkerError = (event) => {
  try {
    const err = event && event.error;
    if (err instanceof Error && typeof err.stack === "string"
        && /unreachable/i.test(err.message || err.stack)) {
      err.stack = "";
      err.message = (err.message || "").slice(0, 200);
      event.preventDefault();
      event.stopPropagation();
    }
  } catch (_) {}
};

Suggested upstream fix: preventDefault() on worker error events that carry WASM trap Errors, in both WorkerWrapper variants, including the data: URL fallback branch.

Patch 4 (host-side, not spark): unhandledrejection silencer

A module-level permanent unhandledrejection listener in the host app that suppresses known-harmless spark rejects (Worker terminate, No target, empty reasons, and WASM traps) as a last-resort net. Not needed if spark fixes patches 1–3.

Bonus: NewSplatWorkerPool.terminateAll() + global hook

Spark has no API to terminate the entire worker pool, so hosts can't reclaim the ~200 MB of WASM linear memory on scene exit. I added terminateAll() and exposed globalThis.__sparkTerminateWorkerPool:

class NewSplatWorkerPool {
  constructor(maxWorkers = 4) {
    // …
    this.allWorkers = new Set();   // [PATCH] track all created workers
  }
  allocWorker() {
    // …
    this.allWorkers.add(worker2);  // [PATCH]
    // …
  }
  terminateAll() {                 // [PATCH]
    for (const worker of this.allWorkers) {
      try { worker.dispose(); } catch (_) {}
    }
    this.allWorkers.clear();
    this.freelist = [];
    this.queue = [];
    this.numWorkers = 0;
  }
}
globalThis.__sparkTerminateWorkerPool = () => { try { workerPool.terminateAll(); } catch (_) {} };

Suggested upstream: expose a public dispose()/terminateAll() on the worker pool (and/or on SparkRenderer) so hosts can reclaim WASM memory on unmount without patching.

Verification

  • Before fix: 8 GB machine, A/B scene switching 10+ cycles → OOM white screen, DevTools crash.
  • After fix: 8 GB machine, A/B scene switching dozens of cycles, no OOM, console clean.

Suggestions for upstream (summary)

  1. Worker onMessage catch block (jsContent): clear error.stack for WASM traps before postMessage, and wrap postMessage in try/catch so a clone failure doesn't crash the worker.
  2. WorkerWrapper / WorkerWrapper$1 error listener: preventDefault() for WASM trap Errors to stop them bubbling to the main-thread global / DevTools console.
  3. WASM stack frames: the data:application/wasm;base64,<entire module> frame is enormous and not actionable for debugging — a module-name + function-offset frame would be tiny and more useful. (This alone would shrink each trap from ~8 MB to a few hundred bytes and mostly eliminate the OOM risk.)
  4. Public API: expose a disposeAll() / terminateAll() on the worker pool / SparkRenderer so hosts can reclaim WASM memory without patching.

Happy to provide the full patch-package diff if helpful.

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