mcp: handle spawn errors and a stopped daemon

This commit is contained in:
shum committed 2026-10-24 14:37:24 +00:00
1 parent d921b066f2
commit 9516414097
3 files changed
+39 -7

No files matched your search

+15 -4
View File
@@ -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<StatusReply> {
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<void> {
try {
await this.request("POST", "/stop")
} catch (error) {
if (!isNotRunning(error)) throw error
}
}
private async status(): Promise<StatusReply | undefined> {
try {
return await this.request<StatusReply>("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<string> {
function firstLine(stream: Readable, child: ChildProcess): Promise<string> {
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<string> {
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)
})
}
+3 -3
View File
@@ -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})
}
}
@@ -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)