Skip to content

swjs_call_function_no_catch corrupts an Emscripten callee's WASM function table on the second invocation #729

Description

@Vithanco

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.

TestWhat it variesResult
ATwo viz.renderString calls from plain JS, same DOTPASS
BTwo calls from JS, second DOT has shuffled attribute orderPASS
CTwo calls from JS, DOT contains fontname (no font in WASM)PASS
DTwo calls from JS, with a parallel instance() allocation in flightPASS
ETwo calls from JS in one synchronous turn (no microtask between)PASS
FSame as E, but routed through wrapper code that dispatches via window.graphvizLayoutJSONPASS
GFull Swift WASM bundle initialised (BridgeJS exports loaded), then JS-only viz callsPASS
HTwo Swift→JS→viz calls on the same instance, same DOT, same turnFAIL
ISame as H + await new Promise(r => setTimeout(r, 0)) between callsUNRELIABLE — depends on whether a background instance() allocation happens to resolve in time
JOne Swift→viz call, then one JS-direct viz call on the same instancePASS — confirms the viz instance still answers JS callers correctly
KTwo 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

  1. Is this a known interaction pattern between swjs_call_function_no_catch and Emscripten-built callees, or a fresh report?
  2. 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?
  3. 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.

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