fix(skill): cancel opening watchers on dispose
This commit is contained in:
2 files changed
+17
-6
No files matched your search
@@ -260,6 +260,7 @@ interface WatchHandle {
|
||||
class SkillWatchManager {
|
||||
private readonly roots = new Map<string, RootWatchState>()
|
||||
private readonly projects = new Map<string, Set<string>>()
|
||||
private readonly lifecycle = new AbortController()
|
||||
private closing = false
|
||||
private invalidationQueued = false
|
||||
|
||||
@@ -313,6 +314,7 @@ class SkillWatchManager {
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.closing = true
|
||||
this.lifecycle.abort(new Error('skill-local watcher disposed'))
|
||||
const states = [...this.roots.values()]
|
||||
this.roots.clear()
|
||||
this.projects.clear()
|
||||
@@ -348,6 +350,7 @@ class SkillWatchManager {
|
||||
}
|
||||
|
||||
private ensureWatcher(state: RootWatchState): Promise<void> {
|
||||
/* v8 ignore next -- A scheduled rewatch can reach this guard only when teardown wins its await. */
|
||||
if (this.closing || !this.config.enabled) return Promise.resolve()
|
||||
if (state.opening !== undefined) return state.opening
|
||||
const opening = this.ensureCurrentWatcher(state)
|
||||
@@ -395,8 +398,11 @@ class SkillWatchManager {
|
||||
state.watcher = watcher
|
||||
state.unhealthy = false
|
||||
} catch (error) {
|
||||
state.unhealthy = true
|
||||
this.ctx.logger.warn(`skill-local: failed to watch ${state.root.path}: ${errorMessage(error)}`)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- teardown can race awaited watcher startup
|
||||
if (!this.closing) {
|
||||
state.unhealthy = true
|
||||
this.ctx.logger.warn(`skill-local: failed to watch ${state.root.path}: ${errorMessage(error)}`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -474,6 +480,9 @@ class SkillWatchManager {
|
||||
}
|
||||
let ready = false
|
||||
const readiness = Promise.withResolvers<undefined>()
|
||||
const signal = this.lifecycle.signal
|
||||
const onAbort = (): void => { readiness.reject(signal.reason) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
const onError = (error: unknown): void => {
|
||||
if (!ready) {
|
||||
readiness.reject(error)
|
||||
@@ -494,6 +503,8 @@ class SkillWatchManager {
|
||||
} catch (error) {
|
||||
await this.closeWatcher(handle)
|
||||
throw error
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
return handle
|
||||
}
|
||||
@@ -539,6 +550,7 @@ class SkillWatchManager {
|
||||
this.invalidationQueued = true
|
||||
queueMicrotask(() => {
|
||||
this.invalidationQueued = false
|
||||
/* v8 ignore next -- Effect teardown can win this queued microtask before provider disposal emits. */
|
||||
if (this.closing) return
|
||||
this.invalidate()
|
||||
})
|
||||
|
||||
@@ -262,11 +262,10 @@ describe('skill-local watcher failures', () => {
|
||||
await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) })
|
||||
const first = watcherHarness.watchers[0]
|
||||
if (first === undefined) throw new Error('expected an opening root watcher')
|
||||
first.emitter.emit('unlinkDir', root)
|
||||
const disposal = provider.dispose()
|
||||
first.emitter.emit('ready')
|
||||
|
||||
await Promise.all([discovery, disposal])
|
||||
await expect(discovery).rejects.toThrow('skill-local watcher disposed')
|
||||
await disposal
|
||||
disposeProvider()
|
||||
await settle()
|
||||
expect(first.closeCalls).toBeGreaterThan(0)
|
||||
@@ -295,8 +294,8 @@ describe('skill-local watcher failures', () => {
|
||||
await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) })
|
||||
const first = watcherHarness.watchers[0]
|
||||
if (first === undefined) throw new Error('expected an opening root watcher')
|
||||
const disposal = provider.dispose()
|
||||
first.emitter.emit('error', new Error('opening failed during disposal'))
|
||||
const disposal = provider.dispose()
|
||||
|
||||
await expect(discovery).rejects.toThrow('opening failed during disposal')
|
||||
await disposal
|
||||
|
||||
Reference in New Issue
Block a user