mirror of
https://github.com/Routstr/routstrd.git
synced 2026-08-11 20:47:58 +00:00
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.
178 lines
4.5 KiB
TypeScript
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);
|
|
}
|
|
} |