Explorar o código

fix(skill): cancel opening watchers on dispose

Tianyi Cui hai 2 meses
pai
achega
48cf50fc55

+ 14 - 2
packages/skill/skill-local/src/index.ts

@@ -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()
     })

+ 3 - 4
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