Skip to content

feat(connector)!: pass frame arrival time into Sequence::step - #1530

Open
Greg Lamberson (glamberson) wants to merge 4 commits into
Devolutions:masterfrom
lamco-admin:feat/connector-monotonic-instant
Open

feat(connector)!: pass frame arrival time into Sequence::step#1530
Greg Lamberson (glamberson) wants to merge 4 commits into
Devolutions:masterfrom
lamco-admin:feat/connector-monotonic-instant

Conversation

@glamberson

@glamberson Greg Lamberson (glamberson) commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Connect-time bandwidth measurement needs to know when bytes arrived, and nothing in the sans-I/O layer could tell it. fix(connector): answer connect-time Bandwidth Measure to unblock FreeRDP servers #1465, now merged, answers the server's Bandwidth Measure Stop with a nominal interval for exactly that reason: the connector has no way to observe the real one.
  • Introduce MonotonicInstant, a millisecond counter with an arbitrary epoch, and make Option<MonotonicInstant> a required parameter of Sequence::step. The I/O drivers already know when a read completed, so Framed records the arrival time of each read and hands it to the state machine. A driver with no clock passes None.
  • With arrival times available, measure for real: a Bandwidth Measure Start opens a window, Payload messages accumulate their byte counts, and Stop reports the elapsed time between its own arrival and the Start's.

#1465 has merged, so this applies directly to master and carries no merge-order dependency. That PR was the FreeRDP unblock on its own; this is the design change behind it, split out at Benoît Cortier (@CBenoit)'s suggestion in review.

Why the clock lives in the driver

Two reasons, both of which rule out having the sequence read a clock itself.

std::time::Instant::now panics on wasm32-unknown-unknown, and ironrdp-connector compiles for it. ironrdp-web reaches ironrdp-async through ironrdp-futures, so the clock read is cfg'd there too: the wasm arm returns None rather than panicking on the first socket read.

More fundamentally, a sequence that timestamps its own entry measures how fast it drained an already-filled buffer, not how long the bytes took to arrive. Framed stamps the actual socket read, so a PDU served entirely from buf correctly reports the read that filled it.

Why the parameter is an Option

Three situations look alike at the Stop and are not alike, and collapsing them produces a worse figure than the nominal one #1465 sends.

A Stop with no preceding Start has no window to time. A window whose Start, Payload and Stop arrived in one socket read carries the same instant on all three, so the elapsed time rounds down to zero; TCP coalescing makes this the common case on a fast link, because the connect-time payload is small. And a driver with no clock reports nothing at any point, which is not a measurement of zero but the absence of one.

Only the middle case is a measurement. Making the parameter Option<MonotonicInstant> is what keeps it distinguishable: a window opens only when the Start carried a reading, so a driver with no clock opens none, accumulates nothing, and answers the Stop with its own payload alone. A window that was timed reports every byte per [MS-RDPBCGR] 3.2.5.14, including when the elapsed time floors to 1 ms, because the bytes really did arrive inside that millisecond.

Both untimed cases report a 1 ms floor rather than 0, since a server computing byteCount * 8 / timeDelta divides by it.

There is no MonotonicInstant::ZERO. An earlier revision used it as both the clock's origin and the "did not measure" sentinel, which is precisely what made the wasm32 and FFI drivers indistinguishable from a fast link: they would have paired a full byte count with an interval the client never measured, and the reported bandwidth would have grown with however much payload the server chose to send.

Breaking change

Sequence::step takes an Option<MonotonicInstant>. Implementors must add the parameter; callers driving a sequence by hand must supply an arrival time or switch to step_no_input, which passes None for them. All 8 in-tree implementors and the call sites across ironrdp-acceptor, ironrdp-async, ironrdp-blocking, ironrdp-client, ironrdp-web and ffi are updated here. Framed::last_read_at returns Option<MonotonicInstant>.

Stacked on this

#1559 is stacked on this pull request and carries its three commits, so the merge order is this one then #1559. Landing this shrinks #1559's diff to its own change, 2 files and +149/-15 in connection.rs plus one connector test.

#1559 counts the 8-byte auto-detect header into the byte count introduced here, and narrows the Start and Stop arms to the connect-time request type. Both come from the automated reviewer's findings on this pull request.

Why this is one PR

The automation flags this as size/XL and asks for it to be split. The three commits here do not separate into reviewable pull requests.

The first changes the Sequence::step signature, and every implementor and caller moves with it: connector, acceptor, async, blocking, client, web, the FFI and the tests. That is 19 of the 23 files and +474/-116 on its own, and a pull request holding the trait change without them does not build.

The second exists because of the review on the first. It distinguishes a window that was never timed from one measured as zero, which is the fix for the finding that a wasm or FFI client would otherwise report a fabricated bandwidth. Splitting it out means filing the first commit with a defect already raised against it.

The third is the tests for both.

Stacked pull requests are the remedy the note suggests, and it adds that they require every branch to live in this repository. This branch is on a fork, so that path is closed.

Validation

  • cargo xtask check fmt/typos/lints/tests/locks all pass on the pinned toolchain, fuzz/ built before the lock check.
  • cargo xtask wasm check passes. Worth stating separately: this is the gate that catches the Instant::now hazard above, since the workspace default target never compiles that crate for wasm.
  • Six new tests in crates/ironrdp-testsuite-core/tests/connector/autodetect.rs: the interval and byte count match what the driver observed across a Start/Payload/Stop window; several Payload messages accumulate across a timed window; a window delivered in one read floors the interval with every byte still counted; a driver reporting no arrival times answers with the Stop payload alone; a Stop with no preceding Start is answered with the floor; and a second Start clears both stores and restarts the timer, which 3.2.5.14 requires and which nothing else covered.
  • Four more in a new crates/ironrdp-testsuite-core/tests/async_framed.rs covering Framed::last_read_at itself, since the tests above feed Sequence::step hand-picked instants and would stay green if the driver stopped stamping reads. They pin that an unread Framed reports no arrival, that each socket read advances the stamp, that PDUs sharing a read share its stamp rather than being stamped when the caller drains them, and that a Framed rebuilt around leftover bytes reports no arrival for them, which is the case ironrdp-tokio's split and unsplit helpers produce. Each was checked by planting the corresponding regression and confirming the test fails.
  • Those live in ironrdp-testsuite-core rather than inline because ironrdp-async sets [lib] test = false, so an inline #[cfg(test)] module would compile and never run. This adds ironrdp-async as a dev-dependency of ironrdp-testsuite-core.

@github-actions github-actions Bot added scope/web Affects the web/WASM ecosystem scope/core Touches the core architectural tier A-extra size/L Size: 400-799 lines of code labels Aug 3, 2026
@glamberson
Greg Lamberson (glamberson) force-pushed the feat/connector-monotonic-instant branch from 595f9fb to 88d6870 Compare August 3, 2026 15:11
@github-actions github-actions Bot added size/L Size: 400-799 lines of code and removed size/L Size: 400-799 lines of code labels Aug 3, 2026
Benoît Cortier (CBenoit) pushed a commit that referenced this pull request Aug 3, 2026
…RDP servers (#1465)

## Summary

- The connector answers only the RTT auto-detect request at connect time
and returns nothing for the Bandwidth Measure Stop, on the assumption
that skipping it does not stall the sequence.
- That assumption fails for FreeRDP-based servers: GNOME Remote Desktop
blocks in its `AWAIT_BW_RESULT` state until it receives a Bandwidth
Measure Results reply and never proceeds to licensing, so the connection
hangs right after the Client Info PDU. Windows servers tolerate the
omission, which hid it.
- Reply to a connect-time `BandwidthMeasureStop` with a
`BandwidthMeasureResults` PDU carrying the payload size the server
handed us, over a nominal interval.

## Scope

This is the unblock alone. The reported interval is nominal
(`time_delta_ms: 1`) because the sans-I/O layer has no time source:
nothing reaches the connector that says when the bytes arrived. The
figure is an informational QoS hint and the server proceeds on receipt,
which is what unsticks the connection.

Measuring it properly needs an arrival time threaded down from the I/O
driver, which is a breaking change to `Sequence::step` and does not
belong in a fix aimed at getting FreeRDP servers connecting. It is
#1530, stacked on this branch: it introduces `MonotonicInstant`, has
`Framed` record when each read completed, and replaces the nominal
figure here with the real Start-to-Stop interval and accumulated byte
count.

Split out at @CBenoit's suggestion in review.

## Validation

- `cargo xtask check fmt/typos/lints/tests/locks` all pass on the pinned
toolchain.
- Regression test: a connect-time Bandwidth Measure Stop produces a
response frame and the auto-detect phase continues.
- Reproduced and fixed live against gnome-remote-desktop 49: before, the
connector stalled after Client Info with grd in `AWAIT_BW_RESULT`;
after, grd proceeds through licensing and DEMAND_ACTIVE to an active
session.

## Notes

- Found while bringing up the client-side Graphics Pipeline against grd
(#1446), but it is an independent connector bug affecting any connection
to a FreeRDP-based server, not specific to EGFX.
- Previously depended on #1511 for a zero-`payloadLength` regression
test. #1511 has merged, and that test moved out with the rest of the
measurement work, so there is no dependency left here.
@glamberson
Greg Lamberson (glamberson) force-pushed the feat/connector-monotonic-instant branch from 88d6870 to 08107e1 Compare August 3, 2026 16:06
@github-actions github-actions Bot added size/L Size: 400-799 lines of code maintainer-required Maintainer review or intervention is required risk/high Substantial core public API impact, or fail-closed triage; needs maintainer-level scrutiny and removed size/L Size: 400-799 lines of code labels Aug 3, 2026

Copilot AI 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.

Pull request overview

Introduces driver-observed PDU arrival timestamps for accurate connect-time bandwidth measurement.

Changes:

  • Adds MonotonicInstant and updates the breaking Sequence::step API.
  • Records read completion times in async and blocking drivers.
  • Measures bandwidth windows and adds regression tests.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
ffi/src/connector/mod.rs Adapts FFI connector stepping.
ffi/src/connector/activation.rs Adapts FFI activation stepping.
crates/ironrdp-web/src/session.rs Supplies an inert browser timestamp.
crates/ironrdp-testsuite-core/tests/session/connection_activation.rs Updates activation tests.
crates/ironrdp-testsuite-core/tests/server/acceptor.rs Updates acceptor tests.
crates/ironrdp-testsuite-core/tests/connector/autodetect.rs Tests bandwidth measurement behavior.
crates/ironrdp-connector/src/license_exchange.rs Accepts arrival timestamps.
crates/ironrdp-connector/src/lib.rs Defines the timestamp type and API.
crates/ironrdp-connector/src/connection.rs Implements bandwidth measurement.
crates/ironrdp-connector/src/connection_finalization.rs Accepts arrival timestamps.
crates/ironrdp-connector/src/connection_activation.rs Propagates arrival timestamps.
crates/ironrdp-connector/src/channel_connection.rs Accepts arrival timestamps.
crates/ironrdp-client/src/rdp.rs Updates manual sequence stepping.
crates/ironrdp-blocking/src/framed.rs Records blocking-read timestamps.
crates/ironrdp-blocking/src/connector.rs Passes blocking-read timestamps.
crates/ironrdp-async/src/framed.rs Records and forwards async-read timestamps.
crates/ironrdp-acceptor/src/finalization.rs Accepts arrival timestamps.
crates/ironrdp-acceptor/src/connection.rs Propagates arrival timestamps.
crates/ironrdp-acceptor/src/channel_connection.rs Accepts arrival timestamps.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/ironrdp-connector/src/connection.rs Outdated
Comment thread crates/ironrdp-connector/src/connection.rs Outdated
@glamberson
Greg Lamberson (glamberson) force-pushed the feat/connector-monotonic-instant branch from 08107e1 to 7d2ce5d Compare August 3, 2026 17:17
Benoît Cortier (CBenoit) added a commit that referenced this pull request Aug 4, 2026
`cargo-semver-checks` could be unavailable while the public API
compatibility job still passed, hiding real API breaks such as PR #1530.

Install the checked binary in a run-scoped directory accessible to the
unprivileged analysis user, and make setup or any unexpected semver exit
status fail the job. Only cargo-semver-checks' documented results remain
accepted: `0` for compatible and `100` for an incompatibility.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot removed the risk/high Substantial core public API impact, or fail-closed triage; needs maintainer-level scrutiny label Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

This pull request is size/XL, so automated review is disabled for it: a change this large is hard to review well in one piece, whether by a human or a model.

Please split it into focused pull requests that can each be reviewed on their own. When the parts build on each other, stacked pull requests let you open each one on top of the last without waiting for the one below to merge. Stacks require every branch to live in this repository, so from a fork, please open separate pull requests instead.

Automated review resumes once the change is below the size/XL threshold.

Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request Aug 6, 2026
…-time

The Network Characteristics Byte Count store was incremented by the payload
length alone. [MS-RDPBCGR] 3.2.5.14 says to increment it "by the value specified
in the payloadLength field plus the size of the header fields (8 bytes)", on each
Bandwidth Measure Payload and on the 0x002B Stop, so every counted message was
reported 8 bytes short. 2.2.14.1.3 pins that 8 by requiring headerLength to be
0x08.

Those two increments are the whole of the connect-time byte count. The section's
other rule, which counts every byte received while the window is open, belongs to
the 0x0014 and 0x0114 Starts. Those are the reliable and lossy UDP variants;
connect-time is 0x1014 and its step list has no such clause.

The Start and Stop arms now check the request type instead of matching every
variant. That is required rather than tidy: 2.2.14.1.4 sets headerLength to 0x08
only for the 0x002B Stop and 0x06 otherwise, so a fixed 8-byte addend is correct
only once the type is known. It also stops a continuous-detection request being
answered with a connect-time result on the wrong channel, and 3.2.5.14 asks for a
sequence-number correlation on the 0x0629 Stop that this phase does not track.

This deliberately does not follow FreeRDP, which counts the whole PDU length at
the framing layer in rdp.c after rdp_read_header, for any window including
connect-time, then adds payloadLength again in autodetect.c. On this path that
counts the payload twice and adds framing bytes the spec does not ask for. The
figure is an informational QoS hint and the server proceeds either way, so
following the spec costs no interop.

Four existing expectations move by 8 per counted message: 4096 to 4104, 1536 to
1552, 3584 to 3608, 512 to 520. Two tests added for the request-type guards.

Stacked on Devolutions#1530, which introduces the window state this counts into.
Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request Aug 7, 2026
Two gaps the deep reviewer named on Devolutions#1530 that were left open.

A second Bandwidth Measure Start has to clear both stores and restart
the timer per MS-RDPBCGR 3.2.5.14. That was the one branch the spec
explicitly requires and nothing exercised it; the new test fails
independently if either half of the reset is dropped.

Nothing pinned Framed::last_read_at either. The connector tests feed
Sequence::step hand-picked instants, so a regression that stopped
stamping reads would leave them green while every measured window
collapsed to the unmeasurable floor. These cover both directions: a
stamp that stops advancing, and a stamp taken on drain rather than on
the read, which would break the rule that a PDU served from the buffer
keeps the arrival time of the read that filled it.

They live in ironrdp-testsuite-core rather than inline because
ironrdp-async sets [lib] test = false, so an inline module would compile
and never run.
@glamberson
Greg Lamberson (glamberson) force-pushed the feat/connector-monotonic-instant branch from 650ebdc to 5157e46 Compare August 7, 2026 15:08
Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request Aug 7, 2026
…-time

The Network Characteristics Byte Count store was incremented by the payload
length alone. [MS-RDPBCGR] 3.2.5.14 says to increment it "by the value specified
in the payloadLength field plus the size of the header fields (8 bytes)", on each
Bandwidth Measure Payload and on the 0x002B Stop, so every counted message was
reported 8 bytes short. 2.2.14.1.3 pins that 8 by requiring headerLength to be
0x08.

Those two increments are the whole of the connect-time byte count. The section's
other rule, which counts every byte received while the window is open, belongs to
the 0x0014 and 0x0114 Starts. Those are the reliable and lossy UDP variants;
connect-time is 0x1014 and its step list has no such clause.

The Start and Stop arms now check the request type instead of matching every
variant. That is required rather than tidy: 2.2.14.1.4 sets headerLength to 0x08
only for the 0x002B Stop and 0x06 otherwise, so a fixed 8-byte addend is correct
only once the type is known. It also stops a continuous-detection request being
answered with a connect-time result on the wrong channel, and 3.2.5.14 asks for a
sequence-number correlation on the 0x0629 Stop that this phase does not track.

This deliberately does not follow FreeRDP, which counts the whole PDU length at
the framing layer in rdp.c after rdp_read_header, for any window including
connect-time, then adds payloadLength again in autodetect.c. On this path that
counts the payload twice and adds framing bytes the spec does not ask for. The
figure is an informational QoS hint and the server proceeds either way, so
following the spec costs no interop.

Four existing expectations move by 8 per counted message: 4096 to 4104, 1536 to
1552, 3584 to 3608, 512 to 520. Two tests added for the request-type guards.

Stacked on Devolutions#1530, which introduces the window state this counts into.
@github-actions github-actions Bot removed the breaking-change Includes a breaking change, and requires special scrutiny at the boundaries label Aug 7, 2026
@glamberson

Copy link
Copy Markdown
Contributor Author

Answering the size note.

The three commits here do not separate into reviewable pull requests. The first
changes the Sequence::step signature, and every implementor and caller moves
with it: connector, acceptor, async, blocking, client, web, the FFI and the
tests, 19 of the 23 files and +474/-116 by itself. A pull request holding the
trait change without them does not build. The second is the fix for the review
finding on the first, that a wasm or FFI client would report a fabricated
bandwidth when nothing was measured, so extracting it means filing the first
with a known defect. The third is the tests for both.

The note suggests stacked pull requests and adds that they require every branch
to live in this repository. This branch is on a fork, so that path is closed.

The body now says this under "Why this is one PR". It also now records that
#1559 is stacked on this one, which was missing: the merge order is this pull
request then #1559, and landing this one shrinks #1559's diff to 2 files.

@CBenoit Benoît Cortier (CBenoit) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you for the follow up

@CBenoit Benoît Cortier (CBenoit) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would like to address the comment I added here, so we actually support that for WASM as well.

If we can actually get a MonotonicTime on all platforms, we can maybe consider removing the Option as well?

Also perform a pass of test minimization if you can

Comment thread crates/ironrdp-async/src/framed.rs
@glamberson

Copy link
Copy Markdown
Contributor Author

Both remaining points, since neither has a thread.

Removing the Option: I don't think it can go, and the blocker is the FFI rather
than the browser. ffi/src/connector/ doesn't use Framed. The embedder owns
the socket and the read loop and hands step bytes it has already read, so a
clock read inside the shim would stamp when the caller got around to calling
step, not when the bytes arrived. That is the same error as a sequence reading
its own clock, which is what this PR removes. The alternatives are a new FFI
surface so the embedder can pass the time, or a reading we know is wrong.

What web-time does change is what None means: no longer "wasm has no clock" but
"this driver does not observe arrival times", which is permanent for a
caller-driven loop rather than a gap waiting to be filled. The MonotonicInstant
doc now says that; the Sequence::step parameter doc already did.

Test minimization: 10 down to 8, no assertion lost.
connect_time_bandwidth_reports_the_measured_interval_and_total_bytes is
subsumed by ..._measured_window_reports_every_payload, which asserts the same
interval and also covers accumulation. nothing_read_yet_means_no_arrival_time
survives as the opening assertion of each_socket_read_advances_the_arrival_time.

I kept both floor tests, which look like duplicates and are not: in
coalesced_into_one_read a window is open, measured zero elapsed, and 1536 bytes
are still counted; in without_a_clock no window opened and only the Stop's own
512 are. That distinction is what the second commit exists for.

Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request Aug 7, 2026
Two gaps the deep reviewer named on Devolutions#1530 that were left open.

A second Bandwidth Measure Start has to clear both stores and restart
the timer per MS-RDPBCGR 3.2.5.14. That was the one branch the spec
explicitly requires and nothing exercised it; the new test fails
independently if either half of the reset is dropped.

Nothing pinned Framed::last_read_at either. The connector tests feed
Sequence::step hand-picked instants, so a regression that stopped
stamping reads would leave them green while every measured window
collapsed to the unmeasurable floor. These cover both directions: a
stamp that stops advancing, and a stamp taken on drain rather than on
the read, which would break the rule that a PDU served from the buffer
keeps the arrival time of the read that filled it.

They live in ironrdp-testsuite-core rather than inline because
ironrdp-async sets [lib] test = false, so an inline module would compile
and never run.
@glamberson
Greg Lamberson (glamberson) force-pushed the feat/connector-monotonic-instant branch from 5157e46 to a757203 Compare August 7, 2026 16:10
Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request Aug 7, 2026
…-time

The Network Characteristics Byte Count store was incremented by the payload
length alone. [MS-RDPBCGR] 3.2.5.14 says to increment it "by the value specified
in the payloadLength field plus the size of the header fields (8 bytes)", on each
Bandwidth Measure Payload and on the 0x002B Stop, so every counted message was
reported 8 bytes short. 2.2.14.1.3 pins that 8 by requiring headerLength to be
0x08.

Those two increments are the whole of the connect-time byte count. The section's
other rule, which counts every byte received while the window is open, belongs to
the 0x0014 and 0x0114 Starts. Those are the reliable and lossy UDP variants;
connect-time is 0x1014 and its step list has no such clause.

The Start and Stop arms now check the request type instead of matching every
variant. That is required rather than tidy: 2.2.14.1.4 sets headerLength to 0x08
only for the 0x002B Stop and 0x06 otherwise, so a fixed 8-byte addend is correct
only once the type is known. It also stops a continuous-detection request being
answered with a connect-time result on the wrong channel, and 3.2.5.14 asks for a
sequence-number correlation on the 0x0629 Stop that this phase does not track.

This deliberately does not follow FreeRDP, which counts the whole PDU length at
the framing layer in rdp.c after rdp_read_header, for any window including
connect-time, then adds payloadLength again in autodetect.c. On this path that
counts the payload twice and adds framing bytes the spec does not ask for. The
figure is an informational QoS hint and the server proceeds either way, so
following the spec costs no interop.

Four existing expectations move by 8 per counted message: 4096 to 4104, 1536 to
1552, 3584 to 3608, 512 to 520. Two tests added for the request-type guards.

Stacked on Devolutions#1530, which introduces the window state this counts into.
@CBenoit

Copy link
Copy Markdown
Member

Removing the Option: I don't think it can go, and the blocker is the FFI rather than the browser. ffi/src/connector/ doesn't use Framed. The embedder owns the socket and the read loop and hands step bytes it has already read, so a clock read inside the shim would stamp when the caller got around to calling step, not when the bytes arrived. That is the same error as a sequence reading its own clock, which is what this PR removes. The alternatives are a new FFI surface so the embedder can pass the time, or a reading we know is wrong.

I think the alternative of extending the FFI surface is the right move. The FFI surface there was already closely modeled after the Rust interface, so it would be natural we extend it the same way: a MonotonicTime wrapper + a way to produce a "now" value, then pass it through the step function. We gain feature parity, as we should, and at this point the question becomes: why bother supporting the alternative path where we don’t know the frame arrival time (None) when it seems possible to have Some in all the consumers. I’m good with requiring the frame arrival monotonic time everywhere and consistently as a new API contract.

@CBenoit Benoît Cortier (CBenoit) added risk/high Substantial core public API impact, or fail-closed triage; needs maintainer-level scrutiny breaking-change Includes a breaking change, and requires special scrutiny at the boundaries and removed risk/unknown labels Aug 9, 2026
Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request Aug 10, 2026
Two gaps the deep reviewer named on Devolutions#1530 that were left open.

A second Bandwidth Measure Start has to clear both stores and restart
the timer per MS-RDPBCGR 3.2.5.14. That was the one branch the spec
explicitly requires and nothing exercised it; the new test fails
independently if either half of the reset is dropped.

Nothing pinned Framed::last_read_at either. The connector tests feed
Sequence::step hand-picked instants, so a regression that stopped
stamping reads would leave them green while every measured window
collapsed to the unmeasurable floor. These cover both directions: a
stamp that stops advancing, and a stamp taken on drain rather than on
the read, which would break the rule that a PDU served from the buffer
keeps the arrival time of the read that filled it.

They live in ironrdp-testsuite-core rather than inline because
ironrdp-async sets [lib] test = false, so an inline module would compile
and never run.
Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request Aug 10, 2026
…-time

The Network Characteristics Byte Count store was incremented by the payload
length alone. [MS-RDPBCGR] 3.2.5.14 says to increment it "by the value specified
in the payloadLength field plus the size of the header fields (8 bytes)", on each
Bandwidth Measure Payload and on the 0x002B Stop, so every counted message was
reported 8 bytes short. 2.2.14.1.3 pins that 8 by requiring headerLength to be
0x08.

Those two increments are the whole of the connect-time byte count. The section's
other rule, which counts every byte received while the window is open, belongs to
the 0x0014 and 0x0114 Starts. Those are the reliable and lossy UDP variants;
connect-time is 0x1014 and its step list has no such clause.

The Start and Stop arms now check the request type instead of matching every
variant. That is required rather than tidy: 2.2.14.1.4 sets headerLength to 0x08
only for the 0x002B Stop and 0x06 otherwise, so a fixed 8-byte addend is correct
only once the type is known. It also stops a continuous-detection request being
answered with a connect-time result on the wrong channel, and 3.2.5.14 asks for a
sequence-number correlation on the 0x0629 Stop that this phase does not track.

This deliberately does not follow FreeRDP, which counts the whole PDU length at
the framing layer in rdp.c after rdp_read_header, for any window including
connect-time, then adds payloadLength again in autodetect.c. On this path that
counts the payload twice and adds framing bytes the spec does not ask for. The
figure is an informational QoS hint and the server proceeds either way, so
following the spec costs no interop.

Four existing expectations move by 8 per counted message: 4096 to 4104, 1536 to
1552, 3584 to 3608, 512 to 520. Two tests added for the request-type guards.

Stacked on Devolutions#1530, which introduces the window state this counts into.
Connect-time bandwidth measurement needs to know when bytes arrived, and
nothing in the sans-I/O layer could tell it. The connector answered a
Bandwidth Measure Stop with a fabricated interval because the real one was
not observable from where the response is built.

Introduce MonotonicInstant, a millisecond counter with no epoch and no
platform dependency, and make it a required parameter of Sequence::step.
The I/O drivers already know when a read completed, so Framed records the
arrival time of each read and hands it to the state machine. Callers that
genuinely have no frame to time (the no-input path) use step_no_input.

With arrival times available, the connector measures for real: a Bandwidth
Measure Start opens a window, Payload messages accumulate their byte
counts, and Stop reports the elapsed time between its own arrival and the
Start's. A Stop with no preceding Start still gets its response, because
the server blocks without one, but reports a zero interval rather than
inventing a figure.

BREAKING CHANGE: Sequence::step takes a MonotonicInstant. Implementors
must add the parameter; callers driving a sequence by hand must supply an
arrival time or switch to step_no_input.
… was

The connect-time Bandwidth Measure handler opened a counting window on every
Start and reported the accumulated Payload bytes on the Stop, whatever the
elapsed time turned out to be. On a driver that reports no arrival times the
elapsed time is always zero and falls back to the 1 ms floor, so the reply
paired a full byte count with an interval the client never measured. A server
computing byteCount * 8 / timeDelta then scales its estimate with however much
payload it chose to send, and the wasm32 and FFI drivers are in that position
for the whole connection.

Model the absence of a reading as the absence of a reading: Sequence::step
takes Option<MonotonicInstant>, and MonotonicInstant::ZERO is gone. It had
served as both the clock's origin and the "did not measure" sentinel, which is
what made the two cases indistinguishable at the point where it mattered.

A window now opens only when the Start carried a reading, so the two outcomes
separate. A driver with no clock opens nothing, accumulates nothing, and
answers the Stop with its own payload alone. A window that was timed reports
every byte per MS-RDPBCGR 3.2.5.14, including when the elapsed time rounds down
to the floor because one socket read delivered the whole exchange: the bytes
did arrive inside that millisecond, so the floor bounds a real measurement.

Framed::last_read_at becomes Option<MonotonicInstant> and the wasm arm of
monotonic_now returns None rather than a fixed instant.
Two gaps the deep reviewer named on Devolutions#1530 that were left open.

A second Bandwidth Measure Start has to clear both stores and restart
the timer per MS-RDPBCGR 3.2.5.14. That was the one branch the spec
explicitly requires and nothing exercised it; the new test fails
independently if either half of the reset is dropped.

Nothing pinned Framed::last_read_at either. The connector tests feed
Sequence::step hand-picked instants, so a regression that stopped
stamping reads would leave them green while every measured window
collapsed to the unmeasurable floor. These cover both directions: a
stamp that stops advancing, and a stamp taken on drain rather than on
the read, which would break the rule that a PDU served from the buffer
keeps the arrival time of the read that filled it.

They live in ironrdp-testsuite-core rather than inline because
ironrdp-async sets [lib] test = false, so an inline module would compile
and never run.
`web-time` is `std::time` on every target except `wasm32-unknown-unknown`,
where it reads `Performance.now()`, so the driver clock no longer has a
hole in the browser build and `monotonic_now` is one function again
rather than two behind a `cfg`. The crate is already in `Cargo.lock`
through quinn and rustls-pki-types, so this is an edge and not a new
dependency.

The `Option` stays. `ffi/src/connector/` does not use `Framed`: the
embedder owns the read loop and hands `step` bytes it has already read,
so a clock read there would stamp when the caller called rather than
when the bytes arrived, which is the error this series exists to remove.
`MonotonicInstant`'s doc said the absence was about wasm; it now says
what it is actually about.

Test pass: ten to eight, no assertion lost.
`connect_time_bandwidth_reports_the_measured_interval_and_total_bytes`
was subsumed by `..._measured_window_reports_every_payload`, which
asserts the same interval and also covers accumulation.
`nothing_read_yet_means_no_arrival_time` survives as the opening
assertion of `each_socket_read_advances_the_arrival_time`.
@glamberson
Greg Lamberson (glamberson) force-pushed the feat/connector-monotonic-instant branch from a757203 to ca75ae3 Compare August 10, 2026 16:18
Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request Aug 10, 2026
…-time

The Network Characteristics Byte Count store was incremented by the payload
length alone. [MS-RDPBCGR] 3.2.5.14 says to increment it "by the value specified
in the payloadLength field plus the size of the header fields (8 bytes)", on each
Bandwidth Measure Payload and on the 0x002B Stop, so every counted message was
reported 8 bytes short. 2.2.14.1.3 pins that 8 by requiring headerLength to be
0x08.

Those two increments are the whole of the connect-time byte count. The section's
other rule, which counts every byte received while the window is open, belongs to
the 0x0014 and 0x0114 Starts. Those are the reliable and lossy UDP variants;
connect-time is 0x1014 and its step list has no such clause.

The Start and Stop arms now check the request type instead of matching every
variant. That is required rather than tidy: 2.2.14.1.4 sets headerLength to 0x08
only for the 0x002B Stop and 0x06 otherwise, so a fixed 8-byte addend is correct
only once the type is known. It also stops a continuous-detection request being
answered with a connect-time result on the wrong channel, and 3.2.5.14 asks for a
sequence-number correlation on the 0x0629 Stop that this phase does not track.

This deliberately does not follow FreeRDP, which counts the whole PDU length at
the framing layer in rdp.c after rdp_read_header, for any window including
connect-time, then adds payloadLength again in autodetect.c. On this path that
counts the payload twice and adds framing bytes the spec does not ask for. The
figure is an informational QoS hint and the server proceeds either way, so
following the spec costs no interop.

Four existing expectations move by 8 per counted message: 4096 to 4104, 1536 to
1552, 3584 to 3608, 512 to 520. Two tests added for the request-type guards.

Stacked on Devolutions#1530, which introduces the window state this counts into.
@github-actions github-actions Bot added risk/unknown and removed breaking-change Includes a breaking change, and requires special scrutiny at the boundaries risk/high Substantial core public API impact, or fail-closed triage; 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/1 One automated review completed maintainer-required Maintainer review or intervention is required risk/unknown scope/core Touches the core architectural tier scope/cross-cutting Spans multiple architectural boundaries scope/ffi Affects native or .NET bindings scope/web Affects the web/WASM ecosystem size/XL Size: 800 or more lines of code

Development

Successfully merging this pull request may close these issues.

3 participants