Skip to content

fix(server): measure bandwidth across a single large graphics write - #2021

Open
Greg Lamberson (glamberson) wants to merge 3 commits into
Devolutions:masterfrom
lamco-admin:fix/bandwidth-measure-large-write
Open

Greg Lamberson (glamberson) wants to merge 3 commits into
Devolutions:masterfrom
lamco-admin:fix/bandwidth-measure-large-write

Conversation

@glamberson

@glamberson Greg Lamberson (glamberson) commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Continuous bandwidth measurement opened a fixed window of RTT ticks (Start, four ticks, Stop). The client counts the ordinary traffic between Start and Stop (MS-RDPBCGR 3.2.5.14), so a window over a quiet desktop timed idle time: on a 2 ms LAN the server reported a few hundred kbps, and mstsc told the user the network was slow.
  • When an EGFX write of at least 10 KiB goes out, the server now sends Bandwidth Measure Start immediately before it and Stop immediately after, on the same stream, so the client times a burst the link actually carried. At most one such measurement per second, and never while one is outstanding. gnome-remote-desktop measures the same way.
  • The tick window takes over whenever no bracketed measurement has started for eight RTT ticks, so a session without EGFX, or one whose only large frame was the initial render, keeps measuring.
  • A bandwidth measurement the client never answers, or a bracket whose Stop is never sent, is abandoned after the same 30 s as an unanswered RTT probe (expire_stale_probes), instead of stopping bandwidth measurement for the rest of the session. This also covers the tick window, which could already get stuck that way.
  • A Bandwidth Measure Results with a zero time delta now counts as one millisecond instead of discarding the figure: The client's timer has millisecond resolution, and a burst on a fast link routinely completes within one. A result with no bytes counted still clears the stored figure.
  • New public API: AutoDetectManager::begin_bandwidth_measure and AutoDetectManager::end_bandwidth_measure. Additive; build_bandwidth_measure is unchanged.

Validation

cargo xtask check fmt/lints/tests/typos/locks all pass. Nine new tests in ironrdp-testsuite-core cover the bracket, the size threshold, the pacing, the tick window staying closed while brackets recur and resuming when they stop, the expiry of an unended bracket and of an unanswered measurement, a slow tick window still reaching its Stop, and the zero-delta rule. The existing test that treated a zero time delta as unusable now checks a zero byte count instead.

@github-actions github-actions Bot added kind/protocol Affects RDP or related protocol behavior risk/medium Behavioral change that does not substantially alter a core public API size/M Size: up to 449 counted lines and 10 files; exceeds S in either measure triage/overlap Possible overlap with another pull request; advisory only needs-review A human reviewer is the current next actor labels Sep 27, 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 PR brackets large EGFX writes with Bandwidth Measure Start/Stop so the client times a real burst, retires the tick-window measurement after the first bracket, floors zero time deltas at one millisecond, and rejects zero byte counts. Independent inspection confirms the bracketing is protocol-conforming, the zero-delta floor only under-reports, and the in-tree Egfx caller pairs begin/end and aborts on write errors. Published: a one-way latch retires the tick window after a single large write, leaving one-large-write-then-quiet sessions without any bandwidth re-measurement; the new public bracket API can wedge the state machine with no timeout or cancel; and three no-behavior-change compression nits in the new code.

Comment thread crates/ironrdp-server/src/autodetect.rs Outdated
Comment thread crates/ironrdp-server/src/autodetect.rs
Comment thread crates/ironrdp-server/src/server.rs Outdated
Comment thread crates/ironrdp-server/src/autodetect.rs Outdated
Comment thread crates/ironrdp-server/src/autodetect.rs Outdated
@github-actions github-actions Bot added ai-reviewed/1 One automated review completed and removed needs-review A human reviewer is the current next actor labels Sep 28, 2026
@github-actions github-actions Bot added needs-review A human reviewer is the current next actor and removed needs-review A human reviewer is the current next actor labels Sep 28, 2026
@CBenoit Benoît Cortier (CBenoit) added automation-failed Exact-head automated classification or review failed or was unavailable and removed needs-review A human reviewer is the current next actor labels Sep 30, 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.

PR 2021 replaces the fixed tick-window bandwidth measurement (which times idle desktop stretches and reports implausibly low figures) with a Start/data/Stop bracket around EGFX writes of at least 10 KiB, paced to one per second, with the tick window kept as a fallback after eight bracket-free ticks, and adds 30 s expiry for abandoned bandwidth transactions. Independent inspection confirms the mechanism is correct: the bracketed continuous Start/Stop go out back-to-back with matching sequence numbers on the message channel, brackets never overlap and hold off the tick window, the tick counter reset keeps the fallback closed only while brackets recur, expiry covers both a Stop that is never sent and an unanswered measurement without cutting short an open window, and sequence allocation is centralized. The wire-level integration in server.rs matches the existing auto-detect path. Remaining concerns are the unverified zero-timeDelta semantic inversion (which also deviates from the spec-ma…

  1. [protocol + skeptical] Zero timeDelta clamped to 1 ms without client-behavior evidence, deviating from the spec-mandated calculation — medium 🟠 ❓ — crates/ironrdp-server/src/autodetect.rs
    measured_bandwidth_kbps inverts the previously tested semantic (zero timeDelta made the result unusable and cleared the stored figure) into clamp-to-1-ms-and-report. The timer-resolution argument is physically plausible and the old rule would discard most bracketed measurements on fast links, but nothing in the repository shows clients never use timeDelta 0 as a no-measurement sentinel, and the PR cites no evidence that gnome-remote-desktop clamps zero deltas. A client that does mean 'invalid' receives a fabricated byte_count*8 kbps figure (capped at u32::MAX, ~4.3 Tbps) advertised in RDP_NETCHAR_RESULTS. The handling also intentionally deviates from the MS-RDPBCGR-mandated (byteCount*8)/timeDelta calculation at both degenerate inputs (zero bytes yields None rather than 0 kbps; zero delta floors to 1 ms where the formula is undefined); the deviation is defensible and partly documented, but the divergence from the MUST clause should be documented against the spec. Missing client-behavior context prevents a firm conclusion.
  2. [skeptical] measured_bandwidth_kbps duplicates the PDU crate's computed_bandwidth_kbps, which now has no production callers — low 🟡 — crates/ironrdp-server/src/autodetect.rs
    After this change the server computes bandwidth from the raw Bandwidth Measure Results fields with a private helper, and a search confirms AutoDetectResponse::computed_bandwidth_kbps (ironrdp-pdu/src/rdp/autodetect.rs:613) is referenced only by its own unit tests; the server was its sole production caller. The same wire formula now lives in two crates with deliberately divergent zero handling, so any future correction (rounding, overflow, units) must be found and mirrored by hand, and the two implementations will silently disagree on what a client's result means. The stated goal of avoiding the impossible branch did not require duplication: the special case could wrap a call to the PDU helper or the helper could take the clamped delta.

Comment thread crates/ironrdp-server/src/autodetect.rs Outdated
Comment thread crates/ironrdp-server/src/autodetect.rs Outdated
Comment thread crates/ironrdp-server/src/server.rs Outdated
@github-actions github-actions Bot added ai-reviewed/2 Two automated reviews completed needs-author-action The pull request author is the current next actor and removed ai-reviewed/1 One automated review completed automation-failed Exact-head automated classification or review failed or was unavailable labels Sep 30, 2026
@glamberson
Greg Lamberson (glamberson) force-pushed the fix/bandwidth-measure-large-write branch from 95b0633 to 5cdf387 Compare October 1, 2026 14:16
@github-actions github-actions Bot added automation-failed Exact-head automated classification or review failed or was unavailable risk/unknown Risk could not be determined automatically; needs maintainer-level scrutiny and removed needs-author-action The pull request author is the current next actor risk/medium Behavioral change that does not substantially alter a core public API labels Oct 1, 2026
@CBenoit

Benoît Cortier (CBenoit) commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

PR automation is failing because of a picky-krb 0.12.5 incompatibility, fixed on master by #2074. Please rebase on master to fix it.

Update: no rebase needed anymore. picky-krb 0.12.5 was yanked from crates.io (re-released as 0.13.0), so the API check builds again without changes to this branch. PR automation has been re-run here and passes.

@github-actions github-actions Bot added the risk/medium Behavioral change that does not substantially alter a core public API label Oct 7, 2026
@github-actions github-actions Bot added needs-review A human reviewer is the current next actor and removed risk/unknown Risk could not be determined automatically; needs maintainer-level scrutiny triage/overlap Possible overlap with another pull request; advisory only automation-failed Exact-head automated classification or review failed or was unavailable labels Oct 7, 2026
@glamberson
Greg Lamberson (glamberson) force-pushed the fix/bandwidth-measure-large-write branch from 5cdf387 to 8393b3a Compare October 7, 2026 22:45
@github-actions github-actions Bot added triage/overlap Possible overlap with another pull request; advisory only and removed needs-review A human reviewer is the current next actor labels Oct 7, 2026
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

This pull request may overlap with #2012.

Both PRs work in the MS-RDPBCGR auto-detect bandwidth measurement domain: measurement windows of Bandwidth Measure Start/Stop, computing kbps from client results with a one-millisecond floor for zero timeDelta, and expiring unanswered measurements. This PR is the server-side initiator; #2012 is the client-side responder, so the scope is shared but on opposite ends of the same protocol feature.

This notice is advisory only. Automated review continues as usual, and how these pull requests relate is for maintainers and authors to decide.

Note

LLM-assisted content (no human feedback).

@github-actions github-actions Bot added the needs-review A human reviewer is the current next actor label Oct 7, 2026
The continuous measurement opened a fixed window of RTT ticks, and the
client counts only the traffic that happens to pass between Start and
Stop, so a window over a quiet desktop timed idle time and reported a
fast link as a few hundred kbps. Bracket one EGFX write of at least
10 KiB with Start and Stop instead, at most once a second, so the
client times a burst the link carried. Sessions without EGFX keep the
tick window until the first bracketed measurement.

A zero time delta now counts as one millisecond: the client's timer has
millisecond resolution and a burst on a fast link completes within one.
A result with no bytes counted still clears the stored figure.
The first bracketed measurement no longer turns the tick window off
for good: each bracketed Start resets the tick count, so the window
takes over again once eight ticks pass without one.

A bracketed measurement, or one waiting for the client's results, is
now dropped by expire_stale_probes once it is older than the RTT probe
age, so a Stop that is never sent or a client that never answers no
longer stops measurement for the rest of the session. A tick window
that is still open is not timed, since it sends its own Stop.

Also folds the Start and Stop writes into let chains, passes the
measured fields to measured_bandwidth_kbps, and allocates sequence
numbers through one helper.
@glamberson
Greg Lamberson (glamberson) force-pushed the fix/bandwidth-measure-large-write branch from 8393b3a to 0846c3d Compare October 8, 2026 11:26
@github-actions github-actions Bot removed the needs-review A human reviewer is the current next actor label Oct 8, 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.

PR 2021 brackets large EGFX-over-TCP writes with Bandwidth Measure Start/Stop, adds begin/end_bandwidth_measure to AutoDetectManager, computes bandwidth from the client's results with a 1 ms lower bound, centralizes sequence allocation, and expires stuck bandwidth measurements. Independent inspection of pr-head confirms the change is protocol-conformant in framing, sequencing, and pacing, with tests targeting each reverted behavior. All six validated specialist findings check out against the head code: the normative-formula deviation in zero-value handling, the latch guarding an unreachable state (a fresh AutoDetectManager is built per connection at server.rs:2343, the only construction site, contradicting the recorded justification), the duplicated bandwidth arithmetic whose PDU counterpart now has no workspace consumers, the 10 KiB threshold hardcoded in public docs and the external test, the duplicated encode+write trio, and the hand-built results PDU duplicating the new complete_b…

  1. [protocol] Bandwidth computation deviates from the normative (byteCount * 8) / timeDelta calculation in zero-value cases — low 🟡 — crates/ironrdp-server/src/autodetect.rs
    MS-RDPBCGR 3.3.5.14 requires the server to calculate bandwidth as (byteCount * 8) / timeDelta. The new measured_bandwidth_kbps deviates at the degenerate inputs: a timeDelta of 0 is clamped to 1 ms, and a byteCount of 0 yields no figure where the formula yields 0. The clamped figure is stored and advertised in the bandwidth field of a Network Characteristics Result, so a value the required calculation does not produce goes on the wire. The spec does not define server behavior for a zero timeDelta or zero byteCount and the clamp is a defensible lower-bound interpretation, so this is a recorded deviation, not a clear violation.
  2. [skeptical] measured_bandwidth_kbps reimplements the PDU crate's computed_bandwidth_kbps arithmetic, leaving two definitions of the same figure — low 🟡 — crates/ironrdp-server/src/autodetect.rs
    AutoDetectResponse::computed_bandwidth_kbps (ironrdp-pdu/src/rdp/autodetect.rs:613) already defines bandwidth as byte_count * 8 / time_delta_ms. After this PR the server no longer calls it, so its only workspace consumers are its own unit tests, while the server carries a private copy of the arithmetic in measured_bandwidth_kbps. The deliberate zero-handling differences (zero bytes -> None, zero time -> one-millisecond lower bound) can be layered on the PDU helper for the non-degenerate case. Any future change to the PDU computation, such as saturation or response-type awareness, would then silently diverge from the figure the server reports on the wire and publishes via the autodetect handles.
  3. [code-compressor] Extract a shared async helper for encode + write of autodetect PDUs — low 🟡 — crates/ironrdp-server/src/server.rs
    The PR adds two more copies of the encode_autodetect_request -> writer.write_all -> map_err(ServerError::io) trio (the Start and Stop blocks in write_egfx_over_tcp) alongside two pre-existing identical copies in the tick handler (build_netchar_result and build_bandwidth_measure, around lines 3469-3499). A single private async fn write_autodetect_request(writer, pdu, message_channel_id, user_channel_id) -> ServerResult<()> replaces four copies with four one-line calls, removing roughly eight lines and one place per site to get channel ids or the error label wrong, with identical encode/await/error-mapping order so Start-data-Stop ordering and early-return-on-error semantics are preserved. The two pre-existing sites are out of this diff, so adopting the helper there is a small out-of-diff touch; using it only for the new blocks still removes the PR's own duplication but leaves the file inconsistent.

Comment thread crates/ironrdp-server/src/server.rs Outdated
Comment thread crates/ironrdp-server/src/autodetect.rs
Comment thread crates/ironrdp-testsuite-core/tests/server/autodetect.rs
@github-actions github-actions Bot added ai-reviewed/3 Final automated review completed needs-author-action The pull request author is the current next actor and removed ai-reviewed/2 Two automated reviews completed labels Oct 8, 2026
@glamberson

Copy link
Copy Markdown
Contributor Author

On the review's first item, MS-RDPBCGR 3.3.5.14 says the server MUST calculate the bandwidth as (byteCount * 8) / timeDelta. With a timeDelta of 0 that formula divides by zero, so the spec leaves the case open, and I count it as one millisecond, because the client's timer has millisecond resolution and a burst on a fast link routinely completes within one. A byteCount of 0 would give 0, and I return no figure instead, so a measurement that counted nothing clears the stored bandwidth and can't be advertised as a 0 kbps link. Both rules are in the PR body and in the doc of measured_bandwidth_kbps, and zero_time_delta_counts_as_one_millisecond and zero_byte_count_ages_out_a_previous_bandwidth_figure pin them.

@glamberson

Copy link
Copy Markdown
Contributor Author

On the review's second item, the two functions differ on purpose. computed_bandwidth_kbps returns None for a zero timeDelta, computed_bandwidth_zero_delta pins that, and it is public API of ironrdp-pdu, so I don't want this PR to change it. The server needs the opposite for that one input, a one millisecond lower bound, so it keeps its own small function and doesn't layer one rule on the other for a single multiplication and division. The PDU helper stays as it was.

@glamberson

Copy link
Copy Markdown
Contributor Author

On the review's third item, I'm leaving the helper out of this PR. The new Start and Stop blocks follow the pattern the three existing sites in this file already use, and a helper would mean touching those three too, which AGENTS.md asks fixes to avoid ("avoid unrelated refactors"). Using it only for the new blocks would leave the file inconsistent. It would make a reasonable small cleanup of its own.

The auto-detect manager is built per connection since the connection
state split, so a Stop can only follow the Start this write sent, and
end_bandwidth_measure returns one only for an open bracket.
write_egfx_over_tcp no longer records the channel of its Start.

The paced-measurements test sends its results through answer_bracket,
the results half of complete_bracket, and asserts the outcome it used
to ignore.
@github-actions github-actions Bot added risk/high Substantial core public API impact, or fail-closed triage; needs maintainer-level scrutiny needs-review A human reviewer is the current next actor and removed needs-author-action The pull request author is the current next actor risk/medium Behavioral change that does not substantially alter a core public API labels Oct 8, 2026

This branch was successfully deployed

1 active deployment
llm-providers — cef12974 Deployed Oct 8, 2026 by glamberson via Classify pull request #2087
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-reviewed/3 Final automated review completed kind/protocol Affects RDP or related protocol behavior needs-review A human reviewer is the current next actor risk/high Substantial core public API impact, or fail-closed triage; needs maintainer-level scrutiny size/M Size: up to 449 counted lines and 10 files; exceeds S in either measure triage/overlap Possible overlap with another pull request; advisory only

Development

Successfully merging this pull request may close these issues.

2 participants