From 60bc8b4cb2f9aa8f7d278ab843ff2dfe29968f1e Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:07:13 +0800 Subject: [PATCH] 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 --- src/daemon/index.ts | 117 +++++++++++++++++++++++++------------------- src/start-daemon.ts | 8 ++- 2 files changed, 72 insertions(+), 53 deletions(-) diff --git a/src/daemon/index.ts b/src/daemon/index.ts index f1d0de4..c44e605 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -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 { const args = parseArgs(process.argv); const config = await loadDaemonConfig(); @@ -172,20 +183,22 @@ async function main(): Promise { `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 { `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); }; diff --git a/src/start-daemon.ts b/src/start-daemon.ts index 9d9ea8f..f807886 100644 --- a/src/start-daemon.ts +++ b/src/start-daemon.ts @@ -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 { 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, });