fix(skill): close watchers after probe disposal
This commit is contained in:
@@ -489,6 +489,10 @@ class SkillWatchManager {
|
|||||||
let ready = false
|
let ready = false
|
||||||
const readiness = Promise.withResolvers<undefined>()
|
const readiness = Promise.withResolvers<undefined>()
|
||||||
const signal = this.lifecycle.signal
|
const signal = this.lifecycle.signal
|
||||||
|
if (signal.aborted) {
|
||||||
|
await this.closeWatcher(handle)
|
||||||
|
signal.throwIfAborted()
|
||||||
|
}
|
||||||
const onAbort = (): void => { readiness.reject(signal.reason) }
|
const onAbort = (): void => { readiness.reject(signal.reason) }
|
||||||
signal.addEventListener('abort', onAbort, { once: true })
|
signal.addEventListener('abort', onAbort, { once: true })
|
||||||
const onError = (error: unknown): void => {
|
const onError = (error: unknown): void => {
|
||||||
|
|||||||
@@ -18,12 +18,18 @@ interface FakeWatchFileControl {
|
|||||||
listener(current: Stats, previous: Stats): void
|
listener(current: Stats, previous: Stats): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FakeStatGate {
|
||||||
|
started: PromiseWithResolvers<undefined>
|
||||||
|
release: PromiseWithResolvers<undefined>
|
||||||
|
}
|
||||||
|
|
||||||
const watcherHarness = vi.hoisted(() => ({
|
const watcherHarness = vi.hoisted(() => ({
|
||||||
watchers: [] as FakeWatcherControl[],
|
watchers: [] as FakeWatcherControl[],
|
||||||
startupErrors: [] as Error[],
|
startupErrors: [] as Error[],
|
||||||
closeErrors: 0,
|
closeErrors: 0,
|
||||||
deferredReady: 0,
|
deferredReady: 0,
|
||||||
watchFiles: [] as FakeWatchFileControl[],
|
watchFiles: [] as FakeWatchFileControl[],
|
||||||
|
statGates: [] as FakeStatGate[],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('node:fs', async (importOriginal) => {
|
vi.mock('node:fs', async (importOriginal) => {
|
||||||
@@ -40,6 +46,21 @@ vi.mock('node:fs', async (importOriginal) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
async stat(...args: Parameters<typeof actual.stat>) {
|
||||||
|
const gate = watcherHarness.statGates.shift()
|
||||||
|
if (gate !== undefined) {
|
||||||
|
gate.started.resolve(undefined)
|
||||||
|
await gate.release.promise
|
||||||
|
}
|
||||||
|
return await actual.stat(...args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('chokidar', () => ({
|
vi.mock('chokidar', () => ({
|
||||||
default: {
|
default: {
|
||||||
watch(_path: unknown, options: Record<string, unknown>) {
|
watch(_path: unknown, options: Record<string, unknown>) {
|
||||||
@@ -89,6 +110,7 @@ beforeEach(() => {
|
|||||||
watcherHarness.closeErrors = 0
|
watcherHarness.closeErrors = 0
|
||||||
watcherHarness.deferredReady = 0
|
watcherHarness.deferredReady = 0
|
||||||
watcherHarness.watchFiles.length = 0
|
watcherHarness.watchFiles.length = 0
|
||||||
|
watcherHarness.statGates.length = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('skill-local watcher failures', () => {
|
describe('skill-local watcher failures', () => {
|
||||||
@@ -279,6 +301,42 @@ describe('skill-local watcher failures', () => {
|
|||||||
expect(first.closeCalls).toBeGreaterThan(0)
|
expect(first.closeCalls).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('closes an opening watcher when disposal wins the mode probe', async () => {
|
||||||
|
const home = await tempDir('skill-watch-probe-dispose')
|
||||||
|
const root = join(home, '.dsh/skills')
|
||||||
|
await writeSkill(root, 'racing-skill')
|
||||||
|
watcherHarness.deferredReady = 1
|
||||||
|
const statGate: FakeStatGate = {
|
||||||
|
started: Promise.withResolvers<undefined>(),
|
||||||
|
release: Promise.withResolvers<undefined>(),
|
||||||
|
}
|
||||||
|
watcherHarness.statGates.push(statGate)
|
||||||
|
const ctx = new Context()
|
||||||
|
await ctx.plugin(SkillService)
|
||||||
|
let provider!: InstanceType<typeof SkillLocal.LocalSkillProvider>
|
||||||
|
const disposeProvider = ctx.skills.registerProvider((control) => {
|
||||||
|
provider = new SkillLocal.LocalSkillProvider(ctx, control, {
|
||||||
|
dshHome: join(home, '.dsh'),
|
||||||
|
agentsHome: join(home, '.agents'),
|
||||||
|
watch: true,
|
||||||
|
watchPollIntervalMs: 10,
|
||||||
|
watchStabilityThresholdMs: 20,
|
||||||
|
})
|
||||||
|
return provider
|
||||||
|
})
|
||||||
|
|
||||||
|
const discovery = provider.list({})
|
||||||
|
await statGate.started.promise
|
||||||
|
const disposal = provider.dispose()
|
||||||
|
statGate.release.resolve(undefined)
|
||||||
|
|
||||||
|
await expect(discovery).rejects.toThrow('skill-local watcher disposed')
|
||||||
|
await disposal
|
||||||
|
expect(watcherHarness.watchers).toHaveLength(1)
|
||||||
|
expect(watcherHarness.watchers[0]?.closeCalls).toBeGreaterThan(0)
|
||||||
|
disposeProvider()
|
||||||
|
})
|
||||||
|
|
||||||
it('contains an opening watcher rejection during provider teardown', async () => {
|
it('contains an opening watcher rejection during provider teardown', async () => {
|
||||||
const home = await tempDir('skill-watch-opening-reject')
|
const home = await tempDir('skill-watch-opening-reject')
|
||||||
const root = join(home, '.dsh/skills')
|
const root = join(home, '.dsh/skills')
|
||||||
|
|||||||
Reference in New Issue
Block a user