Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: use child_process when --no-threads is used #2772

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
15 changes: 13 additions & 2 deletions docs/config/index.md
Expand Up @@ -489,10 +489,21 @@ By providing an object instead of a string you can define individual outputs whe
- **Default:** `true`
- **CLI:** `--threads`, `--threads=false`

Enable multi-threading using [tinypool](https://github.com/tinylibs/tinypool) (a lightweight fork of [Piscina](https://github.com/piscinajs/piscina))
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.

Disabling this option also disables module isolation, meaning all tests with the same environment are running inside a single child process.

### singleThread

- **Type:** `boolean`
- **Default:** `false`
- **Version:** Since Vitest 0.29.0

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`.


:::warning
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.
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.

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.
:::
Expand Down
17 changes: 17 additions & 0 deletions packages/utils/src/helpers.ts
Expand Up @@ -11,6 +11,23 @@ export function slash(path: string) {
return path.replace(/\\/g, '/')
}

// convert RegExp.toString to RegExp
export function parseRegexp(input: string): RegExp {
// Parse input
const m = input.match(/(\/?)(.+)\1([a-z]*)/i)

// match nothing
if (!m)
return /$^/

// Invalid flags
if (m[3] && !/^(?!.*?(.).*?\1)[gmixXsuUAJ]+$/.test(m[3]))
return RegExp(input)

// Create the regular expression
return new RegExp(m[2], m[3])
}

export function toArray<T>(array?: Nullable<Arrayable<T>>): Array<T> {
if (array === null || array === undefined)
array = []
Expand Down
18 changes: 18 additions & 0 deletions packages/utils/src/timers.ts
Expand Up @@ -6,13 +6,22 @@ export function getSafeTimers() {
setInterval: safeSetInterval,
clearInterval: safeClearInterval,
clearTimeout: safeClearTimeout,
setImmediate: safeSetImmediate,
clearImmediate: safeClearImmediate,
} = (globalThis as any)[SAFE_TIMERS_SYMBOL] || globalThis

const {
nextTick: safeNextTick,
} = (globalThis as any)[SAFE_TIMERS_SYMBOL] || globalThis.process || { nextTick: (cb: () => void) => cb() }

return {
nextTick: safeNextTick,
setTimeout: safeSetTimeout,
setInterval: safeSetInterval,
clearInterval: safeClearInterval,
clearTimeout: safeClearTimeout,
setImmediate: safeSetImmediate,
clearImmediate: safeClearImmediate,
}
}

Expand All @@ -22,13 +31,22 @@ export function setSafeTimers() {
setInterval: safeSetInterval,
clearInterval: safeClearInterval,
clearTimeout: safeClearTimeout,
setImmediate: safeSetImmediate,
clearImmediate: safeClearImmediate,
} = globalThis

const {
nextTick: safeNextTick,
} = globalThis.process || { nextTick: cb => cb() }

const timers = {
nextTick: safeNextTick,
setTimeout: safeSetTimeout,
setInterval: safeSetInterval,
clearInterval: safeClearInterval,
clearTimeout: safeClearTimeout,
setImmediate: safeSetImmediate,
clearImmediate: safeClearImmediate,
}

;(globalThis as any)[SAFE_TIMERS_SYMBOL] = timers
Expand Down
1 change: 1 addition & 0 deletions packages/vitest/rollup.config.js
Expand Up @@ -23,6 +23,7 @@ const entries = [
'src/runners.ts',
'src/environments.ts',
'src/runtime/worker.ts',
'src/runtime/child.ts',
'src/runtime/loader.ts',
'src/runtime/entry.ts',
'src/integrations/spy.ts',
Expand Down
1 change: 1 addition & 0 deletions packages/vitest/src/node/cli.ts
Expand Up @@ -21,6 +21,7 @@ cli
.option('--open', 'Open UI automatically (default: !process.env.CI))')
.option('--api [api]', 'Serve API, available options: --api.port <port>, --api.host [host] and --api.strictPort')
.option('--threads', 'Enabled threads (default: true)')
.option('--single-thread', 'Run tests inside a single thread, requires --threads (default: false)')
.option('--silent', 'Silent console output from tests')
.option('--isolate', 'Isolate environment for each test file (default: true)')
.option('--reporter <name>', 'Specify reporters')
Expand Down
4 changes: 2 additions & 2 deletions packages/vitest/src/node/core.ts
Expand Up @@ -12,7 +12,7 @@ import { deepMerge, hasFailed, noop, slash, toArray } from '../utils'
import { getCoverageProvider } from '../integrations/coverage'
import { Typechecker } from '../typecheck/typechecker'
import { createPool } from './pool'
import type { WorkerPool } from './pool'
import type { ProcessPool } from './pool'
import { createBenchmarkReporters, createReporters } from './reporters/utils'
import { StateManager } from './state'
import { resolveConfig } from './config'
Expand All @@ -32,7 +32,7 @@ export class Vitest {
reporters: Reporter[] = undefined!
coverageProvider: CoverageProvider | null | undefined
logger: Logger
pool: WorkerPool | undefined
pool: ProcessPool | undefined
typechecker: Typechecker | undefined

vitenode: ViteNodeServer = undefined!
Expand Down
228 changes: 22 additions & 206 deletions packages/vitest/src/node/pool.ts
@@ -1,37 +1,26 @@
import { MessageChannel } from 'node:worker_threads'
import _url from 'node:url'
import { cpus } from 'node:os'
import { pathToFileURL } from 'node:url'
import { resolve } from 'pathe'
import type { Options as TinypoolOptions } from 'tinypool'
import { Tinypool } from 'tinypool'
import { createBirpc } from 'birpc'
import type { RawSourceMap } from 'vite-node'
import type { ResolvedConfig, WorkerContext, WorkerRPC, WorkerTestEnvironment } from '../types'
import { distDir, rootDir } from '../constants'
import { AggregateError, getEnvironmentTransformMode, groupBy } from '../utils'
import { envsOrder, groupFilesByEnv } from '../utils/test-helpers'
import type { Vitest } from './core'
import { createChildProcessPool } from './pools/child'
import { createThreadsPool } from './pools/threads'

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

export interface WorkerPool {
export interface ProcessPool {
runTests: RunWithFiles
close: () => Promise<void>
}

const workerPath = _url.pathToFileURL(resolve(distDir, './worker.js')).href
const loaderPath = _url.pathToFileURL(resolve(distDir, './loader.js')).href
export interface PoolProcessOptions {
execArgv: string[]
env: Record<string, string>
}

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

export function createPool(ctx: Vitest): WorkerPool {
const threadsCount = ctx.config.watch
? Math.max(Math.floor(cpus().length / 2), 1)
: Math.max(cpus().length - 1, 1)

const maxThreads = ctx.config.maxThreads ?? threadsCount
const minThreads = ctx.config.minThreads ?? threadsCount

export function createPool(ctx: Vitest): ProcessPool {
const conditions = ctx.server.config.resolve.conditions?.flatMap(c => ['--conditions', c]) || []

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

const options: TinypoolOptions = {
filename: workerPath,
// TODO: investigate further
// It seems atomics introduced V8 Fatal Error https://github.com/vitest-dev/vitest/issues/1191
useAtomics: ctx.config.useAtomics ?? false,

maxThreads,
minThreads,

const options: PoolProcessOptions = {
execArgv: ctx.config.deps.registerNodeLoader
? [
...execArgv,
'--require',
suppressLoaderWarningsPath,
'--experimental-loader',
loaderPath,
...conditions,
...execArgv,
]
: [
...execArgv,
...conditions,
],
}

if (ctx.config.isolate) {
options.isolateWorkers = true
options.concurrentTasksPerWorker = 1
}

if (!ctx.config.threads) {
options.concurrentTasksPerWorker = 1
options.maxThreads = 1
options.minThreads = 1
}

options.env = {
TEST: 'true',
VITEST: 'true',
NODE_ENV: ctx.config.mode || 'test',
VITEST_MODE: ctx.config.watch ? 'WATCH' : 'RUN',
...process.env,
...ctx.config.env,
}

const pool = new Tinypool(options)

const runWithFiles = (name: string): RunWithFiles => {
let id = 0

async function runFiles(config: ResolvedConfig, files: string[], environment: WorkerTestEnvironment, invalidates: string[] = []) {
ctx.state.clearFiles(files)
const { workerPort, port } = createChannel(ctx)
const workerId = ++id
const data: WorkerContext = {
port: workerPort,
config,
files,
invalidates,
environment,
workerId,
}
try {
await pool.run(data, { transferList: [workerPort], name })
}
finally {
port.close()
workerPort.close()
}
}

const Sequencer = ctx.config.sequence.sequencer
const sequencer = new Sequencer(ctx)

return async (files, invalidates) => {
const config = ctx.getSerializableConfig()

if (config.shard)
files = await sequencer.shard(files)

files = await sequencer.sort(files)

const filesByEnv = await groupFilesByEnv(files, config)
const envs = envsOrder.concat(
Object.keys(filesByEnv).filter(env => !envsOrder.includes(env)),
)

if (!ctx.config.threads) {
// always run environments isolated between each other
for (const env of envs) {
const files = filesByEnv[env]

if (!files?.length)
continue

const filesByOptions = groupBy(files, ({ environment }) => JSON.stringify(environment.options))

for (const option in filesByOptions) {
const files = filesByOptions[option]

if (files?.length) {
const filenames = files.map(f => f.file)
await runFiles(config, filenames, files[0].environment, invalidates)
}
}
}
}
else {
const promises = Object.values(filesByEnv).flat()
const results = await Promise.allSettled(promises
.map(({ file, environment }) => runFiles(config, [file], environment, invalidates)))

const errors = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason)
if (errors.length > 0)
throw new AggregateError(errors, 'Errors occurred while running tests. For more information, see serialized error.')
}
}
}

return {
runTests: runWithFiles('run'),
close: async () => {
// node before 16.17 has a bug that causes FATAL ERROR because of the race condition
const nodeVersion = Number(process.version.match(/v(\d+)\.(\d+)/)?.[0].slice(1))
if (nodeVersion >= 16.17)
await pool.destroy()
env: {
TEST: 'true',
VITEST: 'true',
NODE_ENV: ctx.config.mode || 'test',
VITEST_MODE: ctx.config.watch ? 'WATCH' : 'RUN',
...process.env,
...ctx.config.env,
},
}
}

function createChannel(ctx: Vitest) {
const channel = new MessageChannel()
const port = channel.port2
const workerPort = channel.port1

createBirpc<{}, WorkerRPC>(
{
async onWorkerExit(error, code) {
await ctx.logger.printError(error, false, 'Unexpected Exit')
process.exit(code || 1)
},
snapshotSaved(snapshot) {
ctx.snapshot.add(snapshot)
},
resolveSnapshotPath(testPath: string) {
return ctx.snapshot.resolvePath(testPath)
},
async getSourceMap(id, force) {
if (force) {
const mod = ctx.server.moduleGraph.getModuleById(id)
if (mod)
ctx.server.moduleGraph.invalidateModule(mod)
}
const r = await ctx.vitenode.transformRequest(id)
return r?.map as RawSourceMap | undefined
},
fetch(id, environment) {
const transformMode = getEnvironmentTransformMode(ctx.config, environment)
return ctx.vitenode.fetchModule(id, transformMode)
},
resolveId(id, importer, environment) {
const transformMode = getEnvironmentTransformMode(ctx.config, environment)
return ctx.vitenode.resolveId(id, importer, transformMode)
},
onPathsCollected(paths) {
ctx.state.collectPaths(paths)
ctx.report('onPathsCollected', paths)
},
onCollected(files) {
ctx.state.collectFiles(files)
ctx.report('onCollected', files)
},
onAfterSuiteRun(meta) {
ctx.coverageProvider?.onAfterSuiteRun(meta)
},
onTaskUpdate(packs) {
ctx.state.updateTasks(packs)
ctx.report('onTaskUpdate', packs)
},
onUserConsoleLog(log) {
ctx.state.updateUserLog(log)
ctx.report('onUserConsoleLog', log)
},
onUnhandledError(err, type) {
ctx.state.catchError(err, type)
},
onFinished(files) {
ctx.report('onFinished', files, ctx.state.getUnhandledErrors())
},
},
{
post(v) {
port.postMessage(v)
},
on(fn) {
port.on('message', fn)
},
},
)

return { workerPort, port }
if (!ctx.config.threads)
return createChildProcessPool(ctx, options)
return createThreadsPool(ctx, options)
}