Files
Veskslop-t/poc/src/core-process.ts
T

252 lines
8.1 KiB
TypeScript

/*
* Veskslop daemon — Vesktop core process control.
*
* The daemon can start, stop, and restart the Electron client that hosts the
* core bridge. Only a process started by this daemon instance is managed.
*
* On Linux without a DISPLAY, the daemon starts Xvfb and points the client at
* it. Set VESKSLOP_XVFB=0 to disable that.
*/
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = fileURLToPath(new URL("../..", import.meta.url));
const DEFAULT_ARGS = "--no-sandbox --disable-gpu --disable-dev-shm-usage";
const XVFB_ARGS = "-screen 0 1280x720x24 -nolisten tcp";
const MAX_OUTPUT_LINES = 20;
export interface CoreExit {
code: number | null;
signal: string | null;
at: string;
}
export interface CoreProcessStatus {
managed: boolean;
running: boolean;
pid: number | null;
startedAt: string | null;
command: string | null;
lastExit: CoreExit | null;
output: string[];
}
export interface CoreProcessOptions {
tokenFile: string;
}
function prefixLines(prefix: string, chunk: Buffer): string {
return chunk
.toString()
.split("\n")
.map(line => (line ? `${prefix} ${line}` : line))
.join("\n");
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
function findFreeDisplay(): string {
for (let number = 99; number < 110; number++) {
if (!existsSync(`/tmp/.X11-unix/X${number}`)) return `:${number}`;
}
return ":99";
}
export class CoreProcess {
private child?: ChildProcess;
private startedAt?: number;
private command?: string;
private xvfb?: ChildProcess;
private display?: string;
private lastExit: CoreExit | null = null;
private readonly output: string[] = [];
private readonly exitListeners = new Set<() => void>();
constructor(private readonly options: CoreProcessOptions) {}
status(): CoreProcessStatus {
const running = !!this.child && this.child.exitCode === null && !this.child.killed;
return {
managed: !!this.child,
running,
pid: this.child?.pid ?? null,
startedAt: this.startedAt ? new Date(this.startedAt).toISOString() : null,
command: this.command ?? null,
lastExit: this.lastExit,
output: [...this.output]
};
}
onExit(listener: () => void) {
this.exitListeners.add(listener);
return () => this.exitListeners.delete(listener);
}
async start(forceToken = false): Promise<CoreProcessStatus> {
if (this.child) return this.status();
const override = process.env.VESKSLOP_CORE_CMD;
const args = (process.env.VESKSLOP_CORE_ARGS ?? DEFAULT_ARGS).split(/\s+/).filter(Boolean);
const display = await this.ensureDisplay();
const env = {
...process.env,
...(display ? { DISPLAY: display } : {}),
VESKSLOP_HEADLESS: process.env.VESKSLOP_HEADLESS ?? "1",
VESKSLOP_TOKEN_FILE: this.options.tokenFile,
...(forceToken ? { VESKSLOP_FORCE_TOKEN: "1" } : {})
};
let command: string;
let child: ChildProcess;
if (override) {
command = override;
child = spawn(override, { cwd: ROOT, env, shell: true, stdio: ["ignore", "pipe", "pipe"] });
} else {
const electron = join(
ROOT,
"node_modules",
".bin",
process.platform === "win32" ? "electron.cmd" : "electron"
);
if (!existsSync(electron)) {
throw new Error("electron is not installed; run pnpm install or set VESKSLOP_CORE_CMD");
}
command = `${electron} . ${args.join(" ")}`;
child = spawn(electron, [".", ...args], { cwd: ROOT, env, stdio: ["ignore", "pipe", "pipe"] });
}
this.child = child;
this.startedAt = Date.now();
this.command = command;
this.lastExit = null;
this.output.length = 0;
child.stdout?.on("data", chunk => {
this.appendOutput(chunk);
process.stdout.write(prefixLines("[core]", chunk));
});
child.stderr?.on("data", chunk => {
this.appendOutput(chunk);
process.stderr.write(prefixLines("[core]", chunk));
});
child.on("error", error => {
console.error("[core] failed to start:", error.message);
this.appendOutput(Buffer.from(error.message));
});
child.on("exit", (code, signal) => {
console.log(`[core] exited (code ${code ?? "null"}${signal ? `, signal ${signal}` : ""})`);
this.lastExit = { code, signal, at: new Date().toISOString() };
this.child = undefined;
this.startedAt = undefined;
this.command = undefined;
for (const listener of this.exitListeners) listener();
});
console.log(`[core] starting: ${command}${display ? ` (DISPLAY=${display})` : ""}`);
return this.status();
}
async stop(timeoutMs = 8000): Promise<CoreProcessStatus> {
const child = this.child;
if (!child) return this.status();
await new Promise<void>(resolve => {
const timer = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
// already gone
}
}, timeoutMs);
child.once("exit", () => {
clearTimeout(timer);
resolve();
});
try {
child.kill("SIGTERM");
} catch {
clearTimeout(timer);
resolve();
}
});
return this.status();
}
async restart(forceToken = false): Promise<CoreProcessStatus> {
await this.stop();
return this.start(forceToken);
}
/** Stop the core and any Xvfb that this daemon started. */
async dispose(timeoutMs = 4000): Promise<void> {
await this.stop(timeoutMs);
this.stopXvfb();
}
private appendOutput(chunk: Buffer) {
for (const line of chunk.toString().split("\n")) {
if (!line.trim()) continue;
this.output.push(line);
if (this.output.length > MAX_OUTPUT_LINES) this.output.shift();
}
}
private async ensureDisplay(): Promise<string | undefined> {
if (process.env.DISPLAY) return process.env.DISPLAY;
if (process.platform !== "linux" || process.env.VESKSLOP_XVFB === "0") return undefined;
if (this.xvfb && this.xvfb.exitCode === null) return this.display;
const display = findFreeDisplay();
const child = spawn("Xvfb", [display, ...XVFB_ARGS.split(" ")], { stdio: ["ignore", "ignore", "pipe"] });
child.stderr?.on("data", chunk => process.stderr.write(prefixLines("[xvfb]", chunk)));
child.on("error", error => {
console.warn(`[core] Xvfb is not available: ${error.message}`);
this.xvfb = undefined;
this.display = undefined;
});
child.on("exit", () => {
this.xvfb = undefined;
this.display = undefined;
});
this.xvfb = child;
this.display = display;
// Wait for the X socket so Electron does not race the server startup.
const socket = `/tmp/.X11-unix/X${display.slice(1)}`;
for (let attempt = 0; attempt < 30 && !existsSync(socket); attempt++) await sleep(100);
if (!existsSync(socket)) console.warn(`[core] Xvfb socket ${socket} did not appear; continuing anyway`);
console.log(`[core] Xvfb on ${display} (pid ${child.pid})`);
return display;
}
private stopXvfb() {
const child = this.xvfb;
this.xvfb = undefined;
this.display = undefined;
if (child && child.exitCode === null) {
try {
child.kill("SIGTERM");
} catch {
// already gone
}
}
}
}