fix(skill): cancel opening watchers on dispose

This commit is contained in:
Tianyi Cui
2026-07-29 21:17:04 +08:00
parent e9233123eb
commit 48cf50fc55
2 files changed
+17 -6

No files matched your search

+14 -2
View File
@@ -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