Files
orca/src/main/ipc/runtime-watcher-process-pool.ts
T
Jinjing f93e92646c P2 watcher lifecycle bounds (#8640)
* fix watcher lifecycle cancellation bounds

* fix(review): guard remote watcher installs against post-shutdown resurrection

closeAllWatchers aborted the in-flight install *tokens* it could see, but a
same-key joiner awaiting a 'cancelled' resolution (and a fired retry tick)
calls installRemoteWatcher directly and, on the fresh-generation recursion,
builds a brand-new non-aborted AbortController and calls provider.watch()
after teardown — leaking an SSH watcher into the just-cleared remoteWatchers
map. Latch the subsystem shut in closeAllWatchers and refuse installs while
latched; a genuine new fs:watchWorktree clears it. Adds a regression test
(fails without the latch) plus a test for the same-tick handoff-revival guard
that had no coverage.

Also extract the duplicated isolated-quarantine-vs-fuse branch shared by
retireSlot and releaseFailedRoot into quarantineOrFuse (behavior-preserving).

* Add lifecycle generation guard to refuse stale remote-watcher joiners

- A boolean latch alone can't distinguish a pre-shutdown joiner from a
  fresh call once a genuine new watch reopens the subsystem, letting a
  stale joiner recurse and register a post-shutdown provider.watch()
- Each installRemoteWatcher call now captures a generation counter that
  closeAllWatchers bumps, so a waiter that resumes after a later
  shutdown+reopen is refused instead of resurrecting
- Adds a regression test covering the shutdown-then-reopen race
2026-07-13 19:12:30 -07:00

264 lines
8.1 KiB
TypeScript

import { isWatcherProcessFailure, WatcherProcessFailure } from './parcel-watcher-process-failure'
import { WatcherProcessSupervisor } from './parcel-watcher-process-supervisor'
import type { WatcherProcessSubscribeOptions } from './parcel-watcher-process-protocol'
import type {
WatcherProcessCallback,
WatcherProcessHooks,
WatcherProcessSubscription
} from './parcel-watcher-process-subscription'
const DEFAULT_MAX_SHARED_SUPERVISORS = 4
const DEFAULT_MAX_QUARANTINE_SUPERVISORS = 4
type WatcherSupervisor = Pick<WatcherProcessSupervisor, 'dispose' | 'subscribe'>
type RuntimeWatcherPoolSlot = {
supervisor: WatcherSupervisor
roots: Set<string>
isolated: boolean
retired: boolean
disposed: boolean
}
type RuntimeWatcherPoolAssignment = {
slot: RuntimeWatcherPoolSlot
leases: number
}
export type RuntimeWatcherProcessPoolOptions = {
maxSharedSupervisors?: number
maxQuarantineSupervisors?: number
createSupervisor?: () => WatcherSupervisor
}
/**
* Share a bounded set of watcher children while healthy, then move roots from
* a failed shard into a separate bounded quarantine pool.
*/
export class RuntimeWatcherProcessPool {
private readonly maxSharedSupervisors: number
private readonly maxQuarantineSupervisors: number
private readonly createSupervisor: () => WatcherSupervisor
private readonly activeSlots = new Set<RuntimeWatcherPoolSlot>()
private readonly allSlots = new Set<RuntimeWatcherPoolSlot>()
private readonly assignments = new Map<string, RuntimeWatcherPoolAssignment>()
private readonly isolatedRoots = new Set<string>()
private readonly failedQuarantineRoots = new Set<string>()
constructor(options: RuntimeWatcherProcessPoolOptions = {}) {
this.maxSharedSupervisors = Math.max(
1,
options.maxSharedSupervisors ?? DEFAULT_MAX_SHARED_SUPERVISORS
)
this.maxQuarantineSupervisors = Math.max(
1,
options.maxQuarantineSupervisors ?? DEFAULT_MAX_QUARANTINE_SUPERVISORS
)
this.createSupervisor = options.createSupervisor ?? (() => new WatcherProcessSupervisor())
}
async subscribe(
dir: string,
callback: WatcherProcessCallback,
opts: WatcherProcessSubscribeOptions,
hooks: WatcherProcessHooks = {}
): Promise<WatcherProcessSubscription> {
const assignment = this.assignmentForRoot(dir)
const { slot } = assignment
let assignmentReleased = false
const releaseAssignment = (): void => {
if (assignmentReleased) {
return
}
assignmentReleased = true
this.releaseRoot(assignment, dir)
}
const onTerminalError = (error: Error): void => {
if (isWatcherProcessFailure(error) && error.scope === 'supervisor') {
this.retireSlot(slot)
} else {
this.releaseFailedRoot(assignment, dir, error, releaseAssignment)
}
hooks.onTerminalError?.(error)
}
let subscription: WatcherProcessSubscription
try {
subscription = await slot.supervisor.subscribe(dir, callback, opts, {
...hooks,
onTerminalError
})
} catch (error) {
if (isWatcherProcessFailure(error) && error.scope === 'supervisor') {
this.retireSlot(slot)
} else {
this.releaseFailedRoot(assignment, dir, error, releaseAssignment)
}
throw error
}
let unsubscribePromise: Promise<void> | undefined
return {
unsubscribe: (): Promise<void> => {
unsubscribePromise ??= subscription.unsubscribe().finally(() => {
releaseAssignment()
})
return unsubscribePromise
}
}
}
/** Kill every pooled watcher child (production shutdown or test reset). */
dispose(): void {
// disposeSlot deletes the current slot from allSlots; deleting the
// in-progress element during Set iteration is safe, so no snapshot needed.
for (const slot of this.allSlots) {
this.disposeSlot(slot)
}
this.activeSlots.clear()
this.allSlots.clear()
this.assignments.clear()
this.isolatedRoots.clear()
this.failedQuarantineRoots.clear()
}
resetForTest(): void {
this.dispose()
}
forgetRoot(dir: string): void {
// Physical subscriptions release their assignment through unsubscribe or
// terminal callbacks. This clears fault history after setup gives up.
if (!this.assignments.has(dir)) {
this.isolatedRoots.delete(dir)
this.failedQuarantineRoots.delete(dir)
}
}
private assignmentForRoot(dir: string): RuntimeWatcherPoolAssignment {
if (this.failedQuarantineRoots.has(dir)) {
throw new WatcherProcessFailure(
'file watcher process failed again in quarantine',
'supervisor',
'supervisor_crash_fuse'
)
}
const assigned = this.assignments.get(dir)
if (assigned && !assigned.slot.retired) {
assigned.leases++
return assigned
}
const slot = this.isolatedRoots.has(dir) ? this.quarantineSlot() : this.sharedSlot()
const assignment = { slot, leases: 1 }
slot.roots.add(dir)
this.assignments.set(dir, assignment)
return assignment
}
private sharedSlot(): RuntimeWatcherPoolSlot {
const sharedSlots = Array.from(this.activeSlots).filter(
(slot) => !slot.isolated && !slot.retired
)
if (sharedSlots.length < this.maxSharedSupervisors) {
return this.createSlot(false)
}
return sharedSlots.reduce((leastLoaded, candidate) =>
candidate.roots.size < leastLoaded.roots.size ? candidate : leastLoaded
)
}
private quarantineSlot(): RuntimeWatcherPoolSlot {
const quarantineSlots = Array.from(this.activeSlots).filter(
(slot) => slot.isolated && !slot.retired
)
if (quarantineSlots.length < this.maxQuarantineSupervisors) {
return this.createSlot(true)
}
return quarantineSlots.reduce((leastLoaded, candidate) =>
candidate.roots.size < leastLoaded.roots.size ? candidate : leastLoaded
)
}
private createSlot(isolated: boolean): RuntimeWatcherPoolSlot {
const slot: RuntimeWatcherPoolSlot = {
supervisor: this.createSupervisor(),
roots: new Set(),
isolated,
retired: false,
disposed: false
}
this.activeSlots.add(slot)
this.allSlots.add(slot)
return slot
}
private retireSlot(slot: RuntimeWatcherPoolSlot): void {
if (slot.retired) {
return
}
slot.retired = true
this.activeSlots.delete(slot)
for (const root of slot.roots) {
if (this.assignments.get(root)?.slot === slot) {
this.assignments.delete(root)
}
this.quarantineOrFuse(root, slot.isolated)
}
slot.roots.clear()
// Why: failAllSubscriptions is still iterating callbacks; defer disposal
// so every logical root receives the supervisor failure first.
queueMicrotask(() => this.disposeSlot(slot))
}
private releaseRoot(assignment: RuntimeWatcherPoolAssignment, dir: string): void {
if (this.assignments.get(dir) !== assignment) {
return
}
assignment.leases--
if (assignment.leases > 0) {
return
}
const { slot } = assignment
this.assignments.delete(dir)
slot.roots.delete(dir)
this.isolatedRoots.delete(dir)
if (slot.isolated && slot.roots.size === 0 && !slot.retired) {
this.activeSlots.delete(slot)
this.disposeSlot(slot)
}
}
private releaseFailedRoot(
assignment: RuntimeWatcherPoolAssignment,
dir: string,
error: unknown,
releaseAssignment: () => void
): void {
releaseAssignment()
if (!isWatcherProcessFailure(error) || error.code !== 'subscribe_timeout') {
return
}
this.quarantineOrFuse(dir, assignment.slot.isolated)
}
// Why: one bounded quarantine attempt is the recovery budget per watch
// lifetime; an already-isolated root that fails again is fused, not re-isolated,
// so it cannot spawn another child generation.
private quarantineOrFuse(dir: string, isolated: boolean): void {
if (isolated) {
this.isolatedRoots.delete(dir)
this.failedQuarantineRoots.add(dir)
return
}
this.isolatedRoots.add(dir)
}
private disposeSlot(slot: RuntimeWatcherPoolSlot): void {
if (slot.disposed) {
return
}
slot.disposed = true
slot.supervisor.dispose()
this.allSlots.delete(slot)
}
}