electron/main/terminalProcessTree.ts
Rama main · réplica de lectura del repositorio original.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import koffi from 'koffi';
import { execFile } from 'node:child_process';
import { readFileSync } from 'node:fs';
interface ProcessIdentity {
pid: number;
parentPid: number;
birth: string;
zombie: boolean;
}
let procPidInfo: ReturnType<ReturnType<typeof koffi.load>['func']> | undefined;
/** Read a birth identity, not ps lstart (which loses subsecond PID reuse). */
function readProcess(pid: number): ProcessIdentity | undefined {
if (pid <= 1 || pid === process.pid) return undefined;
if (process.platform === 'darwin') {
procPidInfo ??= koffi
.load('/usr/lib/libproc.dylib')
.func(
'int proc_pidinfo(int pid, int flavor, uint64_t arg, void *buffer, int size)'
);
// Public proc_bsdinfo / PROC_PIDTBSDINFO from sys/proc_info.h. The final
// two uint64 fields are process start seconds and microseconds.
const info = Buffer.alloc(136);
if (procPidInfo(pid, 3, 0, info, info.length) !== info.length) {
if (koffi.errno() === 3) return undefined; // ESRCH: already reaped
throw new Error('Could not inspect terminal process identity');
}
return {
pid,
parentPid: info.readUInt32LE(16),
birth: `${info.readBigUInt64LE(120)}:${info.readBigUInt64LE(128)}`,
zombie: info.readUInt32LE(4) === 5, // SZOMB
};
}
if (process.platform === 'linux') {
try {
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
// comm may contain spaces or parentheses; fields after its final ')'
// begin with state (3), ppid (4), ... starttime (22, clock ticks).
const fields = stat
.slice(stat.lastIndexOf(')') + 2)
.trim()
.split(/\s+/);
if (fields.length < 20) throw new Error('Invalid terminal process stat');
return {
pid,
parentPid: Number(fields[1]),
birth: fields[19],
zombie: fields[0] === 'Z',
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw error;
}
}
throw new Error('Terminal process identity is unavailable');
}
function listParents(): Promise<Array<{ pid: number; parentPid: number }>> {
return new Promise((resolve, reject) => {
execFile(
'/bin/ps',
['-A', '-o', 'pid=,ppid='],
{ encoding: 'utf8', timeout: 1000, maxBuffer: 2 * 1024 * 1024 },
(error, stdout) => {
if (error) return reject(error);
resolve(
stdout
.trim()
.split('\n')
.flatMap((line) => {
const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
return match
? [{ pid: Number(match[1]), parentPid: Number(match[2]) }]
: [];
})
);
}
);
});
}
/** In-memory ownership for one PTY Stop; retained across reparenting/retries. */
export class TerminalProcessTree {
private readonly owned = new Map<number, ProcessIdentity>();
constructor(pid: number) {
const root = readProcess(pid);
if (!root) throw new Error('Terminal process identity is unavailable');
this.owned.set(pid, root);
}
private current(owner: ProcessIdentity) {
const current = readProcess(owner.pid);
if (!current || current.birth !== owner.birth || current.zombie) {
this.owned.delete(owner.pid);
return undefined;
}
return current;
}
private async refresh() {
const parents = await listParents();
const live = new Map<number, ProcessIdentity>();
for (const owner of this.owned.values()) {
const current = this.current(owner);
if (current) live.set(current.pid, current);
}
// Map iteration also visits added descendants, so capture every level
// before TERM can make a parent disappear. Never adopt a reused parent.
for (const owner of live.values()) {
for (const row of parents) {
if (row.parentPid !== owner.pid || live.has(row.pid)) continue;
const child = readProcess(row.pid);
if (
child &&
child.parentPid === owner.pid &&
!child.zombie &&
this.current(owner)
) {
live.set(child.pid, child);
this.owned.set(child.pid, child);
}
}
}
return [...live.values()];
}
async signal(signal: 'SIGTERM' | 'SIGKILL') {
const live = await this.refresh();
for (const owner of live.reverse()) {
// Recheck immediately before signalling. Never signal a negative PGID
// or re-traverse the old root PID after its process has exited.
if (!this.current(owner)) continue;
try {
process.kill(owner.pid, signal);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error;
}
}
}
async isRunning() {
return (await this.refresh()).length > 0;
}
}