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 <routstrd>' 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.
This commit is contained in:
redshift
2026-08-03 22:46:04 +08:00
parent e87860be9f
commit 769e8206ed
5 changed files with 50 additions and 3 deletions
+1
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "routstrd",
+12 -1
View File
@@ -240,18 +240,29 @@ async function initDaemon(): Promise<void> {
}
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") {
+18 -1
View File
@@ -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<void> {
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") {
+13
View File
@@ -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<LegacyFetch>(async () => {
throw Object.assign(new Error("permission denied"), { code: "EACCES" });
+6 -1
View File
@@ -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 {