diff --git a/src/daemon/wallet/coco-client.test.ts b/src/daemon/wallet/coco-client.test.ts new file mode 100644 index 0000000..2124e65 --- /dev/null +++ b/src/daemon/wallet/coco-client.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, mock } from "bun:test"; +import { assertLegacyCocodNotRunning } from "./coco-client"; + +type GuardOptions = NonNullable< + Parameters[0] +>; +type LegacyFetch = NonNullable; + +const SOCKET_PATH = "/tmp/routstrd-test/cocod.sock"; +const PID_FILE_PATH = "/tmp/routstrd-test/cocod.pid"; + +function socketOnly(path: string): boolean { + return path === SOCKET_PATH; +} + +describe("assertLegacyCocodNotRunning", () => { + it("does not probe when the legacy socket and PID file do not exist", async () => { + const fetchImpl = mock(async () => new Response("pong")); + + await assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: () => false, + fetchImpl, + }); + + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("refuses to continue when cocod responds on the legacy socket", async () => { + const fetchImpl = mock(async () => + Response.json({ output: "pong" }), + ); + + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: socketOnly, + fetchImpl, + }), + ).rejects.toThrow("Legacy cocod daemon is still running"); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchImpl.mock.calls[0]?.[0]).toBe("http://localhost/ping"); + expect(fetchImpl.mock.calls[0]?.[1]).toMatchObject({ unix: SOCKET_PATH }); + }); + + it.each(["ENOENT", "ECONNREFUSED", "FailedToOpenSocket"])( + "allows startup for a stale socket that fails with %s", + async (code) => { + const fetchImpl = mock(async () => { + throw Object.assign(new Error("socket unavailable"), { code }); + }); + + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: socketOnly, + fetchImpl, + }), + ).resolves.toBeUndefined(); + }, + ); + + it("recognizes stale socket errors nested under cause", async () => { + const fetchImpl = mock(async () => { + throw new TypeError("fetch failed", { + cause: Object.assign(new Error("connection refused"), { + code: "ECONNREFUSED", + }), + }); + }); + + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: socketOnly, + fetchImpl, + }), + ).resolves.toBeUndefined(); + }); + + it("refuses to continue when the legacy PID is still running", async () => { + const fetchImpl = mock(async () => + Response.json({ output: "pong" }), + ); + + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: (path) => path === PID_FILE_PATH, + readFile: () => "4242\n", + isProcessRunning: (pid) => pid === 4242, + fetchImpl, + }), + ).rejects.toThrow("Legacy cocod daemon is still running with PID 4242"); + + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("ignores a stale PID file when no socket exists", async () => { + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: (path) => path === PID_FILE_PATH, + readFile: () => "4242\n", + isProcessRunning: () => false, + }), + ).resolves.toBeUndefined(); + }); + + it("fails closed when the socket cannot be probed safely", async () => { + const fetchImpl = mock(async () => { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + }); + + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: socketOnly, + fetchImpl, + }), + ).rejects.toThrow("Cannot verify whether the legacy cocod daemon has stopped"); + }); +}); diff --git a/src/daemon/wallet/coco-client.ts b/src/daemon/wallet/coco-client.ts index 302bfab..122e528 100644 --- a/src/daemon/wallet/coco-client.ts +++ b/src/daemon/wallet/coco-client.ts @@ -13,6 +13,32 @@ const CONFIG_DIR = const CONFIG_FILE = join(CONFIG_DIR, "config.json"); const DB_PATH = join(CONFIG_DIR, "coco.db"); +const LEGACY_COCOD_SOCKET = + process.env.COCOD_SOCKET || join(CONFIG_DIR, "cocod.sock"); +const LEGACY_COCOD_PID_FILE = join(CONFIG_DIR, "cocod.pid"); + +const STALE_SOCKET_ERROR_CODES = new Set([ + "ECONNREFUSED", + "ENOENT", + // Bun's Unix-socket fetch error for an abandoned socket inode. + "FailedToOpenSocket", +]); + +type UnixRequestInit = RequestInit & { unix: string }; +type LegacyCocodFetch = ( + input: string | URL | Request, + init: UnixRequestInit, +) => Promise; + +export interface LegacyCocodGuardOptions { + socketPath?: string; + pidFilePath?: string; + pathExists?: (path: string) => boolean; + readFile?: (path: string) => string; + isProcessRunning?: (pid: number) => boolean; + fetchImpl?: LegacyCocodFetch; + timeoutMs?: number; +} interface CocodConfig { mnemonic: string; @@ -39,7 +65,118 @@ async function seedGetter(): Promise { return mnemonicToSeedSync(mnemonic); } +function defaultIsProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function hasErrorCode(error: unknown, codes: Set): boolean { + let current: unknown = error; + const visited = new Set(); + + while (current && typeof current === "object" && !visited.has(current)) { + visited.add(current); + const candidate = current as { code?: unknown; cause?: unknown }; + if (typeof candidate.code === "string" && codes.has(candidate.code)) { + return true; + } + current = candidate.cause; + } + + return false; +} + +/** + * Refuse to open coco.db while the legacy cocod daemon owns its Unix socket. + * Two independent wallet engines must never operate on the same proof database. + * + * A socket left behind after a crash is safe to ignore only when connecting + * fails with ENOENT or ECONNREFUSED. Other probe failures are treated as unsafe + * because they do not prove that cocod has stopped. + */ +export async function assertLegacyCocodNotRunning( + options: LegacyCocodGuardOptions = {}, +): Promise { + const socketPath = options.socketPath || LEGACY_COCOD_SOCKET; + const pidFilePath = options.pidFilePath || LEGACY_COCOD_PID_FILE; + const pathExists = options.pathExists || existsSync; + const readFile = options.readFile || ((path) => readFileSync(path, "utf-8")); + const isProcessRunning = options.isProcessRunning || defaultIsProcessRunning; + + const getRunningLegacyPid = (): number | null => { + if (!pathExists(pidFilePath)) return null; + + try { + const pid = Number.parseInt(readFile(pidFilePath).trim(), 10); + return Number.isInteger(pid) && pid > 0 && isProcessRunning(pid) + ? pid + : null; + } catch { + // An unreadable or malformed PID file does not prove that cocod is alive; + // the socket probe below remains the authoritative fallback. + return null; + } + }; + + const runningPid = getRunningLegacyPid(); + if (runningPid !== null) { + throw new Error( + `Legacy cocod daemon is still running with PID ${runningPid}. ` + + "Refusing to open the wallet database because cocod and coco-core cannot safely use it at the same time. " + + "Run 'cocod stop' and try again.", + ); + } + + if (!pathExists(socketPath)) return; + + const fetchImpl = options.fetchImpl || (fetch as LegacyCocodFetch); + const timeoutMs = options.timeoutMs ?? 1_000; + + try { + const response = await fetchImpl("http://localhost/ping", { + unix: socketPath, + signal: AbortSignal.timeout(timeoutMs), + }); + await response.body?.cancel(); + } catch (error) { + if (hasErrorCode(error, STALE_SOCKET_ERROR_CODES)) { + // Recheck after the failed probe in case cocod started concurrently. + const newlyRunningPid = getRunningLegacyPid(); + if (newlyRunningPid === null) { + logger.debug(`Ignoring stale legacy cocod socket at ${socketPath}`); + return; + } + + throw new Error( + `Legacy cocod daemon is still running with PID ${newlyRunningPid}. ` + + "Refusing to open the wallet database because cocod and coco-core cannot safely use it at the same time. " + + "Run 'cocod stop' and try again.", + { cause: error }, + ); + } + + throw new Error( + `Cannot verify whether the legacy cocod daemon has stopped at ${socketPath}. ` + + "Refusing to open the wallet database to prevent concurrent access. " + + "Run 'cocod stop', verify the daemon has exited, and try again.", + { cause: error }, + ); + } + + throw new Error( + `Legacy cocod daemon is still running at ${socketPath}. ` + + "Refusing to open the wallet database because cocod and coco-core cannot safely use it at the same time. " + + "Run 'cocod stop' and try again.", + ); +} + export async function createCocoClient(): Promise { + await assertLegacyCocodNotRunning(); + const database = new Database(DB_PATH); const repo = new SqliteRepositories({ database }); await repo.init();