Skip to content

Commit 493b16f

Browse files
committed
fix(browser): close the pages of a project that has no tests left
Browser pages were only closed by the provider's `close()`, which runs once the whole workspace run is over. Projects run concurrently and each opens up to `maxWorkers` pages, so a run held `projects * maxWorkers` pages at its peak and the projects that finished early kept their page alive until the slowest one was done. Add an optional `closePage` to `BrowserProvider`, implement it for Playwright, and call it from the pool once a session has no test files left. The results of the last test can still be on their way when that happens, so the session waits for them to be handled before its page goes away. In watch mode the pages are kept so that reruns still reuse the session.
1 parent c6174a6 commit 493b16f

5 files changed

Lines changed: 110 additions & 8 deletions

File tree

packages/browser-playwright/src/playwright.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,19 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
653653
await this._throwIfClosing(browserPage)
654654
}
655655

656+
async closePage(sessionId: string): Promise<void> {
657+
const page = this.pages.get(sessionId)
658+
659+
if (!page) {
660+
return
661+
}
662+
663+
debug?.('[%s][%s] closing the page', sessionId, this.browserName)
664+
this.pages.delete(sessionId)
665+
await page.close()
666+
// the context is closed with the provider, it can still hold pending tracing chunks
667+
}
668+
656669
private async _throwIfClosing(disposable?: { close: () => Promise<void> }) {
657670
if (this.closing) {
658671
debug?.('[%s] provider was closed, cannot perform the action on %s', this.browserName, String(disposable))

packages/browser/src/node/rpc.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -291,11 +291,18 @@ export function setupBrowserRpc(globalServer: ParentBrowserProject, defaultMocke
291291
return project.benchmark.writeResult(relativePath, data)
292292
},
293293
async onTaskUpdate(method, packs, events) {
294-
if (method === 'collect') {
295-
vitest.state.updateTasks(packs)
294+
const sessions = vitest._browserSessions
295+
sessions.startUpdate(options.sessionId)
296+
try {
297+
if (method === 'collect') {
298+
vitest.state.updateTasks(packs)
299+
}
300+
else {
301+
await vitest._testRun.updated(packs, events)
302+
}
296303
}
297-
else {
298-
await vitest._testRun.updated(packs, events)
304+
finally {
305+
sessions.finishUpdate(options.sessionId)
299306
}
300307
},
301308
onAfterSuiteRun(meta) {

packages/vitest/src/node/browser/sessions.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,48 @@ import { createDefer } from '@vitest/utils/helpers'
55

66
export class BrowserSessions {
77
private sessions = new Map<string, BrowserServerStateSession>()
8+
private pendingUpdates = new Map<string, { count: number; waiters: (() => void)[] }>()
89

910
public sessionIds: Set<string> = new Set()
1011

1112
getSession(sessionId: string): BrowserServerStateSession | undefined {
1213
return this.sessions.get(sessionId)
1314
}
1415

16+
startUpdate(sessionId: string): void {
17+
const pending = this.pendingUpdates.get(sessionId)
18+
if (pending) {
19+
pending.count++
20+
}
21+
else {
22+
this.pendingUpdates.set(sessionId, { count: 1, waiters: [] })
23+
}
24+
}
25+
26+
finishUpdate(sessionId: string): void {
27+
const pending = this.pendingUpdates.get(sessionId)
28+
if (!pending) {
29+
return
30+
}
31+
pending.count--
32+
if (pending.count > 0) {
33+
return
34+
}
35+
this.pendingUpdates.delete(sessionId)
36+
pending.waiters.forEach(resolve => resolve())
37+
}
38+
39+
// the results of the last test can still be arriving when the session has no tests left
40+
waitForUpdates(sessionId: string): Promise<void> {
41+
const pending = this.pendingUpdates.get(sessionId)
42+
if (!pending) {
43+
return Promise.resolve()
44+
}
45+
return new Promise<void>((resolve) => {
46+
pending.waiters.push(resolve)
47+
})
48+
}
49+
1550
destroySession(sessionId: string): void {
1651
this.sessions.delete(sessionId)
1752
}

packages/vitest/src/node/pools/browser.ts

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export function createBrowserPool(vitest: Vitest): ProcessPool {
3737
: Math.max(maxThreadsCount, 1)
3838

3939
const projectPools = new WeakMap<TestProject, BrowserPool>()
40+
const pools = new Set<BrowserPool>()
4041

4142
const ensurePool = (project: TestProject) => {
4243
if (projectPools.has(project)) {
@@ -49,6 +50,7 @@ export function createBrowserPool(vitest: Vitest): ProcessPool {
4950
maxWorkers: getThreadsCount(project),
5051
})
5152
projectPools.set(project, pool)
53+
pools.add(pool)
5254
vitest.onCancel(() => {
5355
pool.cancel()
5456
})
@@ -170,21 +172,31 @@ export function createBrowserPool(vitest: Vitest): ProcessPool {
170172
// a frozen or crashed browser never answers the close message;
171173
// don't wait for it forever, the browser process is killed
172174
// when this process exits anyway
173-
await Promise.all(Array.from(providers, (provider) => {
175+
const withTimeout = (promise: Promise<void>, message: string) => {
174176
let timer: ReturnType<typeof setTimeout>
175177
return Promise.race([
176-
Promise.resolve(provider.close()).finally(() => clearTimeout(timer)),
178+
promise.finally(() => clearTimeout(timer)),
177179
new Promise<void>((resolve) => {
178180
timer = setTimeout(() => {
179-
vitest.logger.warn(`The browser did not close within ${PROVIDER_CLOSE_TIMEOUT}ms. The browser process will be killed when the process exits.`)
181+
vitest.logger.warn(message)
180182
resolve()
181183
}, PROVIDER_CLOSE_TIMEOUT)
182184
timer.unref()
183185
}),
184186
])
185-
}))
187+
}
188+
189+
await Promise.all(Array.from(pools, pool => withTimeout(
190+
pool.waitForClosedPages(),
191+
`The browser did not close its pages within ${PROVIDER_CLOSE_TIMEOUT}ms. The browser process will be killed when the process exits.`,
192+
)))
193+
await Promise.all(Array.from(providers, provider => withTimeout(
194+
Promise.resolve(provider.close()),
195+
`The browser did not close within ${PROVIDER_CLOSE_TIMEOUT}ms. The browser process will be killed when the process exits.`,
196+
)))
186197
vitest._browserSessions.sessionIds.clear()
187198
providers.clear()
199+
pools.clear()
188200
vitest.projects.forEach((project) => {
189201
project.browser?.state.orchestrators.forEach((orchestrator) => {
190202
orchestrator.$close()
@@ -207,6 +219,7 @@ class BrowserPool {
207219
private _providedContext: string | undefined
208220

209221
private readySessions: Set<string>
222+
private _closingPages: Promise<void>[] = []
210223

211224
private _traces: Traces
212225
private _otel: {
@@ -344,11 +357,44 @@ class BrowserPool {
344357
return orchestrator
345358
}
346359

360+
public async waitForClosedPages(): Promise<void> {
361+
await Promise.all(this._closingPages)
362+
}
363+
364+
// in watch mode the pages are reused by the next run
365+
private closePages(): void {
366+
const provider = this.project.browser!.provider
367+
368+
if (this.project.vitest.config.watch || !provider.closePage) {
369+
return
370+
}
371+
372+
const sessionIds = [...this.orchestrators.keys()]
373+
sessionIds.forEach((sessionId) => {
374+
this.readySessions.delete(sessionId)
375+
this.orchestrators.delete(sessionId)
376+
})
377+
378+
// not awaited here: a browser that stopped answering would hold up the whole run,
379+
// `close` waits for it instead
380+
this._closingPages.push(Promise.all(sessionIds.map(async (sessionId) => {
381+
try {
382+
// the page cannot go away while its results are still being handled
383+
await this.project.vitest._browserSessions.waitForUpdates(sessionId)
384+
await provider.closePage!(sessionId)
385+
}
386+
catch (error) {
387+
debug?.('[%s] failed to close the page: %s', sessionId, error)
388+
}
389+
})).then(() => undefined))
390+
}
391+
347392
private finishSession(sessionId: string): void {
348393
this.readySessions.add(sessionId)
349394

350395
// the last worker finished running tests
351396
if (this.readySessions.size === this.orchestrators.size) {
397+
this.closePages()
352398
this._otel.span.end()
353399
this._promise?.resolve()
354400
this._promise = undefined

packages/vitest/src/node/types/browser.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export interface BrowserProvider {
5252
supportsParallelism: boolean
5353
getCommandsContext: (sessionId: string) => Record<string, unknown>
5454
openPage: (sessionId: string, url: string, options: { parallel: boolean }) => Promise<void>
55+
closePage?: (sessionId: string) => Awaitable<void>
5556
getCDPSession?: (sessionId: string) => Promise<CDPSession>
5657
close: () => Awaitable<void>
5758
}

0 commit comments

Comments
 (0)