From 769e8206ede365281b22300f0330270306fe1e44 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:15:33 +0000 Subject: [PATCH] fix(wallet): stop legacy cocod before direct daemon startup and ignore migration lock PID routstrd start already stopped the real legacy cocod daemon, but wallet migration claimed ~/.cocod/cocod.pid using routstrd's own PID. The post-claim recheck in assertLegacyCocodNotRunning then saw that live PID and falsely reported it as a running legacy cocod, so every start attempt failed with a different 'Legacy cocod daemon is still running with PID ' error. - assertLegacyCocodNotRunning now accepts ignorePid so the migration's own lock owner is not mistaken for cocod. - The direct daemon entry point (src/daemon/index.ts) calls stopLegacyCocod before migration, so startup is safe even when launched directly or by an older/global CLI, instead of relying solely on the parent command. - initDaemon() stops legacy cocod before migrating as well. - Added a regression test for the self-owned migration lock PID. --- bun.lock | 1 + src/cli.ts | 13 ++++++++++++- src/daemon/index.ts | 19 ++++++++++++++++++- src/daemon/wallet/coco-client.test.ts | 13 +++++++++++++ src/daemon/wallet/coco-client.ts | 7 ++++++- 5 files changed, 50 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index d8caf08..52ef082 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "routstrd", diff --git a/src/cli.ts b/src/cli.ts index 5fab864..a362ef5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -240,18 +240,29 @@ async function initDaemon(): Promise { } console.log(`Database will be stored at: ${DB_PATH}`); + await stopLegacyCocod(); + + let migrationLockOwner: number | undefined; const migration = await migrateLegacyWallet({ assertLegacyStopped: () => assertLegacyCocodNotRunning({ socketPath: legacyCocodSocketPath(), pidFilePath: legacyCocodPidPath(), + ignorePid: migrationLockOwner, }), acquireLegacyLock: () => { mkdirSync(dirname(legacyCocodPidPath()), { recursive: true, mode: 0o700, }); - return claimLegacyCocodPidFile({ pidFilePath: legacyCocodPidPath() }); + const release = claimLegacyCocodPidFile({ + pidFilePath: legacyCocodPidPath(), + }); + migrationLockOwner = process.pid; + return () => { + migrationLockOwner = undefined; + release(); + }; }, }); if (migration.status === "migrated") { diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 0e3bb4b..6475874 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -60,6 +60,7 @@ import { assertLegacyCocodNotRunning, claimLegacyCocodPidFile, createCocoClient, + stopLegacyCocod, } from "./wallet/coco-client"; import { migrateLegacyWallet } from "./wallet/migration"; import { @@ -126,18 +127,34 @@ async function main(): Promise { const { ensureProvidersBootstrapped, getRoutstr21Models, getModelProviders, refreshProvidersAndModels } = createModelService(modelManager, providerManager, store); + // The daemon may be launched directly (or by an older/global CLI), so do + // not rely on the parent command having stopped the external wallet first. + await stopLegacyCocod({ + socketPath: legacyCocodSocketPath(), + pidFilePath: legacyCocodPidPath(), + }); + + let migrationLockOwner: number | undefined; const migration = await migrateLegacyWallet({ assertLegacyStopped: () => assertLegacyCocodNotRunning({ socketPath: legacyCocodSocketPath(), pidFilePath: legacyCocodPidPath(), + ignorePid: migrationLockOwner, }), acquireLegacyLock: () => { mkdirSync(dirname(legacyCocodPidPath()), { recursive: true, mode: 0o700, }); - return claimLegacyCocodPidFile({ pidFilePath: legacyCocodPidPath() }); + const release = claimLegacyCocodPidFile({ + pidFilePath: legacyCocodPidPath(), + }); + migrationLockOwner = process.pid; + return () => { + migrationLockOwner = undefined; + release(); + }; }, }); if (migration.status === "migrated") { diff --git a/src/daemon/wallet/coco-client.test.ts b/src/daemon/wallet/coco-client.test.ts index b9dd10a..0749c42 100644 --- a/src/daemon/wallet/coco-client.test.ts +++ b/src/daemon/wallet/coco-client.test.ts @@ -315,6 +315,19 @@ describe("assertLegacyCocodNotRunning", () => { ).resolves.toBeUndefined(); }); + it("ignores the caller's own migration lock PID", async () => { + await expect( + assertLegacyCocodNotRunning({ + socketPath: SOCKET_PATH, + pidFilePath: PID_FILE_PATH, + pathExists: (path) => path === PID_FILE_PATH, + readFile: () => "4242\n", + isProcessRunning: () => true, + ignorePid: 4242, + }), + ).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" }); diff --git a/src/daemon/wallet/coco-client.ts b/src/daemon/wallet/coco-client.ts index 3a90cb0..8aa2cfa 100644 --- a/src/daemon/wallet/coco-client.ts +++ b/src/daemon/wallet/coco-client.ts @@ -60,6 +60,8 @@ export interface LegacyCocodGuardOptions { pathExists?: (path: string) => boolean; readFile?: (path: string) => string; isProcessRunning?: (pid: number) => boolean; + /** PID owned by the caller's already-acquired legacy exclusion lock. */ + ignorePid?: number; fetchImpl?: LegacyCocodFetch; timeoutMs?: number; } @@ -243,7 +245,10 @@ export async function assertLegacyCocodNotRunning( try { const pid = Number.parseInt(readFile(pidFilePath).trim(), 10); - return Number.isInteger(pid) && pid > 0 && isProcessRunning(pid) + return Number.isInteger(pid) && + pid > 0 && + pid !== options.ignorePid && + isProcessRunning(pid) ? pid : null; } catch {