File
Blob: firmware/vendor/str0m/tests/dtls-retransmit.rs
| 1 | //! Regression test for the DTLS retransmit bug fixed in PR #943 (refs #932). |
| 2 | //! |
| 3 | //! When a DTLS flight timeout elapses, dimpl's `handle_timeout` re-arms the |
| 4 | //! flight timer and queues the retransmit packet. Previously str0m ran |
| 5 | //! `handle_timeout` at the end of `do_poll_output`, after both the DTLS |
| 6 | //! `poll_output` loop and `dtls.poll_packet()` had already run — so the |
| 7 | //! freshly queued retransmit was left sitting in dimpl's tx queue until |
| 8 | //! some unrelated subsystem (typically an ICE consent check) woke the |
| 9 | //! caller for another poll pass. |
| 10 | //! |
| 11 | //! This test drives two peers to the mid-handshake state (L has sent |
| 12 | //! ClientHello, R ignores DTLS so never replies), advances L's clock past |
| 13 | //! the flight deadline with a single `handle_input(Timeout)`, then asserts |
| 14 | //! that a single `poll_output` pass emits the retransmit as a `Transmit`. |
| 15 | //! Without the fix, the pass instead returns `Timeout` and the retransmit |
| 16 | //! is stranded. |
| 17 | //! |
| 18 | //! The scenario asserted here is specific to dimpl's tx-queue semantics, |
| 19 | //! so the test is compiled only under dimpl-backed crypto providers. |
| 20 | #![cfg(any( |
| 21 | feature = "aws-lc-rs", |
| 22 | feature = "rust-crypto", |
| 23 | feature = "openssl-dimpl", |
| 24 | feature = "wincrypto-dimpl", |
| 25 | feature = "apple-crypto", |
| 26 | ))] |
| 27 | use std::net::Ipv4Addr; |
| 28 | use std::time::{Duration, Instant}; |
| 29 | |
| 30 | use netem::{NetemConfig, Probability, RandomLoss}; |
| 31 | use str0m::RtcError; |
| 32 | use str0m::config::DtlsVersion; |
| 33 | use str0m::{Candidate, Input, Output, Reason, Rtc}; |
| 34 | use tracing::info_span; |
| 35 | |
| 36 | mod common; |
| 37 | use common::{TestRtc, init_crypto_default, progress}; |
| 38 | |
| 39 | #[test] |
| 40 | fn dtls_retransmit_emitted_in_same_poll_pass() { |
| 41 | init_crypto_default(); |
| 42 | |
| 43 | let now = Instant::now(); |
| 44 | let mut l = TestRtc::new_with_rtc(info_span!("L"), Rtc::new(now)); |
| 45 | let mut r = TestRtc::new_with_rtc(info_span!("R"), Rtc::new(now)); |
| 46 | |
| 47 | let host_l = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp").unwrap(); |
| 48 | let host_r = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp").unwrap(); |
| 49 | l.add_local_candidate(host_l.clone()); |
| 50 | l.add_remote_candidate(host_r.clone()); |
| 51 | r.add_local_candidate(host_r); |
| 52 | r.add_remote_candidate(host_l); |
| 53 | |
| 54 | let finger_l = l.direct_api().local_dtls_fingerprint().clone(); |
| 55 | let finger_r = r.direct_api().local_dtls_fingerprint().clone(); |
| 56 | l.direct_api().set_remote_fingerprint(finger_r); |
| 57 | r.direct_api().set_remote_fingerprint(finger_l); |
| 58 | |
| 59 | let creds_l = l.direct_api().local_ice_credentials(); |
| 60 | let creds_r = r.direct_api().local_ice_credentials(); |
| 61 | l.direct_api().set_remote_ice_credentials(creds_r); |
| 62 | r.direct_api().set_remote_ice_credentials(creds_l); |
| 63 | |
| 64 | l.direct_api().set_ice_controlling(true); |
| 65 | r.direct_api().set_ice_controlling(false); |
| 66 | |
| 67 | // Only L starts DTLS. R receives L's ClientHello but ignores it |
| 68 | // (active_state is None in dtls::handle_receive), so L never gets a |
| 69 | // ServerHello and must retransmit. |
| 70 | l.direct_api().start_dtls(true).unwrap(); |
| 71 | |
| 72 | // Drive both sides until L is awaiting the DTLS flight deadline. |
| 73 | let mut steps = 0; |
| 74 | while l.rtc.last_timeout_reason() != Reason::DTLS { |
| 75 | progress(&mut l, &mut r).unwrap(); |
| 76 | steps += 1; |
| 77 | assert!( |
| 78 | steps < 200, |
| 79 | "failed to reach DTLS-awaiting state (last reason: {:?})", |
| 80 | l.rtc.last_timeout_reason() |
| 81 | ); |
| 82 | } |
| 83 | |
| 84 | // Advance past the flight deadline in one step with no network input. |
| 85 | // The fix causes the following poll_output pass to emit the queued |
| 86 | // retransmit. Without the fix, it returns Output::Timeout and the |
| 87 | // retransmit stays stranded in dimpl. |
| 88 | let deadline = l.last + Duration::from_millis(1500); |
| 89 | l.rtc.handle_input(Input::Timeout(deadline)).unwrap(); |
| 90 | |
| 91 | let mut got_transmit = false; |
| 92 | loop { |
| 93 | match l.rtc.poll_output().unwrap() { |
| 94 | Output::Timeout(_) => break, |
| 95 | Output::Transmit(_) => { |
| 96 | got_transmit = true; |
| 97 | break; |
| 98 | } |
| 99 | Output::Event(_) => continue, |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | assert!( |
| 104 | got_transmit, |
| 105 | "poll_output should emit the DTLS retransmit in the same pass that runs handle_timeout" |
| 106 | ); |
| 107 | } |
| 108 | |
| 109 | /// A terminal dimpl handshake timeout must be returned and must leave the |
| 110 | /// `Rtc` inert rather than continually returning the expired DTLS deadline. |
| 111 | /// |
| 112 | /// This runs for every dimpl-backed crypto provider. It drives the scheduler |
| 113 | /// with the exact timeout returned by `Rtc`, which is the behavior that |
| 114 | /// previously caused an immediate-wake loop after dimpl had exhausted its |
| 115 | /// retry budget. |
| 116 | #[test] |
| 117 | fn terminal_dtls_12_to_auto_timeout_does_not_rearm_expired_deadline() { |
| 118 | terminal_dtls_timeout_does_not_rearm_expired_deadline_for( |
| 119 | DtlsVersion::Dtls12, |
| 120 | DtlsVersion::Auto, |
| 121 | ); |
| 122 | } |
| 123 | |
| 124 | #[test] |
| 125 | fn terminal_dtls_13_to_auto_timeout_does_not_rearm_expired_deadline() { |
| 126 | terminal_dtls_timeout_does_not_rearm_expired_deadline_for( |
| 127 | DtlsVersion::Dtls13, |
| 128 | DtlsVersion::Auto, |
| 129 | ); |
| 130 | } |
| 131 | |
| 132 | #[test] |
| 133 | fn terminal_dtls_auto_to_auto_timeout_does_not_rearm_expired_deadline() { |
| 134 | terminal_dtls_timeout_does_not_rearm_expired_deadline_for(DtlsVersion::Auto, DtlsVersion::Auto); |
| 135 | } |
| 136 | |
| 137 | #[test] |
| 138 | fn terminal_dtls_12_to_12_timeout_does_not_rearm_expired_deadline() { |
| 139 | terminal_dtls_timeout_does_not_rearm_expired_deadline_for( |
| 140 | DtlsVersion::Dtls12, |
| 141 | DtlsVersion::Dtls12, |
| 142 | ); |
| 143 | } |
| 144 | |
| 145 | #[test] |
| 146 | fn terminal_dtls_13_to_13_timeout_does_not_rearm_expired_deadline() { |
| 147 | terminal_dtls_timeout_does_not_rearm_expired_deadline_for( |
| 148 | DtlsVersion::Dtls13, |
| 149 | DtlsVersion::Dtls13, |
| 150 | ); |
| 151 | } |
| 152 | |
| 153 | fn terminal_dtls_timeout_does_not_rearm_expired_deadline_for( |
| 154 | client_dtls: DtlsVersion, |
| 155 | server_dtls: DtlsVersion, |
| 156 | ) { |
| 157 | init_crypto_default(); |
| 158 | |
| 159 | let now = Instant::now(); |
| 160 | let left_rtc = Rtc::builder().set_dtls_version(client_dtls).build(now); |
| 161 | let right_rtc = Rtc::builder().set_dtls_version(server_dtls).build(now); |
| 162 | let mut left = TestRtc::new_with_rtc(info_span!("L"), left_rtc); |
| 163 | let mut right = TestRtc::new_with_rtc(info_span!("R"), right_rtc); |
| 164 | |
| 165 | // Responses from the passive peer are discarded after it receives the |
| 166 | // client's first flight, so the active peer retries until dimpl times out. |
| 167 | left.set_netem( |
| 168 | NetemConfig::new() |
| 169 | .loss(RandomLoss::new(Probability::new(1.0))) |
| 170 | .seed(1), |
| 171 | ); |
| 172 | |
| 173 | let host_left = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp").unwrap(); |
| 174 | let host_right = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp").unwrap(); |
| 175 | left.add_local_candidate(host_left.clone()); |
| 176 | left.add_remote_candidate(host_right.clone()); |
| 177 | right.add_local_candidate(host_right); |
| 178 | right.add_remote_candidate(host_left); |
| 179 | |
| 180 | let fingerprint_left = left.direct_api().local_dtls_fingerprint().clone(); |
| 181 | let fingerprint_right = right.direct_api().local_dtls_fingerprint().clone(); |
| 182 | left.direct_api().set_remote_fingerprint(fingerprint_right); |
| 183 | right.direct_api().set_remote_fingerprint(fingerprint_left); |
| 184 | |
| 185 | let credentials_left = left.direct_api().local_ice_credentials(); |
| 186 | let credentials_right = right.direct_api().local_ice_credentials(); |
| 187 | left.direct_api() |
| 188 | .set_remote_ice_credentials(credentials_right); |
| 189 | right |
| 190 | .direct_api() |
| 191 | .set_remote_ice_credentials(credentials_left); |
| 192 | |
| 193 | left.direct_api().set_ice_controlling(true); |
| 194 | right.direct_api().set_ice_controlling(false); |
| 195 | left.direct_api().start_dtls(true).unwrap(); |
| 196 | right.direct_api().start_dtls(false).unwrap(); |
| 197 | |
| 198 | // Let the first flight reach the passive peer and its response be dropped. |
| 199 | let mut setup_steps = 0; |
| 200 | while left.rtc.last_timeout_reason() != Reason::DTLS { |
| 201 | progress(&mut left, &mut right).unwrap(); |
| 202 | setup_steps += 1; |
| 203 | assert!( |
| 204 | setup_steps < 200, |
| 205 | "failed to reach a DTLS flight deadline (last reason: {:?})", |
| 206 | left.rtc.last_timeout_reason() |
| 207 | ); |
| 208 | } |
| 209 | |
| 210 | // Unlike TestRtc::progress, preserve an immediate timeout exactly. This |
| 211 | // models a scheduler that runs work scheduled for now without adding a |
| 212 | // retry delay of its own. |
| 213 | let mut last_now = left.last; |
| 214 | let mut terminal_error = None; |
| 215 | for _ in 0..1_000 { |
| 216 | left.rtc.handle_input(Input::Timeout(last_now)).unwrap(); |
| 217 | |
| 218 | loop { |
| 219 | match left.rtc.poll_output() { |
| 220 | Err(RtcError::Dtls(error)) => { |
| 221 | terminal_error = Some(error); |
| 222 | break; |
| 223 | } |
| 224 | Err(error) => panic!("unexpected RTC error: {error}"), |
| 225 | Ok(Output::Timeout(timeout)) => { |
| 226 | last_now = timeout; |
| 227 | break; |
| 228 | } |
| 229 | Ok(Output::Transmit(_)) => { |
| 230 | // Drop each retransmitted DTLS flight. |
| 231 | } |
| 232 | Ok(Output::Event(_)) => { |
| 233 | // ICE may report its disconnected state before DTLS gives up. |
| 234 | } |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | if terminal_error.is_some() { |
| 239 | break; |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | assert!( |
| 244 | terminal_error.is_some(), |
| 245 | "terminal dimpl timeout was not returned within the bounded scheduler run" |
| 246 | ); |
| 247 | assert!( |
| 248 | !left.rtc.is_alive(), |
| 249 | "terminal DTLS error must close the RTC" |
| 250 | ); |
| 251 | |
| 252 | // After the error, callers that continue polling and feeding timeout input |
| 253 | // must not receive another due timeout, packet, or event. |
| 254 | let terminal_last_now = last_now; |
| 255 | for _ in 0..16 { |
| 256 | left.rtc |
| 257 | .handle_input(Input::Timeout(terminal_last_now)) |
| 258 | .unwrap(); |
| 259 | |
| 260 | match left.rtc.poll_output().unwrap() { |
| 261 | Output::Timeout(timeout) => { |
| 262 | assert!( |
| 263 | timeout > terminal_last_now, |
| 264 | "closed RTC returned a timeout at or before its last input" |
| 265 | ); |
| 266 | } |
| 267 | Output::Transmit(_) => panic!("closed RTC produced a DTLS transmission"), |
| 268 | Output::Event(event) => panic!("closed RTC produced an event: {event:?}"), |
| 269 | } |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | /// End-to-end: a DTLS handshake between two peers must complete even |
| 274 | /// when the link drops packets. Before the fix, dropped handshake |
| 275 | /// packets stalled for seconds at a time because each lost flight's |
| 276 | /// retransmit was deferred to the next unrelated wake-up — on a lossy |
| 277 | /// link the handshake could miss its connect deadline entirely. |
| 278 | #[test] |
| 279 | fn dtls_handshake_completes_under_packet_loss() { |
| 280 | init_crypto_default(); |
| 281 | |
| 282 | let now = Instant::now(); |
| 283 | let mut l = TestRtc::new_with_rtc(info_span!("L"), Rtc::new(now)); |
| 284 | let mut r = TestRtc::new_with_rtc(info_span!("R"), Rtc::new(now)); |
| 285 | |
| 286 | // 30% random loss in both directions from the very first packet. |
| 287 | let lossy = NetemConfig::new() |
| 288 | .loss(RandomLoss::new(Probability::new(0.3))) |
| 289 | .seed(1); |
| 290 | l.set_netem(lossy.clone()); |
| 291 | r.set_netem(lossy); |
| 292 | |
| 293 | let host_l = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp").unwrap(); |
| 294 | let host_r = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp").unwrap(); |
| 295 | l.add_local_candidate(host_l.clone()); |
| 296 | l.add_remote_candidate(host_r.clone()); |
| 297 | r.add_local_candidate(host_r); |
| 298 | r.add_remote_candidate(host_l); |
| 299 | |
| 300 | let finger_l = l.direct_api().local_dtls_fingerprint().clone(); |
| 301 | let finger_r = r.direct_api().local_dtls_fingerprint().clone(); |
| 302 | l.direct_api().set_remote_fingerprint(finger_r); |
| 303 | r.direct_api().set_remote_fingerprint(finger_l); |
| 304 | |
| 305 | let creds_l = l.direct_api().local_ice_credentials(); |
| 306 | let creds_r = r.direct_api().local_ice_credentials(); |
| 307 | l.direct_api().set_remote_ice_credentials(creds_r); |
| 308 | r.direct_api().set_remote_ice_credentials(creds_l); |
| 309 | |
| 310 | l.direct_api().set_ice_controlling(true); |
| 311 | r.direct_api().set_ice_controlling(false); |
| 312 | |
| 313 | l.direct_api().start_dtls(true).unwrap(); |
| 314 | r.direct_api().start_dtls(false).unwrap(); |
| 315 | l.direct_api().start_sctp(true); |
| 316 | r.direct_api().start_sctp(false); |
| 317 | |
| 318 | // Drive both sides forward. The DTLS default connect timeout is 10s, |
| 319 | // so a bounded iteration cap that allows well past that is enough to |
| 320 | // tell whether the handshake is making progress at all. |
| 321 | let mut iterations = 0; |
| 322 | while !(l.is_connected() && r.is_connected()) { |
| 323 | progress(&mut l, &mut r).unwrap(); |
| 324 | iterations += 1; |
| 325 | assert!( |
| 326 | iterations < 5000, |
| 327 | "DTLS handshake did not complete under 30% packet loss \ |
| 328 | (l_connected={}, r_connected={})", |
| 329 | l.is_connected(), |
| 330 | r.is_connected() |
| 331 | ); |
| 332 | } |
| 333 | } |