diff --git a/packages/browser/src/client/client.ts b/packages/browser/src/client/client.ts index 269bf2cbd..af9d86c21 100644 --- a/packages/browser/src/client/client.ts +++ b/packages/browser/src/client/client.ts @@ -23,8 +23,14 @@ export const ENTRY_URL: string = `${ const onCancelCallbacks: ((reason: CancelReason) => void)[] = [] -export function onCancel(callback: (reason: CancelReason) => void): void { +export function onCancel(callback: (reason: CancelReason) => void): () => void { onCancelCallbacks.push(callback) + return () => { + const index = onCancelCallbacks.indexOf(callback) + if (index !== -1) { + onCancelCallbacks.splice(index, 1) + } + } } let pageMarkHandler: ((name: string, options?: MarkOptions) => Promise) | null = null diff --git a/packages/expect/src/state.ts b/packages/expect/src/state.ts index 426cdf530..a304d086a 100644 --- a/packages/expect/src/state.ts +++ b/packages/expect/src/state.ts @@ -11,7 +11,11 @@ if (!Object.hasOwn(globalThis, MATCHERS_OBJECT)) { const matchers = Object.create(null) const customEqualityTesters: Array = [] const asymmetricMatchers = Object.create(null) + // `configurable` so that vm pools can strip the accessors from a disposed + // context: the getters capture the expect state, which would otherwise keep + // the whole test-file world reachable from the leaked context shell Object.defineProperty(globalThis, MATCHERS_OBJECT, { + configurable: true, get: () => globalState, }) Object.defineProperty(globalThis, JEST_MATCHERS_OBJECT, { @@ -23,6 +27,7 @@ if (!Object.hasOwn(globalThis, MATCHERS_OBJECT)) { }), }) Object.defineProperty(globalThis, ASYMMETRIC_MATCHERS_OBJECT, { + configurable: true, get: () => asymmetricMatchers, }) } diff --git a/packages/vitest/src/runtime/external-executor.ts b/packages/vitest/src/runtime/external-executor.ts index 315e6cc5d..bb3942228 100644 --- a/packages/vitest/src/runtime/external-executor.ts +++ b/packages/vitest/src/runtime/external-executor.ts @@ -11,6 +11,7 @@ import { lookupPackageScopeType } from '@vitest/utils/resolver' import { extname, normalize } from 'pathe' import { CommonjsExecutor } from './vm/commonjs-executor' import { EsmExecutor } from './vm/esm-executor' +import { setActiveVmExecutor } from './vm/utils' import { ViteExecutor } from './vm/vite-executor' const { existsSync } = fs @@ -72,9 +73,9 @@ export class ExternalModulesExecutor { this.esm = new EsmExecutor(this, { context: this.context, }) + setActiveVmExecutor(this) this.cjs = new CommonjsExecutor({ context: this.context, - importModuleDynamically: this.importModuleDynamically, fileMap: options.fileMap, codeCache: options.codeCache, interopDefault: options.interopDefault, diff --git a/packages/vitest/src/runtime/rpc.ts b/packages/vitest/src/runtime/rpc.ts index baaccba43..753a80344 100644 --- a/packages/vitest/src/runtime/rpc.ts +++ b/packages/vitest/src/runtime/rpc.ts @@ -63,8 +63,14 @@ export async function rpcDone(): Promise { const onCancelCallbacks: ((reason: CancelReason) => void)[] = [] -export function onCancel(callback: (reason: CancelReason) => void): void { +export function onCancel(callback: (reason: CancelReason) => void): () => void { onCancelCallbacks.push(callback) + return () => { + const index = onCancelCallbacks.indexOf(callback) + if (index !== -1) { + onCancelCallbacks.splice(index, 1) + } + } } export function createRuntimeRpc( diff --git a/packages/vitest/src/runtime/runBaseTests.ts b/packages/vitest/src/runtime/runBaseTests.ts index 050d34226..81d13283b 100644 --- a/packages/vitest/src/runtime/runBaseTests.ts +++ b/packages/vitest/src/runtime/runBaseTests.ts @@ -40,53 +40,58 @@ export async function run( }), ]) - workerState.onCancel((reason) => { + const offCancel = workerState.onCancel((reason) => { closeInspector(config) testRunner.cancel?.(reason) }) workerState.durations.prepare = performance.now() - workerState.durations.prepare - await traces.$( - `vitest.test.runner.${method}`, - async () => { - for (const file of files) { - if (config.isolate) { - moduleRunner.mocker?.reset() - resetModules(workerState.evaluatedModules, true) - } + try { + await traces.$( + `vitest.test.runner.${method}`, + async () => { + for (const file of files) { + if (config.isolate) { + moduleRunner.mocker?.reset() + resetModules(workerState.evaluatedModules, true) + } - workerState.filepath = file.filepath + workerState.filepath = file.filepath - if (method === 'run') { - const collectAsyncLeaks = config.detectAsyncLeaks ? detectAsyncLeaks(file.filepath, workerState.ctx.projectName) : undefined + if (method === 'run') { + const collectAsyncLeaks = config.detectAsyncLeaks ? detectAsyncLeaks(file.filepath, workerState.ctx.projectName) : undefined - await traces.$( - `vitest.test.runner.${method}.module`, - { attributes: { 'code.file.path': file.filepath } }, - () => startTests([file], testRunner), - ) + await traces.$( + `vitest.test.runner.${method}.module`, + { attributes: { 'code.file.path': file.filepath } }, + () => startTests([file], testRunner), + ) - const leaks = await collectAsyncLeaks?.() + const leaks = await collectAsyncLeaks?.() - if (leaks?.length) { - workerState.rpc.onAsyncLeaks(leaks) + if (leaks?.length) { + workerState.rpc.onAsyncLeaks(leaks) + } + } + else { + await traces.$( + `vitest.test.runner.${method}.module`, + { attributes: { 'code.file.path': file.filepath } }, + () => collectTests([file], testRunner), + ) } - } - else { - await traces.$( - `vitest.test.runner.${method}.module`, - { attributes: { 'code.file.path': file.filepath } }, - () => collectTests([file], testRunner), - ) - } - // reset after tests, because user might call `vi.setConfig` in setupFile - vi.resetConfig() - // mocks should not affect different files - vi.restoreAllMocks() - } - }, - ) + // reset after tests, because user might call `vi.setConfig` in setupFile + vi.resetConfig() + // mocks should not affect different files + vi.restoreAllMocks() + } + }, + ) + } + finally { + offCancel() + } await traces.$('vitest.runtime.coverage.stop', () => stopCoverageInsideWorker(config.coverage, moduleRunner, { isolate: config.isolate })) } diff --git a/packages/vitest/src/runtime/runVmTests.ts b/packages/vitest/src/runtime/runVmTests.ts index 31e058c3b..707c0608d 100644 --- a/packages/vitest/src/runtime/runVmTests.ts +++ b/packages/vitest/src/runtime/runVmTests.ts @@ -36,6 +36,8 @@ export async function run( Object.defineProperty(globalThis, '__vitest_index__', { value: VitestIndex, enumerable: false, + configurable: true, + writable: true, }) const viteEnvironment = workerState.environment.viteEnvironment || workerState.environment.name @@ -79,7 +81,10 @@ export async function run( config.snapshotOptions.snapshotEnvironment = snapshotEnvironment - workerState.onCancel((reason) => { + // the callback captures this file's runner: unsubscribe once the run is + // over, or every finished file's world stays reachable from the worker's + // cancel listeners for the lifetime of the worker + const offCancel = workerState.onCancel((reason) => { closeInspector(config) testRunner.cancel?.(reason) }) @@ -89,42 +94,47 @@ export async function run( const { vi } = VitestIndex - await traces.$( - `vitest.test.runner.${method}`, - async () => { - for (const file of files) { - workerState.filepath = file.filepath + try { + await traces.$( + `vitest.test.runner.${method}`, + async () => { + for (const file of files) { + workerState.filepath = file.filepath - if (method === 'run') { - const collectAsyncLeaks = config.detectAsyncLeaks ? detectAsyncLeaks(file.filepath, workerState.ctx.projectName) : undefined + if (method === 'run') { + const collectAsyncLeaks = config.detectAsyncLeaks ? detectAsyncLeaks(file.filepath, workerState.ctx.projectName) : undefined - await traces.$( - `vitest.test.runner.${method}.module`, - { attributes: { 'code.file.path': file.filepath } }, - () => startTests([file], testRunner), - ) + await traces.$( + `vitest.test.runner.${method}.module`, + { attributes: { 'code.file.path': file.filepath } }, + () => startTests([file], testRunner), + ) - const leaks = await collectAsyncLeaks?.() + const leaks = await collectAsyncLeaks?.() - if (leaks?.length) { - workerState.rpc.onAsyncLeaks(leaks) + if (leaks?.length) { + workerState.rpc.onAsyncLeaks(leaks) + } + } + else { + await traces.$( + `vitest.test.runner.${method}.module`, + { attributes: { 'code.file.path': file.filepath } }, + () => collectTests([file], testRunner), + ) } - } - else { - await traces.$( - `vitest.test.runner.${method}.module`, - { attributes: { 'code.file.path': file.filepath } }, - () => collectTests([file], testRunner), - ) - } - // reset after tests, because user might call `vi.setConfig` in setupFile - vi.resetConfig() - // mocks should not affect different files - vi.restoreAllMocks() - } - }, - ) + // reset after tests, because user might call `vi.setConfig` in setupFile + vi.resetConfig() + // mocks should not affect different files + vi.restoreAllMocks() + } + }, + ) + } + finally { + offCancel() + } await traces.$('vitest.runtime.coverage.stop', () => stopCoverageInsideWorker(config.coverage, moduleRunner, { isolate: false })) } diff --git a/packages/vitest/src/runtime/runners/test.ts b/packages/vitest/src/runtime/runners/test.ts index 321dfe105..19a833897 100644 --- a/packages/vitest/src/runtime/runners/test.ts +++ b/packages/vitest/src/runtime/runners/test.ts @@ -54,6 +54,12 @@ export class TestRunner implements VitestTestRunner { const environment = this.workerState.environment this.viteEnvironment = environment.viteEnvironment || environment.name this.viteModuleRunner = config.experimental.viteModuleRunner + // vm pools downgrade worker-scoped fixtures to file scope, so the hook has + // nothing to tear down there; registering it anyway would keep the + // listener, an in-context closure, alive for the lifetime of the worker + if (this.pool !== 'vmThreads' && this.pool !== 'vmForks') { + this.onCleanupWorkerContext = listener => this.workerState.onCleanup(listener) + } } importFile(filepath: string, source: VitestRunnerImportSource): unknown { @@ -81,9 +87,7 @@ export class TestRunner implements VitestTestRunner { this.workerState.current = file } - onCleanupWorkerContext(listener: () => unknown): void { - this.workerState.onCleanup(listener) - } + onCleanupWorkerContext?: (listener: () => unknown) => void onAfterRunFiles(_files: File[]): void { this.snapshotClient.clear() diff --git a/packages/vitest/src/runtime/vm/commonjs-executor.ts b/packages/vitest/src/runtime/vm/commonjs-executor.ts index 455cba539..3a41acb1e 100644 --- a/packages/vitest/src/runtime/vm/commonjs-executor.ts +++ b/packages/vitest/src/runtime/vm/commonjs-executor.ts @@ -1,17 +1,16 @@ import type { CodeCache } from './code-cache' import type { FileMap } from './file-map' -import type { ImportModuleDynamically, VMSyntheticModule } from './types' +import type { VMSyntheticModule } from './types' import { Module as _Module, createRequire, isBuiltin } from 'node:module' import vm from 'node:vm' import { basename, dirname, extname } from 'pathe' -import { interopCommonJsModule, SyntheticModule } from './utils' +import { activeImportModuleDynamically, interopCommonJsModule, SyntheticModule } from './utils' interface CommonjsExecutorOptions { fileMap: FileMap codeCache?: CodeCache interopDefault?: boolean context: vm.Context - importModuleDynamically: ImportModuleDynamically } const _require = createRequire(import.meta.url) @@ -22,6 +21,11 @@ interface PrivateNodeModule extends NodeJS.Module { const requiresCache = new WeakMap() +// Compiled scripts of commonjs modules, shared across vm contexts: only the +// evaluation has to happen per context. No invalidation is needed because +// watch mode reruns destroy the worker. +const cjsScriptCache = new Map() + export class CommonjsExecutor { private context: vm.Context private requireCache = new Map() @@ -114,17 +118,24 @@ export class CommonjsExecutor { _compile(code: string, filename: string) { const cjsModule = Module.wrap(code) const codeCache = executor.codeCache - const cachedData = codeCache?.get(filename, cjsModule) - const script = new vm.Script(cjsModule, { - filename, - cachedData, - importModuleDynamically: options.importModuleDynamically, - } as any) - if (cachedData && script.cachedDataRejected) { - codeCache!.delete(filename) + let script = cjsScriptCache.get(filename) + if (!script) { + const cachedData = codeCache?.get(filename, cjsModule) + // the dynamic import callback is a static function (the executor is + // resolved when it is called), so the compiled script holds no + // per-context state and can be reused by every vm context + script = new vm.Script(cjsModule, { + filename, + cachedData, + importModuleDynamically: activeImportModuleDynamically, + } as any) + if (cachedData && script.cachedDataRejected) { + codeCache!.delete(filename) + } + // @ts-expect-error mark script with current identifier + script.identifier = filename + cjsScriptCache.set(filename, script) } - // @ts-expect-error mark script with current identifier - script.identifier = filename const fn = script.runInContext(executor.context) const __dirname = dirname(filename) executor.requireCache.set(filename, this) diff --git a/packages/vitest/src/runtime/vm/esm-executor.ts b/packages/vitest/src/runtime/vm/esm-executor.ts index 5307df5e2..011aec4c2 100644 --- a/packages/vitest/src/runtime/vm/esm-executor.ts +++ b/packages/vitest/src/runtime/vm/esm-executor.ts @@ -3,6 +3,7 @@ import type { ExternalModulesExecutor } from '../external-executor' import type { VMModule } from './types' import { dirname } from 'node:path' import { fileURLToPath } from 'node:url' +import { VITEST_VM_CONTEXT_SYMBOL } from '../moduleRunner/startVitestModuleRunner' import { SourceTextModule, SyntheticModule } from './utils' interface EsmExecutorOptions { @@ -12,6 +13,33 @@ interface EsmExecutorOptions { const dataURIRegex = /^data:(?text\/javascript|application\/json|application\/wasm)(?:;(?charset=utf-8|base64))?,(?.*)$/ +function getContextExecutor(mod: VMModule): ExternalModulesExecutor { + const vmContext = (mod.context as any)?.[VITEST_VM_CONTEXT_SYMBOL] + if (!vmContext) { + throw new Error(`Cannot import "${mod.identifier}": its vm context was torn down.`) + } + return vmContext.externalModulesExecutor +} + +async function staticImportModuleDynamically(specifier: string, referencer: VMModule): Promise { + return getContextExecutor(referencer).importModuleDynamically(specifier, referencer) +} + +function staticInitializeImportMeta(meta: ImportMeta, mod: VMModule): void { + meta.url = mod.identifier + if (mod.identifier.startsWith('file:')) { + const filename = fileURLToPath(mod.identifier) + meta.filename = filename + meta.dirname = dirname(filename) + } + meta.resolve = (specifier: string, importer?: string | URL) => { + return getContextExecutor(mod).resolve( + specifier, + importer != null ? importer.toString() : mod.identifier, + ) + } +} + export class EsmExecutor { private moduleCache = new Map>() @@ -79,21 +107,13 @@ export class EsmExecutor { identifier: fileURL, context: this.context, cachedData, - importModuleDynamically: this.executor.importModuleDynamically, - initializeImportMeta: (meta, mod) => { - meta.url = mod.identifier - if (mod.identifier.startsWith('file:')) { - const filename = fileURLToPath(mod.identifier) - meta.filename = filename - meta.dirname = dirname(filename) - } - meta.resolve = (specifier: string, importer?: string | URL) => { - return this.executor.resolve( - specifier, - importer != null ? importer.toString() : mod.identifier, - ) - } - }, + // static callbacks: Node keeps them registered for as long as the + // module's host-defined-options symbol is alive, so a closure here would + // retain this executor (and the whole test file's world) beyond the + // file's lifetime. The executor is recovered from the module's context + // at call time instead. + importModuleDynamically: staticImportModuleDynamically, + initializeImportMeta: staticInitializeImportMeta, }) // the code cache of a SourceTextModule must be created before evaluation if (!cachedData) { diff --git a/packages/vitest/src/runtime/vm/utils.ts b/packages/vitest/src/runtime/vm/utils.ts index b75666d9d..dccde7426 100644 --- a/packages/vitest/src/runtime/vm/utils.ts +++ b/packages/vitest/src/runtime/vm/utils.ts @@ -1,4 +1,4 @@ -import type { VMSourceTextModule, VMSyntheticModule } from './types' +import type { VMModule, VMSourceTextModule, VMSyntheticModule } from './types' import vm from 'node:vm' export function interopCommonJsModule( @@ -53,3 +53,73 @@ export const SyntheticModule: typeof VMSyntheticModule = (vm as any) .SyntheticModule export const SourceTextModule: typeof VMSourceTextModule = (vm as any) .SourceTextModule + +// The active executor of this worker: vm pools run one test file (and so +// one executor) at a time, which lets script-level dynamic import callbacks +// be static functions instead of per-executor closures. Node registers the +// callback for the lifetime of the compiled script, so a closure would both +// pin the executor's test file world and make compiled scripts unshareable +// between contexts. +interface ActiveVmExecutor { + importModuleDynamically: (specifier: string, referencer: VMModule) => Promise +} + +let activeVmExecutor: ActiveVmExecutor | undefined + +export function setActiveVmExecutor(executor: ActiveVmExecutor | undefined): void { + activeVmExecutor = executor +} + +export async function activeImportModuleDynamically(specifier: string, referencer: VMModule): Promise { + if (!activeVmExecutor) { + throw new Error(`Cannot import "${specifier}": the test context was torn down.`) + } + return activeVmExecutor.importModuleDynamically(specifier, referencer) +} + +// Node never collects a vm context in which multiple scripts installed +// closures, and `vm.SourceTextModule`s are pinned by the realm's base object +// list: the ContextifyContext/ModuleWrap wrappers keep the whole context +// reachable even through forced full GCs, so a long-lived vm worker +// accumulates every test file's world until it hits `vmMemoryLimit` and gets +// recycled, destroying the worker's compile caches with it. Clearing what the +// test file added to the global object (and the DOM) caps what a pinned +// context retains. Pristine globals are kept so that work queued before the +// teardown (jsdom events, worker-scoped fixture cleanups) can still run. +const captureKeysScript = new vm.Script( + `Object.getOwnPropertyNames(globalThis).concat(Object.getOwnPropertySymbols(globalThis))`, + { filename: 'virtual:vitest-capture-context-keys.js' }, +) + +export function captureContextKeys(context: vm.Context): Set { + try { + return new Set(captureKeysScript.runInContext(context)) + } + catch { + return new Set() + } +} + +const stripScript = new vm.Script( + `(initialKeys) => { + const g = globalThis + try { g.document.body.textContent = '' } catch {} + try { g.document.head.textContent = '' } catch {} + let keys = [] + try { keys = Object.getOwnPropertyNames(g).concat(Object.getOwnPropertySymbols(g)) } catch {} + for (const key of keys) { + if (initialKeys.has(key)) continue + try { delete g[key] } catch {} + } +}`, + { filename: 'virtual:vitest-strip-context.js' }, +) + +export function stripDisposedContext(context: vm.Context, initialKeys: Set): void { + try { + stripScript.runInContext(context)(initialKeys) + } + catch { + // the context is being thrown away; stripping is best-effort + } +} diff --git a/packages/vitest/src/runtime/workers/vm.ts b/packages/vitest/src/runtime/workers/vm.ts index 7c529bdbd..d1510d086 100644 --- a/packages/vitest/src/runtime/workers/vm.ts +++ b/packages/vitest/src/runtime/workers/vm.ts @@ -3,6 +3,7 @@ import type { WorkerGlobalState, WorkerSetupContext } from '../../types/worker' import type { Traces } from '../../utils/traces' import type { ModuleInformation } from '../external-executor' import { pathToFileURL } from 'node:url' +import v8 from 'node:v8' import { isContext, runInContext } from 'node:vm' import { resolve } from 'pathe' import { loadEnvironment } from '../../integrations/env/loader' @@ -18,6 +19,7 @@ import { setupEnv } from '../setup-common' import { provideWorkerState } from '../utils' import { CodeCache } from '../vm/code-cache' import { FileMap } from '../vm/file-map' +import { captureContextKeys, setActiveVmExecutor, stripDisposedContext } from '../vm/utils' const entryFile = pathToFileURL(resolve(distDir, 'workers/runVmTests.js')).href @@ -84,6 +86,11 @@ export async function runVmTests(method: 'run' | 'collect', state: WorkerGlobalS ) } + // captured before vitest installs its own globals (worker state, console, + // mocker, executor symbol): they reference the test file's module graph, so + // the teardown strip must treat them as removable, not as pristine + const initialContextKeys = captureContextKeys(context) + provideWorkerState(context, state) // this is unfortunately needed for our own dependencies @@ -171,6 +178,13 @@ export async function runVmTests(method: 'run' | 'collect', state: WorkerGlobalS 'vitest.runtime.environment.teardown', () => vm.teardown?.(), ) + // unregisters the runner from Vite's `Error.prepareStackTrace` interceptor: + // its module-level cache holds `evaluatedModules` of every runner it has + // seen, which would otherwise keep each test file's entire module graph + // (and with it the vm context) alive for the lifetime of the worker + await moduleRunner.close() + stripDisposedContext(context, initialContextKeys) + setActiveVmExecutor(undefined) } } @@ -178,4 +192,11 @@ export function setupVmWorker(context: WorkerSetupContext): void { if (context.config.experimental.viteModuleRunner === false) { throw new Error(`Pool "${context.pool}" cannot run with "experimental.viteModuleRunner: false". Please, use "threads" or "forks" instead.`) } + // V8's isolate-level compilation cache keeps evaluated `vm.SourceTextModule`s + // (and everything their module state references) alive until a + // memory-pressure GC clears the cache, which in practice means every test + // file's world accumulates until the worker hits `vmMemoryLimit`. The + // compiled-code caching the flag disables is already covered by the + // worker's own script and code caches. + v8.setFlagsFromString('--no-compilation-cache') } diff --git a/packages/vitest/src/types/worker.ts b/packages/vitest/src/types/worker.ts index fb3f70a62..1fce11fcc 100644 --- a/packages/vitest/src/types/worker.ts +++ b/packages/vitest/src/types/worker.ts @@ -81,7 +81,7 @@ export interface WorkerGlobalState { resolvingModules: Set moduleExecutionInfo: Map getterTracker?: GetterTracker - onCancel: (listener: (reason: CancelReason) => unknown) => void + onCancel: (listener: (reason: CancelReason) => unknown) => () => void onCleanup: (listener: () => unknown) => void providedContext: Record durations: { diff --git a/test/e2e/fixtures/leak-probe/a.test.js b/test/e2e/fixtures/leak-probe/a.test.js new file mode 100644 index 000000000..c9098e629 --- /dev/null +++ b/test/e2e/fixtures/leak-probe/a.test.js @@ -0,0 +1,15 @@ +import { expect, test } from 'vitest' +import { state } from './module-state.js' + +const extended = test.extend({ + fixture: async ({}, use) => { + await use('fixture') + }, +}) + +extended('keeps a heavy module graph alive', async ({ fixture }) => { + const dynamic = await import('./module-state.js') + expect(dynamic.state).toBe(state) + expect(fixture).toBe('fixture') + document.body.innerHTML = '
payload
' +}) diff --git a/test/e2e/fixtures/leak-probe/b.test.js b/test/e2e/fixtures/leak-probe/b.test.js new file mode 100644 index 000000000..c9098e629 --- /dev/null +++ b/test/e2e/fixtures/leak-probe/b.test.js @@ -0,0 +1,15 @@ +import { expect, test } from 'vitest' +import { state } from './module-state.js' + +const extended = test.extend({ + fixture: async ({}, use) => { + await use('fixture') + }, +}) + +extended('keeps a heavy module graph alive', async ({ fixture }) => { + const dynamic = await import('./module-state.js') + expect(dynamic.state).toBe(state) + expect(fixture).toBe('fixture') + document.body.innerHTML = '
payload
' +}) diff --git a/test/e2e/fixtures/leak-probe/c.test.js b/test/e2e/fixtures/leak-probe/c.test.js new file mode 100644 index 000000000..c9098e629 --- /dev/null +++ b/test/e2e/fixtures/leak-probe/c.test.js @@ -0,0 +1,15 @@ +import { expect, test } from 'vitest' +import { state } from './module-state.js' + +const extended = test.extend({ + fixture: async ({}, use) => { + await use('fixture') + }, +}) + +extended('keeps a heavy module graph alive', async ({ fixture }) => { + const dynamic = await import('./module-state.js') + expect(dynamic.state).toBe(state) + expect(fixture).toBe('fixture') + document.body.innerHTML = '
payload
' +}) diff --git a/test/e2e/fixtures/leak-probe/d.test.js b/test/e2e/fixtures/leak-probe/d.test.js new file mode 100644 index 000000000..c9098e629 --- /dev/null +++ b/test/e2e/fixtures/leak-probe/d.test.js @@ -0,0 +1,15 @@ +import { expect, test } from 'vitest' +import { state } from './module-state.js' + +const extended = test.extend({ + fixture: async ({}, use) => { + await use('fixture') + }, +}) + +extended('keeps a heavy module graph alive', async ({ fixture }) => { + const dynamic = await import('./module-state.js') + expect(dynamic.state).toBe(state) + expect(fixture).toBe('fixture') + document.body.innerHTML = '
payload
' +}) diff --git a/test/e2e/fixtures/leak-probe/e.test.js b/test/e2e/fixtures/leak-probe/e.test.js new file mode 100644 index 000000000..c9098e629 --- /dev/null +++ b/test/e2e/fixtures/leak-probe/e.test.js @@ -0,0 +1,15 @@ +import { expect, test } from 'vitest' +import { state } from './module-state.js' + +const extended = test.extend({ + fixture: async ({}, use) => { + await use('fixture') + }, +}) + +extended('keeps a heavy module graph alive', async ({ fixture }) => { + const dynamic = await import('./module-state.js') + expect(dynamic.state).toBe(state) + expect(fixture).toBe('fixture') + document.body.innerHTML = '
payload
' +}) diff --git a/test/e2e/fixtures/leak-probe/f.test.js b/test/e2e/fixtures/leak-probe/f.test.js new file mode 100644 index 000000000..c9098e629 --- /dev/null +++ b/test/e2e/fixtures/leak-probe/f.test.js @@ -0,0 +1,15 @@ +import { expect, test } from 'vitest' +import { state } from './module-state.js' + +const extended = test.extend({ + fixture: async ({}, use) => { + await use('fixture') + }, +}) + +extended('keeps a heavy module graph alive', async ({ fixture }) => { + const dynamic = await import('./module-state.js') + expect(dynamic.state).toBe(state) + expect(fixture).toBe('fixture') + document.body.innerHTML = '
payload
' +}) diff --git a/test/e2e/fixtures/leak-probe/g.test.js b/test/e2e/fixtures/leak-probe/g.test.js new file mode 100644 index 000000000..c9098e629 --- /dev/null +++ b/test/e2e/fixtures/leak-probe/g.test.js @@ -0,0 +1,15 @@ +import { expect, test } from 'vitest' +import { state } from './module-state.js' + +const extended = test.extend({ + fixture: async ({}, use) => { + await use('fixture') + }, +}) + +extended('keeps a heavy module graph alive', async ({ fixture }) => { + const dynamic = await import('./module-state.js') + expect(dynamic.state).toBe(state) + expect(fixture).toBe('fixture') + document.body.innerHTML = '
payload
' +}) diff --git a/test/e2e/fixtures/leak-probe/h.test.js b/test/e2e/fixtures/leak-probe/h.test.js new file mode 100644 index 000000000..c9098e629 --- /dev/null +++ b/test/e2e/fixtures/leak-probe/h.test.js @@ -0,0 +1,15 @@ +import { expect, test } from 'vitest' +import { state } from './module-state.js' + +const extended = test.extend({ + fixture: async ({}, use) => { + await use('fixture') + }, +}) + +extended('keeps a heavy module graph alive', async ({ fixture }) => { + const dynamic = await import('./module-state.js') + expect(dynamic.state).toBe(state) + expect(fixture).toBe('fixture') + document.body.innerHTML = '
payload
' +}) diff --git a/test/e2e/fixtures/leak-probe/i.test.js b/test/e2e/fixtures/leak-probe/i.test.js new file mode 100644 index 000000000..c9098e629 --- /dev/null +++ b/test/e2e/fixtures/leak-probe/i.test.js @@ -0,0 +1,15 @@ +import { expect, test } from 'vitest' +import { state } from './module-state.js' + +const extended = test.extend({ + fixture: async ({}, use) => { + await use('fixture') + }, +}) + +extended('keeps a heavy module graph alive', async ({ fixture }) => { + const dynamic = await import('./module-state.js') + expect(dynamic.state).toBe(state) + expect(fixture).toBe('fixture') + document.body.innerHTML = '
payload
' +}) diff --git a/test/e2e/fixtures/leak-probe/j.test.js b/test/e2e/fixtures/leak-probe/j.test.js new file mode 100644 index 000000000..c9098e629 --- /dev/null +++ b/test/e2e/fixtures/leak-probe/j.test.js @@ -0,0 +1,15 @@ +import { expect, test } from 'vitest' +import { state } from './module-state.js' + +const extended = test.extend({ + fixture: async ({}, use) => { + await use('fixture') + }, +}) + +extended('keeps a heavy module graph alive', async ({ fixture }) => { + const dynamic = await import('./module-state.js') + expect(dynamic.state).toBe(state) + expect(fixture).toBe('fixture') + document.body.innerHTML = '
payload
' +}) diff --git a/test/e2e/fixtures/leak-probe/leak-probe-env.ts b/test/e2e/fixtures/leak-probe/leak-probe-env.ts new file mode 100644 index 000000000..22967e11b --- /dev/null +++ b/test/e2e/fixtures/leak-probe/leak-probe-env.ts @@ -0,0 +1,43 @@ +import v8 from 'node:v8' +import vm from 'node:vm' +import { builtinEnvironments } from 'vitest/runtime' + +const previousContexts: WeakRef[] = [] + +// node exposes no public gc hook, but the flag can be flipped just long +// enough to grab one from a throwaway context +v8.setFlagsFromString('--expose-gc') +const gc = vm.runInNewContext('gc') +v8.setFlagsFromString('--no-expose-gc') + +async function assertReleased() { + let alive = 0 + for (let attempt = 0; attempt < 10; attempt++) { + gc() + await new Promise(resolve => setTimeout(resolve, 10)) + alive = previousContexts.filter(ref => ref.deref()).length + // a small number of worlds stays reachable through Node's ESM callback + // registry (moduleRegistries): entries hold SourceTextModules through + // their host-defined-options symbols until later registrations replace + // them, which retains the youngest world or two, and occasionally the + // first world of the worker. The bound only catches references that + // accumulate with every file + if (alive <= 4) { + return + } + } + throw new Error( + `${alive} of ${previousContexts.length} vm contexts of finished test files were not released`, + ) +} + +export default { + name: 'leak-probe', + viteEnvironment: 'client', + async setupVM(options: Record) { + await assertReleased() + const env = await builtinEnvironments.jsdom.setupVM!(options) + previousContexts.push(new WeakRef(env.getVmContext())) + return env + }, +} diff --git a/test/e2e/fixtures/leak-probe/module-state.js b/test/e2e/fixtures/leak-probe/module-state.js new file mode 100644 index 000000000..5b3197112 --- /dev/null +++ b/test/e2e/fixtures/leak-probe/module-state.js @@ -0,0 +1 @@ +export const state = Array.from({ length: 10_000 }, (_, i) => ({ i })) diff --git a/test/e2e/test/vm-threads.test.ts b/test/e2e/test/vm-threads.test.ts index 34b10e8b0..e71ab3cbf 100644 --- a/test/e2e/test/vm-threads.test.ts +++ b/test/e2e/test/vm-threads.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest' -import { createFile, resolvePath, runInlineTests, runVitest } from '../../test-utils' +import { createFile, resolvePath, runInlineTests, runVitest, runVitestCli } from '../../test-utils' test('importing files in restricted fs works correctly', async () => { createFile( @@ -155,3 +155,30 @@ test.skipIf(nodeMajor < 22)('can require package with module-sync exports condit expect(stderr).toBe('') expect(exitCode).toBe(0) }) + +// vm pool workers must not accumulate finished test files: worker-lifetime +// registries (cancel listeners, module runner caches, ESM callback +// registrations, V8 compilation cache) each used to pin every file's vm +// context until the worker hit vmMemoryLimit. The probe environment lives in +// the worker realm, keeps a WeakRef to each file's context and asserts that +// the number of surviving contexts stays bounded: a worker-lifetime +// reference from any registry into every file grows the count file by file +// (9 surviving worlds by the last file against the bound of 4) and fails the +// run. The fixture spawns the real CLI because the in-process runVitest +// harness serves the workspace's development-condition module graph, which +// retains additional references of its own. +test.for(['vmThreads', 'vmForks'] as const)( + '%s releases the vm contexts of finished test files', + async (pool) => { + const { stderr, exitCode } = await runVitestCli( + 'run', + '--root', + 'fixtures/leak-probe', + `--pool=${pool}`, + '--environment=./leak-probe-env.ts', + ) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + }, +)