Skip to content

Commit 7bf5450

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

File tree

22 files changed

+643
-324
lines changed

22 files changed

+643
-324
lines changed

docs/config/index.md

Lines changed: 13 additions & 2 deletions
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

Lines changed: 17 additions & 0 deletions
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

Lines changed: 18 additions & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 2 additions & 2 deletions
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

Lines changed: 22 additions & 206 deletions
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
}

0 commit comments

Comments
 (0)