diff --git a/bin/cli/commands/serve.mjs b/bin/cli/commands/serve.mjs index 284d765dfc..004b4815ac 100644 --- a/bin/cli/commands/serve.mjs +++ b/bin/cli/commands/serve.mjs @@ -20,6 +20,7 @@ import { buildNodeHeapArgs, } from "../../../scripts/build/runtime-env.mjs"; import { resolveTlsOptions } from "../../../scripts/dev/tls-options.mjs"; +import { startDetachedTray, validateTrayOptions } from "../tray/detachedTray.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const _pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "..", "package.json"), "utf8")); @@ -42,7 +43,7 @@ function parsePort(value, fallback) { } export function registerServe(program) { - program + const command = program .command("serve", { isDefault: true }) .description(t("serve.description")) .option("--port ", t("serve.port")) @@ -51,7 +52,7 @@ export function registerServe(program) { .option("--log", t("serve.log")) .option("--no-recovery", t("serve.no_recovery")) .option("--max-restarts ", t("serve.max_restarts"), parseInt, 2) - .option("--tray", t("serve.tray") || "Show system tray icon (desktop only)") + .option("--tray", t("serve.tray") || "Start in the system tray (desktop only)") .option("--no-tray", t("serve.no_tray") || "Disable system tray icon") .option( "--tls-cert ", @@ -66,6 +67,9 @@ export function registerServe(program) { .action(async (opts) => { await runServe(opts); }); + command.addOption(command.createOption("--tray-worker").hideHelp()); + command.addOption(command.createOption("--tray-ready-port ").hideHelp()); + command.addOption(command.createOption("--tray-ready-token ").hideHelp()); } /** Once-per-process guard so the Android/Termux cache hint is not spammed. */ @@ -95,6 +99,32 @@ export function resetInstrumentationFailureHintForTests() { export async function runServe(opts = {}) { const startedAt = performance.now(); + const trayOptionError = validateTrayOptions(opts); + if (trayOptionError) throw new Error(trayOptionError); + + if (opts.tray === true && opts.trayWorker !== true) { + const port = parsePort(opts.port ?? process.env.PORT ?? "20128", 20128); + const tlsCert = opts.tlsCert ?? process.env.OMNIROUTE_TLS_CERT; + const tlsKey = opts.tlsKey ?? process.env.OMNIROUTE_TLS_KEY; + urlScheme = resolveTlsOptions({ + ...process.env, + ...(tlsCert ? { OMNIROUTE_TLS_CERT: tlsCert } : {}), + ...(tlsKey ? { OMNIROUTE_TLS_KEY: tlsKey } : {}), + }) + ? "https" + : "http"; + const result = await startDetachedTray({ + cliPath: join(ROOT, "bin", "omniroute.mjs"), + port, + maxRestarts: opts.maxRestarts ?? 2, + tlsCert, + tlsKey, + }); + console.log(`\x1b[32m✔ OmniRoute tray started in background\x1b[0m`); + console.log(` \x1b[1mDashboard:\x1b[0m ${urlScheme}://localhost:${port}`); + return result; + } + // Same prep as bin/omniroute.mjs — keep it here so a direct `runServe()` call // (tests / programmatic) still gets a writable Next.js cache dir before spawn. ensureAndroidCacheDir({ env: process.env }); @@ -255,7 +285,8 @@ export async function runServe(opts = {}) { opts.log === true, opts.maxRestarts ?? 2, startedAt, - useTray + useTray, + { trayReadyPort: opts.trayReadyPort, trayReadyToken: opts.trayReadyToken } ); } @@ -368,9 +399,11 @@ async function runWithSupervisor( showLog, maxRestarts, startedAt, - useTray = false + useTray = false, + { trayReadyPort, trayReadyToken } = {} ) { if (showLog) process.env.OMNIROUTE_SHOW_LOG = "1"; + writePidFile("supervisor", process.pid); const supervisor = new ServerSupervisor({ serverPath: serverJs, @@ -394,17 +427,38 @@ async function runWithSupervisor( process.on("SIGINT", () => { killTrayIfActive(); + cleanupPidFile("supervisor"); supervisor.stop(); }); process.on("SIGTERM", () => { killTrayIfActive(); + cleanupPidFile("supervisor"); supervisor.stop(); }); if (!showLog) { waitForServer(dashboardPort, 60000).then(async (up) => { if (up) { - if (useTray) await maybeStartTray(dashboardPort, apiPort, supervisor); + if (useTray) { + const trayReady = await maybeStartTray(dashboardPort, apiPort, supervisor); + if (!trayReady) { + cleanupPidFile("supervisor"); + supervisor.stop(); + process.exitCode = 1; + return; + } + if (trayReadyPort && trayReadyToken) { + const { notifyTrayReady } = await import("../tray/detachedTray.mjs"); + try { + await notifyTrayReady(parsePort(trayReadyPort, 0), trayReadyToken); + } catch { + cleanupPidFile("supervisor"); + supervisor.stop(); + process.exitCode = 1; + return; + } + } + } onReady(dashboardPort, apiPort, noOpen, startedAt); } else { reportReadinessTimeout(dashboardPort, supervisor); @@ -451,29 +505,30 @@ function killTrayIfActive() { async function maybeStartTray(port, apiPort, supervisor) { try { const { initTray, isTraySupported } = await import("../tray/index.mjs"); - if (!isTraySupported()) return; + if (!isTraySupported()) return false; const { default: open } = await import("open").catch(() => ({ default: null })); const dashboardUrl = `${urlScheme}://localhost:${port}`; const tray = await initTray({ port, onQuit: () => { killTrayIfActive(); + cleanupPidFile("supervisor"); supervisor.stop(); }, onOpenDashboard: () => open?.(dashboardUrl), - onShowLogs: () => { - // In-place: open logs stream (best-effort) - process.stdout.write(`[omniroute][tray] Logs at: ${dashboardUrl}/logs\n`); - }, + onShowLogs: () => open?.(`${dashboardUrl}/dashboard/logs`), }); if (tray) { const { killTray } = await import("../tray/index.mjs"); _killTray = killTray; + return true; } + return false; } catch (err) { // tray is optional — do not fail the server, but surface why it failed so // "--tray shows nothing" is diagnosable instead of silent (#4605). process.stderr.write(`[omniroute][tray] failed to start: ${err?.message ?? String(err)}\n`); + return false; } } diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index 442df57300..3ed2f2dbcf 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -254,7 +254,7 @@ "log": "Show server logs inline", "no_recovery": "Disable auto-restart on crash (debugging mode)", "max_restarts": "Max crash restarts within 30s before giving up (default: 2)", - "tray": "Show system tray icon (desktop only, opt-in)", + "tray": "Start in the system tray (desktop only, opt-in)", "no_tray": "Disable system tray icon", "tls_cert": "Path to a TLS certificate (PEM) to serve HTTPS (also OMNIROUTE_TLS_CERT)", "tls_key": "Path to the TLS private key (PEM) to serve HTTPS (also OMNIROUTE_TLS_KEY)" diff --git a/bin/cli/tray/autostart.mjs b/bin/cli/tray/autostart.mjs index a61a5c739a..3462c2711f 100644 --- a/bin/cli/tray/autostart.mjs +++ b/bin/cli/tray/autostart.mjs @@ -276,6 +276,10 @@ function isAgentSelfMac() { } } +function isDetachedTrayWorker() { + return process.argv.includes("--tray-worker"); +} + function enableMac() { const plistDir = join(homedir(), "Library", "LaunchAgents"); mkdirSync(plistDir, { recursive: true }); @@ -300,7 +304,7 @@ function enableMac() { // If we're already the running agent, launchctl load/unload would SIGTERM us. // The plist is updated on disk and launchd already has us loaded under our own // PID — nothing more to do for the current session. - if (isAgentSelfMac()) return existsSync(plistPath); + if (isAgentSelfMac() || isDetachedTrayWorker()) return existsSync(plistPath); try { execSync("launchctl load -w " + JSON.stringify(plistPath), { stdio: "ignore" }); } catch {} @@ -313,7 +317,7 @@ function disableMac() { // `launchctl unload` sends SIGTERM and a user clicking "Disable Autostart" // from the tray would lose the tray icon instead of just flipping the label. // Removing the plist file is enough to stop the agent at the next login. - if (!isAgentSelfMac()) { + if (!isAgentSelfMac() && !isDetachedTrayWorker()) { try { execSync("launchctl unload -w " + JSON.stringify(plistPath), { stdio: "ignore" }); } catch {} diff --git a/bin/cli/tray/detachedTray.mjs b/bin/cli/tray/detachedTray.mjs new file mode 100644 index 0000000000..310c3fe545 --- /dev/null +++ b/bin/cli/tray/detachedTray.mjs @@ -0,0 +1,176 @@ +import { execFileSync, spawn } from "node:child_process"; +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { createServer, connect } from "node:net"; + +/** Builds arguments for the hidden process that owns the server and tray. */ +export function buildTrayWorkerArgs({ port, maxRestarts, readyPort, readyToken, tlsCert, tlsKey }) { + const args = [ + "serve", + "--tray", + "--tray-worker", + "--no-open", + "--port", + String(port), + "--max-restarts", + String(maxRestarts), + "--tray-ready-port", + String(readyPort), + "--tray-ready-token", + readyToken, + ]; + if (tlsCert) args.push("--tls-cert", tlsCert); + if (tlsKey) args.push("--tls-key", tlsKey); + return args; +} + +/** Builds the platform command that starts the hidden tray worker. */ +export function buildTrayLaunch({ platform, execPath, cliPath, workerArgs, label }) { + if (platform === "darwin") { + return { + command: "launchctl", + args: ["submit", "-l", label, "--", execPath, cliPath, ...workerArgs], + options: { stdio: "ignore" }, + }; + } + return { + command: execPath, + args: [cliPath, ...workerArgs], + options: { detached: true, stdio: "ignore", windowsHide: true }, + }; +} + +/** Returns an error for command modes that conflict with detached tray mode. */ +export function validateTrayOptions(opts) { + if (opts.trayWorker && (!opts.trayReadyPort || !opts.trayReadyToken)) { + return "tray worker requires readiness credentials"; + } + if (!opts.tray || opts.trayWorker) return null; + if (opts.daemon) return "--tray cannot use --daemon"; + if (opts.log) return "--tray cannot use --log"; + if (opts.noRecovery || opts.recovery === false) return "--tray cannot use --no-recovery"; + return null; +} + +/** Creates a token-protected loopback server for tray worker readiness. */ +export async function createTrayReadinessServer(token) { + let markReady; + const ready = new Promise((resolve) => { + markReady = resolve; + }); + const expected = Buffer.from(token); + const server = createServer((socket) => { + let data = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk) => { + data += chunk; + if (data.length > 256) socket.destroy(); + }); + socket.on("end", () => { + const received = Buffer.from(data); + if (received.length !== expected.length || !timingSafeEqual(received, expected)) { + socket.end("ERROR"); + return; + } + socket.end("READY"); + markReady(); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + return { + port: address.port, + wait(timeoutMs) { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("Tray worker did not become ready")), + timeoutMs + ); + ready.then(() => { + clearTimeout(timer); + resolve(); + }); + }); + }, + close() { + server.close(); + }, + }; +} + +/** Notifies the parent process that the server and tray are ready. */ +export async function notifyTrayReady(port, token) { + await new Promise((resolve, reject) => { + const socket = connect({ host: "127.0.0.1", port }, () => socket.end(token)); + let reply = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk) => { + reply += chunk; + }); + socket.on("end", () => { + if (reply === "READY") resolve(); + else reject(new Error("Tray readiness token was rejected")); + }); + socket.on("error", reject); + }); +} + +/** Starts a detached tray worker and waits until its server and tray are ready. */ +export async function startDetachedTray( + { cliPath, port, maxRestarts, tlsCert, tlsKey, timeoutMs = 60000 }, + { platform = process.platform, spawnProcess = spawn } = {} +) { + const token = randomBytes(32).toString("hex"); + const readiness = await createTrayReadinessServer(token); + const label = `com.omniroute.tray.${process.pid}.${Date.now()}`; + const workerArgs = buildTrayWorkerArgs({ + port, + maxRestarts, + readyPort: readiness.port, + readyToken: token, + tlsCert, + tlsKey, + }); + const launch = buildTrayLaunch({ + platform, + execPath: process.execPath, + cliPath, + workerArgs, + label, + }); + const child = spawnProcess(launch.command, launch.args, launch.options); + const spawnFailure = new Promise((_, reject) => { + child.once("error", reject); + child.once("exit", (code) => { + if (platform !== "darwin" || code !== 0) { + reject(new Error(`Tray worker exited before readiness with code ${code ?? "unknown"}`)); + } + }); + }); + if (platform !== "darwin") child.unref?.(); + try { + await Promise.race([readiness.wait(timeoutMs), spawnFailure]); + return { platform, pid: child.pid, label: platform === "darwin" ? label : null }; + } catch (err) { + if (platform === "darwin") { + try { + execFileSync("launchctl", ["bootout", `gui/${process.getuid()}/${label}`], { + stdio: "ignore", + }); + } catch {} + } else if (platform === "win32" && child.pid) { + try { + execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }); + } catch {} + } else if (child.pid) { + try { + process.kill(child.pid, "SIGTERM"); + } catch {} + } + throw err; + } finally { + readiness.close(); + } +} diff --git a/bin/cli/tray/traySystray.mjs b/bin/cli/tray/traySystray.mjs index c7916a4108..e720e3d45d 100644 --- a/bin/cli/tray/traySystray.mjs +++ b/bin/cli/tray/traySystray.mjs @@ -97,9 +97,7 @@ export async function initSystrayUnix( } }); - tray.ready().catch((err) => { - process.stderr.write(`[omniroute][tray] systray2 failed: ${err?.message ?? String(err)}\n`); - }); + await tray.ready(); return tray; } diff --git a/changelog.d/features/unreleased-detached-cli-tray.md b/changelog.d/features/unreleased-detached-cli-tray.md new file mode 100644 index 0000000000..e556a57dc0 --- /dev/null +++ b/changelog.d/features/unreleased-detached-cli-tray.md @@ -0,0 +1 @@ +- **feat(cli):** run `omniroute serve --tray` as a detached desktop process after server and tray readiness, with graphical login auto-start support. diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 8e0926137a..9f5c79ee4a 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -357,6 +357,49 @@ omniroute --port 3000 The CLI automatically loads `.env` from `~/.omniroute/.env` or `./.env`. +### Tray mode + +Start OmniRoute in the system tray: + +```bash +omniroute serve --tray +``` + +The command returns after the server and tray are ready. + +The server continues without the terminal. + +Tray mode supports macOS, Windows, and graphical Linux sessions. Tray mode does not open the dashboard automatically. + +Use the tray menu for these actions: + +- Open the dashboard. +- Open `/dashboard/logs`. +- Change auto-start. +- Stop OmniRoute. + +Do not combine `--tray` with these options: + +- `--daemon` +- `--log` +- `--no-recovery` + +These modes require different process ownership. + +Enable startup at the next machine login: + +```bash +omniroute autostart enable +``` + +Auto-start uses tray mode on macOS, Windows, and graphical Linux sessions. Headless Linux uses the existing systemd user service. + +Disable startup at login: + +```bash +omniroute autostart disable +``` + ### Uninstalling When you no longer need OmniRoute, we provide two quick scripts for a clean removal: diff --git a/tests/unit/cli-serve-startup-time.test.ts b/tests/unit/cli-serve-startup-time.test.ts index 2d6c418a55..710e070e79 100644 --- a/tests/unit/cli-serve-startup-time.test.ts +++ b/tests/unit/cli-serve-startup-time.test.ts @@ -30,14 +30,14 @@ test("serve daemon mode does not accept startedAt", () => { test("serve runWithSupervisor uses startedAt before defaulted useTray", () => { const signatureRegex = - /async\s+function\s+runWithSupervisor\s*\([\s\S]*?startedAt\s*,\s*useTray\s*=\s*false\s*\)/; + /async\s+function\s+runWithSupervisor\s*\([\s\S]*?startedAt\s*,\s*useTray\s*=\s*false\s*,/; assert.match( serveSource, signatureRegex, "runWithSupervisor should declare startedAt before the defaulted useTray parameter" ); - const callRegex = /runWithSupervisor\s*\([\s\S]*?startedAt\s*,\s*useTray\s*\)/; + const callRegex = /runWithSupervisor\s*\([\s\S]*?startedAt\s*,\s*useTray\s*,/; assert.match( serveSource, callRegex, diff --git a/tests/unit/cli/autostart-linux.test.ts b/tests/unit/cli/autostart-linux.test.ts index 26bb63de20..100d13828e 100644 --- a/tests/unit/cli/autostart-linux.test.ts +++ b/tests/unit/cli/autostart-linux.test.ts @@ -75,7 +75,8 @@ test("resolveCliPath finds omniroute.mjs from argv", async () => { if (existsSync(desktopPath)) { const desktop = readFileSync(desktopPath, "utf8"); - assert.match(desktop, /Exec=.*serve --no-open/); + assert.match(desktop, /Exec=.*serve --no-open --tray/); + assert.match(desktop, /Terminal=false/); } disable(); diff --git a/tests/unit/cli/autostart-macos-launchctl.test.ts b/tests/unit/cli/autostart-macos-launchctl.test.ts index 329bbb1bff..75ade6f46f 100644 --- a/tests/unit/cli/autostart-macos-launchctl.test.ts +++ b/tests/unit/cli/autostart-macos-launchctl.test.ts @@ -88,4 +88,17 @@ test("enable/disable macOS skip launchctl when the current process is the agent" const source = readFileSync(join(process.cwd(), "bin/cli/tray/autostart.mjs"), "utf8"); assert.match(source, /isAgentSelfMac/); assert.match(source, /parseAgentSelfFromLaunchctl/); + assert.match(source, /isDetachedTrayWorker/); + assert.match(source, /process\.argv\.includes\("--tray-worker"\)/); +}); + +test("macOS autostart starts detached tray mode without a dashboard window", () => { + const source = readFileSync(join(process.cwd(), "bin/cli/tray/autostart.mjs"), "utf8"); + const programArguments = source.match(/ProgramArguments<\/key>([\s\S]*?)<\/array>/); + + assert.ok(programArguments); + assert.match(programArguments[1], /serve<\/string>/); + assert.match(programArguments[1], /--tray<\/string>/); + assert.match(programArguments[1], /--no-open<\/string>/); + assert.doesNotMatch(programArguments[1], /--tray-worker/); }); diff --git a/tests/unit/cli/tray-detached.test.ts b/tests/unit/cli/tray-detached.test.ts new file mode 100644 index 0000000000..17852e929f --- /dev/null +++ b/tests/unit/cli/tray-detached.test.ts @@ -0,0 +1,194 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import test from "node:test"; + +import { + buildTrayLaunch, + buildTrayWorkerArgs, + createTrayReadinessServer, + notifyTrayReady, + startDetachedTray, + validateTrayOptions, +} from "../../../bin/cli/tray/detachedTray.mjs"; + +test("buildTrayWorkerArgs creates a non-recursive hidden tray worker command", () => { + const args = buildTrayWorkerArgs({ + port: 20128, + maxRestarts: 3, + readyPort: 43123, + readyToken: "secret-token", + tlsCert: "/tmp/cert.pem", + tlsKey: "/tmp/key.pem", + }); + + assert.deepEqual(args, [ + "serve", + "--tray", + "--tray-worker", + "--no-open", + "--port", + "20128", + "--max-restarts", + "3", + "--tray-ready-port", + "43123", + "--tray-ready-token", + "secret-token", + "--tls-cert", + "/tmp/cert.pem", + "--tls-key", + "/tmp/key.pem", + ]); +}); + +test("buildTrayLaunch detaches Windows and Linux workers from the terminal", () => { + for (const platform of ["linux", "win32"]) { + const launch = buildTrayLaunch({ + platform, + execPath: "/usr/bin/node", + cliPath: "/opt/omniroute/bin/omniroute.mjs", + workerArgs: ["serve", "--tray-worker"], + label: "com.omniroute.tray.123", + }); + + assert.equal(launch.command, "/usr/bin/node"); + assert.deepEqual(launch.args, ["/opt/omniroute/bin/omniroute.mjs", "serve", "--tray-worker"]); + assert.deepEqual(launch.options, { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + } +}); + +test("buildTrayLaunch submits a macOS launchd job", () => { + const launch = buildTrayLaunch({ + platform: "darwin", + execPath: "/usr/bin/node", + cliPath: "/opt/omniroute/bin/omniroute.mjs", + workerArgs: ["serve", "--tray-worker"], + label: "com.omniroute.tray.123", + }); + + assert.equal(launch.command, "launchctl"); + assert.deepEqual(launch.args, [ + "submit", + "-l", + "com.omniroute.tray.123", + "--", + "/usr/bin/node", + "/opt/omniroute/bin/omniroute.mjs", + "serve", + "--tray-worker", + ]); + assert.deepEqual(launch.options, { stdio: "ignore" }); +}); + +test("validateTrayOptions rejects modes that cannot detach safely", () => { + assert.equal(validateTrayOptions({ tray: true, daemon: true }), "--tray cannot use --daemon"); + assert.equal(validateTrayOptions({ tray: true, log: true }), "--tray cannot use --log"); + assert.equal( + validateTrayOptions({ tray: true, noRecovery: true }), + "--tray cannot use --no-recovery" + ); + assert.equal( + validateTrayOptions({ tray: true, recovery: false }), + "--tray cannot use --no-recovery" + ); + assert.equal(validateTrayOptions({ tray: true }), null); + assert.equal( + validateTrayOptions({ tray: true, trayWorker: true }), + "tray worker requires readiness credentials" + ); + assert.equal( + validateTrayOptions({ + tray: true, + trayWorker: true, + trayReadyPort: "43123", + trayReadyToken: "token", + }), + null + ); +}); + +test("tray worker readiness requires the parent token", async () => { + const readiness = await createTrayReadinessServer("expected-token"); + try { + await assert.rejects(notifyTrayReady(readiness.port, "wrong-token")); + const ready = readiness.wait(1000); + await notifyTrayReady(readiness.port, "expected-token"); + await ready; + } finally { + readiness.close(); + } +}); + +test("startDetachedTray waits for worker readiness and detaches it", async () => { + let workerArgs: string[] = []; + let unrefCalled = false; + const result = await startDetachedTray( + { + cliPath: "/tmp/omniroute.mjs", + port: 20128, + maxRestarts: 2, + timeoutMs: 1000, + }, + { + platform: "linux", + spawnProcess: (_command, args, options) => { + const child = new EventEmitter() as EventEmitter & { + pid: number; + unref: () => void; + }; + child.pid = 45678; + child.unref = () => { + unrefCalled = true; + }; + workerArgs = args; + const port = Number(args[args.indexOf("--tray-ready-port") + 1]); + const token = args[args.indexOf("--tray-ready-token") + 1]; + void notifyTrayReady(port, token); + assert.deepEqual(options, { detached: true, stdio: "ignore", windowsHide: true }); + return child; + }, + } + ); + + assert.equal(result.platform, "linux"); + assert.equal(result.pid, 45678); + assert.equal(workerArgs.includes("--tray-worker"), true); + assert.equal(workerArgs.includes("--no-open"), true); + assert.equal(unrefCalled, true); +}); + +test("startDetachedTray stops a worker that never becomes ready", async () => { + const originalKill = process.kill; + const signals: Array<{ pid: number; signal: NodeJS.Signals | number }> = []; + const child = new EventEmitter() as EventEmitter & { pid: number; unref: () => void }; + child.pid = 56789; + child.unref = () => {}; + process.kill = ((pid: number, signal?: NodeJS.Signals | number) => { + signals.push({ pid, signal: signal ?? 0 }); + return true; + }) as typeof process.kill; + try { + await assert.rejects( + startDetachedTray( + { + cliPath: "/tmp/omniroute.mjs", + port: 20128, + maxRestarts: 2, + timeoutMs: 20, + }, + { + platform: "linux", + spawnProcess: () => child, + } + ), + /did not become ready/ + ); + } finally { + process.kill = originalKill; + } + assert.deepEqual(signals, [{ pid: 56789, signal: "SIGTERM" }]); +});