swiftwasm / swiftwasm/JavaScriptKit

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

Offen
#729 0 Kommentare 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen

Dieses Issue hat noch niemand übernommen.

Vorherrschende Sprache
Swift
Sterne
986
Forks
76
Ø Merge
21 Std. 11 Min.
Gemergte PRs (30 T.)
4

Beschreibung

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.

Beitragsleitfaden

Beitragsleitfaden öffnen

Erste Schritte

  1. Lies das ganze Issue und danach den Beitragsleitfaden des Projekts.
  2. Schreib ins Issue, dass du es übernimmst — das erspart doppelte Arbeit.
  3. Forke das Repository und arbeite in einem Branch.
  4. Öffne einen Pull Request, der die Issue-Nummer nennt.

Rechercherichtung

Führe die angehängte viz-repro.html aus und vergleiche den fehlschlagenden H-Pfad mit den erfolgreichen Fällen J und K. Untersuche dann Package/runtime.js im Umfeld von swjs_call_function_no_catch bei Zeile 573, mit Fokus auf die Verarbeitung von Argumenten und Rückgabewerten. Als erledigt gilt die Identifizierung des Bridge-Zustands oder der Interaktion, die dazu führt, dass der zweite Aufruf einen Trap auslöst, sowie die Dokumentation oder Validierung eines geeigneten Fixes.

Vom Indexierungsmodell aus dem Issue-Text verfasst.

Bewertung

Tech-Stack
javascript, swift, wasm
Bereich
web-dev
Issue-Typ
Bug
Schwierigkeit
4/5
Geschätzter Aufwand
3-5 Tage
Aktivitätsstatus
Ruhig
Klarheit
Muss geklärt werden
Anfängerfreundlichkeit
45/100

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.