diff --git a/src/index.ts b/src/index.ts index 1a080167a..e6018a22a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { createModelCacheState } from "./plugin-state" import { createFirstMessageVariantGate } from "./shared/first-message-variant" import { injectServerAuthIntoClient, log, logLegacyPluginStartupWarning } from "./shared" import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector" +import { lspManager } from "./tools/lsp/client" import { startTmuxCheck } from "./tools" let activePluginDispose: PluginDispose | null = null @@ -83,6 +84,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const dispose = createPluginDispose({ backgroundManager: managers.backgroundManager, skillMcpManager: managers.skillMcpManager, + lspManager, disposeHooks: hooks.disposeHooks, }) diff --git a/src/openclaw/__tests__/dispatcher.test.ts b/src/openclaw/__tests__/dispatcher.test.ts index 43485ae1c..62a467abb 100644 --- a/src/openclaw/__tests__/dispatcher.test.ts +++ b/src/openclaw/__tests__/dispatcher.test.ts @@ -3,6 +3,7 @@ import { interpolateInstruction, resolveCommandTimeoutMs, shellEscapeArg, + terminateCommandProcess, wakeGateway, wakeCommandGateway, } from "../dispatcher" @@ -41,6 +42,10 @@ describe("OpenClaw Dispatcher", () => { expect(result.success).toBe(true) expect(fetchSpy).toHaveBeenCalled() const call = fetchSpy.mock.calls.find(c => c[0] === "https://example.com") + expect(call).toBeDefined() + if (!call) { + throw new Error("Expected fetch call for https://example.com") + } expect(call[0]).toBe("https://example.com") expect(call[1]?.method).toBe("POST") expect(call[1]?.body).toBe('{"foo":"bar"}') @@ -67,4 +72,40 @@ describe("OpenClaw Dispatcher", () => { else process.env.OMO_OPENCLAW_COMMAND_TIMEOUT_MS = original } }) + + test("terminateCommandProcess kills process group on unix when pid exists", () => { + const killSpy = spyOn(process, "kill").mockImplementation(() => true) + const proc = { + pid: 4321, + kill: mock(() => {}), + } + + try { + terminateCommandProcess(proc, "SIGKILL") + + expect(killSpy).toHaveBeenCalledWith(-4321, "SIGKILL") + expect(proc.kill).not.toHaveBeenCalled() + } finally { + killSpy.mockRestore() + } + }) + + test("terminateCommandProcess falls back to direct kill when process group kill fails", () => { + const killSpy = spyOn(process, "kill").mockImplementation(() => { + throw new Error("group kill failed") + }) + const proc = { + pid: 9876, + kill: mock(() => {}), + } + + try { + terminateCommandProcess(proc, "SIGKILL") + + expect(killSpy).toHaveBeenCalledWith(-9876, "SIGKILL") + expect(proc.kill).toHaveBeenCalledWith("SIGKILL") + } finally { + killSpy.mockRestore() + } + }) }) diff --git a/src/openclaw/dispatcher.ts b/src/openclaw/dispatcher.ts index a965d7b47..d7dd5efda 100644 --- a/src/openclaw/dispatcher.ts +++ b/src/openclaw/dispatcher.ts @@ -141,18 +141,17 @@ export async function wakeCommandGateway( return shellEscapeArg(value) }) - // Always use sh -c to handle the shell command string correctly const proc = spawn(["sh", "-c", interpolated], { env: { ...process.env }, stdout: "ignore", stderr: "ignore", + detached: process.platform !== "win32", }) - // Handle timeout manually let timeoutId: ReturnType | undefined const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { - proc.kill() + terminateCommandProcess(proc, "SIGKILL") reject(new Error("Command timed out")) }, timeout) }) @@ -178,3 +177,24 @@ export async function wakeCommandGateway( } } } + +type KillableProcess = { + pid?: number + kill: (signal?: NodeJS.Signals) => void +} + +export function terminateCommandProcess(proc: KillableProcess, signal: NodeJS.Signals): void { + try { + if (process.platform !== "win32" && proc.pid) { + try { + process.kill(-proc.pid, signal) + return + } catch { + proc.kill(signal) + return + } + } + + proc.kill(signal) + } catch {} +}