Skip to content

fix(leiosfetch): answer unconfigured requests instead of holding agency - #2173

Merged
wolf31o2 merged 9 commits into
mainfrom
fix/3623-leiosfetch-slot
Sep 3, 2026
Merged

fix(leiosfetch): answer unconfigured requests instead of holding agency#2173
wolf31o2 merged 9 commits into
mainfrom
fix/3623-leiosfetch-slot

Conversation

@wolf31o2

@wolf31o2 wolf31o2 commented Sep 2, 2026

Copy link
Copy Markdown
Member

Keep block-range delivery alive through the terminal response.

Closes #3623

Tests: go test -race ./protocol/leiosfetch -count=1

An unconfigured BlockRequestFunc/BlockTxsRequestFunc returned nil, leaving
the server in StateBlock/StateBlockTxs holding agency. The requester's send
loop waits on sendReadyChan for agency that only the missing response
returns, so no further leios-fetch request can be written to that bearer.
Answer MsgNoBlock/MsgNoBlockTxs instead; the configured not-found path at
server.go:138 already emits both wire IDs.

BlockRangeRequest has no absence reply, so an unconfigured callback now
returns a protocol error rather than hanging the requester silently.

acquireSlot fails the connection when acquire reports
ErrRequestSlotAbandoned, so the consumer's peer governance can replace a
peer that took agency and never returned it. The slot is never reused, so
this does not reintroduce the mis-delivery hazard it guards.

Refs blinklabs-io/dingo#3623

Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io>
@wolf31o2
wolf31o2 requested review from a team as code owners September 2, 2026 11:53
@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 4113b131-9d73-48a7-815e-e965056cdd8e

📥 Commits

Reviewing files that changed from the base of the PR and between c056989 and 7175355.

📒 Files selected for processing (6)
  • protocol/leiosfetch/README.md
  • protocol/leiosfetch/client.go
  • protocol/leiosfetch/error.go
  • protocol/leiosfetch/leiosfetch_test.go
  • protocol/leiosfetch/server.go
  • protocol/leiosfetch/server_test.go

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The leios-fetch server now returns NoBlock or NoBlockTxs for unconfigured block responders and fails the connection for an unconfigured block-range responder. The client now reports ErrRequestSlotAbandoned through SendError when a late response leaves a request slot occupied. Documentation and tests cover default responder outcomes, nil configuration, connection errors, and subsequent block or block-txs requests.

Merge Risk: 🔵 Low · up to 71753

The change prevents unconfigured requests from hanging indefinitely and safely closes a peer connection when an abandoned exchange cannot be synchronized. This is mergeable with owner awareness because the connection-level failure can also interrupt other protocols sharing that peer connection.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: unconfigured leiosfetch requests now receive responses instead of leaving the server holding agency.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/3623-leiosfetch-slot

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread protocol/leiosfetch/client.go Outdated
Comment thread protocol/leiosfetch/client.go
Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 1 file (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread protocol/leiosfetch/client.go Outdated
No conflicts. Merged with rerere disabled.

Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io>
VotesRequest and BlockRangeRequest sent directly with no slot, so an
abandoned block request left them blocking on their result channels with
no context and no diagnosis. The leios-fetch states share one
connection-wide agency, so nothing can be written to the bearer while the
peer still owes that response, and neither method reached acquireSlot to
surface it as ErrRequestSlotAbandoned.

Both now take a context and acquire the shared admission slot. Their
responses do not arrive through the slot, so success releases it
explicitly; a context that expires with the response outstanding abandons
instead, since the peer still holds agency.

Neither method had a caller or a test.

Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread protocol/leiosfetch/client.go Outdated
Comment thread protocol/leiosfetch/leiosfetch_test.go Outdated
abandon keeps the slot busy until a late response drains it through
deliver, but a votes or range response goes to its own result channel and
never reaches deliver, so abandoning on context expiry left the slot
permanently busy and failed every later request on the connection. Those
two states release instead; their responses are not slot-correlated and a
late one is dropped by the handler.

The result channels were unbuffered with blocking sends, so a response
whose caller had given up blocked the protocol receive loop. They are now
capacity 1 -- the caller registers before the request is sent -- and the
handlers drop a response nobody is waiting for.

Consolidates the two near-duplicate admission tests into one table-driven
test.

Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread protocol/leiosfetch/client.go Outdated

@cryptodj413 cryptodj413 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff against the PR head, including tracing protocol.Protocol's send/receive/state-machine code and writing executable reproductions (run with -race) rather than taking either the bots' or my own first read at face value.

Two confirmed, reproduced merge blockers, both rooted in the same design gap: blockRequestSlot now gates admission for all four request kinds (Block/BlockTxs/Votes/Range), but only Block/BlockTxs route their response back through it via deliver() (which always drains the slot). Votes and Range responses bypass it entirely (handleVotes, handleNextBlockAndTxsInRange, handleLastBlockAndTxsInRange only push into their own shared channels), so the slot's admission bookkeeping and its response delivery are out of sync for those two:

  1. VotesRequest's ctx.Done() path (abandon) permanently wedges the shared slot, then kills a healthy connection — see inline comment. Reproduced: peer answers MsgVotes 200ms after a 50ms client timeout (a healthy peer); the next leios-fetch request on the connection still burns the full abandonedRequestWait and then fails the whole multiplexed bearer with ErrRequestSlotAbandoned, because nothing ever drains an abandoned votes slot.
  2. BlockRangeRequest's ctx.Done() path (release) causes confirmed stale-data mis-delivery to the next caller — this is the still-open Cubic P1 at client.go:499; replied to that thread with a concrete repro confirming it. A second, unrelated BlockRangeRequest received the first (abandoned) request's stale terminal message as if it were its own, with err == nil.

Both are worse than the bug this PR fixes: a silent wrong answer (2) or a silent permanent wedge with the connection then torn down for a peer that behaved correctly (1), versus the original detectable hang.

Also found:

  • No test exercises a VotesRequest/BlockRangeRequest whose own context expires with a response still outstanding, nor a successful end-to-end call to either — the new release-on-success paths and the buffered/non-blocking channel change are entirely uncovered by this PR's own tests.
  • blockSlot/blockTxsSlot (client.go:37-38) are dead now that Block/BlockTxs moved onto blockRequestSlot; they only survive as always-false fallback targets in the four handleBlock*/handleNoBlock* handlers. Not part of this diff, but worth a heads-up: client_test.go's TestClientMessageHandler currently drives routing through these dead fields, so it passes even with blockRequestSlot.deliver() deleted from all four handlers — it isn't covering the real path anymore.
  • README's new "Abandoned requests" section (209-222) was written for the first commit and never updated for the later two: it doesn't mention that Votes/Range now share the same admission slot, or the release-vs-abandon split that's the source of finding 2.

No API/wire compatibility break: nothing in the blinklabs-io workspace calls VotesRequest/BlockRangeRequest today (only Block/BlockTxs, unchanged signatures), so the new ctx parameter is safe as an isolated change; dingo's own follow-up (dingo#3772) already flags that consuming this needs a go.mod bump plus a client-side test update.

Happy to help work through a fix for 1/2 — the cleanest direction looks like draining blockRequestSlot from the votes/range handlers too (so abandon becomes legitimately recoverable everywhere and both context paths can use it consistently), or correlating each votes/range response per-request the way Block/BlockTxs already do via deliver(), rather than through one shared, unscoped channel.

Comment thread protocol/leiosfetch/client.go Outdated
// connection-wide agency and have no request identifier.
blockRequestSlot requestSlot
blockSlot requestSlot
blockTxsSlot requestSlot

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blockSlot/blockTxsSlot look dead as of this PR: BlockRequest/BlockTxsRequest now acquire blockRequestSlot exclusively (lines 360, 395), so nothing ever calls .acquire() on these two anymore. They only survive as always-false second deliver() attempts in handleBlock/handleNoBlock/handleBlockTxs/handleNoBlockTxs below (e.g. lines 552, 565, 571, 584). Worth deleting both the fields and the fallback calls in a follow-up, since client_test.go's TestClientMessageHandler currently exercises message routing through these dead fields rather than the real blockRequestSlot path.

}
return resp, nil
case <-ctx.Done():
c.blockRequestSlot.abandon(w)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

abandon() leaves blockRequestSlot busy+abandoned until something calls deliver() or release() on it. But a late votes reply only reaches handleVotes (line 592), which pushes into votesResultChan and never touches blockRequestSlot. So this abandonment is structurally undrainable: the next leios-fetch request on this connection (of any of the four kinds, since they all share this slot) will always burn the full abandonedRequestWait grace period and then fail the whole connection with ErrRequestSlotAbandoned — even when the peer answers correctly and promptly after this caller's context expired.

Reproduced: peer sends MsgVotes 200ms after a 50ms client-side timeout (state machine confirms the peer returned agency to Idle); the next BlockRequest still fails with leios-fetch: peer retained agency in state Block after an abandoned request: ..., tearing down the connection for a peer that behaved correctly. This is the same failure class dingo#3623 describes (silent permanent wedge), now reachable through a votes timeout rather than an unconfigured responder, plus it kills the connection unnecessarily.

Note StateVotes already carries a StateMap timeout (line 244-247), so a genuinely non-responsive peer is already caught there — this abandon path only ever fires for peers that do answer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified fixed as of 3654866. Repro: VotesRequest with a 50ms context, peer answers correctly 200ms later (well within the grace period) — the abandoned slot now drains via deliver(), and a subsequent BlockRequest on the same client succeeds normally with no connection error. Thanks.

if errors.Is(err, ErrRequestSlotAbandoned) {
c.SendError(
fmt.Errorf(
"%s: peer retained agency in state %s after an abandoned request: %w",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this formats the error with the new request's target state argument, not the state where agency was actually retained. In my Finding-A repro (see general review comment) this produced "peer retained agency in state Block after an abandoned request" for a wedge that actually originated in StateVotes on a connection where the peer had already returned agency — misleading in an operator's logs.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirming this one is still live at b136417, and I think the shared slot has made it worse rather than neutral: now that all four request kinds admit through blockRequestSlot, the state argument is the state the new caller wanted, and the abandoned request that actually wedged the exchange could have been any of the other three. An operator reading state Block has no way to tell which.

Cheap fix if you want one: have acquire return the state recorded when the slot was taken, or store it on requestSlot at acquire time, and format that instead. Not blocking from me.

Comment thread protocol/leiosfetch/README.md Outdated

`BlockRequest` and `BlockTxsRequest` are bounded by the caller's context. A
request whose context expires is abandoned: its delivery channel is cleared so
a late response is dropped rather than mis-delivered, and the slot stays busy

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section was written for the first commit in the PR and not updated for the later two (e147ef6, 2463098): it only describes Block/BlockTxs's abandon-then-grace-period policy. It doesn't mention that VotesRequest/BlockRangeRequest now acquire the same shared slot (so an abandoned block request fails them too), or the release-vs-abandon split between them, which is the source of the mis-delivery in the other inline comment on this PR.

Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io>

@cryptodj413 cryptodj413 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed 3654866 ("correlate votes and range responses") against the two merge blockers from the previous review, with fresh reproductions run against this exact commit.

Finding A (VotesRequest permanent wedge) — fixed, verified. Routing votes responses through blockRequestSlot.deliver() makes abandon() properly drainable now. Repro: a VotesRequest whose 50ms context expires, followed by the peer answering correctly 200ms later (well within the peer's real behavior), no longer wedges the slot or kills the connection — a subsequent BlockRequest on the same client succeeds normally and no connection error is emitted. Thanks for the fix.

Finding B (BlockRangeRequest stale mis-delivery) — still present. The correlation mechanism changed (per-request channel via deliver() instead of a shared blockRangeResultChan), but BlockRangeRequest's ctx.Done() branch still calls release(), not abandon() (see inline comment), so the same mis-delivery survives through the new mechanism: release() frees the slot immediately, letting a second, unrelated BlockRangeRequest become the slot's registered waiter before the first (abandoned) request's late terminal message arrives; deliver() then hands that stale message to the second request's channel. Reproduced against this exact commit:

req1 (100ms ctx, range [1,2]) err: context deadline exceeded
req2 (2s ctx, range [3,4])    err: <nil>   blockRaw: 82 01 02   (req1's stale data, not req2's own 82 03 04)

The comment justifying release() ("a range response reaches blockRangeResultChan, never deliver, so an abandoned slot here would never drain") is now factually incorrect post-refactor — range responses do reach deliver() now (via handleNextBlockAndTxsInRange/handleLastBlockAndTxsInRange), so abandon() should be just as drainable for BlockRangeRequest as it now is for VotesRequest. Switching this one branch from release(w) to abandon(w) looks like it would close this out, matching VotesRequest's pattern exactly.

Still open from the previous round, unaddressed by this commit: the dead blockSlot/blockTxsSlot fields (client_test.go's TestClientMessageHandler still acquires those instead of blockRequestSlot for the Block/BlockTxs cases, so it still isn't exercising the real production delivery path for those two message types), and the README "Abandoned requests" section (still describes only the pre-refactor Block/BlockTxs-only policy).

Comment thread protocol/leiosfetch/client.go Outdated
// release, not abandon, for the reason given in VotesRequest:
// a range response reaches blockRangeResultChan, never deliver,
// so an abandoned slot here would never drain.
c.blockRequestSlot.release(w)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still release()s rather than abandon()s, and the justifying comment above ("never deliver") is no longer accurate after this commit's refactor: handleNextBlockAndTxsInRange/handleLastBlockAndTxsInRange now call c.blockRequestSlot.deliver(msg) (lines 585, 591), so a late range response does reach deliver() just like Block/BlockTxs.

Because this still releases immediately, a second BlockRangeRequest can acquire the slot and register as the new s.waiter before this (abandoned) request's late terminal message arrives — and deliver() will hand that stale message to the second request. Reproduced against this exact commit: a second, unrelated BlockRangeRequest (fresh context, different range) received the first (abandoned) request's stale MsgLastBlockAndTxsInRange payload with err == nil.

VotesRequest's equivalent branch (line 440) already calls abandon(w) and — per the top-level review comment — that now correctly recovers when the peer answers within the grace period. This branch looks like it should match: c.blockRequestSlot.abandon(w) instead of release(w).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on this rather than opening a duplicate — the release -> abandon change did land in 83e7b7e, but I do not think it takes effect for the case you described.

abandon only mutates while s.waiter == w (client.go:129), and deliver clears s.waiter on the first range message, because it frees the slot on every message rather than only on the terminal one. So for any range that already received a Next, the slot is free before the context expires and abandon is a no-op — the quarantine only covers a range abandoned before its first message.

I filed that root cause separately on handleNextBlockAndTxsInRange, with two repros: a Next/Next/Last range never assembles at all (passes on origin/main, bisects to 3654866), and a BlockRequest issued mid-range comes back holding a *MsgNextBlockAndTxsInRange. Your read of this branch was right; it just needs the fix one level down.

Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io>
@wolf31o2

wolf31o2 commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Resolved the latest range-response review finding in 83e7b7e. Abandoned BlockRangeRequest calls now quarantine the shared request slot until the late terminal response drains, preventing stale data from reaching a later request. Added a delayed-response regression test and updated the abandoned-request documentation. Validation: focused race test, protocol-wide race tests, conformance tests, and make test passed; integration endpoint test skipped because no Cardano node is configured. Repository lint reported no source issues but the example phase was blocked by a concurrent golangci-lint lock.

Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io>

@chrisguiney chrisguiney left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the depth here — the server-side half is right, and I checked it against the state machine rather than taking the description on trust: MsgLastBlockAndTxsInRange genuinely carries a mandatory block, so a protocol error really is the only honest answer for an unconfigured range responder.

The client-side refactor that grew on top of it breaks the streaming range state. Details inline; the main one is at handleNextBlockAndTxsInRange.

The short version

requestSlot.deliver (client.go:178) clears the waiter and frees the slot on every message, but StateBlockRange self-transitions on MessageTypeNextBlockAndTxsInRange (leiosfetch.go:96), so one range registration has to survive N messages. Two things follow, both reproduced:

  1. A Next/Next/Last range makes BlockRangeRequest return context deadline exceeded. The same test passes on origin/main.
  2. A BlockRequest issued while a range is streaming returns a *MsgNextBlockAndTxsInRange — the mis-delivery the slot exists to prevent.

Bisected across the branch with test 1: e147ef6 ok, 2463098 ok, 3654866 first failure, still failing at b136417.

Verified good

  • Merging blockSlot and blockTxsSlot is right — those states share one connection-wide agency, so two slots were never buying anything.
  • Dropping the Start() cleanup goroutine removes a goroutine rather than leaking one, and the tests keep goleak.
  • The protocol-level timeout re-arms on the StateBlockRange self-transition (setState runs per transition), so a long range will not trip config.Timeout. The README's claim there holds.
  • SendError on ErrRequestSlotAbandoned is a big hammer, but I went looking for a gentler option and could not find one: sendLoop only writes at AgencyClient, so a bearer whose peer never returns agency really is dead for leios-fetch.

Checks run locally

go build ./..., go vet ./protocol/..., gofmt, golangci-lint run ./protocol/leiosfetch/... (0 issues), go test ./... and go test -race -count=2 ./protocol/leiosfetch/ all pass — which is the problem: nothing in the suite sends more than one message in a range. Not run: conformance, devnet, nilaway locally (CI green).

Smaller things, not inline

  • VotesRequest and BlockRangeRequest gained a ctx parameter. That is a public API break; no in-org client consumer today (dingo wires only the server callbacks), so I am not asking you to avoid it — but it is worth a line in the description.
  • BlockRequest, BlockTxsRequest and VotesRequest are now three near-identical bodies differing only in the message constructor and the not-found mapping. A single doRequest(ctx, state, msg) helper would make the shared-slot policy live in one place, which is also where a future rule would have to go. Nit, and fine as a follow-up.
  • The PR description still describes only the first commit. The single-slot refactor, the ctx break and the votes/range routing are the bulk of the diff and are not mentioned.

Process note

Cubic's last review was 09-02 20:13Z, so 3654866, 83e7b7e and b136417 are unreviewed by it, and CodeRabbit's green check reads "Review skipped: incremental reviews are disabled". The regression landed in 3654866, inside that gap — worth another bot pass before this goes back out for human review.

Comment thread protocol/leiosfetch/client.go Outdated

func (c *Client) handleNextBlockAndTxsInRange(msg protocol.Message) {
c.blockRangeResultChan <- msg
if !c.blockRequestSlot.deliver(msg) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deliver frees the slot on every message, but this state streams.

requestSlot.deliver (client.go:178) does s.waiter = nil; s.freeLocked() unconditionally. StateMap self-transitions StateBlockRange on MessageTypeNextBlockAndTxsInRange (leiosfetch.go:96), so a range legitimately delivers N messages through the one registration BlockRangeRequest made. After the first Next, the waiter is gone: message 2 lands on w == nil, returns false, and gets logged as a dropped response while the caller is still selecting on w.

The premise for this is stated at client.go:187 — "receives at most one response per request (the state machine rejects a second server message before it reaches this handler)". That holds for Block, BlockTxs and Votes, but StateBlockRange is the one state where the state machine explicitly permits the second message.

Reproduced, two ways.

A range of Next/Next/Last never assembles — BlockRangeRequest returns context deadline exceeded. This test passes on origin/main and fails here:

ouroboros_mock.ConversationEntryInput{
    ProtocolId:  leiosfetch.ProtocolId,
    MessageType: leiosfetch.MessageTypeBlockRangeRequest,
},
ouroboros_mock.ConversationEntryOutput{
    ProtocolId: leiosfetch.ProtocolId,
    IsResponse: true,
    Messages: []protocol.Message{
        leiosfetch.NewMsgNextBlockAndTxsInRange([]byte{0x82, 0x01, 0x01}, nil),
        leiosfetch.NewMsgNextBlockAndTxsInRange([]byte{0x82, 0x01, 0x02}, nil),
        leiosfetch.NewMsgLastBlockAndTxsInRange([]byte{0x82, 0x01, 0x03}, nil),
    },
},
// ... resp, err := client.BlockRangeRequest(ctx, start, end)
// require.NoError(t, err); require.Len(t, resp, 3)

Bisected with it: e147ef6 ok, 2463098 ok, 3654866 first failure, still failing at head.

Worse, the freed slot is immediately reusable while the peer is still streaming. With a BlockRequest issued 250ms into a range, I got:

BlockRequest returned *leiosfetch.MsgNextBlockAndTxsInRange

That is the mis-delivery the slot was built to prevent, now reachable on the range path.

One thing worth deciding before the fix: whichever way the waiter is retained across a stream, deliver runs on the protocol receive loop, so the send has to stay non-blocking. A retained waiter on the current capacity-1 channel would let a server that streams faster than the caller drains block recvLoop. Happy to think through options with you if it helps — a per-request queue and a slot that only frees on the terminal message is the shape I would reach for, but you know this protocol better than I do.

return ret, nil
}
case <-ctx.Done():
// Range responses are delivered through the shared slot, including

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follows from the deliver finding above: this abandon(w) is a no-op for any range that already received a message.

abandon only mutates while s.waiter == w (client.go:129), and deliver cleared s.waiter on the first Next. So the quarantine 83e7b7e added only applies to a range abandoned before its first message — which is the narrower half of what the earlier review thread asked for. Past that point the slot is already free, a later request can acquire it, and the peer's remaining range messages go to that request instead.

The comment here states the intended behaviour exactly right ("Keep the slot abandoned until that response drains"); it is deliver that does not implement it. I did try to get a clean end-to-end repro of the stale-terminal-message case specifically, but the connection tore down first in my harness, so I am flagging this as reasoning from the code plus the two repros above rather than something I measured.

// response from an abandoned range exchange cannot be delivered to a later
// range request. Range responses use the shared request slot, so the first
// request must remain quarantined until its terminal response drains.
func TestBlockRangeRequestSubsequentAfterAbandoned(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No test sends more than one message in a range.

This test and every other range case respond with a single MsgLastBlockAndTxsInRange, so the streaming path — the one thing StateBlockRange exists for — is never exercised. That is why the full suite, -race -count=2, lint and all of CI are green on a head where a two-message range does not work.

A Next/Next/Last case here would have caught 3654866 on the commit that introduced it. Snippet in the handleNextBlockAndTxsInRange comment if it saves you a few minutes.

Comment thread protocol/leiosfetch/client.go Outdated
votesResultChan: make(chan protocol.Message),
blockRangeResultChan: make(chan protocol.Message),
config: cfg,
// Capacity 1: the caller registers before its request is sent, so

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two comments got orphaned by the refactor and now describe code that is not here.

  • This one describes a channel capacity, but votesResultChan and blockRangeResultChan are gone and the literal now sets only config. The capacity-1 rationale still applies, just to the channel requestSlot.acquire creates — it would read correctly there.
  • client.go:273: // Start goroutine to cleanup resources on protocol shutdown. is now the last line of Start(), with no goroutine after it.

Trivial, but both are the kind of thing that misleads the next reader of this file.

expires is abandoned: its delivery channel is cleared so a late response is
dropped rather than mis-delivered, and the slot stays busy so the next request
cannot be correlated with the outstanding response. For `BlockRangeRequest`,
this applies to the whole streaming exchange; the terminal response drains the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This documents behaviour the code does not currently have: deliver frees the slot on the first range message, not the terminal one (see the inline comment on handleNextBlockAndTxsInRange).

Worth re-checking this paragraph once the streaming fix lands — as written it is the right specification, so it may need no edit at all beyond confirming the code matches it.

Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io>
@wolf31o2
wolf31o2 dismissed cryptodj413’s stale review September 3, 2026 23:09

Approved by Chris

@wolf31o2
wolf31o2 merged commit 6eba251 into main Sep 3, 2026
14 checks passed
@wolf31o2
wolf31o2 deleted the fix/3623-leiosfetch-slot branch September 3, 2026 23:10
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.

3 participants