Replace the legacy Emscripten JS-exceptions model with native wasm-EH (legacy encoding) across the whole build, keeping Asyncify coroutines working via a from-source Binaryen --hoist-cpp-catches pre-pass. Net result: native-EH is the only build mode, the 3D viewer is on by default, and pcbnew shrinks substantially. Highlights: - Binaryen submodule everywhere + --hoist-cpp-catches integration in apply-asyncify; post-link Asyncify covers every app wasm (not just standalone test wasm). - Build deps (incl. OpenCASCADE without OCC_CONVERT_SIGNALS) and all KiCad apps with -fwasm-exceptions; emscripten_sleep added to the post-link asyncify-imports. - libcontext fiber entry wired under native exceptions; while-loop main loop + currData shim injected into all wx apps. - Native-EH collab apply fixed: DEBUG-define the embind TU + match all out-of-CMake C++ TUs' ABI flags to the core, fixing the vtable-layout skew / mis-dispatch. - 3D viewer enabled by default (real raytracer linked, not the stub). - Retire the EH-spike scaffolding; flip the asyncify-races ablation pins to shim-redundancy pins (native-EH stays clean with the legacy shims ablated). - Fix the asyncify-races quiescence check to not require Asyncify.currData==0: under the native-EH per-frame-yield top loop the main stack is asyncify-suspended every frame, so currData legitimately churns (a freed-but-not-yet-nulled buffer, not a leak). Refresh the pcbnew toolbar screenshot baseline for the new kicad. - CI: drop the obsolete binaryen_version input/env (the build uses the binaryen submodule fork's wasm-opt, not a version download); key the wasm-output cache on the binaryen submodule SHA instead. Bumps the wxwidgets + binaryen submodules to their squashed feature commits. Validated green: all 7 apps native-EH (real 3D in pcbnew); KiCad e2e 63/63 Firefox + Chromium (3D viewer renders); wx 336; coroutine 34/34 both engines; asyncify 7/7 both engines. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
253 lines
9.9 KiB
TypeScript
253 lines
9.9 KiB
TypeScript
import { test, expect, tryLoadApp } from '../e2e/utils/fixtures';
|
|
|
|
// Red-green specs for the Asyncify race-condition harness
|
|
// (tests/apps/standalone/asyncify-races/races_test.cpp — see docs/features/async/).
|
|
//
|
|
// Two kinds of tests here:
|
|
// - GREEN-target tests assert the desired end state (clean pass, clean console).
|
|
// While a fix is missing they FAIL — that failing run is the recorded "red".
|
|
// - ABLATION tests run the shim-ablated builds (races_test_noheal.js /
|
|
// races_test_nosleepfix.js) and assert the historical bug REPRODUCES.
|
|
// They pin the disease so the shim fixes stay testable forever.
|
|
|
|
const BATTERY = [
|
|
'post_park_fiber_swap',
|
|
'sleep_inside_fiber_inside_modal',
|
|
'out_of_order_sleep_resolution',
|
|
'long_parked_sleep_clobbered_by_swap',
|
|
];
|
|
|
|
const CRASH_SIGNATURES = [
|
|
'index out of bounds',
|
|
'indirect call to null',
|
|
'invalid state',
|
|
'unwind',
|
|
// assertion-free builds surface a clobbered doRewind as a TypeError
|
|
'is not a function',
|
|
];
|
|
|
|
function findSummary(logs: string[]) {
|
|
return logs.find((log) => log.includes('[ASYNCIFY_RACES] SUMMARY'));
|
|
}
|
|
|
|
function parseSummary(summary: string) {
|
|
const match = summary.match(/total=(\d+)\s+passed=(\d+)\s+failed=(\d+)/);
|
|
expect(match, 'summary line should be parseable').not.toBeNull();
|
|
return { total: Number(match![1]), passed: Number(match![2]), failed: Number(match![3]) };
|
|
}
|
|
|
|
function crashLines(testLogger: { consoleLogs: string[]; errors: string[] }) {
|
|
const all = [...testLogger.consoleLogs, ...testLogger.errors];
|
|
return all.filter(
|
|
(line) =>
|
|
CRASH_SIGNATURES.some((sig) => line.toLowerCase().includes(sig)) &&
|
|
// The harness's own meta-output mentions these words legitimately.
|
|
!line.includes('[ASYNCIFY_RACES]')
|
|
);
|
|
}
|
|
|
|
function realErrors(testLogger: { errors: string[] }) {
|
|
return testLogger.errors.filter((e) => !e.includes('favicon'));
|
|
}
|
|
|
|
test.describe('Asyncify races — green targets (full shims)', () => {
|
|
test('battery: all chained scenarios pass with a clean console', async ({
|
|
page,
|
|
testLogger,
|
|
}) => {
|
|
await page.goto('/standalone/asyncify-races/races_test.html');
|
|
const loaded = await tryLoadApp(page, 30000);
|
|
expect(loaded, 'races harness should load').toBe(true);
|
|
|
|
await expect
|
|
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
|
|
timeout: 60000,
|
|
message: 'battery should emit a final SUMMARY line (a missing one means a wedge/hang)',
|
|
})
|
|
.not.toBeNull();
|
|
|
|
const { total, passed, failed } = parseSummary(findSummary(testLogger.consoleLogs)!);
|
|
const failLogs = testLogger.consoleLogs.filter((l) => l.includes('[ASYNCIFY_RACES] FAIL '));
|
|
const passLogs = testLogger.consoleLogs.filter((l) => l.includes('[ASYNCIFY_RACES] PASS '));
|
|
|
|
expect(total).toBe(BATTERY.length);
|
|
expect(passed).toBe(BATTERY.length);
|
|
expect(failed).toBe(0);
|
|
expect(failLogs, `FAIL lines: ${failLogs.join(' || ')}`).toHaveLength(0);
|
|
expect(passLogs).toHaveLength(BATTERY.length);
|
|
|
|
for (const name of BATTERY) {
|
|
expect.soft(
|
|
testLogger.consoleLogs.some((l) => l.includes(`[ASYNCIFY_RACES] PASS ${name}`)),
|
|
`scenario ${name} should PASS`
|
|
).toBe(true);
|
|
}
|
|
|
|
expect(crashLines(testLogger), 'no crash signatures in console').toHaveLength(0);
|
|
expect(realErrors(testLogger), 'no page errors').toHaveLength(0);
|
|
});
|
|
|
|
test('modal_in_modal_in_modal: three nested ShowModals resolve LIFO', async ({
|
|
page,
|
|
testLogger,
|
|
}) => {
|
|
// RED today: wx dialog.cpp keeps the modal resolver in a single slot
|
|
// (Module._endModal = fn; delete after use), so with three nested modals
|
|
// the middle EndModal resolves nothing and its ShowModal parks forever.
|
|
// GREEN after the Stage-3 wx fix (LIFO resolver stack).
|
|
await page.goto('/standalone/asyncify-races/races_test.html#only=modal_in_modal_in_modal');
|
|
await tryLoadApp(page, 30000);
|
|
|
|
await expect
|
|
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
|
|
timeout: 45000,
|
|
message: 'triple modal should complete (middle EndModal must not be lost)',
|
|
})
|
|
.not.toBeNull();
|
|
|
|
const { passed, failed } = parseSummary(findSummary(testLogger.consoleLogs)!);
|
|
expect(passed).toBe(1);
|
|
expect(failed).toBe(0);
|
|
expect(crashLines(testLogger), 'no crash signatures in console').toHaveLength(0);
|
|
});
|
|
|
|
test('wakeup_during_transition: modal teardown over parked sleeps stays clean', async ({
|
|
page,
|
|
testLogger,
|
|
}) => {
|
|
await page.goto('/standalone/asyncify-races/races_test.html#only=wakeup_during_transition');
|
|
await tryLoadApp(page, 30000);
|
|
|
|
await expect
|
|
.poll(() => findSummary(testLogger.consoleLogs) ?? null, { timeout: 45000 })
|
|
.not.toBeNull();
|
|
|
|
const { passed, failed } = parseSummary(findSummary(testLogger.consoleLogs)!);
|
|
expect(passed).toBe(1);
|
|
expect(failed).toBe(0);
|
|
expect(crashLines(testLogger), 'no crash signatures in console').toHaveLength(0);
|
|
expect(realErrors(testLogger), 'no page errors').toHaveLength(0);
|
|
});
|
|
|
|
test('nested_quasi_modal_pump_error: pump rejection must not leak the parked DoRun', async ({
|
|
page,
|
|
testLogger,
|
|
}) => {
|
|
await page.goto(
|
|
'/standalone/asyncify-races/races_test.html#only=nested_quasi_modal_pump_error'
|
|
);
|
|
await tryLoadApp(page, 30000);
|
|
|
|
await expect
|
|
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
|
|
timeout: 45000,
|
|
message:
|
|
'nested loop must exit after a pump error (silent stall = the c27fe8bf bug, fixed in wx evtloop.cpp)',
|
|
})
|
|
.not.toBeNull();
|
|
|
|
const { passed, failed } = parseSummary(findSummary(testLogger.consoleLogs)!);
|
|
expect(passed).toBe(1);
|
|
expect(failed).toBe(0);
|
|
});
|
|
|
|
test('sleep-park mode: park throw must not escape as an unhandled "unwind" rejection', async ({
|
|
page,
|
|
testLogger,
|
|
}) => {
|
|
await page.goto('/standalone/asyncify-races/races_test.html#mode=sleep-park');
|
|
await tryLoadApp(page, 30000);
|
|
|
|
await expect
|
|
.poll(() => findSummary(testLogger.consoleLogs) ?? null, { timeout: 45000 })
|
|
.not.toBeNull();
|
|
|
|
const { passed, failed } = parseSummary(findSummary(testLogger.consoleLogs)!);
|
|
expect(passed).toBe(1);
|
|
expect(failed).toBe(0);
|
|
|
|
const unwindLeaks = [...testLogger.errors, ...testLogger.consoleLogs].filter(
|
|
(l) =>
|
|
l.toLowerCase().includes('unwind') &&
|
|
!l.includes('[ASYNCIFY_RACES]') &&
|
|
// console *log* lines about unwind from our own shims are fine; errors are not
|
|
(testLogger.errors.includes(l) || l.toLowerCase().includes('uncaught'))
|
|
);
|
|
expect(unwindLeaks, `unwind escaped the park: ${unwindLeaks.join(' || ')}`).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// These two were "ablation pins": they ablated a JS-shim fix (via SHIM_DISABLE_*,
|
|
// see tests/apps/Makefile.wasm) and asserted the LEGACY-EH disease came back — a
|
|
// stuck Fibers.trampoline guard (trampolineRunning=true) / a clobbered parked-sleep
|
|
// buffer. Under native wasm-EH the top loop is a per-frame-yield while-loop with NO
|
|
// park-throw (docs/features/wasm-exceptions; native-EH top-loop redesign), so the
|
|
// disease's *trigger* is gone at the root: ablating the shim no longer reproduces
|
|
// it. (The green "battery" above already runs both scenarios full-shim.)
|
|
//
|
|
// So they are flipped red->green: each now pins that the native-EH path stays clean
|
|
// EVEN with the legacy shim ablated — i.e. the scenario completes with no disease
|
|
// signature. That keeps them as the live signal for the open question below.
|
|
//
|
|
// TODO(research): are these shims still needed AT ALL under native-EH-only? The
|
|
// fiber trampoline self-heal (§3c) and the nested-Asyncify handleSleep save/restore
|
|
// (§3) in scripts/common/inject-dyncall-shims.sh were written for the legacy
|
|
// park-throw model; ablating either no longer breaks the scenario it guarded. If a
|
|
// full sweep of the real apps (modals / nested fibers / long sleeps / pthread pool)
|
|
// confirms they're dead weight under native-EH, drop the shim injection AND these
|
|
// pins. Until then they stay injected (belt-and-suspenders). Tracked in
|
|
// tests/README.md "Open tasks".
|
|
test.describe('Asyncify races — shim-redundancy pins (native-EH stays clean with the legacy shim ablated)', () => {
|
|
test('trampoline-heal ablated: native-EH post-park swap still completes (no stuck guard)', async ({
|
|
page,
|
|
testLogger,
|
|
}) => {
|
|
await page.goto(
|
|
'/standalone/asyncify-races/races_test_noheal.html#only=post_park_fiber_swap'
|
|
);
|
|
await tryLoadApp(page, 30000);
|
|
|
|
await expect
|
|
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
|
|
timeout: 45000,
|
|
message: 'post-park swap should complete even with the trampoline self-heal ablated',
|
|
})
|
|
.not.toBeNull();
|
|
|
|
const { passed, failed } = parseSummary(findSummary(testLogger.consoleLogs)!);
|
|
expect(passed).toBe(1);
|
|
expect(failed).toBe(0);
|
|
|
|
// The legacy disease signature must be ABSENT: no stuck-trampoline watchdog dump.
|
|
expect(
|
|
testLogger.consoleLogs.some((l) =>
|
|
l.includes('[ASYNCIFY_RACES] WATCHDOG post_park_fiber_swap')
|
|
),
|
|
'no stuck-trampoline watchdog should fire under native-EH'
|
|
).toBe(false);
|
|
expect(crashLines(testLogger), 'no crash signatures in console').toHaveLength(0);
|
|
});
|
|
|
|
test('handleSleep ablated: native-EH long parked sleep is not clobbered by a swap', async ({
|
|
page,
|
|
testLogger,
|
|
}) => {
|
|
await page.goto(
|
|
'/standalone/asyncify-races/races_test_nosleepfix.html#only=long_parked_sleep_clobbered_by_swap'
|
|
);
|
|
await tryLoadApp(page, 30000);
|
|
|
|
await expect
|
|
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
|
|
timeout: 45000,
|
|
message: 'long parked sleep should resolve even with the handleSleep fix ablated',
|
|
})
|
|
.not.toBeNull();
|
|
|
|
const { passed, failed } = parseSummary(findSummary(testLogger.consoleLogs)!);
|
|
expect(passed).toBe(1);
|
|
expect(failed).toBe(0);
|
|
expect(crashLines(testLogger), 'no crash signatures in console').toHaveLength(0);
|
|
expect(realErrors(testLogger), 'no page errors').toHaveLength(0);
|
|
});
|
|
});
|