export interface BackendIdentity { nonce: string pid: number profile: string startMarker: string } export interface BackendOwnershipEntry extends BackendIdentity { command?: string /** PID of the Electron parent that spawned this backend, when known. */ parentPid?: number /** Start marker of that parent, so a reused PID is not mistaken for it. */ parentStartMarker?: string } export interface BackendOwnershipStore { read: () => string | null write: (contents: string) => void /** Move an unreadable ownership file aside (e.g. rename to `.corrupt`) so * its contents survive for inspection instead of being rewritten away. * Optional: stores that can't quarantine simply skip the sweep. */ quarantine?: () => void } export interface BackendOwnershipDeps { matchesIdentity: (identity: BackendIdentity) => Promise /** True when the recorded parent is still running; undefined when unknown. */ matchesParent: (entry: BackendOwnershipEntry) => Promise stop: (identity: BackendIdentity) => Promise | void store: BackendOwnershipStore /** * Overall time budget for one reap sweep. The ownership file legitimately * accumulates one record per profile per launch, and each record can cost * up to two identity probes (parent + backend) plus a stop — on Windows * those shell out to PowerShell, whose 5.1 cold starts are slow (#87169). * Without a bound, a large roster could stall boot for minutes while the * renderer's 45s backend-boot budget expires and the user stares at the * connecting screen. When the budget is exhausted the sweep preserves the * unprocessed records for the next launch and returns what it reaped. */ reapDeadlineMs?: number } /** Default budget for one reap sweep (see `reapDeadlineMs`). */ export const REAP_ORPHANS_DEADLINE_MS = 5_000 export interface BackendClaim extends BackendIdentity { command?: string parentPid?: number parentStartMarker?: string } function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.length > 0 } function isCompleteIdentity(value: unknown): value is BackendIdentity { if (!value || typeof value !== 'object') { return false } const candidate = value as Partial return ( Number.isInteger(candidate.pid) && Number(candidate.pid) > 0 && isNonEmptyString(candidate.startMarker) && isNonEmptyString(candidate.nonce) && isNonEmptyString(candidate.profile) ) } function identitiesMatch(left: BackendIdentity, right: BackendIdentity): boolean { return ( left.pid === right.pid && left.startMarker === right.startMarker && left.nonce === right.nonce && left.profile === right.profile ) } export function parseBackendOwnership(contents: unknown): BackendOwnershipEntry[] { return parseBackendOwnershipDetailed(contents).entries } /** Parse result that distinguishes "empty/valid" from "unreadable". A corrupt * ownership file must NOT read as an empty roster: `reapOrphans` rewrites the * file with its survivors, so treating garbage as `[]` permanently erased the * records of still-running backends — the exact shape of the #89298 report * (ownership file gone, 28 leaked serve processes nothing will ever reap). */ export function parseBackendOwnershipDetailed(contents: unknown): { corrupt: boolean entries: BackendOwnershipEntry[] } { const text = String(contents ?? '') if (!text.trim()) { return { corrupt: false, entries: [] } } let parsed: unknown try { parsed = JSON.parse(text) } catch { return { corrupt: true, entries: [] } } const values = Array.isArray(parsed) ? parsed : parsed && typeof parsed === 'object' && Array.isArray((parsed as { backends?: unknown }).backends) ? (parsed as { backends: unknown[] }).backends : [] const entries: BackendOwnershipEntry[] = [] for (const value of values) { if (!isCompleteIdentity(value)) { continue } const candidate = value as BackendOwnershipEntry const entry: BackendOwnershipEntry = { nonce: candidate.nonce, pid: candidate.pid, profile: candidate.profile, startMarker: candidate.startMarker } if (typeof candidate.command === 'string') { entry.command = candidate.command } if (Number.isInteger(candidate.parentPid) && Number(candidate.parentPid) > 0) { entry.parentPid = candidate.parentPid } if (isNonEmptyString(candidate.parentStartMarker)) { entry.parentStartMarker = candidate.parentStartMarker } if (!entries.some(existing => identitiesMatch(existing, entry))) { entries.push(entry) } } return { corrupt: false, entries } } export function serializeBackendOwnership(entries: BackendOwnershipEntry[]): string { return `${JSON.stringify({ backends: entries }, null, 2)}\n` } /** * Persistent ownership for local backend roots. * * Claiming is asynchronous so a failed persistence transaction can await child * cleanup before reporting failure to the caller. */ export function createBackendOwnership(deps: BackendOwnershipDeps) { const readDetailed = () => parseBackendOwnershipDetailed(deps.store.read()) const read = () => readDetailed().entries const write = (entries: BackendOwnershipEntry[]) => deps.store.write(serializeBackendOwnership(entries)) return { async claim(claim: BackendClaim): Promise { if (!isCompleteIdentity(claim)) { throw new Error('Cannot own a backend without a complete process identity.') } const entry: BackendOwnershipEntry = { nonce: claim.nonce, pid: claim.pid, profile: claim.profile, startMarker: claim.startMarker } if (typeof claim.command === 'string') { entry.command = claim.command } if (Number.isInteger(claim.parentPid) && Number(claim.parentPid) > 0) { entry.parentPid = claim.parentPid } if (isNonEmptyString(claim.parentStartMarker)) { entry.parentStartMarker = claim.parentStartMarker } try { const entries = read().filter(candidate => candidate.pid !== entry.pid) write([...entries, entry]) } catch (error) { try { await deps.stop(entry) } catch { // Persistence remains the claim failure even if cleanup also fails. } throw error } return entry }, release(identity: BackendIdentity): void { if (!isCompleteIdentity(identity)) { throw new Error('Cannot release a backend without a complete process identity.') } const entries = read() const next = entries.filter(entry => !identitiesMatch(entry, identity)) if (next.length !== entries.length) { write(next) } }, async reapOrphans(): Promise { const { corrupt, entries } = readDetailed() // An unreadable ownership file yields zero parsed entries — rewriting // survivors ([]) here would DESTROY the only record of any backends the // corrupt file described, guaranteeing they leak forever (#89298). // Preserve the evidence for inspection and skip the sweep. if (corrupt) { try { deps.store.quarantine?.() } catch { // Quarantine is best-effort; the important part is not rewriting. } return [] } const survivors: BackendOwnershipEntry[] = [] const reaped: number[] = [] const deadline = Date.now() + (deps.reapDeadlineMs ?? REAP_ORPHANS_DEADLINE_MS) for (let i = 0; i < entries.length; i += 1) { // Budget exhausted: preserve the unprocessed records so a later launch // can retry them. A slow identity probe must never stall boot — the // renderer's backend-boot budget is 45s and the spawn itself needs // most of it. if (Date.now() >= deadline) { survivors.push(...entries.slice(i)) break } const entry = entries[i] // A backend whose Electron parent is still running is NOT an orphan: // reaping it would kill a live instance's session. This is what stops // a second launch from SIGTERMing the running instance's backend even // if it reaches reapOrphans (see main.ts startHermes + #87295). let parentAlive: boolean | undefined try { parentAlive = await deps.matchesParent(entry) } catch { survivors.push(entry) continue } if (parentAlive === true) { survivors.push(entry) continue } let matches: boolean | undefined try { matches = await deps.matchesIdentity(entry) } catch { survivors.push(entry) continue } if (matches === false) { continue } if (matches !== true) { survivors.push(entry) continue } try { await deps.stop(entry) reaped.push(entry.pid) } catch { // Preserve failed ownership so a later startup can retry it. survivors.push(entry) } } write(survivors) return reaped }, clear(): void { write([]) } } } export function backendCommandMatches(command: unknown): boolean { return /(?:^|[\s/\\"])(?:hermes(?:\.exe)?|hermes_cli\.main|hermes_cli[/\\]main\.py)"?(?:\s+(?:--profile|-p)\s+\S+)?\s+(?:serve|dashboard)(?:\s|$)/i.test( String(command ?? '') ) } /** Coordinates all quit paths so asynchronous backend teardown runs once. */ export function createBackendShutdownCoordinator(teardown: () => Promise | void) { let completion: Promise | undefined return { run(): Promise { if (!completion) { completion = Promise.resolve().then(teardown) } return completion }, hasStarted(): boolean { return completion !== undefined } } }