fix(nwc): preserve wallet error responses and bound CLI response bodies

This commit is contained in:
redshift
2026-10-02 02:10:51 +08:00
parent c3a29900ff
commit 01fe864257
8 changed files with 221 additions and 289 deletions
-238
View File
@@ -1,238 +0,0 @@
# NWC hang: `routstrd nwc status` never returns
Investigation, root cause, fix, build, deployment, and verification.
- **Date:** 2026-09-29
- **Affected command:** `routstrd nwc status` (also `nwc fund` and the auto-refill loop)
- **Base version:** `routstrd` v0.4.11
- **Fix branch / commit:** `fix/nwc-status-hang` — `ceb6d72`
- **Library involved:** `applesauce-wallet-connect@6.2.0` (latest at the time)
---
## 1. Symptom
`routstrd nwc status` hung forever. The local daemon was alive and healthy;
only the NWC endpoint blocked.
```
$ curl -m 6 http://127.0.0.1:8008/health # 3 ms
$ curl -m 85 http://127.0.0.1:8008/nwc/status # still pending after 85 s
```
The daemon logs showed the same wedge for manual funding attempts, which never
printed a result:
```
14:14:19 [nwc] Paying invoice via NWC... <- never completed
14:16:03 [nwc] Paying invoice via NWC... <- never completed
```
## 2. Call chain
```
cli.ts:2198 nwc status action
-> handleDaemonCommand("/nwc/status") utils/daemon-client.ts
-> ensureDaemonRunning() (health check, OK)
-> fetch(...) (no timeout originally)
-> GET /nwc/status daemon/http/index.ts:660
-> deps.walletAdapter.getNwcStatus() wallet/index.ts
-> await wallet.getInfo() wallet/index.ts
-> await wallet.getBalance() wallet/index.ts
```
`wallet` is a `WalletConnect` instance from `applesauce-wallet-connect`.
## 3. Root cause
`applesauce-wallet-connect` applies its per-request timeout **only after** it has
negotiated an encryption scheme from the wallet's `kind:13194` info event:
```js
genericCall(method, params, options = {}) {
return defer(async () => {
const encryption = await firstValueFrom(this.encryption$); // can block forever
return await WalletRequestFactory.create(...).sign();
}).pipe(
switchMap((requestEvent) => {
const responses$ = this.events$.pipe(
...,
simpleTimeout(options.timeout || this.defaultTimeout), // 30 s default
);
return merge(request$, responses$);
}),
);
}
```
If `encryption$` never emits, the `switchMap` (and therefore the timeout) is
never even created. `encryption$` derives from `support$`, which only emits when
the wallet service publishes a `kind:13194` wallet-info event. On a stale or
half-open relay subscription that event never arrives, so `getInfo()` /
`getBalance()` wait forever.
The `"nip04"` fallback in `encryption$` is effectively unreachable (it maps over
`support$`, which is filtered to wallet-info events only). NWC only needs the
info event to *choose* encryption — it could default to nip04 and proceed, since
the service pubkey is already known from the connection URI.
### Proof
A standalone probe against an unreachable relay still had `getInfo()` pending
after 45 s, despite the library's advertised 30 s internal timeout:
```
service set from URI: true
internal defaultTimeout (s): 30
RESULT: getInfo() STILL PENDING after 45.004s — internal timeout never fires
```
A fresh `WalletConnect` to the real relay returned `getInfo` in ~2 s, so the
wallet/relay themselves were fine — the daemon's long-lived relay subscription
had gone stale (the daemon still held a TCP socket to the relay, `67.205.128.242:443`).
## 4. Contributing bugs in routstrd
Even with the library defect, routstrd should not have been able to hang forever:
1. **No timeout in the daemon's NWC calls.** `getNwcStatus`, `fundFromNWC`
(`wallet.payInvoice`), and the auto-refill loop had no bound.
2. **No timeout on the HTTP route.** `GET /nwc/status` just `await`s the payload;
so did every other route.
3. **No timeout on the CLI `fetch`.** `_callUrl()` in `utils/daemon-client.ts`
used `fetch` with no `AbortController`, so a hung daemon blocked the CLI.
4. **Auto-refill never started after a hot connect.** The loop was only started
during `createWalletAdapter(...)` when a connection string already existed at
startup. `nwc connect` hot-reloads via `walletAdapter.reconnect()`, which did
not start the loop — hence no `auto-refill` log lines at all.
## 5. The fix
Branch `fix/nwc-status-hang` (based on tag `v0.4.11`), commit `ceb6d72`.
- **`src/utils/with-timeout.ts`** (new)
`withTimeout(promise, ms, message)` — `Promise.race` with a clearing timer.
- **`src/daemon/wallet/index.ts`**
- NWC reads (`get_info` / `get_balance`) are bounded (default 15 s). On timeout,
the relay pool is closed, a fresh `WalletConnect`/`RelayPool` is created from
the stored connection string, and the read is retried once.
- Invoice payments are bounded (default 45 s). A timeout rebuilds the
connection so later calls recover without a daemon restart; the payment is
not auto-retried (the invoice is single-use — the caller decides).
- `ensureAutoRefillLoop()` starts the loop whenever auto-refill is configured,
even if no wallet is connected yet. `reconnect()` calls it, so a hot
`nwc connect` now activates refills without a restart.
- Timeouts are injectable (`nwcReadTimeoutMs`, `nwcPayTimeoutMs`) for tests.
- **`src/daemon/wallet/auto-refill.ts`**
Takes the bounded `payInvoice` function instead of calling
`wallet.payInvoice` directly.
- **`src/utils/daemon-client.ts`**
120 s `AbortController` timeout on CLI daemon requests, with a distinct
"Daemon request timed out" error instead of a generic connection failure.
- **Tests**
- `src/daemon/wallet/index.nwc.test.ts` — mocks `applesauce-wallet-connect` /
`applesauce-relay` and proves: (a) a stale connection is rebuilt and status
still succeeds, (b) two consecutive hangs produce a bounded error, (c) a hung
invoice payment returns an error instead of hanging.
- `src/utils/with-timeout.test.ts` — unit tests for the helper.
Design note: this is defense-in-depth on routstrd's side. A request that stalls
now fails in bounded time and self-heals instead of blocking a user command or
the daemon's auto-refill loop.
## 6. Build, install, restart
```sh
cd /root/workspace/routstrd
bun run build # dist/index.js + dist/daemon/index.js
```
The original global package was backed up first:
```
/root/routstrd-backup-20260929-155718
# path also stored in /root/.routstrd-build-backup-path
```
The new `dist/` (and matching `src/` files) were copied into
`/root/.bun/install/global/node_modules/routstrd/`, then:
```sh
routstrd restart
```
Old daemon PID `90` -> new PID `21452`; `/health` was polled until healthy
before the command returned (~2 s).
## 7. Verification
```
$ time routstrd nwc status
{
"connected": true,
"alias": "Megalithic.me",
"network": "mainnet",
"balance": 60876,
"autoRefill": { "threshold": 30000, "amount": 21000, "cooldownMs": 30000 }
}
real 0m1.540s
```
```
$ time curl -m30 http://127.0.0.1:8008/nwc/status
real 0m1.347s
```
The auto-refill loop now starts and actually runs:
```
[wallet] Auto-refill enabled: threshold=30000 sats, amount=21000 sats ...
[nwc] NWC wallet connected. Relay: wss://relay-nwc.rizful.com/v1 ...
[auto-refill] Paying invoice via NWC...
[auto-refill] Successfully refilled 21000 sats. Preimage: 49b20b7b...
```
`routstrd status` reports `wallet: connected`, balance `22820` sats.
## 8. Test results
New tests: **6 pass, 0 fail**. `bunx tsc --noEmit` is clean.
The full suite still reports 4 failures that are **pre-existing on a clean
`v0.4.11`** and unrelated to this change:
- 3 in `tests/utils/daemon-client.test.ts` — `tests/integrations/pi.test.ts`
calls `mock.module("../../src/utils/daemon-client", ...)` and the mock leaks
across files depending on run order.
- 1 in `src/daemon/wallet/diagnostics.test.ts` — the unreadable-file test relies
on `chmod 000`, which does not block reads when running as root.
Confirmed by stashing the fix and running the suite on the pristine tag: same
4 failures.
## 9. Rollback
```sh
cp /root/routstrd-backup-20260929-155718/dist/index.js \
/root/.bun/install/global/node_modules/routstrd/dist/index.js
cp /root/routstrd-backup-20260929-155718/dist/daemon/index.js \
/root/.bun/install/global/node_modules/routstrd/dist/daemon/index.js
routstrd restart
```
## 10. Upstream recommendation
`applesauce-wallet-connect@6.2.0` still has this defect. Suggested fixes
upstream:
1. Apply the request timeout around the **whole** pipeline, e.g. move
`simpleTimeout` so it wraps `defer(async () => { ... }).pipe(switchMap(...))`,
not just the `responses$` stream inside it.
2. Make the `"nip04"` encryption fallback reachable (e.g. `startWith` on
`support$`, or default to nip04 when wallet info is not yet known).
3. Let `getInfo()` / `getBalance()` forward a timeout / `AbortSignal` to
`genericCall`.
+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.
CLI `/nwc/*` requests have a 120-second deadline covering headers and response-body consumption. Other daemon routes are not subject to this cap because mint payment operations may run longer and cannot be cancelled by aborting the CLI request.
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.
+2 -10
View File
@@ -1,3 +1,4 @@
import { withTimeout as withRequestTimeout } from "../../utils/with-timeout";
import {
Manager,
OperationInProgressError,
@@ -629,16 +630,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> {
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. */
+83 -4
View File
@@ -1,4 +1,5 @@
import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test";
import { RestrictedError } from "applesauce-wallet-connect/helpers/error";
import type { CocodClient } from "./cocod-client";
/**
@@ -11,13 +12,16 @@ import type { CocodClient } from "./cocod-client";
* relay connection, and retry once instead of hanging `routstrd nwc status`.
*/
type Behavior = "resolve" | "hang";
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 {
@@ -27,6 +31,7 @@ function next(queue: Behavior[]): Behavior {
class MockRelayPool {
relays = new Map<string, unknown>([["wss://relay.example", {}]]);
remove(url: string, _close?: boolean): void {
state.closedPools++;
this.relays.delete(url);
}
}
@@ -53,7 +58,10 @@ class MockWalletConnect {
network: string;
methods: string[];
}> {
if (next(state.infoQueue) === "hang") return new Promise<never>(() => {});
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),
@@ -63,14 +71,31 @@ class MockWalletConnect {
}
getBalance(): Promise<{ balance: number }> {
if (next(state.balanceQueue) === "hang") return new Promise<never>(() => {});
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 }> {
if (next(state.payQueue) === "hang") return new Promise<never>(() => {});
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 });
}
}
@@ -109,6 +134,9 @@ beforeEach(() => {
state.balanceQueue = [];
state.payQueue = [];
state.createdInstances = 0;
state.closedPools = 0;
state.pendingPayments = [];
state.paymentStarted = undefined;
});
afterAll(() => {
@@ -153,3 +181,54 @@ describe("NWC status resilience", () => {
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"]);
});
});
+10 -9
View File
@@ -1,6 +1,7 @@
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";
@@ -13,7 +14,7 @@ import { startAutoRefillLoop, type AutoRefillConfig } from "./auto-refill";
* connection is rebuilt before one retry.
*/
const NWC_READ_TIMEOUT_MS = 15_000;
/** Lightning payments can legitimately take a little longer to settle. */
/** 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 };
@@ -176,7 +177,8 @@ export async function createWalletAdapter(
`${label} timed out`,
);
} catch (error) {
if (!nwcConnectionString) throw 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`,
);
@@ -194,8 +196,8 @@ export async function createWalletAdapter(
/**
* 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: the invoice itself is single-use, and the
* caller decides whether to attempt a fresh invoice.
* 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;
@@ -207,7 +209,10 @@ export async function createWalletAdapter(
"NWC payment timed out",
);
} catch (error) {
rebuildNwcConnection("reconnected after payment timeout");
// Include the library's own timeout, but not normal wallet error replies.
if (!(error instanceof WalletBaseError)) {
rebuildNwcConnection("reconnected after payment timeout");
}
throw error;
}
}
@@ -232,10 +237,6 @@ export async function createWalletAdapter(
nwcConnectionString = undefined;
logger.log("[nwc] NWC wallet disconnected.");
}
// A connection added after startup previously never started the
// auto-refill loop, so it silently stayed disabled until a restart.
ensureAutoRefillLoop();
},
async getBalances(): Promise<Record<string, number>> {
+79
View File
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, test } from "bun:test";
import { callDaemonUrl, DAEMON_REQUEST_TIMEOUT_MS } from "./daemon-client";
import { DEFAULT_CONFIG } from "./config";
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
afterEach(() => {
globalThis.fetch = originalFetch;
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
});
function shortenDeadline(): void {
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[]) =>
originalSetTimeout(callback, delay === DAEMON_REQUEST_TIMEOUT_MS ? 20 : delay, ...args)
) as typeof setTimeout;
}
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("NWC 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("does not impose the NWC deadline on mint payment routes", async () => {
let deadlines = 0;
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[]) => {
if (delay === DAEMON_REQUEST_TIMEOUT_MS) deadlines++;
return originalSetTimeout(callback, delay, ...args);
}) as typeof setTimeout;
globalThis.fetch = (async () => Response.json({ output: "paid" })) as unknown as typeof fetch;
expect(await request("/wallet/send/bolt11")).toEqual({ output: "paid" });
expect(deadlines).toBe(0);
});
test("clears the deadline after consuming a successful response", async () => {
let cleared = 0;
globalThis.clearTimeout = ((timer) => {
cleared++;
originalClearTimeout(timer as ReturnType<typeof setTimeout>);
}) as typeof clearTimeout;
globalThis.fetch = (async () => Response.json({ output: "ok" })) as unknown as typeof fetch;
expect(await request()).toEqual({ output: "ok" });
expect(cleared).toBe(1);
});
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");
});
});
+33 -28
View File
@@ -57,9 +57,9 @@ class DaemonConnectionError extends Error {
}
/**
* Upper bound for a single daemon request. 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.
* Upper bound for NWC requests, including response-body consumption.
* Other routes can perform long-running mint payments; aborting the client
* does not cancel those operations, so do not impose this cap on them.
*/
export const DAEMON_REQUEST_TIMEOUT_MS = 120_000;
@@ -77,7 +77,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 },
@@ -106,36 +106,41 @@ async function _callUrl(
if (authorization) headers.set("Authorization", authorization);
if (bodyString) headers.set("Content-Type", "application/json");
let response: Response;
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
DAEMON_REQUEST_TIMEOUT_MS,
);
const timeoutId = path.startsWith("/nwc/")
? setTimeout(() => controller.abort(), DAEMON_REQUEST_TIMEOUT_MS)
: undefined;
try {
response = await fetch(url, {
method,
headers,
body: bodyString,
signal: controller.signal,
});
let response: Response;
try {
response = await fetch(url, {
method,
headers,
body: bodyString,
signal: controller.signal,
});
} catch (error) {
if (controller.signal.aborted) throw error;
// 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}`);
}
return await response.json() as CommandResponse;
} catch (error) {
if (controller.signal.aborted) {
throw new Error(
`Daemon request timed out after ${DAEMON_REQUEST_TIMEOUT_MS / 1000}s`,
`Daemon request timed out after ${DAEMON_REQUEST_TIMEOUT_MS / 1000}s; ` +
"any payment outcome is unknown — check before retrying",
);
}
throw new DaemonConnectionError(error);
throw error;
} finally {
clearTimeout(timeoutId);
if (timeoutId !== undefined) clearTimeout(timeoutId);
}
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>;
}
async function callLocalDaemon(
@@ -147,7 +152,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;
@@ -162,7 +167,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);
}
@@ -177,7 +182,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> {
+1
View File
@@ -1,5 +1,6 @@
/**
* 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