[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.

perf(browser): open browser sessions adaptively instead of maxWorkers upfront (#10726)

authored by

Vladimir and committed by
GitHub
(Jul 9, 2026, 9:55 AM +0200) 6db8de2e 941bc836

+122 -38
+122 -38
packages/vitest/src/node/pools/browser.ts
··· 187 187 private _promise: DeferPromise<void> | undefined 188 188 private _providedContext: string | undefined 189 189 190 + private _scaling: Promise<void> | undefined 191 + // EMA of how long a single file takes in this pool; undefined until the 192 + // first file finishes, which means "no signal yet — keep opening sessions" 193 + private _fileCostEma: number | undefined 194 + // refined with the real duration after every session open 195 + private _sessionOpenCost = 250 196 + // a session's first file pays the tester bootstrap on top of the test 197 + // itself, so it would wildly overestimate the steady per-file cost 198 + private _warmedUpSessions = new Set<string>() 199 + 190 200 private readySessions: Set<string> 191 201 192 202 private _traces: Traces ··· 250 260 return this._promise 251 261 } 252 262 253 - // open the minimum amount of tabs 254 - // if there is only 1 file running, we don't need 8 tabs running 255 - const workerCount = Math.min( 256 - this.options.maxWorkers - this.orchestrators.size, 257 - files.length, 258 - ) 259 - 260 - const promises: Promise<void>[] = [] 261 - for (let i = 0; i < workerCount; i++) { 262 - const sessionId = crypto.randomUUID() 263 - this.project.vitest._browserSessions.sessionIds.add(sessionId) 264 - const project = this.project.name 265 - debug?.('[%s] creating session for %s', sessionId, project) 266 - const page = this._traces.$( 267 - `vitest.browser.open`, 268 - { 269 - context: this._otel.context, 270 - attributes: { 271 - 'vitest.browser.session_id': sessionId, 272 - }, 273 - }, 274 - () => this.openPage(sessionId, { parallel: workerCount > 1 }), 275 - ).then(() => { 276 - // start running tests on the page when it's ready 277 - this.runNextTest(method, sessionId) 278 - }) 279 - promises.push(page) 280 - } 281 - await Promise.all(promises) 282 - debug?.('all sessions are created') 263 + this.scaleSessions(method) 283 264 return this._promise 265 + } 266 + 267 + // Sessions are opened one by one while the queue justifies another tab 268 + // instead of `maxWorkers` tabs upfront: every tab pays a context + page + 269 + // full module graph bring-up that competes with already-running sessions 270 + // for the same Vite server, so for fast suites fewer tabs finish sooner. 271 + private scaleSessions(method: 'run' | 'collect'): void { 272 + if (this._scaling) { 273 + return 274 + } 275 + this._scaling = (async () => { 276 + while ( 277 + this._queue.length 278 + && this.orchestrators.size < this.options.maxWorkers 279 + && this.shouldOpenAnotherSession() 280 + ) { 281 + const sessionId = crypto.randomUUID() 282 + this.project.vitest._browserSessions.sessionIds.add(sessionId) 283 + debug?.('[%s] creating session for %s', sessionId, this.project.name) 284 + const openStart = performance.now() 285 + await this._traces.$( 286 + `vitest.browser.open`, 287 + { 288 + context: this._otel.context, 289 + attributes: { 290 + 'vitest.browser.session_id': sessionId, 291 + }, 292 + }, 293 + () => this.openPage(sessionId, { 294 + parallel: this.options.maxWorkers > 1 295 + && this.orchestrators.size + this._queue.length > 1, 296 + }), 297 + ) 298 + this._sessionOpenCost = performance.now() - openStart 299 + // start running tests on the page when it's ready; a failure here 300 + // already took a file off the queue, so it can never be swallowed 301 + try { 302 + this.runNextTest(method, sessionId) 303 + } 304 + catch (error) { 305 + this.reject(error as Error) 306 + return 307 + } 308 + } 309 + debug?.('finished scaling sessions, %s sessions are running', this.orchestrators.size) 310 + })() 311 + this._scaling 312 + .catch((error) => { 313 + // a failure to open an extra session when the queue is already 314 + // drained should not fail the run: the sessions that are still 315 + // running have their own error and timeout handling 316 + if (!this._queue.length && this.orchestrators.size > 0) { 317 + debug?.('failed to open an extra session, ignoring: %s', error) 318 + return 319 + } 320 + this.reject(error as Error) 321 + }) 322 + .finally(() => { 323 + this._scaling = undefined 324 + // completion might have been blocked by the in-flight scaling 325 + this.checkCompletion() 326 + }) 327 + } 328 + 329 + private shouldOpenAnotherSession(): boolean { 330 + // the first session always opens; without a per-file signal 331 + // keep the old behavior of scaling up to maxWorkers 332 + if (this.orchestrators.size === 0 || this._fileCostEma == null) { 333 + return true 334 + } 335 + // only pay for another tab when the remaining work, split across the 336 + // sessions we already have, still takes considerably longer than opening 337 + // a tab costs — a new tab does not just cost its own bring-up, it also 338 + // competes with the running sessions for the same Vite server 339 + const projectedDrainMs 340 + = (this._queue.length * this._fileCostEma) / this.orchestrators.size 341 + return projectedDrainMs > Math.max(this._sessionOpenCost, 100) * 2 284 342 } 285 343 286 344 private async openPage(sessionId: string, options: { parallel: boolean }): Promise<void> { ··· 328 386 private finishSession(sessionId: string): void { 329 387 this.readySessions.add(sessionId) 330 388 331 - // the last worker finished running tests 332 - if (this.readySessions.size === this.orchestrators.size) { 333 - this._otel.span.end() 334 - this._promise?.resolve() 335 - this._promise = undefined 336 - debug?.('[%s] all tests finished running', sessionId) 337 - } 338 - else { 389 + if (!this.checkCompletion()) { 339 390 debug?.( 340 391 `did not finish sessions for ${sessionId}: |ready - %s| |overall - %s|`, 341 392 [...this.readySessions].join(', '), 342 393 [...this.orchestrators.keys()].join(', '), 343 394 ) 344 395 } 396 + } 397 + 398 + private checkCompletion(): boolean { 399 + // the run already finished (or was rejected) — nothing to resolve 400 + if (!this._promise) { 401 + return false 402 + } 403 + // the last worker finished running tests; a session that is still 404 + // opening (this._scaling) will call this again once it settles 405 + if (!this._scaling && this.readySessions.size === this.orchestrators.size) { 406 + this._otel.span.end() 407 + this._promise.resolve() 408 + this._promise = undefined 409 + debug?.('all tests finished running') 410 + return true 411 + } 412 + return false 345 413 } 346 414 347 415 private runNextTest(method: 'run' | 'collect', sessionId: string): void { ··· 373 441 const orchestrator = this.getOrchestrator(sessionId) 374 442 debug?.('[%s] run test %s', sessionId, file) 375 443 444 + // warm the transform cache while the iframe is booting so the test 445 + // file import doesn't wait for the transform; mirrors the URL the 446 + // tester will request (see `importFile` in the browser runner) 447 + const fileUrl = `/${/^\w:/.test(file.filepath) ? '@fs/' : ''}${file.filepath}`.replace(/\/+/g, '/') 448 + void this.project.vite.transformRequest(fileUrl).catch(() => {}) 449 + 376 450 this.setBreakpoint(sessionId, file.filepath).then(() => { 451 + const fileStart = performance.now() 377 452 // this starts running tests inside the orchestrator 378 453 const testersPromise = this._traces.$( 379 454 `vitest.browser.run`, ··· 403 478 ) 404 479 testersPromise 405 480 .then(() => { 481 + if (this._warmedUpSessions.has(sessionId)) { 482 + const fileCost = performance.now() - fileStart 483 + this._fileCostEma = this._fileCostEma == null 484 + ? fileCost 485 + : this._fileCostEma * 0.7 + fileCost * 0.3 486 + } 487 + else { 488 + this._warmedUpSessions.add(sessionId) 489 + } 406 490 debug?.('[%s] test %s finished running', sessionId, file) 407 491 this.runNextTest(method, sessionId) 408 492 })