From c10c72837b0d50446ac72b4a2f5df3883a21169a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:13:57 +0800 Subject: [PATCH] fix(skill): close watchers after probe disposal --- packages/skill/skill-local/src/index.ts | 4 ++ .../tests/skill-local-watcher.spec.ts | 58 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index f124a10f62..add2f8792c 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -489,6 +489,10 @@ class SkillWatchManager { let ready = false const readiness = Promise.withResolvers() const signal = this.lifecycle.signal + if (signal.aborted) { + await this.closeWatcher(handle) + signal.throwIfAborted() + } const onAbort = (): void => { readiness.reject(signal.reason) } signal.addEventListener('abort', onAbort, { once: true }) const onError = (error: unknown): void => { diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts index 4f9cfc59ef..14e3c07e41 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -18,12 +18,18 @@ interface FakeWatchFileControl { listener(current: Stats, previous: Stats): void } +interface FakeStatGate { + started: PromiseWithResolvers + release: PromiseWithResolvers +} + const watcherHarness = vi.hoisted(() => ({ watchers: [] as FakeWatcherControl[], startupErrors: [] as Error[], closeErrors: 0, deferredReady: 0, watchFiles: [] as FakeWatchFileControl[], + statGates: [] as FakeStatGate[], })) 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() + return { + ...actual, + async stat(...args: Parameters) { + const gate = watcherHarness.statGates.shift() + if (gate !== undefined) { + gate.started.resolve(undefined) + await gate.release.promise + } + return await actual.stat(...args) + }, + } +}) + vi.mock('chokidar', () => ({ default: { watch(_path: unknown, options: Record) { @@ -89,6 +110,7 @@ beforeEach(() => { watcherHarness.closeErrors = 0 watcherHarness.deferredReady = 0 watcherHarness.watchFiles.length = 0 + watcherHarness.statGates.length = 0 }) describe('skill-local watcher failures', () => { @@ -279,6 +301,42 @@ describe('skill-local watcher failures', () => { 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(), + release: Promise.withResolvers(), + } + watcherHarness.statGates.push(statGate) + const ctx = new Context() + await ctx.plugin(SkillService) + let provider!: InstanceType + 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 () => { const home = await tempDir('skill-watch-opening-reject') const root = join(home, '.dsh/skills')