Files
routstrd/src/utils/daemon-client.ts
T
redshift cdfde85595 fix: prevent race condition spawning multiple cocod and routstrd instances
When multiple routstrd processes (or CLI auto-starts) bootstrap simultaneously,
each process found cocod/routstrd unreachable, spawned its own copy, and
contended for the same database socket. The paired ~200ms burst timestamps
in the logs were the symptom.

Fixes applied:

- Add cross-process startup lock (process-lock.ts)
  - Uses atomic mkdir as the lock primitive.
  - Stores PID + UUID token; verifies ownership on release to avoid
    removing a lock taken by a new process after the original crashed.
  - Removes stale locks (unreachable PID or lock older than staleAfterMs).
  - Exports withCrossProcessLock() for simple RAII-style usage.

- Fix cocod startup race (cocod-client.ts)
  - Wrap ensureDaemonRunning() in a socket-derived lock path.
  - Re-ping inside the lock before spawning; if another process just started
    cocod, the current process detects it and connects instead of spawning.
  - Switch from 'cocod daemon' to 'cocod init' for cleaner daemonization.
  - Allow exit code 0 from 'cocod init' (normal daemon exit without error).

- Consolidate routstrd daemon startup (daemon-client.ts)
  - startDaemonProcess() now delegates to startDaemon() from start-daemon.ts,
    sharing the same lock and health-check logic.

- Add cross-process lock to routstrd daemon startup (start-daemon.ts)
  - Performs pre-lock isDaemonHealthy() check to skip lock entirely when
    already running.
  - Performs re-check inside the lock before spawning to avoid duplicate
    daemon processes.
2026-04-30 13:36:16 +05:30

178 lines
4.5 KiB
TypeScript

import { existsSync } from "fs";
import { startDaemon } from "../start-daemon";
import {
CONFIG_FILE,
DEFAULT_CONFIG,
type RoutstrdConfig,
} from "./config";
import {
createNIP98Authorization,
parseSecretKey,
npubFromSecretKey,
type HttpMethod,
} from "./nip98";
export interface CommandResponse {
output?: unknown;
error?: string;
}
export async function loadConfig(): Promise<RoutstrdConfig> {
try {
if (existsSync(CONFIG_FILE)) {
const content = await Bun.file(CONFIG_FILE).text();
return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
}
} catch (error) {
console.error("Failed to load config:", error);
}
return DEFAULT_CONFIG;
}
export function getDaemonBaseUrl(config: RoutstrdConfig): string {
return (
config.daemonUrl?.replace(/\/$/, "") || `http://localhost:${config.port}`
);
}
export async function callDaemon(
path: string,
options: { method?: "GET" | "POST" | "DELETE"; body?: object } = {},
): Promise<CommandResponse> {
const { method = "GET", body } = options;
const config = await loadConfig();
const baseUrl = getDaemonBaseUrl(config);
const url = `${baseUrl}${path}`;
const bodyString = body ? JSON.stringify(body) : undefined;
const bodyBytes = bodyString
? new TextEncoder().encode(bodyString)
: undefined;
let authorization: string | undefined;
if (config.daemonUrl && config.nsec) {
const secretKey = parseSecretKey(config.nsec);
authorization = await createNIP98Authorization(
secretKey,
url,
method as HttpMethod,
bodyBytes,
);
}
const response = await fetch(url, {
method,
headers: {
...(authorization ? { Authorization: authorization } : {}),
...(bodyString ? { "Content-Type": "application/json" } : {}),
},
body: bodyString,
});
if (!response.ok) {
const errorData = (await response.json()) as { error?: string };
throw new Error(errorData.error || `HTTP ${response.status}`);
}
return response.json() as Promise<CommandResponse>;
}
export async function isDaemonRunning(): Promise<boolean> {
try {
const config = await loadConfig();
const baseUrl = getDaemonBaseUrl(config);
const url = `${baseUrl}/health`;
let authorization: string | undefined;
if (config.daemonUrl && config.nsec) {
const secretKey = parseSecretKey(config.nsec);
authorization = await createNIP98Authorization(secretKey, url, "GET");
}
const response = await fetch(url, {
headers: authorization ? { Authorization: authorization } : {},
});
return response.ok;
} catch {
return false;
}
}
export function getUserNpub(config: RoutstrdConfig): string | null {
if (!config.nsec) return null;
try {
const secretKey = parseSecretKey(config.nsec);
return npubFromSecretKey(secretKey);
} catch {
return null;
}
}
export function getNpubSuffix(config: RoutstrdConfig): string | null {
const npub = getUserNpub(config);
if (!npub) return null;
return npub.slice(-7);
}
export async function startDaemonProcess(): Promise<void> {
const config = await loadConfig();
await startDaemon({
port: String(config.port || 8008),
provider: config.provider || undefined,
});
}
export async function ensureDaemonRunning(): Promise<void> {
if (await isDaemonRunning()) {
return;
}
const config = await loadConfig();
if (config.daemonUrl) {
throw new Error(`Daemon is not reachable at ${config.daemonUrl}`);
}
console.log("Starting daemon...");
await startDaemonProcess();
}
export async function handleDaemonCommand(
path: string,
options: { method?: "GET" | "POST"; body?: object } = {},
): Promise<CommandResponse> {
try {
await ensureDaemonRunning();
const result = await callDaemon(path, options);
if (result.error) {
console.log(result.error);
process.exit(1);
}
if (result.output !== undefined) {
if (typeof result.output === "string") {
console.log(result.output);
} else {
try {
const formatted = JSON.stringify(result.output, null, 2);
console.log(formatted ?? String(result.output));
} catch {
console.log(String(result.output));
}
}
}
return result;
} catch (error) {
const message = (error as Error).message;
if (
message?.includes("fetch failed") ||
message?.includes("Connection refused")
) {
console.error("Daemon is not running and failed to auto-start");
process.exit(1);
}
console.error(message);
process.exit(1);
}
}