Skip to content

lightningd: don't disconnect when sending error for unknown channel_reestablish - #8988

Open
vincenzopalazzo wants to merge 2 commits into
ElementsProject:masterfrom
vincenzopalazzo:fix/dual-funding-reconnect-loop
Open

lightningd: don't disconnect when sending error for unknown channel_reestablish#8988
vincenzopalazzo wants to merge 2 commits into
ElementsProject:masterfrom
vincenzopalazzo:fix/dual-funding-reconnect-loop

Conversation

@vincenzopalazzo

Copy link
Copy Markdown
Collaborator

Summary

  • When receiving WIRE_CHANNEL_REESTABLISH for an unknown channel, send the error without disconnecting
  • Prevents a race where the disconnect arrives before the error, leaving the peer with a stale saved channel in DUALOPEND_OPEN_COMMIT_READY that retries indefinitely

The bug: during dual-funding, if one side saves the channel (reaches DUALOPEND_OPEN_COMMIT_READY) but the other deletes its unsaved copy on disconnect, reconnects create an infinite loop. The saved side sends CHANNEL_REESTABLISH, the other side sends error + disconnect. The disconnect races with the error delivery -- if the peer's dualopend doesn't receive the error before the socket closes, dualopen_errmsg is called with disconnect=false, which calls channel_fail_transient instead of deleting the channel, and the cycle repeats.

By not disconnecting, the error reliably reaches the peer's dualopend, which processes it via peer_failed_received_errmsg(disconnect=true) and properly deletes the stale channel.

Test plan

  • Verify make check-units passes (done locally)
  • CI integration tests, particularly test_disconnect_opener

Fixes #8822

@madelinevibes madelinevibes added the BOUNTY! 🫰 A bounty is available for this PR label Mar 30, 2026
@madelinevibes madelinevibes added this to the 26.06 milestone Mar 30, 2026
@madelinevibes
madelinevibes requested a review from niftynei March 30, 2026 02:35
@madelinevibes madelinevibes added the Status::Ready for Review The work has been completed and is now awaiting evaluation or approval. label Mar 30, 2026
@madelinevibes

Copy link
Copy Markdown
Collaborator

@vincenzopalazzo can you rebase please

@nGoline nGoline 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.

I agree with the direction: not disconnecting after error is spec-legal, and I think a few people will challenge that, so it's worth putting the citation in the PR.

BOLT #1 ties error to failing the channel, never to the connection:

A sending node:
  - when sending `error`:
    - MUST fail the channel(s) referred to by the error message.

There's no "MUST close the connection" in that section, and BOLT #1 says it explicitly elsewhere when it means it (the init requirements). BOLT #2 makes the dichotomy explicit in ~25 places: "MUST either send a warning and close the connection, or send an error and fail the channel." "Fail the channel" is defined in BOLT #5 Failing a Channel purely as forget/mutual-close/unilateral-close, nothing about TCP. And the BOLT #1 rationale frames dropping the connection as the behaviour error exists to replace: "There are unrecoverable errors that require an abort of conversations; if the connection is simply dropped, then the peer may retry the connection."

Here the requirement is vacuous anyway, since we don't know the channel there's nothing for us to fail. And error rather than warning is right: only error obliges the peer to fail the channel, a warning would leave the stale channel exactly where it is.

Two substantive concerns, then some smaller ones inline.

1. I don't think the race is where the commit message says it is.

The commit says the disconnect races the error delivery on our side. But disconnect_peer() -> drain_peer() in connectd/multiplex.c sets draining_state = WRITING_TO_PEER and gives 5 seconds to flush peer_outq before closing, so the error should reach the wire ahead of the FIN. The race looks like it's on the receiving node instead: its connectd hands the error to dualopend while simultaneously tearing down subds on EOF, so lightningd ends up in dualopen_errmsg() with peer_fd == NULL and disconnect == false. Could you confirm the mechanism from the logs in #8822? It matters, because it changes where the fix belongs.

2. This fixes the peer, not us.

As written, the loop only goes away when the erroring side is running this patch. Our node still loops forever against anything that errors and hangs up: older CLN, LND, eclair, or a peer that simply drops the connection.

The unilateral fix is in dualopen_errmsg() (lightningd/dual_open_control.c). The if (!peer_fd) branch calls channel_fail_transient(channel, disconnect, ...), which leaves a DUALOPEND_OPEN_COMMIT_READY channel alive indefinitely. Every other path in that same function deletes a channel_state_open_uncommitted channel: the (warning || disconnect) check at the top, the !disconnect branch further down, and channel_fail_permanent() itself. That asymmetry looks like the actual bug in #8822, and fixing it there makes us self-healing regardless of what the peer does.

Ideally both land. If only one does, I'd argue for that one.

We're also missing a regression test to #8822. There's no test for the reconnect loop this is meant to fix, which makes the change hard to defend later. Repo convention would be a first commit adding the loop reproduction with @pytest.mark.xfail(strict=True), then the fix commit removing the marker. If the loop is hard to reproduce deterministically, dev-disconnect around the DUALOPEND_OPEN_COMMIT_READY transition plus asserting the channel is gone after one reconnect (rather than counting reconnects) should be enough.

Comment thread lightningd/peer_control.c Outdated
Comment thread lightningd/peer_control.c Outdated
Comment thread lightningd/peer_control.c Outdated
Comment thread lightningd/peer_control.c Outdated
Comment thread tests/test_misc.py
…eestablish

When a peer sends WIRE_CHANNEL_REESTABLISH for a channel we don't know
about (e.g. dual-funding, where we deleted the unsaved channel on
disconnect but the peer saved it in DUALOPEND_OPEN_COMMIT_READY), we
sent an error and hung up.

Our error does make it onto the wire: disconnect_peer() -> drain_peer()
in connectd/multiplex.c gives peer_outq 5 seconds to flush first.  The
race is on the receiving node: its connectd hands the error to dualopend
and tears the subds down on EOF at the same time, and when dualopend
loses that race lightningd only sees "Owning subdaemon dualopend died"
(subd.c passes peer_fd=NULL, disconnect=false), so dualopen_errmsg()
keeps the DUALOPEND_OPEN_COMMIT_READY channel for a later reconnect.
It then reestablishes on every reconnect, and we error and hang up
again.

BOLT #1 only requires that a node sending `error` fails the channel(s)
the error refers to; it never says to drop the connection, and here we
don't know the channel, so there is nothing for us to fail.  So send the
error and stay connected: the peer's dualopend then reliably reads it
and forgets the channel.

Since we no longer hang up, bound how many unknown-channel
reestablishes we answer on a single connection, so a peer can't use
this to make us log and write errors indefinitely.

Changelog-Fixed: dual-funding reconnect loop when peer doesn't know about a saved channel
Fixes: ElementsProject#8822
Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com>
Sets up ElementsProject#8822: l1 drops the last tx_complete after dualopend has decided
the commitment is ready, so l1 saves a DUALOPEND_OPEN_COMMIT_READY
channel that l2 never saved.  On reconnect l1 reestablishes a channel l2
doesn't know, and l2 has to answer with an error and stay connected.

Note this pins the new behaviour rather than the loop itself: the loop
is a race on l1's side (see the previous commit), and locally l1 wins it
and forgets the channel even without the fix, so a strict xfail
reproduction would just be flaky.  The test does fail without the fix,
on l2 hanging up.

Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com>
@vincenzopalazzo
vincenzopalazzo force-pushed the fix/dual-funding-reconnect-loop branch from 694cbe5 to 592381a Compare August 8, 2026 13:05
@vincenzopalazzo

Copy link
Copy Markdown
Collaborator Author

Thanks, this is the useful kind of review. All five inline comments are addressed in 36cc672; I force-pushed rather than adding fixups because one of the fixes was the commit message itself.

1. You're right about the race, and my commit message was wrong. Rewritten in 36cc672. !peer_fd in dualopen_errmsg() can only come from the "Owning subdaemon %s died" path in lightningd/subd.c, which passes peer_fd=NULL, disconnect=false, warning=falsehandle_peer_error() always attaches a peer_fd. So the node that loses is the receiver whose dualopend dies before it reads the error, exactly as you describe, and our own error does get flushed by drain_peer() first.

2. I don't think we can fix it there without regressing resume.

Because !peer_fd only ever means "our dualopend died", it's the one branch in that function where we don't know the peer failed the channel — every other branch is a warning, an error, or an abort we actually saw. And it's the case we deliberately resume: peer_connected_hook_final() restarts dualopend for DUALOPEND_OPEN_COMMIT_READY on reconnect (lightningd/peer_control.c), and wallet_update_channel_commit() moves it to DUALOPEND_OPEN_COMMITTED once their commitment_signed lands. Deleting on subd death means a v2 open interrupted at the commitment exchange can never resume, even when both sides saved it. I can't find a test covering that resume path (test_v2_open_sigs_reconnect_* all resume from DUALOPEND_OPEN_COMMITTED), so it would be a silent regression too.

If you want us self-healing against peers that do hang up (older CLN, LND, eclair), I think the safe shape is bounded retries rather than an unconditional delete: count reestablish attempts for a COMMIT_READY channel and forget it after N. There's no funding tx, so nothing is at stake and N=3 or so is cheap. Happy to add that here or as a follow-up — your call. I'd just rather not land the unconditional version.

Does that address the concern, or did I misread what you were proposing?

3. Regression test added in 592381a, but not as an xfail.

I tried the xfail-first structure and it doesn't hold up: I can't reproduce the loop deterministically. The setup reproduces fine (dev_disconnect -WIRE_TX_COMPLETE on l1: l1 saves DUALOPEND_OPEN_COMMIT_READY, l2 throws its unsaved channel away), but on my machine l1 wins the race against the hangup and forgets the channel even without the patch — the channel-state assertion passes on 199ecd2. @pytest.mark.xfail(strict=True) would just be flaky in the other direction, which is worse than no marker.

So the test pins the behaviour instead: l2 answers the unknown reestablish, l1 reads the error and forgets the channel, and neither side hangs up. That does fail without the patch (only_one(l1.rpc.listpeers()['peers']) raises, because l2 hung up on us).

Which is more evidence for your point 1, I think: the loop only appears when the receiver loses the race, so it's a flake by nature — which is how #8822 was found in the first place.

One thing to flag: tests/test_connection.py::test_disconnect_opener (the test #8822 came from) fails identically for me on 199ecd2 and on this branch — the node never logs "Server started with public key". Looks like local environment noise, but mentioning it in case CI disagrees.

@vincenzopalazzo
vincenzopalazzo requested a review from nGoline August 8, 2026 13:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

BOUNTY! 🫰 A bounty is available for this PR PLEASE clear CI 🫠 Status::Ready for Review The work has been completed and is now awaiting evaluation or approval.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dual-funding can leave peers unable to reconnect

4 participants