Skip to content

fix(payments): cancel in-flight payment verification on transport close - #96

Merged
ContextVM-org merged 1 commit into
ContextVM:masterfrom
nisargpatel7042lva:fix/graceful-shutdown-payment-verification
Sep 17, 2026
Merged

ContextVM-org merged 1 commit into
ContextVM:masterfrom
nisargpatel7042lva:fix/graceful-shutdown-payment-verification

Conversation

@nisargpatel7042lva

Copy link
Copy Markdown
Contributor

Closes #81

Follows the review notes on the issue point by point (thanks for the guidance, it was more specific than the original proposal and I went with it wherever the two differed).

What was wrong

NostrServerTransport.close() had no way to reach the payment middlewares. A verifyPayment poll that started before shutdown kept running for up to verifyTimeoutMs, and if it settled it still forwarded the paid tool call, published payment_accepted, or granted explicit-gating authorization into a store nobody would read.

The fix

1. Transport-owned close notification. NostrServerTransport gets a closeSignal: AbortSignal getter, backed by a private AbortController that is aborted as the first statement of close(), before taskQueue.shutdown(). Nothing is chained through onclose, so a consumer reassigning it after withServerPayments() cannot disarm this.

2. Factory-level abortSignal. Both createServerPaymentsMiddleware and createExplicitGatingMiddleware take an optional abortSignal at construction (not via ctx; both factories are still usable standalone without a transport). withServerPayments reads transport.closeSignal and passes it to both.

The shared signal is bridged to each request's own per-verify controller in one helper, verifyPaymentUnlessShutdown (server-payments-utils.ts): addEventListener('abort', ..., { once: true }) on the shared signal when the verify starts, removeEventListener in finally once it settles, so a long-lived transport does not accumulate a listener per payment. No AbortSignal.any, keeping Node 18 support.

3. aborted is checked after the verify settles, not only while polling. That check gates payment_accepted, forward(), and the explicit-gating grant, so a verify that happens to succeed during the shutdown race is not acted on either.

  • Transparent path: on abort it does a clean return. No rethrow, no onerror noise at shutdown.
  • Explicit path: on abort there is no grant; the pending entry is cleared the same way the existing catch does for a rejected or timed-out verify.

Cancel-not-drain, on purpose. Side effects stop; payment-dedup state is not touched. In the transparent middleware the pending entry is left to expire on its normal TTL, so a redelivery of the same request event still hits the CEP-8 double-charge guard (there is a test for exactly that). In the explicit middleware the pending entry is cleared, matching the "existing catch already cleans up" note. If you would rather that entry also be left in place, that is a two-line change and I am happy to flip it.

No behavior change when close() is never called: with a signal that never fires, the helper is equivalent to the previous withTimeout(...).finally(abort). Invoice issuance, dedup, capacity, and double-charge protection are untouched.

4. Optional, flagged separately: the LNbits processor now polls with sleepWithAbort instead of sleep, so it wakes on abort instead of finishing its current poll interval (same helper NWC already uses). Four lines; easy to drop if you would rather keep this PR to the middlewares.

Tests

All deterministic: the fake processor exposes a manually resolvable verifyPayment, so the test decides when it settles relative to close(). No timer-based fakes, no sockets.

  • server-payments.test.ts (+4): late settle after abort sends no payment_accepted and does not forward; abort cuts the verify short and the run settles in well under the timeout with no rethrow, and the pending entry still dedups a redelivery; the listener on the shared signal is added once and removed once; a signal that never fires leaves the paid flow unchanged.
  • server-explicit-gating.test.ts (+3): late settle after abort does not grant and a retry mints a fresh -32042; abort cuts the verify short with no grant and no leaked rejection; a signal that never fires leaves the grant flow unchanged.
  • server-transport-payments.shutdown.test.ts (new): integration over the in-memory MockRelayHub with a real NostrServerTransport, withServerPayments, and an MCP client. Calls transport.close() mid-verify and asserts closeSignal and the per-request signal both abort, no tool execution, no payment_accepted event on the relay, grant never called, and that a processor honoring the signal settles within 1s rather than minutes.
  • Unhandled rejections are captured via process.on('unhandledRejection') in all three and asserted empty.

Existing tests in server-payments.test.ts, server-explicit-gating.test.ts, server-transport-payments.test.ts, and nostr-server-transport*.test.ts pass with no changes to their assertions (the two unit files only have new describe blocks appended).

Changeset added as a patch.

Verification

Run locally on Bun 1.4.2 (Linux):

  • bun lint, bun typecheck: clean
  • bun run build, bun run verify-exports, bunx publint: clean
  • bun test src: 562 pass, 5 skip, 0 fail
  • NODE_ENV=test bun test --concurrent --timeout=60000 (the CI invocation): my tests pass under concurrency as well

NostrServerTransport.close() had no way to reach the payment middlewares,
so a verifyPayment poll started before shutdown kept running for up to
verifyTimeoutMs and, if it settled, still forwarded the paid tool call,
published payment_accepted, or granted explicit-gating authorization into
a store nobody would read.

- NostrServerTransport exposes `closeSignal: AbortSignal`, aborted at the
  top of close() before any teardown. Transport-owned, so it cannot be
  disarmed by consumers reassigning `onclose`.
- withServerPayments passes it to both middlewares as an optional
  `abortSignal` factory param (not via ctx).
- verifyPaymentUnlessShutdown bridges the shared signal to each request's
  own per-verify controller (addEventListener on start, removeEventListener
  in finally) and checks `aborted` after the verify settles, so a late
  success is not acted on either.
- Transparent path: clean return, no payment_accepted, no forward; the
  pending entry is left to expire on its TTL (cancel-not-drain, CEP-8
  redelivery dedup untouched). Explicit path: no grant; pending cleared
  as for any failed verify.
- LNbits processor polls with sleepWithAbort so it wakes on abort.

No change in behavior when close() is never called.

Closes ContextVM#81
@ContextVM-org

Copy link
Copy Markdown
Contributor

Approved, nice work

@ContextVM-org
ContextVM-org merged commit 62523ff into ContextVM:master Sep 17, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Graceful shutdown should cancel in-flight payment verification

2 participants