import * as net from 'node:net'; import * as os from 'node:os'; import * as path from 'node:path'; import * as fs from 'node:fs'; import * as crypto from 'node:crypto'; import {type ChildProcess, spawn} from 'node:child_process'; import {IPCCommon} from './common.js'; import {timeout} from './util.js'; const IPC_SOCKET_ARG = 'ipc-socket'; const ACCEPT_TIMEOUT_MS = 10000; export class ParentIPC extends IPCCommon { private readonly cmdPath: string; private readonly cmdArgs: string[]; private cmd: ChildProcess | null = null; private readonly listener: net.Server; private cmdExitResult: { code: number | null, signal: string | null } | null = null; private cmdExitCallbacks: ((result: { code: number | null, signal: string | null }) => void)[] = []; constructor(cmdPath: string, cmdArgs: string[], ...localApis: object[]) { const socketPath = path.join(os.tmpdir(), `kitten-ipc-${ process.pid }-${ crypto.randomInt(2**48 - 1) }.sock`); super(localApis, socketPath); this.cmdPath = cmdPath; if (cmdArgs.includes(`--${ IPC_SOCKET_ARG }`)) { throw new Error(`you should not use '--${ IPC_SOCKET_ARG }' argument in your command`); } this.cmdArgs = cmdArgs; this.listener = net.createServer(); } async start(): Promise { try { fs.unlinkSync(this.socketPath); } catch { } await new Promise((resolve, reject) => { this.listener.listen(this.socketPath, () => { resolve(); }); this.listener.on('error', reject); }); const cmdArgs = [...this.cmdArgs, `--${ IPC_SOCKET_ARG }`, this.socketPath]; this.cmd = spawn(this.cmdPath, cmdArgs, {stdio: 'inherit'}); this.cmd.on('error', (err) => { this.raiseErr(err); }); this.cmd.on('close', (code, signal) => { const result = { code, signal }; this.cmdExitResult = result; for (const cb of this.cmdExitCallbacks) cb(result); this.cmdExitCallbacks = []; }); await this.acceptConn(); } private async acceptConn(): Promise { const acceptPromise = new Promise((resolve, reject) => { this.listener.once('connection', (conn) => { resolve(conn); }); this.listener.once('error', reject); }); const exitPromise = new Promise((_, reject) => { if (this.cmdExitResult) { reject(new Error(`command exited before connection established`)); } else { this.cmdExitCallbacks.push(() => { reject(new Error(`command exited before connection established`)); }); } }); try { this.conn = await timeout(Promise.race([acceptPromise, exitPromise]), ACCEPT_TIMEOUT_MS); this.readConn(); } catch (e) { if (this.cmd) this.cmd.kill(); throw e; } } async wait(): Promise { if (!this.cmd) { throw new Error('Command is not started yet'); } const exitPromise = new Promise<{ code: number | null, signal: string | null }>((resolve) => { if (this.cmdExitResult) { resolve(this.cmdExitResult); } else { this.cmdExitCallbacks.push(resolve); } }); try { await Promise.race([ exitPromise.then(({ code, signal }) => { if (signal || code) { if (signal) throw new Error(`Process exited with signal ${ signal }`); else throw new Error(`Process exited with code ${ code }`); } else if (!this.ready) { throw new Error('command exited before connection established'); } }), this.errorQueue.collect().then((errors) => { if (errors.length === 1) { throw errors[0]; } else if (errors.length > 1) { throw new Error(errors.map(e => e.toString()).join(', ')); } }), ]); } finally { try { fs.unlinkSync(this.socketPath); } catch {} } } }