Skip to content

Commit fb95fc7

Browse files
authored
fix(pool): capture workers stdio to logger (#8809)
1 parent 7c8255f commit fb95fc7

File tree

7 files changed

+92
-24
lines changed

7 files changed

+92
-24
lines changed

packages/vitest/src/node/pools/workers/forksWorker.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ChildProcess } from 'node:child_process'
2+
import type { Writable } from 'node:stream'
23
import type { SerializedConfig } from '../../types/config'
34
import type { PoolOptions, PoolWorker, WorkerRequest } from '../types'
45
import { fork } from 'node:child_process'
@@ -17,10 +18,15 @@ export class ForksPoolWorker implements PoolWorker {
1718
protected env: Partial<NodeJS.ProcessEnv>
1819

1920
private _fork?: ChildProcess
21+
private stdout: NodeJS.WriteStream | Writable
22+
private stderr: NodeJS.WriteStream | Writable
2023

2124
constructor(options: PoolOptions) {
2225
this.execArgv = options.execArgv
2326
this.env = options.env
27+
this.stdout = options.project.vitest.logger.outputStream
28+
this.stderr = options.project.vitest.logger.errorStream
29+
2430
/** Loads {@link file://./../../../runtime/workers/forks.ts} */
2531
this.entrypoint = resolve(options.distPath, 'workers/forks.js')
2632
}
@@ -51,7 +57,18 @@ export class ForksPoolWorker implements PoolWorker {
5157
this._fork ||= fork(this.entrypoint, [], {
5258
env: this.env,
5359
execArgv: this.execArgv,
60+
stdio: 'pipe',
5461
})
62+
63+
if (this._fork.stdout) {
64+
this.stdout.setMaxListeners(1 + this.stdout.getMaxListeners())
65+
this._fork.stdout.pipe(this.stdout)
66+
}
67+
68+
if (this._fork.stderr) {
69+
this.stderr.setMaxListeners(1 + this.stderr.getMaxListeners())
70+
this._fork.stderr.pipe(this.stderr)
71+
}
5572
}
5673

5774
async stop(): Promise<void> {
@@ -80,6 +97,16 @@ export class ForksPoolWorker implements PoolWorker {
8097
await waitForExit
8198
clearTimeout(sigkillTimeout)
8299

100+
if (fork.stdout) {
101+
fork.stdout?.unpipe(this.stdout)
102+
this.stdout.setMaxListeners(this.stdout.getMaxListeners() - 1)
103+
}
104+
105+
if (fork.stderr) {
106+
fork.stderr?.unpipe(this.stderr)
107+
this.stderr.setMaxListeners(this.stderr.getMaxListeners() - 1)
108+
}
109+
83110
this._fork = undefined
84111
}
85112

packages/vitest/src/node/pools/workers/threadsWorker.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Writable } from 'node:stream'
12
import type { PoolOptions, PoolWorker, WorkerRequest } from '../types'
23
import { resolve } from 'node:path'
34
import { Worker } from 'node:worker_threads'
@@ -11,10 +12,15 @@ export class ThreadsPoolWorker implements PoolWorker {
1112
protected env: Partial<NodeJS.ProcessEnv>
1213

1314
private _thread?: Worker
15+
private stdout: NodeJS.WriteStream | Writable
16+
private stderr: NodeJS.WriteStream | Writable
1417

1518
constructor(options: PoolOptions) {
1619
this.execArgv = options.execArgv
1720
this.env = options.env
21+
this.stdout = options.project.vitest.logger.outputStream
22+
this.stderr = options.project.vitest.logger.errorStream
23+
1824
/** Loads {@link file://./../../../runtime/workers/threads.ts} */
1925
this.entrypoint = resolve(options.distPath, 'workers/threads.js')
2026
}
@@ -36,13 +42,27 @@ export class ThreadsPoolWorker implements PoolWorker {
3642
this._thread ||= new Worker(this.entrypoint, {
3743
env: this.env,
3844
execArgv: this.execArgv,
45+
stdout: true,
46+
stderr: true,
3947
})
48+
49+
this.stdout.setMaxListeners(1 + this.stdout.getMaxListeners())
50+
this._thread.stdout.pipe(this.stdout)
51+
52+
this.stderr.setMaxListeners(1 + this.stderr.getMaxListeners())
53+
this._thread.stderr.pipe(this.stderr)
4054
}
4155

4256
async stop(): Promise<void> {
43-
await this.thread.terminate().then(() => {
44-
this._thread = undefined
45-
})
57+
await this.thread.terminate()
58+
59+
this._thread?.stdout?.unpipe(this.stdout)
60+
this.stdout.setMaxListeners(this.stdout.getMaxListeners() - 1)
61+
62+
this._thread?.stderr?.unpipe(this.stderr)
63+
this.stderr.setMaxListeners(this.stderr.getMaxListeners() - 1)
64+
65+
this._thread = undefined
4666
}
4767

4868
deserialize(data: unknown): unknown {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { test } from 'vitest'
2+
import EventEmitter from 'node:events'
3+
4+
test('write to streams', () => {
5+
process.stdout.write('Worker writing to stdout')
6+
process.stderr.write('Worker writing to stderr')
7+
8+
triggerNodeWarning()
9+
})
10+
11+
function triggerNodeWarning() {
12+
const emitter = new TestFixturesCustomEmitter()
13+
emitter.setMaxListeners(2)
14+
emitter.addListener('message', () => {})
15+
emitter.addListener('message', () => {})
16+
emitter.addListener('message', () => {})
17+
}
18+
19+
class TestFixturesCustomEmitter extends EventEmitter {}

test/config/test/console.test.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expect, test, vi } from 'vitest'
1+
import { expect, test } from 'vitest'
22
import { runVitest } from '../../test-utils'
33

44
test('default intercept', async () => {
@@ -9,17 +9,13 @@ test('default intercept', async () => {
99
})
1010

1111
test.each(['threads', 'vmThreads'] as const)(`disable intercept pool=%s`, async (pool) => {
12-
// `disableConsoleIntercept: true` forwards workers console.error to main thread's stderr
13-
const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
14-
15-
await runVitest({
12+
const { stderr } = await runVitest({
1613
root: './fixtures/console',
1714
disableConsoleIntercept: true,
1815
pool,
1916
})
2017

21-
const call = spy.mock.lastCall![0]
22-
expect(call.toString()).toBe('__test_console__\n')
18+
expect(stderr).toBe('__test_console__\n')
2319
})
2420

2521
test('group synchronous console logs', async () => {

test/config/test/node-sqlite.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ test.runIf(nodeMajor >= 22)('import node:sqlite', async () => {
88
'vitest.config.ts': {
99
test: {
1010
pool: 'forks',
11-
execArgv: ['--experimental-sqlite'],
11+
execArgv: ['--experimental-sqlite', '--no-warnings=ExperimentalWarning'],
1212
},
1313
},
1414
'basic.test.ts': ts`

test/config/test/pool.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ describe.each(['forks', 'threads', 'vmThreads', 'vmForks'])('%s', async (pool) =
99

1010
expect(config.pool).toBe(pool)
1111
})
12+
13+
test('can capture worker\'s stdout and stderr', async () => {
14+
const { stdout, stderr } = await runVitest({
15+
root: './fixtures/pool',
16+
include: ['write-to-stdout-and-stderr.test.ts'],
17+
pool,
18+
})
19+
20+
expect(stderr).toContain('Worker writing to stderr')
21+
expect(stdout).toContain('Worker writing to stdout')
22+
expect(stderr).toContain('MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 3 message listeners added to [TestFixturesCustomEmitter]')
23+
})
1224
})
1325

1426
test('extended project inherits top-level pool related options', async () => {

test/coverage-test/vitest.config.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,16 @@ const UNIT_TESTS = 'test/**.unit.test.ts'
88
const BROWSER_TESTS = 'test/**.browser.test.ts'
99
const FIXTURES = '**/fixtures/**'
1010

11-
const config = defineConfig({
12-
test: {
13-
pool: 'threads',
14-
setupFiles: ['./setup.ts'],
15-
},
16-
})
17-
1811
export default defineConfig({
1912
test: {
2013
reporters: 'verbose',
2114
isolate: false,
15+
setupFiles: ['./setup.ts'],
2216
projects: [
2317
// Test cases for v8-provider
2418
{
19+
extends: true,
2520
test: {
26-
...config.test,
2721
name: { label: 'v8', color: 'green' },
2822
env: { COVERAGE_PROVIDER: 'v8' },
2923
include: [GENERIC_TESTS, V8_TESTS],
@@ -39,8 +33,8 @@ export default defineConfig({
3933

4034
// Test cases for istanbul-provider
4135
{
36+
extends: true,
4237
test: {
43-
...config.test,
4438
name: { label: 'istanbul', color: 'magenta' },
4539
env: { COVERAGE_PROVIDER: 'istanbul' },
4640
include: [GENERIC_TESTS, ISTANBUL_TESTS],
@@ -56,8 +50,8 @@ export default defineConfig({
5650

5751
// Test cases for custom-provider
5852
{
53+
extends: true,
5954
test: {
60-
...config.test,
6155
name: { label: 'custom', color: 'yellow' },
6256
env: { COVERAGE_PROVIDER: 'custom' },
6357
include: [CUSTOM_TESTS],
@@ -67,8 +61,8 @@ export default defineConfig({
6761

6862
// Test cases for browser. Browser mode itself is activated by COVERAGE_BROWSER env var.
6963
{
64+
extends: true,
7065
test: {
71-
...config.test,
7266
name: { label: 'istanbul-browser', color: 'blue' },
7367
env: { COVERAGE_PROVIDER: 'istanbul', COVERAGE_BROWSER: 'true' },
7468
testTimeout: 15_000,
@@ -98,8 +92,8 @@ export default defineConfig({
9892
},
9993
},
10094
{
95+
extends: true,
10196
test: {
102-
...config.test,
10397
name: { label: 'v8-browser', color: 'red' },
10498
env: { COVERAGE_PROVIDER: 'v8', COVERAGE_BROWSER: 'true' },
10599
testTimeout: 15_000,
@@ -131,8 +125,8 @@ export default defineConfig({
131125

132126
// Test cases that aren't provider specific
133127
{
128+
extends: true,
134129
test: {
135-
...config.test,
136130
name: { label: 'unit', color: 'cyan' },
137131
include: [UNIT_TESTS],
138132
typecheck: {

0 commit comments

Comments
 (0)