diff --git a/src/node/tests/tcp.rs b/src/node/tests/tcp.rs index f120afa..393fb62 100644 --- a/src/node/tests/tcp.rs +++ b/src/node/tests/tcp.rs @@ -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; +} diff --git a/src/peer/machine.rs b/src/peer/machine.rs index 02e1990..5305400 100644 --- a/src/peer/machine.rs +++ b/src/peer/machine.rs @@ -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() {