Summary
When Swift WASM (via JavaScriptKit) calls a JavaScript function that internally invokes an Emscripten-built WASM module (@viz-js/viz), the second such call on the same Emscripten instance traps inside the Emscripten callee with RuntimeError: table index is out of bounds. The trap fires inside the callee's WASM, but only when the caller is JavaScriptKit; the same JS function called the same number of times from plain JavaScript works perfectly. Yielding to the JS event loop between calls does not reliably help — the corruption is per-Emscripten-instance, not just timing-dependent. Allocating a fresh Emscripten instance for each Swift-originated call avoids the trap entirely.
This is reproducible with an 11-test differential matrix that isolates the trigger to the swjs_call_function_no_catch path specifically.
Environment
- JavaScriptKit: as bundled with Swift 6.3 / BridgeJS plugin (current)
@viz-js/viz: 3.26.0 (Graphviz 14.1.5, Emscripten-built)
- Browser: Chrome (latest), macOS 15.4
runtime.js referenced below is BridgeJS-generated Package/runtime.js
Failing stack
RuntimeError: table index is out of bounds
at wasm://wasm/...:wasm-function[1006]:0x5742f
at wasm://wasm/...:wasm-function[160]:0x9cad
at wasm://wasm/...:wasm-function[1097]:0x619e2
at wasm://wasm/...:wasm-function[2197]:0xc45bc
at Object.ccall (viz.js:9)
at renderInput (viz.js:135)
at Viz.render (viz.js:308)
at Viz.renderString (viz.js:318)
at window.graphvizLayoutJSON (vgraph-v1.0.1.js:218)
at swjs_call_function_no_catch (Package/runtime.js:573)
The trap is inside viz-js's WASM linear memory / function table, but it only fires when the call frame above is swjs_call_function_no_catch. Identical JavaScript code, identical viz-js instance, identical input DOT — fails when called from Swift WASM, passes when called from JS.
Differential test matrix
A single HTML page (viz-repro.html, source linked below) runs eleven variants of "render a small graph" against the same viz-js instance, varying only how the call reaches it.
| Test |
What it varies |
Result |
| A |
Two viz.renderString calls from plain JS, same DOT |
PASS |
| B |
Two calls from JS, second DOT has shuffled attribute order |
PASS |
| C |
Two calls from JS, DOT contains fontname (no font in WASM) |
PASS |
| D |
Two calls from JS, with a parallel instance() allocation in flight |
PASS |
| E |
Two calls from JS in one synchronous turn (no microtask between) |
PASS |
| F |
Same as E, but routed through wrapper code that dispatches via window.graphvizLayoutJSON |
PASS |
| G |
Full Swift WASM bundle initialised (BridgeJS exports loaded), then JS-only viz calls |
PASS |
| H |
Two Swift→JS→viz calls on the same instance, same DOT, same turn |
FAIL |
| I |
Same as H + await new Promise(r => setTimeout(r, 0)) between calls |
UNRELIABLE — depends on whether a background instance() allocation happens to resolve in time |
| J |
One Swift→viz call, then one JS-direct viz call on the same instance |
PASS — confirms the viz instance still answers JS callers correctly |
| K |
Two Swift→viz calls with 500 ms yield (ensures a fresh instance is swapped in) |
PASS — confirms the bug is per-instance, not per-call-accumulating |
The matrix isolates the trigger to the second swjs_call_function_no_catch invocation that ends up calling the same Emscripten instance. JS callers cannot reproduce it under any input or timing variation; Swift callers reproduce it deterministically.
J is particularly informative: after a Swift→viz call, the same viz instance still serves a JS-direct call correctly. So the viz-js heap is not globally corrupted — only the Swift-bridge call path on that instance is broken. K confirms that allocating a fresh viz instance per Swift call avoids the trap entirely.
Hypothesis
I don't have enough JavaScriptKit internals knowledge to call the cause. What we observe is consistent with swjs_call_function_no_catch leaving residue in JS-side state that the JS function's next execution path through the Emscripten callee interacts with — possibly something around how the JSObject reference table is reused, how return values from JS-into-Emscripten are held, or how the underlying WebAssembly.Table imports are shared between modules. The fact that JS-direct calls don't reproduce, while two Swift bridge calls do, suggests it's specific to the bridge mechanics rather than the Emscripten callee's own state machine.
Reproducer
viz-repro.zip
Self-contained ZIP attached: viz-repro.zip (1.9 MB).
To run:
unzip viz-repro.zip
cd viz-repro-bundle
python3 -m http.server 8000
Then open http://localhost:8000/viz-repro.html and click each lettered button. H reliably fails; A–G, J, K reliably pass; I is timing-dependent.
The ZIP includes the pre-built Swift WASM bundle (Package/VGraphWasm.wasm + runtime.js) so no Swift toolchain or build step is needed. @viz-js/viz and @bjorn3/browser_wasi_shim are loaded from jsDelivr.
I have not tested whether other Emscripten-built WASM modules (e.g. @hpcc-js/wasm) exhibit the same behaviour. Happy to test if it would help narrow scope.
Workaround
Maintain a pool of pre-allocated viz-js instances and consume a fresh one per Swift→viz call (refill async). Pool size N tolerates up to N Swift→viz calls per event-loop turn. This is shipping in our codebase now, but it's a heavy hammer — eagerly allocating 5+ Emscripten WASM instances at startup for a workaround that should not be necessary.
Ask
- Is this a known interaction pattern between
swjs_call_function_no_catch and Emscripten-built callees, or a fresh report?
- If the call mechanism is leaving recoverable state in the Emscripten module, is there a way to flush / reset it from the Swift side without disposing the whole instance?
- Would you accept a PR adding an "after-call-cleanup hook" in JavaScriptKit's bridge, or is the right answer something Emscripten-side?
Happy to dig further into the JavaScriptKit runtime if you can point me at the most likely files. The smoking gun is somewhere in how swjs_call_function_no_catch (runtime.js:573) marshals arguments and return values when the called JS function calls into another WASM module.
Summary
When Swift WASM (via JavaScriptKit) calls a JavaScript function that internally invokes an Emscripten-built WASM module (
@viz-js/viz), the second such call on the same Emscripten instance traps inside the Emscripten callee withRuntimeError: table index is out of bounds. The trap fires inside the callee's WASM, but only when the caller is JavaScriptKit; the same JS function called the same number of times from plain JavaScript works perfectly. Yielding to the JS event loop between calls does not reliably help — the corruption is per-Emscripten-instance, not just timing-dependent. Allocating a fresh Emscripten instance for each Swift-originated call avoids the trap entirely.This is reproducible with an 11-test differential matrix that isolates the trigger to the
swjs_call_function_no_catchpath specifically.Environment
@viz-js/viz: 3.26.0 (Graphviz 14.1.5, Emscripten-built)runtime.jsreferenced below is BridgeJS-generatedPackage/runtime.jsFailing stack
The trap is inside viz-js's WASM linear memory / function table, but it only fires when the call frame above is
swjs_call_function_no_catch. Identical JavaScript code, identical viz-js instance, identical input DOT — fails when called from Swift WASM, passes when called from JS.Differential test matrix
A single HTML page (
viz-repro.html, source linked below) runs eleven variants of "render a small graph" against the same viz-js instance, varying only how the call reaches it.viz.renderStringcalls from plain JS, same DOTfontname(no font in WASM)instance()allocation in flightwindow.graphvizLayoutJSONSwift→JS→vizcalls on the same instance, same DOT, same turnawait new Promise(r => setTimeout(r, 0))between callsinstance()allocation happens to resolve in timeThe matrix isolates the trigger to the second
swjs_call_function_no_catchinvocation that ends up calling the same Emscripten instance. JS callers cannot reproduce it under any input or timing variation; Swift callers reproduce it deterministically.J is particularly informative: after a Swift→viz call, the same viz instance still serves a JS-direct call correctly. So the viz-js heap is not globally corrupted — only the Swift-bridge call path on that instance is broken. K confirms that allocating a fresh viz instance per Swift call avoids the trap entirely.
Hypothesis
I don't have enough JavaScriptKit internals knowledge to call the cause. What we observe is consistent with
swjs_call_function_no_catchleaving residue in JS-side state that the JS function's next execution path through the Emscripten callee interacts with — possibly something around how the JSObject reference table is reused, how return values from JS-into-Emscripten are held, or how the underlyingWebAssembly.Tableimports are shared between modules. The fact that JS-direct calls don't reproduce, while two Swift bridge calls do, suggests it's specific to the bridge mechanics rather than the Emscripten callee's own state machine.Reproducer
viz-repro.zip
Self-contained ZIP attached:
viz-repro.zip(1.9 MB).To run:
unzip viz-repro.zip cd viz-repro-bundle python3 -m http.server 8000Then open
http://localhost:8000/viz-repro.htmland click each lettered button. H reliably fails; A–G, J, K reliably pass; I is timing-dependent.The ZIP includes the pre-built Swift WASM bundle (
Package/VGraphWasm.wasm+runtime.js) so no Swift toolchain or build step is needed.@viz-js/vizand@bjorn3/browser_wasi_shimare loaded from jsDelivr.I have not tested whether other Emscripten-built WASM modules (e.g.
@hpcc-js/wasm) exhibit the same behaviour. Happy to test if it would help narrow scope.Workaround
Maintain a pool of pre-allocated viz-js instances and consume a fresh one per Swift→viz call (refill async). Pool size N tolerates up to N Swift→viz calls per event-loop turn. This is shipping in our codebase now, but it's a heavy hammer — eagerly allocating 5+ Emscripten WASM instances at startup for a workaround that should not be necessary.
Ask
swjs_call_function_no_catchand Emscripten-built callees, or a fresh report?Happy to dig further into the JavaScriptKit runtime if you can point me at the most likely files. The smoking gun is somewhere in how
swjs_call_function_no_catch(runtime.js:573) marshals arguments and return values when the called JS function calls into another WASM module.