diff --git a/src/cli/update-cli/update-command-execution.ts b/src/cli/update-cli/update-command-execution.ts index 9b1a92f6712a..e267c0dc5fa4 100644 --- a/src/cli/update-cli/update-command-execution.ts +++ b/src/cli/update-cli/update-command-execution.ts @@ -1,3 +1,9 @@ +import { theme } from "../../../packages/terminal-core/src/theme.js"; +import { + formatLocalTuiPidList, + listLocalTuiProcesses, + terminateLocalTuiProcesses, +} from "../../infra/local-tui-processes.js"; import type { DevUpdateTarget } from "../../infra/update-dev-target.js"; import type { ResolvedGlobalInstallTarget } from "../../infra/update-global.js"; import type { UpdateRunResult } from "../../infra/update-runner.js"; @@ -33,6 +39,38 @@ import { const CLI_NAME = resolveCliName(); +async function stopLocalTuiClientsBeforeMutableUpdate(params: { jsonMode: boolean }) { + if ( + (process.env.VITEST || process.env.NODE_ENV === "test") && + process.env.OPENCLAW_ALLOW_TEST_LOCAL_TUI_PROCESS_MUTATION !== "1" + ) { + return; + } + const tuiProcesses = listLocalTuiProcesses(); + if (tuiProcesses.length === 0) { + return; + } + const pids = formatLocalTuiPidList(tuiProcesses); + if (!params.jsonMode) { + defaultRuntime.log( + theme.muted( + `Closing local TUI clients before update so they do not load stale runtime chunks: ${pids}`, + ), + ); + } + const stopped = await terminateLocalTuiProcesses({ processes: tuiProcesses }); + if (!params.jsonMode) { + if (stopped.stopped.length > 0) { + defaultRuntime.log(theme.muted(`Stopped local TUI clients: ${stopped.stopped.join(", ")}`)); + } + if (stopped.failed.length > 0) { + defaultRuntime.log( + theme.warn(`Could not stop local TUI clients: ${stopped.failed.join(", ")}`), + ); + } + } +} + type MutableUpdateExecutionResult = { result: UpdateRunResult; preManagedServiceStop: PreManagedServiceStop | undefined; @@ -153,6 +191,8 @@ export async function executeMutableUpdate(params: { } }; + await stopLocalTuiClientsBeforeMutableUpdate({ jsonMode: Boolean(params.opts.json) }); + if (params.updateInstallKind === "package") { try { await stopManagedServiceBeforeMutableUpdate(); diff --git a/src/commands/doctor-whatsapp-responsiveness.test-support.ts b/src/commands/doctor-whatsapp-responsiveness.test-support.ts deleted file mode 100644 index 1e5c363bfe55..000000000000 --- a/src/commands/doctor-whatsapp-responsiveness.test-support.ts +++ /dev/null @@ -1,26 +0,0 @@ -import "./doctor-whatsapp-responsiveness.js"; - -type LocalTuiProcess = { pid: number; command: string }; -type ProcessSignal = "SIGTERM" | "SIGKILL"; -type ProcessController = { kill(pid: number, signal: ProcessSignal | 0): boolean }; - -type TestApi = { - listLocalTuiProcesses(): LocalTuiProcess[]; - terminateLocalTuiProcesses(params: { - processes: LocalTuiProcess[]; - controller?: ProcessController; - graceMs?: number; - }): Promise<{ stopped: number[]; failed: number[] }>; -}; - -function getTestApi(): TestApi { - return (globalThis as Record)[ - Symbol.for("openclaw.doctorWhatsappResponsivenessTestApi") - ] as TestApi; -} - -export const listLocalTuiProcesses: TestApi["listLocalTuiProcesses"] = () => - getTestApi().listLocalTuiProcesses(); - -export const terminateLocalTuiProcesses: TestApi["terminateLocalTuiProcesses"] = (params) => - getTestApi().terminateLocalTuiProcesses(params); diff --git a/src/commands/doctor-whatsapp-responsiveness.test.ts b/src/commands/doctor-whatsapp-responsiveness.test.ts index 3e29da49a10b..9bde7c8d0f3b 100644 --- a/src/commands/doctor-whatsapp-responsiveness.test.ts +++ b/src/commands/doctor-whatsapp-responsiveness.test.ts @@ -3,14 +3,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; const noteMock = vi.hoisted(() => vi.fn()); -const spawnSyncMock = vi.hoisted(() => vi.fn()); - -vi.mock("node:child_process", async () => { - const { mockNodeChildProcessSpawnSync } = await import("openclaw/plugin-sdk/test-node-mocks"); - return mockNodeChildProcessSpawnSync(spawnSyncMock, () => - vi.importActual("node:child_process"), - ); -}); vi.mock("../../packages/terminal-core/src/note.js", () => ({ note: noteMock, @@ -18,81 +10,12 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({ const { collectWhatsappResponsivenessHealthFindings, noteWhatsappResponsivenessHealth } = await import("./doctor-whatsapp-responsiveness.js"); -const { listLocalTuiProcesses, terminateLocalTuiProcesses } = - await import("./doctor-whatsapp-responsiveness.test-support.js"); describe("doctor WhatsApp responsiveness", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("lists only verified local TUI processes", () => { - spawnSyncMock.mockReturnValue({ - status: 0, - stdout: [ - " 101 openclaw-tui", - " 102 /usr/bin/node /usr/lib/node_modules/openclaw/dist/index.js gateway --port 18789", - " 103 openclaw channels", - " 104 openclaw tui --local", - " 105 /usr/bin/openclaw chat", - " 106 helper --note 'openclaw tui'", - " 107 openclaw-helper openclaw terminal", - " 108 openclaw --flag tui", - ].join("\n"), - }); - - if (process.platform === "win32") { - expect(listLocalTuiProcesses()).toEqual([]); - expect(spawnSyncMock).not.toHaveBeenCalled(); - } else { - expect(listLocalTuiProcesses()).toEqual([ - { pid: 101, command: "openclaw-tui" }, - { pid: 104, command: "openclaw tui --local" }, - { pid: 105, command: "/usr/bin/openclaw chat" }, - ]); - expect(spawnSyncMock).toHaveBeenCalledWith("ps", ["-axo", "pid=,command="], { - encoding: "utf8", - killSignal: "SIGKILL", - timeout: 1_000, - }); - } - }); - - it("terminates stale local TUI processes with a kill fallback", async () => { - const alive = new Set([101]); - const signals: Array<[number, string | number]> = []; - const controller = { - kill: vi.fn((pid: number, signal: string | number) => { - signals.push([pid, signal]); - if (signal === "SIGKILL") { - alive.delete(pid); - return true; - } - if (signal === 0) { - if (alive.has(pid)) { - return true; - } - throw new Error("gone"); - } - return true; - }), - }; - - await expect( - terminateLocalTuiProcesses({ - processes: [{ pid: 101, command: "openclaw-tui" }], - controller, - graceMs: 0, - }), - ).resolves.toEqual({ stopped: [101], failed: [] }); - expect(signals).toEqual([ - [101, "SIGTERM"], - [101, 0], - [101, "SIGKILL"], - [101, 0], - ]); - }); - it("warns and repairs local TUI pressure when WhatsApp is enabled and the gateway is degraded", async () => { const terminate = vi.fn().mockResolvedValue({ stopped: [101], failed: [] }); const cfg = { channels: { whatsapp: { enabled: true } } } as OpenClawConfig; diff --git a/src/commands/doctor-whatsapp-responsiveness.ts b/src/commands/doctor-whatsapp-responsiveness.ts index d33779792310..e25a308dd9ae 100644 --- a/src/commands/doctor-whatsapp-responsiveness.ts +++ b/src/commands/doctor-whatsapp-responsiveness.ts @@ -1,86 +1,17 @@ /** Doctor hints for WhatsApp responsiveness when local TUI clients block gateway work. */ -import { spawnSync } from "node:child_process"; -import path from "node:path"; import { note } from "../../packages/terminal-core/src/note.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { HealthFinding } from "../flows/health-checks.js"; +import { + formatLocalTuiPidList, + listLocalTuiProcesses, + terminateLocalTuiProcesses, + type LocalTuiProcess, +} from "../infra/local-tui-processes.js"; import type { StatusSummary } from "../status/types.js"; -import { sleep } from "../utils/sleep.js"; -type LocalTuiProcess = { - pid: number; - command: string; -}; - -type ProcessSignal = "SIGTERM" | "SIGKILL"; - -type ProcessController = { - kill: (pid: number, signal: ProcessSignal | 0) => boolean; -}; - -const LOCAL_TUI_SUBCOMMANDS = new Set(["chat", "terminal", "tui"]); const WHATSAPP_RESPONSIVENESS_CHECK_ID = "core/doctor/whatsapp-responsiveness"; -const LOCAL_TUI_PROCESS_PROBE_TIMEOUT_MS = 1_000; - -function tokenizeCommandLine(command: string): string[] { - return command.trim().split(/\s+/u).filter(Boolean); -} - -function normalizeExecutableName(value: string | undefined): string { - return path.basename(value ?? "").replace(/\.exe$/iu, ""); -} - -function isLocalTuiCommand(command: string): boolean { - const argv = tokenizeCommandLine(command); - const executable = normalizeExecutableName(argv[0]); - if (executable === "openclaw-tui") { - return true; - } - return executable === "openclaw" && LOCAL_TUI_SUBCOMMANDS.has(argv[1] ?? ""); -} - -function parsePsPidLine(line: string): LocalTuiProcess | null { - const match = line.match(/^\s*(\d+)\s+(.+)$/); - if (!match) { - return null; - } - const pid = Number(match[1]); - if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) { - return null; - } - const command = match[2]?.trim() ?? ""; - if (!isLocalTuiCommand(command)) { - return null; - } - return { pid, command }; -} - -/** Lists local OpenClaw TUI processes that can contend with gateway responsiveness. */ -function listLocalTuiProcesses(): LocalTuiProcess[] { - if (process.platform === "win32") { - return []; - } - const ps = spawnSync("ps", ["-axo", "pid=,command="], { - encoding: "utf8", - killSignal: "SIGKILL", - timeout: LOCAL_TUI_PROCESS_PROBE_TIMEOUT_MS, - }); - if (ps.error || ps.status !== 0 || typeof ps.stdout !== "string") { - return []; - } - const seen = new Set(); - const processes: LocalTuiProcess[] = []; - for (const line of ps.stdout.split(/\r?\n/)) { - const proc = parsePsPidLine(line); - if (!proc || seen.has(proc.pid)) { - continue; - } - seen.add(proc.pid); - processes.push(proc); - } - return processes; -} function hasWhatsappEnabled(cfg: OpenClawConfig): boolean { const whatsapp = cfg.channels?.whatsapp; @@ -94,10 +25,6 @@ function hasWhatsappEnabled(cfg: OpenClawConfig): boolean { return true; } -function formatPidList(processes: LocalTuiProcess[]): string { - return processes.map((proc) => String(proc.pid)).join(", "); -} - /** Collects read-only structured findings for WhatsApp responsiveness pressure. */ export function collectWhatsappResponsivenessHealthFindings(params: { cfg: OpenClawConfig; @@ -118,7 +45,7 @@ export function collectWhatsappResponsivenessHealthFindings(params: { return []; } - const pids = formatPidList(tuiProcesses); + const pids = formatLocalTuiPidList(tuiProcesses); return [ { checkId: WHATSAPP_RESPONSIVENESS_CHECK_ID, @@ -135,64 +62,6 @@ export function collectWhatsappResponsivenessHealthFindings(params: { ]; } -function isProcessAlive(controller: ProcessController, pid: number): boolean { - try { - controller.kill(pid, 0); - return true; - } catch { - return false; - } -} - -/** Terminates local TUI processes with SIGTERM, then SIGKILL for remaining pids. */ -async function terminateLocalTuiProcesses(params: { - processes: LocalTuiProcess[]; - controller?: ProcessController; - graceMs?: number; -}): Promise<{ stopped: number[]; failed: number[] }> { - const controller = params.controller ?? process; - const graceMs = Math.max(0, params.graceMs ?? 500); - const stopped: number[] = []; - const failed: number[] = []; - - for (const proc of params.processes) { - try { - controller.kill(proc.pid, "SIGTERM"); - } catch { - // Already gone is success for this repair. - } - } - if (graceMs > 0) { - await sleep(graceMs); - } - for (const proc of params.processes) { - if (!isProcessAlive(controller, proc.pid)) { - stopped.push(proc.pid); - continue; - } - try { - controller.kill(proc.pid, "SIGKILL"); - } catch { - // Already gone is still success. - } - if (isProcessAlive(controller, proc.pid)) { - failed.push(proc.pid); - } else { - stopped.push(proc.pid); - } - } - return { stopped, failed }; -} - -if (process.env.VITEST || process.env.NODE_ENV === "test") { - (globalThis as Record)[ - Symbol.for("openclaw.doctorWhatsappResponsivenessTestApi") - ] = { - listLocalTuiProcesses, - terminateLocalTuiProcesses, - }; -} - /** Emits WhatsApp responsiveness warnings and optionally stops contending local TUI clients. */ export async function noteWhatsappResponsivenessHealth(params: { cfg: OpenClawConfig; @@ -215,7 +84,7 @@ export async function noteWhatsappResponsivenessHealth(params: { [ "Gateway event loop is degraded while local TUI clients are running.", "WhatsApp replies can queue behind TUI startup/session refresh work.", - `Local TUI pids: ${formatPidList(tuiProcesses)}`, + `Local TUI pids: ${formatLocalTuiPidList(tuiProcesses)}`, ].join("\n"), ); if (params.shouldRepair) { diff --git a/src/infra/local-tui-processes.test.ts b/src/infra/local-tui-processes.test.ts new file mode 100644 index 000000000000..21d43b7f7865 --- /dev/null +++ b/src/infra/local-tui-processes.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vitest"; +import { + listLocalTuiProcesses, + parseLocalTuiProcessLine, + terminateLocalTuiProcesses, +} from "./local-tui-processes.js"; + +describe("local TUI processes", () => { + it("parses only verified local TUI command lines", () => { + expect(parseLocalTuiProcessLine(" 101 openclaw-tui", 999)).toEqual({ + pid: 101, + command: "openclaw-tui", + }); + expect(parseLocalTuiProcessLine(" 104 openclaw tui --local", 999)).toEqual({ + pid: 104, + command: "openclaw tui --local", + }); + expect(parseLocalTuiProcessLine(" 105 /usr/bin/openclaw chat", 999)).toEqual({ + pid: 105, + command: "/usr/bin/openclaw chat", + }); + expect(parseLocalTuiProcessLine(" 102 openclaw gateway --port 18789", 999)).toBeNull(); + expect(parseLocalTuiProcessLine(" 106 helper --note 'openclaw tui'", 999)).toBeNull(); + expect(parseLocalTuiProcessLine(" 107 openclaw-helper openclaw terminal", 999)).toBeNull(); + expect(parseLocalTuiProcessLine(" 108 openclaw --flag tui", 999)).toBeNull(); + expect(parseLocalTuiProcessLine(" 999 openclaw tui", 999)).toBeNull(); + }); + + it("lists local TUI processes from ps output", () => { + const spawnSync = vi.fn().mockReturnValue({ + status: 0, + stdout: [ + " 101 openclaw-tui", + " 101 openclaw-tui", + " 102 /usr/bin/node /usr/lib/node_modules/openclaw/dist/index.js gateway --port 18789", + " 104 openclaw tui --local", + " 105 /usr/bin/openclaw chat", + ].join("\n"), + }); + + expect( + listLocalTuiProcesses({ + platform: "darwin", + currentPid: 999, + spawnSync, + }), + ).toEqual([ + { pid: 101, command: "openclaw-tui" }, + { pid: 104, command: "openclaw tui --local" }, + { pid: 105, command: "/usr/bin/openclaw chat" }, + ]); + expect(spawnSync).toHaveBeenCalledWith("ps", ["-axo", "pid=,command="], { + encoding: "utf8", + killSignal: "SIGKILL", + timeout: 1_000, + }); + }); + + it("skips process probing on Windows", () => { + const spawnSync = vi.fn(); + + expect(listLocalTuiProcesses({ platform: "win32", spawnSync })).toEqual([]); + expect(spawnSync).not.toHaveBeenCalled(); + }); + + it("terminates stale local TUI processes with a kill fallback", async () => { + const alive = new Set([101]); + const signals: Array<[number, string | number]> = []; + const controller = { + kill: vi.fn((pid: number, signal: string | number) => { + signals.push([pid, signal]); + if (signal === "SIGKILL") { + alive.delete(pid); + return true; + } + if (signal === 0) { + if (alive.has(pid)) { + return true; + } + throw new Error("gone"); + } + return true; + }), + }; + + await expect( + terminateLocalTuiProcesses({ + processes: [{ pid: 101, command: "openclaw-tui" }], + controller, + graceMs: 0, + }), + ).resolves.toEqual({ stopped: [101], failed: [] }); + expect(signals).toEqual([ + [101, "SIGTERM"], + [101, 0], + [101, "SIGKILL"], + [101, 0], + ]); + }); +}); diff --git a/src/infra/local-tui-processes.ts b/src/infra/local-tui-processes.ts new file mode 100644 index 000000000000..74b2023cb33a --- /dev/null +++ b/src/infra/local-tui-processes.ts @@ -0,0 +1,146 @@ +import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from "node:child_process"; +import path from "node:path"; +import { sleep } from "../utils/sleep.js"; + +export type LocalTuiProcess = { + pid: number; + command: string; +}; + +type ProcessSignal = "SIGTERM" | "SIGKILL"; + +type ProcessController = { + kill: (pid: number, signal: ProcessSignal | 0) => boolean; +}; + +type PsResult = { + error?: Error; + status: number | null; + stdout?: string; +}; + +const LOCAL_TUI_SUBCOMMANDS = new Set(["chat", "terminal", "tui"]); +const LOCAL_TUI_PROCESS_PROBE_TIMEOUT_MS = 1_000; + +function tokenizeCommandLine(command: string): string[] { + return command.trim().split(/\s+/u).filter(Boolean); +} + +function normalizeExecutableName(value: string | undefined): string { + return path.basename(value ?? "").replace(/\.exe$/iu, ""); +} + +export function isLocalTuiCommand(command: string): boolean { + const argv = tokenizeCommandLine(command); + const executable = normalizeExecutableName(argv[0]); + if (executable === "openclaw-tui") { + return true; + } + return executable === "openclaw" && LOCAL_TUI_SUBCOMMANDS.has(argv[1] ?? ""); +} + +export function parseLocalTuiProcessLine(line: string, currentPid = process.pid) { + const match = line.match(/^\s*(\d+)\s+(.+)$/); + if (!match) { + return null; + } + const pid = Number(match[1]); + if (!Number.isFinite(pid) || pid <= 0 || pid === currentPid) { + return null; + } + const command = match[2]?.trim() ?? ""; + if (!isLocalTuiCommand(command)) { + return null; + } + return { pid, command }; +} + +/** Lists local OpenClaw TUI processes whose in-memory chunk graph may outlive an update. */ +export function listLocalTuiProcesses( + params: { + platform?: NodeJS.Platform; + currentPid?: number; + spawnSync?: ( + command: string, + args: string[], + options: SpawnSyncOptionsWithStringEncoding, + ) => PsResult; + } = {}, +): LocalTuiProcess[] { + if ((params.platform ?? process.platform) === "win32") { + return []; + } + const spawnSyncImpl = params.spawnSync ?? spawnSync; + const ps = spawnSyncImpl("ps", ["-axo", "pid=,command="], { + encoding: "utf8", + killSignal: "SIGKILL", + timeout: LOCAL_TUI_PROCESS_PROBE_TIMEOUT_MS, + }); + if (ps.error || ps.status !== 0 || typeof ps.stdout !== "string") { + return []; + } + const seen = new Set(); + const processes: LocalTuiProcess[] = []; + for (const line of ps.stdout.split(/\r?\n/)) { + const proc = parseLocalTuiProcessLine(line, params.currentPid); + if (!proc || seen.has(proc.pid)) { + continue; + } + seen.add(proc.pid); + processes.push(proc); + } + return processes; +} + +function isProcessAlive(controller: ProcessController, pid: number): boolean { + try { + controller.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** Terminates local TUI processes with SIGTERM, then SIGKILL for remaining pids. */ +export async function terminateLocalTuiProcesses(params: { + processes: LocalTuiProcess[]; + controller?: ProcessController; + graceMs?: number; +}): Promise<{ stopped: number[]; failed: number[] }> { + const controller = params.controller ?? process; + const graceMs = Math.max(0, params.graceMs ?? 500); + const stopped: number[] = []; + const failed: number[] = []; + + for (const proc of params.processes) { + try { + controller.kill(proc.pid, "SIGTERM"); + } catch { + // Already gone is success for this repair. + } + } + if (graceMs > 0) { + await sleep(graceMs); + } + for (const proc of params.processes) { + if (!isProcessAlive(controller, proc.pid)) { + stopped.push(proc.pid); + continue; + } + try { + controller.kill(proc.pid, "SIGKILL"); + } catch { + // Already gone is still success. + } + if (isProcessAlive(controller, proc.pid)) { + failed.push(proc.pid); + } else { + stopped.push(proc.pid); + } + } + return { stopped, failed }; +} + +export function formatLocalTuiPidList(processes: readonly LocalTuiProcess[]) { + return processes.map((proc) => String(proc.pid)).join(", "); +}