diff --git a/src/lnurlcash/ops.ts b/src/lnurlcash/ops.ts index b0d800a..1efaa5d 100644 --- a/src/lnurlcash/ops.ts +++ b/src/lnurlcash/ops.ts @@ -41,5 +41,5 @@ export type { TransferOptions, TransferOutcome, TransferQuote, - TransferResult + TransferResult, } from './ops/transfer' diff --git a/src/lnurlcash/ops/shared.ts b/src/lnurlcash/ops/shared.ts index 45ba0e3..b93fade 100644 --- a/src/lnurlcash/ops/shared.ts +++ b/src/lnurlcash/ops/shared.ts @@ -19,45 +19,102 @@ export class UncertainOutcomeError extends Error { } } +// the wait was interrupted from outside (service shutdown) - distinct +// from budget exhaustion so the caller can treat it as normal teardown +export class PollAbortedError extends Error { + constructor() { + super('The wait was interrupted by shutdown.') + this.name = 'PollAbortedError' + } +} + +export type FundOperationOptions = LnurlcashOptions & { + readonly assertOwner?: () => void +} + +export const assertFundOwner = (options: FundOperationOptions): void => { + options.assertOwner?.() +} + export type PollOptions = { // first delay between checks (doubles each round up to intervalCapMs) intervalMs?: number intervalCapMs?: number // total budget before giving up maxWaitMs?: number + // aborts the wait promptly (shutdown). Only the WAIT is interruptible: + // callers pass this for work whose observation phase may outlive the + // caller - once pollVerifyUntilSettled has returned, the signal no + // longer reaches anything + signal?: AbortSignal } -const DEFAULT_POLL: Required = { +const DEFAULT_POLL: Required> = { intervalMs: 1000, intervalCapMs: 5000, - maxWaitMs: 120_000 + maxWaitMs: 120_000, } -const sleep = (ms: number): Promise => - new Promise(resolve => setTimeout(resolve, ms)) +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +// a sleep that ends immediately on abort instead of riding out its timer +const abortableSleep = (ms: number, signal: AbortSignal): Promise => + new Promise((resolve, reject) => { + const onAbort = (): void => { + clearTimeout(timer) + reject(new PollAbortedError()) + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, ms) + if (signal.aborted) { + clearTimeout(timer) + reject(new PollAbortedError()) + return + } + signal.addEventListener('abort', onAbort, {once: true}) + }) // polls a LUD-21/LUD-25 verify endpoint until it reports settled, with // backoff, inside a total time budget. A single failed check isn't fatal - // the next round tries again. Returns the settled VerifyResult; throws on -// budget exhaustion. +// budget exhaustion, or PollAbortedError when the caller's signal fires +// (a hung fetch is interrupted too: the signal is bound into the request). export const pollVerifyUntilSettled = async ( verifyUrl: string, poll: PollOptions, - options: LnurlcashOptions + options: LnurlcashOptions, ): Promise => { - const {intervalMs, intervalCapMs, maxWaitMs} = {...DEFAULT_POLL, ...poll} + const {intervalMs, intervalCapMs, maxWaitMs, signal} = { + ...DEFAULT_POLL, + ...poll, + } + const fetchOptions: LnurlcashOptions = signal + ? { + ...options, + fetch: (input, init) => { + const base = options.fetch ?? globalThis.fetch + return base(input, {...init, signal}) + }, + } + : options const deadline = Date.now() + maxWaitMs let delay = intervalMs let lastError: unknown = null while (Date.now() < deadline) { + if (signal?.aborted) throw new PollAbortedError() try { - const result = await fetchInvoiceVerification(verifyUrl, options) + const result = await fetchInvoiceVerification(verifyUrl, fetchOptions) if (result.settled) return result lastError = null } catch (err) { + // the signal's own AbortError lands here on an interrupted fetch + if (signal?.aborted) throw new PollAbortedError() lastError = err } - await sleep(Math.min(delay, Math.max(0, deadline - Date.now()))) + if (signal) await abortableSleep(Math.min(delay, Math.max(0, deadline - Date.now())), signal) + else await sleep(Math.min(delay, Math.max(0, deadline - Date.now()))) delay = Math.min(delay * 2, intervalCapMs) } if (lastError instanceof Error) {