From df41b7cd2deb7323f2fa4b29cdb5bddd5e30bd20 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:36:12 +0800 Subject: [PATCH] fix(credentials-local): contain watcher errors that arrive after close chokidar's close() drops every listener while a scheduled write-settle poll can still run. That straggler stats a document the home teardown is deleting, which Windows reports as EPERM, and an 'error' emission with no listener is rethrown as an uncaught exception: the whole coverage partition fails while every test file passes. Re-attach the provider's error listener across the close so the straggler stays inside the provider, and make it a no-op after disposal. --- .../credentials-local/src/index.ts | 17 ++++++++++--- .../credentials-local/tests/watcher.spec.ts | 25 ++++++++++++++++++- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index d033fa32fc..07bad5abda 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -593,15 +593,26 @@ export class LocalCredentialProvider extends CredentialProvider { if (this.closed) return this.queueRefresh() }) - watcher.on('error', (error) => { + const reportWatcherError = (error: unknown): void => { + // A watcher error after disposal is not actionable: the watched home is + // going away with this provider, and logging it would be noise. + if (this.closed) return this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename) this.ctx.logger.warn(error) - }) + } + watcher.on('error', reportWatcherError) yield async () => { // Quiesce: stop accepting events, close the watcher, then wait out any // queued or in-flight operation so nothing publishes after disposal. this.closed = true - await watcher.close() + // `close()` drops every listener but leaves a scheduled write-settle + // poll; its straggler stats the document this teardown is deleting, + // which Windows reports as EPERM, and an 'error' emission with no + // listener is rethrown as an uncaught exception. Re-attach across the + // close so that straggler stays inside this provider. + const closed = watcher.close() + watcher.on('error', reportWatcherError) + await closed await this.operations } } diff --git a/packages/credentials/credentials-local/tests/watcher.spec.ts b/packages/credentials/credentials-local/tests/watcher.spec.ts index 02436e94d9..27b4ce0fbe 100644 --- a/packages/credentials/credentials-local/tests/watcher.spec.ts +++ b/packages/credentials/credentials-local/tests/watcher.spec.ts @@ -52,7 +52,7 @@ interface FakeChokidar { __instances: Array<{ path: string options: { awaitWriteFinish: { stabilityThreshold: number; pollInterval: number } } - watcher: import('node:events').EventEmitter + watcher: import('node:events').EventEmitter & { close: () => Promise } }> } @@ -141,6 +141,29 @@ describe('watcher pipeline', () => { expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'good', source: 'file' }) }) + it('contains a watcher error emitted after close removed its listeners', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + const ctx = new Context() + const fiber = ctx.plugin(LocalCredentialProvider, { path, debounceMs: 5 }) + await fiber + const [instance] = await fakeInstances() + // The real close() drops every listener while a scheduled write-settle poll + // survives it; that straggler stats the document teardown is deleting, + // which Windows reports as EPERM. Without a listener the emitter rethrows + // the error as an uncaught exception and takes an unrelated test file down. + instance!.watcher.close = vi.fn(() => { + instance!.watcher.removeAllListeners() + return Promise.resolve() + }) + await fiber.dispose() + + expect(() => instance!.watcher.emit('error', Object.assign( + new Error("EPERM: operation not permitted, stat 'C:\\Temp\\dsh\\.credentials.yaml'"), + { code: 'EPERM', syscall: 'stat' }, + ))).not.toThrow() + }) + it('quiesces the refresh pipeline before dispose completes', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml')