diff --git a/packages/simplex-chat-mcp/src/client.ts b/packages/simplex-chat-mcp/src/client.ts index 5b39fad437..4460aa5f5d 100644 --- a/packages/simplex-chat-mcp/src/client.ts +++ b/packages/simplex-chat-mcp/src/client.ts @@ -1,4 +1,4 @@ -import {spawn} from "child_process" +import {ChildProcess, spawn} from "child_process" import {closeSync} from "fs" import {join} from "path" import {Readable} from "stream" @@ -32,7 +32,7 @@ export class DaemonClient { async ensureDaemon(version: string, spawnIt: SpawnDaemon = root => spawnDaemon(root)): Promise { let status = await this.status() if (status !== undefined && compareVersions(status.version, version) < 0) { - await this.request("POST", "/stop") + await this.stopDaemon() status = undefined } if (status === undefined) { @@ -43,6 +43,14 @@ export class DaemonClient { return status } + private async stopDaemon(): Promise { + try { + await this.request("POST", "/stop") + } catch (error) { + if (!isNotRunning(error)) throw error + } + } + private async status(): Promise { try { return await this.request("GET", "/status") @@ -68,7 +76,7 @@ export async function spawnDaemon(root: string, [command, args]: [string, string }) const channel = child.stdio[readyFd] as Readable try { - const line = await firstLine(channel) + const line = await firstLine(channel, child) if (line === "ready" || line === "running") return throw new ExitError(line.startsWith(errorPrefix) ? line.slice(errorPrefix.length) : `The SimpleX daemon exited; see ${logFile(root)}`, 1) } finally { @@ -80,13 +88,14 @@ export async function spawnDaemon(root: string, [command, args]: [string, string } } -function firstLine(stream: Readable): Promise { +function firstLine(stream: Readable, child: ChildProcess): Promise { return new Promise((resolve, reject) => { let text = "" const finish = (settle: () => void): void => { clearTimeout(timer) stream.off("data", onData) stream.off("end", onEnd) + child.off("error", onSpawnError) settle() } const onData = (chunk: string): void => { @@ -95,11 +104,13 @@ function firstLine(stream: Readable): Promise { if (i >= 0) finish(() => resolve(text.slice(0, i))) } const onEnd = (): void => finish(() => resolve(text.trim())) + const onSpawnError = (error: Error): void => finish(() => reject(new ExitError(error.message, 1))) const timer = setTimeout(() => finish(() => reject(new ExitError("The SimpleX daemon did not start in 5 minutes.", 1))), daemonStartTimeout) stream.setEncoding("utf8") stream.on("data", onData) stream.on("end", onEnd) stream.on("error", onEnd) + child.once("error", onSpawnError) }) } diff --git a/packages/simplex-chat-mcp/src/files.ts b/packages/simplex-chat-mcp/src/files.ts index bec7d53875..6b8a093045 100644 --- a/packages/simplex-chat-mcp/src/files.ts +++ b/packages/simplex-chat-mcp/src/files.ts @@ -1,5 +1,5 @@ import {createHash, randomBytes} from "crypto" -import {chmodSync, existsSync, fchmodSync, mkdirSync, linkSync, openSync, readFileSync, readdirSync, statSync, truncateSync, unlinkSync, writeFileSync} from "fs" +import {chmodSync, existsSync, fchmodSync, mkdirSync, linkSync, openSync, readFileSync, readdirSync, rmSync, statSync, truncateSync, unlinkSync, writeFileSync} from "fs" import {homedir, tmpdir} from "os" import {join, resolve} from "path" import {ExitError} from "./log" @@ -99,8 +99,8 @@ export function serverToken(dir: string): string { const file = join(dir, "token") const token = randomBytes(32).toString("base64url") const temp = join(dir, `token.${process.pid}.${randomBytes(6).toString("hex")}.tmp`) - writePrivateFile(temp, `${token}\n`) try { + writePrivateFile(temp, `${token}\n`) linkSync(temp, file) return token } catch (error) { @@ -108,7 +108,7 @@ export function serverToken(dir: string): string { if (process.platform !== "win32") chmodSync(file, 0o600) return readFileSync(file, "utf8").trim() } finally { - unlinkSync(temp) + rmSync(temp, {force: true}) } } diff --git a/packages/simplex-chat-mcp/test/unit/client.test.ts b/packages/simplex-chat-mcp/test/unit/client.test.ts index 059e69e70d..9424349d8e 100644 --- a/packages/simplex-chat-mcp/test/unit/client.test.ts +++ b/packages/simplex-chat-mcp/test/unit/client.test.ts @@ -32,6 +32,11 @@ describe("spawning the daemon", () => { expect(readFileSync(logFile(root), "utf8")).toContain("boom") }) + it("reports a command that cannot start", async () => { + const missing = join(root, "no-such-command") + await expect(spawnDaemon(root, [missing, []])).rejects.toEqual(new ExitError(`spawn ${missing} ENOENT`, 1)) + }) + it("gives the daemon the data root", async () => { await spawnDaemon(root, script(`require("fs").writeFileSync(process.env.SIMPLEX_MCP_HOME + "/seen", process.env.SIMPLEX_MCP_HOME); require("fs").writeSync(3, "ready\\n")`)) expect(readFileSync(join(root, "seen"), "utf8")).toBe(root) @@ -75,6 +80,22 @@ describe("ensureDaemon", () => { await closeServer(server) }) + it("tolerates an older daemon that another client already stopped", async () => { + const client = DaemonClient.forRoot(root) + let server = await listen(socketPath(root), serverToken(root), { + "GET /status": async (_b, res) => { + sendJson(res, 200, status("0.1.9")) + setImmediate(() => closeServer(server)) + }, + }) + const spawned = jest.fn(async () => { + server = await listen(socketPath(root), serverToken(root), {"GET /status": async (_b, res) => sendJson(res, 200, status("0.2.0"))}) + }) + expect(await client.ensureDaemon("0.2.0", spawned)).toMatchObject({version: "0.2.0"}) + expect(spawned).toHaveBeenCalledTimes(1) + await closeServer(server) + }) + it("compares versions numerically", () => { expect(compareVersions("0.2.0", "0.10.0")).toBeLessThan(0) expect(compareVersions("1.0.0", "0.9.9")).toBeGreaterThan(0)