Skip to content

docs(rdpsnd): document RdpsndServerHandler::start wFormatNo contract#1343

Merged
Benoît Cortier (CBenoit) merged 1 commit into
Devolutions:masterfrom
clintcan:docs-rdpsnd-start-wformatno
Jun 1, 2026
Merged

docs(rdpsnd): document RdpsndServerHandler::start wFormatNo contract#1343
Benoît Cortier (CBenoit) merged 1 commit into
Devolutions:masterfrom
clintcan:docs-rdpsnd-start-wformatno

Conversation

@clintcan
Copy link
Copy Markdown
Contributor

What

RdpsndServerHandler and its methods are currently undocumented. This PR adds rustdoc to the trait, with the load-bearing detail on start's return value.

Why

The Option<u16> returned by start is stamped directly onto every Wave/Wave2 PDU as wFormatNo (RdpsndServer::wave). The non-obvious part — with no doc to warn you — is which list that index addresses:

  • A compliant client resolves each wave's format as ClientFormats[wFormatNo] against the list it sent in the Client Audio Formats PDU, and rejects any wFormatNo >= NumberOfClientFormats. FreeRDP does exactly this in rdpsnd_recv_wave2_pdu (and rdpsnd_recv_wave_info_pdu) — out-of-range is dropped, so all audio silently disappears.
  • The client's list is its accepted subset of the server's offered formats, so it generally differs from get_formats() in both length and order. Returning a get_formats() index only works when the chosen format happens to sit at the same position in both lists.

This bites the moment a handler offers more than one format and a client accepts only some of them: indexing get_formats() sends the wrong (or out-of-range) wFormatNo, and the failure mode is silent — no error, just no sound. We hit this in a downstream server when adding a second (AAC) format alongside PCM: mstsc kept working (it lists the chosen format at the same index in both), but FreeRDP/Thincast clients that accepted only PCM got an out-of-range index and went silent. Picking the format and then returning its position within client_format.formats fixes it.

The doc makes the contract explicit so the next implementer doesn't have to rediscover it from a wire capture.

Notes

  • Docs only — no behavior change.
  • cargo check -p ironrdp-rdpsnd and RUSTDOCFLAGS="-D warnings" cargo doc -p ironrdp-rdpsnd --no-deps both pass; no reformatting from cargo fmt.
  • Possible follow-up (happy to discuss separately): make the contract harder to get wrong by having start return the chosen AudioFormat (or its client_format.formats index) and letting the crate compute/validate wFormatNo. That's an API/semver change, so I kept this PR to docs only.

The value returned by `start` is stamped directly onto every Wave/Wave2
PDU as `wFormatNo`. A compliant client resolves each wave's format as
`ClientFormats[wFormatNo]` against the list *it* sent in the Client Audio
Formats PDU (e.g. FreeRDP's `rdpsnd_recv_wave2_pdu` rejects
`wFormatNo >= NumberOfClientFormats` and silently drops all audio).

Returning an index into the handler's own `get_formats()` list instead
only works when the chosen format happens to occupy the same position in
both lists. With several offered formats and a client that accepts a
subset, the two lists differ in length and order, so a `get_formats()`
index is wrong or out of range and audio dies with no error — a subtle
trap the previous (undocumented) signature gave no hint about.

Document that the returned index addresses `client_format.formats`, and
add brief docs to the trait and its other methods while here. Docs only;
no behavior change.
@clintcan clintcan marked this pull request as ready for review May 30, 2026 12:56
@clintcan
Copy link
Copy Markdown
Contributor Author

clintcan commented Jun 1, 2026

This is ready for review whenever someone has a moment. It's docs-only — adds rustdoc to RdpsndServerHandler::start documenting that the returned u16 is the wire wFormatNo, which the client resolves against its own Client Audio Formats list (not the server's get_formats()); FreeRDP/Thincast reject wFormatNo >= NumberOfClientFormats and silently drop the wave. cargo check -p ironrdp-rdpsnd and RUSTDOCFLAGS=-D warnings cargo doc both pass. Happy to adjust wording or split out the follow-up API idea floated in the description.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds Rustdoc documentation to RdpsndServerHandler, focusing on the contract for start()’s Option<u16> return value so implementers correctly compute wFormatNo for Wave/Wave2 PDUs.

Changes:

  • Documented the RdpsndServerHandler trait’s role in RDPSND server operation.
  • Added detailed rustdoc to get_formats() and especially start(), clarifying that the returned index must address client_format.formats (the client-echoed list), not the server’s offered format list.
  • Added a brief doc comment to stop() describing when it is invoked.

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

Copy link
Copy Markdown
Member

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

Choose a reason for hiding this comment

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

This is useful documentation, as it’s typical when you had to dive into a rabbit hole because of a an obscure bug. Thank you for contributing it.

@CBenoit
Copy link
Copy Markdown
Member

Benoît Cortier (CBenoit) commented Jun 1, 2026

* Possible follow-up (happy to discuss separately): make the contract harder to get wrong by having `start` return the chosen `AudioFormat` (or its `client_format.formats` index) and letting the crate compute/validate `wFormatNo`. That's an API/semver change, so I kept this PR to docs only.

I would be more than happy to have a misuse-resistant API instead of the error-prone we have currently (even if it’s now well documented). Generally speaking, I think this would be a superior API on many levels: hard to misuse, self-explanatory types and signature, the crate can also perform additional sanity checks -- e.g.: is the selected format is not part of the common set?

On another note, it also makes sense to me if the function only gets to see the audio formats in common with the client, and we can let the crate handle everything else (if there is nothing in common, we don’t even need to call the method in the first place). I guess it depends how far we want to go in the abstraction. I’m unsure whether it can be genuinely useful for the consumers to see the full client format set unconditionally.

@CBenoit Benoît Cortier (CBenoit) merged commit 7894d9f into Devolutions:master Jun 1, 2026
20 checks passed
@clintcan
Copy link
Copy Markdown
Contributor Author

clintcan commented Jun 1, 2026

Opened #1356 to track the misuse-resistant start API — full proposal and the open question (how far to abstract / index-vs-format return shape) are there so it's easier to follow than a comment on a merged PR. Carrying over my read from above: hand start only the negotiated intersection (each entry pre-tagged with its wFormatNo), keep get_formats() for the server's advertised list, and don't call start at all when there's nothing in common.

clintcan pushed a commit to clintcan/IronRDP that referenced this pull request Jun 2, 2026
…ndler

The old `start(&ClientAudioFormatPdu) -> Option<u16>` made every handler
compute `wFormatNo` itself, as an index into the *client's* format list.
Getting it wrong (e.g. returning a server-list index) yields
`wFormatNo >= NumberOfClientFormats`, which a compliant client rejects,
silently dropping all audio — and each handler re-implemented the same
server-vs-client intersection.

Move that work into the crate and split selection from lifecycle:

    fn choose_format<'a>(&mut self, common: &'a [NegotiatedFormat]) -> Option<&'a NegotiatedFormat>;
    fn start(&mut self, format: &NegotiatedFormat);

The crate computes `common` (formats from `get_formats()` the client also
advertised, in the server's preference order, each tagged with its
client-list `wFormatNo`), calls `choose_format`, then `start` with the
chosen format. `NegotiatedFormat` has a private `wformat_no` and no public
constructor, and `choose_format` returns a borrow of an element of `common`,
so a handler cannot pick a format the client did not accept nor emit an
out-of-range `wFormatNo`. `choose_format` is not called when nothing is in
common. Separating `choose_format` (pure selection) from `start` (encoder
init / producer startup) keeps the two concerns distinct.

Resolves the footgun documented in Devolutions#1343. Closes Devolutions#1356.

BREAKING CHANGE: `RdpsndServerHandler::start` is replaced by `choose_format`
(selection) plus `start(&NegotiatedFormat)` (lifecycle). Implementors now
return a `&NegotiatedFormat` from `choose_format` instead of computing a
`wFormatNo`.

- Add `NegotiatedFormat` (private `wformat_no`, `format()` accessor) and
  `negotiate_formats` (intersection + client-index mapping), with unit tests
  for client-index mapping, the PCM-only/AAC-first regression, empty overlap,
  and WAVEFORMATEX-identity equality.
- Match formats on WAVEFORMATEX identity (tag/channels/rate/bits), ignoring
  derived and codec-`data` fields a client may not echo verbatim.
- Update the example server to the new two-method shape.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants