[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

fix: add handshake timeout to iframe communication (#10656)

authored by

Vladimir and committed by
GitHub
(Jun 26, 2026, 9:39 AM +0900) 3545fe78 90c4ed4c

+241 -68
+90
test/browser/specs/readiness.test.ts
··· 58 58 } 59 59 `) 60 60 }) 61 + 62 + test('fails instead of hanging when the tester never becomes ready', { timeout: 20000 }, async () => { 63 + const { stderr, fs, testTree } = await runInlineBrowserTests( 64 + { 65 + 'basic.test.ts': ` 66 + import { expect, test } from 'vitest' 67 + 68 + test('never runs', () => { 69 + expect(1).toBe(1) 70 + }) 71 + `, 72 + 'silent-tester.html': ` 73 + <!DOCTYPE html> 74 + <html lang="en"> 75 + <head> 76 + <meta charset="UTF-8" /> 77 + <script> 78 + // simulate a tester that loads but never reports readiness 79 + const postMessage = BroadcastChannel.prototype.postMessage 80 + BroadcastChannel.prototype.postMessage = function (message) { 81 + if (message && message.event === 'ready') { 82 + return 83 + } 84 + return postMessage.call(this, message) 85 + } 86 + </script> 87 + </head> 88 + <body></body> 89 + </html> 90 + `, 91 + }, 92 + { 93 + env: { VITEST_BROWSER_IFRAME_TIMEOUT: '2000' }, 94 + browser: { 95 + instances: [instances[0]], 96 + testerHtmlPath: './silent-tester.html', 97 + }, 98 + }, 99 + ) 100 + 101 + expect(stderr).toContain(`Failed to run the test ${fs.resolveFile('basic.test.ts')}`) 102 + expect(stderr).toContain(`The iframe "${fs.resolveFile('basic.test.ts')}" did not become ready within 2000ms. The tester likely failed to initialize, check the browser console for errors.`) 103 + expect(testTree()).toMatchInlineSnapshot(`{}`) 104 + }) 105 + 106 + test('fails instead of hanging when the tester stops responding to messages', { timeout: 20000 }, async () => { 107 + const { stderr, fs, testTree } = await runInlineBrowserTests( 108 + { 109 + 'basic.test.ts': ` 110 + import { expect, test } from 'vitest' 111 + 112 + test('never runs', () => { 113 + expect(1).toBe(1) 114 + }) 115 + `, 116 + 'unresponsive-tester.html': ` 117 + <!DOCTYPE html> 118 + <html lang="en"> 119 + <head> 120 + <meta charset="UTF-8" /> 121 + <script> 122 + // tester reports readiness but its acknowledgements and responses 123 + // never reach the orchestrator (e.g. it crashed mid-run) 124 + const postMessage = BroadcastChannel.prototype.postMessage 125 + BroadcastChannel.prototype.postMessage = function (message) { 126 + if (message && typeof message.event === 'string' 127 + && (message.event.startsWith('ack:') || message.event.startsWith('response:'))) { 128 + return 129 + } 130 + return postMessage.call(this, message) 131 + } 132 + </script> 133 + </head> 134 + <body></body> 135 + </html> 136 + `, 137 + }, 138 + { 139 + env: { VITEST_BROWSER_IFRAME_TIMEOUT: '2000' }, 140 + browser: { 141 + instances: [instances[0]], 142 + testerHtmlPath: './unresponsive-tester.html', 143 + }, 144 + }, 145 + ) 146 + 147 + expect(stderr).toContain(`Failed to run the test ${fs.resolveFile('basic.test.ts')}`) 148 + expect(stderr).toContain(`The iframe "${fs.resolveFile('basic.test.ts')}" did not acknowledge the "prepare" message within 2000ms. The tester might have crashed, been removed, or be blocked by a long synchronous task.`) 149 + expect(testTree()).toMatchInlineSnapshot(`{}`) 150 + })
+2
packages/browser/src/client/channel.ts
··· 70 70 = | IframeChannelIncomingEvent 71 71 | IframeChannelOutgoingEvent 72 72 73 + export type IframeReceivedEvent = IframeChannelEvent & { messageId: number } 74 + 73 75 export const channel: BroadcastChannel = new BroadcastChannel( 74 76 `vitest:${getBrowserState().sessionId}`, 75 77 )
+75 -15
packages/browser/src/client/orchestrator.ts
··· 1 1 import type { Context as OTELContext } from '@opentelemetry/api' 2 - import type { GlobalChannelIncomingEvent, IframeChannelEvent, IframeChannelOutgoingEvent, IframeViewportDoneEvent, IframeViewportFailEvent } from '@vitest/browser/client' 2 + import type { GlobalChannelIncomingEvent, IframeChannelEvent, IframeChannelOutgoingEvent, IframeReceivedEvent, IframeViewportDoneEvent, IframeViewportFailEvent } from '@vitest/browser/client' 3 3 import type { BrowserTesterOptions, SerializedConfig } from 'vitest' 4 4 import type { FileSpecification } from 'vitest/internal/browser' 5 5 import { channel, client, globalChannel } from '@vitest/browser/client' ··· 17 17 private recreateNonIsolatedIframe = false 18 18 private iframes = new Map<string, HTMLIFrameElement>() 19 19 private readyIframes = new Set<string>() 20 - private readyWaiters = new Map<string, () => void>() 20 + private readyWaiters = new Map<string, { resolve: () => void; reject: (error: Error) => void }>() 21 + private messageId = 0 21 22 22 23 public eventTarget: EventTarget = new EventTarget() 23 24 ··· 290 291 const waiter = this.readyWaiters.get(iframeId) 291 292 if (waiter) { 292 293 this.readyWaiters.delete(iframeId) 293 - waiter() 294 + waiter.resolve() 294 295 } 295 296 } 296 297 ··· 299 300 return Promise.resolve() 300 301 } 301 302 302 - return new Promise((resolve) => { 303 - this.readyWaiters.set(iframeId, resolve) 303 + return new Promise<void>((resolve, reject) => { 304 + const timeout = getIframeTimeout() 305 + // the tester reports readiness as soon as its module evaluates; if it 306 + // never does (e.g. it threw during bootstrap), don't wait forever 307 + const timer = setTimeout(() => { 308 + this.readyWaiters.delete(iframeId) 309 + reject(new Error( 310 + `The iframe "${iframeId}" did not become ready within ${timeout}ms. ` 311 + + `The tester likely failed to initialize, check the browser console for errors.`, 312 + )) 313 + }, timeout) 314 + 315 + this.readyWaiters.set(iframeId, { 316 + resolve: () => { 317 + clearTimeout(timer) 318 + resolve() 319 + }, 320 + reject: (error) => { 321 + clearTimeout(timer) 322 + reject(error) 323 + }, 324 + }) 304 325 }) 305 326 } 306 327 ··· 308 329 const iframe = this.iframes.get(iframeId) 309 330 this.iframes.delete(iframeId) 310 331 this.readyIframes.delete(iframeId) 311 - this.readyWaiters.delete(iframeId) 332 + const waiter = this.readyWaiters.get(iframeId) 333 + if (waiter) { 334 + this.readyWaiters.delete(iframeId) 335 + // surface an error instead of silently abandoning whoever awaits readiness 336 + waiter.reject(new Error(`The iframe "${iframeId}" was removed before it became ready.`)) 337 + } 312 338 iframe?.remove() 313 339 } 314 340 ··· 432 458 break 433 459 } 434 460 default: { 435 - // ignore responses 461 + // ignore acknowledgements and responses to events we sent 462 + const event = e.data.event 436 463 if ( 437 - typeof e.data.event === 'string' 438 - && (e.data.event as string).startsWith('response:') 464 + typeof event === 'string' 465 + && (event.startsWith('response:') || event.startsWith('ack:')) 439 466 ) { 440 467 break 441 468 } ··· 465 492 } 466 493 events.add(event.event) 467 494 468 - channel.postMessage(event) 495 + const messageId = this.messageId++ 496 + channel.postMessage({ ...event, messageId } satisfies IframeReceivedEvent) 497 + 469 498 return new Promise<void>((resolve, reject) => { 499 + let ackTimer: ReturnType<typeof setTimeout> 500 + 470 501 const cleanupEvents = () => { 502 + clearTimeout(ackTimer) 471 503 channel.removeEventListener('message', onReceived) 472 504 this.eventTarget.removeEventListener('iframeerror', onError) 505 + events!.delete(event.event) 473 506 } 474 507 508 + // The tester acknowledges the message as soon as it receives it, then 509 + // sends the actual response once the work is done. We only time out 510 + // waiting for the acknowledgement: it proves the tester is alive, after 511 + // which the work (e.g. running a whole test file) may take any amount of 512 + // time, so there is intentionally no deadline on the response itself. 513 + const timeout = getIframeTimeout() 514 + ackTimer = setTimeout(() => { 515 + cleanupEvents() 516 + reject(new Error( 517 + `The iframe "${event.iframeId}" did not acknowledge the "${event.event}" message within ${timeout}ms. ` 518 + + `The tester might have crashed, been removed, or be blocked by a long synchronous task.`, 519 + )) 520 + }, timeout) 521 + 475 522 function onReceived(e: MessageEvent) { 476 - if (e.data.iframeId === event.iframeId && e.data.event === `response:${event.event}`) { 477 - resolve() 523 + if (e.data.iframeId !== event.iframeId || e.data.messageId !== messageId) { 524 + return 525 + } 526 + if (e.data.event === `ack:${event.event}`) { 527 + // alive and processing: wait for the response without a deadline 528 + clearTimeout(ackTimer) 529 + return 530 + } 531 + if (e.data.event === `response:${event.event}`) { 478 532 cleanupEvents() 479 - events!.delete(event.event) 533 + resolve() 480 534 } 481 535 } 482 536 483 537 function onError(e: Event) { 484 - reject((e as CustomEvent).detail) 485 538 cleanupEvents() 486 - events!.delete(event.event) 539 + reject((e as CustomEvent).detail) 487 540 } 488 541 489 542 this.eventTarget.addEventListener('iframeerror', onError) ··· 550 603 if (debug && debug !== 'false') { 551 604 client.rpc.debug(...args.map(String)) 552 605 } 606 + } 607 + 608 + // Liveness timeout for tester iframes (readiness and message acknowledgement), 609 + // not a timeout for the test work itself. Overridable via the `VITEST_BROWSER_IFRAME_TIMEOUT` 610 + // env in case a tester legitimately needs longer to boot or acknowledge. 611 + function getIframeTimeout(): number { 612 + return Number(getConfig().env.VITEST_BROWSER_IFRAME_TIMEOUT) || 60_000 553 613 }
+74 -53
packages/browser/src/client/tester/tester.ts
··· 1 - import type { BrowserRPC, IframeChannelEvent } from '@vitest/browser/client' 1 + import type { BrowserRPC, IframeReceivedEvent } from '@vitest/browser/client' 2 2 import type { FileSpecification } from 'vitest/internal/browser' 3 3 import { channel, client, onCancel, registerPageMarkHandler } from '@vitest/browser/client' 4 4 import { parse } from 'flatted' ··· 35 35 getBrowserState().traces = traces 36 36 37 37 channel.addEventListener('message', async (e) => { 38 - await client.waitForConnection() 39 - 40 38 const data = e.data 41 - debug?.('event from orchestrator', JSON.stringify(e.data)) 42 39 43 40 if (!isEvent(data)) { 41 + await client.waitForConnection() 44 42 const error = new Error(`Unknown message: ${JSON.stringify(e.data)}`) 45 43 unhandledError(error, 'Unknown Iframe Message') 46 44 return ··· 51 49 return 52 50 } 53 51 54 - switch (data.event) { 55 - case 'execute': { 56 - const { method, files, context, concurrencyId, workerId } = data 57 - const state = getWorkerState() 58 - const parsedContext = parse(context) 52 + // tell the orchestrator we received the event before doing any work (which 53 + // may be long-running or gated on the connection), so it can tell a busy 54 + // tester apart from a crashed one. See `sendEventToIframe` in orchestrator.ts. 55 + channel.postMessage({ 56 + event: `ack:${data.event}`, 57 + iframeId: data.iframeId, 58 + messageId: data.messageId, 59 + }) 59 60 60 - state.ctx.concurrencyId = concurrencyId 61 - state.ctx.workerId = workerId 62 - state.ctx.providedContext = parsedContext 63 - state.providedContext = parsedContext 64 - state.metaEnv.VITEST_POOL_ID = String(concurrencyId) 65 - state.metaEnv.VITEST_WORKER_ID = String(workerId) 61 + await client.waitForConnection() 62 + debug?.('event from orchestrator', JSON.stringify(e.data)) 66 63 67 - if (method === 'collect') { 68 - await executeTests('collect', files).catch(err => unhandledError(err, 'Collect Error')) 64 + try { 65 + switch (data.event) { 66 + case 'execute': { 67 + const { method, files, context, concurrencyId, workerId } = data 68 + const state = getWorkerState() 69 + const parsedContext = parse(context) 70 + 71 + state.ctx.concurrencyId = concurrencyId 72 + state.ctx.workerId = workerId 73 + state.ctx.providedContext = parsedContext 74 + state.providedContext = parsedContext 75 + state.metaEnv.VITEST_POOL_ID = String(concurrencyId) 76 + state.metaEnv.VITEST_WORKER_ID = String(workerId) 77 + 78 + if (method === 'collect') { 79 + await executeTests('collect', files).catch(err => unhandledError(err, 'Collect Error')) 80 + } 81 + else { 82 + await executeTests('run', files).catch(err => unhandledError(err, 'Run Error')) 83 + } 84 + break 69 85 } 70 - else { 71 - await executeTests('run', files).catch(err => unhandledError(err, 'Run Error')) 86 + case 'cleanup': { 87 + await cleanup().catch(err => unhandledError(err, 'Cleanup Error')) 88 + rootTesterSpan?.span.end() 89 + await traces.finish() 90 + break 72 91 } 73 - break 74 - } 75 - case 'cleanup': { 76 - await cleanup().catch(err => unhandledError(err, 'Cleanup Error')) 77 - rootTesterSpan?.span.end() 78 - await traces.finish() 79 - break 80 - } 81 - case 'prepare': { 82 - await traces.waitInit() 83 - const tracesContext = traces.getContextFromCarrier(data.otelCarrier) 84 - traces.recordInitSpan(tracesContext) 85 - rootTesterSpan = traces.startContextSpan( 86 - `vitest.browser.tester.run`, 87 - tracesContext, 88 - ) 89 - traces.bind(rootTesterSpan.context) 90 - await prepare(data).catch(err => unhandledError(err, 'Prepare Error')) 91 - break 92 - } 93 - case 'viewport:done': 94 - case 'viewport:fail': 95 - case 'viewport': { 96 - break 97 - } 98 - default: { 99 - const error = new Error(`Unknown event: ${(data as any).event}`) 100 - unhandledError(error, 'Unknown Event') 92 + case 'prepare': { 93 + await traces.waitInit() 94 + const tracesContext = traces.getContextFromCarrier(data.otelCarrier) 95 + traces.recordInitSpan(tracesContext) 96 + rootTesterSpan = traces.startContextSpan( 97 + `vitest.browser.tester.run`, 98 + tracesContext, 99 + ) 100 + traces.bind(rootTesterSpan.context) 101 + await prepare(data).catch(err => unhandledError(err, 'Prepare Error')) 102 + break 103 + } 104 + case 'viewport:done': 105 + case 'viewport:fail': 106 + case 'viewport': { 107 + break 108 + } 109 + default: { 110 + const error = new Error(`Unknown event: ${(data as any).event}`) 111 + unhandledError(error, 'Unknown Event') 112 + } 101 113 } 102 114 } 103 - 104 - channel.postMessage({ 105 - event: `response:${data.event}`, 106 - iframeId: getBrowserState().iframeId!, 107 - }) 115 + catch (error: any) { 116 + // errors not handled by the cases above (e.g. tracing setup/teardown) must 117 + // not stop us from responding, otherwise the orchestrator would wait forever 118 + await unhandledError(error, 'Tester Error') 119 + } 120 + finally { 121 + // always let the orchestrator know the event was handled so its 122 + // `sendEventToIframe` promise resolves, even if the work above threw 123 + channel.postMessage({ 124 + event: `response:${data.event}`, 125 + iframeId: data.iframeId, 126 + messageId: data.messageId, 127 + }) 128 + } 108 129 }) 109 130 110 131 const url = new URL(location.href) ··· 307 328 stack: e.stack, 308 329 }, type).catch(() => {}) 309 330 } 310 - function isEvent(data: unknown): data is IframeChannelEvent { 331 + function isEvent(data: unknown): data is IframeReceivedEvent { 311 332 return typeof data === 'object' && !!data && 'event' in data 312 333 }