mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
* fix(runtime): retain watcher and PTY teardown ownership * fix(runtime): restore watchers after interrupted cleanup * fix(runtime): prevent stale watcher revival * test(runtime): cover watcher shutdown ownership * test(daemon): model physical PTY exit * fix(daemon): keep shutdown terminating when disposal cannot prove exit A rejecting host.dispose() (unreapable child past its exit deadline) left the shutdown RPC without its process.nextTick(shutdown) and skipped socket cleanup in shutdown(), stranding the daemon as an unreachable orphan after the stale-daemon replacement flow unlinks its socket. Log and continue: daemon exit reparents the child to init instead of blocking on it. * fix(runtime): keep local watching alive after an idle-kill deadline miss An idle child that outlived the exit deadline set shutdownRequested on the shared desktop supervisor, which has no retire-and-replace path — every later subscribe rejected supervisor_disposed and the roots were cached unwatchable, silently ending local file watching for the session. The idle path owns zero records, so there is no double-watch hazard; the zombie keeps its capacity reservation until physical exit and the next subscribe gets a fresh child. * fix(renderer): resync replayed paired-web file watches Transparent replay removed the implicit resync the old close-and-rebuild path provided: a replayed files.watch only reports changes from its own native setup, so changes during the reconnect gap were silently lost. Deliver a conservative overflow to consumers once the replayed watch is ready, matching the overflow-after-interruption contract everywhere else. * fix(runtime): address teardown review findings * fix(runtime): retry watches after teardown deadlines * Fix PTY descendant leaks on forced teardown * Fix jitter-sensitive terminal lifecycle test
327 lines
11 KiB
TypeScript
327 lines
11 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'
|
|
import { RuntimeWatcherPendingAssignment } from './runtime-watcher-pending-assignment'
|
|
import { RuntimeWatcherPoolLifecycle } from './runtime-watcher-pool-lifecycle'
|
|
import { RuntimeWatcherPredecessorBarriers } from './runtime-watcher-predecessor-barriers'
|
|
import { RuntimeWatcherQuarantineQueue } from './runtime-watcher-quarantine-queue'
|
|
import { handleRuntimeWatcherSubscriptionFailure } from './runtime-watcher-subscription-failure'
|
|
import type {
|
|
RuntimeWatcherPoolAssignment,
|
|
RuntimeWatcherPoolSlot,
|
|
RuntimeWatcherPoolSupervisor,
|
|
RuntimeWatcherProcessPoolOptions
|
|
} from './runtime-watcher-pool-state'
|
|
|
|
export type { RuntimeWatcherProcessPoolOptions } from './runtime-watcher-pool-state'
|
|
|
|
// Why: extra ~50 MiB processes are justified only when quarantine isolates a fault.
|
|
const DEFAULT_MAX_SHARED_SUPERVISORS = 1
|
|
const DEFAULT_MAX_QUARANTINE_SUPERVISORS = 4
|
|
|
|
/**
|
|
* Share children while healthy, then move failed roots into bounded quarantine slots.
|
|
*/
|
|
export class RuntimeWatcherProcessPool {
|
|
private readonly maxSharedSupervisors: number
|
|
private readonly maxQuarantineSupervisors: number
|
|
private readonly createSupervisor: () => RuntimeWatcherPoolSupervisor
|
|
private readonly activeSlots = new Set<RuntimeWatcherPoolSlot>()
|
|
private readonly allSlots = new Set<RuntimeWatcherPoolSlot>()
|
|
private readonly assignments = new Map<string, RuntimeWatcherPoolAssignment>()
|
|
private readonly pendingAssignments = new Map<
|
|
string,
|
|
RuntimeWatcherPendingAssignment<RuntimeWatcherPoolAssignment>
|
|
>()
|
|
private readonly lifecycle = new RuntimeWatcherPoolLifecycle()
|
|
private readonly predecessorBarriers = new RuntimeWatcherPredecessorBarriers()
|
|
private readonly quarantineQueue: RuntimeWatcherQuarantineQueue<RuntimeWatcherPoolSlot>
|
|
|
|
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.quarantineQueue = new RuntimeWatcherQuarantineQueue(this.maxQuarantineSupervisors)
|
|
this.createSupervisor = options.createSupervisor ?? (() => new WatcherProcessSupervisor())
|
|
}
|
|
|
|
async subscribe(
|
|
dir: string,
|
|
callback: WatcherProcessCallback,
|
|
opts: WatcherProcessSubscribeOptions,
|
|
hooks: WatcherProcessHooks = {}
|
|
): Promise<WatcherProcessSubscription> {
|
|
this.lifecycle.assertActive()
|
|
const assignmentOrPromise = this.assignmentForRoot(dir, hooks)
|
|
const assignment =
|
|
assignmentOrPromise instanceof Promise ? await assignmentOrPromise : assignmentOrPromise
|
|
this.lifecycle.assertActive()
|
|
const { slot } = assignment
|
|
let assignmentReleased = false
|
|
const releaseAssignment = (): void => {
|
|
if (assignmentReleased) {
|
|
return
|
|
}
|
|
assignmentReleased = true
|
|
this.releaseRoot(assignment, dir)
|
|
}
|
|
const onTerminalError = (error: Error): void => {
|
|
handleRuntimeWatcherSubscriptionFailure(
|
|
error,
|
|
releaseAssignment,
|
|
(failure) => this.retireSlot(slot, failure),
|
|
() => this.lifecycle.quarantineOrFuse(dir, slot.isolated)
|
|
)
|
|
hooks.onTerminalError?.(error)
|
|
}
|
|
let subscription: WatcherProcessSubscription
|
|
try {
|
|
subscription = await slot.supervisor.subscribe(dir, callback, opts, {
|
|
...hooks,
|
|
onTerminalError
|
|
})
|
|
} catch (error) {
|
|
handleRuntimeWatcherSubscriptionFailure(
|
|
error,
|
|
releaseAssignment,
|
|
(failure) => this.retireSlot(slot, failure),
|
|
() => this.lifecycle.quarantineOrFuse(dir, slot.isolated)
|
|
)
|
|
throw error
|
|
}
|
|
|
|
let unsubscribePromise: Promise<void> | undefined
|
|
return {
|
|
unsubscribe: (): Promise<void> => {
|
|
unsubscribePromise ??= subscription.unsubscribe().then(
|
|
() => releaseAssignment(),
|
|
(error: unknown) => {
|
|
if (isWatcherProcessFailure(error) && error.scope === 'supervisor') {
|
|
this.retireSlot(slot, error)
|
|
} else {
|
|
releaseAssignment()
|
|
}
|
|
throw error
|
|
}
|
|
)
|
|
return unsubscribePromise
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Kill every pooled watcher child (production shutdown or test reset). */
|
|
dispose(): void {
|
|
this.lifecycle.dispose()
|
|
// disposeSlot deletes the current slot from allSlots; deleting the
|
|
// in-progress element during Set iteration is safe, so no snapshot needed.
|
|
const disposedError = new WatcherProcessFailure(
|
|
'file watcher supervisor disposed',
|
|
'supervisor',
|
|
'supervisor_disposed'
|
|
)
|
|
this.quarantineQueue.failAll(disposedError)
|
|
for (const slot of this.allSlots) {
|
|
this.disposeSlot(slot)
|
|
}
|
|
this.activeSlots.clear()
|
|
this.allSlots.clear()
|
|
this.assignments.clear()
|
|
this.pendingAssignments.clear()
|
|
this.predecessorBarriers.clear()
|
|
}
|
|
|
|
resetForTest(): void {
|
|
this.dispose()
|
|
this.lifecycle.reset()
|
|
}
|
|
|
|
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.quarantineQueue.failRoot(dir)
|
|
this.lifecycle.isolatedRoots.delete(dir)
|
|
this.lifecycle.failedQuarantineRoots.delete(dir)
|
|
}
|
|
}
|
|
|
|
private assignmentForRoot(
|
|
dir: string,
|
|
hooks: WatcherProcessHooks
|
|
): RuntimeWatcherPoolAssignment | Promise<RuntimeWatcherPoolAssignment> {
|
|
this.predecessorBarriers.throwIfRetained(dir)
|
|
if (this.lifecycle.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 pending = this.pendingAssignments.get(dir)
|
|
if (pending) {
|
|
return pending.wait(hooks, (assignment) => assignment.leases++)
|
|
}
|
|
const slot = this.lifecycle.isIsolated(dir) ? this.quarantineSlot(dir) : this.sharedSlot()
|
|
if (slot instanceof Promise) {
|
|
let abandoned = false
|
|
let grantedAssignment: RuntimeWatcherPoolAssignment | undefined
|
|
const basePromise = slot.then((resolvedSlot) => {
|
|
grantedAssignment = this.assignRootToSlot(dir, resolvedSlot, 0)
|
|
if (abandoned) {
|
|
this.releaseRoot(grantedAssignment, dir, false)
|
|
}
|
|
return grantedAssignment
|
|
})
|
|
const pendingAssignment = new RuntimeWatcherPendingAssignment(
|
|
basePromise,
|
|
() => {
|
|
abandoned = true
|
|
if (grantedAssignment) {
|
|
this.releaseRoot(grantedAssignment, dir, false)
|
|
} else {
|
|
this.quarantineQueue.failRoot(dir)
|
|
}
|
|
},
|
|
() => {
|
|
if (this.pendingAssignments.get(dir) === pendingAssignment) {
|
|
this.pendingAssignments.delete(dir)
|
|
}
|
|
}
|
|
)
|
|
this.pendingAssignments.set(dir, pendingAssignment)
|
|
return pendingAssignment.wait(hooks, (assignment) => assignment.leases++)
|
|
}
|
|
return this.assignRootToSlot(dir, slot)
|
|
}
|
|
|
|
private assignRootToSlot(
|
|
dir: string,
|
|
slot: RuntimeWatcherPoolSlot,
|
|
leases = 1
|
|
): RuntimeWatcherPoolAssignment {
|
|
const assignment = { slot, leases }
|
|
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(dir: string): RuntimeWatcherPoolSlot | Promise<RuntimeWatcherPoolSlot> {
|
|
const quarantineSlots = Array.from(this.activeSlots).filter(
|
|
(slot) => slot.isolated && !slot.retired
|
|
)
|
|
if (quarantineSlots.length < this.maxQuarantineSupervisors) {
|
|
return this.createSlot(true)
|
|
}
|
|
return this.quarantineQueue.wait(dir)
|
|
}
|
|
|
|
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, error: WatcherProcessFailure): void {
|
|
if (slot.retired) {
|
|
return
|
|
}
|
|
slot.retired = true
|
|
this.activeSlots.delete(slot)
|
|
// Why: a replacement cannot safely reacquire a root while the failed child
|
|
// may still own its native watcher handle (notably on Windows).
|
|
this.predecessorBarriers.retain(slot.roots, error)
|
|
for (const root of slot.roots) {
|
|
if (this.assignments.get(root)?.slot === slot) {
|
|
this.assignments.delete(root)
|
|
}
|
|
this.lifecycle.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)
|
|
this.drainQuarantineWaiters()
|
|
})
|
|
}
|
|
|
|
private releaseRoot(
|
|
assignment: RuntimeWatcherPoolAssignment,
|
|
dir: string,
|
|
releaseLease = true
|
|
): void {
|
|
if (this.assignments.get(dir) !== assignment) {
|
|
return
|
|
}
|
|
if (releaseLease) {
|
|
assignment.leases--
|
|
}
|
|
if (assignment.leases > 0) {
|
|
return
|
|
}
|
|
const { slot } = assignment
|
|
this.assignments.delete(dir)
|
|
slot.roots.delete(dir)
|
|
this.lifecycle.isolatedRoots.delete(dir)
|
|
if (slot.isolated && slot.roots.size === 0 && !slot.retired) {
|
|
if (!this.quarantineQueue.grantNext(slot)) {
|
|
this.activeSlots.delete(slot)
|
|
this.disposeSlot(slot)
|
|
}
|
|
}
|
|
}
|
|
|
|
private drainQuarantineWaiters(): void {
|
|
while (
|
|
this.quarantineQueue.length > 0 &&
|
|
Array.from(this.activeSlots).filter((slot) => slot.isolated && !slot.retired).length <
|
|
this.maxQuarantineSupervisors
|
|
) {
|
|
this.quarantineQueue.grantNext(this.createSlot(true))
|
|
}
|
|
}
|
|
|
|
private disposeSlot(slot: RuntimeWatcherPoolSlot): void {
|
|
if (slot.disposed) {
|
|
return
|
|
}
|
|
slot.disposed = true
|
|
slot.supervisor.dispose()
|
|
this.allSlots.delete(slot)
|
|
}
|
|
}
|