From 7768d5caf6545bb705dac1175c3229d891c8f042 Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Mon, 10 Aug 2026 03:46:33 -0500 Subject: [PATCH] feat(server): measure and report network characteristics The server measures round-trip time and bandwidth from the continuous auto-detect exchange and reports both to the client in a Network Characteristics Result on the MCS message channel, per [MS-RDPBCGR] 2.2.14.1.5. Nothing is sent until both figures exist. A result carrying RTT alone reports part of the picture as though it were the whole, which is what krdp and grd avoid by returning early when they have no bandwidth measurement. The result is paced at one per second on its own clock rather than once per probe, since the caller sets the probe cadence and the reported figures change far more slowly. It is also withheld unless a client response has arrived since the last result: samples never age out of the window, so a client that stops answering would otherwise leave the last values advertised indefinitely. snapshot() still reports them, where freshness stays the embedder's to judge. baseRTT is the lowest RTT seen over the session rather than the lowest in the sliding window. 2.2.14.1.5 defines it as "the lowest detected round-trip time" with no window scoping, and a floor that rises makes the averageRTT minus baseRTT difference meaningless as queueing delay. Tests live in ironrdp-testsuite-core. ironrdp-server sets [lib] test = false, so an inline module would compile and never run. --- crates/ironrdp-pdu/src/rdp/autodetect.rs | 30 ++ crates/ironrdp-server/src/autodetect.rs | 189 ++++++++++-- crates/ironrdp-server/src/server.rs | 21 ++ .../tests/server/autodetect.rs | 286 +++++++++++++++++- 4 files changed, 503 insertions(+), 23 deletions(-) diff --git a/crates/ironrdp-pdu/src/rdp/autodetect.rs b/crates/ironrdp-pdu/src/rdp/autodetect.rs index ad81f02f3..1987ae4d9 100644 --- a/crates/ironrdp-pdu/src/rdp/autodetect.rs +++ b/crates/ironrdp-pdu/src/rdp/autodetect.rs @@ -956,6 +956,15 @@ mod tests { 0x14, 0x00, 0x00, 0x00, // averageRTT = 20 ]; + const NETCHAR_RTT_WIRE: &[u8] = &[ + 0x0E, // headerLength + 0x00, // headerTypeId + 0x07, 0x00, // sequenceNumber = 7 + 0x40, 0x08, // requestType = NETCHAR_RESULT_RTT (0x0840) + 0x08, 0x00, 0x00, 0x00, // baseRTT = 8 + 0x12, 0x00, 0x00, 0x00, // averageRTT = 18 + ]; + #[test] fn decode_rtt_request() { let pdu = ironrdp_core::decode::(RTT_REQUEST_WIRE).unwrap(); @@ -1098,6 +1107,27 @@ mod tests { assert_eq!(encoded.as_slice(), NETCHAR_ALL_WIRE); } + #[test] + fn decode_netchar_rtt() { + let pdu = ironrdp_core::decode::(NETCHAR_RTT_WIRE).unwrap(); + match pdu { + AutoDetectRequest::NetworkCharacteristicsResult { + sequence_number, + request_type, + base_rtt_ms, + bandwidth_kbps, + average_rtt_ms, + } => { + assert_eq!(sequence_number, 7); + assert_eq!(request_type, NETCHAR_RESULT_RTT); + assert_eq!(base_rtt_ms, Some(8)); + assert_eq!(bandwidth_kbps, None); + assert_eq!(average_rtt_ms, 18); + } + other => panic!("expected NetworkCharacteristicsResult, got {other:?}"), + } + } + #[test] fn request_round_trip() { let cases = vec![ diff --git a/crates/ironrdp-server/src/autodetect.rs b/crates/ironrdp-server/src/autodetect.rs index cf18bd4c7..cb627d9fb 100644 --- a/crates/ironrdp-server/src/autodetect.rs +++ b/crates/ironrdp-server/src/autodetect.rs @@ -16,6 +16,24 @@ const RTT_WINDOW_SIZE: usize = 8; /// Probes older than this are discarded as unresponsive. pub(crate) const RTT_PROBE_MAX_AGE_MS: u64 = 30_000; +/// Run a bandwidth measurement once every this many RTT ticks. Bandwidth +/// changes far more slowly than RTT and each measurement sends a payload, so it +/// is sampled less often than the per-tick RTT probe. +const BW_MEASURE_INTERVAL_TICKS: u32 = 8; + +/// Minimum spacing between Network Characteristics Result PDUs. +/// +/// The reported figures are averaged over a window and change far more slowly +/// than the RTT probe cadence, so pacing the result independently keeps an +/// aggressive probe rate from turning into an equally aggressive stream of +/// unsolicited PDUs. +const NETCHAR_RESULT_MIN_INTERVAL_MS: u64 = 1_000; + +/// Size of the synthetic Bandwidth Measure Payload, in bytes. Large enough that +/// the client's measured time delta is meaningful on a real link, small enough +/// not to be a noticeable periodic cost. +const BW_PAYLOAD_LEN: usize = 8192; + /// Server-side auto-detect state machine. /// /// Tracks outstanding RTT probes and computes round-trip statistics from @@ -35,6 +53,30 @@ pub struct AutoDetectManager { /// Outstanding probes as `(sequence_number, sent_at_ms)`. pending_probes: Vec<(u16, u64)>, rtt_samples: VecDeque, + /// Sequence number of an in-flight bandwidth measurement, if any. A new + /// measurement is not started while one is outstanding. + pending_bw: Option, + /// Most recently measured bandwidth in kilobits per second. + bandwidth_kbps: Option, + /// Counts RTT ticks to pace bandwidth measurements (see [`BW_MEASURE_INTERVAL_TICKS`]). + bw_tick_count: u32, + /// When the last Network Characteristics Result was built, for pacing. + last_netchar_result_ms: Option, + /// Lowest RTT observed over the whole session, for the wire `baseRTT`. + /// + /// [MS-RDPBCGR] 2.2.14.1.5 defines that field as "the lowest detected + /// round-trip time" with no window scoping, so it is tracked separately from + /// the sliding window behind [`snapshot()`](Self::snapshot). A floor derived + /// from the window can rise once a low sample is evicted, which would make + /// the baseRTT / averageRTT pair unusable for the queueing-delay difference + /// a client computes from it. + min_rtt_ms: Option, + /// Set when a client response updates the measured figures, cleared when a + /// Network Characteristics Result reports them. + /// + /// Without it a client that stops answering leaves the last window values + /// being advertised indefinitely, since they never age out of `rtt_samples`. + measurement_is_fresh: bool, } impl AutoDetectManager { @@ -43,14 +85,21 @@ impl AutoDetectManager { next_sequence: 0, pending_probes: Vec::new(), rtt_samples: VecDeque::with_capacity(RTT_WINDOW_SIZE), + pending_bw: None, + bandwidth_kbps: None, + bw_tick_count: 0, + last_netchar_result_ms: None, + measurement_is_fresh: false, + min_rtt_ms: None, } } /// Generate an RTT Measure Request PDU for continuous detection. /// - /// The caller must encode and send the returned [`AutoDetectRequest`] as - /// a Share Data PDU on the IO channel. `now_ms` is recorded as the send time and - /// is what [`handle_response()`](Self::handle_response) measures against. + /// The caller must encode and send the returned [`AutoDetectRequest`] on + /// the MCS message channel, framed by a `SEC_AUTODETECT_REQ` security + /// header ([MS-RDPBCGR] 2.2.14.3). `now_ms` is recorded as the send time + /// and is what [`handle_response()`](Self::handle_response) measures against. pub fn send_rtt_request(&mut self, now_ms: u64) -> AutoDetectRequest { let seq = self.next_sequence; self.next_sequence = seq.wrapping_add(1); @@ -58,30 +107,123 @@ impl AutoDetectManager { AutoDetectRequest::rtt_continuous(seq) } - /// Process an RTT Measure Response from the client. + /// Build a Bandwidth Measure transaction (Start → Payload → Stop) when one + /// is due, or `None` otherwise. /// - /// Returns the measured RTT in milliseconds if the sequence number - /// matches an outstanding probe, or `None` if it was unexpected. - /// `now_ms` is the receipt time on the same clock passed to - /// [`send_rtt_request()`](Self::send_rtt_request). - pub fn handle_response(&mut self, response: &AutoDetectResponse, now_ms: u64) -> Option { - let AutoDetectResponse::RttResponse { sequence_number } = response else { + /// Paced to one measurement per [`BW_MEASURE_INTERVAL_TICKS`] calls and + /// suppressed while a prior measurement is still outstanding. The three PDUs + /// must be sent back-to-back on the MCS message channel; the client counts + /// the bytes received between Start and Stop and replies with a Bandwidth + /// Measure Results PDU, processed by [`handle_response()`](Self::handle_response). + pub fn build_bandwidth_measure(&mut self) -> Option<[AutoDetectRequest; 3]> { + self.bw_tick_count = self.bw_tick_count.wrapping_add(1); + if self.pending_bw.is_some() || !self.bw_tick_count.is_multiple_of(BW_MEASURE_INTERVAL_TICKS) { return None; - }; + } + let seq = self.next_sequence; + self.next_sequence = seq.wrapping_add(1); + self.pending_bw = Some(seq); + Some([ + AutoDetectRequest::bw_start_continuous(seq), + AutoDetectRequest::bw_payload(seq, vec![0u8; BW_PAYLOAD_LEN]), + AutoDetectRequest::bw_stop_continuous(seq), + ]) + } - let idx = self.pending_probes.iter().position(|(s, _)| *s == *sequence_number)?; - let (_, sent_at_ms) = self.pending_probes.remove(idx); + /// Build a Network Characteristics Result reporting the measured network. + /// + /// Returns `None` until the network has actually been characterised, which + /// means both an RTT sample and a completed bandwidth measurement. A result + /// carrying RTT alone reports part of the picture as though it were the + /// whole, so it is not sent; the same is true of the reference + /// implementations, which return early with no bandwidth figure to report. + /// + /// Also returns `None` when one was sent less than + /// [`NETCHAR_RESULT_MIN_INTERVAL_MS`] ago. The RTT probe cadence is set by + /// the caller and can be far faster than the rate at which the reported + /// figures meaningfully change, so the result is paced independently rather + /// than emitted once per probe. + /// + /// Finally, returns `None` unless a client response has updated the figures + /// since the last result. Samples do not age out of the window, so a client + /// that stops answering would otherwise leave the last values being + /// advertised forever. `snapshot()` still reports them, where staleness is + /// the embedder's to judge, but they are not put on the wire as a current + /// claim. + /// + /// `baseRTT` is the lowest RTT seen over the whole session, not the lowest in + /// the current window, so it never rises. `averageRTT` is the window average + /// and does track current conditions; the difference between the two is what + /// a client reads as queueing delay. + /// + /// Like [`send_rtt_request()`](Self::send_rtt_request), the caller sends the + /// returned PDU on the MCS message channel. The client does not reply to it. + pub fn build_netchar_result(&mut self, now_ms: u64) -> Option { + if !self.measurement_is_fresh { + return None; + } + let bandwidth_kbps = self.bandwidth_kbps?; + let base_rtt_ms = self.min_rtt_ms?; + let snapshot = self.snapshot()?; + + if let Some(last) = self.last_netchar_result_ms { + if now_ms.saturating_sub(last) < NETCHAR_RESULT_MIN_INTERVAL_MS { + return None; + } + } + self.last_netchar_result_ms = Some(now_ms); + self.measurement_is_fresh = false; - // Saturating rather than wrapping: a caller whose clock went backwards gets a - // zero sample, not a nonsense one near u32::MAX. - let rtt_ms = u32::try_from(now_ms.saturating_sub(sent_at_ms)).unwrap_or(u32::MAX); + let seq = self.next_sequence; + self.next_sequence = seq.wrapping_add(1); + Some(AutoDetectRequest::netchar_result( + seq, + base_rtt_ms, + bandwidth_kbps, + snapshot.avg_ms, + )) + } - if self.rtt_samples.len() >= RTT_WINDOW_SIZE { - self.rtt_samples.pop_front(); + /// Process an Auto-Detect Response from the client. + /// + /// For an RTT Measure Response, records the sample and returns the measured + /// RTT in milliseconds. For a Bandwidth Measure Results, records the + /// computed bandwidth internally and returns `None`. Returns `None` for an + /// unexpected or unmatched response. + /// + /// `now_ms` is the receipt time on the same clock passed to + /// [`send_rtt_request()`](Self::send_rtt_request). + pub fn handle_response(&mut self, response: &AutoDetectResponse, now_ms: u64) -> Option { + match response { + AutoDetectResponse::RttResponse { sequence_number } => { + let idx = self.pending_probes.iter().position(|(s, _)| *s == *sequence_number)?; + let (_, sent_at_ms) = self.pending_probes.remove(idx); + + // Saturating rather than wrapping: a caller whose clock went backwards gets + // a zero sample, not a nonsense one near u32::MAX. + let rtt_ms = u32::try_from(now_ms.saturating_sub(sent_at_ms)).unwrap_or(u32::MAX); + + if self.rtt_samples.len() >= RTT_WINDOW_SIZE { + self.rtt_samples.pop_front(); + } + self.rtt_samples.push_back(rtt_ms); + self.min_rtt_ms = Some(self.min_rtt_ms.map_or(rtt_ms, |m| m.min(rtt_ms))); + self.measurement_is_fresh = true; + + Some(rtt_ms) + } + AutoDetectResponse::BandwidthMeasureResults { sequence_number, .. } => { + if self.pending_bw == Some(*sequence_number) { + self.pending_bw = None; + if let Some(kbps) = response.computed_bandwidth_kbps() { + self.bandwidth_kbps = Some(kbps); + self.measurement_is_fresh = true; + } + } + None + } + _ => None, } - self.rtt_samples.push_back(rtt_ms); - - Some(rtt_ms) } /// Get current RTT statistics, or `None` if no measurements yet. @@ -132,7 +274,10 @@ impl Default for AutoDetectManager { /// Snapshot of RTT measurement results. #[derive(Debug, Clone, Copy)] pub struct RttSnapshot { - /// Minimum observed RTT in milliseconds. + /// Minimum RTT in milliseconds over the current window. + /// + /// Not the `baseRTT` sent on the wire, which is the session-lifetime lowest + /// per [MS-RDPBCGR] 2.2.14.1.5. This one can rise as low samples age out. pub min_ms: u32, /// Maximum observed RTT in milliseconds. pub max_ms: u32, diff --git a/crates/ironrdp-server/src/server.rs b/crates/ironrdp-server/src/server.rs index 6ce6e54f5..5c0573f1c 100644 --- a/crates/ironrdp-server/src/server.rs +++ b/crates/ironrdp-server/src/server.rs @@ -1468,6 +1468,27 @@ impl RdpServer { let request = ad.send_rtt_request(now_ms); let data = encode_autodetect_request(request, message_channel_id, user_channel_id)?; writer.write_all(&data).await?; + + // Report the measured characteristics to the client + // ([MS-RDPBCGR] 2.2.14.1.5). The client does not reply. Sent only + // once both RTT and bandwidth are known, and paced independently + // of this probe cadence, so a fast caller does not turn into a + // fast stream of unsolicited PDUs. + if let Some(result) = ad.build_netchar_result(now_ms) { + let data = encode_autodetect_request(result, message_channel_id, user_channel_id)?; + writer.write_all(&data).await?; + } + + // Periodically measure bandwidth: Start → Payload → Stop sent + // back-to-back so the client counts only the payload window, then + // replies with a Bandwidth Measure Results PDU. Until one has + // completed there is no characteristics result to send at all. + if let Some(bw_pdus) = ad.build_bandwidth_measure() { + for pdu in bw_pdus { + let data = encode_autodetect_request(pdu, message_channel_id, user_channel_id)?; + writer.write_all(&data).await?; + } + } } } } diff --git a/crates/ironrdp-testsuite-core/tests/server/autodetect.rs b/crates/ironrdp-testsuite-core/tests/server/autodetect.rs index 54d795554..50ab4c1d2 100644 --- a/crates/ironrdp-testsuite-core/tests/server/autodetect.rs +++ b/crates/ironrdp-testsuite-core/tests/server/autodetect.rs @@ -1,4 +1,4 @@ -use ironrdp_pdu::rdp::autodetect::AutoDetectResponse; +use ironrdp_pdu::rdp::autodetect::{AutoDetectRequest, AutoDetectResponse}; use ironrdp_server::autodetect::AutoDetectManager; #[test] @@ -60,6 +60,75 @@ fn snapshot_reflects_measurements() { assert_eq!(snap.avg_ms, 20); } +#[test] +fn netchar_result_none_without_measurements() { + let mut mgr = AutoDetectManager::new(); + assert!( + mgr.build_netchar_result(0).is_none(), + "no result should be produced before the network has been characterised" + ); +} + +#[test] +fn netchar_result_withheld_until_bandwidth_is_known() { + let mut mgr = AutoDetectManager::new(); + + for _ in 0..3 { + let req = mgr.send_rtt_request(0); + let response = AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }; + let _ = mgr.handle_response(&response, 20); + } + + assert!(mgr.snapshot().is_some(), "RTT samples were recorded"); + assert!( + mgr.build_netchar_result(20).is_none(), + "RTT without a bandwidth measurement is not a characterisation of the network" + ); +} + +#[test] +fn bandwidth_measure_transacts_and_enables_netchar() { + let mut mgr = AutoDetectManager::new(); + let req = mgr.send_rtt_request(0); + let _ = mgr.handle_response( + &AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }, + 20, + ); + + // Drive a bandwidth measurement to completion (paced internally). + let pdus = loop { + if let Some(p) = mgr.build_bandwidth_measure() { + break p; + } + }; + assert_eq!( + pdus[0].sequence_number(), + pdus[2].sequence_number(), + "Start and Stop share the transaction sequence" + ); + let results = AutoDetectResponse::BandwidthMeasureResults { + sequence_number: pdus[0].sequence_number(), + response_type: ironrdp_pdu::rdp::autodetect::BW_RESULTS_CONTINUOUS, + time_delta_ms: 10, + byte_count: 100_000, + }; + assert!(mgr.handle_response(&results, 20).is_none()); + + match mgr + .build_netchar_result(20) + .expect("result once RTT and bandwidth are known") + { + AutoDetectRequest::NetworkCharacteristicsResult { bandwidth_kbps, .. } => { + assert_eq!(bandwidth_kbps, Some(80_000), "byte_count * 8 / time_delta_ms"); + } + other => panic!("expected NetworkCharacteristicsResult, got {other:?}"), + } +} + #[test] fn sequence_number_wraps_at_u16_max() { let mut mgr = AutoDetectManager::new(); @@ -130,3 +199,218 @@ fn stale_probe_expiry() { mgr.expire_stale_probes(1, 0); assert_eq!(mgr.pending_count(), 0); } + +/// Mirrors the crate-private `NETCHAR_RESULT_MIN_INTERVAL_MS`, which is not +/// reachable across the crate boundary. +const NETCHAR_RESULT_INTERVAL_MS: u64 = 1_000; + +/// Mirrors the crate-private `RTT_WINDOW_SIZE`. +const RTT_WINDOW: usize = 8; + +/// The result is paced on its own clock, not on the probe cadence: a caller +/// probing far faster than the interval still emits at most one per interval. +#[test] +fn netchar_result_is_paced_independently_of_the_probe_cadence() { + let mut mgr = AutoDetectManager::new(); + + let req = mgr.send_rtt_request(0); + let response = AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }; + let _ = mgr.handle_response(&response, 20); + + let bw_seq = loop { + if let Some(pdus) = mgr.build_bandwidth_measure() { + break pdus[0].sequence_number(); + } + }; + let results = AutoDetectResponse::BandwidthMeasureResults { + sequence_number: bw_seq, + response_type: ironrdp_pdu::rdp::autodetect::BW_RESULTS_CONTINUOUS, + time_delta_ms: 10, + byte_count: 100_000, + }; + assert!(mgr.handle_response(&results, 20).is_none()); + + assert!(mgr.build_netchar_result(1_000).is_some(), "the first one is due"); + + // Feed a fresh sample so only the interval is under test here. + let req = mgr.send_rtt_request(1_000); + let _ = mgr.handle_response( + &AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }, + 1_020, + ); + + assert!( + mgr.build_netchar_result(1_000 + NETCHAR_RESULT_INTERVAL_MS - 1) + .is_none(), + "a probe inside the interval does not emit another" + ); + assert!( + mgr.build_netchar_result(1_000 + NETCHAR_RESULT_INTERVAL_MS).is_some(), + "the interval having elapsed, the next one is due" + ); +} + +/// A client that stops answering must stop producing results. Samples never +/// age out of the window, so without this the last values would be +/// advertised forever. +#[test] +fn netchar_result_stops_when_the_client_stops_answering() { + let mut mgr = AutoDetectManager::new(); + + let req = mgr.send_rtt_request(0); + let _ = mgr.handle_response( + &AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }, + 20, + ); + let bw_seq = loop { + if let Some(pdus) = mgr.build_bandwidth_measure() { + break pdus[0].sequence_number(); + } + }; + let results = AutoDetectResponse::BandwidthMeasureResults { + sequence_number: bw_seq, + response_type: ironrdp_pdu::rdp::autodetect::BW_RESULTS_CONTINUOUS, + time_delta_ms: 10, + byte_count: 100_000, + }; + assert!(mgr.handle_response(&results, 20).is_none()); + + let mut now = 1_000; + assert!(mgr.build_netchar_result(now).is_some(), "the first one is due"); + + // The client has gone quiet. The interval keeps elapsing and the window + // still holds its samples, but there is nothing new to report. + for _ in 0..5 { + now += NETCHAR_RESULT_INTERVAL_MS; + assert!( + mgr.build_netchar_result(now).is_none(), + "no response has arrived since the last result" + ); + } + assert!(mgr.snapshot().is_some(), "the samples are still there for the embedder"); + + // One reply, and reporting resumes. + let req = mgr.send_rtt_request(now); + let _ = mgr.handle_response( + &AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }, + now + 20, + ); + now += NETCHAR_RESULT_INTERVAL_MS; + assert!( + mgr.build_netchar_result(now).is_some(), + "a fresh sample resumes reporting" + ); +} + +/// [MS-RDPBCGR] 2.2.14.1.5 defines baseRTT as the lowest detected RTT, with no +/// window scoping, so it must not rise when a low sample ages out of the +/// window. This is the reviewer's sequence: one 5 ms sample, then eight of +/// 100 ms, which evicts it. +#[test] +fn base_rtt_is_the_session_low_not_the_window_low() { + let mut mgr = AutoDetectManager::new(); + + let sample = |mgr: &mut AutoDetectManager, rtt: u64| { + let req = mgr.send_rtt_request(0); + let _ = mgr.handle_response( + &AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }, + rtt, + ); + }; + + sample(&mut mgr, 5); + for _ in 0..RTT_WINDOW { + sample(&mut mgr, 100); + } + + assert_eq!( + mgr.snapshot().expect("samples recorded").min_ms, + 100, + "the 5 ms sample has aged out of the window" + ); + + let bw_seq = loop { + if let Some(pdus) = mgr.build_bandwidth_measure() { + break pdus[0].sequence_number(); + } + }; + let results = AutoDetectResponse::BandwidthMeasureResults { + sequence_number: bw_seq, + response_type: ironrdp_pdu::rdp::autodetect::BW_RESULTS_CONTINUOUS, + time_delta_ms: 10, + byte_count: 100_000, + }; + assert!(mgr.handle_response(&results, 20).is_none()); + + match mgr.build_netchar_result(1_000).expect("RTT and bandwidth are known") { + AutoDetectRequest::NetworkCharacteristicsResult { base_rtt_ms, .. } => { + assert_eq!(base_rtt_ms, Some(5), "baseRTT stays at the lowest ever detected"); + } + other => panic!("expected NetworkCharacteristicsResult, got {other:?}"), + } +} + +/// Pins which measured quantity lands in which wire field. +/// +/// The session minimum, the window minimum and the window average are all +/// different here, so no swap between them leaves both assertions holding. +#[test] +fn netchar_result_maps_each_measurement_to_its_own_field() { + let mut mgr = AutoDetectManager::new(); + + let sample = |mgr: &mut AutoDetectManager, rtt: u64| { + let req = mgr.send_rtt_request(0); + let _ = mgr.handle_response( + &AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }, + rtt, + ); + }; + + sample(&mut mgr, 5); + for rtt in [10, 20, 30, 40, 50, 60, 70, 80] { + sample(&mut mgr, rtt); + } + + let snap = mgr.snapshot().expect("samples recorded"); + assert_eq!(snap.min_ms, 10, "the 5 ms sample has left the window"); + assert_eq!(snap.avg_ms, 45, "average of 10 through 80"); + + let bw_seq = loop { + if let Some(pdus) = mgr.build_bandwidth_measure() { + break pdus[0].sequence_number(); + } + }; + let results = AutoDetectResponse::BandwidthMeasureResults { + sequence_number: bw_seq, + response_type: ironrdp_pdu::rdp::autodetect::BW_RESULTS_CONTINUOUS, + time_delta_ms: 10, + byte_count: 100_000, + }; + assert!(mgr.handle_response(&results, 20).is_none()); + + match mgr.build_netchar_result(1_000).expect("RTT and bandwidth are known") { + AutoDetectRequest::NetworkCharacteristicsResult { + base_rtt_ms, + bandwidth_kbps, + average_rtt_ms, + .. + } => { + assert_eq!(base_rtt_ms, Some(5), "baseRTT is the session low, not the window low"); + assert_eq!(average_rtt_ms, 45, "averageRTT is the window average"); + assert_eq!(bandwidth_kbps, Some(80_000), "byte_count * 8 / time_delta_ms"); + } + other => panic!("expected NetworkCharacteristicsResult, got {other:?}"), + } +}