Merge pull request #117 from Routstr/fix/nwc-status-hang

fix(nwc): bound NWC requests so nwc status cannot hang forever
This commit is contained in:
redshift
2026-10-02 08:51:31 +00:00
committed by GitHub
9 changed files with 615 additions and 63 deletions
+13
View File
@@ -0,0 +1,13 @@
# NWC request timeouts
In `applesauce-wallet-connect@6.2.0`, encryption negotiation waits for a wallet-info event (kind 13194) before starting the response timeout. If a relay subscription stalls before that event arrives, the library request can remain pending indefinitely.
The wallet adapter adds an overall deadline: 15 seconds per read attempt and 45 seconds per payment. Reads can rebuild the relay connection and retry once. Payments are never automatically retried. The library still applies its own 30-second response timeout after negotiation; the 45-second deadline does not extend it.
Normal NIP-47 wallet errors do not rebuild the shared relay connection: a wallet error proves a response arrived, and rebuilding could interrupt unrelated payments. Transport failures and timeouts, including the library's own timeout, still trigger recovery.
Every CLI daemon request has a deadline covering headers and response-body consumption: 120 seconds by default, and 600 seconds for value-moving wallet routes (`/wallet/send/*`, `/wallet/receive/*`), whose mint operations can legitimately run longer. Aborting the CLI request never cancels the daemon-side operation; the longer bound only delays how soon the CLI reports the stall.
A payment timeout is an **unknown outcome**, not proof that no payment occurred. Promise deadlines do not cancel the underlying operation. Check the mint quote, wallet transactions, and Cashu balance before creating and paying another invoice.
The auto-refill loop starts when static configuration or a dynamic configuration getter is supplied, even without an NWC connection at startup. It reads the current wallet and configuration each cycle, so a later connection can activate refills without restarting the daemon.
+10 -1
View File
@@ -37,6 +37,15 @@ export function startAutoRefillLoop(
getWallet: () => WalletConnect | undefined,
getConfig: () => AutoRefillConfig | undefined,
intervalMs: number = 5000,
payInvoice: (
invoice: string,
) => Promise<{ preimage?: string; fees_paid?: number }> = (invoice) => {
const wallet = getWallet();
if (!wallet?.service) {
return Promise.reject(new Error("NWC not connected"));
}
return wallet.payInvoice(invoice);
},
): () => void {
let lastRefillAt = 0;
let lastAttemptAt = 0; // tracks last attempt (success or failure) for backoff
@@ -113,7 +122,7 @@ export function startAutoRefillLoop(
logger.log("[auto-refill] Wallet disconnected during refill check");
return;
}
const payment = await currentWallet.payInvoice(invoice);
const payment = await payInvoice(invoice);
// Step 3: The Cashu mint should automatically detect the paid invoice
// and issue tokens. We don't need to explicitly mint here; cocod
+2 -10
View File
@@ -1,3 +1,4 @@
import { withTimeout as withRequestTimeout } from "../../utils/with-timeout";
import { recoveryKey, trackRecovery, drainRecoveryWork, waitForRecoveryWork, createRecoveryDisposer, type RecoveryWork } from "./recovery-work";
import {
Manager,
@@ -733,16 +734,7 @@ const EXPIRED_MINT_OBSERVATION_DEADLINE_MS = 15_000;
/** Rejects when `timeoutMs` elapses before `promise` settles. */
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
if (timeoutMs === Infinity) return promise;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new Error("Timed out contacting mint")),
timeoutMs,
);
});
return Promise.race([promise, timeout]).finally(() => {
if (timer !== undefined) clearTimeout(timer);
});
return withRequestTimeout(promise, timeoutMs, "Timed out contacting mint");
}
/** Structural subset of coco's Manager used by expired-quote settlement. */
+240
View File
@@ -0,0 +1,240 @@
import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test";
import { RestrictedError } from "applesauce-wallet-connect/helpers/error";
import type { WalletAdapterOptions } from "./index";
/**
* Regression tests for the NWC hang fixed in this change.
*
* `applesauce-wallet-connect` applies its request timeout only after it has
* negotiated encryption from the wallet's `kind:13194` info event. On a stale
* relay subscription that negotiation never completes, so `getInfo()` /
* `getBalance()` wait forever. The adapter must bound the wait, rebuild the
* relay connection, and retry once instead of hanging `routstrd nwc status`.
*/
type Behavior = "resolve" | "hang" | "restricted" | "library-timeout" | "deferred";
const state = {
infoQueue: [] as Behavior[],
balanceQueue: [] as Behavior[],
payQueue: [] as Behavior[],
createdInstances: 0,
pendingPayments: [] as (() => void)[],
paymentStarted: undefined as (() => void) | undefined,
closedPools: 0,
};
function next(queue: Behavior[]): Behavior {
return queue.shift() ?? "resolve";
}
class MockRelayPool {
relays = new Map<string, unknown>([["wss://relay.example", {}]]);
remove(url: string, _close?: boolean): void {
state.closedPools++;
this.relays.delete(url);
}
}
class MockWalletConnect {
service = "ab".repeat(32);
relays = ["wss://relay.example"];
constructor() {
state.createdInstances += 1;
}
static fromConnectURI(_uri: string): MockWalletConnect {
return new MockWalletConnect();
}
waitForService(): Promise<string> {
return Promise.resolve(this.service);
}
getInfo(): Promise<{
alias: string;
pubkey: string;
network: string;
methods: string[];
}> {
const behavior = next(state.infoQueue);
if (behavior === "hang") return new Promise<never>(() => {});
if (behavior === "restricted") return Promise.reject(new RestrictedError("restricted"));
if (behavior === "library-timeout") return Promise.reject(new Error("Timeout"));
return Promise.resolve({
alias: "Test Wallet",
pubkey: "cd".repeat(32),
network: "mainnet",
methods: ["get_balance", "get_info"],
});
}
getBalance(): Promise<{ balance: number }> {
const behavior = next(state.balanceQueue);
if (behavior === "hang") return new Promise<never>(() => {});
if (behavior === "restricted") return Promise.reject(new RestrictedError("restricted"));
return Promise.resolve({ balance: 123_000 });
}
payInvoice(
_invoice: string,
): Promise<{ preimage: string; fees_paid: number }> {
const behavior = next(state.payQueue);
if (behavior === "hang") return new Promise<never>(() => {});
if (behavior === "restricted") return Promise.reject(new RestrictedError("restricted"));
if (behavior === "library-timeout") return Promise.reject(new Error("Timeout"));
if (behavior === "deferred") {
return new Promise((resolve) => {
const closedAtStart = state.closedPools;
state.pendingPayments.push(() => {
// Closing the shared pool loses the original payment response.
if (state.closedPools === closedAtStart) {
resolve({ preimage: "00".repeat(32), fees_paid: 1000 });
}
});
state.paymentStarted?.();
});
}
return Promise.resolve({ preimage: "00".repeat(32), fees_paid: 1000 });
}
}
mock.module("applesauce-wallet-connect", () => ({
WalletConnect: MockWalletConnect,
}));
mock.module("applesauce-relay", () => ({ RelayPool: MockRelayPool }));
const { createWalletAdapter } = await import("./index");
/**
* Type of the injected wallet client, derived from the adapter options so this
* test keeps compiling when the legacy `CocodClient` is replaced (see #118).
*/
type WalletClientOption = NonNullable<WalletAdapterOptions["walletClient"]>;
function makeClient(): WalletClientOption {
return {
getBalances: async () => ({ "https://mint.example": 0 }),
getDefaultMint: async () => "https://mint.example",
receiveBolt11: async () => ({ invoice: "lnbc-test-invoice" }),
receiveCashu: async () => "ok",
} as unknown as WalletClientOption;
}
function makeAdapter(timeoutMs = 25) {
return createWalletAdapter({
walletClient: makeClient(),
nwcConnectionString:
"nostr+walletconnect://" +
"ab".repeat(32) +
"?relay=wss%3A%2F%2Frelay.example&secret=" +
"11".repeat(32),
nwcReadTimeoutMs: timeoutMs,
nwcPayTimeoutMs: timeoutMs,
});
}
beforeEach(() => {
state.infoQueue = [];
state.balanceQueue = [];
state.payQueue = [];
state.createdInstances = 0;
state.closedPools = 0;
state.pendingPayments = [];
state.paymentStarted = undefined;
});
afterAll(() => {
mock.restore();
});
describe("NWC status resilience", () => {
it("rebuilds a stale relay connection and still reports status", async () => {
state.infoQueue = ["hang"]; // first attempt stalls, retry succeeds
const adapter = await makeAdapter();
const status = await adapter.getNwcStatus();
expect(status.connected).toBe(true);
expect(status.alias).toBe("Test Wallet");
expect(status.balance).toBe(123);
// initial connection + one rebuild
expect(state.createdInstances).toBe(2);
});
it("returns a bounded error instead of hanging forever", async () => {
state.infoQueue = ["hang", "hang"]; // every attempt stalls
const adapter = await makeAdapter();
const startedAt = Date.now();
const status = await adapter.getNwcStatus();
expect(status.connected).toBe(false);
expect(status.error).toContain("timed out");
expect(Date.now() - startedAt).toBeLessThan(2_000);
});
it("bounds a hung invoice payment and surfaces the error", async () => {
state.payQueue = ["hang"];
const adapter = await makeAdapter();
const startedAt = Date.now();
const result = await adapter.fundFromNWC(2100);
expect(result.success).toBe(false);
expect(result.error).toContain("timed out");
expect(Date.now() - startedAt).toBeLessThan(2_000);
});
});
describe("NWC wallet errors versus transport stalls", () => {
it("does not retry or rebuild on a wallet read error", async () => {
state.infoQueue = ["restricted"];
const adapter = await makeAdapter();
expect((await adapter.getNwcStatus()).error).toBe("restricted");
expect(state.createdInstances).toBe(1);
expect(state.closedPools).toBe(0);
});
it("keeps an in-flight payment alive during a pay-only status check", async () => {
state.payQueue = ["deferred"];
state.balanceQueue = ["restricted"];
const adapter = await makeAdapter(500);
const started = new Promise<void>((resolve) => { state.paymentStarted = resolve; });
const payment = adapter.fundFromNWC(2100);
await started;
expect((await adapter.getNwcStatus()).connected).toBe(true);
expect(state.createdInstances).toBe(1);
state.pendingPayments[0]!();
expect((await payment).success).toBe(true);
expect(state.closedPools).toBe(0);
});
it("keeps a slow payment alive when an overlapping payment gets a wallet error", async () => {
state.payQueue = ["deferred", "restricted"];
const adapter = await makeAdapter(500);
const started = new Promise<void>((resolve) => { state.paymentStarted = resolve; });
const slow = adapter.fundFromNWC(2100);
await started;
expect((await adapter.fundFromNWC(2100)).error).toBe("restricted");
state.pendingPayments[0]!();
expect((await slow).success).toBe(true);
expect(state.createdInstances).toBe(1);
});
it("rebuilds and retries reads on the library's own timeout", async () => {
state.infoQueue = ["library-timeout"];
const adapter = await makeAdapter();
expect((await adapter.getNwcStatus()).connected).toBe(true);
expect(state.createdInstances).toBe(2);
});
it("rebuilds after the library's payment timeout without retrying payment", async () => {
state.payQueue = ["library-timeout", "restricted"];
const adapter = await makeAdapter();
expect((await adapter.fundFromNWC(2100)).error).toBe("Timeout");
expect(state.createdInstances).toBe(2);
expect(state.payQueue).toEqual(["restricted"]);
});
});
+162 -43
View File
@@ -1,11 +1,24 @@
import { getTokenMetadata } from "@cashu/cashu-ts";
import { InsufficientBalanceError } from "@routstr/sdk";
import { WalletConnect } from "applesauce-wallet-connect";
import { WalletBaseError } from "applesauce-wallet-connect/helpers/error";
import { RelayPool } from "applesauce-relay";
import { logger } from "../../utils/logger";
import { withTimeout } from "../../utils/with-timeout";
import { createCocodClient, type CocodClient } from "./cocod-client";
import { startAutoRefillLoop, type AutoRefillConfig } from "./auto-refill";
/**
* NWC reads (get_info/get_balance) should answer in a couple of seconds. If
* they don't, the long-lived relay subscription is presumed stale and the
* connection is rebuilt before one retry.
*/
const NWC_READ_TIMEOUT_MS = 15_000;
/** Overall bound including encryption negotiation; replies have a library 30s timeout. */
const NWC_PAY_TIMEOUT_MS = 45_000;
type NwcPayment = { preimage?: string; fees_paid?: number };
export function decodeCashuTokenAmount(token: string): {
amount: number;
unit: "sat" | "msat";
@@ -37,6 +50,10 @@ export interface WalletAdapterOptions {
walletClient?: CocodClient;
/** NWC connection string for Lightning funding (uses applesauce-wallet-connect) */
nwcConnectionString?: string;
/** Override the NWC read timeout in milliseconds (test hook). */
nwcReadTimeoutMs?: number;
/** Override the NWC payment timeout in milliseconds (test hook). */
nwcPayTimeoutMs?: number;
/** Auto-refill configuration (static, for startup only) */
autoRefill?: AutoRefillConfig;
/**
@@ -82,19 +99,44 @@ export async function createWalletAdapter(
let wallet: WalletConnect | undefined;
let pool: RelayPool | undefined;
let nwcConnectionString = options.nwcConnectionString;
const nwcReadTimeoutMs = options.nwcReadTimeoutMs ?? NWC_READ_TIMEOUT_MS;
const nwcPayTimeoutMs = options.nwcPayTimeoutMs ?? NWC_PAY_TIMEOUT_MS;
// Getter for the current wallet instance (used by auto-refill loop)
const getWallet = (): WalletConnect | undefined => wallet;
if (options.nwcConnectionString) {
pool = new RelayPool();
wallet = WalletConnect.fromConnectURI(options.nwcConnectionString, { pool });
/** Close the active relay pool, if any. */
function closeNwcPool(): void {
if (pool) {
for (const [url] of pool.relays) {
pool.remove(url, true);
}
}
pool = undefined;
}
/**
* (Re)create the relay pool + WalletConnect client for a connection string.
* Shared by interactive connects and self-healing recovery so both paths use
* identical setup.
*/
function connectNwc(connectionString: string, reason: string): void {
const nextPool = new RelayPool();
const nextWallet = WalletConnect.fromConnectURI(connectionString, {
pool: nextPool,
});
pool = nextPool;
wallet = nextWallet;
nwcConnectionString = connectionString;
// Connect in background (non-blocking)
wallet.waitForService()
nextWallet
.waitForService()
.then(() => {
logger.log(
`[nwc] NWC wallet connected. Relay: ${wallet!.relays[0]}, Service: ${wallet!.service}`,
`[nwc] NWC wallet ${reason}. Relay: ${nextWallet.relays[0]}, Service: ${nextWallet.service}`,
);
})
.catch((err) => {
@@ -102,39 +144,97 @@ export async function createWalletAdapter(
});
}
/**
* Rebuild the NWC connection in place after a request stalled. applesauce's
* request timeout only covers the response stream, so a stale relay
* subscription can leave `getInfo`/`getBalance` pending forever while it
* negotiates encryption. Recreating the pool gives the next call a fresh
* subscription.
*/
function rebuildNwcConnection(reason: string): void {
if (!nwcConnectionString) return;
closeNwcPool();
wallet = undefined;
connectNwc(nwcConnectionString, reason);
}
/**
* Run an idempotent NWC read, bounding the wait and rebuilding the connection
* once if it stalls.
*/
async function nwcRead<T>(
label: string,
operation: (w: WalletConnect) => Promise<T>,
): Promise<T> {
const first = wallet;
if (!first?.service) {
throw new Error("NWC not connected");
}
try {
return await withTimeout(
operation(first),
nwcReadTimeoutMs,
`${label} timed out`,
);
} catch (error) {
// A normal NIP-47 error proves the wallet answered. Keep other calls alive.
if (error instanceof WalletBaseError || !nwcConnectionString) throw error;
logger.warn(
`[nwc] ${label} failed (${(error as Error).message}); rebuilding NWC connection and retrying`,
);
rebuildNwcConnection("reconnected after stall");
const retry = wallet;
if (!retry?.service) throw error;
return await withTimeout(
operation(retry),
nwcReadTimeoutMs,
`${label} timed out after reconnect`,
);
}
}
/**
* Pay a BOLT-11 invoice over NWC with a bounded wait. A timeout rebuilds the
* relay connection so later calls recover without a daemon restart. The
* payment is not retried here. A timeout is an unknown payment outcome,
* not proof of failure; callers must reconcile before trying a fresh invoice.
*/
async function payNwcInvoice(invoice: string): Promise<NwcPayment> {
const payer = wallet;
if (!payer?.service) throw new Error("NWC not connected");
try {
return await withTimeout(
payer.payInvoice(invoice),
nwcPayTimeoutMs,
"NWC payment timed out",
);
} catch (error) {
// Include the library's own timeout, but not normal wallet error replies.
if (!(error instanceof WalletBaseError)) {
rebuildNwcConnection("reconnected after payment stall");
}
throw error;
}
}
if (options.nwcConnectionString) {
connectNwc(options.nwcConnectionString, "connected");
}
const walletAdapter = {
async reconnect(connectionString?: string): Promise<void> {
logger.log(
`[nwc] Reconnecting NWC wallet... ${connectionString ? "new connection string provided" : "disconnecting"}`,
);
// 1. Close existing relay pool connections
if (pool) {
for (const [url] of pool.relays) {
pool.remove(url, true);
}
}
// 2. Update wallet reference
// Close existing relay pool connections and update the wallet reference
closeNwcPool();
wallet = undefined;
pool = undefined;
// 3. Create new wallet if connection string provided
if (connectionString) {
pool = new RelayPool();
wallet = WalletConnect.fromConnectURI(connectionString, { pool });
// Connect in background (non-blocking)
wallet.waitForService()
.then(() => {
logger.log(
`[nwc] NWC wallet reconnected. Relay: ${wallet!.relays[0]}, Service: ${wallet!.service}`,
);
})
.catch((err) => {
logger.error(`[nwc] NWC reconnection failed: ${err.message}`);
});
connectNwc(connectionString, "reconnected");
} else {
nwcConnectionString = undefined;
logger.log("[nwc] NWC wallet disconnected.");
}
},
@@ -192,9 +292,9 @@ export async function createWalletAdapter(
const { invoice } = await client.receiveBolt11(amount, mintUrl);
logger.log(`[nwc] Invoice: ${invoice}`);
// Step 3: Pay it via NWC
// Step 3: Pay it via NWC (bounded — a stale relay must not hang the CLI)
logger.log("[nwc] Paying invoice via NWC...");
const { preimage, fees_paid } = await wallet.payInvoice(invoice);
const { preimage, fees_paid } = await payNwcInvoice(invoice);
logger.log(`[nwc] ✅ Payment successful!`);
logger.log(`[nwc] Preimage: ${preimage}`);
if (fees_paid !== undefined) {
@@ -244,10 +344,10 @@ export async function createWalletAdapter(
}
try {
const info = await wallet.getInfo();
const info = await nwcRead("get_info", (w) => w.getInfo());
let balance: number | undefined;
try {
const bal = await wallet.getBalance();
const bal = await nwcRead("get_balance", (w) => w.getBalance());
balance = Math.floor(bal.balance / 1000); // msats → sats
} catch {
// Balance might not be available
@@ -326,21 +426,40 @@ export async function createWalletAdapter(
let stopAutoRefill: (() => void) | undefined;
/**
* Start the auto-refill loop if it is not already running. Safe to call more
* than once; it becomes a no-op after the first call.
*/
function ensureAutoRefillLoop(): void {
if (stopAutoRefill) return;
const getConfig = options.getAutoRefillConfig ?? (() => options.autoRefill);
stopAutoRefill = startAutoRefillLoop(
client,
getWallet,
getConfig,
5000,
payNwcInvoice,
);
}
const autoRefillConfig = options.getAutoRefillConfig
? options.getAutoRefillConfig()
: options.autoRefill;
if (autoRefillConfig && wallet) {
const getConfig = options.getAutoRefillConfig ?? (() => options.autoRefill);
stopAutoRefill = startAutoRefillLoop(client, getWallet, getConfig);
logger.log(
`[wallet] Auto-refill enabled: threshold=${autoRefillConfig.threshold} sats, amount=${autoRefillConfig.amount} sats, cooldown=${autoRefillConfig.cooldownMs / 60000} minutes`,
);
} else if (wallet && options.getAutoRefillConfig) {
// Wallet exists but auto-refill is not currently enabled.
// Start the loop anyway so it can pick up changes without a restart.
stopAutoRefill = startAutoRefillLoop(client, getWallet, options.getAutoRefillConfig);
logger.log("[wallet] Auto-refill loop started (currently disabled — enable via CLI to activate)");
if (options.getAutoRefillConfig || options.autoRefill) {
// Start the loop even when no wallet is connected yet: it reads the wallet
// and config fresh each cycle, so a later `nwc connect` activates refills
// without a daemon restart.
ensureAutoRefillLoop();
if (autoRefillConfig) {
logger.log(
`[wallet] Auto-refill enabled: threshold=${autoRefillConfig.threshold} sats, amount=${autoRefillConfig.amount} sats, cooldown=${autoRefillConfig.cooldownMs / 60000} minutes`,
);
} else {
logger.log(
"[wallet] Auto-refill loop started (currently disabled — enable via CLI to activate)",
);
}
}
try {
+91
View File
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, test } from "bun:test";
import {
callDaemonUrl,
DAEMON_LONG_REQUEST_TIMEOUT_MS,
DAEMON_REQUEST_TIMEOUT_MS,
} from "./daemon-client";
import { DEFAULT_CONFIG } from "./config";
const originalFetch = globalThis.fetch;
const originalAbortTimeout = AbortSignal.timeout;
/** Deadline (ms) passed to AbortSignal.timeout by the last request. */
let requestedDeadlines: number[] = [];
afterEach(() => {
globalThis.fetch = originalFetch;
AbortSignal.timeout = originalAbortTimeout;
requestedDeadlines = [];
});
/**
* Record the requested deadline and arm a fast one instead, so tests do not
* wait out the real 120s/600s bounds.
*/
function shortenDeadline(): void {
AbortSignal.timeout = ((ms: number) => {
requestedDeadlines.push(ms);
return originalAbortTimeout(20);
}) as typeof AbortSignal.timeout;
}
function stalledResponse(status = 200): void {
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
const signal = init?.signal;
return new Response(new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"output":'));
signal?.addEventListener("abort", () => controller.error(signal.reason), { once: true });
},
}), { status });
}) as unknown as typeof fetch;
}
const request = (path = "/nwc/status") =>
callDaemonUrl("http://daemon.example", path, {}, DEFAULT_CONFIG);
describe("daemon request deadline", () => {
test("bounds the wait for response headers", async () => {
shortenDeadline();
globalThis.fetch = ((_input: string | URL | Request, init?: RequestInit) => new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true });
})) as unknown as typeof fetch;
await expect(request()).rejects.toThrow("Daemon request timed out");
});
for (const status of [200, 500]) {
test(`bounds a stalled ${status} response body`, async () => {
shortenDeadline();
stalledResponse(status);
await expect(request()).rejects.toThrow("payment outcome is unknown");
});
}
test("applies the default deadline to ordinary routes", async () => {
shortenDeadline();
globalThis.fetch = (async () => Response.json({ output: "ok" })) as unknown as typeof fetch;
expect(await request("/nwc/status")).toEqual({ output: "ok" });
expect(requestedDeadlines).toEqual([DAEMON_REQUEST_TIMEOUT_MS]);
});
for (const path of ["/wallet/send/bolt11", "/wallet/receive/cashu"]) {
test(`uses the long deadline for value-moving route ${path}`, async () => {
shortenDeadline();
globalThis.fetch = (async () => Response.json({ output: "paid" })) as unknown as typeof fetch;
expect(await request(path)).toEqual({ output: "paid" });
expect(requestedDeadlines).toEqual([DAEMON_LONG_REQUEST_TIMEOUT_MS]);
});
}
test("bounds a stalled body on a long-running route too", async () => {
shortenDeadline();
stalledResponse();
await expect(request("/wallet/send/bolt11")).rejects.toThrow("payment outcome is unknown");
expect(requestedDeadlines).toEqual([DAEMON_LONG_REQUEST_TIMEOUT_MS]);
});
test("preserves HTTP errors rather than labeling them connection failures", async () => {
globalThis.fetch = (async () => Response.json({ error: "restricted" }, { status: 403 })) as unknown as typeof fetch;
await expect(request()).rejects.toThrow("restricted");
});
});
+53 -9
View File
@@ -56,6 +56,32 @@ class DaemonConnectionError extends Error {
}
}
/**
* Upper bound for a single daemon request, including response-body
* consumption. The daemon bounds its own NWC operations, so this only guards
* against a wedged server; without it a hung request would block the CLI
* forever.
*/
export const DAEMON_REQUEST_TIMEOUT_MS = 120_000;
/**
* Upper bound for value-moving wallet routes. A cashu melt/swap can
* legitimately run longer than {@link DAEMON_REQUEST_TIMEOUT_MS} (the mint has
* no request timeout in routstrd), so these get a more generous bound that
* still prevents an indefinite CLI hang.
*/
export const DAEMON_LONG_REQUEST_TIMEOUT_MS = 600_000;
/** Routes that may legitimately outlive the default request timeout. */
const LONG_RUNNING_ROUTES = ["/wallet/send/", "/wallet/receive/"];
function requestTimeoutMs(path: string): number {
const pathname = path.split("?")[0] ?? path;
return LONG_RUNNING_ROUTES.some((route) => pathname.startsWith(route))
? DAEMON_LONG_REQUEST_TIMEOUT_MS
: DAEMON_REQUEST_TIMEOUT_MS;
}
export function getDaemonBaseUrl(config: RoutstrdConfig): string {
if (config.daemonUrl) {
return config.daemonUrl.replace(/\/$/, "");
@@ -70,7 +96,7 @@ export function getAuthBaseUrl(config: RoutstrdConfig): string {
return getDaemonBaseUrl(config);
}
async function _callUrl(
export async function callDaemonUrl(
baseUrl: string,
path: string,
options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object },
@@ -99,23 +125,41 @@ async function _callUrl(
if (authorization) headers.set("Authorization", authorization);
if (bodyString) headers.set("Content-Type", "application/json");
const timeoutMs = requestTimeoutMs(path);
const timeoutError = () =>
new Error(
`Daemon request timed out after ${timeoutMs / 1000}s; ` +
"any payment outcome is unknown — check before retrying",
);
// The signal stays armed while the body is read, so a daemon that sends
// headers and then stalls the body cannot hang the CLI either. Aborting
// here never cancels the daemon's operation — see timeoutError's warning.
const signal = AbortSignal.timeout(timeoutMs);
let response: Response;
try {
response = await fetch(url, {
method,
headers,
body: bodyString,
signal,
});
} catch (error) {
if (signal.aborted) throw timeoutError();
// Only connection failures qualify for alternate-host retries.
throw new DaemonConnectionError(error);
}
if (!response.ok) {
const errorData = (await response.json()) as { error?: string };
throw new Error(errorData.error || `HTTP ${response.status}`);
try {
if (!response.ok) {
const errorData = (await response.json()) as { error?: string };
throw new Error(errorData.error || `HTTP ${response.status}`);
}
return (await response.json()) as CommandResponse;
} catch (error) {
if (signal.aborted) throw timeoutError();
throw error;
}
return response.json() as Promise<CommandResponse>;
}
async function callLocalDaemon(
@@ -127,7 +171,7 @@ async function callLocalDaemon(
let connectionError: DaemonConnectionError | undefined;
for (const baseUrl of localDaemonBaseUrls(config)) {
try {
return await _callUrl(baseUrl, path, options, config);
return await callDaemonUrl(baseUrl, path, options, config);
} catch (error) {
if (!(error instanceof DaemonConnectionError)) throw error;
connectionError = error;
@@ -142,7 +186,7 @@ export async function callDaemon(
): Promise<CommandResponse> {
const config = await loadConfig();
if (config.daemonUrl) {
return _callUrl(getDaemonBaseUrl(config), path, options, config);
return callDaemonUrl(getDaemonBaseUrl(config), path, options, config);
}
return callLocalDaemon(path, options, config);
}
@@ -157,7 +201,7 @@ export async function callAuth(
if (!config.authUrl && !config.daemonUrl) {
return callLocalDaemon(path, options, config);
}
return _callUrl(getAuthBaseUrl(config), path, options, config);
return callDaemonUrl(getAuthBaseUrl(config), path, options, config);
}
export async function isDaemonRunning(): Promise<boolean> {
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test";
import { withTimeout } from "./with-timeout";
describe("withTimeout", () => {
test("passes through a settled value", async () => {
await expect(withTimeout(Promise.resolve(42), 1000, "nope")).resolves.toBe(
42,
);
});
test("rejects with the provided message once the deadline elapses", async () => {
const never = new Promise<never>(() => {});
await expect(withTimeout(never, 20, "operation timed out")).rejects.toThrow(
"operation timed out",
);
});
test("propagates the original rejection", async () => {
await expect(
withTimeout(Promise.reject(new Error("boom")), 1000, "unused"),
).rejects.toThrow("boom");
});
});
+21
View File
@@ -0,0 +1,21 @@
/**
* Rejects when `timeoutMs` elapses before `promise` settles.
* This bounds the caller's wait; it does not cancel the underlying operation.
*
* Used to bound requests that may otherwise wait forever — notably NWC calls
* whose underlying library applies its own timeout only after a support/encryption
* handshake that can itself hang on a stale relay subscription.
*/
export function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
message = "Operation timed out",
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => {
if (timer !== undefined) clearTimeout(timer);
});
}