subagent transcript

session 17f09f89 · workflow wf_c636fd6b
delegated task
QUESTION: Can the FreeCAD-to-WebAssembly build migrate from ASYNCIFY to JSPI (JavaScript Promise Integration / WebAssembly stack switching), and is it
▸ Magik
QUESTION: Can the FreeCAD-to-WebAssembly build migrate from ASYNCIFY to JSPI (JavaScript Promise Integration / WebAssembly stack switching), and is it worth it? JSPI would replace ASYNCIFY's whole-module instrumentation with native stack switching — potentially a big binary-size + startup + runtime-perf win. CURRENT BUILD (verified): - Emscripten 4.0.12 at /opt/toolchains/emsdk (clang/LLVM 22, upstream/bin). Node 22 for testing. - Link options (src/Main/CMakeLists.txt ~150-250): -sASYNCIFY=1, -sASYNCIFY_STACK_SIZE=67108864, -sWASM_BIGINT=1, -sDISABLE_EXCEPTION_CATCHING=0 (i.e. **JS exceptions / -fexceptions**, NOT -fwasm-exceptions). Qt built with QT_EMSCRIPTEN_ASYNCIFY=1 (/opt/toolchains/qt-asyncify/6.11.1/wasm_singlethread). Also a post-link hack forces CPython's JS trampoline (getPyEMTrampolinePtr->0) because the wasm-gc trampoline crashes under ASYNCIFY. - WHY asyncify (from the code comment at src/Main/CMakeLists.txt:156, prior research "R10"): "ASYNCIFY + JS-EH build. This is the only combination that yields a VALID module, a rendering GUI, and working Python->OCCT. wasm-EH is out: LLVM mis-compiles OCCT's try_table/catch_all_ref to an invalid br_table (V8 rejects it). JSPI is out: JS-EH's invoke_* frames can't be suspended across; and wasm-EH is invalid. ASYNCIFY instruments the whole module so any function can suspend — Qt's synchronous exec() works." - ASYNCIFY is needed because Qt's event loop / exec() / modal dialogs / the Coin render loop run synchronously and must suspend back to the browser event loop. Any migration MUST preserve that suspend capability. THE CRUX: JSPI suspends via WebAssembly stack switching, which works cleanly across wasm frames but historically could NOT suspend across JS frames (the emscripten invoke_* exception trampolines are JS). So JSPI needs either (a) wasm exceptions (-fwasm-exceptions, no JS invoke_* frames) — but that's the OCCT br_table miscompile blocker; or (b) emscripten/V8 support for JSPI suspending across the JS-EH trampolines. INVESTIGATE (be concrete — check emscripten version support, try small test compiles in ISOLATED /tmp dirs, read the emscripten sources under /opt/toolchains/emsdk/upstream/emscripten): Paths: FreeCAD source /home/magik6k/lcad-wasm/freecad-port/FreeCAD ; toolchain env: source /home/magik6k/lcad-wasm/env.sh ; OCCT wasm /opt/toolchains/occt-wasm ; research notes may be in freecad-port/research/. Use emcc/em++/node. DO NOT touch the FreeCAD build tree at /opt/toolchains/src/freecad-gui-build (a rebuild is running there); build tests in fresh /tmp dirs only. DIMENSION C — benefit, effort, risk of a JSPI migration. Assess: 1. BENEFIT: quantify where possible. ASYNCIFY instruments every function (big .wasm size + startup parse cost + per-call overhead). The current FreeCAD.wasm is ~260MB. How much smaller/faster would JSPI plausibly be (asyncify overhead is often 30-100% code size; JSPI removes it)? Runtime: asyncify adds overhead to EVERY call on a suspending path; JSPI only at actual suspend points. Estimate the win for the render/interaction hot path (the user profiled ~96ms wheel redraws). 2. BROWSER SUPPORT: JSPI shipped in Chrome 137 (mid-2025) unflagged; Firefox/Safari status? The deploy targets browsers — is JSPI broadly available now (2026)? Any fallback needed? 3. EFFORT + RISK: rebuild Qt for JSPI (or reuse /opt/toolchains/qt-jspi if viable), rebuild OCCT/Coin/FreeCAD with JSPI + the chosen EH, re-verify the whole GUI + Python->OCCT + asyncify-dependent paths (the QTimer startup script, modal dialogs, file I/O). What could break? Is this a 1-day or 1-week effort? Return a benefit estimate (size/perf), browser-support status, and an effort/risk verdict.
Fable
thinking
I'm diving into the JSPI migration analysis—specifically looking at the benefit-effort-risk tradeoff. I need to gather concrete evidence on emscripten's JSPI support, browser compatibility, and what technical blockers exist, so I'm starting by checking the toolchain and reviewing relevant configuration files.
Fable
⚙ Bash {"command": "cat /home/magik6k/lcad-wasm/env.sh 2>/dev/null | head -40; echo \"---EMCC---\"; /opt/toolchains/emsdk/upstream/emscripten/emcc --version 2>/dev/null | head -3; echo \"---JSPI settings---\"; grep -rn \"JSPI\\|SUPPORT_LONGJMP\\|_…
tool result
# source this in every shell for the FreeCAD/PySide wasm toolchain
export EMSDK=/opt/toolchains/emsdk
export EM_CONFIG=/opt/toolchains/emsdk/.emscripten
export PATH="/opt/toolchains/emsdk:/opt/toolchains/emsdk/upstream/emscripten:/opt/toolchains/emsdk/node/22.16.0_64bit/bin:$PATH"
# Arch has libclang 22; host shiboken6 wants libclang-21.so.21 — shim it:
export LD_LIBRARY_PATH="/home/magik6k/lcad-wasm/pyside-port/libshim:$LD_LIBRARY_PATH"
---EMCC---
emcc (Emscripten gcc/clang-like replacement + l  …[truncated]
Fable
⚙ Bash {"command": "ls /opt/toolchains/ 2>/dev/null; echo \"---qt-jspi?---\"; ls -d /opt/toolchains/qt-jspi* 2>/dev/null; ls -d /opt/toolchains/qt* 2>/dev/null; echo \"---research---\"; ls /home/magik6k/lcad-wasm/freecad-port/research/ 2>/dev/null…
tool result
boost-wasm
emsdk
fix-freetype-sjlj.sh
fmt-wasm
freecad-gui-web
gtest-wasm
icu-wasm
include
occt-wasm
oscad-deps
oscad-src
pyside-host
python-wasm
qt
qt-asyncify
qt-jspi
qtsrc
rebuild-deps-407.sh
rebuild-deps-jseh.sh
rebuild-deps-wasmeh.sh
rebuild-qt-asyncify-412.sh
rebuil  …[truncated]
Fable
thinking
I see there's already a qt-jspi directory in place, so let me check the existing research notes and understand the current state of the JSPI migration plan and how it interacts with JavaScript exception handling.
Fable
⚙ Bash {"command": "echo \"=== qt-jspi contents ===\"; ls -R /opt/toolchains/qt-jspi 2>/dev/null | head -30; echo \"=== rebuild-qt-jspi-jseh.sh ===\"; cat /opt/toolchains/rebuild-qt-jspi-jseh.sh 2>/dev/null | head -60", "description": "Inspect qt-…
tool result
=== qt-jspi contents ===
/opt/toolchains/qt-jspi:
6.11.1

/opt/toolchains/qt-jspi/6.11.1:
wasm_singlethread

/opt/toolchains/qt-jspi/6.11.1/wasm_singlethread:
bin
doc
include
lib
libexec
metatypes
mkspecs
modules
plugins
sbom
share

/opt/toolchains/qt-jspi/6.11.1/wasm_singlethread/bin:
qmake
qmake6
qt-cmake  …[truncated]
Fable
⚙ Bash {"command": "wc -l /home/magik6k/lcad-wasm/freecad-port/research/R8-jspi-migration-plan.md /home/magik6k/lcad-wasm/freecad-port/research/R10-eh-jspi-asyncify-findings.md /home/magik6k/lcad-wasm/freecad-port/research/R5-dialogs-files-jspi.md…
tool result
   90 /home/magik6k/lcad-wasm/freecad-port/research/R8-jspi-migration-plan.md
   72 /home/magik6k/lcad-wasm/freecad-port/research/R10-eh-jspi-asyncify-findings.md
  125 /home/magik6k/lcad-wasm/freecad-port/research/R5-dialogs-files-jspi.md
  287 total
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
I see there's already a Qt-JSPI build in place, so I'm going to review the key findings from R10, the migration plan in R8, and also check R5 to get the full picture.
Fable
⚙ Read {"file_path": "/home/magik6k/lcad-wasm/freecad-port/research/R10-eh-jspi-asyncify-findings.md"}
tool result
1	# R10 — Exceptions / JSPI / Asyncify: the definitive matrix
2	
3	This documents an exhaustive investigation into the single hardest problem of the
4	port: making CPython's call trampoline, Qt's synchronous `exec()` event loop, and
5	OCCT's exception-heavy geometry kernel coexist in one wasm module. Every viable
6	combination of *exception model* × *stack-switching model* was built and tested.
7	
8	## The three sub-problems (each forces a choice)
9	
10	1. **CPython call trampoline.** CPython ≥3  …[truncated]
Fable
⚙ Read {"file_path": "/home/magik6k/lcad-wasm/freecad-port/research/R8-jspi-migration-plan.md"}
tool result
1	# R8 — JSPI Migration Plan (replace ASYNCIFY)
2	
3	Status: **planned** (not yet executed). Prereq artifacts already exist:
4	`toolchain/rebuild-qt-jspi.sh`, and the code carries the ASYNCIFY↔JSPI notes.
5	
6	## 1. Why (recap of the empirical finding)
7	
8	The GUI currently links with `-sASYNCIFY` (Qt built `QT_HAVE_EMSCRIPTEN_ASYNCIFY`).
9	That boots the full GUI, but **Python→C calls made during the Qt event loop
10	crash in `_PyEM_TrampolineCall` ("unreachable")**: CPython's
11	`PY_CALL_TRAM  …[truncated]
Fable
thinking
These research notes show that earlier attempts (R8 as a plan, R10 as the definitive matrix) concluded ASYNCIFY+JS-EH+JS-trampoline is the only viable combination. The core issue blocking JSPI is that it either requires wasm-EH through Qt's gate, or JSPI+JS-EH becomes a dead-end since invoke_* JS frames can't be suspended across it, and wasm-EH itself hits a blocker with LLVM.

The real problem is a chain: JSPI needs wasm-EH to avoid those JS invoke_* frames, but wasm-EH causes LLVM to miscompile OCCT's try_table into invalid br_table instructions. Before diving into Dimension C analysis (benefit, effort, risk), I should verify whether these blockers still apply in emscripten 4.0.12 with cla  …
Fable
⚙ Bash {"command": "echo \"=== find built FreeCAD.wasm ===\"; find /opt/toolchains -name \"*.wasm\" \\( -path \"*freecad*\" -o -name \"FreeCAD*\" \\) 2>/dev/null | xargs -r ls -la 2>/dev/null | head; echo \"=== any freecad wasm anywhere ===\"; fin…
tool result
=== find built FreeCAD.wasm ===
-rwx------ 1 magik6k magik6k 200485650 Jul  4 01:27 /opt/toolchains/freecad-gui-web/FreeCAD.wasm
-rwxr-xr-x 1 magik6k magik6k 204832049 Jul  6 01:04 /opt/toolchains/src/freecad-gui-build/bin/FreeCAD.wasm
-rwxr-xr-x 1 magik6k magik6k     17033 Jul  5 22:04 /opt/toolchains/src/freecad-gui-build/src/3rdParty/libE57Format/CMakeFiles/_CMakeLTOTest-C/bin/boo.wasm
-rwxr-xr-x 1 magik6k magik6k     17033 Jul  5 22:04 /opt/toolchains/src/freecad-gui-build/src/3rdParty/libE5  …[truncated]
Fable
⚙ Read {"file_path": "/home/magik6k/lcad-wasm/freecad-port/research/R5-dialogs-files-jspi.md"}
tool result
1	# R5 — Modal Dialogs, JSPI, File I/O, Networking
2	
3	## 1. Modal inventory (measured)
4	
5	`exec()` on dialogs/menus/event-loops:
6	
7	| Area | C++ sites | Python sites |
8	|---|---|---|
9	| src/Gui (core) | 81 | 2 |
10	| PartDesign | 18 | 2 |
11	| Sketcher | 16 | 1 |
12	| TechDraw (out of MVP) | 12 | 17 |
13	| Part | 4 | 18 |
14	| Fem (out) / Draft (T3) | 3 / 0 | 7 / 9 |
15	| **Total src/** | **185** | **156** |
16	
17	Plus ~119 path-returning file-dialog call sites — but FreeCAD wraps
18	QF  …[truncated]
Fable
thinking
Now I need to verify whether JSPI and JavaScript exception handling still fail to suspend across invoke frames in emscripten 4.0.12, and check if the OCCT wasm-EH miscompile issue persists in clang-22. I'm focusing on Dimension C for this testing.

I'll build two isolated tests: one to check if suspending inside a try/catch block fails with JSPI+JS-EH, and another to see if OCCT's try_table/catch_all_ref pattern still miscompiles to an invalid br_table. These are the core blockers that determine whether the effort and risk are manageable for the verdict.
Fable
Let me test the two core blockers in isolated dirs. First the JSPI+JS-EH suspend-across-invoke question, and the OCCT wasm-EH miscompile.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/jspi-jseh-test; rm -rf $D; mkdir -p $D; cd $D\ncat > main.cpp <<'EOF'\n#include <cstdio>\n#include <emscripten.h>\n\n// async import that returns a promise we await via …
tool result
compile attempt below
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\ngrep -n \"JSPI\\|promising\\|WebAssembly.Suspend\\|suspendOnReturnedPromise\\|invoke_\" /opt/toolchains/emsdk/upstream/emscripten/src/library_async.js 2>/dev/null | head -40\ne…
tool result
=== does emscripten warn about JSPI + JS-EH mixing? ===
/opt/toolchains/emsdk/upstream/emscripten/tools/link.py:1772:      diagnostics.warning('experimental', '-sASYNCIFY=2 (JSPI) is still experimental')
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
I need to examine how the JSPI implementation in library_async.js handles the invoke_ function, particularly when suspending inside a try/catch block. Let me create a concrete test case that demonstrates this behavior and run it under Node 22 to see what actually happens.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\necho \"=== JSPI impl markers in library_async.js ===\"\ngrep -n \"JSPI\\|promising\\|Suspend\\|WebAssembly\\.\" /opt/toolchains/emsdk/upstream/emscripten/src/library_async.js 2…
tool result
=== JSPI impl markers in library_async.js ===

=== node version + JSPI flag support ===
v22.16.0
  --trace-wasm-stack-switching (trace wasm stack switching)
        type: bool  default: --no-trace-wasm-stack-switching
  --wasm-stack-switching-stack-size (default size of stacks for wasm stack-switching (in kB))
        type: int  default: --wasm-stack-switching-stack-size=984
  --experimental-wasm-pgo-to-file (experimental: dump Wasm PGO information to a local file (for testing))
        type: bo  …[truncated]
Fable
thinking
JSPI is available in Node 22 through V8's wasm stack switching and is enabled by default, so I don't need any experimental flags. Now I need to write an actual test I can run and locate the JSPI library code.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\ngrep -rln \"promising\\|WebAssembly.Suspend\\|JSPI\" /opt/toolchains/emsdk/upstream/emscripten/src/*.js 2>/dev/null | head\necho \"=== jspi lib file ===\"\nls /opt/toolchains/e…
tool result
/opt/toolchains/emsdk/upstream/emscripten/src/postamble.js
/opt/toolchains/emsdk/upstream/emscripten/src/preamble.js
/opt/toolchains/emsdk/upstream/emscripten/src/settings.js
=== jspi lib file ===
libasync.js
/opt/toolchains/emsdk/upstream/emscripten/src/parseTools.mjs
/opt/toolchains/emsdk/upstream/emscripten/src/lib/libasync.js
/opt/toolchains/emsdk/upstream/emscripten/src/lib/libcore.js
/opt/toolchains/emsdk/upstream/emscripten/src/lib/libembind.js
/opt/toolchains/emsdk/upstream/emscripten/sr  …[truncated]
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\ngrep -n \"JSPI\\|Suspending\\|promising\\|WebAssembly.Suspend\\|invoke\" /opt/toolchains/emsdk/upstream/emscripten/src/lib/libasync.js 2>/dev/null | head -60", "description": "…
tool result
35:    // Asyncify code that is shared between mode 1 (original) and mode 2 (JSPI).
49:      assert('Suspending' in WebAssembly, 'JSPI not supported by current environment. Perhaps it needs to be enabled via flags?');
62:            imports[x] = original = new WebAssembly.Suspending(original);
79:              // invoke_* functions are allowed to change the state if we do
81:              var ignoredInvoke = x.startsWith('invoke_') &&
166:          // Wrap all exports with a promising WebAssembl  …[truncated]
Fable
thinking
I'm spotting the special handling for invoke_* frames around line 79-81 — this is where the emscripten JSPI logic kicks in for managing those particular frame types.
Fable
⚙ Read {"file_path": "/opt/toolchains/emsdk/upstream/emscripten/src/lib/libasync.js", "offset": 40, "limit": 130}
tool result
40	    instrumentWasmImports(imports) {
41	#if EMBIND_GEN_MODE
42	      // Instrumenting is not needed when generating code.
43	      return imports;
44	#endif
45	#if ASYNCIFY_DEBUG
46	      dbg('asyncify instrumenting imports');
47	#endif
48	#if ASSERTIONS && ASYNCIFY == 2
49	      assert('Suspending' in WebAssembly, 'JSPI not supported by current environment. Perhaps it needs to be enabled via flags?');
50	#endif
51	      var importPattern = {{{ new RegExp(`^(${ASYNCIFY_IMPORTS_EXCEPT_JS_LIBS.  …[truncated]
Fable
thinking
I need to run an actual test to see whether JSPI can suspend across an `invoke_*` frame when using JS exception handling. The core question is whether V8's stack-switching mechanism can handle suspension through the JavaScript wrapper that gets generated when compiling with `-fexceptions`.

The empirical finding suggests the suspend point is reached, but the chain breaks because there's a JS `invoke_` frame in the middle that the suspender can't cross. I'll build a minimal reproduction with the right compiler flags (`-fexceptions -sDISABLE_EXCEPTION_CATCHING=0 -sJSPI=1`), create an exported function that calls an async import inside a try/catch block, and then check the generated wasm to see  …
Fable
Now let me build a faithful runnable reproduction of the crux: JSPI suspending across a JS-EH try/catch frame, under emscripten 4.0.12.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/jspi-jseh-test; rm -rf $D; mkdir -p $D; cd $D\ncat > main.cpp <<'EOF'\n#include <cstdio>\n#include <emscripten.h>\nextern \"C\" {\n// async import: returns a Promise; JS…
tool result
em++: warning: -sASYNCIFY=2 (JSPI) is still experimental [-Wexperimental]
wasm-ld: error: /tmp/emscripten_temp_jgcq6nzn/main_0.o: undefined symbol: async_sleep
em++: error: '/opt/toolchains/emsdk/upstream/bin/wasm-ld -o a.out.wasm /tmp/emscripten_temp_jgcq6nzn/main_0.o -L/opt/toolchains/emsdk/upstream/emscripten/cache/sysroot/lib/wasm32-emscripten -L/opt/toolchains/emsdk/upstream/emscripten/src/lib -lGL-getprocaddr -lal -lhtml5 -lstubs -lnoexit -lc -ldlmalloc -lcompiler_rt -lc++ -lc++abi -lsocke  …[truncated]
Fable
thinking
I'm running into a linking issue where the `async_sleep` symbol isn't being found when I pass the JavaScript library through stdin with a heredoc. The problem seems to be that the js-library definition needs to provide that symbol, but the way I'm passing the heredoc alongside the `--js-library /dev/stdin` flag is causing a conflict.

Let me just write the js-library to a temporary file instead to avoid the stdin collision.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/jspi-jseh-test; cd $D\ncat > lib.js <<'LIB'\naddToLibrary({\n async_sleep__async: true,\n async_sleep: function() {\n return new Promise(function(res){ setTimeout(f…
tool result
em++: warning: -sASYNCIFY=2 (JSPI) is still experimental [-Wexperimental]
=== compile exit ===
-rwxr-xr-x 1 magik6k magik6k 16181 Jul  6 01:07 app.wasm
=== invoke_ / async_sleep imports ===
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
The wasm-objdump isn't showing any invoke_ imports, which is odd since they should appear as JavaScript imports when using `-fexceptions`. I'm wondering if the exception handling is being processed differently with the WASM_BIGINT flag, or if I'm looking at the wrong import list. Let me verify the imports through a different approach.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/jspi-jseh-test; cd $D\necho \"=== all imports ===\"\n/opt/toolchains/emsdk/upstream/bin/wasm-objdump -j Import -x app.wasm 2>/dev/null | head -40\necho \"=== grep invoke…
tool result
=== all imports ===
=== grep invoke in generated js ===
11
invoke_
invoke_i
invoke_ii
invoke_v
invoke_vi
invoke_vii
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
The generated JavaScript includes the `invoke_ii` function, which is what I expected for the indirect call through the function pointer. The `wasm-objdump` tool at that path might not be available or isn't showing the imports, but since the JS clearly has `invoke_ii` present, I should just run the test in Node to see if it's actually being called on the stack.

Let me write the test runner to check whether suspend-across-invoke works or fails with the "suspend without promising" error.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/jspi-jseh-test; cd $D\ncat > run.mjs <<'EOF'\nimport Mod from './app.js';\nconst m = await Mod();\ntry {\n // run is a JSPI export -> returns a Promise\n const p = m.c…
tool result
file:///tmp/jspi-jseh-test/run.mjs:2
const m = await Mod();
                ^

TypeError: Mod is not a function
    at file:///tmp/jspi-jseh-test/run.mjs:2:17
    at ModuleJob.run (node:internal/modules/esm/module_job:274:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:644:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5)

Node.js v22.16.0
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/jspi-jseh-test; cd $D\nhead -3 app.js; echo \"...\"; grep -n \"module.exports\\|export default\\|MODULARIZE\\|EXPORT_ES6\" app.js | head\necho \"=== recompile MODULARIZE…
tool result
// include: shell.js
// The Module object: Our interface to the outside world. We import
// and export values on it. There are various ways Module can be used:
...
38:// In MODULARIZE mode _scriptName needs to be captured already at the very top of the page immediately when the page is parsed, so it is generated there
39:// before the page load. In non-MODULARIZE modes generate it here.
90:  // MODULARIZE will export the module in the proper place outside, we don't need to export here
366:  // T  …[truncated]
Fable
thinking
I need to enable the JSPI flag in Node 22.16 to expose WebAssembly.Suspending, so I'll use either `--experimental-wasm-jspi` or `--wasm-jspi` when running Node.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nnode --v8-options 2>/dev/null | grep -iE \"jspi|wasm-jspi|wasm.*switching\" | head\necho \"=== try flags ===\"\nD=/tmp/jspi-jseh-test; cd $D\nnode --experimental-wasm-jspi run.…
tool result
  --trace-wasm-stack-switching (trace wasm stack switching)
        type: bool  default: --no-trace-wasm-stack-switching
  --wasm-stack-switching-stack-size (default size of stacks for wasm stack-switching (in kB))
        type: int  default: --wasm-stack-switching-stack-size=984
  --experimental-wasm-stack-switching (enable stack switching for Wasm (experimental))
        type: bool  default: --no-experimental-wasm-stack-switching
  --experimental-wasm-jspi (enable javascript promise integratio  …[truncated]
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/jspi-jseh-test; cd $D\nnode --experimental-wasm-jspi --experimental-wasm-stack-switching --wasm-jspi run.mjs 2>&1 | head -6\necho \"=== check what enables Suspending ===…
tool result
node: bad option: --wasm-jspi
=== check what enables Suspending ===
undefined
node: bad option: --wasm-jspi
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
The `--experimental-wasm-jspi` flag in Node 22.16 might be exposing an older version of the JSPI API rather than the finalized `WebAssembly.Suspending` API. I should investigate what's actually available with this flag to understand which API variant is being exposed.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\necho \"node 22.16 v8:\"; node -e \"console.log(process.versions.v8)\"\nnode --experimental-wasm-jspi -e \"console.log('Suspending', typeof WebAssembly.Suspending); console.log(…
tool result
node 22.16 v8:
12.4.254.21-node.26
Suspending undefined
promising undefined
Function function
=== is there a newer node in emsdk? ===
22.16.0_64bit
/opt/toolchains/emsdk/node/22.16.0_64bit/bin/node
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
Node 22.16 with V8 12.4 doesn't support the phase-4 WebAssembly JSPI API (Suspending/promising constructors) that shipped in V8 12.9, so it's too old to properly test the current emscripten JSPI implementation.

I need Node 24+ or Chrome 137+ to actually run and verify this code. Let me check what's available on the system or if I can install a newer Node version for testing.
Fable
⚙ Bash {"command": "for n in /usr/bin/node /usr/local/bin/node $(which -a node 2>/dev/null); do\n [ -x \"$n\" ] && echo \"$n -> $($n --version 2>/dev/null) v8=$($n -e 'console.log(process.versions.v8)' 2>/dev/null)\"\ndone\necho \"=== does any ex…
tool result
Exit code 2
/usr/bin/node -> v26.2.0 v8=14.6.202.34-node.20
/bin/node -> v26.2.0 v8=14.6.202.34-node.20
/sbin/node -> v26.2.0 v8=14.6.202.34-node.20
/usr/bin/node -> v26.2.0 v8=14.6.202.34-node.20
/usr/sbin/node -> v26.2.0 v8=14.6.202.34-node.20
/bin/node -> v26.2.0 v8=14.6.202.34-node.20
/sbin/node -> v26.2.0 v8=14.6.202.34-node.20
/usr/bin/node -> v26.2.0 v8=14.6.202.34-node.20
/usr/sbin/node -> v26.2.0 v8=14.6.202.34-node.20
/bin/node -> v26.2.0 v8=14.6.202.34-node.20
/sbin/node -> v26.2.0 v8  …[truncated]
Fable
thinking
System Node v26.2.0 has V8 14.6, which includes phase-4 JSPI with suspending and promising enabled by default since V8 12.9, so I should be able to test this without needing any special flags.
Fable
⚙ Bash {"command": "/usr/bin/node -e \"console.log('Suspending', typeof WebAssembly.Suspending, '| promising', typeof WebAssembly.promising)\"\necho \"=== run the suspend-across-invoke test with node 26 ===\"\nD=/tmp/jspi-jseh-test; cd $D\n/usr/bi…
tool result
Suspending function | promising function
=== run the suspend-across-invoke test with node 26 ===
ERROR: trying to suspend without WebAssembly.promising
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
I've reproduced the R10 error about suspending without WebAssembly.promising, and I'm trying to narrow down whether it's because the `run` export isn't properly wrapped in promising or if the invoke_ frame is breaking the suspension path. Let me test a control case with the same code but without the try/catch to see if removing the invoke_ frame on the suspend path makes a difference.

The issue is that when a Suspending import gets called, V8 needs the root export to be created via promising — but if there's a JS frame like invoke_ii sitting between the promising root and the actual suspend point, V8 sees the suspension happening on a stack segment that isn't rooted at promising, since that  …
Fable
Reproduced the R10 error exactly. Now the control: same suspend WITHOUT the try/catch (no invoke_ frame on the suspend path).
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/jspi-ctrl; rm -rf $D; mkdir -p $D; cd $D\ncp /tmp/jspi-jseh-test/lib.js .\ncat > main.cpp <<'EOF'\n#include <emscripten.h>\nextern \"C\" { int async_sleep(void); }\nexte…
tool result
ERROR: trying to suspend without WebAssembly.promising
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
The control is also failing, which means the issue isn't with the invoke frame itself—even a basic suspend call throws the "trying to suspend without WebAssembly.promising" error. This tells me my test harness is misconfigured: the export isn't being routed through the promising wrapper. Either ccall with async:true isn't properly connecting to the wrapper, or I need to register the export in JSPI_EXPORTS. Let me look into how emscripten handles this, since it should be wrapping all exports automatically.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/jspi-ctrl; cd $D\ngrep -n \"JSPI_EXPORTS\\|ASYNCIFY_EXPORTS\\|exportPattern\" /opt/toolchains/emsdk/upstream/emscripten/src/lib/libasync.js | head\necho \"=== default: h…
tool result
159:      var exportPattern = {{{ new RegExp(`^(${ASYNCIFY_EXPORTS.join('|').replace(/\*/g, '.*')})$`) }}};
167:          let isAsyncifyExport = exportPattern.test(x);
=== default: how is run wrapped? check settings default ===
// - 1: Minimal logging.
// - 2: Verbose logging.
//
// [link]
var ASYNCIFY_DEBUG = 0;

// Deprecated, use JSPI_EXPORTS instead.
// [deprecated]
var ASYNCIFY_EXPORTS = [];

// Use VM support for the JavaScript Promise Integration proposal. This allows
// async operations   …[truncated]
Fable
thinking
I see the issue now—my `run` export isn't being wrapped in promising because it's not included in the default JSPI_EXPORTS list. I need to explicitly set `-sJSPI_EXPORTS=run` to make the suspend work, but I'll need to clean up my build configuration to avoid the linker errors I ran into before.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/jspi-ctrl; cd $D\nem++ main.cpp -O1 -sJSPI=1 -sWASM_BIGINT=1 -fexceptions -sDISABLE_EXCEPTION_CATCHING=0 \\\n -sEXPORTED_RUNTIME_METHODS=ccall -sMODULARIZE=1 -sEXPORT_E…
tool result
control (no try/catch):
ERROR: trying to suspend without WebAssembly.promising
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
The export name might need to be `run` instead of `_run`, or the ccall async path isn't properly using the wrapped export. Let me add some debugging to check what's actually in asyncExports and whether ccall is invoking it correctly, then try calling the export directly through Module or checking the async exports object.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/jspi-ctrl; cd $D\n# Try both name forms and direct call\nem++ main.cpp -O1 -sJSPI=1 -sWASM_BIGINT=1 -fexceptions -sDISABLE_EXCEPTION_CATCHING=0 \\\n -sEXPORTED_RUNTIME_…
tool result
typeof _run: function
direct result: Promise { <pending> } instanceof Promise: true
awaited: 8 (expect 8)
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
The control path works correctly now—returning 8 as expected with the direct call and proper exports. The real problem was in the `ccall` async handling, not the export wrapper itself, so JSPI's basic suspend mechanism is functioning. Now I need to test the try/catch version with the invoke_ frame on the suspend path using the same correct approach.
Fable
Control works (returns 8). Now the decisive test: suspend INSIDE a try/catch (invoke_ frame on the stack).
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/jspi-jseh-test; cd $D\nem++ main.cpp -O1 -sJSPI=1 -sWASM_BIGINT=1 -fexceptions -sDISABLE_EXCEPTION_CATCHING=0 \\\n -sEXPORTED_RUNTIME_METHODS=ccall -sEXPORTED_FUNCTIONS…
tool result
=== suspend inside try/catch (JS-EH invoke_ on stack) ===
ERROR: trying to suspend JS frames
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
The error message is clearer now—JSPI can't suspend across JS exception handling frames even with the latest V8 14.6, which confirms the R10 blocker persists in emscripten 4.0.12 with clang-22. I'm wondering if the suspension itself is actually...

Actually, I see it now: the suspend point in `async_sleep()` happens after `fp(5)` returns, but since `async_sleep()` is an extern C function with no noexcept guarantee, the compiler wraps the call in an invoke_ frame for exception handling. So the suspend occurs inside that JS frame, which is exactly why JSPI rejects it. This mirrors the FreeCAD problem perfectly—Qt's exec() and OCCT calls are wrapped in try/catch blocks throughout the codebase,   …
Fable
Definitive: **"trying to suspend JS frames"** — JSPI cannot suspend across JS-EH invoke_ frames, even on V8 14.6 (latest, mid-2026). Now let me verify the other half: does wasm-EH still miscompile OCCT-style rethrow under clang-22?
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\necho \"clang: $(clang --version | head -1)\"\nD=/tmp/wasmeh-occt; rm -rf $D; mkdir -p $D; cd $D\n# OCCT-style: pervasive rethrow, catch(...), nested try, re-raise of a base exc…
tool result
Exit code 2
clang: clang version 22.1.5
=== compile with -fwasm-exceptions (native wasm EH) ===
occt.cpp:4:47: error: no matching constructor for initialization of 'Standard_Failure'
    4 | __attribute__((noinline)) void raise(){ throw Standard_Failure{"boom"}; }
      |                                               ^               ~~~~~~~~
occt.cpp:3:8: note: candidate constructor (the implicit copy constructor) not viable: no known conversion from 'const char[5]' to 'const Standard_Failure' f  …[truncated]
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/wasmeh-occt; cd $D\nsed -i 's/struct Standard_Failure { const char\\* msg;/struct Standard_Failure { const char* msg=0;/' occt.cpp\nem++ occt.o 2>/dev/null; em++ occt.cp…
tool result
Exit code 2
occt.cpp:4:47: error: no matching constructor for initialization of 'Standard_Failure'
    4 | __attribute__((noinline)) void raise(){ throw Standard_Failure{"boom"}; }
      |                                               ^               ~~~~~~~~
occt.cpp:3:8: note: candidate constructor (the implicit copy constructor) not viable: no known conversion from 'const char[5]' to 'const Standard_Failure' for 1st argument
    3 | struct Standard_Failure { const char* msg=0; virtual ~Standa  …[truncated]
Fable
thinking
The virtual destructor is breaking aggregate initialization, so I'll switch to a regular constructor instead. But I'm realizing this synthetic test might not actually reproduce the real br_table miscompile—R10 mentioned it only happens with actual OCCT functions like those in TKFillet. A passing synthetic case wouldn't prove the issue is fixed, so I should try compiling a real OCCT source file to see if occt-wasm has the necessary sources and headers available.

This is touching on the wasm-EH miscompile problem, which might be another agent's domain, but for my Dimension C analysis I need to establish whether JSPI is even viable. The core constraint is clear: JSPI requires wasm-EH, and R10   …
Fable
⚙ Bash {"command": "ls /opt/toolchains/occt-wasm/ 2>/dev/null; echo \"---\"; find /opt/toolchains -maxdepth 3 -name \"*.cxx\" -path \"*occt*\" 2>/dev/null | head -3; echo \"=== occt src tree? ===\"; ls -d /opt/toolchains/src/occt* /opt/toolchains/…
tool result
bin
include
lib
share
---
=== occt src tree? ===
(eval):1: no matches found: /opt/toolchains/src/occt*
/opt/toolchains/src/OCCT-7_8_1
=== include dir ===
opencascade
cmake
libTKBin.a
libTKBinL.a
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nSRC=/opt/toolchains/src/OCCT-7_8_1\n# Find a file R10 flagged: TKGeomAlgo / TKShHealing / TKFillet / TKTopAlgo with rethrow patterns\nls $SRC/src | grep -iE \"TKGeomAlgo|TKShHe…
tool result
TKFillet
TKGeomAlgo
TKShHealing
TKTopAlgo
=== files with OCC_CATCH_SIGNALS + rethrow in GeomAlgo-ish packages ===
/opt/toolchains/src/OCCT-7_8_1/src/ShapeFix/ShapeFix_Edge.cxx
/opt/toolchains/src/OCCT-7_8_1/src/BRepFill/BRepFill_OffsetWire.cxx
/opt/toolchains/src/OCCT-7_8_1/src/ShapeFix/ShapeFix_EdgeProjAux.cxx
/opt/toolchains/src/OCCT-7_8_1/src/ShapeFix/ShapeFix_Solid.cxx
/opt/toolchains/src/OCCT-7_8_1/src/ShapeFix/ShapeFix_FaceConnect.cxx
zshexit  …[truncated]
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nSRC=/opt/toolchains/src/OCCT-7_8_1\nINC=/opt/toolchains/occt-wasm/include/opencascade\nD=/tmp/wasmeh-occt; cd $D\nF=$SRC/src/ShapeFix/ShapeFix_Edge.cxx\necho \"=== compile a RE…
tool result
=== compile a REAL OCCT file with -fwasm-exceptions -O2 ===
compile exit; obj:
-rw-r--r-- 1 magik6k magik6k 41474 Jul  6 01:10 real.o
=== now run wasm-opt on it the way emscripten link would (validate) ===
em++: warning: -sASYNCIFY=2 (JSPI) is still experimental [-Wexperimental]
link exit; wasm:
-rwxr-xr-x 1 magik6k magik6k 173 Jul  6 01:10 rmod.wasm
=== VALIDATE in V8 ===
valid: true
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
The wasm file is only 173 bytes because the OCCT object's functions got stripped out during linking—nothing's exporting them and undefined symbols aren't being kept. I need to force these functions to stay in the binary by explicitly exporting them or using whole-archive linking, then run wasm-opt to optimize them properly.

The real issue is that the miscompile is already baked into the object file at the LLVM codegen stage—there's a br_table instruction with mismatched arity. Binaryen can't even parse it, and V8 rejects it outright. So I need to get those functions into the wasm first, then validate them.

Let me try forcing all symbols to be exported and kept by using linker flags like `-  …
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/wasmeh-occt; cd $D\necho \"=== keep all functions: --no-gc-sections + export-all ===\"\nem++ real.o -O2 -fwasm-exceptions -sJSPI=1 -sERROR_ON_UNDEFINED_SYMBOLS=0 \\\n -…
tool result
=== keep all functions: --no-gc-sections + export-all ===
em++: warning: -sASYNCIFY=2 (JSPI) is still experimental [-Wexperimental]
wasm-ld: error: real.o: undefined symbol: typeinfo for Standard_Transient
wasm-ld: error: real.o: undefined symbol: typeinfo for Geom_Geometry
wasm-ld: error: real.o: undefined symbol: typeinfo for Geom_Surface
wasm-ld: error: real.o: undefined symbol: typeinfo for Geom2d_Geometry
wasm-ld: error: real.o: undefined symbol: typeinfo for Geom2d_Curve
wasm-ld: error: re  …[truncated]
Fable
thinking
I'm noticing that emscripten 4.0.12 is defaulting to legacy exception handling with the `-mllvm -wasm-use-legacy-eh` flag rather than the newer try_table approach, which is unexpected. This legacy encoding apparently fails Binaryen's parsing, so I need to get the OCCT to properly test this.

The challenge is that I can't just run wasm-opt directly on the object file since it's not a complete module. Linking against the actual OCCT wasm libraries won't work either since they were built with JS-EH, which would conflict. I'm trying to create a standalone wasm by linking the object with `-sERROR_ON_UNDEFINED_SYMBOLS=0` and `--import-undefined` to treat undefined symbols as imports, but keeping f  …
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/wasmeh-occt; cd $D\necho \"=== link relocatable-ish: keep funcs, undefined->imports, run wasm-opt at -O2 ===\"\nem++ real.o -O2 -fwasm-exceptions -sERROR_ON_UNDEFINED_SY…
tool result
=== link relocatable-ish: keep funcs, undefined->imports, run wasm-opt at -O2 ===
wasm-ld: error: real.o: undefined symbol: typeinfo for Standard_Transient
wasm-ld: error: real.o: undefined symbol: typeinfo for Geom_Geometry
wasm-ld: error: real.o: undefined symbol: typeinfo for Geom_Surface
wasm-ld: error: real.o: undefined symbol: typeinfo for Geom2d_Geometry
wasm-ld: error: real.o: undefined symbol: typeinfo for Geom2d_Curve
wasm-ld: error: real.o: undefined symbol: typeinfo for Geom2d_Curve
  …[truncated]
Fable
thinking
I'm noticing two key details from the emscripten linker command: first, emscripten 4.0.12 is defaulting to legacy wasm exception handling rather than the newer try_table approach, which was R10's main issue, though R10 also reported that legacy encoding fails Binaryen parsing. Second, the command includes `--import-undefined` but the thought cuts off before explaining its significance.

The next part reveals that `--import-undefined` only handles function imports, not data symbols like typeinfo, which need `--allow-undefined` or `--unresolved-symbols` instead. There's some exploration of linking against the actual OCCT static libraries built with JS-EH, and trying different linker flags like  …
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/wasmeh-occt; cd $D\nem++ real.o -O2 -fwasm-exceptions -sERROR_ON_UNDEFINED_SYMBOLS=0 \\\n -Wl,--unresolved-symbols=ignore-all -Wl,--import-undefined -Wl,--no-gc-section…
tool result
exit; wasm:
-rwxr-xr-x 1 magik6k magik6k 18314 Jul  6 01:12 keep.wasm
bytes: 18314 V8 valid: true
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
So with emscripten 4.0.12 and clang-22 using the legacy wasm exception handling encoding, ShapeFix_Edge.cxx actually compiles into a valid 18KB wasm module that passes V8 validation and runs through Binaryen's optimizer without errors—a significant improvement from the earlier 173-byte failure. But I'm wondering if this 18KB size is actually reasonable for what we're trying to accomplish.

The key question is whether R10's issue with the new try_table encoding is actually fixed in clang-22, or if ShapeFix_Edge just isn't the problematic function. I should test a few more of the specifically flagged heavy files from TKFillet, TKGeomAlgo, and TKTopAlgo to see if they also compile cleanly with   …
Fable
Legacy wasm-EH is now the emscripten 4.0.12 default and ShapeFix_Edge validated fine. Let me batch-test the specific packages R10 flagged (TKFillet/ChFi3d, TKGeomAlgo, TKTopAlgo) to see if the miscompile still reproduces.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nSRC=/opt/toolchains/src/OCCT-7_8_1; INC=/opt/toolchains/occt-wasm/include/opencascade\nD=/tmp/wasmeh-batch; rm -rf $D; mkdir -p $D; cd $D\n# Pick heavy exception-using files fr…
tool result
Testing 12 files (both legacy default + new try_table):
  compile-fail(skip): BOPAlgo_ArgumentAnalyzer.cxx
LEGACY: pass=0 fail=0
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
The first compilation is failing, likely due to missing include directories since OCCT requires many header paths. ShapeFix_Edge compiled successfully earlier because it had fewer dependencies, though I might also need to define rapidjson. Let me look at what the actual error is.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\ncat /tmp/e1 | head -8", "description": "Check compile error"}
tool result
clang++: error: no such file or directory: '/opt/toolchains/src/OCCT-7_8_1/src/ChFi3d/ChFi3d_Builder.cxx
/opt/toolchains/src/OCCT-7_8_1/src/BRepFill/BRepFill_OffsetWire.cxx
/opt/toolchains/src/OCCT-7_8_1/src/BRepMesh/BRepMesh_BaseMeshAlgo.cxx
/opt/toolchains/src/OCCT-7_8_1/src/BRepMesh/BRepMesh_Delaun.cxx
/opt/toolchains/src/OCCT-7_8_1/src/GeomFill/GeomFill_Sweep.cxx
/opt/toolchains/src/OCCT-7_8_1/src/BRepMesh/BRepMesh_EdgeDiscret.cxx
/opt/toolchains/src/OCCT-7_8_1/src/BRepMesh/BRepMesh_ModelHea  …[truncated]
Fable
thinking
I see the issue now — in zsh, unquoted variables don't word-split by default like they do in bash, so `$FILES` is being treated as a single argument rather than being split across lines. I need to use parameter expansion to force the splitting behavior.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nSRC=/opt/toolchains/src/OCCT-7_8_1; INC=/opt/toolchains/occt-wasm/include/opencascade\nD=/tmp/wasmeh-batch; cd $D\ngrep -rl \"OCC_CATCH_SIGNALS\\|Standard_Failure::Raise\\|thro…
tool result
12 files.txt
  PASS: ChFi3d_Builder.cxx
  PASS: BRepFill_OffsetWire.cxx
  PASS: BOPAlgo_PaveFiller_7.cxx
  PASS: BOPAlgo_Tools.cxx
  PASS: BRepMesh_BaseMeshAlgo.cxx
  PASS: BRepMesh_EdgeDiscret.cxx
  PASS: BRepMesh_ModelHealer.cxx
  PASS: BRepMesh_Delaun.cxx
  PASS: BRepMesh_DataStructureOfDelaun.cxx
  PASS: BOPAlgo_ArgumentAnalyzer.cxx
  PASS: BOPAlgo_PaveFiller.cxx
  PASS: BOPAlgo_CheckerSI.cxx
== LEGACY wasm-EH: pass=12 fail=0 compile-skip=0 ==
zshexit:1: parameter HISTDB_FD does not contain   …[truncated]
Fable
thinking
All 12 tests are passing with the legacy wasm-EH setup under clang-22, which suggests the R10 blocker around wasm-EH miscompilation in OCCT might actually be fixed in this newer compiler version. Now I'm going to test the specific packages R10 flagged—TKFillet, TKGeomAlgo, TKShHealing, and TKTopAlgo—where TKGeomAlgo pulls in GeomFill and IntPat modules.

I should also test files that use the catch-all rethrow pattern (the `catch(...) { throw; }` construct that R10 specifically identified as problematic with try_table/catch_all_ref encoding), and run the same tests against the new try_table encoding to make sure both approaches work correctly.
Fable
12/12 pass. Let me widen to the exact rethrow pattern R10 flagged (`catch(...){throw;}` → try_table/catch_all_ref) and also test the NEW try_table encoding.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nSRC=/opt/toolchains/src/OCCT-7_8_1; INC=/opt/toolchains/occt-wasm/include/opencascade\nD=/tmp/wasmeh-batch; cd $D\n# files with catch(...) followed by rethrow — the exact catch…
tool result
candidate rethrow files: 2
== rethrow-pattern LEGACY: pass=1 fail=0 cskip=1 ==

=== NEW try_table encoding on the same set (force -wasm-use-legacy-eh=false) ===
  NEW V8-INVALID: step.tab.cxx
== rethrow-pattern NEW try_table: pass=0 fail=1 ==
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
My grep pattern was too restrictive—it only caught 2 files because OCCT uses different catch syntax like `catch (Standard_Failure`. The legacy approach compiled one file successfully but skipped another, while the new try_table encoding produces an invalid V8 module for step.tab.cxx, which is a yacc-generated parser file rather than hand-written code.

I need to test step.tab.cxx directly under both encodings to isolate whether this is the specific case that R10 flagged—that's where the real difference will show up.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nSRC=/opt/toolchains/src/OCCT-7_8_1; INC=/opt/toolchains/occt-wasm/include/opencascade\nD=/tmp/wasmeh-batch; cd $D\nF=$(cat files2.txt | grep step.tab)\necho \"file: $F\"\nfor e…
tool result
file: /opt/toolchains/src/OCCT-7_8_1/src/StepFile/step.tab.cxx
=== legacy(default) ===
V8 valid: true
=== new ===
compile failed: clang++: error: unknown argument: '-mllvm -wasm-use-legacy-eh=false'
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
I see the issue now — the `-mllvm -wasm-use-legacy-eh=false` flags need to be passed as separate arguments rather than quoted together. The earlier "NEW-INVALID" result for step.tab.cxx was misleading because the compile actually failed due to the unrecognized argument, so there was no on.o file generated, yet the linker somehow picked up a stale tn.wasm from a previous run.

Since emscripten 4.0.12 defaults to legacy exception handling anyway and that configuration passes all tests including step.tab, the practical build will work fine. The new try_table encoding isn't the default and wouldn't be shipped, but I should still verify it works correctly to confirm whether the underlying LLVM is  …
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nSRC=/opt/toolchains/src/OCCT-7_8_1; INC=/opt/toolchains/occt-wasm/include/opencascade\nD=/tmp/wasmeh-batch; cd $D\nF=$SRC/src/StepFile/step.tab.cxx\necho \"=== NEW try_table en…
tool result
=== NEW try_table encoding (properly split args) ===
compile: ok
NEW try_table V8 valid: false

=== confirm what emscripten 4.0.12 uses by DEFAULT ===
exception-model=wasm
wasm-use-legacy-eh
wasm-use-legacy-eh"
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
The new try_table encoding still triggers the LLVM miscompile in clang-22, but emscripten 4.0.12 defaults to legacy exception handling which successfully compiles all the test files including the problematic one.

I need to verify that the legacy wasm-EH path actually handles exceptions correctly at runtime when combined with JSPI suspend—that's the real constraint. Let me build a test that throws and catches an exception across a JSPI suspend boundary to confirm this configuration actually works end-to-end.
Fable
NEW try_table still miscompiles (V8 invalid) but emscripten 4.0.12 **defaults to legacy EH which works**. Now the decisive positive test: JSPI suspend INSIDE a try/catch under `-fwasm-exceptions` (no invoke_ frames) — the actual target config.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/jspi-wasmeh; rm -rf $D; mkdir -p $D; cd $D\ncat > lib.js <<'LIB'\naddToLibrary({\n async_sleep__async: true,\n async_sleep: function(){ return new Promise(r=>setTimeou…
tool result
=== wasm-EH + JSPI: suspend inside try/catch ===
RESULT = 114 (expect 114)
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
I've found a decisive result: the combination of wasm exceptions with JSPI actually works in the latest emscripten version, allowing suspension inside try/catch blocks that also throw and catch—something that was previously thought impossible. The wasm-EH encoding successfully compiles all the OCCT files including the problematic patterns, and JSPI can now suspend across try/catch boundaries without issues.

This resolves both blockers that R10 cited: the wasm-EH miscompilation is fixed in clang-22, and the JSPI+JS-EH suspend problem becomes irrelevant since we're using wasm-EH instead, which has no invoke frames to worry about. So the migration path is clear: use wasm-EH with JSPI, and Qt-j  …
Fable
**Breakthrough:** wasm-EH + JSPI suspends across try/catch correctly (114). The R10 blockers are resolved by the toolchain upgrade. Now let me quantify the size benefit and confirm browser support.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/size-test; rm -rf $D; mkdir -p $D; cd $D\n# Build a self-contained, sizable C++ module (many functions, call graph) 3 ways\npython3 -c \"\nprint('#include <vector>\\n#in…
tool result
Exit code 1
wasm code sizes (self-contained 400-fn module, -O2):
stat: cannot statx 'plain.wasm': No such file or directory
  plain   bytes
stat: cannot statx 'asy.wasm': No such file or directory
  asy     bytes
stat: cannot statx 'jspi.wasm': No such file or directory
  jspi    bytes
Traceback (most recent call last):
  File "<string>", line 3, in <module>
    p=os.path.getsize('plain.wasm'); a=os.path.getsize('asy.wasm'); j=os.path.getsize('jspi.wasm')
  File "<frozen genericpath>", line 86,   …[truncated]
Fable
thinking
I'm running into a zsh word-splitting issue with the COMMON variable — unquoted it won't split properly. I could use `${=COMMON}` to force word-splitting in zsh, switch to bash, or just write out the commands explicitly instead of relying on variable expansion.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/size-test; cd $D\nem++ big.cpp -O2 -fwasm-exceptions -sWASM_BIGINT=1 -sEXPORTED_FUNCTIONS=_run,_malloc -sMODULARIZE=1 -sEXPORT_ES6=1 --js-library lib.js -o plain.mjs 2>&…
tool result
Exit code 1
big.cpp:5:109: error: use of undeclared identifier 'f1'
big.cpp:6:109: error: use of undeclared identifier 'f2'
big.cpp:7:109: error: use of undeclared identifier 'f3'
big.cpp:8:109: error: use of undeclared identifier 'f4'
big.cpp:9:109: error: use of undeclared identifier 'f5'
big.cpp:10:109: error: use of undeclared identifier 'f6'
big.cpp:11:109: error: use of undeclared identifier 'f7'
big.cpp:12:109: error: use of undeclared identifier 'f8'
big.cpp:13:109: error: use of undecla  …[truncated]
Fable
thinking
I'm hitting a forward declaration issue where the generated functions reference each other before they're defined. I need to add function prototypes at the top to resolve this, then regenerate the code with the proper declarations in place.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/size-test; cd $D\npython3 -c \"\nprint('#include <cmath>\\n#include <emscripten.h>')\nprint('extern \\\"C\\\" int async_sleep(void);')\nfor i in range(400): print(f'doub…
tool result
wasm sizes (400-fn suspending call graph, -O2):
  plain  68770
  asy    70696
  jspi   68770
ASYNCIFY vs plain: +3%   JSPI vs plain: +0.0%
asyncify is 3% larger than JSPI
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
The asyncify instrumentation only affects functions that can actually reach a suspending import, so in my synthetic test, only the run() function gets instrumented since f0-f399 never call async_sleep directly. To properly measure the overhead, I need the functions to sit on a path that can suspend, which in real applications like FreeCAD means they'd be reachable from something like Qt's event loop.

The problem is that FreeCAD has tons of virtual calls through Qt, OCCT, and Coin, so asyncify's conservative analysis for indirect calls instruments a huge fraction of the codebase — that's where the real overhead comes from. I should modify my synthetic to be more realistic by making each func  …
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/size-test; cd $D\npython3 -c \"\nprint('#include <cmath>\\n#include <emscripten.h>')\nprint('extern \\\"C\\\" int async_sleep(void);')\nprint('volatile int gate=0;')\nfo…
tool result
sizes (400 fns ALL on suspend path):
  p2   75793
  a2   112982
  j2   75793
ASYNCIFY vs plain: +49%   JSPI vs plain: +0.0%
asyncify 49% larger than JSPI on the suspend path
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
When functions land on the suspend path, ASYNCIFY balloons the code by 49%, while JSPI adds nothing—aligning with what the research shows. The key insight is that JSPI keeps the binary size identical to uninstrumented code, whereas for something like FreeCAD with its Qt event loop and virtual-call-heavy architecture, a substantial portion of the module ends up on that suspend path, making the asyncify overhead particularly costly.

But the 200MB wasm isn't pure code—there's embedded data like the Python standard library, icons, and translations mixed in. I need to actually break down the section composition of the current FreeCAD binary to estimate realistic savings, especially since the bui  …
Fable
+49% code-size tax measured for asyncify on the suspend path; JSPI = 0%. Now let me see how much of the real 200MB FreeCAD.wasm is code (asyncify-taxable) vs data.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nW=/opt/toolchains/src/freecad-gui-build/bin/FreeCAD.wasm\necho \"total: $(stat -c%s $W) bytes ($(python3 -c \"print(f'{$(stat -c%s $W)/1e6:.0f} MB')\"))\"\necho \"=== section s…
tool result
total: 288458891 bytes (288 MB)
=== section sizes ===
  code                    230.0 MB
  data                     57.5 MB
  elem                      0.6 MB
  func                      0.2 MB
  type                      0.0 MB
  import                    0.0 MB
  export                    0.0 MB
  global                    0.0 MB
  memory                    0.0 MB
  table                     0.0 MB
  datacount                 0.0 MB
zshexit:1: parameter HISTDB_FD does not contain a file descri  …[truncated]
Fable
thinking
The current FreeCAD.wasm build is 288MB total, with 230MB in the CODE section that's subject to asyncify overhead and 57.5MB in data. Since asyncify can add 30-49% overhead to suspend-path functions, and a significant portion of FreeCAD's codebase (particularly Qt and virtual-heavy code) likely sits on that suspend path, the potential code-size savings from optimizing asyncify are substantial.

If roughly 70-80% of the code section gets instrumented with ~45% expansion, the pre-tax code would be around 160-190MB instead of 230MB, bringing the total wasm down to 220-250MB — a savings of roughly 40-70MB or 15-25%. The exact figure depends on how much code is actually instrumented in the curren  …
Fable
Code section is 230MB of the 288MB — that's the asyncify-taxable part. Let me measure the runtime (not just size) overhead on the non-suspending hot path.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/size-test; cd $D\n# Time run() (gate=0, no actual suspend) many times: pure instrumentation overhead on hot path\ncat > perf.mjs <<'EOF'\nconst which = process.argv[2];\…
tool result
hot-path timing (run() = 400 nested calls, NO suspend), asyncify uses wrapped export:
Node.js v26.2.0
Node.js v26.2.0
Node.js v26.2.0
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
I need to look at the actual error message instead of the suppressed output.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/size-test; cd $D\n/usr/bin/node perf.mjs a2 2>&1 | head -8", "description": "See perf error"}
tool result
wasm://wasm/0006e55a:1


RangeError: Maximum call stack size exceeded
    at wasm://wasm/0006e55a:wasm-function[18]:0x18578
    at wasm://wasm/0006e55a:wasm-function[22]:0x19339
    at wasm://wasm/0006e55a:wasm-function[5]:0x41d7
    at wasm://wasm/0006e55a:wasm-function[4]:0x3ae6
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
I see the issue now — my recursive functions are calling each other in a chain where each one invokes the next, and even though I'm multiplying by zero, that doesn't short-circuit the actual function call itself, so it still triggers infinite mutual recursion between f0 through f399 and back. The multiplication by zero prevents the result from mattering, but the call still happens and causes a stack overflow at runtime, though the code compiles fine and the size measurement works since that's just static analysis.

To fix this, I'll remove the recursion entirely and have run() call each function independently in a loop instead. Since asyncify instruments based on static reachability to async  …
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/size-test; cd $D\npython3 -c \"\nprint('#include <cmath>\\n#include <emscripten.h>')\nprint('extern \\\"C\\\" int async_sleep(void);')\nprint('volatile int gate=0;')\nfo…
tool result
sizes:
  p3   15139
  a3   18758
  j3   15139
p3: 45.55 us/call
a3: 44.89 us/call
j3: 46.18 us/call
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
The timing results show that call overhead is negligible here because the heavy mathematical work inside each function dominates the execution time. Asyncify does add per-call overhead, but it becomes visible only when dealing with lightweight, frequently-called functions like those in scene graph traversals or event dispatchers. My test functions have substantial bodies with multiple trigonometric operations, so the instrumentation cost gets buried in the computation time. To actually measure asyncify's overhead, I'd need to design a test with minimal function bodies and high call frequency.

The code size increase of 49% is concrete and measurable, while runtime overhead depends heavily on  …
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nD=/tmp/size-test; cd $D\n# call-dense: tiny functions, deep call chains (mimics Coin scenegraph / Qt dispatch)\npython3 -c \"\nprint('#include <emscripten.h>')\nprint('extern \…
tool result
call-dense sizes:
  pd   11577
  ad   53505
  jd   11577
pd: 2550.4 us/call (400k tiny calls each)
ad: 4407.2 us/call (400k tiny calls each)
jd: 1565.6 us/call (400k tiny calls each)
zshexit:1: parameter HISTDB_FD does not contain a file descriptor
Fable
thinking
The call-dense benchmark results are revealing a stark contrast: asyncify balloons the code size to 53.5KB (a 362% increase) while keeping runtime at 4407us, making it 1.73x slower than the plain version. JSPI, on the other hand, maintains the original size and delivers the best performance at 1566us—actually faster than plain code on this hot path.

The runtime difference between JSPI and plain is likely just measurement noise since they compile to identical WebAssembly; what matters is that asyncify imposes a substantial penalty on call-dense code like the Coin scenegraph traversal and Qt event dispatch. If the 96ms wheel redraw is dominated by asyncify overhead, we could realistically see  …
Fable
Strong result: on call-dense code (like Coin scenegraph / Qt dispatch), asyncify is **+362% size** and **1.7x slower** than plain; JSPI has zero overhead. Now let me confirm 2026 browser support.
Fable
⚙ ToolSearch {"query": "select:WebSearch", "max_results": 3}
tool result
Fable
⚙ WebSearch {"query": "JSPI JavaScript Promise Integration WebAssembly Firefox Safari support status 2026"}
Fable
⚙ WebSearch {"query": "WebAssembly JSPI Firefox 139 shipped enabled default baseline"}
tool result
Web search results for query: "WebAssembly JSPI Firefox 139 shipped enabled default baseline"

Links: [{"title":"Introducing the WebAssembly JavaScript Promise Integration API · V8","url":"https://v8.dev/blog/jspi"},{"title":"State of WebAssembly 2026","url":"https://devnewsletter.com/p/state-of-webassembly-2026/"},{"title":"1342060 - wasm: enable by default","url":"https://bugzilla.mozilla.org/show_bug.cgi?id=1342060"},{"title":"Introducing the WebAssembly JavaScript Promise Integration API | V  …[truncated]
tool result
Web search results for query: "JSPI JavaScript Promise Integration WebAssembly Firefox Safari support status 2026"

Links: [{"title":"js-promise-integration/proposals/js-promise-integration/Overview.md at main · WebAssembly/js-promise-integration","url":"https://github.com/WebAssembly/js-promise-integration/blob/main/proposals/js-promise-integration/Overview.md"},{"title":"JavaScript promise integration (WebAssembly) | Can I use... Support tables for HTML5, CSS3, etc","url":"https://caniuse.com/  …[truncated]
Fable
thinking
Looking at the browser support landscape, Chrome and Edge have full support shipping unflagged in version 137+, with Chromium 149 already confirmed capable in FreeCAD's CI. Firefox 139 has the feature available but it's still gated behind a flag, which creates a real deployment concern since users would need to manually enable `javascript.options.wasm_js_promise_integration` to use it.

Safari hasn't shipped JSPI at all — they removed their objection late 2025 and assigned someone to work on it, but there's no firm commitment yet. However, Interop 2026 includes JSPI at 20% weight, which should push Firefox toward unflagging and accelerate Safari's implementation during the year.

The practic  …
Fable
⚙ WebSearch {"query": "caniuse wasm JSPI Firefox unflagged default 2026 Firefox 145"}
tool result
Web search results for query: "caniuse wasm JSPI Firefox unflagged default 2026 Firefox 145"

Links: [{"title":"State of WebAssembly 2026","url":"https://devnewsletter.com/p/state-of-webassembly-2026/"},{"title":"Browser Compatibility of wasm on Firefox Browsers","url":"https://www.testmuai.com/web-technologies/wasm-firefox/"},{"title":"The State of WebAssembly – 2025 and 2026","url":"https://platform.uno/blog/the-state-of-webassembly-2025-2026/"},{"title":"JavaScript promise integration (WebAss  …[truncated]
Fable
thinking
Looking at the browser support landscape for JSPI, Chrome and Edge have it enabled by default as of version 137, making them the primary deployment target since they cover most desktop CAD users. Firefox still has it behind a flag as of January 2026 and isn't expected to enable it by default until later in the year, while Safari hasn't shipped support yet.

For the Dimension C analysis, JSPI offers substantial benefits: the current 288MB FreeCAD.wasm could shrink by 15-30% (roughly 45-90MB reduction) since JSPI adds zero code-size overhead compared to asyncify's 49-362% tax depending on function type. This translates to faster browser compilation, parsing, and instantiation. On the runtime s  …
Fable
I now have all the evidence needed. Let me compile the Dimension C verdict. Key empirical results from isolated `/tmp` tests on emsdk 4.0.12 / clang-22 / V8 14.6: - **JSPI + JS-EH** (invoke_ frames): fails with *"trying to suspend JS frames"* — still dead. - **JSPI + wasm-EH**: suspend inside a throwing try/catch returns correct result (114) — **works**. - **wasm-EH (legacy, emscripten default) on real OCCT**: 13/13 flagged files compile → Binaryen parses → V8 validates. Only the non-default `try_table` encoding still miscompiles (step.tab.cxx invalid). - **Size**: asyncify +49% (compute-dense) to +362% (call-dense) vs plain; JSPI byte-identical to plain (0%). - **Runtime**: asyncify 1.7x slower on call-dense hot path; JSPI zero overhead. - Real FreeCAD.wasm = 288MB (code 230MB / data 57.5MB).
Fable
⚙ StructuredOutput {"dimension": "C — Benefit, effort, and risk of migrating FreeCAD-wasm from ASYNCIFY to JSPI", "verdict": "FEASIBLE-WITH-WORK, and worth it. The R10 \"JSPI is out\" conclusion is STALE — both blockers it cited are resolved by the current to…
tool result
Structured output provided successfully