fix: capture daemon crash logs and prevent silent crashes

- Redirect detached daemon stdout/stderr to ~/.routstrd/debug.log
  instead of ignoring them, so uncaught exception stack traces are
  no longer lost
- Add process-level uncaughtException/unhandledRejection handlers
  that log to the file logger before the process dies
- Wrap setInterval async callbacks (model refresh + refund jobs) in
  IIFE catch chains so rejected promises can't escape and kill the
  process silently
This commit is contained in:
redshift
2026-07-09 00:07:13 +08:00
parent 44cda2577d
commit 60bc8b4cb2
2 changed files with 72 additions and 53 deletions
+66 -51
View File
@@ -49,6 +49,17 @@ import { FileRequestResponseLogSink } from "./request-response-log-sink";
import { refreshModelsAndIntegrations } from "../integrations";
import { RoutstrClient } from "@routstr/sdk";
// Global error handlers — the daemon is spawned detached with stdout/stderr
// redirected to a file, so without these, uncaught async errors would kill
// the process silently. Log to the file logger before exiting.
process.on("uncaughtException", (error) => {
logger.error("UNCAUGHT EXCEPTION:", error);
});
process.on("unhandledRejection", (reason) => {
logger.error("UNHANDLED REJECTION:", reason);
});
async function main(): Promise<void> {
const args = parseArgs(process.argv);
const config = await loadDaemonConfig();
@@ -172,20 +183,22 @@ async function main(): Promise<void> {
`Starting recurring model refresh job (every ${REFRESH_INTERVAL_MS / 1000 / 60 / 60} hours)`,
);
refreshInterval = setInterval(async () => {
logger.log("Running scheduled Nostr event refresh...");
try {
await modelManager.refreshNostrEvents();
} catch (error) {
logger.error("Scheduled Nostr event refresh failed:", error);
}
refreshInterval = setInterval(() => {
(async () => {
logger.log("Running scheduled Nostr event refresh...");
try {
await modelManager.refreshNostrEvents();
} catch (error) {
logger.error("Scheduled Nostr event refresh failed:", error);
}
logger.log("Running scheduled model refresh...");
try {
await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Scheduled");
} catch (error) {
logger.error("Scheduled model refresh failed:", error);
}
logger.log("Running scheduled model refresh...");
try {
await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Scheduled");
} catch (error) {
logger.error("Scheduled model refresh failed:", error);
}
})().catch((error) => logger.error("Model refresh interval escaped:", error));
}, REFRESH_INTERVAL_MS);
};
@@ -206,46 +219,48 @@ async function main(): Promise<void> {
`Starting recurring refund job (every ${REFUND_INTERVAL_MS / 1000 / 60} minutes)`,
);
refundInterval = setInterval(async () => {
logger.log("Running scheduled refund...");
try {
const state = store.getState() as any;
const pendingDistribution = (state.cachedTokens || []).map(
(t: { baseUrl: string; balance?: number }) => ({
baseUrl: t.baseUrl,
amount: t.balance || 0,
}),
);
const apiKeysStored = (state.apiKeys || []).map(
(k: { baseUrl: string; balance?: number }) => ({
baseUrl: k.baseUrl,
amount: k.balance || 0,
}),
);
refundInterval = setInterval(() => {
(async () => {
logger.log("Running scheduled refund...");
try {
const state = store.getState() as any;
const pendingDistribution = (state.cachedTokens || []).map(
(t: { baseUrl: string; balance?: number }) => ({
baseUrl: t.baseUrl,
amount: t.balance || 0,
}),
);
const apiKeysStored = (state.apiKeys || []).map(
(k: { baseUrl: string; balance?: number }) => ({
baseUrl: k.baseUrl,
amount: k.balance || 0,
}),
);
if (pendingDistribution.length === 0 && apiKeysStored.length === 0) {
logger.log("No pending tokens to refund.");
return;
if (pendingDistribution.length === 0 && apiKeysStored.length === 0) {
logger.log("No pending tokens to refund.");
return;
}
const mintUrl = walletAdapter.getActiveMintUrl();
if (!mintUrl) {
logger.log("No active mint URL for refund.");
return;
}
const spender = refundClient.getCashuSpender();
const results = await spender.refundProviders(mintUrl);
const successCount = results.filter(
(r: { success: boolean }) => r.success,
).length;
logger.log(
`Scheduled refund completed: ${successCount}/${results.length} providers refunded.`,
);
} catch (error) {
logger.error("Scheduled refund failed:", error);
}
const mintUrl = walletAdapter.getActiveMintUrl();
if (!mintUrl) {
logger.log("No active mint URL for refund.");
return;
}
const spender = refundClient.getCashuSpender();
const results = await spender.refundProviders(mintUrl);
const successCount = results.filter(
(r: { success: boolean }) => r.success,
).length;
logger.log(
`Scheduled refund completed: ${successCount}/${results.length} providers refunded.`,
);
} catch (error) {
logger.error("Scheduled refund failed:", error);
}
})().catch((error) => logger.error("Refund interval escaped:", error));
}, REFUND_INTERVAL_MS);
};
+6 -2
View File
@@ -1,8 +1,10 @@
import { openSync } from "fs";
import { logger } from "./utils/logger";
import { CONFIG_DIR, LOGS_DIR } from "./utils/config";
import { withCrossProcessLock } from "./utils/process-lock";
const DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`;
const DEBUG_LOG_PATH = `${CONFIG_DIR}/debug.log`;
async function isDaemonHealthy(port: string): Promise<boolean> {
const controller = new AbortController();
@@ -42,9 +44,11 @@ async function startDaemonUnlocked(
const daemonScript = new URL("./daemon/index.js", import.meta.url).pathname;
const shellCmd = `bun run "${daemonScript}" ${args.map((a) => `'${a}'`).join(" ")}`;
const debugLogFd = openSync(DEBUG_LOG_PATH, "a");
const proc = Bun.spawn(["sh", "-c", shellCmd], {
stdout: "ignore",
stderr: "ignore",
stdout: debugLogFd,
stderr: debugLogFd,
stdin: "ignore",
detached: true,
});