Shield the shared merkle path refetch from caller cancellation

ensureFreshMerkleProofPath coalesces concurrent callers onto a single
in-flight refetch future and awaited it directly. Chronos propagates
cancellation into the awaited future, so one caller being cancelled
(e.g. a publish retry whose RlnRefreshRetryTimeout fired mid-refetch)
cancelled the shared future under every coalesced caller — and threw
away an eth_call that may have been about to complete.

Await the in-flight future via join() instead: cancelling a caller only
detaches its callback. The refetch always runs to completion, coalesced
callers keep waiting, and even when every caller times out the cache
still gets populated in the background so the next publish fast-paths
instead of paying the RPC round-trip again.

Detaching is safe because doRefresh cannot fail with an unhandled
exception: the eth_call wrappers catch CatchableError and return
Result, and failures surface to callers through fetchOk / an empty
cache exactly as before.

Adds a test cancelling the initiating caller mid-refetch and asserting
the shared future survives and the coalesced caller completes with a
populated cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
stubbsta 2026-07-12 17:35:57 +02:00
parent 29dfaa12b6
commit e73cf4729a
No known key found for this signature in database
2 changed files with 41 additions and 2 deletions

View File

@ -268,6 +268,10 @@ proc ensureFreshMerkleProofPath*(
## `invalidateMerkleProofCache`, so the next call here sees an empty cache
## and refetches from chain. Guards against a missing membership index
## because `fetchMerkleProofElements` unwraps it.
## The in-flight refetch is awaited via `join` so that cancelling one
## caller (e.g. a timed-out publish retry) never cancels the shared
## refetch: concurrent callers coalesced onto it keep waiting, and the
## refetch runs to completion so the cache is populated for the next call.
if g.membershipIndex.isNone():
return err("membership index is not set")
@ -276,7 +280,7 @@ proc ensureFreshMerkleProofPath*(
if not g.proofPathRefreshInFlightFut.isNil() and
not g.proofPathRefreshInFlightFut.finished():
await g.proofPathRefreshInFlightFut
await g.proofPathRefreshInFlightFut.join()
if g.merkleProofCache.len > 0:
return ok()
return err("merkle proof path refresh failed")
@ -292,7 +296,7 @@ proc ensureFreshMerkleProofPath*(
discard await g.updateMemberCount()
g.proofPathRefreshInFlightFut = doRefresh()
await g.proofPathRefreshInFlightFut
await g.proofPathRefreshInFlightFut.join()
if not fetchOk:
return err("merkle proof path refresh failed")

View File

@ -583,6 +583,41 @@ suite "Onchain group manager":
manager.proofPathRefreshInFlightFut == inFlight
manager.merkleProofCache.len > 0
test "ensureFreshMerkleProofPath: cancelling one caller does not cancel the shared refetch":
(waitFor manager.init()).isOkOr:
raiseAssert $error
let credentials = generateCredentials()
(waitFor manager.register(credentials, UserMessageLimit(20))).isOkOr:
assert false, "register failed: " & error
manager.merkleProofCache = @[]
manager.proofPathRefreshInFlightFut = nil
let f1 = manager.ensureFreshMerkleProofPath()
let inFlight = manager.proofPathRefreshInFlightFut
let f2 = manager.ensureFreshMerkleProofPath()
check:
inFlight != nil
not inFlight.finished()
# Cancel the initiating caller — models a publish retry whose refresh
# timeout fired while the refetch was still in flight.
waitFor f1.cancelAndWait()
check:
f1.cancelled()
# The shared refetch survives the caller's cancellation.
not inFlight.cancelled()
# The coalesced caller still completes and the cache gets populated.
let r2 = waitFor f2
check:
r2.isOk()
manager.merkleProofCache.len > 0
manager.proofPathRefreshInFlightFut == inFlight
test "verifyProof: should verify valid proof":
let credentials = generateCredentials()
(waitFor manager.init()).isOkOr: