lightningd: don't disconnect when sending error for unknown channel_reestablish - #8988
Conversation
|
@vincenzopalazzo can you rebase please |
03bb0ba to
694cbe5
Compare
nGoline
left a comment
There was a problem hiding this comment.
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.
…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>
694cbe5 to
592381a
Compare
|
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. 2. I don't think we can fix it there without regressing resume. Because 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 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 ( 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 ( 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: |
Summary
WIRE_CHANNEL_REESTABLISHfor an unknown channel, send the error without disconnectingDUALOPEND_OPEN_COMMIT_READYthat retries indefinitelyThe 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 sendsCHANNEL_REESTABLISH, the other side sends error + disconnect. The disconnect races with the error delivery -- if the peer'sdualopenddoesn't receive the error before the socket closes,dualopen_errmsgis called withdisconnect=false, which callschannel_fail_transientinstead of deleting the channel, and the cycle repeats.By not disconnecting, the error reliably reaches the peer's
dualopend, which processes it viapeer_failed_received_errmsg(disconnect=true)and properly deletes the stale channel.Test plan
make check-unitspasses (done locally)test_disconnect_openerFixes #8822