Skip to content
Merged
1 change: 1 addition & 0 deletions packages/vitest/src/node/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ export class Vitest {
this._resolver,
resolved,
this._fsCache,
this.state,
this._traces,
this._tmpDir,
)
Expand Down
50 changes: 35 additions & 15 deletions packages/vitest/src/node/environments/fetchModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ import { normalizeResolvedIdToUrl } from './normalizeUrl'
const saveCachePromises = new Map<string, Promise<VitestFetchResult>>()
const readFilePromises = new Map<string, Promise<string | null>>()

/**
* Tracks the wall time during which at least one transform is running.
* Durations of individual fetches cannot be summed instead: concurrent
* fetches (parallel workers, the vm pool graph prewarm) all wait on the same
* deduplicated in-flight transforms, so per-caller wall times overcount the
* actual work by orders of magnitude.
*/
export interface TransformClock {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in short, we calculate the REAL time it takes to transform. instead of summing individual times

transformStarted: () => void
transformFinished: () => void
}

class ModuleFetcher {
private tmpDirectories = new Set<string>()
private fsCacheEnabled: boolean
Expand All @@ -30,6 +42,7 @@ class ModuleFetcher {
private resolver: VitestResolver,
private config: ResolvedConfig,
private fsCache: FileSystemModuleCache,
private clock: TransformClock,
private tmpProjectDir: string,
) {
this.fsCacheEnabled = config.fsModuleCache === true
Expand Down Expand Up @@ -312,21 +325,27 @@ class ModuleFetcher {
moduleGraphModule: EnvironmentModuleNode,
options?: FetchFunctionOptions,
): Promise<VitestFetchResult> {
const moduleRunnerModule = await fetchModule(
environment,
url,
importer,
{
...options,
inlineSourceMap: false,
},
).catch(handleRollupError)

const result: VitestFetchResult = processResultSource(environment, moduleRunnerModule)
if ('code' in result) {
result.moduleType = await this.cachedModuleType(result.file, result.code, moduleGraphModule.transformResult)
this.clock.transformStarted()
try {
const moduleRunnerModule = await fetchModule(
environment,
url,
importer,
{
...options,
inlineSourceMap: false,
},
).catch(handleRollupError)

const result: VitestFetchResult = processResultSource(environment, moduleRunnerModule)
if ('code' in result) {
result.moduleType = await this.cachedModuleType(result.file, result.code, moduleGraphModule.transformResult)
}
return result
}
finally {
this.clock.transformFinished()
}
return result
}

private sourceLoader(file: string | null): (() => Promise<string | null>) | undefined {
Expand Down Expand Up @@ -415,10 +434,11 @@ export function createFetchModuleFunction(
resolver: VitestResolver,
config: ResolvedConfig,
fsCache: FileSystemModuleCache,
clock: TransformClock,
traces: Traces,
tmpProjectDir: string,
): VitestFetchFunction {
const fetcher = new ModuleFetcher(resolver, config, fsCache, tmpProjectDir)
const fetcher = new ModuleFetcher(resolver, config, fsCache, clock, tmpProjectDir)
return async (url, importer, environment, cacheFs, options, otelCarrier) => {
await traces.waitInit()
const context = otelCarrier
Expand Down
149 changes: 118 additions & 31 deletions packages/vitest/src/node/pools/rpc.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { DevEnvironment, EnvironmentModuleNode, FetchResult } from 'vite'
import type { FetchFunctionOptions } from 'vite/module-runner'
import type { FetchCachedFileSystemResult } from '../../types/general'
import type { RuntimeRPC } from '../../types/rpc'
import type { OTELCarrier } from '../../utils/traces'
import type { TestProject } from '../project'
import type { ResolveSnapshotPathHandlerContext } from '../types/config'
import { existsSync, mkdirSync } from 'node:fs'
Expand Down Expand Up @@ -41,6 +43,56 @@ export function createMethodsRPC(project: TestProject, methodsOptions: MethodsOp
mkdirSync(project.config.dumpDir, { recursive: true })
}
project.vitest.state.metadata[project.name].dumpDir = project.config.dumpDir

function getEnvironment(environmentName: string): DevEnvironment {
const environment = project.vite.environments[environmentName]
if (!environment) {
throw new Error(`The environment ${environmentName} was not defined in the Vite config.`)
}
return environment
}

async function fetchModule(
url: string,
importer: string | undefined,
environment: DevEnvironment,
options?: FetchFunctionOptions,
otelCarrier?: OTELCarrier,
// per-module durations are only recorded for direct worker fetches: the
// graph prewarm fetches whole levels concurrently, so its per-module wall
// times measure the queue position, not the module's own transform cost
accountModuleDuration = true,
): Promise<FetchResult | FetchCachedFileSystemResult> {
const state = project.vitest.state
const start = performance.now()

return await project._fetcher(url, importer, environment, cacheFs, options, otelCarrier).then((result) => {
const metadata = state.metadata[project.name]
if ('externalize' in result) {
metadata.externalized[url] = result.externalize
// builtins and network urls are already resolved inside the worker
// without a round-trip, only module externalizations are worth sharing
if (result.type === 'module' && url[0] === '/') {
let externals = warmExternals.get(environment)
if (!externals) {
externals = Object.create(null) as Record<string, FetchResult>
warmExternals.set(environment, externals)
}
externals[url] = result
}
}
if ('tmp' in result) {
metadata.tmps[url] = result.tmp
}
if (accountModuleDuration) {
const duration = performance.now() - start
metadata.duration[url] ??= []
metadata.duration[url].push(duration)
}
return result
})
}

return {
async fetch(
url,
Expand All @@ -49,37 +101,7 @@ export function createMethodsRPC(project: TestProject, methodsOptions: MethodsOp
options,
otelCarrier,
) {
const environment = project.vite.environments[environmentName]
if (!environment) {
throw new Error(`The environment ${environmentName} was not defined in the Vite config.`)
}

const start = performance.now()

return await project._fetcher(url, importer, environment, cacheFs, options, otelCarrier).then((result) => {
const duration = performance.now() - start
project.vitest.state.transformTime += duration
const metadata = project.vitest.state.metadata[project.name]
if ('externalize' in result) {
metadata.externalized[url] = result.externalize
// builtins and network urls are already resolved inside the worker
// without a round-trip, only module externalizations are worth sharing
if (result.type === 'module' && url[0] === '/') {
let externals = warmExternals.get(environment)
if (!externals) {
externals = Object.create(null) as Record<string, FetchResult>
warmExternals.set(environment, externals)
}
externals[url] = result
}
}
if ('tmp' in result) {
metadata.tmps[url] = result.tmp
}
metadata.duration[url] ??= []
metadata.duration[url].push(duration)
return result
})
return fetchModule(url, importer, getEnvironment(environmentName), options, otelCarrier)
},
async fetchWarmModules(environmentName, files) {
const environment = project.vite.environments[environmentName]
Expand Down Expand Up @@ -150,6 +172,71 @@ export function createMethodsRPC(project: TestProject, methodsOptions: MethodsOp

return warm
},
async prewarmModuleGraph(environmentName, files) {
const environment = getEnvironment(environmentName)
const moduleGraph = environment.moduleGraph
const seen = new Set<string>()

async function walkNode(node: EnvironmentModuleNode): Promise<void> {
const children: Promise<void>[] = []
for (const child of node.importedModules) {
if (child.url == null || seen.has(child.url)) {
continue
}
if (child.transformResult) {
seen.add(child.url)
children.push(walkNode(child))
}
else {
children.push(fetchNode(child.url, node.id ?? undefined))
}
}
if (children.length) {
await Promise.all(children)
}
}

async function fetchNode(url: string, importer: string | undefined): Promise<void> {
if (seen.has(url)) {
return
}
seen.add(url)
try {
await fetchModule(url, importer, environment, undefined, undefined, false)
}
catch {
// the worker's own fetch will surface the error with the proper
// import context
return
}
let node: EnvironmentModuleNode | undefined
try {
node = await moduleGraph.getModuleByUrl(url) ?? moduleGraph.getModuleById(url) ?? undefined
}
catch {
node = moduleGraph.getModuleById(url) ?? undefined
}
if (node) {
await walkNode(node)
}
}

await Promise.all([...files, ...project.config.setupFiles].map(async (file) => {
const nodes = moduleGraph.getModulesByFile(file)
if (nodes && nodes.size) {
await Promise.all(Array.from(nodes, (node) => {
if (node.transformResult) {
seen.add(node.url)
return walkNode(node)
}
return fetchNode(node.url, undefined)
}))
}
else {
await fetchNode(file, undefined)
}
}))
},
async resolve(id, importer, environmentName) {
const environment = project.vite.environments[environmentName]
if (!environment) {
Expand Down
1 change: 1 addition & 0 deletions packages/vitest/src/node/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export class TestProject {
this._resolver,
this.config,
this.vitest._fsCache,
this.vitest.state,
this.vitest._traces,
this.tmpDir,
)
Expand Down
25 changes: 24 additions & 1 deletion packages/vitest/src/node/state.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { File, FileSpecification, Task, TaskResultPack } from '../runtime/runner/types'
import type { AsyncLeak, UserConsoleLog } from '../types/general'
import type { TransformClock } from './environments/fetchModule'
import type { TestProject } from './project'
import type { MergedBlobs } from './reporters/blob'
import type { OnUnhandledErrorCallback } from './types/config'
Expand All @@ -15,7 +16,7 @@ function isAggregateError(err: unknown): err is AggregateError {
return err instanceof Error && 'errors' in err
}

export class StateManager {
export class StateManager implements TransformClock {
filesMap: Map<string, File[]> = new Map()
pathsSet: Set<string> = new Set()
idMap: Map<string, Task> = new Map()
Expand All @@ -24,7 +25,29 @@ export class StateManager {
leakSet: Set<AsyncLeak> = new Set()
reportedTasksMap: WeakMap<Task, TestModule | TestCase | TestSuite> = new WeakMap()
blobs?: MergedBlobs
/**
* Wall time during which the server's module transform pipeline was busy,
* measured as the union of in-flight fetch intervals. Individual fetch
* durations cannot be summed instead: concurrent fetches (parallel workers,
* the vm pool graph prewarm) all wait on the same deduplicated in-flight
* transforms, so per-caller wall times overcount the actual work by orders
* of magnitude.
*/
transformTime = 0
private _transformsInflight = 0
private _transformsBusyStart = 0

transformStarted(): void {
if (this._transformsInflight++ === 0) {
this._transformsBusyStart = performance.now()
}
}

transformFinished(): void {
if (--this._transformsInflight === 0) {
this.transformTime += performance.now() - this._transformsBusyStart
}
}

metadata: Record<string, {
externalized: Record<string, string>
Expand Down
Loading
Loading