mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 08:53:56 +00:00
* fix(agents): harden LSP process failures Source: #100450 Co-authored-by: morluto <williamlin1327@gmail.com> * fix(sandbox): report effective workspace layout Sources: #100435, #100439 Co-authored-by: Aniruddha Adak <aniruddhaadak80@users.noreply.github.com> Co-authored-by: ZengWen-DT <ceng.wen@xydigit.com> * fix(security): fail install checks on stream errors Source: #100413 Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com> * fix(android): normalize all-day calendar events Source: #100032 Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com> * fix(ios): serialize push-to-talk lifecycle Source: #99942 Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com> * fix(talk): reject inherited provider names Source: #99849 Co-authored-by: zenglingbiao <zeng.lingbiao@xydigit.com> * fix(android): stop voice capture in background Source: #99840 Co-authored-by: xialonglee <li.xialong@xydigit.com> * fix(cron): preserve fallback result classification Source: #99913 Co-authored-by: jincheng-xydt <xu.jincheng@xydigit.com> * fix(google): bound Vertex response decompression Source: #99812 Co-authored-by: 黄剑雄0668001315 <huang.jianxiong@xydigit.com> * fix(plugins): report malformed discovery JSON Source: #99892 Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com> * test(sandbox): configure non-default workspace fixture * test: fix small-fix batch validation --------- Co-authored-by: morluto <williamlin1327@gmail.com> Co-authored-by: ZengWen-DT <ceng.wen@xydigit.com> Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com> Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com> Co-authored-by: zenglingbiao <zeng.lingbiao@xydigit.com> Co-authored-by: xialonglee <li.xialong@xydigit.com> Co-authored-by: jincheng-xydt <xu.jincheng@xydigit.com> Co-authored-by: 黄剑雄0668001315 <huang.jianxiong@xydigit.com>
278 lines
9.4 KiB
TypeScript
278 lines
9.4 KiB
TypeScript
/** Tests embedded LSP runtime JSON-RPC, tool behavior, and cleanup. */
|
|
import { EventEmitter } from "node:events";
|
|
import { PassThrough, Writable } from "node:stream";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const spawnMock = vi.hoisted(() => vi.fn());
|
|
const killProcessTreeMock = vi.hoisted(() => vi.fn());
|
|
const loadEmbeddedAgentLspConfigMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("node:child_process", async () => ({
|
|
...(await vi.importActual<typeof import("node:child_process")>("node:child_process")),
|
|
spawn: spawnMock,
|
|
}));
|
|
|
|
vi.mock("../process/kill-tree.js", () => ({
|
|
killProcessTree: killProcessTreeMock,
|
|
}));
|
|
|
|
vi.mock("./embedded-agent-lsp.js", () => ({
|
|
loadEmbeddedAgentLspConfig: loadEmbeddedAgentLspConfigMock,
|
|
}));
|
|
|
|
vi.mock("../logger.js", () => ({
|
|
logDebug: vi.fn(),
|
|
logWarn: vi.fn(),
|
|
}));
|
|
|
|
function encodeLspMessage(body: unknown): string {
|
|
const json = JSON.stringify(body);
|
|
return `Content-Length: ${Buffer.byteLength(json, "utf-8")}\r\n\r\n${json}`;
|
|
}
|
|
|
|
function parseWrittenLspBody(text: string): Record<string, unknown> | null {
|
|
const bodyStart = text.indexOf("\r\n\r\n");
|
|
if (bodyStart === -1) {
|
|
return null;
|
|
}
|
|
return JSON.parse(text.slice(bodyStart + 4)) as Record<string, unknown>;
|
|
}
|
|
|
|
class MockChildProcess extends EventEmitter {
|
|
exitCode: number | null = null;
|
|
signalCode: NodeJS.Signals | null = null;
|
|
killed = false;
|
|
pid = 4321;
|
|
readonly stdout = new PassThrough();
|
|
readonly stderr = new PassThrough();
|
|
readonly stdin: Writable;
|
|
|
|
constructor(
|
|
private readonly initializeResponsePrefix = "",
|
|
private readonly respondMethods?: ReadonlySet<string>,
|
|
) {
|
|
super();
|
|
this.stdin = new Writable({
|
|
write: (chunk, _encoding, callback) => {
|
|
this.respondToRequest(chunk.toString("utf8"));
|
|
callback();
|
|
},
|
|
});
|
|
}
|
|
|
|
kill = vi.fn((signal: NodeJS.Signals = "SIGTERM") => {
|
|
this.killed = true;
|
|
this.signalCode = signal;
|
|
this.emit("exit", null, signal);
|
|
this.emit("close", null, signal);
|
|
return true;
|
|
});
|
|
|
|
private respondToRequest(text: string): void {
|
|
const body = parseWrittenLspBody(text);
|
|
if (!body || typeof body.id !== "number" || typeof body.method !== "string") {
|
|
return;
|
|
}
|
|
if (this.respondMethods && !this.respondMethods.has(body.method)) {
|
|
return;
|
|
}
|
|
const result = body.method === "initialize" ? { capabilities: { hoverProvider: true } } : null;
|
|
queueMicrotask(() => {
|
|
this.stdout.write(
|
|
`${this.initializeResponsePrefix}${encodeLspMessage({ jsonrpc: "2.0", id: body.id, result })}`,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
function configureSingleLspServer(): void {
|
|
loadEmbeddedAgentLspConfigMock.mockReturnValue({
|
|
lspServers: {
|
|
typescript: {
|
|
command: "typescript-language-server",
|
|
args: ["--stdio"],
|
|
},
|
|
},
|
|
diagnostics: [],
|
|
});
|
|
}
|
|
|
|
describe("bundle LSP runtime", () => {
|
|
afterEach(async () => {
|
|
const { disposeAllBundleLspRuntimes } = await import("./agent-bundle-lsp-runtime.js");
|
|
await disposeAllBundleLspRuntimes();
|
|
spawnMock.mockReset();
|
|
killProcessTreeMock.mockReset();
|
|
loadEmbeddedAgentLspConfigMock.mockReset();
|
|
});
|
|
|
|
it("starts LSP servers in a disposable process group", async () => {
|
|
configureSingleLspServer();
|
|
const child = new MockChildProcess();
|
|
spawnMock.mockReturnValue(child);
|
|
const { createBundleLspToolRuntime } = await import("./agent-bundle-lsp-runtime.js");
|
|
|
|
const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" });
|
|
|
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
const [command, args, options] = spawnMock.mock.calls.at(0) ?? [];
|
|
expect(command).toBe("typescript-language-server");
|
|
expect(args).toEqual(["--stdio"]);
|
|
expect(options?.detached).toBe(process.platform !== "win32");
|
|
expect(options?.stdio).toEqual(["pipe", "pipe", "pipe"]);
|
|
expect(options?.windowsHide).toBe(process.platform === "win32");
|
|
expect(runtime.tools.map((tool) => tool.name)).toContain("lsp_hover_typescript");
|
|
|
|
await runtime.dispose();
|
|
|
|
expect(killProcessTreeMock).toHaveBeenCalledWith(4321, { graceMs: 1000 });
|
|
});
|
|
|
|
it("fails LSP startup immediately when the child process cannot spawn", async () => {
|
|
configureSingleLspServer();
|
|
const child = new MockChildProcess();
|
|
spawnMock.mockImplementation(() => {
|
|
queueMicrotask(() => child.emit("error", new Error("spawn ENOENT")));
|
|
return child;
|
|
});
|
|
const { createBundleLspToolRuntime } = await import("./agent-bundle-lsp-runtime.js");
|
|
|
|
const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" });
|
|
|
|
expect(runtime.sessions).toEqual([]);
|
|
expect(runtime.tools).toEqual([]);
|
|
expect(killProcessTreeMock).toHaveBeenCalledWith(4321, { graceMs: 1000 });
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "stdout fails",
|
|
fail: (child: MockChildProcess) => child.stdout.emit("error", new Error("stdout failed")),
|
|
message: "stdout failed",
|
|
},
|
|
{
|
|
name: "stdin fails",
|
|
fail: (child: MockChildProcess) => child.stdin.emit("error", new Error("stdin failed")),
|
|
message: "stdin failed",
|
|
},
|
|
])("rejects pending and future LSP requests when $name", async ({ fail, message }) => {
|
|
configureSingleLspServer();
|
|
const child = new MockChildProcess("", new Set(["initialize"]));
|
|
spawnMock.mockReturnValue(child);
|
|
const { createBundleLspToolRuntime } = await import("./agent-bundle-lsp-runtime.js");
|
|
|
|
const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" });
|
|
const hoverTool = runtime.tools.find((tool) => tool.name === "lsp_hover_typescript");
|
|
if (!hoverTool) {
|
|
throw new Error("expected hover tool");
|
|
}
|
|
|
|
const hoverParams = {
|
|
uri: "file:///tmp/workspace/index.ts",
|
|
line: 0,
|
|
character: 0,
|
|
};
|
|
const request = hoverTool.execute("call-1", hoverParams);
|
|
fail(child);
|
|
|
|
await expect(request).rejects.toThrow(message);
|
|
await expect(hoverTool.execute("call-2", hoverParams)).rejects.toThrow(message);
|
|
|
|
await runtime.dispose();
|
|
});
|
|
|
|
it("blocks new LSP requests on exit while allowing a final stdout response to drain", async () => {
|
|
configureSingleLspServer();
|
|
const child = new MockChildProcess("", new Set(["initialize"]));
|
|
spawnMock.mockReturnValue(child);
|
|
const { createBundleLspToolRuntime } = await import("./agent-bundle-lsp-runtime.js");
|
|
|
|
const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" });
|
|
const hoverTool = runtime.tools.find((tool) => tool.name === "lsp_hover_typescript");
|
|
if (!hoverTool) {
|
|
throw new Error("expected hover tool");
|
|
}
|
|
const hoverParams = {
|
|
uri: "file:///tmp/workspace/index.ts",
|
|
line: 0,
|
|
character: 0,
|
|
};
|
|
const pendingRequest = hoverTool.execute("call-1", hoverParams);
|
|
|
|
child.exitCode = 1;
|
|
child.emit("exit", 1, null);
|
|
await expect(hoverTool.execute("call-2", hoverParams)).rejects.toThrow(
|
|
'LSP server "typescript" exited (1)',
|
|
);
|
|
child.stdout.write(
|
|
encodeLspMessage({ jsonrpc: "2.0", id: 2, result: { contents: "final hover" } }),
|
|
);
|
|
|
|
await expect(pendingRequest).resolves.toMatchObject({
|
|
details: { lspServer: "typescript", lspMethod: "hover" },
|
|
});
|
|
child.emit("close", 1, null);
|
|
await runtime.dispose();
|
|
});
|
|
|
|
it("rejects undrained LSP requests when the exited process closes", async () => {
|
|
configureSingleLspServer();
|
|
const child = new MockChildProcess("", new Set(["initialize"]));
|
|
spawnMock.mockReturnValue(child);
|
|
const { createBundleLspToolRuntime } = await import("./agent-bundle-lsp-runtime.js");
|
|
|
|
const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" });
|
|
const hoverTool = runtime.tools.find((tool) => tool.name === "lsp_hover_typescript");
|
|
if (!hoverTool) {
|
|
throw new Error("expected hover tool");
|
|
}
|
|
const request = hoverTool.execute("call-1", {
|
|
uri: "file:///tmp/workspace/index.ts",
|
|
line: 0,
|
|
character: 0,
|
|
});
|
|
|
|
child.exitCode = 1;
|
|
child.emit("exit", 1, null);
|
|
child.emit("close", 1, null);
|
|
|
|
await expect(request).rejects.toThrow('LSP server "typescript" exited (1)');
|
|
await runtime.dispose();
|
|
});
|
|
|
|
it("keeps LSP framing aligned after multibyte messages in the same chunk", async () => {
|
|
configureSingleLspServer();
|
|
const prefix = encodeLspMessage({
|
|
jsonrpc: "2.0",
|
|
method: "window/logMessage",
|
|
params: { message: "ready té" },
|
|
});
|
|
const child = new MockChildProcess(prefix);
|
|
spawnMock.mockReturnValue(child);
|
|
const { createBundleLspToolRuntime } = await import("./agent-bundle-lsp-runtime.js");
|
|
|
|
const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" });
|
|
|
|
expect(runtime.tools.map((tool) => tool.name)).toContain("lsp_hover_typescript");
|
|
await runtime.dispose();
|
|
});
|
|
|
|
it("disposes active LSP sessions from the global shutdown sweep", async () => {
|
|
configureSingleLspServer();
|
|
const child = new MockChildProcess();
|
|
spawnMock.mockReturnValue(child);
|
|
const { createBundleLspToolRuntime, disposeAllBundleLspRuntimes } =
|
|
await import("./agent-bundle-lsp-runtime.js");
|
|
|
|
const runtime = await createBundleLspToolRuntime({ workspaceDir: "/tmp/workspace" });
|
|
|
|
await disposeAllBundleLspRuntimes();
|
|
|
|
expect(killProcessTreeMock).toHaveBeenCalledWith(4321, { graceMs: 1000 });
|
|
|
|
killProcessTreeMock.mockClear();
|
|
await runtime.dispose();
|
|
expect(killProcessTreeMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|