Skip to content

feat(server): measure and report network characteristics - #1470

Open
Greg Lamberson (glamberson) wants to merge 1 commit into
Devolutions:masterfrom
lamco-admin:feat/autodetect-network-characteristics-result
Open

feat(server): measure and report network characteristics#1470
Greg Lamberson (glamberson) wants to merge 1 commit into
Devolutions:masterfrom
lamco-admin:feat/autodetect-network-characteristics-result

Conversation

@glamberson

@glamberson Greg Lamberson (glamberson) commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • 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 ([MS-RDPBCGR] 2.2.14.1.5).
  • Nothing is sent until both are known. 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 figure.
  • The result is paced at one per second on its own clock rather than once per
    probe, and is withheld unless a client response has arrived since the last one.
    A client that stops answering stops producing results instead of leaving the
    last window values advertised indefinitely.
  • baseRTT is the lowest RTT seen over the session, per 2.2.14.1.5's "lowest
    detected round-trip time". averageRTT is the window average, so the
    difference between them is queueing delay.

Validation

  • cargo xtask check fmt/lints/tests/typos/locks and cargo xtask wasm check
    all pass.
  • cargo semver-checks -p ironrdp-server --baseline-rev master: no update
    required.
  • Tests live in ironrdp-testsuite-core. ironrdp-server sets
    [lib] test = false, so an inline module would compile and never run. Each new
    test was checked by planting the corresponding regression and confirming it
    fails.

Notes

@glamberson
Greg Lamberson (glamberson) force-pushed the feat/autodetect-network-characteristics-result branch from 401d3cd to 2c49685 Compare July 30, 2026 20:36
@github-actions github-actions Bot added scope/core Touches the core architectural tier A-internal size/M Size: up to 449 counted lines and 10 files; exceeds S in either measure labels Jul 31, 2026
@glamberson
Greg Lamberson (glamberson) force-pushed the feat/autodetect-network-characteristics-result branch from 2c49685 to 2edf0d4 Compare August 1, 2026 01:31
@github-actions github-actions Bot added size/M Size: up to 449 counted lines and 10 files; exceeds S in either measure and removed size/M Size: up to 449 counted lines and 10 files; exceeds S in either measure labels Aug 1, 2026
@glamberson
Greg Lamberson (glamberson) force-pushed the feat/autodetect-network-characteristics-result branch from 2edf0d4 to 21454d8 Compare August 3, 2026 00:30
@github-actions github-actions Bot added size/M Size: up to 449 counted lines and 10 files; exceeds S in either measure and removed size/M Size: up to 449 counted lines and 10 files; exceeds S in either measure labels Aug 3, 2026
@CBenoit Benoît Cortier (CBenoit) added risk/medium Behavioral change that does not substantially alter a core public API kind/technical-debt Internal cleanup work breaking-change Includes a breaking change, and requires special scrutiny at the boundaries ai-reviewed/1 One automated review completed labels Aug 3, 2026
@CBenoit

Copy link
Copy Markdown
Member

Two blocking correctness issues remain: validate NETCHAR headerLength against the request-specific permitted values before accepting the PDU, and prevent allocation of an RTT sequence number that is still outstanding after the 16-bit sequence space wraps.

Marc-André Moreau (mamoreau-devolutions) pushed a commit that referenced this pull request Aug 3, 2026
## Summary

- `AutoDetectManager` read the clock itself: `std::time::Instant` in
`pending_probes`, `Instant::now()` in `send_rtt_request`, and
`Instant::elapsed()` in `handle_response` and `expire_stale_probes`.
- It now takes `now_ms`, a caller-supplied monotonic millisecond counter
whose epoch is arbitrary as long as it's consistent across calls.
`ironrdp-server` supplies it from a process-wide monotonic origin in
`server.rs`, so the clock lives in the I/O driver rather than in the
state machine.

## Why

- **Testability.** The RTT assertions were wall-clock dependent.
`snapshot_reflects_measurements` could only check that the average came
out under an arbitrary 100 ms bound, which almost any bug would satisfy.
It now supplies both timestamps and asserts exact values: samples of 10,
20 and 30 ms giving min 10, max 30, average 20.
- **Portability.** `std::time::Instant::now` panics on
`wasm32-unknown-unknown`, so a type that reads it internally can't be
reused from a WASM build.
- **Layering.** A state machine that reads ambient time can't satisfy
the no-I/O rule the Core Tier crates follow, which forecloses moving
this code in that direction later.

Also covers the edges of the arithmetic the injected clock exposes.
There are two `saturating_sub` sites and they saturate in opposite
directions:

- `handle_response`: a clock that ran backwards between request and
response yields a zero sample rather than a wrapped value near
`u32::MAX`, and the zero reaches the sample window, not just the return
value.
- `expire_stale_probes`: the same backwards clock makes the age zero,
which is below any maximum, so the probe stays pending. Wrapping would
make it look older than any limit and drop a probe whose response is
still in flight.

A third test covers the `u32::try_from(..).unwrap_or(u32::MAX)` on the
first of those lines, where a gap wider than about 49.7 days clamps
rather than truncating to the low 32 bits.

## Validation

`cargo xtask check fmt/lints/tests/typos/locks` all pass.

Each of the three new tests was checked against a mutation of the code
it guards rather than only for passing: the two backwards-clock tests
fail if either `saturating_sub` becomes `wrapping_sub`, and the clamp
test fails if the `try_from` becomes a truncating `as u32`, which
reports `Some(0)` for a 49.7 day gap.

## Notes

- Came out of the discussion on #1465, where the same question arises on
the connector side. `ironrdp-connector` reads no clock at all today, and
answering a connect-time Bandwidth Measure properly needs one; taking
the timestamp from the caller is the shape that works on every target.
- `AutoDetectManager` arrived in #1177 with the internal clock, so this
corrects code I wrote rather than anyone else's.

BREAKING CHANGE: `AutoDetectManager::send_rtt_request` now takes
`now_ms`; `handle_response` takes `now_ms`; `expire_stale_probes` takes
`now_ms` and a `max_age_ms` `u64` instead of a `core::time::Duration`.
The `RTT_PROBE_MAX_AGE` constant is now `RTT_PROBE_MAX_AGE_MS`, a `u64`
of milliseconds.

## Two PRs are stacked on this

#1470 and #1471 are built on this branch and cannot merge before it. The
merge order is this PR, then #1470, then #1471.

This is also where the stack's breaking-change marker lives. The `!`
here covers the arity changes to `AutoDetectManager::send_rtt_request`
and `handle_response`; `cargo semver-checks` attributes both to this PR
and reports no further update required for either of the two above when
baselined against it.

## Rebased

Rebased onto `master` on 2026-08-02 as one `--update-refs` operation
with the rest of the stack, so the checks run against the current tree
rather than the state before that day's merges. No conflicts, no content
change. All five gates green on this head independently, not only at the
stack tip.
@glamberson
Greg Lamberson (glamberson) force-pushed the feat/autodetect-network-characteristics-result branch from 21454d8 to 7e9fdca Compare August 3, 2026 15:31
@github-actions github-actions Bot added size/M Size: up to 449 counted lines and 10 files; exceeds S in either measure and removed size/M Size: up to 449 counted lines and 10 files; exceeds S in either measure breaking-change Includes a breaking change, and requires special scrutiny at the boundaries labels Aug 3, 2026
@github-actions github-actions Bot added the maintainer-required Maintainer review or intervention is required label Aug 4, 2026
@github-actions github-actions Bot added ai-reviewed/2 Final automated review completed and removed kind/technical-debt Internal cleanup work ai-reviewed/1 One automated review completed labels Aug 4, 2026

@github-actions github-actions Bot left a comment

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.

The PDU-layer part is sound: `netchar_result_rtt` produces exactly the 0x0840 field set that the pre-existing `netchar_fields`/encode/size/decode paths agree on, and the new 14-byte wire vector uses distinct baseRTT/averageRTT values, so a field transposition would fail. No wire-format or encode/decode defect found. The concerns are server-side. The only stated justification for emitting the result is a receive-buffer rationale attributed to a section that (per the handoff's quotation) does not state it; there is no in-repo consumer, and the emission doubles auto-detect PDU volume per tick. Separately: baseRTT is a sliding-window minimum but documented as "lowest observed" and can rise mid-session; RTT samples never expire though probes do, so results keep advertising stale metrics; and both new server-path tests use identical 20 ms samples, so min and average are indistinguishable and swapping the two arguments would still pass.

Protocol analysis: partially_accepted — Kept three items. The 1.3.9 phase tension is unresolved in the corpus, so I report it as a question, not a violation. The window-scoped baseRTT concern is sharpened into a semantics/doc defect, since build_netchar_result's doc says "lowest observed". The unsupported receive-buffer citation is kept as a doc-accuracy defect under the repo's spec-traceable-docs rule. Rejected two: the missing client-capability-flag check and the mcsSDin initiator value both concern the pre-existing, unmodified guard and encoder the RTT request already used. Partially accepted the cadence item: doubled PDU volume is folded into the motivation question, but a result lagging the newest probe by one round is unavoidable, not a defect. Spec text is taken from the handoff as evidence.

  1. question / medium — crates/ironrdp-server/src/server.rs:1476-1479
    What concretely motivates emitting a Network Characteristics Result at all? The change adds an unsolicited PDU to every client on every probe tick, doubling auto-detect traffic, and the only benefit stated anywhere is the adjacent comment's receive-buffer-sizing claim, which the handoff's quotation of 3.2.5.14 does not support ("Extract the network metrics from the PDU" is the whole client-side rule). Nothing in the repository consumes it, the manager measures no bandwidth so the report is partial by construction, and the server already surfaces RTT to its own backend via autodetect_rtt_handle. Without a named consumer or an observed client behaviour that requires it, this is added protocol output with unverified interop cost and no demonstrated gain. If the driver is a specific client that expects the result, please say which and how the improvement was observed; that also determines whether per-probe cadence is right.
  2. question / medium — crates/ironrdp-server/src/server.rs
    This emission happens on the main connection after the connection sequence completes, paired with an RTT_REQUEST_CONTINUOUS probe. Per the handoff, section 1.3.9 lists Network Characteristics Result among the server-to-client messages for Connect-Time detection and, for continuous detection, inside RDP_TUNNEL_SUBHEADER on sideband channels, but omits it from the continuous main-connection list (only 2.2.14.1.1, 2.2.14.1.2 and 2.2.14.1.4), while 2.2.14.3 permits any of the five messages with no phase qualifier and 0x0840 carries no phase discriminator (unlike 0x0001 vs 0x1001 for RTT). The tension is unresolved, so this is not established as a violation, but it is where a strict client keyed to the 1.3.9 continuous set could treat the PDU as unexpected. Was this validated against a real client (mstsc and/or FreeRDP) in the continuous phase, and what was observed?
  3. non_blocking / medium — crates/ironrdp-server/src/autodetect.rs:65-77
    The doc says the result carries "baseRTT (lowest observed)", but snapshot().min_ms is the minimum over the 8-entry sliding window (RTT_WINDOW_SIZE, line 14), not over the connection. Record one 5 ms sample, then eight samples of 100 ms: the 5 ms entry is evicted and the next result reports baseRTT = 100 even though 5 ms was detected, so successive results can report a baseRTT higher than one already sent. A client treating "lowest detected round-trip time" as a monotone floor sees it rise. Either track a session-lifetime minimum in the manager (a single u32 field, no new API) or state the window scope explicitly so the value is not overclaimed. The handoff notes the corpus does not scope baseRTT to a window, so this is a semantics/doc mismatch rather than a proven violation.
  4. non_blocking / medium — crates/ironrdp-server/src/autodetect.rs:69-78
    rtt_samples is never aged out — only pending_probes are, via expire_stale_probes. If a client stops answering probes (network stall, suspended client), every subsequent tick still emits a result advertising the last-known window values as the current average and lowest RTT, indefinitely and with no bound on staleness. Before this change the snapshot was pull-only and its freshness was the embedder's problem; making it protocol output turns it into a claim the peer may act on. Consider gating emission on sample freshness (e.g. skip when the newest sample predates now_ms by more than RTT_PROBE_MAX_AGE_MS, which needs a per-sample timestamp) or on at least one response having arrived since the previous result.
  5. non_blocking / medium — crates/ironrdp-testsuite-core/tests/server/autodetect.rs:72-98
    All three iterations use send_rtt_request(0) and handle_response(.., 20), so every sample is 20 ms and snap.min_ms == snap.avg_ms. The assertions base_rtt_ms == Some(snap.min_ms) and average_rtt_ms == snap.avg_ms are therefore vacuous with respect to the mapping they claim to pin: swapping the two arguments in netchar_result_rtt(seq, snapshot.min_ms, snapshot.avg_ms) leaves both tests green. The crate-internal twin (crates/ironrdp-server/src/autodetect.rs:280) has the same flaw with a single sample, and the PDU-level wire test only pins the constructor's own argument order, not the server-side mapping. snapshot_reflects_measurements in this same file already uses distinct samples (10/20/30); doing the same here would make the assertions meaningful.
  6. non_blocking / low — crates/ironrdp-server/src/server.rs:1472-1475
    The comment attributes a purpose to the spec that the cited section does not carry: per the handoff, 2.2.14.1.5 contains no client-side processing text and 3.2.5.14 says only "Extract the network metrics from the PDU" — no receive-buffer sizing. The repository treats spec-traceable docs as a non-negotiable, and this PR elsewhere removes a spec-inaccurate statement, so introducing a new one regresses the same dimension. The "client does not reply" half is supported. Either drop the citation and present buffer sizing as implementer commentary, or replace it with the actual client action from 3.2.5.14.
  7. non_blocking / low — crates/ironrdp-server/src/server.rs
    This PR corrects the "Share Data PDU on the IO channel" misstatement on send_rtt_request, but the identical and more visible claim survives on the public enable_autodetect entry point: "Auto-detect uses lightweight Share Data PDUs on the IO channel." The actual framing is an MCS Send Data Indication on the message channel with a SEC_AUTODETECT_REQ security header (encode_autodetect_request, line 2074). The next sentence, "It supports bandwidth measurement in addition to RTT", is also false — AutoDetectManager measures RTT only, which is precisely why this PR emits the bandwidth-less 0x0840 form. Since server.rs is already touched and the sibling doc is already being fixed, correcting these two sentences keeps the API docs from contradicting each other.
  8. non_blocking / low — crates/ironrdp-server/src/autodetect.rs:280-304
    netchar_result_reports_measured_rtt and netchar_result_none_without_samples are added twice — here as crate-internal tests and again in crates/ironrdp-testsuite-core/tests/server/autodetect.rs — with the same assertions and only a 1-vs-3 sample-count difference that changes nothing (all samples are 20 ms either way). This mirrors a pre-existing duplication in these two files, so it is not a new pattern, but it adds two more tests to keep in sync for zero additional coverage. Since build_netchar_result is public API exercised across the crate boundary by the testsuite copy, the internal duplicate is the one that can be dropped.

Comment thread crates/ironrdp-server/src/server.rs
Comment thread crates/ironrdp-server/src/autodetect.rs
Comment thread crates/ironrdp-server/src/autodetect.rs
Comment thread crates/ironrdp-testsuite-core/tests/server/autodetect.rs
Comment thread crates/ironrdp-server/src/server.rs Outdated
Comment thread crates/ironrdp-server/src/autodetect.rs Outdated
@glamberson Greg Lamberson (glamberson) changed the title feat(server): emit NetworkCharacteristicsResult with measured RTT feat(server): measure and report network characteristics Aug 10, 2026
@glamberson

Copy link
Copy Markdown
Contributor Author

All six threads answered. The round changed the shape of this rather than
patching it, so a summary is worth having in one place.

What it does now: emits only once both RTT and bandwidth are known, paced at one
per second on its own clock rather than once per probe, and withheld unless a
client response arrived since the last result. baseRTT is the session low rather
than the sliding-window low.

Three of those came straight from your findings. The fourth, the session baseRTT,
is where I departed from the reference implementation: krdp takes its minimum
over a 500 ms window, so its baseRTT can rise too, and I think 2.2.14.1.5's
"lowest detected round-trip time" is not ambiguous enough to follow it there.

#1471 is folded in and should be closed. With the both-known gate, this PR alone
could only ever emit the RTT-only form it now declines to send, so keeping them
apart would have meant shipping a PR whose emission never fires. The 0x0840
constructor went with it, since nothing builds that form now; decode still
accepts it.

Two things your review surfaced that were not in it. ironrdp-server sets
[lib] test = false, so the whole inline test module is dead in CI, and four
tests I had added while answering your other threads would never have run. They
are now in ironrdp-testsuite-core and re-verified there. And answering the
comment-accuracy finding caught two more stale comments of my own, both
introduced earlier in this same round.

Title and body are rewritten to match.

@github-actions github-actions Bot added risk/unknown Risk could not be determined automatically; needs maintainer-level scrutiny and removed risk/medium Behavioral change that does not substantially alter a core public API labels Aug 10, 2026
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.
@glamberson
Greg Lamberson (glamberson) force-pushed the feat/autodetect-network-characteristics-result branch from 7e9fdca to 7768d5c Compare August 10, 2026 08:48
@github-actions github-actions Bot added size/L Size: up to 899 counted lines and 20 files; exceeds M in either measure kind/protocol Affects RDP or related protocol behavior risk/high Substantial core public API impact, or fail-closed triage; needs maintainer-level scrutiny and removed size/M Size: up to 449 counted lines and 10 files; exceeds S in either measure risk/unknown Risk could not be determined automatically; needs maintainer-level scrutiny labels Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-reviewed/2 Final automated review completed kind/protocol Affects RDP or related protocol behavior maintainer-required Maintainer review or intervention is required risk/high Substantial core public API impact, or fail-closed triage; needs maintainer-level scrutiny scope/core Touches the core architectural tier size/L Size: up to 899 counted lines and 20 files; exceeds M in either measure

Development

Successfully merging this pull request may close these issues.

2 participants