// src/services/subprocess.ts // Copyright (C) 2026 Rob Colbert // Licensed under the Apache License, Version 2.0 import path from "node:path"; import fs from "node:fs"; import { ChildProcess, spawn } from "node:child_process"; import { GadgetId, IProject } from "@gadget/api"; import WorkspaceService from "./workspace.js"; import { GadgetService } from "../lib/service.ts"; export interface SubProcess { project: IProject; createdAt: Date; updatedAt: Date; process: ChildProcess; pid: number; stdoutFileName: string; stderrFileName: string; } export interface SubProcessSummary { pid: number; project: IProject; createdAt: Date; updatedAt: Date; stdoutFileName: string; stderrFileName: string; } type SubProcessMap = Map; const GRACEFUL_SHUTDOWN_MS = 5_000; class SubProcessService extends GadgetService { private processMap: SubProcessMap = new Map(); get name(): string { return "SubprocessService"; } get slug(): string { return "svc:subprocess"; } async start(): Promise { this.log.info("started"); } async stop(): Promise { await this.killAll(); this.log.info("stopped"); } async create( project: IProject, cmd: string, args?: string[], ): Promise { const NOW = new Date(); const gadgetDir = WorkspaceService.gadgetDir; if (!gadgetDir) { throw new Error("Gadget workspace directory not initialized"); } const logDir = path.join(gadgetDir, "subprocess-logs"); await fs.promises.mkdir(logDir, { recursive: true }); const projectDir = WorkspaceService.getProjectDirectory(project.slug); const spawnArgs = args ?? []; const useShell = spawnArgs.length === 0; const child = spawn(cmd, spawnArgs, { cwd: projectDir, shell: useShell }); child.on("error", (err) => { this.log.error("subprocess spawn failed", { cmd, args, error: err.message }); }); if (!child.pid) { throw new Error("Failed to spawn subprocess: child process has no pid"); } const pid = child.pid; const stdoutFileName = path.join(logDir, `subproc-${pid}.stdout.log`); const stderrFileName = path.join(logDir, `subproc-${pid}.stderr.log`); const stdoutFile = fs.createWriteStream(stdoutFileName, "utf-8"); const stderrFile = fs.createWriteStream(stderrFileName, "utf-8"); const sp: SubProcess = { project, createdAt: NOW, updatedAt: NOW, process: child, pid, stdoutFileName, stderrFileName, }; child.stdout.on("data", (data: any) => { sp.updatedAt = new Date(); stdoutFile.write(data); }); child.stderr.on("data", (data: any) => { sp.updatedAt = new Date(); stderrFile.write(data); }); child.on("exit", () => { stdoutFile.close(); stderrFile.close(); }); this.processMap.set(pid, sp); return sp; } ps(): SubProcess[] { return Array.from(this.processMap.values()); } summarize(sp: SubProcess): SubProcessSummary { return { pid: sp.pid, project: sp.project, createdAt: sp.createdAt, updatedAt: sp.updatedAt, stdoutFileName: sp.stdoutFileName, stderrFileName: sp.stderrFileName, }; } async killPid(pid: number): Promise { const sp = this.processMap.get(pid); if (!sp) { throw new Error("process not found"); } return this.killSubProcess(sp); } async killProject(projectId: GadgetId): Promise { for (const sp of this.processMap.values()) { if (sp.project._id !== projectId) { continue; } await this.killSubProcess(sp); } } async killSubProcess(sp: SubProcess): Promise { await this.terminateProcess(sp); await this.removeLogFiles(sp); this.processMap.delete(sp.pid); this.log.info("subprocess killed", { pid: sp.pid }); return true; } async haltSubProcess(sp: SubProcess): Promise { await this.terminateProcess(sp); this.processMap.delete(sp.pid); this.log.info("subprocess halted, logs retained", { pid: sp.pid }); return true; } async killAll(): Promise { const entries = Array.from(this.processMap.entries()); for (const [pid] of entries) { const sp = this.processMap.get(pid); if (sp) { await this.killSubProcess(sp); } } return true; } async haltPid(pid: number): Promise { const sp = this.processMap.get(pid); if (!sp) { throw new Error("process not found"); } return this.haltSubProcess(sp); } async getLog( pid: number, stream: "stdout" | "stderr", ): Promise { const sp = this.processMap.get(pid); if (!sp) { throw new Error("process not found"); } const filePath = stream === "stdout" ? sp.stdoutFileName : sp.stderrFileName; try { return await fs.promises.readFile(filePath, "utf-8"); } catch { return ""; } } private async terminateProcess(sp: SubProcess): Promise { const pid = sp.pid; this.log.info("terminating subprocess", { pid }); const exited = new Promise((resolve) => { sp.process.on("exit", () => resolve()); }); sp.process.stdout?.removeAllListeners(); sp.process.stderr?.removeAllListeners(); sp.process.kill("SIGINT"); const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("graceful shutdown timeout")), GRACEFUL_SHUTDOWN_MS), ); try { await Promise.race([exited, timeout]); } catch { this.log.warn("subprocess did not exit gracefully, sending SIGKILL", { pid, }); sp.process.kill("SIGKILL"); try { await Promise.race([ exited, new Promise((_, reject) => setTimeout(() => reject(new Error("SIGKILL timeout")), GRACEFUL_SHUTDOWN_MS), ), ]); } catch { this.log.warn("subprocess did not respond to SIGKILL", { pid }); } } } private async removeLogFiles(sp: SubProcess): Promise { await fs.promises.rm(sp.stdoutFileName, { force: true }); await fs.promises.rm(sp.stderrFileName, { force: true }); } } export default new SubProcessService();