fix: close stale TUI clients before updates

This commit is contained in:
Dallin Romney
2026-08-15 08:36:52 +08:00
parent fd22f7a1c8
commit 0f3799486e
6 changed files with 294 additions and 242 deletions
@@ -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();
@@ -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<PropertyKey, unknown>)[
Symbol.for("openclaw.doctorWhatsappResponsivenessTestApi")
] as TestApi;
}
export const listLocalTuiProcesses: TestApi["listLocalTuiProcesses"] = () =>
getTestApi().listLocalTuiProcesses();
export const terminateLocalTuiProcesses: TestApi["terminateLocalTuiProcesses"] = (params) =>
getTestApi().terminateLocalTuiProcesses(params);
@@ -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<typeof import("node:child_process")>("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;
+8 -139
View File
@@ -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<number>();
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<PropertyKey, unknown>)[
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) {
+100
View File
@@ -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],
]);
});
});
+146
View File
@@ -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<number>();
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(", ");
}