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