swiftwasm / swiftwasm/JavaScriptKit
swjs_call_function_no_catch corrupts an Emscripten callee's WASM function table on the second invocation
Chưa có ai nhận issue này.
- Ngôn ngữ chính
- Swift
- Star
- 986
- Fork
- 76
- Merge trung bình
- 21 giờ 11 phút
- Pull request đã merge (30 ngày)
- 4
Mô tả
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.jsreferenced below is BridgeJS-generatedPackage/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
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_catchand 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.
Hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Hướng nghiên cứu
Chạy viz-repro.html được đính kèm và so sánh đường dẫn H bị lỗi với các trường hợp J và K chạy thành công. Sau đó kiểm tra Package/runtime.js xung quanh swjs_call_function_no_catch ở dòng 573, tập trung vào việc xử lý các đối số và giá trị trả về. Hoàn thành có nghĩa là xác định trạng thái bridge hoặc tương tác khiến lần gọi thứ hai gây ra trap, đồng thời ghi lại hoặc xác thực một bản sửa lỗi phù hợp.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Đánh giá
- Công nghệ
- javascript, swift, wasm
- Lĩnh vực
- web-dev
- Loại issue
- Lỗi
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức độ hoạt động
- Ít trao đổi
- Độ rõ ràng
- Cần làm rõ
- Mức phù hợp với người mới
- 45/100