Skip to content

Commit 1a4b6ec

Browse files
author
Clint Christopher Canada
committed
fix(rdpsnd): address review — fallible start(), test=false seam, compare codec data
Review follow-up for #1359. - `start` now returns `Result<(), Box<dyn RdpsndError>>`. On `Err` the crate declines the negotiated format (clears `format_no`) — the same outcome as `choose_format` returning `None` — instead of leaving the channel "negotiated" but silently producing no audio. `format_no` is committed *before* `start` so a producer that emits a wave during `start` can't race an unset index, and is rolled back to `None` on failure. The example server's two encoder-init failures now return `Err`. - Restore `[lib] test = false` (workspace policy) and move the negotiation tests out of the lib. Add a private `__test` feature (`dep:visibility`) exposing `negotiate_formats` / `audio_format_eq` / `NegotiatedFormat::wformat_no` via `visibility::make(pub)`; the tests live in `ironrdp-testsuite-core` (`tests/rdpsnd/server.rs`), which depends on `ironrdp-rdpsnd` with `__test` — mirroring `ironrdp-cliprdr`. - `audio_format_eq` now compares the codec extra-data blob (`data`). The two derived fields (`n_avg_bytes_per_sec`, `n_block_align`) stay ignored, but the `data` blob carries real configuration for some codecs (AAC's HEAACWAVEINFO, MS-RDPEA 2.2.2.1.1), so ignoring it could match two genuinely incompatible formats. cbSize=0 still decodes to `None` on both sides, so PCM/AAC negotiation is unaffected. - Add black-box `SvcProcessor` wiring tests (via the public surface): a shared format streams (start called once, format committed), no common format skips `choose_format`, and a failing `start` declines (no audio streamed).
1 parent d657272 commit 1a4b6ec

7 files changed

Lines changed: 311 additions & 99 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ironrdp-rdpsnd/Cargo.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,24 @@ categories.workspace = true
1414

1515
[lib]
1616
doctest = false
17-
# test = false
17+
test = false
1818

1919
[features]
2020
default = []
2121
std = []
22+
# Internal (PRIVATE!) feature used to aid testing.
23+
# Don't rely on this whatsoever. It may disappear at any time.
24+
# It uses `visibility` to expose otherwise-private negotiation helpers to the
25+
# integration testsuite (the lib has no inline test harness — `test = false`).
26+
__test = ["dep:visibility"]
2227

2328
[dependencies]
2429
bitflags = "2.11"
2530
tracing = { version = "0.1", features = ["log"] }
2631
ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public
2732
ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] }
2833
ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", features = ["alloc"] } # public
34+
visibility = { version = "0.1", optional = true }
2935

3036
[lints]
3137
workspace = true

crates/ironrdp-rdpsnd/src/server.rs

Lines changed: 46 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ impl NegotiatedFormat {
5858
pub fn format(&self) -> &pdu::AudioFormat {
5959
&self.format
6060
}
61+
62+
/// Test-only accessor for the crate-private `wformat_no`, exposed for the
63+
/// integration testsuite behind the private `__test` feature. Not a stable API.
64+
#[cfg(feature = "__test")]
65+
#[doc(hidden)]
66+
pub fn wformat_no(&self) -> u16 {
67+
self.wformat_no
68+
}
6169
}
6270

6371
/// Handler for the server side of the Audio Output Virtual Channel (`RDPSND`).
@@ -97,8 +105,14 @@ pub trait RdpsndServerHandler: Send + core::fmt::Debug {
97105
/// [`choose_format`]. This is the lifecycle hook: initialize encoder state,
98106
/// spawn the producer, etc. Waves are then emitted via [`RdpsndServer::wave`].
99107
///
108+
/// Return `Err` if initialization fails (e.g. the encoder can't be created).
109+
/// The crate then **declines the negotiated format** — exactly as if
110+
/// [`choose_format`] had returned [`None`] — rather than leaving the channel
111+
/// "negotiated" but silently producing no audio. The error is logged by the
112+
/// crate.
113+
///
100114
/// [`choose_format`]: RdpsndServerHandler::choose_format
101-
fn start(&mut self, format: &NegotiatedFormat);
115+
fn start(&mut self, format: &NegotiatedFormat) -> Result<(), Box<dyn RdpsndError>>;
102116

103117
/// Called when the audio stream is torn down (e.g. the client closed the
104118
/// channel or the session ended).
@@ -222,6 +236,7 @@ impl RdpsndServer {
222236
/// client resolves waves against (MS-RDPEA). The result mirrors the server's
223237
/// ordering so the handler can express preference simply by `get_formats`
224238
/// order, while the `wFormatNo` always points into the client list.
239+
#[cfg_attr(feature = "__test", visibility::make(pub))]
225240
fn negotiate_formats(
226241
server_formats: &[pdu::AudioFormat],
227242
client_formats: &[pdu::AudioFormat],
@@ -241,16 +256,24 @@ fn negotiate_formats(
241256
.collect()
242257
}
243258

244-
/// Compare two audio formats by their WAVEFORMATEX identity — wave format tag,
245-
/// channel count, sample rate, and bit depth. Derived fields
246-
/// (`n_avg_bytes_per_sec`, `n_block_align`) and the codec-specific `data` blob
247-
/// are deliberately ignored: a client echoes back a format it accepts but is
248-
/// not guaranteed to reproduce those byte-for-byte.
259+
/// Compare two audio formats for negotiation. The WAVEFORMATEX identity fields
260+
/// — wave format tag, channel count, sample rate, bit depth — must match, and so
261+
/// must the codec-specific extra-data blob (`data`).
262+
///
263+
/// The two derived fields (`n_avg_bytes_per_sec`, `n_block_align`) are
264+
/// deliberately ignored: they are computable from the others and a client may
265+
/// legitimately not echo them back byte-for-byte. The `data` blob is a different
266+
/// category, though — for codecs whose extra-format bytes carry real
267+
/// configuration (AAC's HEAACWAVEINFO extra data is the clear case, MS-RDPEA
268+
/// 2.2.2.1.1's `cbSize` + extra data), ignoring it could match two genuinely
269+
/// incompatible formats, so it IS compared.
270+
#[cfg_attr(feature = "__test", visibility::make(pub))]
249271
fn audio_format_eq(a: &pdu::AudioFormat, b: &pdu::AudioFormat) -> bool {
250272
a.format == b.format
251273
&& a.n_channels == b.n_channels
252274
&& a.n_samples_per_sec == b.n_samples_per_sec
253275
&& a.bits_per_sample == b.bits_per_sample
276+
&& a.data == b.data
254277
}
255278

256279
impl_as_any!(RdpsndServer);
@@ -307,20 +330,28 @@ impl SvcProcessor for RdpsndServer {
307330
// an out-of-range wFormatNo.
308331
let common = negotiate_formats(self.handler.get_formats(), &client_format.formats);
309332
self.state = RdpsndState::Ready;
310-
self.format_no = if common.is_empty() {
333+
if common.is_empty() {
311334
debug!("No audio format in common with the client; audio disabled");
312-
None
313335
} else if let Some(chosen) = self.handler.choose_format(&common) {
314-
// `chosen` borrows `common` (not `self`), so the encoder
315-
// is read off it and the handler is free to borrow again
316-
// for the `start` lifecycle hook.
336+
// `chosen` borrows `common` (a local), not `self`, so the
337+
// handler is free to borrow `&mut self` again for `start`.
317338
let wformat_no = chosen.wformat_no;
318-
self.handler.start(chosen);
319-
Some(wformat_no)
339+
// Commit the index BEFORE the `start` lifecycle hook: if `start`
340+
// spawns a producer that emits a wave immediately, `wave()` must
341+
// already see a valid `format_no` rather than racing an unset one.
342+
self.format_no = Some(wformat_no);
343+
if let Err(e) = self.handler.start(chosen) {
344+
// Initialization failed (e.g. the encoder couldn't be
345+
// created). Roll back to a cleanly *declined* state — the
346+
// same outcome as `choose_format` returning `None` — instead
347+
// of leaving the channel "negotiated" but silently producing
348+
// no audio.
349+
error!(error = %e, "rdpsnd handler failed to start; declining the negotiated format");
350+
self.format_no = None;
351+
}
320352
} else {
321353
debug!("Handler declined every common audio format; audio disabled");
322-
None
323-
};
354+
}
324355
vec![]
325356
}
326357
RdpsndState::Ready => {
@@ -359,77 +390,3 @@ impl Drop for RdpsndServer {
359390
}
360391

361392
impl SvcServerProcessor for RdpsndServer {}
362-
363-
#[cfg(test)]
364-
mod tests {
365-
use super::{audio_format_eq, negotiate_formats};
366-
use crate::pdu::{AudioFormat, WaveFormat};
367-
368-
fn fmt(format: WaveFormat, rate: u32) -> AudioFormat {
369-
AudioFormat {
370-
format,
371-
n_channels: 2,
372-
n_samples_per_sec: rate,
373-
n_avg_bytes_per_sec: rate * 4,
374-
n_block_align: 4,
375-
bits_per_sample: 16,
376-
data: None,
377-
}
378-
}
379-
380-
#[test]
381-
fn wformat_no_addresses_the_client_list_not_the_server_list() {
382-
// Server prefers AAC over PCM; the client lists them in the opposite
383-
// order. wFormatNo must follow the CLIENT's indices.
384-
let server = [fmt(WaveFormat::AAC_MS, 44100), fmt(WaveFormat::PCM, 44100)];
385-
let client = [fmt(WaveFormat::PCM, 44100), fmt(WaveFormat::AAC_MS, 44100)];
386-
387-
let common = negotiate_formats(&server, &client);
388-
389-
// Ordering follows the server's preference (AAC first)...
390-
assert_eq!(common.len(), 2);
391-
assert_eq!(common[0].format().format, WaveFormat::AAC_MS);
392-
assert_eq!(common[1].format().format, WaveFormat::PCM);
393-
// ...but each wFormatNo is the position in the CLIENT list.
394-
assert_eq!(common[0].wformat_no, 1); // AAC is client index 1
395-
assert_eq!(common[1].wformat_no, 0); // PCM is client index 0
396-
}
397-
398-
#[test]
399-
fn pcm_only_client_gets_a_valid_client_index() {
400-
// Regression for the --enable-aac trap: server advertises [AAC, PCM]
401-
// but a PCM-only client must get wFormatNo 0 (its sole index), not
402-
// PCM's server-list index of 1 (which the client would reject).
403-
let server = [fmt(WaveFormat::AAC_MS, 44100), fmt(WaveFormat::PCM, 44100)];
404-
let client = [fmt(WaveFormat::PCM, 44100)];
405-
406-
let common = negotiate_formats(&server, &client);
407-
408-
assert_eq!(common.len(), 1);
409-
assert_eq!(common[0].format().format, WaveFormat::PCM);
410-
assert_eq!(common[0].wformat_no, 0);
411-
}
412-
413-
#[test]
414-
fn no_shared_format_yields_empty() {
415-
let server = [fmt(WaveFormat::OPUS, 48000)];
416-
let client = [fmt(WaveFormat::PCM, 44100)];
417-
assert!(negotiate_formats(&server, &client).is_empty());
418-
}
419-
420-
#[test]
421-
fn equality_uses_waveformatex_identity_only() {
422-
let mut a = fmt(WaveFormat::PCM, 44100);
423-
let mut b = fmt(WaveFormat::PCM, 44100);
424-
// Differ only in derived/codec fields — still the same format.
425-
b.n_avg_bytes_per_sec = 0;
426-
b.n_block_align = 99;
427-
a.data = Some(vec![1, 2, 3]);
428-
b.data = None;
429-
assert!(audio_format_eq(&a, &b));
430-
431-
// A differing identity field (sample rate) is a different format.
432-
let c = fmt(WaveFormat::PCM, 48000);
433-
assert!(!audio_format_eq(&a, &c));
434-
}
435-
}

crates/ironrdp-testsuite-core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ ironrdp-svc.path = "../ironrdp-svc"
5151
ironrdp-input.path = "../ironrdp-input"
5252
ironrdp-rdcleanpath.path = "../ironrdp-rdcleanpath"
5353
ironrdp-rdpdr.path = "../ironrdp-rdpdr"
54-
ironrdp-rdpsnd.path = "../ironrdp-rdpsnd"
54+
ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", features = ["__test"] }
5555
ironrdp-server.path = "../ironrdp-server"
5656
ironrdp-session = { path = "../ironrdp-session", features = ["qoi"] }
5757
ironrdp-cfg.path = "../ironrdp-cfg"

crates/ironrdp-testsuite-core/tests/rdpsnd/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod client;
2+
mod server;
23

34
use std::borrow::Cow;
45

0 commit comments

Comments
 (0)