Resolve simultaneous node-to-node connects deterministically - #8233
Open
Amaury Chamayou (achamayou) wants to merge 2 commits into
Open
Resolve simultaneous node-to-node connects deterministically#8233Amaury Chamayou (achamayou) wants to merge 2 commits into
Amaury Chamayou (achamayou) wants to merge 2 commits into
Conversation
Amaury Chamayou (achamayou)
force-pushed
the
achamayou-ci-failure-forensics
branch
from
August 28, 2026 14:05
1c5e46f to
9509fc0
Compare
When two nodes dial each other at the same moment, each ends up holding both an outgoing connection it created and an incoming one the peer created. Both currently prefer the incoming connection, so each destroys the socket the other is relying on and both are left writing into a dead connection. On a healthy network this is masked, because closing a socket sends a FIN which the peer observes as a disconnect and repairs. It is not masked when that notification is lost, which is what a partition does. Both nodes now decide which connection to keep by ordering their node IDs: the lower ID keeps the one it opened, the higher prefers the one its peer opened. Both sides reach the same conclusion from information they already have, so exactly one connection survives and no orphan is created. This only applies to a recently created outgoing connection. An incoming connection from a peer we already have a settled connection to means that peer believes the link is broken, and it may have seen a failure we cannot see. Defending our own connection indefinitely would deny it the only means it has of repairing a link that is dead in a way we cannot detect. The host is never told its own node ID, but it appears in the sender field of every outbound message, and is only needed once an outgoing connection exists - which implies we have already sent. It is cached from there. NodeConnections is templated on its socket type, mirroring RPCConnections, so that the behaviour can be tested over a mock network which can suppress connection teardown the way a partition does. Production use is unchanged via NodeConnectionsImpl<TCP>. Host connection lifecycle events are promoted from DEBUG to INFO. These are a handful of events per node lifetime, and their absence is what made the original failure require log reconstruction to diagnose. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Amaury Chamayou (achamayou)
force-pushed
the
achamayou-ci-failure-forensics
branch
from
August 28, 2026 16:39
9509fc0 to
3db1410
Compare
Amaury Chamayou (achamayou)
marked this pull request as ready for review
August 28, 2026 17:14
Copilot started reviewing on behalf of
Amaury Chamayou (achamayou)
August 28, 2026 17:15
View session
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes a liveness bug in CCF’s host-side node-to-node transport where simultaneous cross-connects could leave both nodes holding an orphaned socket (especially when disconnect notifications are blackholed during partitions), by making the “which connection to keep” decision deterministic. It also adds the first focused unit tests for NodeConnections, using a mock network to reproduce the failure mode reliably.
Changes:
- Make
NodeConnectionsdeterministic under simultaneous connect by tie-breaking on node ID, with a bounded “simultaneous connect” time window. - Promote node connection lifecycle logs from DEBUG to INFO to improve diagnosability at CI log levels.
- Add a new mock-based unit test (
node_connections_test) and a changelog entry for the fix.
Custom instructions used:
- None (no
.github/copilot-instructions.mdor.github/instructions/*files were loaded during this review)
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/host/node_connections.h | Deterministic resolution of simultaneous node-to-node connects (templated impl + ID-based tie-break + logging changes). |
| src/host/test/node_connections.cpp | New doctest unit tests with a mock network to reproduce and validate the fix. |
| CMakeLists.txt | Adds the new node_connections_test unit test target. |
| CHANGELOG.md | Documents the node-to-node simultaneous connect fix in the release notes. |
Suppressed comments (1)
src/host/node_connections.h:454
- The connection failure log message just above has a duplicated word ("dropping outbound message message"), which looks like a typo and makes diagnostics harder to read.
outbound_connection = connection_it->second.socket;
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+17
to
+19
| // See NodeConnectionsImpl::simultaneous_connect_window. | ||
| static constexpr auto default_simultaneous_connect_window = | ||
| std::chrono::seconds(2); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the node-to-node channel defect investigated in #8232. This is quite a corner case, perhaps we want to convert node to node channels to TLS over TCP first instead, but in case this is still some way away, this may be a good liveness fix.
Closes #8232
The problem
When two nodes dial each other at the same moment, each ends up holding two connections to the same peer: an outgoing one it created, and an incoming one the peer created. Today both nodes unconditionally prefer the incoming connection, so each destroys the socket the other is relying on. Both are left holding a connection whose far end no longer exists.
The existing code knows about this.
associate_incomingcarries a comment describing exactly this case and resolves it with an assumption:That assumption holds only because closing a socket normally sends a FIN which the peer observes as a disconnect. It does not hold when that notification is lost, which is precisely what a partition does. In the failure that prompted this, the two orphaned sockets were created during partition churn, the FIN was swallowed by an
iptablesDROP rule, and the pair never spoke again. A node then sat as aPreVoteCandidatefor 20 seconds issuing six pre-votes that were encrypted, handed to the host, written into a dead socket and silently discarded, while the peer that could have voted for it logged nothing at all.This is not test-only. Any silent blackhole - asymmetric firewall state, a NAT idle eviction, a cloud SDN drop - reaches the same state in production, and the result is a stalled election with no diagnostics.
The fix
Make the outcome deterministic instead of racy. Both nodes order the two connections by node ID: the lower ID keeps the connection it opened, the higher ID prefers the one its peer opened. Both sides compute the same answer from information they already have, so exactly one connection survives, both ends agree on which, and no orphan is ever created.
The host is never told its own node ID, but it appears in the sender field of every
node_outboundmessage, and is only needed once an outgoing connection exists - which implies we have already sent. It is cached from there, so this needs no new plumbing or ringbuffer message.The tie-break only defends a recently created outgoing connection. This qualification is essential rather than incidental. An incoming connection from a peer we already have a settled connection to means that peer believes the link is broken, and it is in a better position to know - it may have observed a failure we cannot see. Defending our own connection indefinitely would deny that peer the only means it has of repairing a link that is dead in a way we cannot detect, which is precisely the failure mode this change exists to prevent. So a connection young enough to still be part of a genuine race is defended, and anything older yields exactly as it does today. A real race is resolved within a round trip; the window is 2s.
Host connection lifecycle events are promoted from
LOG_DEBUG_FMTtoLOG_INFO_FMT. These fire a handful of times per node lifetime, and their absence at CI log level is what made the original incident require millisecond arithmetic on an idle timer and inference from missing log lines to diagnose.Compatibility with nodes that do not have this change
There is no wire-format change: no new message types, no altered serialisation, no negotiation. Connection selection is a purely local decision, so an old node and a new node can always talk to each other.
The behaviour in a mixed cluster follows from one observation: the higher-ID node's behaviour is unchanged. It still prefers the incoming connection, exactly as today. Only the lower-ID node behaves differently. So for a pair of nodes:
So the fix engages for exactly those pairs whose lower-ID node has been upgraded, is inert otherwise, and is fully effective once the cluster is upgraded. There is no ordering requirement on a rolling upgrade, and no state in which mixed versions are worse than the current behaviour.
Testing
src/host/test/node_connections.cppis new, and is the first unit test for this file.NodeConnectionsis templated on its socket type - mirroring the existingRPCConnections<TCP>- so the test can drive it over a mock network. Production use is unchanged, viausing NodeConnections = NodeConnectionsImpl<TCP>.Testing this needs a mock rather than loopback sockets, and that is the point: on a healthy loopback the race self-corrects, because the FIN is delivered and the peer repairs. Reproducing the real failure requires suppressing connection teardown, which the mock does with a
deliver_closeflag standing in for the DROP rule. The mock also buffers writes issued before a connect completes, as libuv does viaTCPImpl::pending_writes; without that a node's first message would be lost and would mask the behaviour under test.Two cases, each verified to fail against the specific line of code it covers:
Simultaneous connect leaves a usable connection in both directions. Both nodes send before either has accepted, teardown is then suppressed, and a message must still get through both ways. With the tie-break reverted this reproduces the production symptom exactly - the first message arrives in each direction and every subsequent one vanishes:
A later incoming connection is accepted, so a peer can repair. A connects to B, then B alone observes that connection fail, leaving A holding a silently dead outgoing connection. B dials A, and A must accept it. With the age check removed this fails, confirming it covers the regression described above rather than passing incidentally:
Both pass against the fix:
src/host/run.cppandsrc/host/test/rpc_connections.cppstill compile, andclang-format-18is clean. CI is green across all five jobs, including thepartitionsjob whose failure prompted the investigation.What this does not do
It does not add liveness detection to an already-established channel. A channel can still be blackholed by other means, and CCF will still not notice, because
NodeToNodeChannelManagerresets a channel's idle timer on sends as well as receives - so a node talking into a void indefinitely refreshes its own timeout. In the original incident node 1's 16 s self-heal timer was reset six times by the very pre-votes that were failing. That deserves separating "is this channel in use" from "is this channel working", and is left for a follow-up; it is discussed in #8232.It also does not change
partitions_test.py. The test asserts a reasonable liveness property and the two previous stabilisation attempts on these same functions (#8132, #8197) tuned timing rather than fixing the framework.Related
The
throw eexception-slicing bug inmessaging::Dispatcher::dispatch, found while writing these tests, was split out into #8235 and has now merged. This branch is rebased on top of it.