Skip to content

Commit 6e663f8

Browse files
committed
Keep recovery from closing newer connections
1 parent 5dbe595 commit 6e663f8

3 files changed

Lines changed: 168 additions & 19 deletions

File tree

now.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,33 @@ peer dial and generation exchange. A deterministic red test proved that one
121121
slow peer open blocks an existing healthy peer. Branch
122122
`fix/peer-lock-head-of-line` narrows this serialization to one peer.
123123

124+
PR #172 merged that isolation as `5dbe595`. Its focused red test and all three
125+
GitHub checks passed. The existing restart recovery test then exposed a real
126+
regression. It passed 4 of 4 times on parent `475807e`, 2 of 4 times on the PR
127+
tree, and 0 of 2 times in GitHub CI.
128+
129+
The first race let a failed probe request recovery while the peer connection
130+
was still opening. Recovery waited for the peer gate and then closed the new
131+
successful connection. A second form let the connection enter the map between
132+
the failed probe and recovery. Recovery now closes only a connection that
133+
existed when that failed probe started.
134+
135+
The peer gate also added two asynchronous lock operations to every cached
136+
connection use. A retained failed run kept one live canonical connection on
137+
both peers. The bus archive bytes arrived, but a concurrent older inbox entry
138+
returned before the local rename scan recorded its delete. The cached path now
139+
uses only the connection-map lock. The per-peer gate remains on replacement,
140+
dial, incoming registration, and stream-failure close operations.
141+
142+
The corrected tree passed 4 of 4 serial restart recovery samples. The samples
143+
took 130.36, 129.42, 129.55, and 130.05 seconds. The focused recovery test, all
144+
15 active mux tests, and simultaneous-peer integration also passed.
145+
146+
The original red test proved the lock fix removed cross-peer blocking. It did
147+
not prove the change preserved restart convergence. A focused test can prove
148+
its named behavior while the existing property suite finds a different loss.
149+
Keep both kinds of proof.
150+
124151
The local library suite passed 513 tests with five measurements ignored. The
125152
binary suite passed 19 tests, and the update contract passed eight tests. The
126153
five-second latency property measured 502 records, seven scans, a 10.001166 ms

src/daemon.rs

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1808,11 +1808,17 @@ impl DaemonState {
18081808

18091809
/// Close only the failed peer's cached connection. The next probe opens a new
18101810
/// connection and refreshes discovery without touching any other peer.
1811-
async fn recover_unreachable_peer(&self, peer_id: EndpointId, label: &str, attempt: usize) {
1811+
async fn recover_unreachable_peer(
1812+
&self,
1813+
peer_id: EndpointId,
1814+
label: &str,
1815+
attempt: usize,
1816+
failed_probe_started: Instant,
1817+
) {
18121818
let endpoint = self.endpoint_handle();
18131819
let redialled = self
18141820
.peer_connections
1815-
.redial(peer_id, b"peer health recovery")
1821+
.redial_opened_before(peer_id, b"peer health recovery", failed_probe_started)
18161822
.await;
18171823
warn!(
18181824
target: VALIDATION_LOG_TARGET,
@@ -2760,9 +2766,10 @@ async fn run_peer_health_loop_with(
27602766
"recent application traffic proved peer liveness"
27612767
),
27622768
}
2763-
round.push((peer_id, label, true, peer.roaming));
2769+
round.push((peer_id, label, true, peer.roaming, Instant::now()));
27642770
continue;
27652771
}
2772+
let probe_started = Instant::now();
27662773
let health = state.check_peer_reachability(&peer).await;
27672774
match peer_probe_log_action(
27682775
&mut roaming_away,
@@ -2852,14 +2859,20 @@ async fn run_peer_health_loop_with(
28522859
"persistent path degradation closed the shared peer connection"
28532860
);
28542861
}
2855-
round.push((peer_id, label, health.reachable, peer.roaming));
2862+
round.push((
2863+
peer_id,
2864+
label,
2865+
health.reachable,
2866+
peer.roaming,
2867+
probe_started,
2868+
));
28562869
}
2857-
for (peer_id, label, reachable, roaming) in round {
2870+
for (peer_id, label, reachable, roaming, probe_started) in round {
28582871
if let PeerHealthAction::Recover { attempt } =
28592872
tracker.on_probe(peer_id, reachable, roaming, Instant::now())
28602873
{
28612874
state
2862-
.recover_unreachable_peer(peer_id, &label, attempt)
2875+
.recover_unreachable_peer(peer_id, &label, attempt, probe_started)
28632876
.await;
28642877
}
28652878
}
@@ -5914,14 +5927,18 @@ mod tests {
59145927
let bluey = iroh::SecretKey::generate().public();
59155928
let before = state.endpoint_handle().generation;
59165929

5917-
state.recover_unreachable_peer(bluey, "bluey", 3).await;
5930+
state
5931+
.recover_unreachable_peer(bluey, "bluey", 3, Instant::now())
5932+
.await;
59185933
assert_eq!(
59195934
state.endpoint_handle().generation,
59205935
before,
59215936
"one peer's absence must never replace the shared endpoint"
59225937
);
59235938

5924-
state.recover_unreachable_peer(bluey, "bluey", 100).await;
5939+
state
5940+
.recover_unreachable_peer(bluey, "bluey", 100, Instant::now())
5941+
.await;
59255942
assert_eq!(state.endpoint_handle().generation, before);
59265943

59275944
node.shutdown().await?;
@@ -5956,7 +5973,7 @@ mod tests {
59565973
};
59575974
assert_eq!(attempt, expected_attempt);
59585975
state
5959-
.recover_unreachable_peer(bluey, "bluey", attempt)
5976+
.recover_unreachable_peer(bluey, "bluey", attempt, now)
59605977
.await;
59615978
}
59625979

@@ -5972,7 +5989,7 @@ mod tests {
59725989
};
59735990
assert_eq!(attempt, OLD_RECYCLE_ATTEMPT);
59745991
state
5975-
.recover_unreachable_peer(bluey, "bluey", attempt)
5992+
.recover_unreachable_peer(bluey, "bluey", attempt, now)
59765993
.await;
59775994

59785995
assert_eq!(
@@ -6971,7 +6988,7 @@ mod tests {
69716988
let absent = iroh::SecretKey::generate().public();
69726989
let drops_before = *state.tunnel_drop_tx.borrow();
69736990
state
6974-
.recover_unreachable_peer(absent, "absent-peer", 100)
6991+
.recover_unreachable_peer(absent, "absent-peer", 100, Instant::now())
69756992
.await;
69766993
let drops_after = *state.tunnel_drop_tx.borrow();
69776994
assert_eq!(drops_after, drops_before);
@@ -7562,7 +7579,12 @@ mod tests {
75627579
.await;
75637580
// One peer away while the other answers: cheap recovery, no teardown.
75647581
state
7565-
.recover_unreachable_peer(iroh::SecretKey::generate().public(), "absent", 3)
7582+
.recover_unreachable_peer(
7583+
iroh::SecretKey::generate().public(),
7584+
"absent",
7585+
3,
7586+
Instant::now(),
7587+
)
75667588
.await;
75677589
// And a recycle attempt, which is what a failed health poll ends in.
75687590
let outcome = state

src/mux.rs

Lines changed: 107 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,24 @@ impl PeerConnections {
643643
protocol: &str,
644644
_activity: StreamActivity,
645645
) -> Result<Option<Connection>> {
646+
{
647+
let conns = self.conns.lock().await;
648+
if let Some(existing) = conns.get(&peer_addr.id)
649+
&& existing.generation == generation
650+
&& existing.connection.close_reason().is_none()
651+
{
652+
return Ok(Some(existing.connection.clone()));
653+
}
654+
if let Some(existing) = conns.get(&peer_addr.id)
655+
&& existing.generation > generation
656+
{
657+
bail!(
658+
"endpoint generation changed before mux open: requested {generation}, current {}",
659+
existing.generation
660+
);
661+
}
662+
}
663+
646664
let peer_gate = self.peer_gate(peer_addr.id).await;
647665
let _peer_guard = peer_gate.lock().await;
648666
let mut conns = self.conns.lock().await;
@@ -731,14 +749,29 @@ impl PeerConnections {
731749

732750
/// Make one peer use a fresh multipath connection on its next stream.
733751
pub async fn redial(&self, peer: EndpointId, reason: &[u8]) -> bool {
734-
let peer_gate = self.peer_gate(peer).await;
735-
let _peer_guard = peer_gate.lock().await;
736-
let removed = self.conns.lock().await.remove(&peer);
737-
if let Some(removed) = removed {
738-
removed.connection.close(0u32.into(), reason);
739-
return true;
752+
self.redial_opened_before(peer, reason, Instant::now()).await
753+
}
754+
755+
/// Close only a connection that existed before the failed probe started.
756+
pub async fn redial_opened_before(
757+
&self,
758+
peer: EndpointId,
759+
reason: &[u8],
760+
probe_started: Instant,
761+
) -> bool {
762+
let mut connections = self.conns.lock().await;
763+
let Some(current) = connections.get(&peer) else {
764+
return false;
765+
};
766+
if current.opened_at > probe_started {
767+
return false;
740768
}
741-
false
769+
let removed = connections
770+
.remove(&peer)
771+
.expect("the matched mux connection disappeared while locked");
772+
drop(connections);
773+
removed.connection.close(0u32.into(), reason);
774+
true
742775
}
743776

744777
/// Cache an accepted peer connection for traffic in the reverse direction.
@@ -1180,6 +1213,73 @@ mod tests {
11801213
Ok(())
11811214
}
11821215

1216+
/// Recovery must not close a connection that opened after the failed probe.
1217+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1218+
async fn recovery_does_not_close_a_connection_opened_after_the_probe() -> Result<()> {
1219+
let client = Endpoint::builder(presets::N0).bind().await?;
1220+
let (opened_tx, _opened_rx) = mpsc::unbounded_channel();
1221+
let manager = Arc::new(PeerConnections::new(client.id(), opened_tx));
1222+
let entered = Arc::new(tokio::sync::Barrier::new(2));
1223+
let release = Arc::new(tokio::sync::Notify::new());
1224+
let slow = Router::builder(
1225+
Endpoint::builder(presets::N0)
1226+
.alpns(vec![MUX_ALPN.to_vec()])
1227+
.bind()
1228+
.await?,
1229+
)
1230+
.accept(
1231+
MUX_ALPN,
1232+
BlockedGenerationMuxEcho {
1233+
entered: entered.clone(),
1234+
release: release.clone(),
1235+
},
1236+
)
1237+
.spawn();
1238+
slow.endpoint().online().await;
1239+
1240+
let peer = slow.endpoint().id();
1241+
let failed_probe_started = Instant::now();
1242+
let slow_manager = manager.clone();
1243+
let slow_client = client.clone();
1244+
let slow_addr = slow.endpoint().addr();
1245+
let slow_open = tokio::spawn(async move {
1246+
slow_manager
1247+
.open_mux_stream(&slow_client, 0, &slow_addr, "slow", StreamActivity::Probe)
1248+
.await
1249+
});
1250+
tokio::time::timeout(Duration::from_secs(3), entered.wait())
1251+
.await
1252+
.context("the slow peer did not reach generation exchange")?;
1253+
1254+
release.notify_one();
1255+
let stream = slow_open.await??;
1256+
let stable_id = stream.connection.stable_id();
1257+
drop(stream);
1258+
let recovery = manager
1259+
.redial_opened_before(peer, b"peer health recovery", failed_probe_started)
1260+
.await;
1261+
1262+
assert_eq!(
1263+
recovery,
1264+
false,
1265+
"recovery closed a connection that opened after the failed probe started"
1266+
);
1267+
assert_eq!(
1268+
manager.connection(peer).await.map(|conn| conn.stable_id()),
1269+
Some(stable_id),
1270+
"recovery closed the connection that completed after it started"
1271+
);
1272+
assert!(
1273+
manager.redial(peer, b"current connection recovery").await,
1274+
"recovery did not close the connection present when it started"
1275+
);
1276+
assert!(manager.connection(peer).await.is_none());
1277+
1278+
slow.shutdown().await?;
1279+
client.close().await;
1280+
Ok(())
1281+
}
1282+
11831283
async fn assert_stream_failure_recovery(
11841284
failures: usize,
11851285
expect_same_connection: bool,

0 commit comments

Comments
 (0)