import * as plugins from './plugins.js'; import { controllerProcessIdentityIsLive, readControllerProcessIdentity, readProcessGroupMemberPids, type IControllerProcessIdentity, } from './classes.processinspection.js'; interface ILinuxProcessState { state: string; parentId: number; groupId: number; sessionId: number; startTicks: string; } const readLinuxProcessState = (pid: number): ILinuxProcessState | null => { try { const stat = plugins.fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); const suffix = stat.lastIndexOf(') '); if (suffix < 0) throw new Error(`Invalid process metadata for PID ${pid}.`); const fields = stat.slice(suffix + 2).trim().split(/\s+/u); if (fields.length < 20 || !/^\d+$/u.test(fields[19])) { throw new Error(`Invalid process metadata for PID ${pid}.`); } return { state: fields[0], parentId: Number(fields[1]), groupId: Number(fields[2]), sessionId: Number(fields[3]), startTicks: fields[19], }; } catch (error) { if (['ENOENT', 'ESRCH'].includes((error as NodeJS.ErrnoException).code ?? '')) return null; throw error; } }; /** Linux removes access to fd before Node necessarily observes the child exit. */ export const linuxOpenCodeProcessHasExited = (pid: number): boolean => { const metadata = readLinuxProcessState(pid); return metadata === null || ['Z', 'X', 'x'].includes(metadata.state); }; export const openCodeServeArguments = (port: number): string[] => [ 'serve', '--hostname', '127.0.0.1', '--port', String(port), '--mdns=false', '--print-logs', ]; interface IOpenCodeOrphanRecoveryOptions { directory: string; executablePath: string; port: number; listenerExists(): boolean; ownsListener(pid: number, port: number): boolean; stopTimeoutMs: number; finalStopTimeoutMs: number; } /** Recover a detached child left behind by a previous controller's abrupt exit. */ export class OpenCodeOrphanRecovery { constructor(private readonly options: IOpenCodeOrphanRecoveryOptions) {} public async recover(signal?: AbortSignal): Promise { if (process.platform !== 'linux' || !this.options.listenerExists()) return; signal?.throwIfAborted(); // An occupied port is not ownership evidence. Only an orphaned session // leader in this controller's private runtime directory can be reclaimed. const entries = await plugins.fs.promises.readdir('/proc'); let orphan: IControllerProcessIdentity | undefined; for (const entry of entries) { signal?.throwIfAborted(); if (!/^[1-9][0-9]*$/u.test(entry)) continue; const pid = Number(entry); if (pid < 2 || !this.isOwnedOrphan(pid)) continue; const identity = await readControllerProcessIdentity(pid); if (identity && this.options.ownsListener(pid, this.options.port) && this.isOwnedOrphan(pid, identity.startTicks)) { if (orphan) throw new Error('Multiple orphaned OpenCode listeners require manual inspection.'); orphan = identity; } } if (!this.options.listenerExists()) return; if (!orphan) { throw new Error( `OpenCode port ${this.options.port} is occupied by a process that cannot be verified as an orphan owned by this AGL controller. No process was stopped.`, ); } const members = await this.liveGroupMembers(orphan.processGroupId); // Recheck every ownership attribute immediately before the first signal. signal?.throwIfAborted(); if (!this.isOwnedOrphan(orphan.pid, orphan.startTicks) || !this.options.ownsListener(orphan.pid, this.options.port)) { throw new Error('OpenCode orphan ownership changed before recovery. No process was stopped.'); } this.signalGroup(orphan.processGroupId, 'SIGTERM'); if (await this.waitForGroupExit(orphan.processGroupId, this.options.stopTimeoutMs)) return; // The leader may exit before its descendants. Require a surviving member // from the original group before escalation, so a reused PGID is untouched. let anchored = false; for (const member of members) { const current = await readControllerProcessIdentity(member.pid); if (current?.fingerprint === member.fingerprint && current.processGroupId === orphan.processGroupId && await controllerProcessIdentityIsLive(member.pid, member.fingerprint)) { anchored = true; break; } } if (!anchored) { if ((await this.liveGroupMembers(orphan.processGroupId)).length === 0) return; throw new Error('OpenCode orphan group ownership changed during recovery.'); } this.signalGroup(orphan.processGroupId, 'SIGKILL'); if (!await this.waitForGroupExit(orphan.processGroupId, this.options.finalStopTimeoutMs)) { throw new Error('The verified orphaned OpenCode process group did not exit.'); } } private isOwnedOrphan(pid: number, startTicks?: string): boolean { try { const state = readLinuxProcessState(pid); if (!state || state.parentId !== 1 || state.groupId !== pid || state.sessionId !== pid || ['Z', 'X', 'x'].includes(state.state) || (startTicks !== undefined && state.startTicks !== startTicks) || plugins.fs.statSync(`/proc/${pid}`).uid !== process.getuid!()) return false; if (plugins.fs.readlinkSync(`/proc/${pid}/cwd`) !== this.options.directory) return false; const executable = plugins.fs.readlinkSync(`/proc/${pid}/exe`); if (!this.isPackagedExecutable(executable)) return false; const expected = [executable, ...openCodeServeArguments(this.options.port)]; const argv = plugins.fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0'); if (argv.pop() !== '' || argv.length !== expected.length || !argv.every((value, index) => value === expected[index])) return false; // Protect against a PID being recycled while the other files were read. return readLinuxProcessState(pid)?.startTicks === state.startTicks; } catch (error) { if (['ENOENT', 'ESRCH', 'EACCES', 'EPERM'].includes((error as NodeJS.ErrnoException).code ?? '')) { return false; } throw error; } } private isPackagedExecutable(executable: string): boolean { if (executable === this.options.executablePath) return true; // pnpm replaces the global installation directory on upgrade. Accept the // same package/version there only after checking its canonical bin layout. const expectedDirectory = plugins.path.dirname(plugins.path.dirname(this.options.executablePath)); const directory = plugins.path.dirname(plugins.path.dirname(executable)); const expected = JSON.parse(plugins.fs.readFileSync(plugins.path.join(expectedDirectory, 'package.json'), 'utf8')); if (plugins.path.basename(directory) !== expected.name || plugins.path.basename(plugins.path.dirname(directory)) !== 'node_modules' || executable !== plugins.path.join(directory, 'bin', plugins.path.basename(this.options.executablePath))) return false; const actual = JSON.parse(plugins.fs.readFileSync(plugins.path.join(directory, 'package.json'), 'utf8')); const stat = plugins.fs.statSync(executable); return actual.name === expected.name && actual.version === expected.version && stat.isFile() && (stat.mode & 0o022) === 0 && (stat.uid === process.getuid!() || stat.uid === 0) && plugins.fs.realpathSync.native(executable) === executable; } private async liveGroupMembers(groupId: number): Promise { const members: IControllerProcessIdentity[] = []; for (const pid of await readProcessGroupMemberPids(groupId)) { const identity = await readControllerProcessIdentity(pid); if (identity && identity.processGroupId === groupId && await controllerProcessIdentityIsLive(pid, identity.fingerprint)) members.push(identity); } return members; } private async waitForGroupExit(groupId: number, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while ((await this.liveGroupMembers(groupId)).length > 0) { if (Date.now() >= deadline) return false; await plugins.timersPromises.setTimeout(Math.min(50, deadline - Date.now())); } return true; } private signalGroup(groupId: number, signal: 'SIGTERM' | 'SIGKILL'): void { try { process.kill(-groupId, signal); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; } } }