kittenipc/lib/ts/src/parent.ts
2026-03-28 14:33:46 +03:00

127 lines
4.4 KiB
TypeScript

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<void> {
try {
fs.unlinkSync(this.socketPath);
} catch {
}
await new Promise<void>((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<void> {
const acceptPromise = new Promise<net.Socket>((resolve, reject) => {
this.listener.once('connection', (conn) => {
resolve(conn);
});
this.listener.once('error', reject);
});
const exitPromise = new Promise<net.Socket>((_, 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<void> {
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 {}
}
}
}