From e84ed613392ae1c6c4bf281a7038b91530205099 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 9 Dec 2025 20:26:38 +0000 Subject: [PATCH] cli: gateway subcommands, drop ipc probes --- src/cli/ports.ts | 52 ++++++++++++++++++++++++++++++ src/cli/program.ts | 73 +++++++++++------------------------------- src/commands/health.ts | 10 ++++-- src/commands/send.ts | 12 +++++-- src/gateway/server.ts | 2 +- 5 files changed, 88 insertions(+), 61 deletions(-) create mode 100644 src/cli/ports.ts diff --git a/src/cli/ports.ts b/src/cli/ports.ts new file mode 100644 index 000000000..dbb61d365 --- /dev/null +++ b/src/cli/ports.ts @@ -0,0 +1,52 @@ +import { execFileSync } from "node:child_process"; + +export type PortProcess = { pid: number; command?: string }; + +export function parseLsofOutput(output: string): PortProcess[] { + const lines = output.split(/\r?\n/).filter(Boolean); + const results: PortProcess[] = []; + let current: Partial = {}; + for (const line of lines) { + if (line.startsWith("p")) { + if (current.pid) results.push(current as PortProcess); + current = { pid: Number.parseInt(line.slice(1), 10) }; + } else if (line.startsWith("c")) { + current.command = line.slice(1); + } + } + if (current.pid) results.push(current as PortProcess); + return results; +} + +export function listPortListeners(port: number): PortProcess[] { + try { + const out = execFileSync( + "lsof", + ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-FpFc"], + { encoding: "utf-8" }, + ); + return parseLsofOutput(out); + } catch (err: unknown) { + const status = (err as { status?: number }).status; + const code = (err as { code?: string }).code; + if (code === "ENOENT") { + throw new Error("lsof not found; required for --force"); + } + if (status === 1) return []; // no listeners + throw err instanceof Error ? err : new Error(String(err)); + } +} + +export function forceFreePort(port: number): PortProcess[] { + const listeners = listPortListeners(port); + for (const proc of listeners) { + try { + process.kill(proc.pid, "SIGTERM"); + } catch (err) { + throw new Error( + `failed to kill pid ${proc.pid}${proc.command ? ` (${proc.command})` : ""}: ${String(err)}`, + ); + } + } + return listeners; +} diff --git a/src/cli/program.ts b/src/cli/program.ts index 866ab9813..98856e634 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -1,5 +1,3 @@ -import { execFileSync } from "node:child_process"; - import chalk from "chalk"; import { Command } from "commander"; import { agentCommand } from "../commands/agent.js"; @@ -17,58 +15,12 @@ import { defaultRuntime } from "../runtime.js"; import { VERSION } from "../version.js"; import { startWebChatServer } from "../webchat/server.js"; import { createDefaultDeps } from "./deps.js"; - -export type PortProcess = { pid: number; command?: string }; - -export function parseLsofOutput(output: string): PortProcess[] { - const lines = output.split(/\r?\n/).filter(Boolean); - const results: PortProcess[] = []; - let current: Partial = {}; - for (const line of lines) { - if (line.startsWith("p")) { - if (current.pid) results.push(current as PortProcess); - current = { pid: Number.parseInt(line.slice(1), 10) }; - } else if (line.startsWith("c")) { - current.command = line.slice(1); - } - } - if (current.pid) results.push(current as PortProcess); - return results; -} - -export function listPortListeners(port: number): PortProcess[] { - try { - const out = execFileSync( - "lsof", - ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-FpFc"], - { encoding: "utf-8" }, - ); - return parseLsofOutput(out); - } catch (err: unknown) { - const status = (err as { status?: number }).status; - const code = (err as { code?: string }).code; - if (code === "ENOENT") { - throw new Error("lsof not found; required for --force"); - } - // lsof returns exit status 1 when no processes match - if (status === 1) return []; - throw err instanceof Error ? err : new Error(String(err)); - } -} - -export function forceFreePort(port: number): PortProcess[] { - const listeners = listPortListeners(port); - for (const proc of listeners) { - try { - process.kill(proc.pid, "SIGTERM"); - } catch (err) { - throw new Error( - `failed to kill pid ${proc.pid}${proc.command ? ` (${proc.command})` : ""}: ${String(err)}`, - ); - } - } - return listeners; -} +import { + forceFreePort, + listPortListeners, + PortProcess, + parseLsofOutput, +} from "./ports.js"; export function buildProgram() { const program = new Command(); @@ -386,6 +338,8 @@ Examples: // Only spawn if there is clearly no listener. const url = new URL(opts.url ?? "ws://127.0.0.1:18789"); const port = Number(url.port || 18789); + const listeners = listPortListeners(port); + if (listeners.length > 0) throw err; await startGatewayServer(port); return await attempt(); } @@ -554,6 +508,11 @@ Examples: .option("--json", "Output JSON instead of text", false) .option("--timeout ", "Connection timeout in milliseconds", "10000") .option("--verbose", "Verbose logging", false) + .option( + "--probe", + "Also attempt a live Baileys connect (can conflict if gateway is already connected)", + false, + ) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); const timeout = opts.timeout @@ -568,7 +527,11 @@ Examples: } try { await healthCommand( - { json: Boolean(opts.json), timeoutMs: timeout }, + { + json: Boolean(opts.json), + timeoutMs: timeout, + probe: Boolean(opts.probe), + }, defaultRuntime, ); } catch (err) { diff --git a/src/commands/health.ts b/src/commands/health.ts index 74c12f937..a3948d590 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -191,6 +191,7 @@ async function probeTelegram( export async function getHealthSnapshot( timeoutMs?: number, + opts?: { probe?: boolean }, ): Promise { const cfg = loadConfig(); const linked = await webAuthExists(); @@ -210,7 +211,8 @@ export async function getHealthSnapshot( const start = Date.now(); const cappedTimeout = Math.max(1000, timeoutMs ?? DEFAULT_TIMEOUT_MS); - const connect = linked ? await probeWebConnect(cappedTimeout) : undefined; + const connect = + linked && opts?.probe ? await probeWebConnect(cappedTimeout) : undefined; const telegramToken = process.env.TELEGRAM_BOT_TOKEN ?? cfg.telegram?.botToken ?? ""; @@ -237,10 +239,12 @@ export async function getHealthSnapshot( } export async function healthCommand( - opts: { json?: boolean; timeoutMs?: number }, + opts: { json?: boolean; timeoutMs?: number; probe?: boolean }, runtime: RuntimeEnv, ) { - const summary = await getHealthSnapshot(opts.timeoutMs); + const summary = await getHealthSnapshot(opts.timeoutMs, { + probe: opts.probe, + }); const fatal = !summary.web.linked || (summary.web.connect && !summary.web.connect.ok) || diff --git a/src/commands/send.ts b/src/commands/send.ts index a85e89c73..23bc6be88 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -1,4 +1,5 @@ import type { CliDeps } from "../cli/deps.js"; +import { listPortListeners } from "../cli/ports.js"; import { info, success } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; @@ -78,8 +79,15 @@ export async function sendCommand( result = await sendViaGateway(); } catch (err) { if (!opts.spawnGateway) throw err; - await startGatewayServer(18789); - result = await sendViaGateway(); + // Only spawn when nothing is listening. + try { + const listeners = listPortListeners(18789); + if (listeners.length > 0) throw err; + await startGatewayServer(18789); + result = await sendViaGateway(); + } catch { + throw err; + } } runtime.log( diff --git a/src/gateway/server.ts b/src/gateway/server.ts index d67b88638..1e5c766b8 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -404,7 +404,7 @@ export async function startGatewayServer(port = 18789): Promise { presenceVersion += 1; const snapshot = buildSnapshot(); // Fill health asynchronously for snapshot - const health = await getHealthSnapshot(); + const health = await getHealthSnapshot(undefined, { probe: false }); snapshot.health = health; snapshot.stateVersion.health = ++healthVersion; const helloOk = {