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.
This commit is contained in:
Tianyi Cui committed 2026-10-05 09:38:53 +08:00
1 parent 534acc5952
commit df41b7cd2d
2 files changed
+38 -4

No files matched your search

@@ -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
}
}
@@ -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<void> }
}>
}
@@ -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')