Repository navigation
fix(api): stop contended PaymentIntent claims leaking their map entry - #194
Merged
Merged
Conversation
ClaimGuard::drop decided whether to remove a PaymentIntent's entry from
CLAIM_LOCKS by checking Arc::strong_count(lock) == 2. tokio's
OwnedMutexGuard releases the mutex before it drops its own Arc, so after
the holder's Drop::drop saw a queued waiter (count 3) and kept the entry,
there was a gap in which the waiter could acquire on another worker,
finish, and run its own ClaimGuard::drop while the holder's Arc was still
live. It also saw count 3 and kept the entry. Nobody removed it, so every
contended PaymentIntent could leak one entry permanently, breaking the
map's documented bound against unauthenticated garbage ids. Exclusion was
not affected. This is what made entry_survives_while_a_waiter_is_queued
fail intermittently in CI ("entry outlived the last guard for this key").
Track claimants explicitly instead. claim() increments a per-entry count
under the map lock before awaiting and returns a Registration that
decrements it (and removes the entry at zero) on drop. The registration
is a plain local until the lock is acquired, so a claim future dropped
while still queued (axum drops handler futures on client disconnect)
withdraws too. ClaimGuard's fields are ordered so the mutex is released
before the registration is withdrawn, so a newcomer can never create a
fresh mutex for a key whose old one is still held.
Adds tests for a waiter cancelled while queued and for one cancelled
after the holder released but before it was polled again.
Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
entry_survives_while_a_waiter_is_queued slept 50ms and assumed the waiter had queued by then. It now waits, via a test-only claimant-count helper, until the waiter is actually registered behind the held claim, with a 10s bound that fails the test instead of hanging, so the test proves what its name says. Also: - Registration::drop logs an error next to its debug_assert, so a release build reports a missing entry instead of silently ignoring it. - The ClaimGuard field-order comment no longer calls the order load-bearing. With the other order a newcomer could get a fresh mutex while the old holder is only running its destructor, after its critical section is over, so this order is the tidy one rather than a safety requirement. Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
Input hardening for the payment endpoints. - Add payment_claim::is_payment_intent_id: true iff the id is "pi_" followed by one or more ASCII alphanumerics. - sign_certificate rejects a malformed id first, before any other work or Stripe call, with a new CertificateError::InvalidPaymentIntentId that sign_certificate_route maps to 400 "Invalid payment reference". - sign_certificate requires the retrieved PaymentIntent to be the one requested before it is marked as spent. - /update-donation and /check-payment-status apply the same format check before any Stripe call and return their existing 400 error. Tests: unit tests for is_payment_intent_id; hermetic tests that each endpoint returns 400 for malformed ids without Stripe configured; and source-order pins that the format check precedes the claim and the Stripe retrieval, and that the id match precedes the mark. Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
/update-donation and /check-payment-status answered a malformed
PaymentIntent id with DonationError::InvalidCurrency ("Invalid
currency"). Replace that variant, which had no other use, with
DonationError::InvalidPaymentIntentId, a 400 "Invalid payment
reference", matching /sign-certificate. It covers both the format check
and the existing PaymentIntentId parse failure in those two routes. The
hermetic route tests now assert the message as well as the status.
is_payment_intent_id also rejects ids longer than 255 bytes, Stripe's
documented maximum id length, with a unit test that a 255-byte id is
accepted and a 256-byte one rejected.
Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
sanity
commented
Sep 29, 2026
sanity
left a comment
Contributor
Author
There was a problem hiding this comment.
Comprehensive PR Review: #194
- Review tier: Full (concurrency in the payment path)
- Reviewers run:
- Round 1 (
95cbf49f): skeptical; testing plus code-first; Codex; Gemini 3.1 Pro via AI Gateway ($0.25). - Round 2 (
83413488): skeptical; Codex. - Final commit
780e022a: diff-of-diff check.
- Round 1 (
- HEAD SHA reviewed:
780e022a. CI green there (build, test, CLA).
Findings
The claimant-count design is correct on every lens.
- Mutual exclusion holds. An entry exists while any registration for its key is alive, and the mutex guard drops before the registration does.
- Cancellation cleans up. A claim is withdrawn whether it's dropped queued, woken but not yet polled, or after acquiring.
- Stress results. A scratch stress harness (8 workers, cancellations and aborts) found 0 exclusion violations and 0 leaks in 40 runs. An off-by-one mutant violated exclusion on every run, so the detector works. The PR's own stress loop had 0 failures in 16,000+ runs after the fix, against 115–134 per 4,000 before.
Review items, all addressed in this PR:
- Tests:
cancelled_waiter_does_not_leak_its_keyalso passes on the old code. The PR body now says so, and thatwaiter_cancelled_after_holder_released_does_not_leakfails every time on the old code (a second leak path, also fixed).entry_survives_while_a_waiter_is_queuednow waits deterministically for the waiter to register, instead of sleeping 50ms.
- Robustness:
Registration::droplogs, as well asdebug_asserts, if its invariant ever breaks. The field-order comment was corrected: that order is tidy, not safety-critical. - Input hardening:
- Only well-formed PaymentIntent ids are accepted:
pi_plus ASCII alphanumerics, at most 255 bytes, checked before any claim or Stripe call. - The retrieved PaymentIntent must be the one requested before it is marked.
- Malformed ids on
/update-donationand/check-payment-statusnow get 400 "Invalid payment reference" instead of "Invalid currency".
- Only well-formed PaymentIntent ids are accepted:
Dismissed
- The retrieved-id check is pinned by source order only, since testing it by behaviour would need a Stripe mock. The source pin is non-vacuous (production source only).
Verdict
State: Ready to Merge
HEAD SHA reviewed: 780e022a
[AI-assisted - Claude]
sanity
marked this pull request as ready for review
September 29, 2026 01:51
sanity
added a commit
that referenced
this pull request
Sep 29, 2026
Conflicts: errors.rs/routes.rs error variants (keep NotaryMismatch, InvalidPaymentIntentId and NotFound; InvalidCurrency was removed on main); update_donation (main's id check + this branch's notary quote). The /notary-certificate route now uses payment_claim::is_payment_intent_id from main instead of a local copy (local copy and its test removed). #194's new sign_certificate test gains the notary_certificate_base64 field. Claude-Session: https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the intermittent
payment_claim::tests::entry_survives_while_a_waiter_is_queuedfailure (seen in run 36506939413:payment_claim.rs:218"entry outlived the last guard for this key"). The test was right; the code had a real race. This should unblock CI on #192.Root cause
ClaimGuard::dropremoved a PaymentIntent's entry fromCLAIM_LOCKSonly whenArc::strong_count(lock) == 2. That inference had two leak paths.1. Handoff race (the CI failure). tokio's
OwnedMutexGuard::dropreleases the semaphore before itsArcfield is dropped (checked in tokio 1.38.1, the locked version). So with a holder and one queued waiter:ClaimGuard::dropruns first, sees count 3 (map + holder + waiter) and keeps the entry._guardfield drops and releases the mutex; the holder'sArcis still alive.ClaimGuard::drop. It also sees count 3 and keeps the entry.Arcdrops. Nothing removes the entry.2. Woken-then-dropped waiter (deterministic). If the holder releases while a waiter is queued, the holder sees count 3 and keeps the entry. If the waiter's future is then dropped before it is polled again, it never builds a
ClaimGuard, so nothing ever runs the removal check and the entry leaks every time. axum drops a handler's future when the client disconnects, so this can happen in production.Either way a contended PaymentIntent could leak its entry permanently, which breaks the bound the module documents against unauthenticated garbage ids. Mutual exclusion was not affected.
Fix
Track claimants explicitly instead of inferring them from
strong_count. This closes both leak paths:claimantscount, only touched under the map lock.claim()increments the count under the map lock before awaiting and gets back aRegistration. Dropping it decrements the count and removes the entry at zero.claim()future dropped while waiting (queued, or woken but not yet polled) withdraws as well.ClaimGuarddeclares_guardbefore_registration, so the mutex is released before the registration is withdrawn. The other order would not break exclusion, since the holder's critical section is already over by then. It would only let a newcomer get a fresh mutex while the old holder is still running its destructor, so this order is the tidy one.Registration::dropfinds its entry missing (an invariant break), it logs an error as well as hitting adebug_assert, so a release build does not ignore it silently.The module docs now describe the count and why
strong_countwasn't reliable.entry_survives_while_a_waiter_is_queuedno longer sleeps 50ms and hopes. It waits (withyield_now, bounded by a 10s timeout that fails rather than hangs) until the waiter is actually registered, so it proves what its name says.Also: only well-formed PaymentIntent ids are accepted, and the retrieved PaymentIntent must be the one requested.
Tests
waiter_cancelled_after_holder_released_does_not_leak: a waiter is polled once, the holder releases, then the waiter is dropped without being polled again. It fails deterministically on the old code (leak path 2): 5 of 5 runs against theorigin/mainimplementation. It passes with the fix.cancelled_waiter_does_not_leak_its_key: a waiter is dropped (by timeout) while still queued, before the holder releases. It also passes on the old code, because the dropped waiter'sArcgoes away before the holder checks the count. It does not demonstrate the old bug. It guards the new design's cancellation contract: the registration must be withdrawn when the future is dropped.mem::forgetbefore the await, recreated after). Both tests failed. Then I reverted the edit./sign-certificate,/update-donationand/check-payment-statusreturn 400 for malformed ids without Stripe configured; with the checks disabled in a scratch edit, those tests failed. Source-order pins check that the format check comes before the claim and the Stripe retrieval, and that the id match comes before the mark.Evidence (handoff race)
Stress runs of the
payment_claimtests binary (--test-threads=8), many processes in parallel on a 16-core box so threads get preempted inside the gap:origin/main436c663)payment_claim.rs:218"entry outlived the last guard for this key"A scratch in-process test that runs 2000 holder/waiter handoffs per run (not committed: slow and probabilistic) leaked in 2 of 5 runs before the fix and 0 of 10 after. The failure reproduced on unmodified code without any sleep or yield injected.
At 780e022 (current head),
cargo test -p ghostkey-apipasses (62 passed, 1 ignored as before), andcargo fmt -p ghostkey-api -- --checkis clean.https://claude.ai/code/session_01C7ph5zmqbaZdYnnuuNjGnv
[AI-assisted - Claude]