diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 6fcef9137..2be33cf45 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -261,7 +261,30 @@ export class Vitest { ) } - private async _restart(reason?: string) { + private _restartPromise?: Promise + private _restartQueued = false + + // Restarts must not overlap: chokidar regularly delivers several change + // events for one edit, and a restart that starts while another is still + // re-creating the servers reports `onServerRestart` to reporters that were + // re-instantiated but not yet initialized. + private _restart(reason?: string): Promise { + if (this._restartPromise) { + this._restartQueued = true + return this._restartPromise + } + this._restartPromise = (async () => { + do { + this._restartQueued = false + await this._restartNow(reason) + } while (this._restartQueued) + })().finally(() => { + this._restartPromise = undefined + }) + return this._restartPromise + } + + private async _restartNow(reason?: string) { await Promise.all(this._onRestartListeners.map(fn => fn(reason))) this.report('onServerRestart', reason) await this.close() diff --git a/test/e2e/fixtures/pool/write-to-stdout-and-stderr.test.ts b/test/e2e/fixtures/pool/write-to-stdout-and-stderr.test.ts index 2d0beea18..2966523f7 100644 --- a/test/e2e/fixtures/pool/write-to-stdout-and-stderr.test.ts +++ b/test/e2e/fixtures/pool/write-to-stdout-and-stderr.test.ts @@ -1,11 +1,17 @@ import { test } from 'vitest' import EventEmitter from 'node:events' -test('write to streams', () => { +test('write to streams', async () => { process.stdout.write('Worker writing to stdout') process.stderr.write('Worker writing to stderr') + // `emitWarning` prints to stderr asynchronously: without waiting for the + // event (plus one tick for the default handler's write), a fast worker + // teardown can exit before the warning reaches the captured stderr + const warning = new Promise(resolve => process.once('warning', () => resolve())) triggerNodeWarning() + await warning + await new Promise(resolve => setImmediate(resolve)) }) function triggerNodeWarning() { @@ -16,4 +22,4 @@ function triggerNodeWarning() { emitter.addListener('message', () => {}) } -class TestFixturesCustomEmitter extends EventEmitter {} \ No newline at end of file +class TestFixturesCustomEmitter extends EventEmitter {} diff --git a/test/e2e/test/watch/restart-coalescing.test.ts b/test/e2e/test/watch/restart-coalescing.test.ts new file mode 100644 index 000000000..e784cbc99 --- /dev/null +++ b/test/e2e/test/watch/restart-coalescing.test.ts @@ -0,0 +1,24 @@ +import { runVitest } from '#test-utils' +import { expect, test } from 'vitest' + +// chokidar regularly delivers several change events for one config edit, each +// triggering a restart. A restart that begins while another is still +// re-creating the servers used to report `onServerRestart` to reporters that +// were re-instantiated but not yet initialized, crashing the run with +// "Cannot read properties of undefined (reading 'logger')". +test('concurrent restarts are coalesced instead of overlapping', async () => { + const { ctx, vitest } = await runVitest({ + root: 'fixtures/watch', + watch: true, + }) + + const restart = (ctx as any)._restart.bind(ctx) + await Promise.all([restart('config'), restart('config'), restart('config')]) + + expect(vitest.stdout).toContain('Restarting due to config changes') + expect(vitest.stderr).not.toContain('Cannot read properties') + + // the restarted instance is functional: a rerun still works + await ctx!.rerunFiles() + expect(vitest.stdout).toContain('RERUN') +})