Skip to content

Commit

Permalink
feat!: use child_process when --no-threads is used (#2772)
Browse files Browse the repository at this point in the history
  • Loading branch information
sheremet-va committed Feb 25, 2023
1 parent 4d277d8 commit 7bf5450
Show file tree
Hide file tree
Showing 22 changed files with 643 additions and 324 deletions.
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)
}

0 comments on commit 7bf5450

Please sign in to comment.