Skip to content

Commit 95dcab2

Browse files
Brian M HuntBrian M Hunt
authored andcommitted
fix(tests): stagger iframe worker launch + richer import error
Reported: on production tko.io under Safari, one spec occasionally fails with the opaque WebKit message `Importing a module script failed.` — most often a spec that imports a large chunk graph (e.g. `builds/reference/spec/ bindingGlobalsBehavior.js`, which imports the full reference build). Chromium handles the same graph fine. The failure mode is known across Astro, SvelteKit, Nuxt, Immich, and tracks against upstream WebKit bug 242740. Two changes: 1. **Stagger worker launch** in tests.astro. With pool=4 every worker used to spawn its first iframe simultaneously, hitting the same shared module-graph chunks concurrently — that's what triggers the WebKit race. A 120ms per-worker offset spaces the first-round fetches (360ms total head-start for pool=4) and removes the contention without materially slowing the run (still ~5.2s end-to-end; was ~5.4s). 2. **Richer error on failure** in tests-frame.html. Keep the original dynamic `import()` (no retry) but in the catch, re-fetch the spec URL with `cache: 'no-store'` and report HTTP status, content-length, and content-type alongside the original WebKit message in both the in-frame err div and the `import-error` postMessage. Gives something actionable when the failure does surface.
1 parent 4e20972 commit 95dcab2

2 files changed

Lines changed: 44 additions & 5 deletions

File tree

tko.io/public/tests-frame.html

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,36 @@
6464
<script type="module">
6565
const qs = new URLSearchParams(location.search)
6666
const slug = qs.get('spec')
67+
const specUrl = `/tests/source/${slug}.js`
68+
69+
// On failure the opaque WebKit "Importing a module script
70+
// failed." message carries no URL or status. Refetch the
71+
// spec URL in the catch and surface HTTP status + size so
72+
// the parent can show something useful. The parent throttles
73+
// iframe start-up to avoid the concurrent-fetch race that
74+
// produces the opaque error in the first place; see the
75+
// source-mode worker stagger in tests.astro.
76+
//
77+
// Refs:
78+
// https://github.com/withastro/astro/issues/14775
79+
// https://github.com/sveltejs/kit/issues/5208
80+
// https://github.com/nuxt/nuxt/issues/33478
81+
// https://bugs.webkit.org/show_bug.cgi?id=242740
82+
async function diagnose(err) {
83+
try {
84+
const res = await fetch(specUrl, { cache: 'no-store' })
85+
const size = res.headers.get('content-length') ?? '?'
86+
return `import failed at ${specUrl} [HTTP ${res.status} ${res.statusText}, ${size}B, ${res.headers.get('content-type') || 'unknown'}]: ${err?.message || err}`
87+
} catch (fetchErr) {
88+
return `import failed at ${specUrl} (fetch also failed: ${fetchErr?.message || fetchErr}): ${err?.message || err}`
89+
}
90+
}
91+
6792
if (!slug) {
6893
document.getElementById('err').textContent = 'missing ?spec= query param'
6994
} else {
7095
try {
71-
await import(`/tests/source/${slug}.js`)
96+
await import(specUrl)
7297
const runner = mocha.run()
7398
runner.on('pass', t => parent.postMessage({ type: 'pass', slug, title: t.fullTitle(), duration: t.duration }, '*'))
7499
runner.on('fail', (t, err) => parent.postMessage({
@@ -80,8 +105,9 @@
80105
runner.on('end', () => parent.postMessage({ type: 'end', slug, stats: runner.stats }, '*'))
81106
parent.postMessage({ type: 'start', slug, total: runner.total }, '*')
82107
} catch (err) {
83-
document.getElementById('err').textContent = 'import failed: ' + (err?.message || err)
84-
parent.postMessage({ type: 'import-error', slug, err: err?.message || String(err) }, '*')
108+
const detail = await diagnose(err)
109+
document.getElementById('err').textContent = detail
110+
parent.postMessage({ type: 'import-error', slug, err: detail }, '*')
85111
}
86112
}
87113
</script>

tko.io/src/pages/tests.astro

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -633,14 +633,27 @@
633633
}
634634

635635
// Phase 1: parallel hidden specs.
636-
async function hiddenWorker() {
636+
//
637+
// Stagger the initial worker launches. If all N workers
638+
// spawn their first iframe simultaneously, they all hit
639+
// the same shared module-graph chunks concurrently and
640+
// WebKit's module loader occasionally reports the opaque
641+
// "Importing a module script failed." error on one of
642+
// them. A per-worker start-offset of 120ms spaces the
643+
// first-round fetches enough to avoid the race without
644+
// materially slowing the run (pool=4 ⇒ 360ms total head-
645+
// start before full concurrency). Subsequent picks within
646+
// a worker are already serial.
647+
const workerStagger = 120
648+
async function hiddenWorker(index) {
649+
if (index > 0) await new Promise(r => setTimeout(r, index * workerStagger))
637650
while (hiddenQueue.length) {
638651
const spec = hiddenQueue.shift()
639652
if (!spec) return
640653
await runOne(spec, { host: hiddenHost, label: null, grantFocus: false })
641654
}
642655
}
643-
await Promise.all(Array.from({ length: page.pool }, hiddenWorker))
656+
await Promise.all(Array.from({ length: page.pool }, (_, i) => hiddenWorker(i)))
644657

645658
// Phase 2: serial focus specs in the visible workarea.
646659
for (const spec of focusQueue) {

0 commit comments

Comments
 (0)