diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index ed68e84bcb..907c15a8ab 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -260,6 +260,7 @@ interface WatchHandle { class SkillWatchManager { private readonly roots = new Map() private readonly projects = new Map>() + private readonly lifecycle = new AbortController() private closing = false private invalidationQueued = false @@ -313,6 +314,7 @@ class SkillWatchManager { async dispose(): Promise { 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 { + /* 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() + 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() }) 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 2a7a3e5f86..a1df287fbe 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -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