4cfec93458
- Introduced a project-scoped `launch` tool to orchestrate long-running services, debuggers, and watchers with persistent execution capabilities. - Implemented a robust daemon broker with Unix/Windows IPC transport that manages process lifecycles, readiness monitoring, and automatic log rotation. - Added detached process support to ensure services persist independently of the main application lifecycle, including recovery and cleanup mechanisms. - Updated the `bash` interceptor to prioritize the new `launch` tool for background processes and provided comprehensive documentation for lifecycle and signal handling.
345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
import * as fs from "node:fs/promises";
|
|
import * as net from "node:net";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { isEexist, isEnoent, postmortem } from "@oh-my-pi/pi-utils";
|
|
import { resolveWorkerSpawnCmd, workerEnvFromParent } from "../subprocess/worker-client";
|
|
import { daemonBrokerEndpoint, daemonRuntimeDir } from "./paths";
|
|
import {
|
|
DAEMON_BROKER_WORKER_ARG,
|
|
DAEMON_IDLE_GRACE_ENV,
|
|
DAEMON_PROJECT_DIR_ENV,
|
|
DAEMON_RUNTIME_DIR_ENV,
|
|
type DaemonOperation,
|
|
type DaemonRpcResult,
|
|
type DaemonWireResponse,
|
|
parseDaemonRpcResult,
|
|
parseDaemonWireResponse,
|
|
} from "./protocol";
|
|
|
|
const CONNECT_TIMEOUT_MS = 10_000;
|
|
const CONNECT_RETRY_MS = 50;
|
|
const TOKEN_FILE = "broker.token";
|
|
|
|
interface PendingRequest {
|
|
operation: DaemonOperation;
|
|
resolve: (result: DaemonRpcResult) => void;
|
|
reject: (error: Error) => void;
|
|
timer: NodeJS.Timeout;
|
|
removeAbort?: () => void;
|
|
}
|
|
|
|
/** Broker location and lifecycle overrides used by smoke tests and isolated consumers. */
|
|
export interface DaemonBrokerClientOptions {
|
|
/** Runtime directory override; defaults to the project-scoped config path. */
|
|
runtimeDir?: string;
|
|
/** Last-client shutdown grace override in milliseconds. */
|
|
idleGraceMs?: number;
|
|
}
|
|
|
|
/** Persistent per-process connection to one project's daemon broker. */
|
|
export interface DaemonBrokerClient {
|
|
readonly projectDir: string;
|
|
request(operation: DaemonOperation, signal?: AbortSignal): Promise<DaemonRpcResult>;
|
|
close(): void;
|
|
}
|
|
|
|
async function canonicalProjectDir(projectDir: string): Promise<string> {
|
|
const resolved = path.resolve(projectDir);
|
|
try {
|
|
return await fs.realpath(resolved);
|
|
} catch (error) {
|
|
if (isEnoent(error)) return resolved;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function readOrCreateToken(runtimeDir: string): Promise<string> {
|
|
await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 });
|
|
const tokenPath = path.join(runtimeDir, TOKEN_FILE);
|
|
const tokenFile = Bun.file(tokenPath);
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
try {
|
|
const token = (await tokenFile.text()).trim();
|
|
if (token.length > 0) return token;
|
|
} catch (error) {
|
|
if (!isEnoent(error)) throw error;
|
|
}
|
|
|
|
try {
|
|
const handle = await fs.open(tokenPath, "wx", 0o600);
|
|
try {
|
|
const token = crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "");
|
|
await handle.writeFile(token, "utf8");
|
|
return token;
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
} catch (error) {
|
|
if (!isEexist(error)) throw error;
|
|
}
|
|
await Bun.sleep(10);
|
|
}
|
|
throw new Error(`Timed out initializing daemon broker token in ${runtimeDir}`);
|
|
}
|
|
|
|
function requestTimeoutMs(operation: DaemonOperation): number {
|
|
switch (operation.op) {
|
|
case "start":
|
|
return (operation.spec.ready?.timeoutMs ?? CONNECT_TIMEOUT_MS) + 5_000;
|
|
case "wait":
|
|
case "logs":
|
|
case "stop":
|
|
return operation.timeoutMs + 5_000;
|
|
default:
|
|
return 30_000;
|
|
}
|
|
}
|
|
|
|
function openSocket(endpoint: string, timeoutMs: number): Promise<net.Socket> {
|
|
const { promise, resolve, reject } = Promise.withResolvers<net.Socket>();
|
|
const socket = net.createConnection({ path: endpoint });
|
|
const timer = setTimeout(() => {
|
|
socket.destroy();
|
|
reject(new Error(`Timed out connecting to daemon broker at ${endpoint}`));
|
|
}, timeoutMs);
|
|
const cleanup = (): void => {
|
|
clearTimeout(timer);
|
|
socket.off("connect", onConnect);
|
|
socket.off("error", onError);
|
|
};
|
|
const onConnect = (): void => {
|
|
cleanup();
|
|
resolve(socket);
|
|
};
|
|
const onError = (error: Error): void => {
|
|
cleanup();
|
|
socket.destroy();
|
|
reject(error);
|
|
};
|
|
socket.once("connect", onConnect);
|
|
socket.once("error", onError);
|
|
return promise;
|
|
}
|
|
|
|
class SocketDaemonClient implements DaemonBrokerClient {
|
|
readonly projectDir: string;
|
|
readonly #runtimeDir: string;
|
|
readonly #endpoint: string;
|
|
readonly #token: string;
|
|
readonly #idleGraceMs: number | undefined;
|
|
readonly #pending = new Map<string, PendingRequest>();
|
|
#socket: net.Socket | undefined;
|
|
#connectPromise: Promise<void> | undefined;
|
|
#buffer = "";
|
|
#closed = false;
|
|
|
|
constructor(projectDir: string, runtimeDir: string, token: string, options: DaemonBrokerClientOptions) {
|
|
this.projectDir = projectDir;
|
|
this.#runtimeDir = runtimeDir;
|
|
this.#endpoint = daemonBrokerEndpoint(projectDir, runtimeDir);
|
|
this.#token = token;
|
|
this.#idleGraceMs = options.idleGraceMs;
|
|
}
|
|
|
|
async request(operation: DaemonOperation, signal?: AbortSignal): Promise<DaemonRpcResult> {
|
|
if (this.#closed) throw new Error("Daemon broker client is closed");
|
|
if (signal?.aborted) throw new Error("Daemon broker request aborted");
|
|
await this.#connect();
|
|
const socket = this.#socket;
|
|
if (!socket || socket.destroyed) throw new Error("Daemon broker socket is unavailable");
|
|
|
|
const id = crypto.randomUUID();
|
|
const { promise, resolve, reject } = Promise.withResolvers<DaemonRpcResult>();
|
|
const timer = setTimeout(() => {
|
|
const pending = this.#pending.get(id);
|
|
if (!pending) return;
|
|
this.#pending.delete(id);
|
|
pending.removeAbort?.();
|
|
reject(new Error(`Daemon ${operation.op} request timed out`));
|
|
}, requestTimeoutMs(operation));
|
|
const pending: PendingRequest = { operation, resolve, reject, timer };
|
|
if (signal) {
|
|
const abort = (): void => {
|
|
if (!this.#pending.delete(id)) return;
|
|
clearTimeout(timer);
|
|
reject(new Error("Daemon broker request aborted"));
|
|
};
|
|
signal.addEventListener("abort", abort, { once: true });
|
|
pending.removeAbort = () => signal.removeEventListener("abort", abort);
|
|
}
|
|
this.#pending.set(id, pending);
|
|
socket.write(`${JSON.stringify({ id, token: this.#token, operation })}\n`);
|
|
return promise;
|
|
}
|
|
|
|
close(): void {
|
|
if (this.#closed) return;
|
|
this.#closed = true;
|
|
this.#socket?.destroy();
|
|
this.#socket = undefined;
|
|
this.#rejectPending(new Error("Daemon broker client closed"));
|
|
}
|
|
|
|
async #connect(): Promise<void> {
|
|
if (this.#socket && !this.#socket.destroyed) return;
|
|
if (this.#connectPromise) return this.#connectPromise;
|
|
this.#connectPromise = this.#connectOnce();
|
|
try {
|
|
await this.#connectPromise;
|
|
} finally {
|
|
this.#connectPromise = undefined;
|
|
}
|
|
}
|
|
|
|
async #connectOnce(): Promise<void> {
|
|
try {
|
|
this.#bindSocket(await openSocket(this.#endpoint, 250));
|
|
return;
|
|
} catch {
|
|
// No live broker. Multiple clients may race to spawn; the broker's PID
|
|
// lease selects one winner before any candidate touches the socket.
|
|
}
|
|
this.#spawnBroker();
|
|
const deadline = Date.now() + CONNECT_TIMEOUT_MS;
|
|
let lastError: Error | undefined;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
this.#bindSocket(await openSocket(this.#endpoint, 250));
|
|
return;
|
|
} catch (error) {
|
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
await Bun.sleep(CONNECT_RETRY_MS);
|
|
}
|
|
}
|
|
throw new Error(`Failed to start daemon broker: ${lastError?.message ?? "socket unavailable"}`);
|
|
}
|
|
|
|
#spawnBroker(): void {
|
|
const spawn = resolveWorkerSpawnCmd(DAEMON_BROKER_WORKER_ARG);
|
|
const overlay: Record<string, string> = {
|
|
[DAEMON_PROJECT_DIR_ENV]: this.projectDir,
|
|
[DAEMON_RUNTIME_DIR_ENV]: this.#runtimeDir,
|
|
};
|
|
if (this.#idleGraceMs !== undefined) overlay[DAEMON_IDLE_GRACE_ENV] = String(this.#idleGraceMs);
|
|
const child = Bun.spawn(spawn.cmd, {
|
|
cwd: spawn.cwd,
|
|
env: workerEnvFromParent(overlay),
|
|
stdin: "ignore",
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
detached: true,
|
|
});
|
|
child.unref();
|
|
}
|
|
|
|
#bindSocket(socket: net.Socket): void {
|
|
this.#socket = socket;
|
|
this.#buffer = "";
|
|
socket.setEncoding("utf8");
|
|
socket.on("data", chunk => this.#onData(chunk));
|
|
socket.on("error", () => {
|
|
// The close handler rejects pending requests with one stable error.
|
|
});
|
|
socket.on("close", () => {
|
|
if (this.#socket === socket) this.#socket = undefined;
|
|
this.#rejectPending(new Error("Daemon broker connection closed"));
|
|
});
|
|
}
|
|
|
|
#onData(chunk: string | Buffer): void {
|
|
this.#buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
for (;;) {
|
|
const newline = this.#buffer.indexOf("\n");
|
|
if (newline < 0) return;
|
|
const line = this.#buffer.slice(0, newline);
|
|
this.#buffer = this.#buffer.slice(newline + 1);
|
|
if (line.length === 0) continue;
|
|
let response: DaemonWireResponse;
|
|
try {
|
|
const decoded: unknown = JSON.parse(line);
|
|
response = parseDaemonWireResponse(decoded);
|
|
} catch (error) {
|
|
this.#rejectPending(error instanceof Error ? error : new Error(String(error)));
|
|
continue;
|
|
}
|
|
const pending = this.#pending.get(response.id);
|
|
if (!pending) continue;
|
|
this.#pending.delete(response.id);
|
|
clearTimeout(pending.timer);
|
|
pending.removeAbort?.();
|
|
if (!response.ok) {
|
|
pending.reject(new Error(response.error));
|
|
continue;
|
|
}
|
|
try {
|
|
pending.resolve(parseDaemonRpcResult(pending.operation, response.result));
|
|
} catch (error) {
|
|
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
|
}
|
|
}
|
|
}
|
|
|
|
#rejectPending(error: Error): void {
|
|
for (const pending of this.#pending.values()) {
|
|
clearTimeout(pending.timer);
|
|
pending.removeAbort?.();
|
|
pending.reject(error);
|
|
}
|
|
this.#pending.clear();
|
|
}
|
|
}
|
|
|
|
const sharedClients = new Map<string, Promise<DaemonBrokerClient>>();
|
|
let cancelExitCleanup: (() => void) | undefined;
|
|
|
|
/** Create an independent socket connection to one project's shared daemon broker. */
|
|
export async function createDaemonBrokerClient(
|
|
projectDir: string,
|
|
options: DaemonBrokerClientOptions = {},
|
|
): Promise<DaemonBrokerClient> {
|
|
const canonical = await canonicalProjectDir(projectDir);
|
|
const runtimeDir = options.runtimeDir ?? daemonRuntimeDir(canonical);
|
|
const token = await readOrCreateToken(runtimeDir);
|
|
return new SocketDaemonClient(canonical, runtimeDir, token, options);
|
|
}
|
|
|
|
/** Get the process-shared daemon broker client for one canonical project directory. */
|
|
export async function daemonClientForProject(projectDir: string): Promise<DaemonBrokerClient> {
|
|
const canonical = await canonicalProjectDir(projectDir);
|
|
let pending = sharedClients.get(canonical);
|
|
if (!pending) {
|
|
pending = createDaemonBrokerClient(canonical);
|
|
sharedClients.set(canonical, pending);
|
|
if (!cancelExitCleanup) {
|
|
cancelExitCleanup = postmortem.register("daemon-broker-clients", () => closeDaemonClients());
|
|
}
|
|
}
|
|
return pending;
|
|
}
|
|
|
|
/** Close every project broker connection held by this omp process. */
|
|
export async function closeDaemonClients(): Promise<void> {
|
|
const pending = [...sharedClients.values()];
|
|
sharedClients.clear();
|
|
for (const client of await Promise.all(pending)) client.close();
|
|
cancelExitCleanup?.();
|
|
cancelExitCleanup = undefined;
|
|
}
|
|
|
|
/** Exercise worker-host broker startup and authenticated RPC for distribution smoke tests. */
|
|
export async function smokeTestDaemonBroker(): Promise<void> {
|
|
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-project-"));
|
|
const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-run-"));
|
|
const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 });
|
|
try {
|
|
const ping = await client.request({ op: "ping" });
|
|
if (ping.op !== "ping" || ping.projectDir !== client.projectDir) throw new Error("daemon broker ping mismatch");
|
|
await client.request({ op: "shutdown" });
|
|
} finally {
|
|
client.close();
|
|
await fs.rm(projectDir, { recursive: true, force: true });
|
|
await fs.rm(runtimeDir, { recursive: true, force: true });
|
|
}
|
|
}
|