Skip to content

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

@Vithanco

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.

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

  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