const fs = require("fs"); const path = require("path"); const os = require("os"); const { spawn } = require("child_process"); const express = require("express"); const Database = require("better-sqlite3"); const PORT = Number(process.env.PORT || 8081); const HOST = process.env.HOST || "0.0.0.0"; const DB_DIR = path.join(__dirname, "data"); const DB_PATH = path.join(DB_DIR, "rc_timing.sqlite"); const AMMC_CONFIG_PATH = path.join(DB_DIR, "ammc_config.json"); const PID_FILE = process.env.RC_TIMING_PID_FILE || ""; const WATCHED_FILES = [ path.join(__dirname, "index.html"), path.join(__dirname, "src", "app.js"), path.join(__dirname, "src", "styles.css"), ]; fs.mkdirSync(DB_DIR, { recursive: true }); const db = new Database(DB_PATH); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); initSchema(); const appVersion = { revision: 1, updatedAt: new Date().toISOString(), }; watchAppFiles(); const ammcState = { process: null, pid: null, startedAt: null, stoppedAt: null, lastExitCode: null, lastExitSignal: null, lastError: "", lastOutput: [], config: loadAmmcConfig(), }; const app = express(); app.use(express.json({ limit: "20mb" })); app.use((req, res, next) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); if (req.method === "OPTIONS") { res.status(204).end(); return; } next(); }); app.get("/api/health", (_req, res) => { res.json({ ok: true, dbPath: DB_PATH }); }); app.get("/api/app-version", (_req, res) => { res.json(appVersion); }); app.get("/api/ammc/config", (_req, res) => { res.json({ config: ammcState.config, status: buildAmmcStatus(), }); }); app.post("/api/ammc/config", (req, res) => { if (!req.body || typeof req.body !== "object") { res.status(400).json({ error: "Expected JSON body" }); return; } try { ammcState.config = normalizeAmmcConfig(req.body, ammcState.config); saveAmmcConfig(ammcState.config); res.json({ ok: true, config: ammcState.config, status: buildAmmcStatus(), }); } catch (error) { res.status(400).json({ error: error instanceof Error ? error.message : String(error), status: buildAmmcStatus(), }); } }); app.get("/api/ammc/status", (_req, res) => { res.json(buildAmmcStatus()); }); app.post("/api/ammc/start", (_req, res) => { try { startAmmcProcess(); res.json({ ok: true, status: buildAmmcStatus() }); } catch (error) { res.status(400).json({ error: error instanceof Error ? error.message : String(error), status: buildAmmcStatus(), }); } }); app.post("/api/ammc/stop", async (_req, res) => { try { await stopAmmcProcess(); res.json({ ok: true, status: buildAmmcStatus() }); } catch (error) { res.status(500).json({ error: error instanceof Error ? error.message : String(error), status: buildAmmcStatus(), }); } }); app.get("/api/state", (_req, res) => { const row = db.prepare("SELECT state_json, updated_at FROM app_state WHERE id = 1").get(); if (!row) { res.json({ state: null, updatedAt: null }); return; } try { res.json({ state: JSON.parse(row.state_json), updatedAt: row.updated_at }); } catch { res.status(500).json({ error: "Stored app state is invalid JSON" }); } }); app.post("/api/state", (req, res) => { if (!req.body || typeof req.body !== "object") { res.status(400).json({ error: "Expected JSON body" }); return; } const stateJson = JSON.stringify(req.body); const nowIso = new Date().toISOString(); db.prepare( ` INSERT INTO app_state (id, state_json, updated_at) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = excluded.updated_at ` ).run(stateJson, nowIso); res.json({ ok: true, updatedAt: nowIso }); }); app.post("/api/passings", (req, res) => { const { sessionId, passing, sessionResult } = req.body || {}; if (!sessionId || !passing || typeof passing !== "object") { res.status(400).json({ error: "Expected { sessionId, passing }" }); return; } const stmt = db.prepare( ` INSERT INTO passings ( session_id, timestamp_ms, transponder, driver_id, driver_name, car_id, car_name, strength, loop_id, resend, raw_json, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` ); stmt.run( String(sessionId), Number(passing.timestamp || Date.now()), String(passing.transponder || ""), passing.driverId ? String(passing.driverId) : null, passing.driverName ? String(passing.driverName) : null, passing.carId ? String(passing.carId) : null, passing.carName ? String(passing.carName) : null, typeof passing.strength === "number" ? passing.strength : null, passing.loopId ? String(passing.loopId) : null, passing.resend ? 1 : 0, JSON.stringify(passing), new Date().toISOString() ); // Keep app_state hot for overlay clients that hydrate from backend instead of // having their own direct decoder socket. if (sessionResult && typeof sessionResult === "object") { const nowIso = new Date().toISOString(); const row = db.prepare("SELECT state_json FROM app_state WHERE id = 1").get(); if (row?.state_json) { try { const parsed = JSON.parse(row.state_json); if (!parsed.resultsBySession || typeof parsed.resultsBySession !== "object") { parsed.resultsBySession = {}; } parsed.resultsBySession[String(sessionId)] = sessionResult; db.prepare( ` INSERT INTO app_state (id, state_json, updated_at) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = excluded.updated_at ` ).run(JSON.stringify(parsed), nowIso); } catch { // leave app_state untouched if the stored JSON is invalid } } } res.json({ ok: true }); }); app.get("/api/passings", (req, res) => { const sessionId = req.query.sessionId ? String(req.query.sessionId) : null; const limit = Math.min(1000, Math.max(1, Number(req.query.limit || 200))); const rows = sessionId ? db .prepare( ` SELECT * FROM passings WHERE session_id = ? ORDER BY timestamp_ms DESC LIMIT ? ` ) .all(sessionId, limit) : db .prepare( ` SELECT * FROM passings ORDER BY timestamp_ms DESC LIMIT ? ` ) .all(limit); res.json({ rows }); }); app.post("/api/export/pdf", (req, res) => { try { const payload = normalizePdfExportPayload(req.body || {}); const pdf = buildSimplePdf(payload); res.setHeader("Content-Type", "application/pdf"); res.setHeader("Content-Disposition", `attachment; filename="${payload.filename}"`); res.send(pdf); } catch (error) { res.status(400).json({ error: error instanceof Error ? error.message : String(error) }); } }); app.use( express.static(__dirname, { setHeaders: (res, filePath) => { if (filePath.endsWith(".html") || filePath.endsWith(".js") || filePath.endsWith(".css")) { res.setHeader("Cache-Control", "no-store"); } }, }) ); app.get("*", (_req, res) => { res.sendFile(path.join(__dirname, "index.html")); }); app.listen(PORT, HOST, () => { writePidFile(); console.log(`RC Timing server listening on http://${HOST}:${PORT}`); const lanUrls = getLanUrls(PORT); if (lanUrls.length) { console.log(`LAN access: ${lanUrls.join(" , ")}`); } console.log(`SQLite database: ${DB_PATH}`); console.log(`AMMC executable: ${buildAmmcStatus().resolvedExecutablePath}`); maybeStartManagedAmmc(); }); function initSchema() { db.exec(` CREATE TABLE IF NOT EXISTS app_state ( id INTEGER PRIMARY KEY CHECK (id = 1), state_json TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS passings ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, timestamp_ms INTEGER NOT NULL, transponder TEXT NOT NULL, driver_id TEXT, driver_name TEXT, car_id TEXT, car_name TEXT, strength REAL, loop_id TEXT, resend INTEGER NOT NULL DEFAULT 0, raw_json TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_passings_session ON passings(session_id); CREATE INDEX IF NOT EXISTS idx_passings_timestamp ON passings(timestamp_ms); `); } function normalizePdfExportPayload(input = {}) { const title = String(input.title || "JMK RB Live Event").trim(); const subtitle = String(input.subtitle || "").trim(); const brandName = String(input.brandName || "JMK RB").trim(); const brandTagline = String(input.brandTagline || "Live Event").trim(); const footer = String(input.footer || "").trim(); const logoDataUrl = String(input.logoDataUrl || "").trim(); const theme = ["classic", "minimal", "motorsport"].includes(String(input.theme || "").toLowerCase()) ? String(input.theme).toLowerCase() : "classic"; const filenameBase = String(input.filename || "export.pdf").trim() || "export.pdf"; const filename = filenameBase.toLowerCase().endsWith(".pdf") ? filenameBase : `${filenameBase}.pdf`; const sections = Array.isArray(input.sections) ? input.sections.map((section) => ({ title: String(section?.title || "").trim(), headers: Array.isArray(section?.headers) ? section.headers.map((value) => String(value || "").trim()) : [], rows: Array.isArray(section?.rows) ? section.rows.map((row) => (Array.isArray(row) ? row.map((value) => String(value ?? "").trim()) : [String(row ?? "").trim()])) : [], })) : []; return { title, subtitle, brandName, brandTagline, footer, theme, logoDataUrl, filename, sections }; } function buildSimplePdf(payload) { const pageWidth = 595; const pageHeight = 842; const marginLeft = 42; const marginTop = 56; const marginBottom = 56; const lineHeight = 15; const maxChars = 86; const logo = parsePdfLogo(payload.logoDataUrl); const headerOffset = logo ? logo.displayHeight + 18 : 0; const lines = buildPdfLines(payload, maxChars); const linesPerPage = Math.max(10, Math.floor((pageHeight - marginTop - marginBottom - headerOffset) / lineHeight)); const pages = []; for (let index = 0; index < lines.length; index += linesPerPage) { pages.push(lines.slice(index, index + linesPerPage)); } if (!pages.length) { pages.push(["No content"]); } const objects = []; const pageObjectIds = []; const contentObjectIds = []; let nextObjectId = 3; for (let index = 0; index < pages.length; index += 1) { pageObjectIds.push(nextObjectId++); contentObjectIds.push(nextObjectId++); } const fontObjectId = nextObjectId++; const imageObjectId = logo ? nextObjectId++ : null; objects.push(createPdfTextObject(1, "<< /Type /Catalog /Pages 2 0 R >>")); objects.push(createPdfTextObject(2, `<< /Type /Pages /Count ${pages.length} /Kids [${pageObjectIds.map((id) => `${id} 0 R`).join(" ")}] >>`)); objects.push(createPdfTextObject(fontObjectId, "<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>")); if (logo && imageObjectId) { objects.push( createPdfStreamObject( imageObjectId, `<< /Type /XObject /Subtype /Image /Width ${logo.width} /Height ${logo.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode`, logo.data ) ); } for (let index = 0; index < pages.length; index += 1) { const resources = imageObjectId ? `<< /Font << /F1 ${fontObjectId} 0 R >> /XObject << /Im1 ${imageObjectId} 0 R >> >>` : `<< /Font << /F1 ${fontObjectId} 0 R >> >>`; const contentStream = Buffer.from( buildPdfContentStream( pages[index], marginLeft, pageHeight - marginTop - headerOffset, lineHeight, logo ? { x: marginLeft, y: pageHeight - marginTop - logo.displayHeight, width: logo.displayWidth, height: logo.displayHeight, } : null ), "utf8" ); objects.push( createPdfTextObject( pageObjectIds[index], `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources ${resources} /Contents ${contentObjectIds[index]} 0 R >>` ) ); objects.push(createPdfStreamObject(contentObjectIds[index], "<<", contentStream)); } return assemblePdf(objects); } function createPdfTextObject(id, body) { return { id, buffer: Buffer.from(`${id} 0 obj\n${body}\nendobj\n`, "binary"), }; } function createPdfStreamObject(id, dictionaryPrefix, streamBuffer) { return { id, buffer: Buffer.concat([ Buffer.from(`${id} 0 obj\n${dictionaryPrefix} /Length ${streamBuffer.length} >>\nstream\n`, "binary"), streamBuffer, Buffer.from("\nendstream\nendobj\n", "binary"), ]), }; } function assemblePdf(objects) { const ordered = [...objects].sort((left, right) => left.id - right.id); const chunks = [Buffer.from("%PDF-1.4\n", "binary")]; const offsets = [0]; let position = chunks[0].length; ordered.forEach((object) => { offsets[object.id] = position; chunks.push(object.buffer); position += object.buffer.length; }); const xrefOffset = position; const size = ordered.length + 1; let xref = `xref\n0 ${size}\n0000000000 65535 f \n`; for (let id = 1; id < size; id += 1) { xref += `${String(offsets[id] || 0).padStart(10, "0")} 00000 n \n`; } chunks.push(Buffer.from(`${xref}trailer\n<< /Size ${size} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`, "binary")); return Buffer.concat(chunks); } function parsePdfLogo(dataUrl) { const match = /^data:image\/jpeg;base64,([A-Za-z0-9+/=\s]+)$/i.exec(String(dataUrl || "").trim()); if (!match) { return null; } const data = Buffer.from(match[1].replace(/\s+/g, ""), "base64"); const dimensions = getJpegDimensions(data); if (!dimensions) { return null; } const scale = Math.min(150 / dimensions.width, 52 / dimensions.height, 1); return { data, width: dimensions.width, height: dimensions.height, displayWidth: Math.max(1, Math.round(dimensions.width * scale)), displayHeight: Math.max(1, Math.round(dimensions.height * scale)), }; } function getJpegDimensions(buffer) { if (!Buffer.isBuffer(buffer) || buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) { return null; } let offset = 2; while (offset + 1 < buffer.length) { while (offset < buffer.length && buffer[offset] !== 0xff) { offset += 1; } while (offset < buffer.length && buffer[offset] === 0xff) { offset += 1; } if (offset >= buffer.length) { break; } const marker = buffer[offset]; offset += 1; if (marker === 0xd9 || marker === 0xda) { break; } if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { continue; } if (offset + 1 >= buffer.length) { break; } const length = buffer.readUInt16BE(offset); if (length < 2 || offset + length > buffer.length) { break; } if ( (marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf) ) { if (length >= 7) { return { height: buffer.readUInt16BE(offset + 3), width: buffer.readUInt16BE(offset + 5), }; } break; } offset += length; } return null; } function buildPdfLines(payload, maxChars) { const lines = []; const separator = payload.theme === "minimal" ? "" : payload.theme === "motorsport" ? "=".repeat(Math.min(maxChars, 70)) : "-".repeat(Math.min(maxChars, 70)); if (payload.brandName) { lines.push(payload.brandName); } if (payload.brandTagline) { lines.push(payload.brandTagline); } if (separator) { lines.push(separator); } if (payload.brandName || payload.brandTagline) { lines.push(""); } lines.push(payload.title); if (payload.subtitle) { lines.push(payload.subtitle); } payload.sections.forEach((section) => { lines.push(""); if (section.title) { lines.push(payload.theme === "motorsport" ? `> ${section.title}` : section.title); } if (section.headers.length) { const widths = getColumnWidths(section.headers, section.rows, maxChars); lines.push(formatPdfRow(section.headers, widths)); lines.push(formatPdfRow(widths.map((width) => (payload.theme === "minimal" ? " ".repeat(Math.min(width, 1)) : "-".repeat(Math.min(width, 12)))), widths)); section.rows.forEach((row) => { lines.push(formatPdfRow(row, widths)); }); } else { section.rows.forEach((row) => { row.forEach((value) => { wrapPdfText(value, maxChars).forEach((line) => lines.push(line)); }); }); } }); if (payload.footer) { lines.push(""); lines.push(payload.footer); } return lines; } function getColumnWidths(headers, rows, maxChars) { const widths = headers.map((header, index) => { const columnValues = rows.map((row) => String(row[index] || "")); const longest = Math.max(header.length, ...columnValues.map((value) => value.length), 4); return Math.min(26, Math.max(4, longest)); }); let total = widths.reduce((sum, width) => sum + width, 0) + Math.max(0, (widths.length - 1) * 3); while (total > maxChars) { const widestIndex = widths.findIndex((width) => width === Math.max(...widths)); if (widths[widestIndex] <= 4) { break; } widths[widestIndex] -= 1; total -= 1; } return widths; } function formatPdfRow(values, widths) { return values .map((value, index) => String(value || "").slice(0, widths[index]).padEnd(widths[index], " ")) .join(" | "); } function wrapPdfText(text, maxChars) { const words = String(text || "").split(/\s+/).filter(Boolean); if (!words.length) { return [""]; } const lines = []; let current = ""; words.forEach((word) => { const next = current ? `${current} ${word}` : word; if (next.length > maxChars) { if (current) { lines.push(current); current = word; } else { lines.push(word.slice(0, maxChars)); current = word.slice(maxChars); } } else { current = next; } }); if (current) { lines.push(current); } return lines; } function buildPdfContentStream(lines, x, y, lineHeight, logoPlacement = null) { const escaped = lines.map((line) => escapePdfText(line)); const commands = []; if (logoPlacement) { commands.push("q"); commands.push(`${logoPlacement.width} 0 0 ${logoPlacement.height} ${logoPlacement.x} ${logoPlacement.y} cm`); commands.push("/Im1 Do"); commands.push("Q"); } commands.push( "BT", "/F1 11 Tf", `${lineHeight} TL`, `${x} ${y} Td`, ...escaped.map((line) => `(${line}) Tj\nT*`), "ET" ); return commands.join("\n"); } function escapePdfText(text) { return String(text || "") .replaceAll("\\", "\\\\") .replaceAll("(", "\\(") .replaceAll(")", "\\)"); } function getLanUrls(port) { const interfaces = os.networkInterfaces(); const urls = []; Object.values(interfaces).forEach((records) => { (records || []).forEach((rec) => { if (rec && rec.family === "IPv4" && !rec.internal) { urls.push(`http://${rec.address}:${port}`); } }); }); return urls; } function watchAppFiles() { WATCHED_FILES.forEach((filePath) => { if (!fs.existsSync(filePath)) { return; } fs.watchFile(filePath, { interval: 1000 }, (curr, prev) => { if (curr.mtimeMs !== prev.mtimeMs) { bumpAppVersion(); } }); }); } function bumpAppVersion() { appVersion.revision += 1; appVersion.updatedAt = new Date().toISOString(); } function getDefaultAmmcExecutablePath(platform = process.platform) { if (platform === "win32") { return path.join(__dirname, "AMMC", "windows64", "ammc-amb.exe"); } if (platform === "darwin") { return path.join(__dirname, "AMMC", "apple_m", "ammc-amb"); } return path.join(__dirname, "AMMC", "linux_x86-64", "ammc-amb"); } function normalizeAmmcConfig(input = {}, previous = {}) { const decoderHost = String(input.decoderHost ?? previous.decoderHost ?? "").trim(); const executablePath = String(input.executablePath ?? previous.executablePath ?? getDefaultAmmcExecutablePath()).trim(); const workingDirectory = String(input.workingDirectory ?? previous.workingDirectory ?? "").trim(); const extraArgs = String(input.extraArgs ?? previous.extraArgs ?? "").trim(); const wsPortValue = Number(input.wsPort ?? previous.wsPort ?? 9000); if (!Number.isFinite(wsPortValue) || wsPortValue < 1 || wsPortValue > 65535) { throw new Error("AMMC WebSocket port must be between 1 and 65535"); } return { managedEnabled: Boolean(input.managedEnabled ?? previous.managedEnabled ?? false), autoStart: Boolean(input.autoStart ?? previous.autoStart ?? false), decoderHost, wsPort: Math.round(wsPortValue), executablePath, workingDirectory, extraArgs, updatedAt: new Date().toISOString(), }; } function loadAmmcConfig() { if (!fs.existsSync(AMMC_CONFIG_PATH)) { return normalizeAmmcConfig({ managedEnabled: false, autoStart: false, decoderHost: "", wsPort: 9000, executablePath: getDefaultAmmcExecutablePath(), workingDirectory: "", extraArgs: "", }); } try { const raw = fs.readFileSync(AMMC_CONFIG_PATH, "utf8"); return normalizeAmmcConfig(JSON.parse(raw)); } catch (error) { console.error(`Failed to load AMMC config: ${error instanceof Error ? error.message : String(error)}`); return normalizeAmmcConfig({ managedEnabled: false, autoStart: false, decoderHost: "", wsPort: 9000, executablePath: getDefaultAmmcExecutablePath(), workingDirectory: "", extraArgs: "", }); } } function saveAmmcConfig(config) { fs.writeFileSync(AMMC_CONFIG_PATH, JSON.stringify(config, null, 2)); } function resolveAmmcExecutable(executablePath) { const value = String(executablePath || "").trim(); if (!value) { return getDefaultAmmcExecutablePath(); } if (path.isAbsolute(value)) { return value; } return path.join(__dirname, value); } function resolveAmmcWorkingDirectory(config, executablePath) { const explicitDir = String(config.workingDirectory || "").trim(); if (!explicitDir) { return path.dirname(executablePath); } return path.isAbsolute(explicitDir) ? explicitDir : path.join(__dirname, explicitDir); } function parseExtraArgs(extraArgs) { return String(extraArgs || "") .split(/\s+/) .map((part) => part.trim()) .filter(Boolean); } function isAmmcRunning() { return Boolean(ammcState.process && ammcState.process.exitCode == null && !ammcState.process.killed); } function buildAmmcStatus() { const resolvedExecutablePath = resolveAmmcExecutable(ammcState.config.executablePath); const workingDirectory = resolveAmmcWorkingDirectory(ammcState.config, resolvedExecutablePath); return { running: isAmmcRunning(), pid: ammcState.pid, startedAt: ammcState.startedAt, stoppedAt: ammcState.stoppedAt, lastExitCode: ammcState.lastExitCode, lastExitSignal: ammcState.lastExitSignal, lastError: ammcState.lastError, lastOutput: ammcState.lastOutput, serverPlatform: process.platform, executableExists: fs.existsSync(resolvedExecutablePath), resolvedExecutablePath, workingDirectory, }; } function appendAmmcOutput(stream, chunk) { const lines = String(chunk || "") .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); lines.forEach((line) => { ammcState.lastOutput.push({ ts: new Date().toISOString(), stream, line, }); }); if (ammcState.lastOutput.length > 30) { ammcState.lastOutput.splice(0, ammcState.lastOutput.length - 30); } } function startAmmcProcess() { if (isAmmcRunning()) { return buildAmmcStatus(); } const config = normalizeAmmcConfig(ammcState.config, ammcState.config); ammcState.config = config; saveAmmcConfig(config); if (!config.managedEnabled) { throw new Error("Enable managed AMMC first"); } if (!config.decoderHost) { throw new Error("Set decoder IP/host before starting AMMC"); } const executablePath = resolveAmmcExecutable(config.executablePath); if (!fs.existsSync(executablePath)) { throw new Error(`AMMC executable not found: ${executablePath}`); } const args = ["-w", String(config.wsPort), config.decoderHost, ...parseExtraArgs(config.extraArgs)]; const workingDirectory = resolveAmmcWorkingDirectory(config, executablePath); ammcState.lastError = ""; appendAmmcOutput("system", `Starting ${path.basename(executablePath)} ${args.join(" ")}`); const child = spawn(executablePath, args, { cwd: workingDirectory, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }); ammcState.process = child; ammcState.pid = child.pid || null; ammcState.startedAt = new Date().toISOString(); ammcState.stoppedAt = null; ammcState.lastExitCode = null; ammcState.lastExitSignal = null; child.stdout.on("data", (chunk) => appendAmmcOutput("stdout", chunk)); child.stderr.on("data", (chunk) => appendAmmcOutput("stderr", chunk)); child.on("error", (error) => { ammcState.lastError = error instanceof Error ? error.message : String(error); appendAmmcOutput("error", ammcState.lastError); }); child.on("exit", (code, signal) => { ammcState.lastExitCode = code; ammcState.lastExitSignal = signal; ammcState.stoppedAt = new Date().toISOString(); appendAmmcOutput("system", `AMMC stopped (code=${code}, signal=${signal || "none"})`); ammcState.process = null; ammcState.pid = null; }); return buildAmmcStatus(); } function stopAmmcProcess() { if (!isAmmcRunning() || !ammcState.process) { return Promise.resolve(buildAmmcStatus()); } const child = ammcState.process; return new Promise((resolve) => { let settled = false; const finish = () => { if (!settled) { settled = true; resolve(buildAmmcStatus()); } }; child.once("exit", finish); child.kill(); setTimeout(() => { if (!settled && isAmmcRunning()) { if (process.platform !== "win32") { child.kill("SIGKILL"); } finish(); } }, 3000); }); } function maybeStartManagedAmmc() { if (!ammcState.config.managedEnabled || !ammcState.config.autoStart) { return; } try { startAmmcProcess(); console.log("Managed AMMC started"); } catch (error) { const msg = error instanceof Error ? error.message : String(error); ammcState.lastError = msg; console.error(`Managed AMMC failed to start: ${msg}`); } } async function shutdown() { await stopAmmcProcess(); removePidFile(); process.exit(0); } process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); process.on("exit", removePidFile); function writePidFile() { if (!PID_FILE) { return; } try { fs.writeFileSync(PID_FILE, `${process.pid}\n`); } catch (error) { console.error(`Failed to write pid file: ${error instanceof Error ? error.message : String(error)}`); } } function removePidFile() { if (!PID_FILE || !fs.existsSync(PID_FILE)) { return; } try { const raw = fs.readFileSync(PID_FILE, "utf8").trim(); if (String(process.pid) === raw) { fs.rmSync(PID_FILE, { force: true }); } } catch { // ignore pid cleanup errors on shutdown } }