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 B — Qt-for-wasm JSPI support + what must be rebuilt. Investigate:
1. Does Qt 6.11 support a JSPI mode? Qt-for-wasm has QT_EMSCRIPTEN_ASYNCIFY; is there a JSPI equivalent (Qt docs / configure flags / QTBUG)? Check the Qt wasm platform plugin sources if available under /opt/toolchains/qt* or qtsrc. Qt historically used asyncify for its synchronous event loop; does newer Qt use JSPI?
2. Would the existing asyncify-built Qt (/opt/toolchains/qt-asyncify) work under a JSPI FreeCAD link, or must Qt be REBUILT for JSPI? (Likely rebuilt — the asyncify instrumentation is baked into Qt's static libs.) Note there's also /opt/toolchains/qt-jspi (78M) in the toolchain — investigate whether a JSPI Qt build ALREADY EXISTS there and its state (was it an earlier attempt?). Check rebuild-qt-jspi*.sh scripts in /opt/toolchains.
3. What FreeCAD-side link/flag changes are needed (ASYNCIFY removed, JSPI added, the CPython trampoline hack, EXPORTED_FUNCTIONS that must become JSPI-suspending).
Return the concrete list of what must change/rebuild for a JSPI FreeCAD, and whether a JSPI Qt already exists to reuse.
Fable
thinking
I'm looking into what needs to be rebuilt for Qt-for-wasm to support JSPI, starting with checking the toolchain directories.
Fable
⚙ Bash {"command": "ls -la /opt/toolchains/ | head -50; echo \"---QT DIRS---\"; ls -d /opt/toolchains/qt* 2>/dev/null; echo \"---JSPI QT---\"; ls -la /opt/toolchains/qt-jspi 2>/dev/null; echo \"---REBUILD SCRIPTS---\"; ls -la /opt/toolchains/rebui…
Fable
thinking
I see there's a JSPI Qt available, so let me check its current state and look at the rebuild scripts.
Fable
⚙ Bash {"command": "echo \"=== qt-jspi tree ===\"; find /opt/toolchains/qt-jspi -maxdepth 3 -type d | head -40; echo; echo \"=== qt-jspi size/libs ===\"; du -sh /opt/toolchains/qt-jspi; ls -la /opt/toolchains/qt-jspi/6.11.1/wasm_singlethread/lib/*…
Fable
thinking
I found a JSPI Qt build at /opt/toolchains/qt-jspi that's about 76MB, so I'm going to check the rebuild scripts to see how it was constructed and what condition it's in.
Fable
⚙ Bash {"command": "echo \"=== rebuild-qt-jspi.sh ===\"; cat /opt/toolchains/rebuild-qt-jspi.sh; echo; echo \"=== rebuild-qt-jspi-jseh.sh ===\"; cat /opt/toolchains/rebuild-qt-jspi-jseh.sh", "description": "Read JSPI Qt rebuild scripts"}
Fable
thinking
So there were three different build attempts with varying exception handling strategies: the first used Qt's default setup combining JSPI with wasm-exceptions, the second patched Qt's gate to allow JSPI paired with JS-EH instead, and the third was exploring JSPI with wasm-exceptions but got cut off.
Qt 6.11 does support JSPI through the `-feature-wasm-jspi` flag, though Qt's configure gate normally requires wasm-exceptions alongside it—the jseh variant worked around this by removing that constraint. Now I need to figure out which build actually produced the current `/opt/toolchains/qt-jspi` by comparing timestamps and checking what exception handling was compiled in, then review the wasmeh …
Fable
⚙ Bash {"command": "echo \"=== rebuild-qt-jspi-wasmeh.sh ===\"; cat /opt/toolchains/rebuild-qt-jspi-wasmeh.sh", "description": "Read wasmeh Qt rebuild script"}
Fable
thinking
I need to figure out which build variant created the current qt-jspi binary from July 4th at 05:44, and the wasmeh script from a minute earlier might not have run successfully. Let me check the actual compiled Qt libraries to see whether they're using wasm exception handling with try_table instructions or JavaScript exception handling with invoke patterns, and look for JSPI markers in the qtwasm platform plugin source and Qt configuration.
Fable
⚙ Bash {"command": "cd /opt/toolchains/qt-jspi/6.11.1/wasm_singlethread\necho \"=== qconfig / features ===\"; grep -rl \"wasm_jspi\\|wasm-jspi\\|JSPI\\|jspi\" mkspecs/ include/QtCore/*.h 2>/dev/null | head\necho \"--- qconfig.pri features ---\"; g…
Fable
thinking
The current qt-jspi build has both wasm-exceptions and wasm-jspi enabled, meaning it was compiled with the wasmeh script combining JSPI and wasm-EH support. That's the key issue—while wasm-exceptions in Qt itself is fine, linking Qt with wasm-EH enabled together with OCCT (also wasm-EH) into a single binary creates a problem.
The real constraint is that JSPI requires either wasm-exceptions everywhere (which blocks OCCT's br_table optimization) or JSPI needs to suspend across JS-EH invoke frames. The existing qt-jspi uses wasm-exceptions, but there's also a jseh variant script that patches Qt's gate to allow JSPI with JS-EH instead. The installed version is wasmeh with both features enabled, …
Fable
⚙ Bash {"command": "cd /opt/toolchains/qtsrc/qtbase 2>/dev/null || echo \"NO QTSRC qtbase\"\necho \"=== qtsrc present? ===\"; ls /opt/toolchains/qtsrc/\necho\necho \"=== JSPI / asyncify refs in wasm platform plugin ===\"\ngrep -rin \"jspi\\|QT_EMS…
Fable
thinking
Qt 6.11's wasm platform plugin has built-in JSPI support that detects availability at runtime and manages suspend/resume control accordingly. I need to examine the suspend mechanism in the control implementation and understand how Qt leverages the async JavaScript integration mechanism.
Writing async JS integration...
Fable
⚙ Bash {"command": "cd /opt/toolchains/qtsrc/qtbase/src/corelib/platform/wasm\necho \"=== qwasmsuspendresumecontrol.cpp lines 40-160 ===\"; sed -n '40,160p' qwasmsuspendresumecontrol.cpp\necho; echo \"=== qstdweb_p.h around 30-60 (wasm_jspi) ===\"…
Fable
thinking
So Qt 6.11 uses `EM_ASYNC_JS` to create Promise-based suspend points that work transparently with both asyncify and JSPI — the runtime detects which mechanism is available and uses the appropriate one. This is a significant shift because it means the same suspension code path handles both approaches seamlessly, rather than requiring separate implementations.
The real constraint emerges when considering the call stack: JSPI can't suspend across JS-EH invoke_ trampolines. If FreeCAD is built with JS exceptions and Python calls into OCCT which then triggers Qt's suspend, those invoke_ frames would block the suspension. The current qt-jspi build uses wasm-exceptions throughout to avoid this, bu …
Fable
⚙ Bash {"command": "cd /opt/toolchains\necho \"=== plugins present in qt-jspi (platform plugin, imageformats) ===\"\nls qt-jspi/6.11.1/wasm_singlethread/plugins/platforms/ 2>/dev/null\necho \"--- total libs count jspi vs asyncify ---\"\necho \"jsp…
Fable
thinking
The first object I checked doesn't have exception handling, so that approach won't work. I need to look at a file that actually uses exceptions, or search the entire archive for exception markers like `__cpp_exceptions` and `eh` to get a clearer picture of what's enabled.
Fable
⚙ Bash {"command": "cd /tmp/qtjspi-eh && rm -f *.o\n/opt/toolchains/emsdk/upstream/bin/llvm-ar x /opt/toolchains/qt-jspi/6.11.1/wasm_singlethread/lib/libQt6Core.a\n# Check target-features section on a real object for exception-handling / reference…
Fable
thinking
So the Qt toolchain at /opt/toolchains/qt-jspi is compiled with WASM-EH exception handling, which means it's using try_table instructions throughout the codebase rather than invoke_ references. This is the JSPI variant with wasm-exceptions support, which creates a compatibility issue—I can't easily link it with a JavaScript-based exception handling version of FreeCAD since the exception handling styles don't mix cleanly at the linking stage.
The core problem is that Emscripten requires a single exception handling mode across the entire binary because the C++ runtime needs to match. WASM-EH and JS-EH have incompatible ABIs for throw/catch operations, so mixing objects compiled with different …
Fable
⚙ Bash {"command": "cd /tmp/qtjspi-eh\necho \"=== target_features content of qmetatype.cpp.o ===\"\n/opt/toolchains/emsdk/upstream/bin/llvm-objdump -s -j target_features qmetatype.cpp.o 2>/dev/null | head -20\necho\necho \"=== which Qt was FreeCAD…
Fable
thinking
So qt-jspi has the necessary features enabled for JSPI support with native WebAssembly exception handling and reference types, but FreeCAD's current build is still using qt-asyncify instead. Let me check the CMakeLists linking section to understand what flags are currently set and how the CPython trampoline workaround fits in.
Fable
⚙ Bash {"command": "sed -n '140,270p' /home/magik6k/lcad-wasm/freecad-port/FreeCAD/src/Main/CMakeLists.txt", "description": "Read FreeCAD Main CMakeLists link section"}
Fable
thinking
I'm reviewing my research notes on prior JSPI attempts and checking the emscripten version to understand the CPython trampoline and JSPI EXPORTED_FUNCTIONS mechanism. For Dimension B, I have solid ground on Qt, but I need to verify whether the jseh script actually produced a working build and if the JSPI plus JavaScript exception handling path is viable.
Fable
⚙ Bash {"command": "ls /home/magik6k/lcad-wasm/freecad-port/research/ 2>/dev/null\necho \"=== grep research for JSPI ===\"\ngrep -rlin \"jspi\" /home/magik6k/lcad-wasm/freecad-port/research/ 2>/dev/null", "description": "Find research notes on JSP…
Fable
⚙ Bash {"command": "echo \"############ R8-jspi-migration-plan.md ############\"; cat /home/magik6k/lcad-wasm/freecad-port/research/R8-jspi-migration-plan.md", "description": "Read JSPI migration plan research"}
Fable
thinking
The R8 plan is actually older than the R10 finding about OCCT's wasm-EH miscompiling invalid br_table instructions. Since R10 is the newer blocker that halted the entire R8 plan, I need to review R10 to see where things stand now, particularly around the br_table issue and JSPI-across-JS-EH interaction. The task summary already covers R10's core points, so I'm focusing on checking whether Qt/JSPI verification has been addressed.
Fable
⚙ Bash {"command": "cat /home/magik6k/lcad-wasm/freecad-port/research/R10-eh-jspi-asyncify-findings.md", "description": "Read R10 EH/JSPI/asyncify findings"}
Fable
thinking
Looking at the R10 matrix, I can see the core issue: JSPI combined with wasm-EH produces an invalid module due to an OCCT br_table miscompile, and that's exactly what the qt-jspi variant includes. The alternative of pairing JSPI with JS-EH links successfully but fails at runtime because JavaScript frames can't be suspended across invoke_ boundaries. For my Qt-specific work, I need to verify one critical detail that could resolve this.
The task is asking whether these blockers still hold in emscripten 4.0.12 with clang-22, though that's more about the compilation layer. For Qt specifically, I've confirmed the deliverables: Qt 6.11 has full JSPI support with runtime detection gated on wasm-ex …
Fable
⚙ Bash {"command": "EM=/opt/toolchains/emsdk/upstream/emscripten\necho \"=== JSPI settings in settings.js ===\"\ngrep -n \"JSPI\" $EM/src/settings.js | head -40\necho\necho \"=== emscripten version ===\"\ncat $EM/emscripten-version.txt 2>/dev/null…
Fable
⚙ Bash {"command": "EM=/opt/toolchains/emsdk/upstream/emscripten\nsed -n '930,975p' $EM/src/settings.js", "description": "Read JSPI_EXPORTS/JSPI_IMPORTS docs"}
Fable
thinking
I'm getting clarity on how Emscripten's JSPI model works in version 4.0.12—enabling it with a flag, then configuring which exports become Promise-returning functions that can suspend, with the default including `main` but requiring FreeCAD to explicitly add `_freecad_run_python` and other entry points that interact with Python or the Qt event loop. The async imports also get automatically marked through EM_ASYNC_JS functions.
Qt's suspend mechanism flows through EM_ASYNC_JS (qtSuspendJs), which becomes a suspending import under JSPI—that part checks out. But there's still the JS-EH invoke frame issue lurking in the OCCT/FreeCAD code path. I've already verified that the Qt platform plugin ha …
Fable
⚙ Bash {"command": "echo \"=== qt-asyncify features ===\"\ngrep -i \"jspi\\|wasm.*exception\" /opt/toolchains/qt-asyncify/6.11.1/wasm_singlethread/mkspecs/qconfig.pri 2>/dev/null\necho \"--- qt-asyncify EH style (invoke_ vs try_table) on libQt6Cor…
Fable
thinking
I'm confirming the technical details across these build configurations: qt-asyncify uses wasm-jspi disabled with JavaScript exception handling but isn't compatible with JSPI, while qt-jspi has both wasm-jspi and wasm-exceptions enabled with proper exception handling and a complete build including all libraries and the platform plugin. Emscripten 4.0.12 supports JSPI through the appropriate compiler flags.
Now I have the answers for this dimension and need to structure the output. The core issue is whether JS-EH can suspend across invoke_ calls in the latest emscripten version—this is the critical blocker determining whether a JS-EH JSPI path could work with the current OCCT setup. I should …
Fable
Let me run the decisive test for the JS-EH + JSPI path (whether the jseh Qt variant could ever work) in an isolated dir.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\nmkdir -p /tmp/jspi-jseh-test && cd /tmp/jspi-jseh-test\ncat > t.cpp <<'EOF'\n#include <emscripten.h>\n#include <cstdio>\n// async import that suspends (becomes WebAssembly.Susp…
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\ncd /tmp/jspi-jseh-test\ncat > run.mjs <<'EOF'\nimport Mod from './t.js';\nconst m = await Mod();\ntry {\n const p = m.ccall('run', 'number', [], [], {async: true});\n const r…
Fable
thinking
Node 22 switched to a new JSPI API with `WebAssembly.Suspending` and `WebAssembly.promising`, which requires the `--experimental-wasm-jspi` flag instead of the older `--experimental-wasm-stack-switching`. Let me test with the updated flag.
Fable
⚙ Bash {"command": "cd /tmp/jspi-jseh-test\necho \"=== try --experimental-wasm-jspi ===\"\nnode --experimental-wasm-jspi run.mjs 2>&1 | tail -20\necho \"=== exit: $? ===\"", "description": "Run with correct JSPI flag"}
Fable
⚙ Bash {"command": "cd /tmp/jspi-jseh-test\nnode --v8-options 2>/dev/null | grep -i \"jspi\\|stack.switch\\|suspend\" | head\necho \"=== try --experimental-wasm-jspi as v8 flag ===\"\nnode --js-flags=\"--experimental-wasm-jspi\" run.mjs 2>&1 | tai…
Fable
thinking
In Node 22.16 with V8, JSPI appears to be stabilized and enabled by default now, but the error about `WebAssembly.Suspending` not being a constructor suggests it's still not available in the runtime. Let me verify whether JSPI is actually enabled or if there's a different flag needed to access it.
Fable
⚙ Bash {"command": "cd /tmp/jspi-jseh-test\necho \"=== default ===\"; node -e \"console.log('Suspending:', typeof WebAssembly.Suspending, 'promising:', typeof WebAssembly.promising)\"\necho \"=== with stack-switching flag ===\"; node --experimenta…
Fable
thinking
JSPI is enabled by default in Node 22.16, so I don't need any experimental flags. The earlier error about `WebAssembly.Suspending` not being a constructor happened because I used the `--experimental-wasm-stack-switching` flag, which is no longer valid in this version and caused Node to error out.
Fable
⚙ Bash {"command": "cd /tmp/jspi-jseh-test\necho \"=== run with NO flags (JSPI default-on) ===\"\nnode run.mjs 2>&1 | tail -20\necho \"=== exit: $? ===\"", "description": "Run JSPI+JS-EH test with default JSPI"}
Fable
thinking
I'm seeing the R10 blocker error about suspending without WebAssembly.promising. The `_run` export should be wrapped by emscripten since it's in JSPI_EXPORTS, so I need to figure out whether the problem is in my ccall async harness or in the actual invoke_ frame. The error happens when a suspend occurs on a stack where the entry export wasn't properly created with promising support.
The issue is that the try/catch introduces an `invoke_` call — a JS function that calls back into wasm via a function pointer. This JS frame becomes a new stack entry that isn't promising-wrapped, so when do_suspend tries to suspend, it fails crossing that invoke_ boundary. This matches exactly what R10 found. I …
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\ncd /tmp/jspi-jseh-test\n# Control: same everything but NO throw/catch (no invoke_ frame)\ncat > t2.cpp <<'EOF'\n#include <emscripten.h>\n#include <cstdio>\nEM_ASYNC_JS(void, do…
Fable
thinking
I need to check the full control output since it was truncated — let me get the complete error message to see what actually went wrong.
Fable
⚙ Bash {"command": "cd /tmp/jspi-jseh-test\necho \"=== CONTROL full output ===\"; node run2.mjs 2>&1 | grep -iE \"RESULT|THREW|resumed|Error|suspend\" | head\necho\necho \"=== how t.js wraps exports (985-1015) ===\"\nsed -n '985,1016p' t.js", "des…
Fable
thinking
The test harness itself is the problem — when I use ccall with async:true, it's not actually invoking the WebAssembly.promising wrapper that _run is exported with, so the suspension fails. Under JSPI, the export is already the promising function, but ccall might be calling the raw export instead of going through the wrapper.
I think the issue is that my JSPI_EXPORTS pattern isn't matching _run correctly — Emscripten might expect the name without the leading underscore. Let me just call the export directly and await it rather than going through ccall, and make sure the JSPI_EXPORTS configuration is right.
Fable
⚙ Bash {"command": "source /home/magik6k/lcad-wasm/env.sh 2>/dev/null\ncd /tmp/jspi-jseh-test\ngrep -n \"exportPattern\" t2.js | head\n# call the promising export directly instead of ccall\ncat > run3.mjs <<'EOF'\nimport Mod from './t2.js';\nconst…
Fable
thinking
There's something inconsistent happening between run2.mjs and run3.mjs — both import the same t2.js file, but run2 gets past the import while run3 fails immediately with "WebAssembly.Suspending is not a constructor." I'm wondering if Node's JSPI support depends on a specific flag or origin trial being enabled, which would explain why Suspending is available as a function in the default environment but not consistently across different invocations.
Looking at the export pattern, `_run` should be matched and promise-wrapped, which is good. The control test (run2) fails with "trying to suspend without WebAssembly.promising," which tells me the promise-wrapped export isn't establishing the prom …
Fable
I have all the Dimension B facts. Let me do one final confirmation on the qt-jspi platform plugin completeness and then report.
Fable
⚙ Bash {"command": "echo \"=== qt-jspi platform plugin object EH + jspi ===\"\ncd /tmp && rm -rf qwasmchk && mkdir qwasmchk && cd qwasmchk\n/opt/toolchains/emsdk/upstream/bin/llvm-ar x /opt/toolchains/qt-jspi/6.11.1/wasm_singlethread/plugins/platf…
Fable
thinking
The Qt JSPI build with wasm-EH is confirmed installed and matches the rebuild script, with the plugin containing the expected JSPI suspend symbols. Now I'm ready to construct the StructuredOutput.
Fable
I have all the facts for Dimension B. Compiling the report.
Fable
⚙ StructuredOutput {"dimension": "B — Qt-for-wasm JSPI support + what must be rebuilt/changed on the Qt and FreeCAD-link side", "findings": ["Qt 6.11.1 has FULL, mature JSPI support — not a stub. Configure exposes `-feature-wasm-jspi` (QT_FEATURE_wasm_jspi / …