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
- Open a page with a
SparkRenderer + paged .rad SplatMesh.
- Navigate into the scene, then back to the list page (component unmounts → host calls
renderer.dispose() + forceContextLoss()).
- Enter a different scene, back, repeat with the first scene.
- 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, decodeBytesUrl → chunked 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 unreachable → RuntimeError: 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):
- The trap is thrown inside the worker's
onMessage handler (the try { result = await handler(args, ...) } block, ~line 959 of the embedded jsContent).
- 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) });
}
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.
- If the clone does succeed, the Error reaches the main-thread
SplatWorker.onMessage (~line 2533), which calls promise.reject(error).
- If the promise chain has no catch →
unhandledrejection.
- 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)
- 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.
WorkerWrapper / WorkerWrapper$1 error listener: preventDefault() for WASM trap Errors to stop them bubbling to the main-thread global / DevTools console.
- 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.)
- 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.
OOM (Out of Memory) when repeatedly loading/unloading scenes — WASM
RuntimeError: unreachabletrap Errors are retained by V8 Global handlesSummary
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: unreachabletrap (WASMunreachableinstruction = Rust panic), V8 generates anError.stackstring containing the entire WASM module as base64 (~1.5 MB → ~8 MB after formatting). TheseErrorobjects 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/sparkv2.1.0.radLOD streaming scenesReproduction
SparkRenderer+ paged.radSplatMesh.renderer.dispose()+forceContextLoss()).Root cause analysis
1. When does the WASM trap occur?
The paged
.radstreaming decode path runs inside the worker (jsContentembedded source,decodeBytesUrl→chunkedbranch):When the worker is terminated mid-decode (host calls
worker.terminate()on unmount), or when edge-case splat data is encountered, the RustChunkDecodercan be left in a half-decoded state. The nextpush()/finish()triggers a Rust panic → compiled to WASMunreachable→RuntimeError: unreachable.2. Why does a single trap cost ~8 MB?
V8 formats WASM exception stacks as:
After string formatting, each
Error.stackballoons 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.jsv2.1.0):onMessagehandler (thetry { result = await handler(args, ...) }block, ~line 959 of the embeddedjsContent).catchcatches it (~line 961) and tries to ship it back:self.postMessage({ id, error })attempts to structured-clone the Error with its ~8 MBstack. On a memory-constrained machine, the clone itself fails → throwsDOMException: Failed to execute 'postMessage' on 'DedicatedWorkerGlobalScope': Data cannot be cloned, out of memory.This DOMException escapes thecatch(an exception thrown inside acatchis not re-caught by the samecatch) → becomes an uncaught worker error.SplatWorker.onMessage(~line 2533), which callspromise.reject(error).unhandledrejection.Errorobject inGlobal handles, permanently pinning the ~8 MBstackstring.event.preventDefault()onunhandledrejectionalone 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):
self_sizesystem / JSArrayBufferDataRuntimeError: unreachable at data:application/wasm;base64,…string nodesReverse-edge BFS to GC roots:
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-packageto 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
onMessagecatch — clearerror.stackBEFOREpostMessageThis is the critical one. The Error OOMs during
postMessagestructured-clone on the worker side, so main-thread fixes arrive too late. Thestackmust be cleared inside the worker, beforepostMessage.Embedded
jsContent,onMessagecatch block (~line 961):Suggested upstream fix: do the
stackclearing +postMessagetry/catch here in spark itself. Ideally also avoid emittingdata: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— clearerror.stackbeforerejectBelt-and-suspenders for the case where the Error does reach the main thread.
_SplatWorker.onMessage(~line 2533):Patch 3:
WorkerWrapper/WorkerWrapper$1— intercept workererroreventThe original
worker.addEventListener("error", () => { revokeObjectURL(objURL) })only revokes the URL and does not callpreventDefault(). Trap Errors that escape theonMessagecatch (e.g. theDOMExceptionfrom a failed clone) bubble to the main-thread global → DevTools console retains them.Added a
neutralizeWorkerErrorhelper that detects WASM traps, clearsevent.error.stack, thenpreventDefault()+stopPropagation(). Also added the missing error listener to thedata:URL fallback branch (which originally had none):Suggested upstream fix:
preventDefault()on workererrorevents that carry WASM trap Errors, in bothWorkerWrappervariants, including thedata:URL fallback branch.Patch 4 (host-side, not spark):
unhandledrejectionsilencerA module-level permanent
unhandledrejectionlistener 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 hookSpark 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 exposedglobalThis.__sparkTerminateWorkerPool:Suggested upstream: expose a public
dispose()/terminateAll()on the worker pool (and/or onSparkRenderer) so hosts can reclaim WASM memory on unmount without patching.Verification
Suggestions for upstream (summary)
onMessagecatch block (jsContent): clearerror.stackfor WASM traps beforepostMessage, and wrappostMessageintry/catchso a clone failure doesn't crash the worker.WorkerWrapper/WorkerWrapper$1error listener:preventDefault()for WASM trap Errors to stop them bubbling to the main-thread global / DevTools console.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.)disposeAll()/terminateAll()on the worker pool /SparkRendererso hosts can reclaim WASM memory without patching.Happy to provide the full
patch-packagediff if helpful.