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:
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')
|
||||
|
||||
Reference in new issue
Block a user