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 4 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
10 changes: 9 additions & 1 deletion docs/config/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,14 +467,22 @@ 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)). If disabled, uses `child_process` to spawn a process to run tests inside. Disabling this option also disables module isolation, meaning all tests run inside a single child process.

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

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

### singleThread

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

Run all tests inside a single worker thread. This will disable module isolation, but can improve test performance. Before Vitest 0.29.0 this was equivalent to using `--no-threads`.

### maxThreads

- **Type:** `number`
Expand Down
1 change: 1 addition & 0 deletions packages/vitest/rollup.config.js
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
254 changes: 169 additions & 85 deletions packages/vitest/src/node/pool.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { MessageChannel } from 'node:worker_threads'
import _url from 'node:url'
import type { ChildProcess } from 'node:child_process'
import { fork } from 'node:child_process'
import v8 from 'node:v8'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { cpus } from 'node:os'
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 } from '../types'
import type { ResolvedConfig, RuntimeRPC, WorkerContext } from '../types'
import { distDir, rootDir } from '../constants'
import { AggregateError } from '../utils'
import type { ChildContext } from '../types/child'
import type { Vitest } from './core'

export type RunWithFiles = (files: string[], invalidates?: string[]) => Promise<void>
Expand All @@ -18,19 +22,18 @@ export interface WorkerPool {
close: () => Promise<void>
}

const workerPath = _url.pathToFileURL(resolve(distDir, './worker.js')).href
const loaderPath = _url.pathToFileURL(resolve(distDir, './loader.js')).href
const workerPath = pathToFileURL(resolve(distDir, './worker.js')).href
const childPath = fileURLToPath(pathToFileURL(resolve(distDir, './child.js')).href)
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
interface ProcessOptions {
execArgv: string[]
env: Record<string, string>
}

export function createPool(ctx: Vitest): WorkerPool {
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 @@ -39,51 +42,69 @@ 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: ProcessOptions = {
execArgv: ctx.config.deps.registerNodeLoader
? [
...execArgv,
'--require',
suppressLoaderWarningsPath,
'--experimental-loader',
loaderPath,
...conditions,
...execArgv,
]
: [
...execArgv,
...conditions,
],
env: {
TEST: 'true',
VITEST: 'true',
NODE_ENV: ctx.config.mode || 'test',
VITEST_MODE: ctx.config.watch ? 'WATCH' : 'RUN',
...process.env,
...ctx.config.env,
},
}

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

export function createThreadsPool(ctx: Vitest, { execArgv, env }: ProcessOptions): 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

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,

execArgv,
}

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

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

ctx.coverageProvider?.onBeforeFilesRun?.()

options.env = {
TEST: 'true',
VITEST: 'true',
NODE_ENV: ctx.config.mode || 'test',
VITEST_MODE: ctx.config.watch ? 'WATCH' : 'RUN',
...process.env,
...ctx.config.env,
}
options.env = env
sheremet-va marked this conversation as resolved.
Show resolved Hide resolved

const pool = new Tinypool(options)

Expand All @@ -92,7 +113,7 @@ export function createPool(ctx: Vitest): WorkerPool {

async function runFiles(config: ResolvedConfig, files: string[], invalidates: string[] = []) {
ctx.state.clearFiles(files)
const { workerPort, port } = createChannel(ctx)
const { workerPort, port } = createWorkerChannel(ctx)
const workerId = ++id
const data: WorkerContext = {
port: workerPort,
Expand Down Expand Up @@ -121,7 +142,7 @@ export function createPool(ctx: Vitest): WorkerPool {

files = await sequencer.sort(files)

if (!ctx.config.threads) {
if (ctx.config.singleThread) {
await runFiles(config, files)
}
else {
Expand All @@ -146,64 +167,127 @@ export function createPool(ctx: Vitest): WorkerPool {
}
}

function createChannel(ctx: Vitest) {
const channel = new MessageChannel()
const port = channel.port2
const workerPort = channel.port1
export function createChildProcessPool(ctx: Vitest, { execArgv, env }: ProcessOptions): WorkerPool {
// isolation is disabled with --no-threads
let child: ChildProcess

function runWithFiles(files: string[], invalidates: string[] = []) {
const data: ChildContext = {
command: 'start',
config: ctx.getSerializableConfig(),
files,
invalidates,
}
child = fork(childPath, [], {
execArgv,
env,
})
setupChildProcessChannel(ctx, child)

createBirpc<{}, WorkerRPC>(
return new Promise<void>((resolve, reject) => {
child.send(data, (err) => {
if (err)
reject(err)
})
child.on('close', (code) => {
if (!code)
resolve()
else
reject(new Error(`Child process exited unexpectedly with code ${code}`))
})
})
}

return {
runTests: runWithFiles,
async close() {
if (!child)
return

if (!child.killed)
child.kill()
},
}
}

function createMethodsRPC(ctx: Vitest): RuntimeRPC {
return {
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) {
return ctx.vitenode.fetchModule(id)
},
resolveId(id, importer) {
return ctx.vitenode.resolveId(id, importer)
},
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())
},
}
}

function setupChildProcessChannel(ctx: Vitest, fork: ChildProcess) {
createBirpc<{}, RuntimeRPC>(
createMethodsRPC(ctx),
{
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) {
return ctx.vitenode.fetchModule(id)
},
resolveId(id, importer) {
return ctx.vitenode.resolveId(id, importer)
},
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)
serialize: v8.serialize,
deserialize: v => v8.deserialize(Buffer.from(v)),
post(v) {
fork.send(v)
},
onFinished(files) {
ctx.report('onFinished', files, ctx.state.getUnhandledErrors())
on(fn) {
fork.on('message', fn)
},
},
)
}

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

createBirpc<{}, RuntimeRPC>(
createMethodsRPC(ctx),
{
post(v) {
port.postMessage(v)
Expand Down