import { x, type Result } from "tinyexec"; import * as v from "valibot"; import type { Logger } from "./logger.ts"; export const ProcessDefinitionSchema = v.object({ command: v.string(), args: v.optional(v.array(v.string())), cwd: v.optional(v.string()), env: v.optional(v.record(v.string(), v.string())), }); export type ProcessDefinition = v.InferOutput; export const ProcessStateSchema = v.picklist([ "idle", "starting", "running", "stopping", "stopped", "error", ]); export type ProcessState = v.InferOutput; export class LazyProcess { readonly name: string; private definition: ProcessDefinition; private logger: Logger; private process: Result | null = null; private _state: ProcessState = "idle"; private outputLoopPromise: Promise | null = null; private donePromise: Promise | null = null; constructor(name: string, definition: ProcessDefinition, logger: Logger) { this.name = name; this.definition = definition; this.logger = logger; } get state(): ProcessState { return this._state; } start(): void { if (this._state === "running" || this._state === "starting") { throw new Error(`Process "${this.name}" is already ${this._state}`); } if (this._state === "stopping") { throw new Error(`Process "${this.name}" is currently stopping`); } this._state = "starting"; this.logger.info(`Starting process: ${this.definition.command}`); try { this.process = x(this.definition.command, this.definition.args ?? [], { nodeOptions: { cwd: this.definition.cwd, env: this.definition.env ? { ...process.env, ...this.definition.env } : undefined, }, }); this._state = "running"; // Start async output logging loop this.outputLoopPromise = this.logOutput(); // Handle process completion this.donePromise = Promise.resolve(this.process) .then((result) => { if (this._state === "running") { if (result.exitCode === 0) { this._state = "stopped"; this.logger.info(`Process exited with code ${result.exitCode}`); } else { this._state = "error"; this.logger.error(`Process exited with code ${result.exitCode}`); } } return this._state; }) .catch((err) => { if (this._state !== "stopping" && this._state !== "stopped") { this._state = "error"; this.logger.error(`Process error:`, err); } return this._state; }); } catch (err) { this._state = "error"; this.logger.error(`Failed to start process:`, err); throw err; } } async stop(timeout?: number): Promise { if (this._state === "idle" || this._state === "stopped" || this._state === "error") { return; } if (this._state === "stopping") { // Already stopping, wait for completion await this.waitForStop(); return; } if (!this.process) { this._state = "stopped"; return; } this._state = "stopping"; this.logger.info(`Stopping process with SIGTERM`); // Send SIGTERM for graceful shutdown this.process.kill("SIGTERM"); const timeoutMs = timeout ?? 5000; // Wait for process to exit or timeout // Wrap PromiseLike in Promise.resolve to get proper Promise with .catch() const exitPromise = Promise.resolve(this.process).catch(() => {}); const timeoutPromise = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), timeoutMs), ); const result = await Promise.race([exitPromise.then(() => "exited" as const), timeoutPromise]); if (result === "timeout") { this.logger.warn(`Process did not exit within ${timeoutMs}ms, sending SIGKILL`); this.process.kill("SIGKILL"); await Promise.resolve(this.process).catch(() => {}); } // Wait for output loop to finish if (this.outputLoopPromise) { await this.outputLoopPromise.catch(() => {}); } this._state = "stopped"; this.process = null; this.outputLoopPromise = null; this.logger.info(`Process stopped`); } async reset(): Promise { if (this._state !== "idle" && this._state !== "stopped") { await this.stop(); } this._state = "idle"; this.process = null; this.outputLoopPromise = null; this.donePromise = null; this.logger.info(`Process reset to idle`); } updateDefinition(definition: ProcessDefinition): void { this.definition = definition; } async waitForExit(): Promise { if (!this.process || !this.donePromise) { return this._state; } await this.donePromise.catch(() => {}); if (this.outputLoopPromise) { await this.outputLoopPromise.catch(() => {}); } return this._state; } private async logOutput(): Promise { if (!this.process) return; try { for await (const line of this.process) { this.logger.info(line); } } catch { // Process may have been killed, ignore iteration errors } } private async waitForStop(): Promise { if (this.process) { await Promise.resolve(this.process).catch(() => {}); } if (this.outputLoopPromise) { await this.outputLoopPromise.catch(() => {}); } } }