From d523248bf4e28c097f8dc34725721afef19cb2dd Mon Sep 17 00:00:00 2001 From: Han Feng Date: Sun, 17 Mar 2024 21:46:18 +0800 Subject: [PATCH 1/7] feat: support `--detectAsyncLeaks` flag --- packages/vitest/src/node/cli/cli-config.ts | 3 ++ packages/vitest/src/node/core.ts | 11 +++++ packages/vitest/src/node/logger.ts | 21 +++++++- packages/vitest/src/node/pools/rpc.ts | 3 ++ packages/vitest/src/runners.ts | 1 + packages/vitest/src/runtime/runners/index.ts | 9 +++- packages/vitest/src/runtime/runners/test.ts | 2 +- .../runners/with-async-leaks-detecter.ts | 49 +++++++++++++++++++ packages/vitest/src/types/config.ts | 6 +++ packages/vitest/src/types/rpc.ts | 3 ++ 10 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 packages/vitest/src/runtime/runners/with-async-leaks-detecter.ts diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index b5e16b9f526c..78a3b700cb0b 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -582,6 +582,9 @@ export const cliOptionsConfig: VitestCLIOptions = { clearScreen: { description: 'Clear terminal screen when re-running tests during watch mode (default: true)', }, + detectAsyncLeaks: { + description: 'Detect async leaks in the test suite. This will slow down the test suite, use only in node environment (default: false)', + }, // disable CLI options cliExclude: null, diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 18146a0c1e4f..bf6707bcb117 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -16,6 +16,7 @@ import { getCoverageProvider } from '../integrations/coverage' import { CONFIG_NAMES, configFiles, workspacesFiles as workspaceFiles } from '../constants' import { rootDir } from '../paths' import { WebSocketReporter } from '../api/setup' +import type { HangingOps } from '../runtime/runners/with-async-leaks-detecter' import { createPool } from './pool' import type { ProcessPool, WorkspaceSpec } from './pool' import { createBenchmarkReporters, createReporters } from './reporters/utils' @@ -69,6 +70,8 @@ export class Vitest { public distPath!: string + public hangingOps?: HangingOps[] + constructor( public readonly mode: VitestRunMode, options: VitestOptions = {}, @@ -487,6 +490,8 @@ export class Vitest { await this.report('onPathsCollected', filepaths) + const detectAsyncLeaks = this.config.detectAsyncLeaks && !this.config.browser.enabled + // previous run await this.runningPromise this._onCancelListeners = [] @@ -505,6 +510,9 @@ export class Vitest { if (!this.isFirstRun && this.config.coverage.cleanOnRerun) await this.coverageProvider?.clean() + if (detectAsyncLeaks) + this.hangingOps = [] + await this.initializeGlobalSetup(paths) try { @@ -528,6 +536,9 @@ export class Vitest { await this.report('onFinished', this.state.getFiles(specs), this.state.getUnhandledErrors()) await this.reportCoverage(allTestsRun) + if (detectAsyncLeaks) + await this.logger.printAsyncLeaksWarning(this.hangingOps!) + this.runningPromise = undefined this.isFirstRun = false diff --git a/packages/vitest/src/node/logger.ts b/packages/vitest/src/node/logger.ts index a494c9b619ee..57cab16fa7c6 100644 --- a/packages/vitest/src/node/logger.ts +++ b/packages/vitest/src/node/logger.ts @@ -3,8 +3,9 @@ import c from 'picocolors' import { version } from '../../../../package.json' import type { ErrorWithDiff } from '../types' import type { TypeCheckError } from '../typecheck/typechecker' -import { toArray } from '../utils' +import { getFullName, toArray } from '../utils' import { highlightCode } from '../utils/colors' +import type { HangingOps } from '../runtime/runners/with-async-leaks-detecter' import { divider } from './reporters/renderers/utils' import { RandomSequencer } from './sequencers/RandomSequencer' import type { Vitest } from './core' @@ -212,4 +213,22 @@ export class Logger { })) this.log(c.red(divider())) } + + async printAsyncLeaksWarning(hangingOps: HangingOps[]) { + if (hangingOps.length === 0) + return + const errorMessage = c.yellow(c.bold( + `\nVitest has detected the following ${hangingOps.length} hanging operation${hangingOps.length > 1 ? 's' : ''} potentially keeping Vitest from exiting: \n`, + )) + this.log(c.yellow(divider(c.bold(c.inverse(' Hanging Operations '))))) + this.log(errorMessage) + + hangingOps.forEach(({ type, taskId, stack }) => { + const task = taskId && this.ctx.state.idMap.get(taskId) + this.log(type + c.dim(` | ${task ? getFullName(task) : taskId}`)) + this.log(`${c.gray(c.dim(`${stack}`))}\n`) + }) + + this.log(c.yellow(divider())) + } } diff --git a/packages/vitest/src/node/pools/rpc.ts b/packages/vitest/src/node/pools/rpc.ts index 3144e3c73f42..5564466d8bda 100644 --- a/packages/vitest/src/node/pools/rpc.ts +++ b/packages/vitest/src/node/pools/rpc.ts @@ -60,5 +60,8 @@ export function createMethodsRPC(project: WorkspaceProject): RuntimeRPC { getCountOfFailedTests() { return ctx.state.getCountOfFailedTests() }, + detectAsyncLeaks(hangingOps) { + ctx.hangingOps?.push(...hangingOps) + }, } } diff --git a/packages/vitest/src/runners.ts b/packages/vitest/src/runners.ts index ef5804e48c33..cba9d6a22397 100644 --- a/packages/vitest/src/runners.ts +++ b/packages/vitest/src/runners.ts @@ -1,2 +1,3 @@ export { VitestTestRunner } from './runtime/runners/test' export { NodeBenchmarkRunner } from './runtime/runners/benchmark' +export { VitestTestRunnerWithAsyncLeaksDetecter } from './runtime/runners/with-async-leaks-detecter' diff --git a/packages/vitest/src/runtime/runners/index.ts b/packages/vitest/src/runtime/runners/index.ts index 40197e90bc41..c54c002e3cb1 100644 --- a/packages/vitest/src/runtime/runners/index.ts +++ b/packages/vitest/src/runtime/runners/index.ts @@ -12,7 +12,14 @@ const runnersFile = resolve(distDir, 'runners.js') async function getTestRunnerConstructor(config: ResolvedConfig, executor: VitestExecutor): Promise { if (!config.runner) { - const { VitestTestRunner, NodeBenchmarkRunner } = await executor.executeFile(runnersFile) + const { VitestTestRunner, NodeBenchmarkRunner, VitestTestRunnerWithAsyncLeaksDetecter } = await executor.executeFile(runnersFile) + + if (config.detectAsyncLeaks) { + if (config.browser?.enabled) + throw new Error('Async leaks detection is not supported in browser mode.') + return VitestTestRunnerWithAsyncLeaksDetecter as VitestRunnerConstructor + } + return (config.mode === 'test' ? VitestTestRunner : NodeBenchmarkRunner) as VitestRunnerConstructor } const mod = await executor.executeId(config.runner) diff --git a/packages/vitest/src/runtime/runners/test.ts b/packages/vitest/src/runtime/runners/test.ts index e567506e75fc..c734a5677a45 100644 --- a/packages/vitest/src/runtime/runners/test.ts +++ b/packages/vitest/src/runtime/runners/test.ts @@ -11,7 +11,7 @@ import { rpc } from '../rpc' export class VitestTestRunner implements VitestRunner { private snapshotClient = getSnapshotClient() - private workerState = getWorkerState() + protected workerState = getWorkerState() private __vitest_executor!: VitestExecutor private cancelRun = false diff --git a/packages/vitest/src/runtime/runners/with-async-leaks-detecter.ts b/packages/vitest/src/runtime/runners/with-async-leaks-detecter.ts new file mode 100644 index 000000000000..fa79d9a3bde9 --- /dev/null +++ b/packages/vitest/src/runtime/runners/with-async-leaks-detecter.ts @@ -0,0 +1,49 @@ +import asyncHooks from 'node:async_hooks' +import { relative } from 'pathe' +import { rpc } from '../rpc' +import { VitestTestRunner } from './test' + +export interface HangingOps { + type: string + stack: string + taskId?: string +} + +export class VitestTestRunnerWithAsyncLeaksDetecter extends VitestTestRunner { + private hangingOps: Map = new Map() + + private asyncHook: asyncHooks.AsyncHook = asyncHooks.createHook({ + init: (id, type) => { + const task = this.workerState.current + const filepath = task?.file?.filepath || this.workerState.filepath + + let stack = new Error('STACK_TRACE_ERROR').stack + + if (filepath && stack?.includes(filepath)) { + stack = stack.split(/\n\s+/).findLast(s => s.includes(filepath)) + + if (stack) { + const hangingOp = { + type, + stack, + taskId: task?.id || relative(this.config.root, filepath), + } + + this.hangingOps.set(id, hangingOp) + } + } + }, + destroy: id => this.hangingOps.delete(id), + }) + + onBeforeRunFiles() { + super.onBeforeRunFiles() + this.asyncHook.enable() + } + + onAfterRunFiles() { + rpc().detectAsyncLeaks(Array.from(this.hangingOps.values())) + this.asyncHook.disable() + super.onAfterRunFiles() + } +} diff --git a/packages/vitest/src/types/config.ts b/packages/vitest/src/types/config.ts index f75c660558c1..c250f45431e3 100644 --- a/packages/vitest/src/types/config.ts +++ b/packages/vitest/src/types/config.ts @@ -824,6 +824,12 @@ export interface UserConfig extends InlineConfig { * Override vite config's clearScreen from cli */ clearScreen?: boolean + + /** + * Detect async leaks in the test suite. This will slow down the test suite, use only in node environment. + * @default false + */ + detectAsyncLeaks?: boolean } export interface ResolvedConfig extends Omit, 'config' | 'filters' | 'browser' | 'coverage' | 'testNamePattern' | 'related' | 'api' | 'reporters' | 'resolveSnapshotPath' | 'benchmark' | 'shard' | 'cache' | 'sequence' | 'typecheck' | 'runner' | 'poolOptions' | 'pool' | 'cliExclude'> { diff --git a/packages/vitest/src/types/rpc.ts b/packages/vitest/src/types/rpc.ts index 3209c4a09695..0ea0376ba4dd 100644 --- a/packages/vitest/src/types/rpc.ts +++ b/packages/vitest/src/types/rpc.ts @@ -1,5 +1,6 @@ import type { FetchResult, RawSourceMap, ViteNodeResolveId } from 'vite-node' import type { CancelReason } from '@vitest/runner' +import type { HangingOps } from '../runtime/runners/with-async-leaks-detecter' import type { EnvironmentOptions, Pool, ResolvedConfig, VitestEnvironment } from './config' import type { Environment, UserConsoleLog } from './general' import type { SnapshotResult } from './snapshot' @@ -26,6 +27,8 @@ export interface RuntimeRPC { snapshotSaved: (snapshot: SnapshotResult) => void resolveSnapshotPath: (testPath: string) => string + + detectAsyncLeaks: (ops: HangingOps[]) => void } export interface RunnerRPC { From 1d5cc35176f9e91413b33f615d2bd633d73d3672 Mon Sep 17 00:00:00 2001 From: Han Feng Date: Sun, 17 Mar 2024 21:47:04 +0800 Subject: [PATCH 2/7] test: add tests --- .../detect-async-leaks/promise.test.ts | 21 ++++++ .../detect-async-leaks/setInterval.test.ts | 21 ++++++ .../detect-async-leaks/timeout.test.ts | 21 ++++++ .../detect-async-leaks/vitest.config.ts | 3 + test/cli/test/detect-async-leaks.test.ts | 66 +++++++++++++++++++ test/core/test/cli-test.test.ts | 6 ++ 6 files changed, 138 insertions(+) create mode 100644 test/cli/fixtures/detect-async-leaks/promise.test.ts create mode 100644 test/cli/fixtures/detect-async-leaks/setInterval.test.ts create mode 100644 test/cli/fixtures/detect-async-leaks/timeout.test.ts create mode 100644 test/cli/fixtures/detect-async-leaks/vitest.config.ts create mode 100644 test/cli/test/detect-async-leaks.test.ts diff --git a/test/cli/fixtures/detect-async-leaks/promise.test.ts b/test/cli/fixtures/detect-async-leaks/promise.test.ts new file mode 100644 index 000000000000..dfc176e34215 --- /dev/null +++ b/test/cli/fixtures/detect-async-leaks/promise.test.ts @@ -0,0 +1,21 @@ +import { afterAll, beforeAll, suite, test } from "vitest"; + +beforeAll(() => { + new Promise(() => {}) +}) + +afterAll(() => { + new Promise(() => {}) +}) + +suite('suite 1', () => { + test('hanging ops 1', () => { + new Promise(() => {}) + }) + + suite('suite 2', () => { + test('hanging ops 2', () => { + new Promise(() => {}) + }) + }) +}) diff --git a/test/cli/fixtures/detect-async-leaks/setInterval.test.ts b/test/cli/fixtures/detect-async-leaks/setInterval.test.ts new file mode 100644 index 000000000000..4a4199323776 --- /dev/null +++ b/test/cli/fixtures/detect-async-leaks/setInterval.test.ts @@ -0,0 +1,21 @@ +import { afterAll, beforeAll, suite, test } from "vitest"; + +beforeAll(() => { + setInterval(() => {}, 1000) +}) + +afterAll(() => { + setInterval(() => {}, 1000) +}) + +suite('suite 1', () => { + test('hanging ops 1', () => { + setInterval(() => {}, 1000) + }) + + suite('suite 2', () => { + test('hanging ops 2', () => { + setInterval(() => {}, 1000) + }) + }) +}) diff --git a/test/cli/fixtures/detect-async-leaks/timeout.test.ts b/test/cli/fixtures/detect-async-leaks/timeout.test.ts new file mode 100644 index 000000000000..4e9354a52a00 --- /dev/null +++ b/test/cli/fixtures/detect-async-leaks/timeout.test.ts @@ -0,0 +1,21 @@ +import { afterAll, beforeAll, suite, test } from "vitest"; + +beforeAll(() => { + setTimeout(() => {}, 1000) +}) + +afterAll(() => { + setTimeout(() => {}, 1000) +}) + +suite('suite 1', () => { + test('hanging ops 1', () => { + setTimeout(() => {}, 1000) + }) + + suite('suite 2', () => { + test('hanging ops 2', () => { + setTimeout(() => {}, 1000) + }) + }) +}) diff --git a/test/cli/fixtures/detect-async-leaks/vitest.config.ts b/test/cli/fixtures/detect-async-leaks/vitest.config.ts new file mode 100644 index 000000000000..abed6b2116e1 --- /dev/null +++ b/test/cli/fixtures/detect-async-leaks/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({}) diff --git a/test/cli/test/detect-async-leaks.test.ts b/test/cli/test/detect-async-leaks.test.ts new file mode 100644 index 000000000000..9191b433bad3 --- /dev/null +++ b/test/cli/test/detect-async-leaks.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from 'vitest' +import { runVitestCli } from '../../test-utils' + +test('should detect hanging operations', async () => { + let { stdout, stderr } = await runVitestCli( + 'run', + '--root', + 'fixtures/detect-async-leaks', + '--detectAsyncLeaks', + ) + + expect(stderr).toBeFalsy() + expect(stdout).toBeTruthy() + expect(stdout).contain('⎯⎯⎯⎯⎯ Hanging Operations ⎯⎯⎯⎯⎯') + expect(stdout).contain('Vitest has detected the following 12 hanging operations potentially keeping Vitest from exiting:') + + stdout = stdout.replaceAll(process.cwd(), '') + + const intervals = [ + `Timeout | setInterval.test.ts + at /fixtures/detect-async-leaks/setInterval.test.ts:4:3`, + + `Timeout | setInterval.test.ts > suite 1 > suite 2 > hanging ops 2 + at /fixtures/detect-async-leaks/setInterval.test.ts:18:7`, + + `Timeout | setInterval.test.ts > suite 1 > hanging ops 1 + at /fixtures/detect-async-leaks/setInterval.test.ts:13:5`, + + `Timeout | setInterval.test.ts + at /fixtures/detect-async-leaks/setInterval.test.ts:8:3`, + ] + + intervals.forEach(interval => expect(stdout).toContain(interval)) + + const timeouts = [ + `Timeout | timeout.test.ts + at /fixtures/detect-async-leaks/timeout.test.ts:4:3`, + + `Timeout | timeout.test.ts > suite 1 > suite 2 > hanging ops 2 + at /fixtures/detect-async-leaks/timeout.test.ts:18:7`, + + `Timeout | timeout.test.ts > suite 1 > hanging ops 1 + at /fixtures/detect-async-leaks/timeout.test.ts:13:5`, + + `Timeout | timeout.test.ts + at /fixtures/detect-async-leaks/timeout.test.ts:8:3`, + ] + + timeouts.forEach(timeout => expect(stdout).toContain(timeout)) + + const promises = [ + `PROMISE | promise.test.ts + at /fixtures/detect-async-leaks/promise.test.ts:4:3`, + + `PROMISE | promise.test.ts > suite 1 > suite 2 > hanging ops 2 + at /fixtures/detect-async-leaks/promise.test.ts:18:7`, + + `PROMISE | promise.test.ts > suite 1 > hanging ops 1 + at /fixtures/detect-async-leaks/promise.test.ts:13:5`, + + `PROMISE | promise.test.ts + at /fixtures/detect-async-leaks/promise.test.ts:8:3`, + ] + + promises.forEach(promise => expect(stdout).toContain(promise)) +}) diff --git a/test/core/test/cli-test.test.ts b/test/core/test/cli-test.test.ts index 1d4a3ce636cf..ae9ae27ef195 100644 --- a/test/core/test/cli-test.test.ts +++ b/test/core/test/cli-test.test.ts @@ -292,6 +292,12 @@ test('clearScreen', async () => { `) }) +test('detectAsyncLeaks', async () => { + expect(getCLIOptions('--detectAsyncLeaks')).toEqual({ detectAsyncLeaks: true }) + expect(getCLIOptions('--detectAsyncLeaks=true')).toEqual({ detectAsyncLeaks: true }) + expect(getCLIOptions('--detectAsyncLeaks=false')).toEqual({ detectAsyncLeaks: false }) +}) + test('public parseCLI works correctly', () => { expect(parseCLI('vitest dev')).toEqual({ filter: [], From 7e74fa929bed9e086e8541ca1c065c96efa423c6 Mon Sep 17 00:00:00 2001 From: Han Feng Date: Sun, 17 Mar 2024 21:52:10 +0800 Subject: [PATCH 3/7] test: promise detection is not stable --- test/cli/test/detect-async-leaks.test.ts | 57 ++++++++++++------------ 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/test/cli/test/detect-async-leaks.test.ts b/test/cli/test/detect-async-leaks.test.ts index 9191b433bad3..83f75e6da4b7 100644 --- a/test/cli/test/detect-async-leaks.test.ts +++ b/test/cli/test/detect-async-leaks.test.ts @@ -12,55 +12,56 @@ test('should detect hanging operations', async () => { expect(stderr).toBeFalsy() expect(stdout).toBeTruthy() expect(stdout).contain('⎯⎯⎯⎯⎯ Hanging Operations ⎯⎯⎯⎯⎯') - expect(stdout).contain('Vitest has detected the following 12 hanging operations potentially keeping Vitest from exiting:') + // expect(stdout).contain('Vitest has detected the following 12 hanging operations potentially keeping Vitest from exiting:') stdout = stdout.replaceAll(process.cwd(), '') const intervals = [ - `Timeout | setInterval.test.ts - at /fixtures/detect-async-leaks/setInterval.test.ts:4:3`, + `Timeout | setInterval.test.ts`, + `at /fixtures/detect-async-leaks/setInterval.test.ts:4:3`, - `Timeout | setInterval.test.ts > suite 1 > suite 2 > hanging ops 2 - at /fixtures/detect-async-leaks/setInterval.test.ts:18:7`, + `Timeout | setInterval.test.ts > suite 1 > suite 2 > hanging ops 2`, + `at /fixtures/detect-async-leaks/setInterval.test.ts:18:7`, - `Timeout | setInterval.test.ts > suite 1 > hanging ops 1 - at /fixtures/detect-async-leaks/setInterval.test.ts:13:5`, + `Timeout | setInterval.test.ts > suite 1 > hanging ops 1`, + `at /fixtures/detect-async-leaks/setInterval.test.ts:13:5`, - `Timeout | setInterval.test.ts - at /fixtures/detect-async-leaks/setInterval.test.ts:8:3`, + `Timeout | setInterval.test.ts`, + `at /fixtures/detect-async-leaks/setInterval.test.ts:8:3`, ] intervals.forEach(interval => expect(stdout).toContain(interval)) const timeouts = [ - `Timeout | timeout.test.ts - at /fixtures/detect-async-leaks/timeout.test.ts:4:3`, + `Timeout | timeout.test.ts`, + `at /fixtures/detect-async-leaks/timeout.test.ts:4:3`, - `Timeout | timeout.test.ts > suite 1 > suite 2 > hanging ops 2 - at /fixtures/detect-async-leaks/timeout.test.ts:18:7`, + `Timeout | timeout.test.ts > suite 1 > suite 2 > hanging ops 2`, + `at /fixtures/detect-async-leaks/timeout.test.ts:18:7`, - `Timeout | timeout.test.ts > suite 1 > hanging ops 1 - at /fixtures/detect-async-leaks/timeout.test.ts:13:5`, + `Timeout | timeout.test.ts > suite 1 > hanging ops 1`, + `at /fixtures/detect-async-leaks/timeout.test.ts:13:5`, - `Timeout | timeout.test.ts - at /fixtures/detect-async-leaks/timeout.test.ts:8:3`, + `Timeout | timeout.test.ts`, + `at /fixtures/detect-async-leaks/timeout.test.ts:8:3`, ] timeouts.forEach(timeout => expect(stdout).toContain(timeout)) - const promises = [ - `PROMISE | promise.test.ts - at /fixtures/detect-async-leaks/promise.test.ts:4:3`, + // promise test is not stable + // const promises = [ + // `PROMISE | promise.test.ts + // at /fixtures/detect-async-leaks/promise.test.ts:4:3`, - `PROMISE | promise.test.ts > suite 1 > suite 2 > hanging ops 2 - at /fixtures/detect-async-leaks/promise.test.ts:18:7`, + // `PROMISE | promise.test.ts > suite 1 > suite 2 > hanging ops 2 + // at /fixtures/detect-async-leaks/promise.test.ts:18:7`, - `PROMISE | promise.test.ts > suite 1 > hanging ops 1 - at /fixtures/detect-async-leaks/promise.test.ts:13:5`, + // `PROMISE | promise.test.ts > suite 1 > hanging ops 1 + // at /fixtures/detect-async-leaks/promise.test.ts:13:5`, - `PROMISE | promise.test.ts - at /fixtures/detect-async-leaks/promise.test.ts:8:3`, - ] + // `PROMISE | promise.test.ts + // at /fixtures/detect-async-leaks/promise.test.ts:8:3`, + // ] - promises.forEach(promise => expect(stdout).toContain(promise)) + // promises.forEach(promise => expect(stdout).toContain(promise)) }) From 9ee5a30666094d0f1097da2607da96d5c1ae427b Mon Sep 17 00:00:00 2001 From: Han Feng Date: Thu, 21 Mar 2024 15:37:16 +0800 Subject: [PATCH 4/7] chore: handle various async resources --- packages/vitest/src/node/logger.ts | 6 +- packages/vitest/src/runtime/runners/index.ts | 2 +- .../runners/with-async-leaks-detecter.ts | 74 +++++++++++++++---- 3 files changed, 63 insertions(+), 19 deletions(-) diff --git a/packages/vitest/src/node/logger.ts b/packages/vitest/src/node/logger.ts index 57cab16fa7c6..08b989f46e2e 100644 --- a/packages/vitest/src/node/logger.ts +++ b/packages/vitest/src/node/logger.ts @@ -223,10 +223,10 @@ export class Logger { this.log(c.yellow(divider(c.bold(c.inverse(' Hanging Operations '))))) this.log(errorMessage) - hangingOps.forEach(({ type, taskId, stack }) => { + hangingOps.forEach(({ error, taskId }) => { const task = taskId && this.ctx.state.idMap.get(taskId) - this.log(type + c.dim(` | ${task ? getFullName(task) : taskId}`)) - this.log(`${c.gray(c.dim(`${stack}`))}\n`) + this.log(error.message + c.dim(` | ${task ? getFullName(task) : taskId}`)) + this.log(`${c.gray(c.dim(`${error.stack}`))}\n`) }) this.log(c.yellow(divider())) diff --git a/packages/vitest/src/runtime/runners/index.ts b/packages/vitest/src/runtime/runners/index.ts index c54c002e3cb1..b0b4f2b88efa 100644 --- a/packages/vitest/src/runtime/runners/index.ts +++ b/packages/vitest/src/runtime/runners/index.ts @@ -16,7 +16,7 @@ async function getTestRunnerConstructor(config: ResolvedConfig, executor: Vitest if (config.detectAsyncLeaks) { if (config.browser?.enabled) - throw new Error('Async leaks detection is not supported in browser mode.') + throw new Error('"--detectAsyncLeaks" flag is not supported in browser mode.') return VitestTestRunnerWithAsyncLeaksDetecter as VitestRunnerConstructor } diff --git a/packages/vitest/src/runtime/runners/with-async-leaks-detecter.ts b/packages/vitest/src/runtime/runners/with-async-leaks-detecter.ts index fa79d9a3bde9..a745628dd8c5 100644 --- a/packages/vitest/src/runtime/runners/with-async-leaks-detecter.ts +++ b/packages/vitest/src/runtime/runners/with-async-leaks-detecter.ts @@ -1,39 +1,78 @@ import asyncHooks from 'node:async_hooks' +import { promisify } from 'node:util' import { relative } from 'pathe' import { rpc } from '../rpc' import { VitestTestRunner } from './test' export interface HangingOps { - type: string - stack: string + error: Error taskId?: string } +const asyncSleep = promisify(setTimeout) + export class VitestTestRunnerWithAsyncLeaksDetecter extends VitestTestRunner { private hangingOps: Map = new Map() private asyncHook: asyncHooks.AsyncHook = asyncHooks.createHook({ - init: (id, type) => { + init: (asyncId, type, triggerAsyncId) => { + // Ignore some async resources + if ( + [ + 'PROMISE', + 'TIMERWRAP', + 'ELDHISTOGRAM', + 'PerformanceObserver', + 'RANDOMBYTESREQUEST', + 'DNSCHANNEL', + 'ZLIB', + 'SIGNREQUEST', + 'TLSWRAP', + 'TCPWRAP', + ].includes(type) + ) + return + const task = this.workerState.current const filepath = task?.file?.filepath || this.workerState.filepath + if (!filepath) + return - let stack = new Error('STACK_TRACE_ERROR').stack + const { stackTraceLimit } = Error + Error.stackTraceLimit = Math.max(100, stackTraceLimit) + const error = new Error(type) - if (filepath && stack?.includes(filepath)) { - stack = stack.split(/\n\s+/).findLast(s => s.includes(filepath)) + let fromUser = error.stack?.includes(filepath) + let directlyTriggered = true - if (stack) { - const hangingOp = { - type, - stack, - taskId: task?.id || relative(this.config.root, filepath), - } + if (!fromUser) { + // Check if the async resource is indirectly triggered by user code + const trigger = this.hangingOps.get(triggerAsyncId) + if (trigger) { + fromUser = true + directlyTriggered = false + error.stack = trigger.error.stack + } + } - this.hangingOps.set(id, hangingOp) + if (fromUser) { + const relativePath = relative(this.config.root, filepath) + if (directlyTriggered) { + error.stack = error.stack + ?.split(/\n\s+/) + .findLast(s => s.includes(filepath)) + ?.replace(filepath, relativePath) } + + this.hangingOps.set(asyncId, { + error, + taskId: task?.id || relativePath, + }) } }, - destroy: id => this.hangingOps.delete(id), + destroy: (asyncId) => { + this.hangingOps.delete(asyncId) + }, }) onBeforeRunFiles() { @@ -41,7 +80,12 @@ export class VitestTestRunnerWithAsyncLeaksDetecter extends VitestTestRunner { this.asyncHook.enable() } - onAfterRunFiles() { + async onAfterRunFiles() { + // Wait for async resources to be destroyed + await asyncSleep(0) + if (this.hangingOps.size > 0) + await asyncSleep(0) + rpc().detectAsyncLeaks(Array.from(this.hangingOps.values())) this.asyncHook.disable() super.onAfterRunFiles() From 5cb8300f0b9427141ec832a9701b8cf9537460fa Mon Sep 17 00:00:00 2001 From: Han Feng Date: Thu, 21 Mar 2024 15:40:58 +0800 Subject: [PATCH 5/7] test: update --- .../detect-async-leaks/edge-cases.test.ts | 25 ++++++++ .../detect-async-leaks/promise.test.ts | 21 ------- .../detect-async-leaks/setInterval.test.ts | 21 ------- .../detect-async-leaks/timeout.test.ts | 8 +-- .../detect-async-leaks/vitest.config.ts | 12 +++- .../detect-async-leaks.test.ts.snap | 24 +++++++ test/cli/test/detect-async-leaks.test.ts | 63 +++---------------- 7 files changed, 71 insertions(+), 103 deletions(-) create mode 100644 test/cli/fixtures/detect-async-leaks/edge-cases.test.ts delete mode 100644 test/cli/fixtures/detect-async-leaks/promise.test.ts delete mode 100644 test/cli/fixtures/detect-async-leaks/setInterval.test.ts create mode 100644 test/cli/test/__snapshots__/detect-async-leaks.test.ts.snap diff --git a/test/cli/fixtures/detect-async-leaks/edge-cases.test.ts b/test/cli/fixtures/detect-async-leaks/edge-cases.test.ts new file mode 100644 index 000000000000..f202635d2a40 --- /dev/null +++ b/test/cli/fixtures/detect-async-leaks/edge-cases.test.ts @@ -0,0 +1,25 @@ +import http from 'node:http' +import { TLSSocket } from 'node:tls' +import { suite, test } from 'vitest' + +suite('should collect', () => { + test.todo('handles indirectly triggered by user code', async () => { + // const server = new http.Server() + // await new Promise(r => server.listen({ host: 'localhost', port: 0 }, r)) + // await new Promise(r => server.close(r)) + }) +}) + +suite('should not collect', () => { + test('handles that have been queued to close', async () => { + const server = http.createServer((_, response) => response.end('ok')) + // @ts-expect-error let me go + await new Promise(r => server.listen(0, r)) + await new Promise(r => server.close(r)) + }) + test('some special objects such as `TLSWRAP`', async () => { + // @ts-expect-error let me go + const socket = new TLSSocket() + socket.destroy() + }) +}) diff --git a/test/cli/fixtures/detect-async-leaks/promise.test.ts b/test/cli/fixtures/detect-async-leaks/promise.test.ts deleted file mode 100644 index dfc176e34215..000000000000 --- a/test/cli/fixtures/detect-async-leaks/promise.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { afterAll, beforeAll, suite, test } from "vitest"; - -beforeAll(() => { - new Promise(() => {}) -}) - -afterAll(() => { - new Promise(() => {}) -}) - -suite('suite 1', () => { - test('hanging ops 1', () => { - new Promise(() => {}) - }) - - suite('suite 2', () => { - test('hanging ops 2', () => { - new Promise(() => {}) - }) - }) -}) diff --git a/test/cli/fixtures/detect-async-leaks/setInterval.test.ts b/test/cli/fixtures/detect-async-leaks/setInterval.test.ts deleted file mode 100644 index 4a4199323776..000000000000 --- a/test/cli/fixtures/detect-async-leaks/setInterval.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { afterAll, beforeAll, suite, test } from "vitest"; - -beforeAll(() => { - setInterval(() => {}, 1000) -}) - -afterAll(() => { - setInterval(() => {}, 1000) -}) - -suite('suite 1', () => { - test('hanging ops 1', () => { - setInterval(() => {}, 1000) - }) - - suite('suite 2', () => { - test('hanging ops 2', () => { - setInterval(() => {}, 1000) - }) - }) -}) diff --git a/test/cli/fixtures/detect-async-leaks/timeout.test.ts b/test/cli/fixtures/detect-async-leaks/timeout.test.ts index 4e9354a52a00..ba705a675a5a 100644 --- a/test/cli/fixtures/detect-async-leaks/timeout.test.ts +++ b/test/cli/fixtures/detect-async-leaks/timeout.test.ts @@ -9,13 +9,13 @@ afterAll(() => { }) suite('suite 1', () => { - test('hanging ops 1', () => { - setTimeout(() => {}, 1000) + test('test 1', () => { + setInterval(() => {}, 1000) }) suite('suite 2', () => { - test('hanging ops 2', () => { - setTimeout(() => {}, 1000) + test('test 2', () => { + setInterval(() => {}, 1000) }) }) }) diff --git a/test/cli/fixtures/detect-async-leaks/vitest.config.ts b/test/cli/fixtures/detect-async-leaks/vitest.config.ts index abed6b2116e1..4a4f5cfe5563 100644 --- a/test/cli/fixtures/detect-async-leaks/vitest.config.ts +++ b/test/cli/fixtures/detect-async-leaks/vitest.config.ts @@ -1,3 +1,13 @@ import { defineConfig } from 'vitest/config' +import { BaseReporter } from '../../../../packages/vitest/src/node/reporters/base' -export default defineConfig({}) +class MyReporter extends BaseReporter { + onInit(): void {} + async onFinished() {} +} + +export default defineConfig({ + test: { + reporters: new MyReporter() + } +}) diff --git a/test/cli/test/__snapshots__/detect-async-leaks.test.ts.snap b/test/cli/test/__snapshots__/detect-async-leaks.test.ts.snap new file mode 100644 index 000000000000..ee87d7f727c1 --- /dev/null +++ b/test/cli/test/__snapshots__/detect-async-leaks.test.ts.snap @@ -0,0 +1,24 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`should detect hanging operations - fixtures/detect-async-leaks/edge-cases.test.ts 1`] = `""`; + +exports[`should detect hanging operations - fixtures/detect-async-leaks/timeout.test.ts 1`] = ` +"⎯⎯⎯⎯⎯ Hanging Operations ⎯⎯⎯⎯⎯ + +Vitest has detected the following 4 hanging operations potentially keeping Vitest from exiting: + +Timeout | timeout.test.ts +at timeout.test.ts:4:3 + +Timeout | timeout.test.ts > suite 1 > test 1 +at timeout.test.ts:13:5 + +Timeout | timeout.test.ts > suite 1 > suite 2 > test 2 +at timeout.test.ts:18:7 + +Timeout | timeout.test.ts +at timeout.test.ts:8:3 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ +" +`; diff --git a/test/cli/test/detect-async-leaks.test.ts b/test/cli/test/detect-async-leaks.test.ts index 83f75e6da4b7..37edc3984e17 100644 --- a/test/cli/test/detect-async-leaks.test.ts +++ b/test/cli/test/detect-async-leaks.test.ts @@ -1,67 +1,18 @@ import { expect, test } from 'vitest' +import { glob } from 'fast-glob' import { runVitestCli } from '../../test-utils' -test('should detect hanging operations', async () => { - let { stdout, stderr } = await runVitestCli( +const files = glob.sync('fixtures/detect-async-leaks/*.test.ts') + +test.each(files)('should detect hanging operations - %s', async (file) => { + const { stdout, stderr } = await runVitestCli( 'run', '--root', 'fixtures/detect-async-leaks', '--detectAsyncLeaks', + file, ) expect(stderr).toBeFalsy() - expect(stdout).toBeTruthy() - expect(stdout).contain('⎯⎯⎯⎯⎯ Hanging Operations ⎯⎯⎯⎯⎯') - // expect(stdout).contain('Vitest has detected the following 12 hanging operations potentially keeping Vitest from exiting:') - - stdout = stdout.replaceAll(process.cwd(), '') - - const intervals = [ - `Timeout | setInterval.test.ts`, - `at /fixtures/detect-async-leaks/setInterval.test.ts:4:3`, - - `Timeout | setInterval.test.ts > suite 1 > suite 2 > hanging ops 2`, - `at /fixtures/detect-async-leaks/setInterval.test.ts:18:7`, - - `Timeout | setInterval.test.ts > suite 1 > hanging ops 1`, - `at /fixtures/detect-async-leaks/setInterval.test.ts:13:5`, - - `Timeout | setInterval.test.ts`, - `at /fixtures/detect-async-leaks/setInterval.test.ts:8:3`, - ] - - intervals.forEach(interval => expect(stdout).toContain(interval)) - - const timeouts = [ - `Timeout | timeout.test.ts`, - `at /fixtures/detect-async-leaks/timeout.test.ts:4:3`, - - `Timeout | timeout.test.ts > suite 1 > suite 2 > hanging ops 2`, - `at /fixtures/detect-async-leaks/timeout.test.ts:18:7`, - - `Timeout | timeout.test.ts > suite 1 > hanging ops 1`, - `at /fixtures/detect-async-leaks/timeout.test.ts:13:5`, - - `Timeout | timeout.test.ts`, - `at /fixtures/detect-async-leaks/timeout.test.ts:8:3`, - ] - - timeouts.forEach(timeout => expect(stdout).toContain(timeout)) - - // promise test is not stable - // const promises = [ - // `PROMISE | promise.test.ts - // at /fixtures/detect-async-leaks/promise.test.ts:4:3`, - - // `PROMISE | promise.test.ts > suite 1 > suite 2 > hanging ops 2 - // at /fixtures/detect-async-leaks/promise.test.ts:18:7`, - - // `PROMISE | promise.test.ts > suite 1 > hanging ops 1 - // at /fixtures/detect-async-leaks/promise.test.ts:13:5`, - - // `PROMISE | promise.test.ts - // at /fixtures/detect-async-leaks/promise.test.ts:8:3`, - // ] - - // promises.forEach(promise => expect(stdout).toContain(promise)) + expect(stdout).toMatchSnapshot() }) From 09082cb32b4a330394c8aa0a94221253731c9119 Mon Sep 17 00:00:00 2001 From: Han Feng Date: Thu, 21 Mar 2024 17:19:05 +0800 Subject: [PATCH 6/7] fix: resolve test runner --- packages/vitest/rollup.config.js | 1 + packages/vitest/src/node/core.ts | 2 +- packages/vitest/src/node/logger.ts | 2 +- packages/vitest/src/runners.ts | 1 - packages/vitest/src/runtime/runners/index.ts | 10 ++++++---- .../detect-async-leaks.ts} | 6 +++--- packages/vitest/src/runtime/runners/node/index.ts | 1 + packages/vitest/src/types/rpc.ts | 2 +- 8 files changed, 14 insertions(+), 11 deletions(-) rename packages/vitest/src/runtime/runners/{with-async-leaks-detecter.ts => node/detect-async-leaks.ts} (93%) create mode 100644 packages/vitest/src/runtime/runners/node/index.ts diff --git a/packages/vitest/rollup.config.js b/packages/vitest/rollup.config.js index c5542c49a153..019c4d559a4e 100644 --- a/packages/vitest/rollup.config.js +++ b/packages/vitest/rollup.config.js @@ -24,6 +24,7 @@ const entries = { 'suite': 'src/suite.ts', 'browser': 'src/browser.ts', 'runners': 'src/runners.ts', + 'runners-node': 'src/runtime/runners/node/index.ts', 'environments': 'src/environments.ts', 'spy': 'src/integrations/spy.ts', 'coverage': 'src/coverage.ts', diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index bf6707bcb117..a252cf05275c 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -16,7 +16,7 @@ import { getCoverageProvider } from '../integrations/coverage' import { CONFIG_NAMES, configFiles, workspacesFiles as workspaceFiles } from '../constants' import { rootDir } from '../paths' import { WebSocketReporter } from '../api/setup' -import type { HangingOps } from '../runtime/runners/with-async-leaks-detecter' +import type { HangingOps } from '../runtime/runners/node/detect-async-leaks' import { createPool } from './pool' import type { ProcessPool, WorkspaceSpec } from './pool' import { createBenchmarkReporters, createReporters } from './reporters/utils' diff --git a/packages/vitest/src/node/logger.ts b/packages/vitest/src/node/logger.ts index 08b989f46e2e..0a88c5bc7a0b 100644 --- a/packages/vitest/src/node/logger.ts +++ b/packages/vitest/src/node/logger.ts @@ -5,7 +5,7 @@ import type { ErrorWithDiff } from '../types' import type { TypeCheckError } from '../typecheck/typechecker' import { getFullName, toArray } from '../utils' import { highlightCode } from '../utils/colors' -import type { HangingOps } from '../runtime/runners/with-async-leaks-detecter' +import type { HangingOps } from '../runtime/runners/node/detect-async-leaks' import { divider } from './reporters/renderers/utils' import { RandomSequencer } from './sequencers/RandomSequencer' import type { Vitest } from './core' diff --git a/packages/vitest/src/runners.ts b/packages/vitest/src/runners.ts index cba9d6a22397..ef5804e48c33 100644 --- a/packages/vitest/src/runners.ts +++ b/packages/vitest/src/runners.ts @@ -1,3 +1,2 @@ export { VitestTestRunner } from './runtime/runners/test' export { NodeBenchmarkRunner } from './runtime/runners/benchmark' -export { VitestTestRunnerWithAsyncLeaksDetecter } from './runtime/runners/with-async-leaks-detecter' diff --git a/packages/vitest/src/runtime/runners/index.ts b/packages/vitest/src/runtime/runners/index.ts index b0b4f2b88efa..ac97f516d29c 100644 --- a/packages/vitest/src/runtime/runners/index.ts +++ b/packages/vitest/src/runtime/runners/index.ts @@ -8,18 +8,20 @@ import { rpc } from '../rpc' import { takeCoverageInsideWorker } from '../../integrations/coverage' import { loadDiffConfig, loadSnapshotSerializers } from '../setup-common' -const runnersFile = resolve(distDir, 'runners.js') +const commonRunnersFile = resolve(distDir, 'runners.js') +const nodeRunnersFile = resolve(distDir, 'runners-node.js') async function getTestRunnerConstructor(config: ResolvedConfig, executor: VitestExecutor): Promise { if (!config.runner) { - const { VitestTestRunner, NodeBenchmarkRunner, VitestTestRunnerWithAsyncLeaksDetecter } = await executor.executeFile(runnersFile) - if (config.detectAsyncLeaks) { if (config.browser?.enabled) throw new Error('"--detectAsyncLeaks" flag is not supported in browser mode.') - return VitestTestRunnerWithAsyncLeaksDetecter as VitestRunnerConstructor + const { WithAsyncLeaksDetecter } = await executor.executeFile(nodeRunnersFile) + return WithAsyncLeaksDetecter as VitestRunnerConstructor } + const { VitestTestRunner, NodeBenchmarkRunner } = await executor.executeFile(commonRunnersFile) + return (config.mode === 'test' ? VitestTestRunner : NodeBenchmarkRunner) as VitestRunnerConstructor } const mod = await executor.executeId(config.runner) diff --git a/packages/vitest/src/runtime/runners/with-async-leaks-detecter.ts b/packages/vitest/src/runtime/runners/node/detect-async-leaks.ts similarity index 93% rename from packages/vitest/src/runtime/runners/with-async-leaks-detecter.ts rename to packages/vitest/src/runtime/runners/node/detect-async-leaks.ts index a745628dd8c5..e0710448ae26 100644 --- a/packages/vitest/src/runtime/runners/with-async-leaks-detecter.ts +++ b/packages/vitest/src/runtime/runners/node/detect-async-leaks.ts @@ -1,8 +1,8 @@ import asyncHooks from 'node:async_hooks' import { promisify } from 'node:util' import { relative } from 'pathe' -import { rpc } from '../rpc' -import { VitestTestRunner } from './test' +import { rpc } from '../../rpc' +import { VitestTestRunner } from '../test' export interface HangingOps { error: Error @@ -11,7 +11,7 @@ export interface HangingOps { const asyncSleep = promisify(setTimeout) -export class VitestTestRunnerWithAsyncLeaksDetecter extends VitestTestRunner { +export class WithAsyncLeaksDetecter extends VitestTestRunner { private hangingOps: Map = new Map() private asyncHook: asyncHooks.AsyncHook = asyncHooks.createHook({ diff --git a/packages/vitest/src/runtime/runners/node/index.ts b/packages/vitest/src/runtime/runners/node/index.ts new file mode 100644 index 000000000000..b476898cbaa7 --- /dev/null +++ b/packages/vitest/src/runtime/runners/node/index.ts @@ -0,0 +1 @@ +export { WithAsyncLeaksDetecter } from './detect-async-leaks' diff --git a/packages/vitest/src/types/rpc.ts b/packages/vitest/src/types/rpc.ts index 0ea0376ba4dd..4c112f3c029e 100644 --- a/packages/vitest/src/types/rpc.ts +++ b/packages/vitest/src/types/rpc.ts @@ -1,6 +1,6 @@ import type { FetchResult, RawSourceMap, ViteNodeResolveId } from 'vite-node' import type { CancelReason } from '@vitest/runner' -import type { HangingOps } from '../runtime/runners/with-async-leaks-detecter' +import type { HangingOps } from '../runtime/runners/node/detect-async-leaks' import type { EnvironmentOptions, Pool, ResolvedConfig, VitestEnvironment } from './config' import type { Environment, UserConsoleLog } from './general' import type { SnapshotResult } from './snapshot' From a10ec1c5fcc3d7ab3cb273f1e23bb72b1c8455d7 Mon Sep 17 00:00:00 2001 From: Han Feng Date: Thu, 21 Mar 2024 17:50:50 +0800 Subject: [PATCH 7/7] test: update --- test/cli/test/detect-async-leaks.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/cli/test/detect-async-leaks.test.ts b/test/cli/test/detect-async-leaks.test.ts index 37edc3984e17..1eaa4f650fc2 100644 --- a/test/cli/test/detect-async-leaks.test.ts +++ b/test/cli/test/detect-async-leaks.test.ts @@ -5,7 +5,7 @@ import { runVitestCli } from '../../test-utils' const files = glob.sync('fixtures/detect-async-leaks/*.test.ts') test.each(files)('should detect hanging operations - %s', async (file) => { - const { stdout, stderr } = await runVitestCli( + const { stdout } = await runVitestCli( 'run', '--root', 'fixtures/detect-async-leaks', @@ -13,6 +13,5 @@ test.each(files)('should detect hanging operations - %s', async (file) => { file, ) - expect(stderr).toBeFalsy() expect(stdout).toMatchSnapshot() })