Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions crates/ironrdp-pdu/src/rdp/autodetect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non_blocking / low: The new vector covers requestType 0x0840 and the existing one covers 0x08C0, leaving 0x0880 (NETCHAR_RESULT_BW_RTT, bandwidth + averageRTT, headerLength 0x0E) as the only Network Characteristics Result variant with no wire test, even though the decoder handles it at lines 518-530 and netchar_fields maps it. Adding the third vector alongside this one closes the set cheaply while the file is already open.


#[test]
fn decode_rtt_request() {
let pdu = ironrdp_core::decode::<AutoDetectRequest>(RTT_REQUEST_WIRE).unwrap();
Expand Down Expand Up @@ -1098,6 +1107,27 @@ mod tests {
assert_eq!(encoded.as_slice(), NETCHAR_ALL_WIRE);
}

#[test]
fn decode_netchar_rtt() {
let pdu = ironrdp_core::decode::<AutoDetectRequest>(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![
Expand Down
189 changes: 167 additions & 22 deletions crates/ironrdp-server/src/autodetect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking / medium: BW_PAYLOAD_LEN is 8192 bytes and timeDelta is reported in whole milliseconds, so the largest bandwidth this probe can express is 8192*8/1 ms = 65536 kbps, about 65 Mbps. On a LAN or loopback the client reports timeDelta 0, computed_bandwidth_kbps returns None, no bandwidth is ever recorded and build_netchar_result therefore returns None for the whole session. On any link faster than roughly 65 Mbps the figure is a large underestimate rather than a capacity measurement, and it is then advertised to the client in the bandwidth field of the Network Characteristics Result, where clients use it for codec and quality decisions. The comment claims the size is "large enough that the client's measured time delta is meaningful on a real link", which the arithmetic does not support for current links.


/// Server-side auto-detect state machine.
///
/// Tracks outstanding RTT probes and computes round-trip statistics from
Expand All @@ -35,6 +53,30 @@ pub struct AutoDetectManager {
/// Outstanding probes as `(sequence_number, sent_at_ms)`.
pending_probes: Vec<(u16, u64)>,
rtt_samples: VecDeque<u32>,
/// Sequence number of an in-flight bandwidth measurement, if any. A new
/// measurement is not started while one is outstanding.
pending_bw: Option<u16>,
/// Most recently measured bandwidth in kilobits per second.
bandwidth_kbps: Option<u32>,
/// 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<u64>,
/// 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<u32>,
/// 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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non_blocking / medium: measurement_is_fresh is a single flag set by either kind of response, so an RTT response alone re-arms result emission. Once one bandwidth measurement has succeeded, bandwidth_kbps is never invalidated: if every later transaction fails (timeDelta 0, or a client that stops answering only the bandwidth results), the server keeps advertising that one old figure as the current bandwidth on every subsequent result PDU. The doc comment states the flag exists so stale values are not "put on the wire as a current claim", which holds only when the client goes silent entirely. Tracking freshness per quantity, or ageing bandwidth_kbps out, would match the stated intent.

}

impl AutoDetectManager {
Expand All @@ -43,45 +85,145 @@ 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);
self.pending_probes.push((seq, now_ms));
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<u32> {
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),
])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking / high: The continuous bandwidth transaction injects a Bandwidth Measure Payload (requestType 0x0002) between the continuous Start (0x0014) and Stop (0x0429). This crate's own documentation scopes that PDU to connect-time twice (ironrdp-pdu/src/rdp/autodetect.rs:67 "Bandwidth Measure Payload (connect-time only)" and :166 on the enum variant), matching the handoff's reading of MS-RDPBCGR 2.2.14.1.3/2.2.14.2.2/1.3.9: after the connection sequence the ordinary server-to-client PDUs between Start and Stop are what the client counts, and 0x0002 has no continuous counterpart. A conforming and simpler alternative needs no new PDU at all: send Start on one tick and Stop on a later tick, letting real traffic fill the window. As written the change puts an off-spec code on the wire, and a client that ignores it leaves an empty window whose byteCount is unrelated to the link.

}

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<AutoDetectRequest> {
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,
))
Comment thread
glamberson marked this conversation as resolved.
}
Comment thread
glamberson marked this conversation as resolved.

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<u32> {
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.
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions crates/ironrdp-server/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
}
Comment thread
glamberson marked this conversation as resolved.

// 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?;
}
}
}
}
}
Expand Down
Loading