peer: add unit coverage for the connection-oriented outbound dial path

The connection-oriented (TCP/Tor) outbound connect->handshake path had no
`cargo test --lib` coverage; it was exercised only by the opt-in Tor
integration suites, and the TCP node tests bypass it via a manual
connectionless handshake helper. Add three unit tests:

- a machine-level test driving Dial{connection_oriented:true} -> Connecting
  (emitting only OpenTransport, no msg1) -> TransportConnected ->
  Handshaking{SentMsg1}, asserting the exact OpenTransport and
  SendHandshake+SetTimer action vectors that start_outbound_handshake emits
  (its oriented reach via on_transport_connected was previously untested; the
  connectionless reach via on_dial was already covered);

- two node-level tests over a real loopback TcpTransport: a successful connect
  that reaches start_handshake (observed via a pending_outbound entry), and a
  connect to a closed port that routes through the failure arm (link torn down,
  no msg1 dispatched).

Tests only; no production change.
This commit is contained in:
Johnathan Corgan
2026-07-15 13:52:34 +00:00
parent 1f765cfd8f
commit f698da50b6
2 changed files with 175 additions and 0 deletions

View File

@@ -286,3 +286,113 @@ async fn test_tcp_reconnection_after_link_death() {
cleanup_nodes(&mut nodes).await;
}
/// Connection-oriented outbound connect succeeds on loopback and reaches the
/// handshake.
///
/// `initiate_connection` opens a real non-blocking TCP connect to a live
/// listener (node 1's `TcpTransport`) and queues a `PendingConnect`;
/// `poll_pending_connects` promotes the resolved connection to `Connected` and
/// runs `start_handshake`. The observable is `pending_outbound`: `start_handshake`
/// → `prepare_outbound_msg1` tracks the dispatched msg1 there (keyed by the
/// allocated session index), which is empty until the connect resolves.
#[tokio::test]
async fn test_tcp_oriented_connect_success_reaches_handshake() {
let mut nodes = vec![make_test_node_tcp().await, make_test_node_tcp().await];
// Target: node 1's live TCP listener + its verified identity.
let target_addr = nodes[1].addr.clone();
let target_identity = PeerIdentity::from_pubkey_full(nodes[1].node.identity().pubkey_full());
let transport_id = nodes[0].transport_id;
// Kick off the non-blocking oriented connect.
nodes[0]
.node
.initiate_connection(transport_id, target_addr, target_identity)
.await
.expect("initiate_connection should queue a pending connect");
// One pending connect queued, no msg1 dispatched yet.
assert_eq!(nodes[0].node.peering.pending_connects.len(), 1);
assert!(nodes[0].node.pending_outbound.is_empty());
// Drive the tick-side poll until the background connect resolves.
for _ in 0..100 {
nodes[0].node.poll_pending_connects().await;
if nodes[0].node.peering.pending_connects.is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(
nodes[0].node.peering.pending_connects.is_empty(),
"connect should have resolved"
);
// Reaching start_handshake -> prepare_outbound_msg1 tracks the outbound msg1
// in pending_outbound: proof the connect-success path dispatched the handshake.
assert_eq!(
nodes[0].node.pending_outbound.len(),
1,
"connect-success path should have reached start_handshake and dispatched msg1"
);
cleanup_nodes(&mut nodes).await;
}
/// Connection-oriented outbound connect to a closed port fails and tears down
/// the link.
///
/// The dial targets a guaranteed-closed loopback port (bind then drop the
/// listener). `initiate_connection` is non-blocking, so it still queues a
/// `PendingConnect` and a `Connecting` link; `poll_pending_connects` observes the
/// background task's `Failed` result and routes through the failure arm
/// (`remove_link` + `note_handshake_timeout`). The observable is the removed link
/// and the absent `pending_outbound` entry — the failure path never reaches
/// `start_handshake`.
#[tokio::test]
async fn test_tcp_oriented_connect_failure_tears_down_link() {
let mut nodes = vec![make_test_node_tcp().await];
// A guaranteed-closed loopback port: bind to grab a port, then drop it.
let closed = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let closed_addr = closed.local_addr().unwrap();
drop(closed);
let target_addr = TransportAddr::from_string(&closed_addr.to_string());
let target_identity = make_peer_identity();
let transport_id = nodes[0].transport_id;
nodes[0]
.node
.initiate_connection(transport_id, target_addr, target_identity)
.await
.expect("initiate_connection is non-blocking; it queues a pending connect");
// One pending connect + the in-flight dial's link.
assert_eq!(nodes[0].node.peering.pending_connects.len(), 1);
assert_eq!(nodes[0].node.links.len(), 1);
for _ in 0..100 {
nodes[0].node.poll_pending_connects().await;
if nodes[0].node.peering.pending_connects.is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(
nodes[0].node.peering.pending_connects.is_empty(),
"failed connect should have been drained"
);
// Failure path removes the link and never reaches start_handshake.
assert!(
nodes[0].node.links.is_empty(),
"connect-failure path should have torn down the link"
);
assert!(
nodes[0].node.pending_outbound.is_empty(),
"connect-failure path must not dispatch msg1"
);
cleanup_nodes(&mut nodes).await;
}

View File

@@ -2168,6 +2168,71 @@ mod tests {
assert_eq!(m.our_index(), None);
}
// ---- Test 7d: connection-oriented dial opens transport first ----------
// The connection-oriented cutover drives the outbound machine
// Discovered -> (Dial, connection_oriented=true) -> Connecting
// (emitting ONLY OpenTransport, no msg1 yet), then TransportConnected ->
// Handshaking{SentMsg1} with the same SendHandshake + two SetTimer that
// `start_outbound_handshake` emits. Covers the oriented reach into
// `start_outbound_handshake` via `on_transport_connected` (the connectionless
// reach via `on_dial` is already covered by the test above).
#[test]
fn connection_oriented_dial_opens_transport_then_connected_handshakes() {
let mut alloc = IndexAllocator::new();
let peer = peer_identity();
let mut m = PeerMachine::new_outbound(LinkId::new(1), peer, 0);
// Connection-oriented dial: open the transport first, no msg1 yet.
let dial = m.step(
PeerEvent::Dial {
transport_id: TransportId::new(1),
remote_addr: TransportAddr::from_string("127.0.0.1:9999"),
peer_identity: peer,
connection_oriented: true,
},
100,
&mut alloc,
);
assert_eq!(
m.state(),
PeerState::Connecting {
link: LinkId::new(1)
}
);
assert_eq!(
dial,
vec![PeerAction::OpenTransport {
transport_id: TransportId::new(1),
remote_addr: TransportAddr::from_string("127.0.0.1:9999"),
}],
"connection-oriented dial emits exactly one OpenTransport and no msg1"
);
// Transport connected: now send msg1 and arm the handshake timers.
let connected = m.step(PeerEvent::TransportConnected, 200, &mut alloc);
assert!(matches!(
m.state(),
PeerState::Handshaking {
phase: HandshakePhase::SentMsg1,
..
}
));
assert_eq!(
connected,
vec![
PeerAction::SendHandshake { bytes: Vec::new() },
PeerAction::SetTimer {
kind: TimerKind::HandshakeRetransmit,
at_ms: 200 + HANDSHAKE_RETRANSMIT_INTERVAL_MS,
},
PeerAction::SetTimer {
kind: TimerKind::HandshakeTimeout,
at_ms: 200 + HANDSHAKE_TIMEOUT_MS,
},
]
);
}
// ---- Test 8: liveness -> LinkDeadSuspected -> ReportLost --------------
#[test]
fn liveness_to_link_dead() {