Skip to content

peer: retire cooperative closers without stranding callers - #11329

Merged
ziggie1984 merged 6 commits into
masterfrom
resource-cleanup-link-failure
Oct 17, 2026
Merged

ziggie1984 merged 6 commits into
masterfrom
resource-cleanup-link-failure

Conversation

@Roasbeef

@Roasbeef Roasbeef commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Change Description

In this PR, we retire any active cooperative-close state whenever WipeChannel removes a channel from the peer and switch, and we make retiring a closer tell the caller who was waiting on it on every path that retires one.

A locally initiated close whose closer was retired left its caller waiting forever. WipeChannel deletes the closer, and the closer is the only writer of a close request's error, so a close whose channel hit a non-disconnecting link failure left CloseChannel waiting on a request nobody was left to answer, parking until the daemon shut down. A peer disconnect did the same, for both closer variants. Retiring a closer now answers the caller, and the notification is parameterized by the reason, since what ends a close is not always a link failure.

A close request's error was a one-slot channel with several writers. Two of those writes selected on nothing else, so once the caller had stopped reading they blocked where they stood, and one of them runs on the channel manager's goroutine, where a block stops every operation for that peer. Adding the disconnect notification introduced a second writer, so the full-buffer case can now occur.

The request now carries a promise rather than a channel. Resolving it is idempotent and reports whether this call was the one that resolved it, so a path arriving second is refused instead of overwriting the outcome the caller already has, and it cannot block, so a path resolving a close is never held up by a caller that has gone away. Callers that wait on channels see no change: the promise is awaited on their behalf and only a failure is forwarded, since a close that succeeds reports through its updates.

One design point worth stating, since it is what makes the multi-caller case correct rather than lucky: a channel can have more than one caller while an earlier one is still live, because a fee bump registers a second. It is the registry's mutex-protected removal, not the promise's idempotence, that partitions ownership of a caller's updates — the promise guards the error, and the exclusive take is what stops two paths from delivering to one caller.

We also require IsChannelClean before the legacy closer treats a missing link as evidence that the channel has flushed, since link removal also happens on paths that do not retire the closer.

Steps to Test

go test ./peer -count=1
go test -race ./peer -run "^(TestPeerChannelClosureRejectsDirtyChannelWithoutLink|TestLinkFailureRetiresRestoredLegacyCloser)$" -count=1
go test -race ./peer -count=1
go test ./htlcswitch -run 'Close|ChanClose|SwitchExit' -count=1
go test ./contractcourt -count=1

Pull Request Checklist

Testing

  • All CI checks pass.
  • Tests cover the positive and negative paths.
  • Each fix's test fails when that fix is removed — verified by mutation, one per behavioural fix.

Code Style and Documentation

  • The change is substantial.
  • Comments and lines follow the project guidelines.
  • No logging statements or lncli commands were added.

@ziggie1984
ziggie1984 self-requested a review October 14, 2026 01:46
@Roasbeef
Roasbeef force-pushed the resource-cleanup-link-failure branch from 5e7b08b to d51df9f Compare October 14, 2026 02:44
@github-actions github-actions Bot added the severity-critical Requires expert review - security/consensus critical label Oct 14, 2026
@github-actions

Copy link
Copy Markdown

🔴 PR Severity: CRITICAL

gh pr view | 9 files | 2020 lines changed

🔴 Critical (7 files)
  • htlcswitch/switch.go - HTLC forwarding / payment routing state machine
  • peer/brontide.go - Encrypted peer connection (Noise protocol) handling
  • peer/close_req_registry.go - New file in peer connection package
  • peer/rbf_close_wrapper_actor.go - Peer connection package (RBF close coordination)
  • peer/test_utils.go - Peer connection package helper (non _test.go, still counted under package path)
  • rpcserver.go - Core server coordination (explicitly critical path)
  • server.go - Core server coordination (explicitly critical path)
🟢 Low (2 files)
  • htlcswitch/switch_test.go - test-only change
  • peer/brontide_test.go - test-only change

Analysis

This PR modifies htlcswitch/switch.go and several files in peer/ (including a new file, close_req_registry.go, and rbf_close_wrapper_actor.go), plus rpcserver.go and server.go. These packages govern HTLC forwarding and encrypted peer-connection/Noise-protocol handling, both explicitly listed as CRITICAL paths requiring expert review. Non-test lines changed total ~644 across 7 files, and total diff size (2020 lines, driven largely by peer/brontide_test.go) is well above the 500-line bump threshold — though severity is already at the maximum tier.


To override, add a severity-override-{critical,high,medium,low} label.

@gijswijs
gijswijs self-requested a review October 14, 2026 15:38

@gijswijs gijswijs left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Had a few minor/medium comments. Easily addressable afaict.

Concept ACK.

Comment thread peer/brontide.go
"channel", req.ChanPoint)
p.WipeChannel(req.ChanPoint)

// The channel is gone, so the close is over as far as this

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The CloseBreach arm resolves the caller with ErrCoopCloseAbortedByLinkFailure — a breach isn't a link failure, and the exported sentinel asserts a false cause. Harmless today (breach arbitrator discards the result), but any future consumer reads a security event as transient/retryable. A breach-specific sentinel, or a generic ErrCoopCloseAborted parameterized by reason, would age better.

Comment thread server.go
// Instruct the switch to close the channel. Provide no close out
// delivery script or target fee per kw because user input is not
// available when the remote peer closes the channel.
// The request this starts is resolved by whichever path

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The comment says the ctx is "bound to the daemon's own lifetime", but defer cancel() fires when the closure returns — right after the CloseLink hand-off — so req.Ctx is dead-on-arrival. Downstream selects on closeReq.Done() (peer/brontide.go:5181, 5221) then race a permanently-ready case against a ready buffered send, dropping those updates ~50% of the time. Master passed context.Background() (never fired), so this is new nondeterminism. No live consumer today, but either drop the defer (bind to s.quit only) or fix the comment.

Comment thread peer/brontide.go Outdated
Comment on lines +1960 to +1965
chanPoint, ok := p.chanPointForCloser(chanID)
if !ok {
continue
}

p.deleteActiveChanCloser(chanID, chanPoint)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

chanPointForCloser's doc says "A closer whose point cannot be recovered is still retired", but the caller does if !ok { continue } — skipping deleteActiveChanCloser entirely, which can leak an RBF state machine + router endpoint past Disconnect (permanently with a global msg router). Calling deleteActiveChanCloser with the zero OutPoint is safe (unregisterRbfCloseActor on a zero key unregisters nothing), so the continue can just be dropped.

Comment thread peer/brontide.go
whenRPCShutdown(shutdown, func(req *htlcswitch.ChanClose) {
rpcShutdown = true

// Record the caller against the channel so that

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

startRbfChanCloser can silently strand the caller:

Two flavors: (a) the wrapper actor runs this on the actor's goroutine — Load succeeds, channelManager concurrently retires the closer, then add lands: an orphan registry record, the fee bump silently no-ops, and the RPC caller hangs until cancel; (b) if the loaded closer is legacy (Left), WhenRight no-ops, the function returns nil, and req is never resolved. Returning an error when the closer is missing or not Right would route both through the existing req.CompleteErr path in handleLocalCloseReq.

Comment thread htlcswitch/switch.go
// reading. A successful close is reported through Updates instead, so
// this is completed with a nil error only to end the request without
// an error.
Err actor.Promise[error]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

htlcswitch is imported by external projects (tapd's tapchannel, loop, faraday vendor lnd); Err: chan error → actor.Promise[error] is a compile-time break for any downstream code touching the field. No action needed in-repo. Worth a release-notes entry so it doesn't surprise anyone at upgrade time.

@ziggie1984

Copy link
Copy Markdown
Collaborator

I compared the full peer test suite before and after removing the following duplicate/ineffective cases:

  • TestRetireChanCloserIsIdempotent
  • TestCloseReqClaimedExactlyOnce
  • TestObserveRbfCloseOrdering/an unclaimed caller is told the close succeeded

The proposed removal deletes 158 lines from peer/brontide_test.go.

Coverage before and after is identical:

before: 2478/3852 statements (64.3302%)
after:  2478/3852 statements (64.3302%)
covered blocks lost: 0

The full peer package remains green after the deletion, and the retained tests that cover the relevant RBF terminal-ordering behavior also pass under the race detector:

go test ./peer -coverprofile=... -count=1
ok github.com/lightningnetwork/lnd/peer 11.158s coverage: 64.3%

go test -race ./peer \
  -run '^(TestObserveRbfCloseOrdering|TestCloseReqRegistryOverlappingCallers)$' \
  -count=1
ok github.com/lightningnetwork/lnd/peer 2.401s

Why these are removable:

  1. TestRetireChanCloserIsIdempotent never installs a closer, so it does not test idempotent closer teardown. Its meaningful promise assertion is already covered directly by TestChanCloseErrChan/only the first resolution counts.
  2. TestCloseReqClaimedExactlyOnce never drives or reports the claimed second success, making its final no-update assertion vacuous. TestObserveRbfCloseOrdering/a claimed caller is not told twice exercises the intended case through a real RBF closer and terminal transition.
  3. The positive TestObserveRbfCloseOrdering subtest is subsumed by TestCloseReqRegistryOverlappingCallers, which drives the same real-FSM success path and verifies the stronger two-caller case, promise resolution, and registry cleanup.

Conclusion: these two tests and one subtest can be removed without losing statement or behavioral coverage.

Separately, TestRbfCloseCancelReleasesOnlyItsCaller should be rewritten rather than deleted: it currently cancels a context but calls releaseCloseReq directly, so it does not exercise the observer's cancellation branch.

@ziggie1984 ziggie1984 added backport-v0.21.x-branch This label triggers a backport to branch `v0.21.x-branch ` backport-v0.20.x-branch This label is used to trigger the creation of a backport PR to the branch `v0.20.x-branch`. labels Oct 14, 2026
@ziggie1984 ziggie1984 added this to v0.21 Oct 14, 2026
@ziggie1984 ziggie1984 moved this to In review in v0.21 Oct 14, 2026
@ziggie1984 ziggie1984 added this to the v0.21.5 milestone Oct 14, 2026
@ziggie1984

Copy link
Copy Markdown
Collaborator

I found and reproduced an additional shutdown/liveness deadlock in reportRbfCloseSuccess.

The function takes all requests and then sends terminal success to them sequentially:

for _, req := range p.activeCloseReqs.takeAll(chanID) {
    select {
    case req.Updates <- terminalUpdate:
    case <-req.Done():
    }
}

If one caller's Updates buffer is full while its context remains alive, that send blocks indefinitely. This is reachable through the production CloseLink path: the buffer has capacity 3, and a slow/blocked RPC stream can stop draining it while successive pending RBF updates fill those slots.

The impact extends beyond that caller:

  1. Every caller later in the takeAll result is prevented from receiving terminal success.
  2. observeRbfCloseUpdates never reaches deleteActiveChanCloser.
  3. The observer is counted in p.cg via WgAdd(1), so peer shutdown blocks in WgWait() even after p.cg.Quit().

I added an isolated deterministic regression test against current head d51df9f82a which uses the production buffer capacity, fills the first caller's buffer, and verifies that the second caller, reporter, and peer shutdown all remain blocked. Cancelling the first caller releases all three. It reproduced 20/20 times under:

go test -race ./peer -run '^TestRbfCloseSuccessHeadOfLineBlocking$' -count=20

This is a channel/backpressure liveness deadlock rather than a mutex deadlock. Simply adding p.cg.Done() to the send would allow shutdown but could discard a confirmed terminal result, which conflicts with the intent of this PR. I think terminal-result ownership/closer retirement needs to be separated from delivery: retire the closer without waiting on an RPC consumer, then deliver to callers independently or with a bounded lifetime.

@Roasbeef
Roasbeef force-pushed the resource-cleanup-link-failure branch from d51df9f to 485e972 Compare October 15, 2026 01:27
@Roasbeef

Copy link
Copy Markdown
Member Author

Addressed the review feedback in 485e9722a. Each change is folded into the
commit it belongs to, so the series is still six commits and the history stays
linear.

Bugs fixed

  • peer: startRbfChanCloser could silently strand a close caller. A closer
    retired between the load and the registry add left an orphan record and no
    path to answer the caller, and a legacy (non-RBF) closer made the whole call
    a no-op returning nil. It now reports an error when the closer is missing
    or not RBF, which routes the caller through the existing
    req.CompleteErr path, and re-checks liveness after the add so a record that
    lands after a retirement is answered rather than left waiting.

  • peer: abandonChanClosers skipped retiring a closer whose channel point
    could not be recovered, contradicting chanPointForCloser's own contract and
    leaking an RBF state machine and its router endpoint past Disconnect. The
    closer is now always retired; a zero point unregisters no actor, which is the
    right behaviour when the point is unknown.

  • peer: reportRbfCloseSuccess delivered the terminal outcome to each caller
    in turn, so one caller whose update buffer was full and whose context was
    still alive blocked the rest. Every later caller went unanswered, the
    observer never reached deleteActiveChanCloser, and because the observer is
    counted the peer's own shutdown blocked too. Delivery is now one goroutine
    per caller, bounded by the caller's lifetime, so a caller that stops reading
    cannot hold up the others or the shutdown. A confirmed close is still never
    dropped: the record is claimed before delivery, and the delivery is
    deliberately not bound to peer shutdown, since the closing transaction
    confirms on chain regardless.

  • server: the closeLink closure deferred its context cancel, but the
    closure returns as soon as the request is handed to the switch, so the
    request's context was cancelled before anything could select on it —
    downstream updates could be dropped against an always-ready case. The context
    is now derived once and bound to the daemon's lifetime, which is what the
    comment claimed.

  • peer: a breach was reported to the caller as a link failure. A breach is
    terminal rather than transient, so it now has its own sentinel
    (ErrCoopCloseAbortedByBreach) instead of asserting a false cause.

Tests

  • Removed TestRetireChanCloserIsIdempotent and TestCloseReqClaimedExactlyOnce,
    which between them never installed a closer or drove the claimed second
    outcome, so their assertions could not fail. The behaviour they meant to
    cover is exercised through a real RBF closer by
    TestCloseReqRegistryOverlappingCallers and the a claimed caller is not told twice subtest. Statement coverage is unchanged.
  • Rewrote TestRbfCloseCancelReleasesOnlyItsCaller to drive the observer's own
    cancellation branch; it previously called releaseCloseReq directly and so
    never exercised the path a real cancellation takes.
  • Added TestRbfCloseSuccessHeadOfLineBlocking, which fills one caller's buffer
    at the production capacity and asserts the reporter, the other caller and
    peer shutdown all still make progress. It fails against the previous blocking
    implementation and passes now, so it is a genuine regression test rather than
    a restatement of the fix.

Local checks: go build ./..., go test ./peer/ (-count=2, -race),
./contractcourt, ./htlcswitch with -tags dev, and the repository's lint
target, all clean.

@Roasbeef
Roasbeef force-pushed the resource-cleanup-link-failure branch from 485e972 to 2dc63ee Compare October 15, 2026 05:28
@Roasbeef

Copy link
Copy Markdown
Member Author

Pushed the review round. Six commits, tree unchanged in intent, and every commit now builds and passes go test ./peer/ on its own.

What changed

1. A caller whose closer is retired right after it is recorded is now answered by whoever wins, not by both. The recheck that runs once the caller is recorded now takes the record before it reports, so a close that reached its close and a retirement that ends it cannot both answer the same caller.

2. A CurrentState failure at the start of an RBF close is reported to the caller instead of being swallowed before the caller is recorded. That path had no way to answer the RPC, which then waited on a request nothing was left to resolve.

3. A breach answers a cooperative close already under way on the channel before the wipe retires its closer. The caller was previously told the wrong cause, or nothing at all.

4. A close that confirmed on chain is claimed through the request's promise before it is delivered, so a caller an abort has already answered is not afterwards also told the close succeeded. The promise is the one place a close is resolved, so winning it is what proves this path is the one answering; a readiness check followed by a send cannot decide that, since a retirement can land in between.

5. Two tests were rewritten so they can actually detect their bugs. The cancellation test registered its second caller too late to catch a release that took every caller on the channel; both callers are now registered before the cancel. The head-of-line test's blocked caller now carries a context that is cancelled in cleanup, so the delivery goroutine it starts exits rather than parking for the life of the test binary.

6. New covers: the breach case above, the abort-versus-confirmation ordering (both orderings, plus a caller that has stopped waiting), and the success-versus-retirement claim.

Each of these is mutation-checked: removing the claim, widening the release, making the delivery sequential, dropping the breach notification, or reporting a success without claiming the request all make the relevant test fail.

The series itself

The first four commits are unchanged. Commits five and six were rebuilt so that every commit is internally consistent:

  • The breach sentinel a commit introduces and the assertion for it now land together, rather than the assertion arriving a commit later. This was invisible to CI: Check commits runs make unit pkg=... case=_NONE_, which sets -test.run=_NONE_, so it compiles the tree and runs no tests at all. Building is not running.
  • A test that depends on the asynchronous delivery introduced by the last commit now lives with it.

Verified independently, not just by the build: all six commits build, and all six pass go test ./peer/ -count=1 when checked out one at a time. Full gate at the tip: gofmt, go build ./..., ./peer -count=1 and -race, ./contractcourt, ./htlcswitch -tags dev, and make lint-source at zero issues.

One item is deliberately not in this change and is worth a follow-up: retiring a closer unregisters its router endpoint by name, which is a time-of-check/time-of-use race on a shared, per-server router — a reconnect that registers a replacement endpoint first can have its endpoint removed by the older retirement. Closing it properly needs an atomic compare-and-remove by endpoint identity in the shared message router, so it is not something to change as part of this review round. The default per-peer router is not exposed to it.

In this commit, we retire any active cooperative close state before removing a
channel from the peer and switch, so a closer cannot outlive the channel and
link that created it.

We also require a clean channel before treating a missing link as proof that the
channel has flushed, since link removal also happens on paths that do not retire
the closer.

The regression tests restore an interrupted legacy close in a new peer instance,
drive a non-disconnecting link failure, and verify that the retired closer is no
longer answered.
In this commit, we tell a locally initiated cooperative close why it will
never finish when a link failure retires its channel. `WipeChannel` deletes
the closer, and the closer is the only writer of the close request's error
channel, so deleting it quietly left `CloseChannel` waiting on a request
nobody was left to answer. With `LinkFailureForceNone`, the peer stays connected, so no disconnect
cleans the request up for us.

The notification runs in the failure path rather than in `WipeChannel`, so
successful close finalization, which also wipes the channel, keeps its
existing behavior. Only a closer we initiated owns a close request, so a
responder or a restored closer has nothing to notify, and the error the
caller receives is a stable value it can match on.

Retiring the closer also clears the channel's closing flag, which the
shutdown set and the legacy close consults. Leaving it set would make a later
attempt to close the channel fail as already closing.

The test starts a local legacy close, drives a non-disconnecting link
failure, and asserts that the request receives the error before the closer
is removed. Without the fix the assertion times out.
In this commit, we make retiring a closer release everything that closer
owns, for both variants, so a closer cannot outlive the channel that created
it.

`deleteActiveChanCloser` dropped the closer from the active map and
unregistered its close actor, but an RBF closer is also a message-router
endpoint and a running state machine, and neither was released. The endpoint
stayed registered in the global, per-server router, so a co-op close message
matched by channel ID was still delivered to a closer for a channel the peer
had left behind: the newer closer's instance of the same stale-closer
behavior. The state machine was never stopped
either, so it kept a goroutine for the channel's remaining lifetime.

Both releases run on their own goroutine, because this function can be
reached from an RBF closer's own goroutine: a failed transition reports
through the error reporter, which re-initializes the closer and retires it
when that fails. The router answers endpoint queries from the same loop that
delivers messages to endpoints, and stopping blocks until the machine's
goroutine has exited, so blocking on either from that goroutine could wait on
itself.

Retirement also answers a caller that is still waiting. The legacy closer
carries its own request, but the RBF one does not, so the caller is recorded
when the RBF close starts. A caller is claimed with a load-and-delete before
it is reported to, in every path that resolves a close, so exactly one of the
paths racing to end a close reports to it and a close that finished cannot be
reported as aborted by a retirement racing it. A caller that cancels, or a
peer that shuts down, also gives up its record rather than leaving one for a
later retirement to answer forever.

The tests retire a real RBF closer built by the production constructor
through a real router, and assert the caller is answered once, the endpoint is
no longer routable, and the machine is no longer running. Each half fails its
test when removed.

A caller is tracked as a set per channel rather than as one record, because a
channel can have more than one while an earlier one is still live: a fee bump
for a close already in progress registers another. Taking a caller's record is
the only way to resolve it, and a path acting for one caller takes that caller
and no other, so an observer can never hand one caller the outcome belonging to
another, and a retirement that ends the close on the channel's behalf answers
every caller still waiting.
In this commit, we tell every caller still waiting on a cooperative close
that the close ended when its peer disconnects, and release the closers that
peer owned.

`Disconnect` retired nothing and answered nobody. The closers it owned were
left in place, and for an RBF closer that means a registered router endpoint
and a running state machine outliving the connection. A caller was also
left with nothing to read: no outcome was coming on either channel, so
`CloseChannel` parked until the daemon shut down. This is the disconnect case
of the link-failure bookkeeping bug, and it affected the legacy closer too: a
disconnect never answered the caller of a locally initiated legacy close.

The closers are now walked on disconnect and each caller is answered before
its closer is retired, so the retirement's own claim finds the caller already
answered. This runs before the peer's context is cancelled, because answering
a caller is a send on a channel that gives up once that context is done.

`abortCloseRequest` also delivers its outcome directly when the caller's slot
is free, rather than always handing the send to another goroutine. With the
deferred send, the cancellation that follows immediately afterwards raced the
send, and Go picks freely between two ready cases, so the caller could be left
without its outcome. The slot is free in the normal case, so this both fixes
that race and avoids a goroutine.

The notification is renamed, `notifyChanCloserOfAbort`, and takes the reason,
since what ends a close is not always a link failure.
@Roasbeef
Roasbeef force-pushed the resource-cleanup-link-failure branch from 2dc63ee to 1f9b500 Compare October 15, 2026 05:53
@Roasbeef

Copy link
Copy Markdown
Member Author

Addressed the test-size point as well. peer/brontide_test.go goes from 4530 to 4174 lines (−356) with no test, assertion, subtest or cancellation path removed: 53 test functions before and after, subtest count unchanged, and the assertion, timeout and context counts identical.

The reduction is all shared setup. Nineteen close-request constructions and five message-router setups collapse into two small helpers (newTestCloseRequest, useTestCloseRouter). The request helper takes its channel capacity as a parameter rather than hardcoding one, because that value is load-bearing in two places: the head-of-line test needs the production capacity so it reproduces the real full-buffer case, and one test needs an unbuffered channel to make a delivery impossible rather than merely unlikely. Those distinctions are preserved at the call sites, along with the four individually-cancellable contexts. Prose added by the last two rounds is tightened, with the ordering explanations kept.

On coverage, one measurement caveat worth stating plainly: the peer package's covered-statement count is not deterministic run to run on this tree, before or after this change. Three runs at the current head gave 1141, 1142 and 1145; three runs of master gave 943, 950 and 949. The same two regions vary on both — brontide.go's peerExitError drain, whose select over the error channel, the context guard and the quit signal takes a branch by scheduling, and ping_manager.go:216. Both are pre-existing and outside this change, so a single-run integer before and after can read as a loss or a gain that is not real. Comparing covered-block sets across paired runs is the honest test: zero covered blocks lost.

The nine mutations this branch relies on still behave as before — each fix's detector fails when that fix is removed, and the one counter-probe (a deliberately over-broad release under the old test ordering) still passes, which is what shows the rewrite is what makes the bug detectable rather than the test merely reporting it.

Every one of the six commits builds and passes go test ./peer/ -count=1 on its own, checked out one at a time in an isolated worktree, so the series stays bisectable. Full gate at the tip: gofmt, go build, ./peer -count=1 and -race, ./contractcourt, ./htlcswitch -tags dev, and make lint-source at zero issues.

@gijswijs
gijswijs self-requested a review October 15, 2026 14:10

@gijswijs gijswijs left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

All my comments were addressed. @Roasbeef went beyond and fixed things that I thought were such ultra-nits that I didn't raise them: The CurrentState hang is now surfaced via startErr, and the legacy confirmed-close path claims the promise through Complete's winner bool before delivering (claimAndReportLegacyCloseSuccess). This closes the "abort masks a confirmed close" race, using exactly the bool I noted was available but unused. The two weak tests from the first round were also rewritten, and the test file shrank 356 lines with no assertion loss. 🚢!

In this commit, we give a cooperative close request a single outcome that
every path can resolve at most once, and that no path can be held up
delivering.

The request carried its error over a channel with room for one, and the
paths that end a close wrote to it directly. Two of those writes have no way
out if the buffer is already full: they send without selecting on anything
else, so once the caller has stopped reading they block where they stand. One
of them runs on the channel manager's goroutine, so a block there stops every
operation for that peer, not just the close. The preceding commit makes the
disconnect path answer a waiting caller, adding a second writer to a request
that previously had one, so the buffer may already hold an outcome when either
send runs.

The request now carries a promise instead of a channel. Completion is
idempotent and reports whether this call was the one that resolved it, so a
second path is refused rather than allowed to overwrite the outcome the
caller already has. Completion cannot block, so a path that resolves a close
is never held up by a caller that stopped reading.

A close that reaches its close resolves the request too, once the caller has
been told it succeeded. Otherwise the request is resolved only when a close
fails or is aborted, and a caller that derived a channel from it has nothing
to end that wait: with a context that is never cancelled, as the breach path
uses, that wait would outlive everything it was waiting for.

Callers that wait on a channel see no change: the promise is awaited on their
behalf, and only a failure is forwarded, since a close that succeeds reports
through its updates. The channel is left open rather than closed, because a
closed channel reads as an immediate nil error and would cut a wait short
before the close had finished.

Retiring a closer no longer writes to the channel it is retiring. Clearing the
channel's closing flag happens only where the goroutine that owns the channel
does it, which the channel manager also does for its own channels as it
drains, so no request carries the channel and no resolution touches one.

The updates a close emits keep their untyped channel, because their terminal
value carries a chancloser type and chancloser imports this package. Every
send is guarded so that a caller that has gone away cannot make a close block
delivering to it, and the buffer holds a pending update from each side of the
negotiation as well as the terminal one.

The failure and success of a close are reported by name. A close that failed
takes the caller's own record before reporting, so a close that afterwards
reached its close cannot tell that caller it succeeded: resolving a request
refuses a second resolution, but the updates a close carries are a separate
channel that nothing refuses.

A close that reached its close reports to every caller still waiting, and the
peer's shutdown no longer suppresses that report. The closing transaction has
been broadcast, so it confirms whether or not the peer is still connected, and
the callers' records are taken before anything is delivered, so no other path
could answer them.

The error a confirmation reports is reported through a callback rather than a
channel, so there is no relay goroutine that a peer shutdown could stop before
it forwards the error.

A close refused because the counterparty breached the channel resolves its
request, and the request that path starts is bound to the daemon's lifetime,
so a request nothing resolves cannot outlive the daemon.
In this commit, we pin the last contract the resolution of a close rests on
and that nothing yet exercises: the order in which an observer reports and
claims a caller. The preceding commit covers the rest, since a test of the
channel a request derives needs the promise that commit introduces. The
observer must take the caller's record before it reports, so that a closer
which was already retired cannot tell a caller the close succeeded after the
caller was told it had ended. The test drives a real RBF closer to its
terminal state through production code and asserts both outcomes: an unclaimed
caller is told the close succeeded, and a caller a retirement already answered
is not told twice. Removing the claim from the report makes the second case
fail, which is what the ordering is for.

The closer is subscribed to before it is driven, since registering the
subscription inside the observer goroutine races the event that drives it, so
a state reached in between would be missed and the observer would wait for a
transition that already happened.

Two more paths are covered, both of which decide whether a caller is answered:
a close that ends in a breach, and a caller whose record is given up while
another caller on the channel is still waiting. The tests for a close reported
after the peer shut down state what they rely on and the one schedule they do
not catch.
@Roasbeef
Roasbeef force-pushed the resource-cleanup-link-failure branch from 1f9b500 to 79497bb Compare October 15, 2026 14:25
@Roasbeef

Copy link
Copy Markdown
Member Author

Force-pushed to make the series internally consistent. The tree is byte-identical to what was already here — git diff 1f9b50009 79497bb56 --stat is empty, so nothing a reviewer already read has changed. This is a history-only rewrite.

Two commits moved:

  • 5e23f9ab3 (was 3926cd4c3) now carries all of its own production behavior: the per-caller asynchronous delivery in reportRbfCloseSuccess, the once-only claim in the legacy success path and in the RBF registration recheck, the propagated CurrentState error, the IsRight guard, and the daemon-lifetime close context in server.go.
  • 79497bb56 (was 1f9b50009) is now tests only, which is what its message and title always claimed it was.

Why this matters, and how it was missed: the previous version had commit 6 repairing defects commit 5 introduced — a later commit fixing an earlier one. Every commit built and passed go test ./peer/ -count=1, which is necessary but not sufficient: the tests at the earlier commit did not exercise the behavior that commit left broken, so a defective commit passed its own suite. Neither could CI catch it: Check commits runs make unit pkg=... case=_NONE_, which sets -test.run=_NONE_ and therefore compiles the tree while running no tests at all.

The check that does catch it, per adjacent pair in the series:

git diff <commit N> <commit N+1> -- '*.go' ':!*_test.go'

If a later commit changes production behavior, the series is not internally consistent, regardless of builds and tests. On this series that diff is now empty for the last pair and non-empty only where a commit legitimately introduces new behavior:

9b7a98284 -> 5d6a97d5a  production lines: 70
5d6a97d5a -> 66ffdd053  production lines: 337
66ffdd053 -> 804a23942  production lines: 161
804a23942 -> 5e23f9ab3  production lines: 539
5e23f9ab3 -> 79497bb56  production lines: 0

Verified at the tip: every one of the six commits builds and passes go test ./peer/ -count=1 checked out individually in an isolated worktree; gofmt, go build, ./peer -count=1 and -race, ./contractcourt, ./htlcswitch -tags dev; and make lint-source at zero issues. Test file unchanged at 4174 lines with all 53 test functions and the same nine mutation proofs (eight fail when their fix is removed, one counter-probe still passes). Six commits, linear, original authorship and messages.

@ziggie1984 ziggie1984 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

Really nice improvement overall. I especially like the promise-based approach.

One remaining design caveat is that terminal completion is split between the promise and the Updates channel. These are independent objects, so the requirement to claim the request before publishing a success update is maintained by convention and the current helpers, rather than enforced by the type system. The implementation and tests handle this correctly, but future code will need to preserve that invariant.

@ziggie1984
ziggie1984 merged commit 8f95052 into master Oct 17, 2026
113 of 118 checks passed
@github-project-automation github-project-automation Bot moved this from In review to Done in v0.21 Oct 17, 2026
@github-actions

Copy link
Copy Markdown

Created backport PR for v0.20.x-branch:

Please cherry-pick the changes locally and resolve any conflicts.

git fetch origin backport-11329-to-v0.20.x-branch
git worktree add --checkout .worktree/backport-11329-to-v0.20.x-branch backport-11329-to-v0.20.x-branch
cd .worktree/backport-11329-to-v0.20.x-branch
git reset --hard HEAD^
git cherry-pick -x 9b7a98284b2a7916b2cfdb43c2aac198fc5d2f38 5d6a97d5ac056a81d5ad689aab98bbe50b00ebc9 66ffdd053696ddb841cf1a6ac2fbab94d327ee37 804a2394285569a7bff31c79d720eaf1e853cb17 5e23f9ab357ec573f2ef0e990d7d7a59f3beaad4 79497bb567b2fde3eb4db318f6e51ff4f74a7f8c
git push --force-with-lease

@github-actions

Copy link
Copy Markdown

Created backport PR for v0.21.x-branch:

Please cherry-pick the changes locally and resolve any conflicts.

git fetch origin backport-11329-to-v0.21.x-branch
git worktree add --checkout .worktree/backport-11329-to-v0.21.x-branch backport-11329-to-v0.21.x-branch
cd .worktree/backport-11329-to-v0.21.x-branch
git reset --hard HEAD^
git cherry-pick -x 5e23f9ab357ec573f2ef0e990d7d7a59f3beaad4 79497bb567b2fde3eb4db318f6e51ff4f74a7f8c
git push --force-with-lease

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-v0.20.x-branch This label is used to trigger the creation of a backport PR to the branch `v0.20.x-branch`. backport-v0.21.x-branch This label triggers a backport to branch `v0.21.x-branch ` no-changelog severity-critical Requires expert review - security/consensus critical

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants