fix(wasm): self-heal fiber trampoline so schematic load doesn't hang

The emscripten fiber glue gates Fibers.trampoline() on Fibers.trampolineRunning
and resets it at the end of its loop. At startup emscripten_set_main_loop(...,1)
throws "unwind" to establish the main loop, and KiCad does so from inside a tool
coroutine, so the throw propagates THROUGH the trampoline and skips the reset —
leaving the flag stuck true. Every fiber swap after startup then becomes a silent
no-op, so opening a schematic (SetScreen -> RunAction(selectionClear) -> fiber
swap) hangs forever with the editor stuck on "untitled".

Wrap the trampoline loop in try/finally (inject-dyncall-shims.sh section "3c") so
the flag is always reset. Add tests/kicad/eeschema-load.spec.ts, which opens a
small wires/junctions schematic via Module.kicadOpenFile and asserts the editor
title switches away from "untitled": it times out (RED) without the shim and
passes (GREEN) with it. Also add features/web-init/0002-url-regex-modal-followup.md
capturing the unrelated URL-detection wxRegEx modal surfaced once loading works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-02 12:03:34 +02:00
commit 18a9de0449
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
3 changed files with 252 additions and 0 deletions

View file

@ -0,0 +1,96 @@
# Next-session prompt: fix the URL-detection wxRegEx modal on schematic load (WASM)
Paste everything below the line into a fresh session to work on this.
---
Fix the "Invalid regular expression … UTF-8 error" modal that pops up when eeschema
(WASM) renders a schematic containing text/symbol fields. Branch: `feature/web-init`.
## Symptom
Loading a schematic that has symbol fields / text (e.g.
`kicad/demos/ecc83/ecc83-pp.kicad_sch`) renders the schematic but immediately throws a
**modal dialog**:
```
KiCad Schematic Editor Error
Invalid regular expression '(https?|ftp|file)://([-\w+&@#/%?=~_|!:,.;]*[^.,;<>\s<><73><EFBFBD><EFBFBD><EFBFBD><EFBFBD>])':
UTF-8 error: code points 0xd800-0xdfff are not defined
```
(The `<60><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>` are mojibake where `¶` U+00B6 should be.) It is **non-fatal** — the schematic
draws behind the dialog — but it blocks the UI and means URL detection in text is broken.
An empty / geometry-only schematic does NOT trigger it (that's why the load regression test
`tests/kicad/eeschema-load.spec.ts` uses wires/junctions only).
## Root cause (already localized)
`kicad/common/string_utils.cpp` has two static `wxRegEx` whose pattern ends in a negated
character class containing `¶` (¶, U+00B6):
- `LinkifyHTML()` ~line 672: `wxS( "\\b(https?|ftp|file)://([-\\w+&@#/%?=~|!:,.;]*[^.,:;<>\\(\\)\\s¶])" )`
- `IsURL()` ~line 683: `wxS( "(https?|ftp|file)://([-\\w+&@#/%?=~|!:,.;]*[^.,:;<>\\s¶])" )`
`IsURL()` is called during field/text rendering — `kicad/eeschema/sch_field.cpp:1042` &
`:1081`, `kicad/eeschema/sch_textbox.cpp:352`, `kicad/eeschema/fields_data_model.cpp:377`
so any schematic with symbol reference/value fields hits it. The error is a **regex
COMPILE** failure of the static pattern (input-independent): the first `IsURL()` call
constructs the static `wxRegEx`, which fails to compile.
Hypothesis: under the emscripten/wxWidgets-WASM build the `¶` in the `wxS(...)` pattern
is mis-encoded (the dialog shows it as invalid UTF-8 in the 0xd8000xdfff surrogate range),
so `wxRegEx` rejects the pattern. Native KiCad compiles it fine, so this is WASM-specific —
likely the wide-char literal handling and/or the `wxString → wxRegEx` UTF-8 conversion in
the wx-WASM port. NOT yet root-caused to the exact byte; that's step 1.
## What to do
1. Reproduce + pin the exact corruption. Add temporary logging that prints the pattern
bytes (hex) right before the `wxRegEx` ctor in `IsURL()`/`LinkifyHTML()`, and compare
what `¶` becomes in the WASM build vs. what it should be (`0xC2 0xB6`). Determine
whether the corruption is at: the C++ wide/narrow literal, the `wxS`/`wxString` storage,
or the `wxRegEx` UTF-8 conversion (`src/common/regex.cpp` / the wx-WASM regex backend).
2. Fix at the lowest correct layer. Prefer the wx-WASM layer (project rule: keep `kicad/`
close to upstream, fix under `__EMSCRIPTEN__` in `wxwidgets/`). Candidate fixes, cheapest
first — validate which is actually right after step 1:
- If the `wxRegEx` UTF-8 conversion is the bug, fix it in the wx-WASM regex backend so
non-ASCII pattern code points (e.g. `¶`) round-trip.
- If it's the literal/encoding, build the pattern via `wxString::FromUTF8("…\xC2\xB6…")`
instead of `¶`, or otherwise ensure correct encoding. (If this has to live in
`string_utils.cpp`, guard it `#ifdef __EMSCRIPTEN__` and keep the upstream pattern for
native — minimal divergence; run `scripts/kicad-diff-stats.sh` after.)
- Last resort: drop `¶` from the WASM pattern (it only excludes the pilcrow from a
URL's trailing char — cosmetic). Note this in a comment if chosen.
3. Verify: load a text-bearing schematic and confirm NO modal, schematic renders, and a real
URL in a text field is still linkified (don't regress URL detection). Then re-run
`tests/kicad/eeschema-load.spec.ts` (must stay green) and ideally add a text-bearing
schematic case that would have shown the modal.
## Context you need
- The schematic LOAD path now works: the fiber/Asyncify hang was fixed by the trampoline
self-heal shim in `scripts/common/inject-dyncall-shims.sh` (section "3c"). Don't touch it.
- Build eeschema (reuses prebuilt deps volume, ~5 min, don't build deps from scratch, don't
run two builders at once — 32G each on a 37G Docker VM OOMs):
`COMPOSE_PROJECT_NAME=kicad-wasm-feature-schematic ./docker/build.sh eeschema --debug`
Then `cd web/apps/frontend && npm run link-wasm`. Build scripts log to files
(`logs/build/*.log`) — don't pipe them.
- wxWidgets-only changes: `scripts/build-wxuniversal-wasm.sh` (on-machine, faster), then
relink eeschema.
- The webapp's iframe is being removed this round (separate task) to simplify dev — so the
app may load eeschema directly rather than in a same-origin iframe. The e2e harness
(`tests/apps/kicad/eeschema.html`, loaded directly by Playwright) already runs iframe-free;
it's the most reliable repro. Run kicad e2e from `tests/`: `npm run test:eeschema`.
- Symbolizing WASM stack frames (browser shows `wasm-function[N]` with no names): the separate
`*.debug.wasm` is PRE-asyncify and useless. See memory `eeschema_wasm_symbolization` for the
`--profiling-funcs` + asyncify `-g` recipe and the name-section index→name parser. Those are
temporary debug build flags — re-add while debugging, remove before committing.
- Use debug tools / symbols, don't guess (CLAUDE.md). Temporary `fprintf(stderr,"[TAG] …")`
shows as `[KICAD_ERR]` in the browser console; remove before committing.
## Memory pointers (read these first)
- `eeschema_schematic_load_crash` — full root-cause history of the load hang + this regex
follow-up (the "NEW minor follow-up" note).
- `eeschema_wasm_symbolization` — how to get real C++ names into browser WASM stack traces.

View file

@ -152,6 +152,24 @@ else
echo "Warning: dynCallLegacy pattern not found - skipping embind dynCall fallback"
fi
# --- 3c. Fiber trampoline self-heal -------------------------------------------
# emscripten_set_main_loop(...,1) throws "unwind" during startup to establish the
# main loop. KiCad establishes that loop from inside a tool coroutine, so the throw
# propagates THROUGH Fibers.trampoline()'s do/while, skipping its
# `trampolineRunning = false` reset. The flag then stays true forever and
# Fibers.trampoline() becomes a permanent no-op (guard: `if (!trampolineRunning ...)`),
# so every later fiber swap silently fails to switch — the schematic load and all
# post-idle tool actions hang. Wrap the loop in try/finally so the flag is always
# reset (self-healing).
if grep -qF '} finally { Fibers.trampolineRunning = false; }' "$JS_FILE"; then
echo "fiber trampoline self-heal already present - skipping"
elif grep -qF 'Fibers.trampolineRunning = true;' "$JS_FILE"; then
perl -0pi -e 's/(Fibers\.trampolineRunning = true;)(\s*)(do \{.*?\} while \(Fibers\.nextFiber\);)(\s*)(Fibers\.trampolineRunning = false;)/$1$2try {$3} finally { $5 }/s' "$JS_FILE"
echo "Injected fiber trampoline self-heal (try/finally)"
else
echo "Warning: Fibers.trampoline pattern not found - skipping trampoline self-heal"
fi
# --- 4. Optional diagnostics (logging only) -----------------------------------
if [ "$SHIM_DIAGNOSTICS" = "1" ]; then
if grep -q 'DIAG] Asyncify/fiber/modal diagnostics installed' "$JS_FILE"; then

View file

@ -0,0 +1,138 @@
import { test, expect } from './fixtures';
/**
* Eeschema schematic-LOAD regression test (fiber / Asyncify trampoline shim).
*
* This guards the fix in scripts/common/inject-dyncall-shims.sh
* ("3c. Fiber trampoline self-heal").
*
* Background: KiCad's tool framework runs action handlers in coroutines that
* switch stacks via emscripten_fiber_swap. The emscripten fiber glue gates its
* context switch on `Fibers.trampolineRunning` and resets that flag at the end of
* `Fibers.trampoline()`. At startup `emscripten_set_main_loop(...,1)` throws
* "unwind" to establish the main loop; KiCad does that from inside a tool
* coroutine, so the throw propagates THROUGH the trampoline and skips the reset.
* The flag then stays `true` forever, `Fibers.trampoline()` becomes a permanent
* no-op, and EVERY fiber swap after startup silently fails to switch contexts.
*
* Opening a schematic calls SCH_EDIT_FRAME::SetScreen() ->
* m_toolManager->RunAction(selectionClear), which performs such a fiber swap. So
* without the shim, OpenProjectFiles() suspends in selectionClear and never
* resumes: the load hangs and the editor title stays "untitled".
*
* The shim wraps the trampoline loop in try/finally so the flag is always reset.
* With it, the load completes and the title switches to the opened file.
*
* Assertion strategy: open a minimal (text-free) schematic via the programmatic
* Module.kicadOpenFile() hook and poll the editor title. GREEN once it shows the
* file name; RED (poll timeout) if the load hangs because the shim is missing.
*
* The schematic holds a few wires + junctions (a box with a crossbar) so a dev
* can eyeball a screenshot and immediately see whether it rendered. It uses ONLY
* geometry no text/symbol fields both to keep the visual unambiguous and to
* avoid an unrelated, still-open URL-detection wxRegEx bug that pops a modal when
* text is rendered (see the regex follow-up). version 20250114 is within this
* build's supported schematic version (SEXPR_SCHEMATIC_FILE_VERSION 20251012).
*/
const SAMPLE_SCH = `(kicad_sch
\t(version 20250114)
\t(generator "eeschema")
\t(generator_version "9.0")
\t(uuid "11111111-1111-1111-1111-111111111111")
\t(paper "A4")
\t(lib_symbols)
\t(wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "22222222-0000-0000-0000-000000000001"))
\t(wire (pts (xy 50.8 101.6) (xy 101.6 101.6)) (stroke (width 0) (type default)) (uuid "22222222-0000-0000-0000-000000000002"))
\t(wire (pts (xy 50.8 50.8) (xy 50.8 101.6)) (stroke (width 0) (type default)) (uuid "22222222-0000-0000-0000-000000000003"))
\t(wire (pts (xy 101.6 50.8) (xy 101.6 101.6)) (stroke (width 0) (type default)) (uuid "22222222-0000-0000-0000-000000000004"))
\t(wire (pts (xy 50.8 76.2) (xy 101.6 76.2)) (stroke (width 0) (type default)) (uuid "22222222-0000-0000-0000-000000000005"))
\t(junction (at 50.8 76.2) (diameter 1.016) (color 0 0 0 0) (uuid "33333333-0000-0000-0000-000000000001"))
\t(junction (at 101.6 76.2) (diameter 1.016) (color 0 0 0 0) (uuid "33333333-0000-0000-0000-000000000002"))
\t(sheet_instances
\t\t(path "/"
\t\t\t(page "1")
\t\t)
\t)
)
`;
type EmscriptenFS = {
mkdirTree(path: string): void;
writeFile(path: string, data: string): void;
};
type KicadModule = { kicadOpenFile(path: string): unknown };
test.describe('Eeschema schematic load', () => {
test('opens a .kicad_sch via kicadOpenFile and finishes loading (fiber shim regression)', async ({
page,
}) => {
await page.goto('/kicad/eeschema.html');
// Editor must be fully up before we drive the open: a visible canvas, the
// wx element registry, the embind open hook, and a top-level Frame (so
// kicadOpenFile's GetTopWindow() resolves to the editor, not a wizard).
await expect(page.locator('#canvas')).toBeVisible({ timeout: 90000 });
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
await page.waitForFunction(
() =>
typeof (window as unknown as { Module?: KicadModule }).Module?.kicadOpenFile ===
'function',
null,
{ timeout: 90000 }
);
await page.waitForFunction(
() =>
!!window.wxElementRegistry &&
window.wxElementRegistry
.findAll({ visible: true })
.some((e) => /Frame$/.test(e.typeName) || (e.name || '').endsWith('Frame')),
null,
{ timeout: 90000 }
);
// Sanity: editor starts on an untitled schematic.
expect(await page.title()).toMatch(/untitled/i);
// Write a minimal, version-compatible schematic into MEMFS and open it.
// kicadOpenFile runs OpenProjectFiles under Asyncify: it suspends and
// returns a placeholder, so we ignore the return and poll the title.
const openedPath = await page.evaluate((content) => {
const w = window as unknown as { FS: EmscriptenFS; Module: KicadModule };
const dir = '/home/kicad/documents';
try {
w.FS.mkdirTree(dir);
} catch {
/* already exists */
}
const path = `${dir}/regression.kicad_sch`;
w.FS.writeFile(path, content);
w.Module.kicadOpenFile(path);
return path;
}, SAMPLE_SCH);
expect(openedPath).toContain('regression.kicad_sch');
// With the fiber trampoline self-heal shim the load completes and the
// title switches to the opened file. WITHOUT it, the selectionClear fiber
// swap hangs and the title stays "untitled" -> this poll times out (RED).
await expect
.poll(async () => page.title(), {
message:
'Schematic load did not complete (title stayed "untitled"). ' +
'The fiber trampoline self-heal shim (inject-dyncall-shims.sh "3c") is ' +
'likely missing or broken.',
timeout: 30000,
intervals: [500],
})
.toMatch(/regression/i);
// Give the canvas a moment to paint the loaded geometry, then capture a
// screenshot so a dev can eyeball the rendered wires/junctions (box with
// a crossbar) as a quick "is it working?" check.
await page.waitForTimeout(1000);
await page.screenshot({
path: 'test-results/eeschema-load-rendered.png',
scale: 'device',
});
});
});