Skip to content

Commit 7bf5450

Browse files
authoredFeb 25, 2023
feat!: use child_process when --no-threads is used (#2772)
1 parent 4d277d8 commit 7bf5450

22 files changed

+643
-324
lines changed
 

‎docs/config/index.md

+13-2
Original file line numberDiff line numberDiff line change
@@ -489,10 +489,21 @@ By providing an object instead of a string you can define individual outputs whe
489489
- **Default:** `true`
490490
- **CLI:** `--threads`, `--threads=false`
491491

492-
Enable multi-threading using [tinypool](https://github.com/tinylibs/tinypool) (a lightweight fork of [Piscina](https://github.com/piscinajs/piscina))
492+
Enable multi-threading using [tinypool](https://github.com/tinylibs/tinypool) (a lightweight fork of [Piscina](https://github.com/piscinajs/piscina)). Prior to Vitest 0.29.0, Vitest was still running tests inside worker thread, even if this option was disabled. Since 0.29.0, if this option is disabled, Vitest uses `child_process` to spawn a process to run tests inside, meaning you can use `process.chdir` and other API that was not available inside workers. If you want to revert to the previous behaviour, use `--single-thread` option instead.
493+
494+
Disabling this option also disables module isolation, meaning all tests with the same environment are running inside a single child process.
495+
496+
### singleThread
497+
498+
- **Type:** `boolean`
499+
- **Default:** `false`
500+
- **Version:** Since Vitest 0.29.0
501+
502+
Run all tests with the same environment inside a single worker thread. This will disable built-in module isolation (your source code or [inlined](#deps-inline) code will still be reevaluated for each test), but can improve test performance. Before Vitest 0.29.0 this was equivalent to using `--no-threads`.
503+
493504

494505
:::warning
495-
This option is different from Jest's `--runInBand`. Vitest uses workers not only for running tests in parallel, but also to provide isolation. By disabling this option, your tests will run sequentially, but in the same global context, so you must provide isolation yourself.
506+
Even though this option will force tests to run one after another, this option is different from Jest's `--runInBand`. Vitest uses workers not only for running tests in parallel, but also to provide isolation. By disabling this option, your tests will run sequentially, but in the same global context, so you must provide isolation yourself.
496507

497508
This might cause all sorts of issues, if you are relying on global state (frontend frameworks usually do) or your code relies on environment to be defined separately for each test. But can be a speed boost for your tests (up to 3 times faster), that don't necessarily rely on global state or can easily bypass that.
498509
:::

‎packages/utils/src/helpers.ts

+17
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,23 @@ export function slash(path: string) {
1111
return path.replace(/\\/g, '/')
1212
}
1313

14+
// convert RegExp.toString to RegExp
15+
export function parseRegexp(input: string): RegExp {
16+
// Parse input
17+
const m = input.match(/(\/?)(.+)\1([a-z]*)/i)
18+
19+
// match nothing
20+
if (!m)
21+
return /$^/
22+
23+
// Invalid flags
24+
if (m[3] && !/^(?!.*?(.).*?\1)[gmixXsuUAJ]+$/.test(m[3]))
25+
return RegExp(input)
26+
27+
// Create the regular expression
28+
return new RegExp(m[2], m[3])
29+
}
30+
1431
export function toArray<T>(array?: Nullable<Arrayable<T>>): Array<T> {
1532
if (array === null || array === undefined)
1633
array = []

‎packages/utils/src/timers.ts

+18
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,22 @@ export function getSafeTimers() {
66
setInterval: safeSetInterval,
77
clearInterval: safeClearInterval,
88
clearTimeout: safeClearTimeout,
9+
setImmediate: safeSetImmediate,
10+
clearImmediate: safeClearImmediate,
911
} = (globalThis as any)[SAFE_TIMERS_SYMBOL] || globalThis
1012

13+
const {
14+
nextTick: safeNextTick,
15+
} = (globalThis as any)[SAFE_TIMERS_SYMBOL] || globalThis.process || { nextTick: (cb: () => void) => cb() }
16+
1117
return {
18+
nextTick: safeNextTick,
1219
setTimeout: safeSetTimeout,
1320
setInterval: safeSetInterval,
1421
clearInterval: safeClearInterval,
1522
clearTimeout: safeClearTimeout,
23+
setImmediate: safeSetImmediate,
24+
clearImmediate: safeClearImmediate,
1625
}
1726
}
1827

@@ -22,13 +31,22 @@ export function setSafeTimers() {
2231
setInterval: safeSetInterval,
2332
clearInterval: safeClearInterval,
2433
clearTimeout: safeClearTimeout,
34+
setImmediate: safeSetImmediate,
35+
clearImmediate: safeClearImmediate,
2536
} = globalThis
2637

38+
const {
39+
nextTick: safeNextTick,
40+
} = globalThis.process || { nextTick: cb => cb() }
41+
2742
const timers = {
43+
nextTick: safeNextTick,
2844
setTimeout: safeSetTimeout,
2945
setInterval: safeSetInterval,
3046
clearInterval: safeClearInterval,
3147
clearTimeout: safeClearTimeout,
48+
setImmediate: safeSetImmediate,
49+
clearImmediate: safeClearImmediate,
3250
}
3351

3452
;(globalThis as any)[SAFE_TIMERS_SYMBOL] = timers

‎packages/vitest/rollup.config.js

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const entries = [
2323
'src/runners.ts',
2424
'src/environments.ts',
2525
'src/runtime/worker.ts',
26+
'src/runtime/child.ts',
2627
'src/runtime/loader.ts',
2728
'src/runtime/entry.ts',
2829
'src/integrations/spy.ts',

‎packages/vitest/src/node/cli.ts

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ cli
2121
.option('--open', 'Open UI automatically (default: !process.env.CI))')
2222
.option('--api [api]', 'Serve API, available options: --api.port <port>, --api.host [host] and --api.strictPort')
2323
.option('--threads', 'Enabled threads (default: true)')
24+
.option('--single-thread', 'Run tests inside a single thread, requires --threads (default: false)')
2425
.option('--silent', 'Silent console output from tests')
2526
.option('--isolate', 'Isolate environment for each test file (default: true)')
2627
.option('--reporter <name>', 'Specify reporters')

‎packages/vitest/src/node/core.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { deepMerge, hasFailed, noop, slash, toArray } from '../utils'
1212
import { getCoverageProvider } from '../integrations/coverage'
1313
import { Typechecker } from '../typecheck/typechecker'
1414
import { createPool } from './pool'
15-
import type { WorkerPool } from './pool'
15+
import type { ProcessPool } from './pool'
1616
import { createBenchmarkReporters, createReporters } from './reporters/utils'
1717
import { StateManager } from './state'
1818
import { resolveConfig } from './config'
@@ -32,7 +32,7 @@ export class Vitest {
3232
reporters: Reporter[] = undefined!
3333
coverageProvider: CoverageProvider | null | undefined
3434
logger: Logger
35-
pool: WorkerPool | undefined
35+
pool: ProcessPool | undefined
3636
typechecker: Typechecker | undefined
3737

3838
vitenode: ViteNodeServer = undefined!

‎packages/vitest/src/node/pool.ts

+22-206
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,26 @@
1-
import { MessageChannel } from 'node:worker_threads'
2-
import _url from 'node:url'
3-
import { cpus } from 'node:os'
1+
import { pathToFileURL } from 'node:url'
42
import { resolve } from 'pathe'
5-
import type { Options as TinypoolOptions } from 'tinypool'
6-
import { Tinypool } from 'tinypool'
7-
import { createBirpc } from 'birpc'
8-
import type { RawSourceMap } from 'vite-node'
9-
import type { ResolvedConfig, WorkerContext, WorkerRPC, WorkerTestEnvironment } from '../types'
103
import { distDir, rootDir } from '../constants'
11-
import { AggregateError, getEnvironmentTransformMode, groupBy } from '../utils'
12-
import { envsOrder, groupFilesByEnv } from '../utils/test-helpers'
134
import type { Vitest } from './core'
5+
import { createChildProcessPool } from './pools/child'
6+
import { createThreadsPool } from './pools/threads'
147

158
export type RunWithFiles = (files: string[], invalidates?: string[]) => Promise<void>
169

17-
export interface WorkerPool {
10+
export interface ProcessPool {
1811
runTests: RunWithFiles
1912
close: () => Promise<void>
2013
}
2114

22-
const workerPath = _url.pathToFileURL(resolve(distDir, './worker.js')).href
23-
const loaderPath = _url.pathToFileURL(resolve(distDir, './loader.js')).href
15+
export interface PoolProcessOptions {
16+
execArgv: string[]
17+
env: Record<string, string>
18+
}
2419

20+
const loaderPath = pathToFileURL(resolve(distDir, './loader.js')).href
2521
const suppressLoaderWarningsPath = resolve(rootDir, './suppress-warnings.cjs')
2622

27-
export function createPool(ctx: Vitest): WorkerPool {
28-
const threadsCount = ctx.config.watch
29-
? Math.max(Math.floor(cpus().length / 2), 1)
30-
: Math.max(cpus().length - 1, 1)
31-
32-
const maxThreads = ctx.config.maxThreads ?? threadsCount
33-
const minThreads = ctx.config.minThreads ?? threadsCount
34-
23+
export function createPool(ctx: Vitest): ProcessPool {
3524
const conditions = ctx.server.config.resolve.conditions?.flatMap(c => ['--conditions', c]) || []
3625

3726
// Instead of passing whole process.execArgv to the workers, pick allowed options.
@@ -40,204 +29,31 @@ export function createPool(ctx: Vitest): WorkerPool {
4029
execArg.startsWith('--cpu-prof') || execArg.startsWith('--heap-prof'),
4130
)
4231

43-
const options: TinypoolOptions = {
44-
filename: workerPath,
45-
// TODO: investigate further
46-
// It seems atomics introduced V8 Fatal Error https://github.com/vitest-dev/vitest/issues/1191
47-
useAtomics: ctx.config.useAtomics ?? false,
48-
49-
maxThreads,
50-
minThreads,
51-
32+
const options: PoolProcessOptions = {
5233
execArgv: ctx.config.deps.registerNodeLoader
5334
? [
5435
...execArgv,
5536
'--require',
5637
suppressLoaderWarningsPath,
5738
'--experimental-loader',
5839
loaderPath,
59-
...conditions,
40+
...execArgv,
6041
]
6142
: [
6243
...execArgv,
6344
...conditions,
6445
],
65-
}
66-
67-
if (ctx.config.isolate) {
68-
options.isolateWorkers = true
69-
options.concurrentTasksPerWorker = 1
70-
}
71-
72-
if (!ctx.config.threads) {
73-
options.concurrentTasksPerWorker = 1
74-
options.maxThreads = 1
75-
options.minThreads = 1
76-
}
77-
78-
options.env = {
79-
TEST: 'true',
80-
VITEST: 'true',
81-
NODE_ENV: ctx.config.mode || 'test',
82-
VITEST_MODE: ctx.config.watch ? 'WATCH' : 'RUN',
83-
...process.env,
84-
...ctx.config.env,
85-
}
86-
87-
const pool = new Tinypool(options)
88-
89-
const runWithFiles = (name: string): RunWithFiles => {
90-
let id = 0
91-
92-
async function runFiles(config: ResolvedConfig, files: string[], environment: WorkerTestEnvironment, invalidates: string[] = []) {
93-
ctx.state.clearFiles(files)
94-
const { workerPort, port } = createChannel(ctx)
95-
const workerId = ++id
96-
const data: WorkerContext = {
97-
port: workerPort,
98-
config,
99-
files,
100-
invalidates,
101-
environment,
102-
workerId,
103-
}
104-
try {
105-
await pool.run(data, { transferList: [workerPort], name })
106-
}
107-
finally {
108-
port.close()
109-
workerPort.close()
110-
}
111-
}
112-
113-
const Sequencer = ctx.config.sequence.sequencer
114-
const sequencer = new Sequencer(ctx)
115-
116-
return async (files, invalidates) => {
117-
const config = ctx.getSerializableConfig()
118-
119-
if (config.shard)
120-
files = await sequencer.shard(files)
121-
122-
files = await sequencer.sort(files)
123-
124-
const filesByEnv = await groupFilesByEnv(files, config)
125-
const envs = envsOrder.concat(
126-
Object.keys(filesByEnv).filter(env => !envsOrder.includes(env)),
127-
)
128-
129-
if (!ctx.config.threads) {
130-
// always run environments isolated between each other
131-
for (const env of envs) {
132-
const files = filesByEnv[env]
133-
134-
if (!files?.length)
135-
continue
136-
137-
const filesByOptions = groupBy(files, ({ environment }) => JSON.stringify(environment.options))
138-
139-
for (const option in filesByOptions) {
140-
const files = filesByOptions[option]
141-
142-
if (files?.length) {
143-
const filenames = files.map(f => f.file)
144-
await runFiles(config, filenames, files[0].environment, invalidates)
145-
}
146-
}
147-
}
148-
}
149-
else {
150-
const promises = Object.values(filesByEnv).flat()
151-
const results = await Promise.allSettled(promises
152-
.map(({ file, environment }) => runFiles(config, [file], environment, invalidates)))
153-
154-
const errors = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason)
155-
if (errors.length > 0)
156-
throw new AggregateError(errors, 'Errors occurred while running tests. For more information, see serialized error.')
157-
}
158-
}
159-
}
160-
161-
return {
162-
runTests: runWithFiles('run'),
163-
close: async () => {
164-
// node before 16.17 has a bug that causes FATAL ERROR because of the race condition
165-
const nodeVersion = Number(process.version.match(/v(\d+)\.(\d+)/)?.[0].slice(1))
166-
if (nodeVersion >= 16.17)
167-
await pool.destroy()
46+
env: {
47+
TEST: 'true',
48+
VITEST: 'true',
49+
NODE_ENV: ctx.config.mode || 'test',
50+
VITEST_MODE: ctx.config.watch ? 'WATCH' : 'RUN',
51+
...process.env,
52+
...ctx.config.env,
16853
},
16954
}
170-
}
171-
172-
function createChannel(ctx: Vitest) {
173-
const channel = new MessageChannel()
174-
const port = channel.port2
175-
const workerPort = channel.port1
176-
177-
createBirpc<{}, WorkerRPC>(
178-
{
179-
async onWorkerExit(error, code) {
180-
await ctx.logger.printError(error, false, 'Unexpected Exit')
181-
process.exit(code || 1)
182-
},
183-
snapshotSaved(snapshot) {
184-
ctx.snapshot.add(snapshot)
185-
},
186-
resolveSnapshotPath(testPath: string) {
187-
return ctx.snapshot.resolvePath(testPath)
188-
},
189-
async getSourceMap(id, force) {
190-
if (force) {
191-
const mod = ctx.server.moduleGraph.getModuleById(id)
192-
if (mod)
193-
ctx.server.moduleGraph.invalidateModule(mod)
194-
}
195-
const r = await ctx.vitenode.transformRequest(id)
196-
return r?.map as RawSourceMap | undefined
197-
},
198-
fetch(id, environment) {
199-
const transformMode = getEnvironmentTransformMode(ctx.config, environment)
200-
return ctx.vitenode.fetchModule(id, transformMode)
201-
},
202-
resolveId(id, importer, environment) {
203-
const transformMode = getEnvironmentTransformMode(ctx.config, environment)
204-
return ctx.vitenode.resolveId(id, importer, transformMode)
205-
},
206-
onPathsCollected(paths) {
207-
ctx.state.collectPaths(paths)
208-
ctx.report('onPathsCollected', paths)
209-
},
210-
onCollected(files) {
211-
ctx.state.collectFiles(files)
212-
ctx.report('onCollected', files)
213-
},
214-
onAfterSuiteRun(meta) {
215-
ctx.coverageProvider?.onAfterSuiteRun(meta)
216-
},
217-
onTaskUpdate(packs) {
218-
ctx.state.updateTasks(packs)
219-
ctx.report('onTaskUpdate', packs)
220-
},
221-
onUserConsoleLog(log) {
222-
ctx.state.updateUserLog(log)
223-
ctx.report('onUserConsoleLog', log)
224-
},
225-
onUnhandledError(err, type) {
226-
ctx.state.catchError(err, type)
227-
},
228-
onFinished(files) {
229-
ctx.report('onFinished', files, ctx.state.getUnhandledErrors())
230-
},
231-
},
232-
{
233-
post(v) {
234-
port.postMessage(v)
235-
},
236-
on(fn) {
237-
port.on('message', fn)
238-
},
239-
},
240-
)
24155

242-
return { workerPort, port }
56+
if (!ctx.config.threads)
57+
return createChildProcessPool(ctx, options)
58+
return createThreadsPool(ctx, options)
24359
}
+124
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import v8 from 'node:v8'
2+
import type { ChildProcess } from 'node:child_process'
3+
import { fork } from 'node:child_process'
4+
import { fileURLToPath, pathToFileURL } from 'node:url'
5+
import { createBirpc } from 'birpc'
6+
import { resolve } from 'pathe'
7+
import type { ContextTestEnvironment, ResolvedConfig, RuntimeRPC } from '../../types'
8+
import type { Vitest } from '../core'
9+
import type { ChildContext } from '../../types/child'
10+
import type { PoolProcessOptions, ProcessPool } from '../pool'
11+
import { distDir } from '../../constants'
12+
import { groupBy } from '../../utils/base'
13+
import { envsOrder, groupFilesByEnv } from '../../utils/test-helpers'
14+
import { createMethodsRPC } from './rpc'
15+
16+
const childPath = fileURLToPath(pathToFileURL(resolve(distDir, './child.js')).href)
17+
18+
function setupChildProcessChannel(ctx: Vitest, fork: ChildProcess) {
19+
createBirpc<{}, RuntimeRPC>(
20+
createMethodsRPC(ctx),
21+
{
22+
serialize: v8.serialize,
23+
deserialize: v => v8.deserialize(Buffer.from(v)),
24+
post(v) {
25+
fork.send(v)
26+
},
27+
on(fn) {
28+
fork.on('message', fn)
29+
},
30+
},
31+
)
32+
}
33+
34+
function stringifyRegex(input: RegExp | string): any {
35+
if (typeof input === 'string')
36+
return input
37+
return `$$vitest:${input.toString()}`
38+
}
39+
40+
function getTestConfig(ctx: Vitest) {
41+
const config = ctx.getSerializableConfig()
42+
// v8 serialize does not support regex
43+
return {
44+
...config,
45+
testNamePattern: config.testNamePattern
46+
? stringifyRegex(config.testNamePattern)
47+
: undefined,
48+
}
49+
}
50+
51+
export function createChildProcessPool(ctx: Vitest, { execArgv, env }: PoolProcessOptions): ProcessPool {
52+
const children = new Set<ChildProcess>()
53+
54+
function runFiles(config: ResolvedConfig, files: string[], environment: ContextTestEnvironment, invalidates: string[] = []) {
55+
const data: ChildContext = {
56+
command: 'start',
57+
config,
58+
files,
59+
invalidates,
60+
environment,
61+
}
62+
63+
const child = fork(childPath, [], {
64+
execArgv,
65+
env,
66+
})
67+
children.add(child)
68+
setupChildProcessChannel(ctx, child)
69+
70+
return new Promise<void>((resolve, reject) => {
71+
child.send(data, (err) => {
72+
if (err)
73+
reject(err)
74+
})
75+
child.on('close', (code) => {
76+
if (!code)
77+
resolve()
78+
else
79+
reject(new Error(`Child process exited unexpectedly with code ${code}`))
80+
81+
children.delete(child)
82+
})
83+
})
84+
}
85+
86+
async function runWithFiles(files: string[], invalidates: string[] = []) {
87+
ctx.state.clearFiles(files)
88+
const config = getTestConfig(ctx)
89+
90+
const filesByEnv = await groupFilesByEnv(files, config)
91+
const envs = envsOrder.concat(
92+
Object.keys(filesByEnv).filter(env => !envsOrder.includes(env)),
93+
)
94+
95+
// always run environments isolated between each other
96+
for (const env of envs) {
97+
const files = filesByEnv[env]
98+
99+
if (!files?.length)
100+
continue
101+
102+
const filesByOptions = groupBy(files, ({ environment }) => JSON.stringify(environment.options))
103+
104+
for (const option in filesByOptions) {
105+
const files = filesByOptions[option]
106+
107+
if (files?.length) {
108+
const filenames = files.map(f => f.file)
109+
await runFiles(config, filenames, files[0].environment, invalidates)
110+
}
111+
}
112+
}
113+
}
114+
115+
return {
116+
runTests: runWithFiles,
117+
async close() {
118+
children.forEach((child) => {
119+
if (!child.killed)
120+
child.kill()
121+
})
122+
},
123+
}
124+
}

‎packages/vitest/src/node/pools/rpc.ts

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { RawSourceMap } from 'vite-node'
2+
import type { RuntimeRPC } from '../../types'
3+
import { getEnvironmentTransformMode } from '../../utils/base'
4+
import type { Vitest } from '../core'
5+
6+
export function createMethodsRPC(ctx: Vitest): RuntimeRPC {
7+
return {
8+
async onWorkerExit(error, code) {
9+
await ctx.logger.printError(error, false, 'Unexpected Exit')
10+
process.exit(code || 1)
11+
},
12+
snapshotSaved(snapshot) {
13+
ctx.snapshot.add(snapshot)
14+
},
15+
resolveSnapshotPath(testPath: string) {
16+
return ctx.snapshot.resolvePath(testPath)
17+
},
18+
async getSourceMap(id, force) {
19+
if (force) {
20+
const mod = ctx.server.moduleGraph.getModuleById(id)
21+
if (mod)
22+
ctx.server.moduleGraph.invalidateModule(mod)
23+
}
24+
const r = await ctx.vitenode.transformRequest(id)
25+
return r?.map as RawSourceMap | undefined
26+
},
27+
fetch(id, environment) {
28+
const transformMode = getEnvironmentTransformMode(ctx.config, environment)
29+
return ctx.vitenode.fetchModule(id, transformMode)
30+
},
31+
resolveId(id, importer, environment) {
32+
const transformMode = getEnvironmentTransformMode(ctx.config, environment)
33+
return ctx.vitenode.resolveId(id, importer, transformMode)
34+
},
35+
onPathsCollected(paths) {
36+
ctx.state.collectPaths(paths)
37+
ctx.report('onPathsCollected', paths)
38+
},
39+
onCollected(files) {
40+
ctx.state.collectFiles(files)
41+
ctx.report('onCollected', files)
42+
},
43+
onAfterSuiteRun(meta) {
44+
ctx.coverageProvider?.onAfterSuiteRun(meta)
45+
},
46+
onTaskUpdate(packs) {
47+
ctx.state.updateTasks(packs)
48+
ctx.report('onTaskUpdate', packs)
49+
},
50+
onUserConsoleLog(log) {
51+
ctx.state.updateUserLog(log)
52+
ctx.report('onUserConsoleLog', log)
53+
},
54+
onUnhandledError(err, type) {
55+
ctx.state.catchError(err, type)
56+
},
57+
onFinished(files) {
58+
ctx.report('onFinished', files, ctx.state.getUnhandledErrors())
59+
},
60+
}
61+
}
+153
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { MessageChannel } from 'node:worker_threads'
2+
import { cpus } from 'node:os'
3+
import { pathToFileURL } from 'node:url'
4+
import { createBirpc } from 'birpc'
5+
import { resolve } from 'pathe'
6+
import type { Options as TinypoolOptions } from 'tinypool'
7+
import Tinypool from 'tinypool'
8+
import { distDir } from '../../constants'
9+
import type { ContextTestEnvironment, ResolvedConfig, RuntimeRPC, WorkerContext } from '../../types'
10+
import type { Vitest } from '../core'
11+
import type { PoolProcessOptions, ProcessPool, RunWithFiles } from '../pool'
12+
import { envsOrder, groupFilesByEnv } from '../../utils/test-helpers'
13+
import { groupBy } from '../../utils/base'
14+
import { createMethodsRPC } from './rpc'
15+
16+
const workerPath = pathToFileURL(resolve(distDir, './worker.js')).href
17+
18+
function createWorkerChannel(ctx: Vitest) {
19+
const channel = new MessageChannel()
20+
const port = channel.port2
21+
const workerPort = channel.port1
22+
23+
createBirpc<{}, RuntimeRPC>(
24+
createMethodsRPC(ctx),
25+
{
26+
post(v) {
27+
port.postMessage(v)
28+
},
29+
on(fn) {
30+
port.on('message', fn)
31+
},
32+
},
33+
)
34+
35+
return { workerPort, port }
36+
}
37+
38+
export function createThreadsPool(ctx: Vitest, { execArgv, env }: PoolProcessOptions): ProcessPool {
39+
const threadsCount = ctx.config.watch
40+
? Math.max(Math.floor(cpus().length / 2), 1)
41+
: Math.max(cpus().length - 1, 1)
42+
43+
const maxThreads = ctx.config.maxThreads ?? threadsCount
44+
const minThreads = ctx.config.minThreads ?? threadsCount
45+
46+
const options: TinypoolOptions = {
47+
filename: workerPath,
48+
// TODO: investigate further
49+
// It seems atomics introduced V8 Fatal Error https://github.com/vitest-dev/vitest/issues/1191
50+
useAtomics: ctx.config.useAtomics ?? false,
51+
52+
maxThreads,
53+
minThreads,
54+
55+
env,
56+
execArgv,
57+
}
58+
59+
if (ctx.config.isolate) {
60+
options.isolateWorkers = true
61+
options.concurrentTasksPerWorker = 1
62+
}
63+
64+
if (ctx.config.singleThread) {
65+
options.concurrentTasksPerWorker = 1
66+
options.maxThreads = 1
67+
options.minThreads = 1
68+
}
69+
70+
const pool = new Tinypool(options)
71+
72+
const runWithFiles = (name: string): RunWithFiles => {
73+
let id = 0
74+
75+
async function runFiles(config: ResolvedConfig, files: string[], environment: ContextTestEnvironment, invalidates: string[] = []) {
76+
ctx.state.clearFiles(files)
77+
const { workerPort, port } = createWorkerChannel(ctx)
78+
const workerId = ++id
79+
const data: WorkerContext = {
80+
port: workerPort,
81+
config,
82+
files,
83+
invalidates,
84+
environment,
85+
workerId,
86+
}
87+
try {
88+
await pool.run(data, { transferList: [workerPort], name })
89+
}
90+
finally {
91+
port.close()
92+
workerPort.close()
93+
}
94+
}
95+
96+
const Sequencer = ctx.config.sequence.sequencer
97+
const sequencer = new Sequencer(ctx)
98+
99+
return async (files, invalidates) => {
100+
const config = ctx.getSerializableConfig()
101+
102+
if (config.shard)
103+
files = await sequencer.shard(files)
104+
105+
files = await sequencer.sort(files)
106+
107+
const filesByEnv = await groupFilesByEnv(files, config)
108+
const envs = envsOrder.concat(
109+
Object.keys(filesByEnv).filter(env => !envsOrder.includes(env)),
110+
)
111+
112+
if (ctx.config.singleThread) {
113+
// always run environments isolated between each other
114+
for (const env of envs) {
115+
const files = filesByEnv[env]
116+
117+
if (!files?.length)
118+
continue
119+
120+
const filesByOptions = groupBy(files, ({ environment }) => JSON.stringify(environment.options))
121+
122+
for (const option in filesByOptions) {
123+
const files = filesByOptions[option]
124+
125+
if (files?.length) {
126+
const filenames = files.map(f => f.file)
127+
await runFiles(config, filenames, files[0].environment, invalidates)
128+
}
129+
}
130+
}
131+
}
132+
else {
133+
const promises = Object.values(filesByEnv).flat()
134+
const results = await Promise.allSettled(promises
135+
.map(({ file, environment }) => runFiles(config, [file], environment, invalidates)))
136+
137+
const errors = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason)
138+
if (errors.length > 0)
139+
throw new AggregateError(errors, 'Errors occurred while running tests. For more information, see serialized error.')
140+
}
141+
}
142+
}
143+
144+
return {
145+
runTests: runWithFiles('run'),
146+
close: async () => {
147+
// node before 16.17 has a bug that causes FATAL ERROR because of the race condition
148+
const nodeVersion = Number(process.version.match(/v(\d+)\.(\d+)/)?.[0].slice(1))
149+
if (nodeVersion >= 16.17)
150+
await pool.destroy()
151+
},
152+
}
153+
}

‎packages/vitest/src/runtime/child.ts

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import v8 from 'node:v8'
2+
import { createBirpc } from 'birpc'
3+
import { parseRegexp } from '@vitest/utils'
4+
import type { ResolvedConfig } from '../types'
5+
import type { RuntimeRPC } from '../types/rpc'
6+
import type { ChildContext } from '../types/child'
7+
import { mockMap, moduleCache, startViteNode } from './execute'
8+
import { rpcDone } from './rpc'
9+
10+
function init(ctx: ChildContext) {
11+
const { config } = ctx
12+
13+
process.env.VITEST_WORKER_ID = '1'
14+
process.env.VITEST_POOL_ID = '1'
15+
16+
// @ts-expect-error untyped global
17+
globalThis.__vitest_environment__ = config.environment
18+
// @ts-expect-error I know what I am doing :P
19+
globalThis.__vitest_worker__ = {
20+
ctx,
21+
moduleCache,
22+
config,
23+
mockMap,
24+
rpc: createBirpc<RuntimeRPC>(
25+
{},
26+
{
27+
eventNames: ['onUserConsoleLog', 'onFinished', 'onCollected', 'onWorkerExit'],
28+
serialize: v8.serialize,
29+
deserialize: v => v8.deserialize(Buffer.from(v)),
30+
post(v) {
31+
process.send?.(v)
32+
},
33+
on(fn) { process.on('message', fn) },
34+
},
35+
),
36+
}
37+
38+
if (ctx.invalidates) {
39+
ctx.invalidates.forEach((fsPath) => {
40+
moduleCache.delete(fsPath)
41+
moduleCache.delete(`mock:${fsPath}`)
42+
})
43+
}
44+
ctx.files.forEach(i => moduleCache.delete(i))
45+
}
46+
47+
function parsePossibleRegexp(str: string | RegExp) {
48+
const prefix = '$$vitest:'
49+
if (typeof str === 'string' && str.startsWith(prefix))
50+
return parseRegexp(str.slice(prefix.length))
51+
return str
52+
}
53+
54+
function unwrapConfig(config: ResolvedConfig) {
55+
if (config.testNamePattern)
56+
config.testNamePattern = parsePossibleRegexp(config.testNamePattern) as RegExp
57+
return config
58+
}
59+
60+
export async function run(ctx: ChildContext) {
61+
init(ctx)
62+
const { run, executor } = await startViteNode(ctx)
63+
await run(ctx.files, ctx.config, ctx.environment, executor)
64+
await rpcDone()
65+
}
66+
67+
const procesExit = process.exit
68+
69+
process.on('message', async (message: any) => {
70+
if (typeof message === 'object' && message.command === 'start') {
71+
try {
72+
message.config = unwrapConfig(message.config)
73+
await run(message)
74+
}
75+
finally {
76+
procesExit()
77+
}
78+
}
79+
})

‎packages/vitest/src/runtime/entry.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { VitestRunner, VitestRunnerConstructor } from '@vitest/runner'
22
import { startTests } from '@vitest/runner'
33
import { resolve } from 'pathe'
4-
import type { ResolvedConfig, WorkerTestEnvironment } from '../types'
4+
import type { ContextTestEnvironment, ResolvedConfig } from '../types'
55
import { getWorkerState, resetModules } from '../utils'
66
import { vi } from '../integrations/vi'
77
import { distDir } from '../constants'
@@ -65,7 +65,7 @@ async function getTestRunner(config: ResolvedConfig, executor: VitestExecutor):
6565
}
6666

6767
// browser shouldn't call this!
68-
export async function run(files: string[], config: ResolvedConfig, environment: WorkerTestEnvironment, executor: VitestExecutor): Promise<void> {
68+
export async function run(files: string[], config: ResolvedConfig, environment: ContextTestEnvironment, executor: VitestExecutor): Promise<void> {
6969
await setupGlobalEnv(config)
7070
await startCoverageInsideWorker(config.coverage, executor)
7171

‎packages/vitest/src/runtime/execute.ts

+64-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1-
import { ViteNodeRunner } from 'vite-node/client'
2-
import { isInternalRequest } from 'vite-node/utils'
1+
import { pathToFileURL } from 'node:url'
2+
import { ModuleCacheMap, ViteNodeRunner } from 'vite-node/client'
3+
import { isInternalRequest, isPrimitive } from 'vite-node/utils'
34
import type { ViteNodeRunnerOptions } from 'vite-node'
4-
import { normalize } from 'pathe'
5+
import { normalize, relative, resolve } from 'pathe'
56
import { isNodeBuiltin } from 'mlly'
7+
import { processError } from '@vitest/runner/utils'
68
import type { MockMap } from '../types/mocker'
79
import { getCurrentEnvironment, getWorkerState } from '../utils/global'
10+
import type { ContextRPC, ContextTestEnvironment, ResolvedConfig } from '../types'
11+
import { distDir } from '../constants'
812
import { VitestMocker } from './mocker'
13+
import { rpc } from './rpc'
914

1015
export interface ExecuteOptions extends ViteNodeRunnerOptions {
1116
mockMap: MockMap
@@ -19,6 +24,62 @@ export async function createVitestExecutor(options: ExecuteOptions) {
1924
return runner
2025
}
2126

27+
let _viteNode: {
28+
run: (files: string[], config: ResolvedConfig, environment: ContextTestEnvironment, executor: VitestExecutor) => Promise<void>
29+
executor: VitestExecutor
30+
}
31+
32+
export const moduleCache = new ModuleCacheMap()
33+
export const mockMap: MockMap = new Map()
34+
35+
export async function startViteNode(ctx: ContextRPC) {
36+
if (_viteNode)
37+
return _viteNode
38+
39+
const { config } = ctx
40+
41+
const processExit = process.exit
42+
43+
process.exit = (code = process.exitCode || 0): never => {
44+
const error = new Error(`process.exit called with "${code}"`)
45+
rpc().onWorkerExit(error, code)
46+
return processExit(code)
47+
}
48+
49+
function catchError(err: unknown, type: string) {
50+
const worker = getWorkerState()
51+
const error = processError(err)
52+
if (worker.filepath && !isPrimitive(error)) {
53+
error.VITEST_TEST_NAME = worker.current?.name
54+
error.VITEST_TEST_PATH = relative(config.root, worker.filepath)
55+
}
56+
rpc().onUnhandledError(error, type)
57+
}
58+
59+
process.on('uncaughtException', e => catchError(e, 'Uncaught Exception'))
60+
process.on('unhandledRejection', e => catchError(e, 'Unhandled Rejection'))
61+
62+
const executor = await createVitestExecutor({
63+
fetchModule(id) {
64+
return rpc().fetch(id, ctx.environment.name)
65+
},
66+
resolveId(id, importer) {
67+
return rpc().resolveId(id, importer, ctx.environment.name)
68+
},
69+
moduleCache,
70+
mockMap,
71+
interopDefault: config.deps.interopDefault,
72+
root: config.root,
73+
base: config.base,
74+
})
75+
76+
const { run } = await import(pathToFileURL(resolve(distDir, 'entry.js')).href)
77+
78+
_viteNode = { run, executor }
79+
80+
return _viteNode
81+
}
82+
2283
export class VitestExecutor extends ViteNodeRunner {
2384
public mocker: VitestMocker
2485

‎packages/vitest/src/runtime/rpc.ts

+19-3
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,39 @@ import {
33
} from '@vitest/utils'
44
import { getWorkerState } from '../utils'
55

6+
const { get } = Reflect
67
const safeRandom = Math.random
78

89
function withSafeTimers(fn: () => void) {
9-
const { setTimeout: safeSetTimeout } = getSafeTimers()
10+
const { setTimeout, clearTimeout, nextTick, setImmediate, clearImmediate } = getSafeTimers()
11+
1012
const currentSetTimeout = globalThis.setTimeout
13+
const currentClearTimeout = globalThis.clearTimeout
1114
const currentRandom = globalThis.Math.random
15+
const currentNextTick = globalThis.process.nextTick
16+
const currentSetImmediate = globalThis.setImmediate
17+
const currentClearImmediate = globalThis.clearImmediate
1218

1319
try {
14-
globalThis.setTimeout = safeSetTimeout
20+
globalThis.setTimeout = setTimeout
21+
globalThis.clearTimeout = clearTimeout
1522
globalThis.Math.random = safeRandom
23+
globalThis.process.nextTick = nextTick
24+
globalThis.setImmediate = setImmediate
25+
globalThis.clearImmediate = clearImmediate
1626

1727
const result = fn()
1828
return result
1929
}
2030
finally {
2131
globalThis.setTimeout = currentSetTimeout
32+
globalThis.clearTimeout = currentClearTimeout
2233
globalThis.Math.random = currentRandom
34+
globalThis.setImmediate = currentSetImmediate
35+
globalThis.clearImmediate = currentClearImmediate
36+
nextTick(() => {
37+
globalThis.process.nextTick = currentNextTick
38+
})
2339
}
2440
}
2541

@@ -36,7 +52,7 @@ export const rpc = () => {
3652
const { rpc } = getWorkerState()
3753
return new Proxy(rpc, {
3854
get(target, p, handler) {
39-
const sendCall = Reflect.get(target, p, handler)
55+
const sendCall = get(target, p, handler)
4056
const safeSendCall = (...args: any[]) => withSafeTimers(async () => {
4157
const result = sendCall(...args)
4258
promises.add(result)

‎packages/vitest/src/runtime/worker.ts

+4-68
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,9 @@
1-
import { pathToFileURL } from 'node:url'
2-
import { relative, resolve } from 'pathe'
31
import { createBirpc } from 'birpc'
42
import { workerId as poolId } from 'tinypool'
5-
import { processError } from '@vitest/runner/utils'
6-
import { ModuleCacheMap } from 'vite-node/client'
7-
import { isPrimitive } from 'vite-node/utils'
8-
import type { ResolvedConfig, WorkerContext, WorkerRPC, WorkerTestEnvironment } from '../types'
9-
import { distDir } from '../constants'
3+
import type { RuntimeRPC, WorkerContext } from '../types'
104
import { getWorkerState } from '../utils/global'
11-
import type { MockMap } from '../types/mocker'
12-
import type { VitestExecutor } from './execute'
13-
import { createVitestExecutor } from './execute'
14-
import { rpc, rpcDone } from './rpc'
15-
16-
let _viteNode: {
17-
run: (files: string[], config: ResolvedConfig, environment: WorkerTestEnvironment, executor: VitestExecutor) => Promise<void>
18-
executor: VitestExecutor
19-
}
20-
21-
const moduleCache = new ModuleCacheMap()
22-
const mockMap: MockMap = new Map()
23-
24-
async function startViteNode(ctx: WorkerContext) {
25-
if (_viteNode)
26-
return _viteNode
27-
28-
const { config } = ctx
29-
30-
const processExit = process.exit
31-
32-
process.exit = (code = process.exitCode || 0): never => {
33-
const error = new Error(`process.exit called with "${code}"`)
34-
rpc().onWorkerExit(error, code)
35-
return processExit(code)
36-
}
37-
38-
function catchError(err: unknown, type: string) {
39-
const worker = getWorkerState()
40-
const error = processError(err)
41-
if (worker.filepath && !isPrimitive(error)) {
42-
error.VITEST_TEST_NAME = worker.current?.name
43-
error.VITEST_TEST_PATH = relative(config.root, worker.filepath)
44-
}
45-
rpc().onUnhandledError(error, type)
46-
}
47-
48-
process.on('uncaughtException', e => catchError(e, 'Uncaught Exception'))
49-
process.on('unhandledRejection', e => catchError(e, 'Unhandled Rejection'))
50-
51-
const executor = await createVitestExecutor({
52-
fetchModule(id) {
53-
return rpc().fetch(id, ctx.environment.name)
54-
},
55-
resolveId(id, importer) {
56-
return rpc().resolveId(id, importer, ctx.environment.name)
57-
},
58-
moduleCache,
59-
mockMap,
60-
interopDefault: config.deps.interopDefault,
61-
root: config.root,
62-
base: config.base,
63-
})
64-
65-
const { run } = await import(pathToFileURL(resolve(distDir, 'entry.js')).href)
66-
67-
_viteNode = { run, executor }
68-
69-
return _viteNode
70-
}
5+
import { mockMap, moduleCache, startViteNode } from './execute'
6+
import { rpcDone } from './rpc'
717

728
function init(ctx: WorkerContext) {
739
// @ts-expect-error untyped global
@@ -87,7 +23,7 @@ function init(ctx: WorkerContext) {
8723
moduleCache,
8824
config,
8925
mockMap,
90-
rpc: createBirpc<WorkerRPC>(
26+
rpc: createBirpc<RuntimeRPC>(
9127
{},
9228
{
9329
eventNames: ['onUserConsoleLog', 'onFinished', 'onCollected', 'onWorkerExit'],

‎packages/vitest/src/types/child.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { ContextRPC } from './rpc'
2+
3+
export interface ChildContext extends ContextRPC {
4+
command: 'start'
5+
}

‎packages/vitest/src/types/config.ts

+7
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,13 @@ export interface InlineConfig {
309309
*/
310310
isolate?: boolean
311311

312+
/**
313+
* Run tests inside a single thread.
314+
*
315+
* @default false
316+
*/
317+
singleThread?: boolean
318+
312319
/**
313320
* Coverage options
314321
*/

‎packages/vitest/src/types/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export { assertType, type AssertType } from '../typecheck/assertType'
66
export * from '../typecheck/types'
77
export * from './config'
88
export * from './tasks'
9+
export * from './rpc'
910
export * from './reporter'
1011
export * from './snapshot'
1112
export * from './worker'

‎packages/vitest/src/types/rpc.ts

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { RawSourceMap } from 'source-map'
2+
import type { FetchResult, ViteNodeResolveId } from 'vite-node'
3+
import type { EnvironmentOptions, ResolvedConfig, VitestEnvironment } from './config'
4+
import type { UserConsoleLog } from './general'
5+
import type { SnapshotResult } from './snapshot'
6+
import type { File, TaskResultPack } from './tasks'
7+
import type { AfterSuiteRunMeta } from './worker'
8+
9+
export interface RuntimeRPC {
10+
fetch: (id: string, environment: VitestEnvironment) => Promise<FetchResult>
11+
resolveId: (id: string, importer: string | undefined, environment: VitestEnvironment) => Promise<ViteNodeResolveId | null>
12+
getSourceMap: (id: string, force?: boolean) => Promise<RawSourceMap | undefined>
13+
14+
onFinished: (files: File[], errors?: unknown[]) => void
15+
onWorkerExit: (error: unknown, code?: number) => void
16+
onPathsCollected: (paths: string[]) => void
17+
onUserConsoleLog: (log: UserConsoleLog) => void
18+
onUnhandledError: (err: unknown, type: string) => void
19+
onCollected: (files: File[]) => void
20+
onAfterSuiteRun: (meta: AfterSuiteRunMeta) => void
21+
onTaskUpdate: (pack: TaskResultPack[]) => void
22+
23+
snapshotSaved: (snapshot: SnapshotResult) => void
24+
resolveSnapshotPath: (testPath: string) => string
25+
}
26+
27+
export interface ContextTestEnvironment {
28+
name: VitestEnvironment
29+
options: EnvironmentOptions | null
30+
}
31+
32+
export interface ContextRPC {
33+
config: ResolvedConfig
34+
files: string[]
35+
invalidates?: string[]
36+
environment: ContextTestEnvironment
37+
}

‎packages/vitest/src/types/worker.ts

+6-34
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,14 @@
11
import type { MessagePort } from 'node:worker_threads'
2-
import type { File, TaskResultPack, Test } from '@vitest/runner'
3-
import type { FetchResult, ModuleCacheMap, RawSourceMap, ViteNodeResolveId } from 'vite-node'
2+
import type { Test } from '@vitest/runner'
3+
import type { ModuleCacheMap, ViteNodeResolveId } from 'vite-node'
44
import type { BirpcReturn } from 'birpc'
55
import type { MockMap } from './mocker'
6-
import type { EnvironmentOptions, ResolvedConfig, VitestEnvironment } from './config'
7-
import type { SnapshotResult } from './snapshot'
8-
import type { UserConsoleLog } from './general'
6+
import type { ResolvedConfig } from './config'
7+
import type { ContextRPC, RuntimeRPC } from './rpc'
98

10-
export interface WorkerTestEnvironment {
11-
name: VitestEnvironment
12-
options: EnvironmentOptions | null
13-
}
14-
15-
export interface WorkerContext {
9+
export interface WorkerContext extends ContextRPC {
1610
workerId: number
1711
port: MessagePort
18-
config: ResolvedConfig
19-
files: string[]
20-
environment: WorkerTestEnvironment
21-
invalidates?: string[]
2212
}
2313

2414
export type ResolveIdFunction = (id: string, importer?: string) => Promise<ViteNodeResolveId | null>
@@ -27,28 +17,10 @@ export interface AfterSuiteRunMeta {
2717
coverage?: unknown
2818
}
2919

30-
export interface WorkerRPC {
31-
fetch: (id: string, environment: VitestEnvironment) => Promise<FetchResult>
32-
resolveId: (id: string, importer: string | undefined, environment: VitestEnvironment) => Promise<ViteNodeResolveId | null>
33-
getSourceMap: (id: string, force?: boolean) => Promise<RawSourceMap | undefined>
34-
35-
onFinished: (files: File[], errors?: unknown[]) => void
36-
onWorkerExit: (error: unknown, code?: number) => void
37-
onPathsCollected: (paths: string[]) => void
38-
onUserConsoleLog: (log: UserConsoleLog) => void
39-
onUnhandledError: (err: unknown, type: string) => void
40-
onCollected: (files: File[]) => void
41-
onAfterSuiteRun: (meta: AfterSuiteRunMeta) => void
42-
onTaskUpdate: (pack: TaskResultPack[]) => void
43-
44-
snapshotSaved: (snapshot: SnapshotResult) => void
45-
resolveSnapshotPath: (testPath: string) => string
46-
}
47-
4820
export interface WorkerGlobalState {
4921
ctx: WorkerContext
5022
config: ResolvedConfig
51-
rpc: BirpcReturn<WorkerRPC>
23+
rpc: BirpcReturn<RuntimeRPC>
5224
current?: Test
5325
filepath?: string
5426
moduleCache: ModuleCacheMap

‎pnpm-lock.yaml

+5-4
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎test/core/test/timers.test.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1186,6 +1186,8 @@ describe('FakeTimers', () => {
11861186

11871187
expect(global.setImmediate).not.toBe(nativeSetImmediate)
11881188
expect(global.clearImmediate).not.toBe(nativeClearImmediate)
1189+
1190+
fakeTimers.useRealTimers()
11891191
})
11901192
})
11911193

0 commit comments

Comments
 (0)
Please sign in to comment.