Files
orca/src/main/plugins/plugin-worker-manager.ts
T
OrcaWinandm4air 4085e1cf60 fix(memory): release stale session registries (#21734)
* fix(memory): bound session and lifecycle registries

* fix(memory): bound transient filesystem registries

* fix(memory): cap path and locale caches

* fix(memory): bound runtime recovery registries

* fix(memory): bound host mirror gap verdicts

* fix(memory): bound shell startup env cache

* fix(memory): bound gitlab host context cache

* fix(memory): release removed ssh generations

* fix(memory): expire cloud refresh replay guards

* fix(memory): release retired plugin generations

* fix(memory): bound plugin log key retention

* fix(memory): bound automation authority generations

* fix(memory): bound native chat enrichment cache

* fix(memory): bound web session tracking generations

* fix(memory): bound codex credential absence paths

* fix(memory): bound WSL canonical path cache

* fix(memory): bound sparse checkout cache

* fix(memory): bound shared directory cache

* fix(memory): bound advertised URL scan snapshots

* fix(memory): bound automation manager cache

* fix(memory): bound web session reorder intents

* fix(memory): bound web session focus intents

* fix(memory): bound web session handoffs

* fix(memory): bound automation dispatch tokens

* fix(memory): bound host mirror waiters

* fix(memory): bound retained session activity

* fix(memory): bound retained session activity

* fix(memory): bound web session close intents

* fix(memory): bound cloud session cache

* fix(memory): bound WSL home cache

* fix(memory): bound SSH capability cache

* fix(memory): bound trust grant cooldowns

* fix(memory): bound WSL auth drain state

* fix(memory): bound Linear workspace credential cache

* fix(memory): bound local Git capability cache

* fix(memory): bound WSL Git environment cache

* fix(memory): bound WSL Git environment cache

* fix(memory): bound WSL preflight cache

* fix(memory): keep hot cache entries warm

* fix(memory): preserve generation fences across eviction

* fix(memory): close remaining eviction fences

* fix(memory): align evicted upstream generations

* fix(memory): trim successful capability probes

* fix(auth): retain expired refresh replay evidence

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-20 14:41:50 -07:00

327 lines
11 KiB
TypeScript

import {
PLUGIN_WORKER_IDLE_REAP_MS,
PLUGIN_WORKER_MAX_ACTIVE_DEFAULT
} from '../../shared/plugins/plugin-host-protocol'
import type { PluginEventName } from '../../shared/plugins/plugin-manifest'
import {
PluginSupervisor,
type PluginRestartDecision,
type PluginRunState
} from './plugin-supervisor'
import type { PluginWorkerHandle, PluginWorkerHostCallExecutor } from './plugin-host-process'
import { PluginWorkerSlotPool } from './plugin-worker-slot-pool'
import {
startPluginWorkerAttempt,
type PluginWorkerFactory,
type PluginWorkerSpawnSpec,
type StartedPluginWorker
} from './plugin-worker-startup'
import { runPluginWorkerRestartLoop } from './plugin-worker-restart-loop'
import { pluginWorkerSpawnSpecsEqual } from './plugin-worker-spawn-spec'
import {
forgetPluginWorkerGenerationIfIdle,
nextPluginWorkerGeneration
} from './plugin-worker-generation-retention'
import { finishPluginWorkerActivation, pluginWorkerErrorText } from './plugin-worker-lifecycle'
export type { PluginWorkerFactory, PluginWorkerSpawnSpec } from './plugin-worker-startup'
export type PluginWorkerManagerOptions = {
entryPath: string
maxActive?: number
idleReapMs?: number
workerFactory?: PluginWorkerFactory
executeHostCall: (
pluginKey: string,
method: string,
params: unknown
) => ReturnType<PluginWorkerHostCallExecutor>
log: (pluginKey: string) => (level: 'info' | 'warn' | 'error', line: string) => void
onWorkerStateChange: (pluginKey: string) => void
onWorkerGone: (pluginKey: string) => void
}
type ActivationRecord = {
spec: PluginWorkerSpawnSpec
generation: number
controller: AbortController
task: Promise<PluginWorkerHandle>
}
/** Owns lazy activation, bounded capacity, restart policy, cancellation, and idle reap. */
export class PluginWorkerManager {
private readonly supervisor = new PluginSupervisor()
private readonly workers = new Map<string, StartedPluginWorker>()
private readonly activations = new Map<string, ActivationRecord>()
private readonly knownSpecs = new Map<string, PluginWorkerSpawnSpec>()
private readonly generations = new Map<string, number>()
private readonly stoppingWorkers = new Set<Promise<void>>()
private readonly slots: PluginWorkerSlotPool
private readonly idleReapMs: number
private disposed = false
constructor(private readonly options: PluginWorkerManagerOptions) {
this.slots = new PluginWorkerSlotPool(options.maxActive ?? PLUGIN_WORKER_MAX_ACTIVE_DEFAULT)
this.idleReapMs = options.idleReapMs ?? PLUGIN_WORKER_IDLE_REAP_MS
}
runState(pluginKey: string): PluginRunState {
return this.supervisor.getState(pluginKey)
}
restartCount(pluginKey: string): number {
return this.supervisor.restartCount(pluginKey)
}
trackedSpecs(): ReadonlyMap<string, PluginWorkerSpawnSpec> {
return new Map(this.knownSpecs)
}
/** @internal - exposed for lifecycle retention tests. */
generationCountForTests(): number {
return this.generations.size
}
async ensureActive(
spec: PluginWorkerSpawnSpec,
assertApproved: () => void = () => undefined
): Promise<PluginWorkerHandle> {
if (this.disposed) {
throw new Error('plugin workers are shut down')
}
if (this.supervisor.getState(spec.pluginKey) === 'errored') {
throw new Error(`plugin ${spec.pluginKey} is errored after repeated failures`)
}
for (;;) {
// Removal may finish while a stale revision waits for its worker to stop.
assertApproved()
const existing = this.workers.get(spec.pluginKey)
const pending = this.activations.get(spec.pluginKey)
const activeSpec = existing?.spec ?? pending?.spec
if (!activeSpec) {
break
}
if (pluginWorkerSpawnSpecsEqual(activeSpec, spec)) {
return existing?.handle ?? pending!.task
}
// Why: refresh/trigger races can present a new dev manifest while the
// old revision is still starting. Cancel and re-check atomically enough
// that callers never join a stale activation by key alone.
await this.deactivate(spec.pluginKey)
if (this.disposed) {
throw new Error('plugin workers are shut down')
}
}
const generation = this.nextGeneration(spec.pluginKey)
this.knownSpecs.set(spec.pluginKey, spec)
this.supervisor.markRunning(spec.pluginKey, { resetRestarts: true })
return this.beginActivation(spec, generation)
}
private beginActivation(
spec: PluginWorkerSpawnSpec,
generation: number,
firstRestart?: Extract<PluginRestartDecision, { restart: true }>
): Promise<PluginWorkerHandle> {
const controller = new AbortController()
const task = this.activate(spec, generation, controller.signal, firstRestart)
const record: ActivationRecord = { spec, generation, controller, task }
this.activations.set(spec.pluginKey, record)
void task.then(
() => finishPluginWorkerActivation(this.activations, spec.pluginKey, record),
() => finishPluginWorkerActivation(this.activations, spec.pluginKey, record)
)
return task
}
private async activate(
spec: PluginWorkerSpawnSpec,
generation: number,
signal: AbortSignal,
firstRestart?: Extract<PluginRestartDecision, { restart: true }>
): Promise<PluginWorkerHandle> {
const log = this.options.log(spec.pluginKey)
return runPluginWorkerRestartLoop({
signal,
firstRestart,
assertActive: () => this.throwIfCancelled(spec.pluginKey, generation, signal),
start: async () => {
const worker = await startPluginWorkerAttempt({
spec,
generation,
signal,
slots: this.slots,
entryPath: this.options.entryPath,
factory: this.options.workerFactory,
executeHostCall: (method, params) =>
this.options.executeHostCall(spec.pluginKey, method, params),
log,
assertActive: () => this.throwIfCancelled(spec.pluginKey, generation, signal),
onExit: (record, code) => this.handleUnexpectedExit(spec.pluginKey, record, code)
})
this.workers.set(spec.pluginKey, worker)
const earlyExit = worker.completeStart()
if (earlyExit.exited) {
this.detachWorker(spec.pluginKey, worker)
throw new Error(`worker exited immediately after ready (code ${earlyExit.code})`)
}
this.supervisor.markRunning(spec.pluginKey)
this.options.onWorkerStateChange(spec.pluginKey)
return worker.handle
},
recordFailure: (error) => this.recordFailure(spec.pluginKey, 'worker failed to start', error),
erroredError: (error) =>
new Error(
`plugin ${spec.pluginKey} is errored after repeated failures: ${pluginWorkerErrorText(error)}`
)
})
}
private handleUnexpectedExit(
pluginKey: string,
record: StartedPluginWorker,
code: number | null
): void {
if (!this.detachWorker(pluginKey, record)) {
return
}
if (this.isCancelled(pluginKey, record.generation)) {
return
}
const decision = this.recordFailure(pluginKey, `worker exited unexpectedly (code ${code})`)
if (decision.restart) {
this.beginActivation(record.spec, record.generation, decision)
}
}
private recordFailure(
pluginKey: string,
context: string,
error?: unknown
): PluginRestartDecision {
this.options.onWorkerGone(pluginKey)
const decision = this.supervisor.markExited(pluginKey, { crashed: true })
this.options.onWorkerStateChange(pluginKey)
if (decision.restart) {
this.options.log(pluginKey)(
'warn',
`${context}${error ? `: ${pluginWorkerErrorText(error)}` : ''}; restart ${decision.attempt} in ${decision.delayMs}ms`
)
} else if (decision.state === 'errored') {
this.options.log(pluginKey)('error', `${context}; marked errored after repeated failures`)
}
return decision
}
private detachWorker(pluginKey: string, record: StartedPluginWorker): boolean {
if (this.workers.get(pluginKey) !== record) {
return false
}
this.workers.delete(pluginKey)
record.lease.release()
return true
}
deliverEventIfRunning(pluginKey: string, event: PluginEventName, payload: unknown): void {
this.workers.get(pluginKey)?.handle.deliverEvent(event, payload)
}
async deactivate(pluginKey: string): Promise<void> {
this.nextGeneration(pluginKey)
const activation = this.activations.get(pluginKey)
activation?.controller.abort()
const record = this.workers.get(pluginKey)
if (record) {
this.workers.delete(pluginKey)
}
this.options.onWorkerGone(pluginKey)
this.supervisor.reset(pluginKey)
this.knownSpecs.delete(pluginKey)
await Promise.all([
activation?.task.catch(() => undefined),
record?.handle.dispose().catch(() => undefined)
])
record?.lease.release()
this.forgetGenerationIfIdle(pluginKey)
}
reapIdle(now = Date.now()): void {
for (const [pluginKey, record] of this.workers) {
if (
record.handle.inFlightCount() !== 0 ||
now - record.handle.lastActivityAt() <= this.idleReapMs
) {
continue
}
this.nextGeneration(pluginKey)
this.knownSpecs.delete(pluginKey)
this.workers.delete(pluginKey)
this.options.onWorkerGone(pluginKey)
this.supervisor.markExited(pluginKey, { crashed: false })
this.options.log(pluginKey)('info', 'worker reaped after idle period')
this.options.onWorkerStateChange(pluginKey)
const stopping = record.handle
.dispose()
.catch(() => undefined)
.finally(() => record.lease.release())
this.stoppingWorkers.add(stopping)
void stopping.then(() => {
this.stoppingWorkers.delete(stopping)
this.forgetGenerationIfIdle(pluginKey)
})
}
}
async disposeAll(): Promise<void> {
this.disposed = true
const pluginKeys = new Set([...this.activations.keys(), ...this.workers.keys()])
for (const key of pluginKeys) {
this.nextGeneration(key)
this.options.onWorkerGone(key)
}
const activations = [...this.activations.values()]
for (const activation of activations) {
activation.controller.abort()
}
this.slots.dispose()
const workers = [...this.workers.values()]
this.workers.clear()
this.knownSpecs.clear()
const stoppingWorkers = [...this.stoppingWorkers]
await Promise.all([
...stoppingWorkers,
...activations.map((activation) => activation.task.catch(() => undefined)),
...workers.map(async (record) => {
await record.handle.dispose().catch(() => undefined)
record.lease.release()
})
])
}
private nextGeneration(pluginKey: string): number {
return nextPluginWorkerGeneration(pluginKey, this.generations)
}
private forgetGenerationIfIdle(pluginKey: string): void {
forgetPluginWorkerGenerationIfIdle(
pluginKey,
this.activations,
this.workers,
this.knownSpecs,
this.generations
)
}
private isCancelled(pluginKey: string, generation: number, signal?: AbortSignal): boolean {
return (
this.disposed || signal?.aborted === true || this.generations.get(pluginKey) !== generation
)
}
private throwIfCancelled(pluginKey: string, generation: number, signal: AbortSignal): void {
if (this.isCancelled(pluginKey, generation, signal)) {
throw new Error('plugin worker activation was cancelled')
}
}
}