From d09f28b797d0dd6b577672e763ea9a3ca5099105 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Wed, 7 Oct 2026 10:46:02 -0500 Subject: [PATCH 1/5] feat(conformance): add client-initiated metadata, artwork and controller scenarios RC1 requires every server to support client-initiated connections, but the matrix only opened one for the player role. Declare the three scenarios the adapters already dispatched on without a ScenarioSpec behind them, each a client-initiated repeat of its server-initiated sibling at scenario_revision 1. The sendspin-cpp and sendspin-js client adapters take the new ids on their existing outbound path. The sendspin-go and sendspin-rs client adapters only drive a player session outbound, so they fail fast as a harness gap instead of reporting a missing audio stream. --- README.md | 5 ++ adapters/sendspin-cpp/client/main.cpp | 6 +- adapters/sendspin-go/client/main.go | 17 ++++ adapters/sendspin-js/client.mjs | 10 ++- adapters/sendspin-rs/client/src/main.rs | 17 +++- src/conformance/scenarios.py | 84 ++++++++++++++++--- tests/test_client_initiated_role_scenarios.py | 59 +++++++++++++ tests/test_stream_start_gate.py | 10 ++- 8 files changed, 191 insertions(+), 17 deletions(-) create mode 100644 tests/test_client_initiated_role_scenarios.py diff --git a/README.md b/README.md index 1dd9c36..7cc8705 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ Current scenarios: - `server-initiated-pcm-24bit` (Server initiates connection and client wants 24-bit PCM): start the server first, let the client advertise a listener and 24-bit PCM as its only supported audio format, let the server connect in, negotiate the 24-bit packed wire format, stream it to the client, and compare canonical PCM hashes — a client SDK with no 24-bit decode path misreads the bytes and produces a hash mismatch - `client-initiated-state-format-pcm` (Client changes its preferred PCM bit depth via `client/state`): start the server first, let the client connect advertising two PCM formats (24-bit then 16-bit), let the server stream the higher-priority 24-bit format, have the client send a `client/state` whose player `format` prefers the 16-bit entry, and verify the server received that preference in `client/state` and the client observed a new `stream/start` in the 16-bit format - `client-initiated-state-format-flac` (Client changes its preferred format from PCM to FLAC via `client/state`): start the server first, let the client connect advertising PCM then FLAC, let the server stream the higher-priority PCM format, have the client send a `client/state` whose player `format` prefers the FLAC entry, and verify the server received that preference in `client/state` and the client observed a new `stream/start` in the FLAC format +- `client-initiated-metadata` (Client initiates connection and client wants Metadata): start the server first, let the client discover/connect to it, receive a metadata snapshot, and compare normalized metadata fields +- `client-initiated-artwork` (Client initiates connection and client wants Artwork): start the server first, let the client discover/connect to it, receive album artwork bytes, and compare the encoded image hash +- `client-initiated-controller` (Client initiates connection and client wants Controller): start the server first, let the client discover/connect to it, observe controller state, send a control command, and verify the server recorded it - `server-initiated-audio-chunk-framing` (Server initiates connection and frames PCM audio chunks): start the server first, let the client advertise a listener and PCM as its only supported audio format, let the server connect in and stream, and have both adapters record each audio frame as it crossed the transport; verify that every frame is message type 4 behind the 13-byte header (type, int64 timestamp, uint32 `send_ahead`), that the client read the same 13 bytes off each one, and that no chunk lasted longer than 150 ms or, the final chunk aside, less than 15 ms. The audio itself is not compared, and `send_ahead` saturation is not judged because no adapter can observe when a frame was transmitted ## Current coverage @@ -32,6 +35,8 @@ The OPUS scenario is currently exercised only by the `SendspinKit` and `sendspin The `client-initiated-state-format-*` scenarios have no capability flag of their own: RC1 gives every player the `client/state` `format` preference, so a client that never sends one shows as a failed case. The FLAC variant still needs a client that can list FLAC (`supports_flac`). Only the `aiosendspin` client adapter drives them today. +The `client-initiated-metadata`, `client-initiated-artwork` and `client-initiated-controller` scenarios have no capability flag of their own either: RC1 requires every server to support client-initiated connections, whichever roles the client brings. The `aiosendspin`, `sendspin-dotnet`, `SendspinKit`, `sendspin-cpp` and `sendspin-js` client adapters drive them over the outbound path they use for `client-initiated-pcm`. The `sendspin-go` and `sendspin-rs` client adapters have an outbound path for player sessions only, so their cases fail as a harness gap rather than being left out. + The `server-initiated-audio-chunk-framing` scenario has no capability flag of its own either. A client adapter that is handed parsed chunks and never sees a raw frame reports that, and its case fails as a harness gap rather than being left out. Only the `aiosendspin` adapters, the `sendspin-go` server and the `sendspin-rs` client observe raw frames today. Unsupported client roles use fail-fast adapters that emit a summary and exit non-zero. Unsupported server roles are filtered out before case creation, so the matrix only shows server rows that can actually run a scenario. diff --git a/adapters/sendspin-cpp/client/main.cpp b/adapters/sendspin-cpp/client/main.cpp index 9fffaf3..f3dd76b 100644 --- a/adapters/sendspin-cpp/client/main.cpp +++ b/adapters/sendspin-cpp/client/main.cpp @@ -160,15 +160,15 @@ static bool is_player_scenario(const std::string& id) { } static bool is_metadata_scenario(const std::string& id) { - return id == "server-initiated-metadata"; + return id == "client-initiated-metadata" || id == "server-initiated-metadata"; } static bool is_controller_scenario(const std::string& id) { - return id == "server-initiated-controller"; + return id == "client-initiated-controller" || id == "server-initiated-controller"; } static bool is_artwork_scenario(const std::string& id) { - return id == "server-initiated-artwork"; + return id == "client-initiated-artwork" || id == "server-initiated-artwork"; } static std::string get_arg(int argc, char* argv[], const std::string& name, diff --git a/adapters/sendspin-go/client/main.go b/adapters/sendspin-go/client/main.go index fbaa829..db5d6e6 100644 --- a/adapters/sendspin-go/client/main.go +++ b/adapters/sendspin-go/client/main.go @@ -103,6 +103,23 @@ func run(parsed args) int { ) } + // The outbound path drives a player session only, so any other role would + // report a missing audio stream instead of what its scenario asks for. + if parsed.InitiatorRole == "client" && !conformance.IsPlayerScenario(parsed.ScenarioID) { + if err := conformance.WriteJSON(parsed.Ready, map[string]any{"status": "error"}); err != nil { + log.Printf("failed to write ready file: %v", err) + } + return exitWithSummary( + parsed, + errorSummary( + parsed, + fmt.Sprintf("Harness gap, not a protocol result: the sendspin-go client adapter has no client-initiated path for scenario %s", parsed.ScenarioID), + nil, + nil, + ), + ) + } + if parsed.InitiatorRole == "client" { if err := conformance.WriteJSON(parsed.Ready, conformance.BuildReadyPayload(parsed.ScenarioID, parsed.InitiatorRole, "")); err != nil { log.Printf("failed to write ready file: %v", err) diff --git a/adapters/sendspin-js/client.mjs b/adapters/sendspin-js/client.mjs index 1cd83f2..a3d3da4 100644 --- a/adapters/sendspin-js/client.mjs +++ b/adapters/sendspin-js/client.mjs @@ -41,8 +41,14 @@ const PLAYER_SCENARIOS = new Set([ "server-initiated-legacy-unencrypted", "server-initiated-audio-chunk-framing", ]); -const METADATA_SCENARIOS = new Set(["server-initiated-metadata"]); -const CONTROLLER_SCENARIOS = new Set(["server-initiated-controller"]); +const METADATA_SCENARIOS = new Set([ + "client-initiated-metadata", + "server-initiated-metadata", +]); +const CONTROLLER_SCENARIOS = new Set([ + "client-initiated-controller", + "server-initiated-controller", +]); function parseArgs(argv) { const values = {}; diff --git a/adapters/sendspin-rs/client/src/main.rs b/adapters/sendspin-rs/client/src/main.rs index 8176923..e723042 100644 --- a/adapters/sendspin-rs/client/src/main.rs +++ b/adapters/sendspin-rs/client/src/main.rs @@ -1224,7 +1224,22 @@ async fn run_request_format_client(args: &Args, server_url: &str) -> serde_json: } async fn run(args: Args) -> Result<(), String> { - let summary = if args.initiator_role == "client" { + // The outbound paths drive a player session only, so these roles would + // report a missing audio stream instead of what their scenario asks for. + let lacks_outbound_path = is_metadata_scenario(&args.scenario_id) + || is_controller_scenario(&args.scenario_id) + || is_artwork_scenario(&args.scenario_id); + let summary = if args.initiator_role == "client" && lacks_outbound_path { + write_json(&args.ready, &serde_json::json!({"status": "error"}))?; + build_error_summary( + &args, + &format!( + "Harness gap, not a protocol result: the sendspin-rs client adapter has no \ + client-initiated path for scenario {}", + args.scenario_id + ), + ) + } else if args.initiator_role == "client" { write_json(&args.ready, &build_ready(&args, None))?; match wait_for_server_url(&args.registry, &args.server_name, args.timeout_seconds).await { Ok(server_url) if is_request_format_scenario(&args.scenario_id) => { diff --git a/src/conformance/scenarios.py b/src/conformance/scenarios.py index 30d9d1a..fed2216 100644 --- a/src/conformance/scenarios.py +++ b/src/conformance/scenarios.py @@ -115,6 +115,20 @@ ) +CONTROLLER_EXTRA_CLI_ARGS = ( + ("controller_command", "next"), + ("controller_repeat", "all"), + ("controller_shuffle", "false"), +) + + +ARTWORK_EXTRA_CLI_ARGS = ( + ("artwork_format", "jpeg"), + ("artwork_width", "256"), + ("artwork_height", "256"), +) + + SERVER_INITIATED_METADATA = ScenarioSpec( id="server-initiated-metadata", display_name="Server initiates connection and client wants Metadata", @@ -146,11 +160,7 @@ required_role_families=("controller",), verification_mode="controller", scenario_revision=4, - extra_cli_args=( - ("controller_command", "next"), - ("controller_repeat", "all"), - ("controller_shuffle", "false"), - ), + extra_cli_args=CONTROLLER_EXTRA_CLI_ARGS, ) @@ -167,11 +177,7 @@ required_role_families=("artwork",), verification_mode="artwork", scenario_revision=4, - extra_cli_args=( - ("artwork_format", "jpeg"), - ("artwork_width", "256"), - ("artwork_height", "256"), - ), + extra_cli_args=ARTWORK_EXTRA_CLI_ARGS, ) @@ -230,6 +236,61 @@ ) +CLIENT_INITIATED_METADATA = ScenarioSpec( + id="client-initiated-metadata", + display_name="Client initiates connection and client wants Metadata", + description=( + "Start the server first, then the client. The client discovers or looks up the " + "server and initiates the WebSocket connection, the server sends a metadata state " + "update, disconnects, and the matrix compares a normalized metadata snapshot and " + "checks that the first state carrying a metadata object bore a past or present " + "timestamp." + ), + initiator_role="client", + preferred_codec="none", + required_role_families=("metadata",), + verification_mode="metadata", + scenario_revision=1, + extra_cli_args=METADATA_EXTRA_CLI_ARGS, +) + + +CLIENT_INITIATED_CONTROLLER = ScenarioSpec( + id="client-initiated-controller", + display_name="Client initiates connection and client wants Controller", + description=( + "Start the server first, then the client. The client discovers or looks up the " + "server and initiates the WebSocket connection, the server observes controller " + "state, receives a control command, disconnects, and the matrix verifies the " + "recorded command." + ), + initiator_role="client", + preferred_codec="none", + required_role_families=("controller",), + verification_mode="controller", + scenario_revision=1, + extra_cli_args=CONTROLLER_EXTRA_CLI_ARGS, +) + + +CLIENT_INITIATED_ARTWORK = ScenarioSpec( + id="client-initiated-artwork", + display_name="Client initiates connection and client wants Artwork", + description=( + "Start the server first, then the client. The client discovers or looks up the " + "server and initiates the WebSocket connection, the server streams album artwork, " + "disconnects, and the matrix compares the received bytes against the server's " + "encoded artwork." + ), + initiator_role="client", + preferred_codec="none", + required_role_families=("artwork",), + verification_mode="artwork", + scenario_revision=1, + extra_cli_args=ARTWORK_EXTRA_CLI_ARGS, +) + + SERVER_INITIATED_LEGACY_UNENCRYPTED = ScenarioSpec( id="server-initiated-legacy-unencrypted", display_name="Server initiates connection over the legacy unencrypted transition mode", @@ -283,6 +344,9 @@ SERVER_INITIATED_PCM_24BIT, CLIENT_INITIATED_STATE_FORMAT_PCM, CLIENT_INITIATED_STATE_FORMAT_FLAC, + CLIENT_INITIATED_METADATA, + CLIENT_INITIATED_ARTWORK, + CLIENT_INITIATED_CONTROLLER, SERVER_INITIATED_LEGACY_UNENCRYPTED, SERVER_INITIATED_AUDIO_CHUNK_FRAMING, ) diff --git a/tests/test_client_initiated_role_scenarios.py b/tests/test_client_initiated_role_scenarios.py new file mode 100644 index 0000000..3edcccf --- /dev/null +++ b/tests/test_client_initiated_role_scenarios.py @@ -0,0 +1,59 @@ +"""Coverage for the client-initiated metadata, artwork and controller scenarios. + +Each one repeats a server-initiated scenario with the connection opened from the +other side, so the two must ask the same thing of a pair and be offered to the +same implementations. A sibling that drifted would make the two rows of the +report compare different behaviour under matching names. +""" + +from __future__ import annotations + +import unittest + +from conformance.implementations import IMPLEMENTATIONS, implementations_for_scenario +from conformance.scenarios import require_scenario + +ROLE_FAMILIES = ("metadata", "artwork", "controller") + + +class ClientInitiatedRoleScenarioTests(unittest.TestCase): + def _pairs(self): + for family in ROLE_FAMILIES: + yield ( + require_scenario(f"client-initiated-{family}"), + require_scenario(f"server-initiated-{family}"), + ) + + def test_differs_from_its_sibling_only_in_who_connects(self) -> None: + for scenario, sibling in self._pairs(): + with self.subTest(scenario=scenario.id): + self.assertEqual(scenario.initiator_role, "client") + self.assertEqual(sibling.initiator_role, "server") + self.assertEqual(scenario.verification_mode, sibling.verification_mode) + self.assertEqual(scenario.required_role_families, sibling.required_role_families) + self.assertEqual(scenario.preferred_codec, sibling.preferred_codec) + self.assertEqual(scenario.extra_cli_args, sibling.extra_cli_args) + + def test_runs_against_every_server_its_sibling_does(self) -> None: + for scenario, sibling in self._pairs(): + with self.subTest(scenario=scenario.id): + self.assertEqual( + implementations_for_scenario(role="server", scenario=scenario), + implementations_for_scenario(role="server", scenario=sibling), + ) + + def test_no_client_is_judged_unsupported_unless_its_sibling_is(self) -> None: + for scenario, sibling in self._pairs(): + for name, implementation in IMPLEMENTATIONS.items(): + with self.subTest(scenario=scenario.id, client=name): + unsupported = implementation.client.unsupported_reason( + implementation=name, role="client", scenario=scenario + ) + sibling_unsupported = implementation.client.unsupported_reason( + implementation=name, role="client", scenario=sibling + ) + self.assertEqual(unsupported is None, sibling_unsupported is None) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_stream_start_gate.py b/tests/test_stream_start_gate.py index 326da70..e0a7e98 100644 --- a/tests/test_stream_start_gate.py +++ b/tests/test_stream_start_gate.py @@ -212,7 +212,15 @@ def test_only_scenarios_that_open_a_stream_are_judged(self) -> None: } unjudged = {scenario_id for scenario_id, (matches, _) in verdicts.items() if matches} - self.assertEqual(unjudged, {"server-initiated-metadata", "server-initiated-controller"}) + self.assertEqual( + unjudged, + { + "server-initiated-metadata", + "server-initiated-controller", + "client-initiated-metadata", + "client-initiated-controller", + }, + ) for scenario_id in verdicts.keys() - unjudged: with self.subTest(scenario_id): self.assertTrue(verdicts[scenario_id][1].startswith(HARNESS_GAP)) From 6f1a37874a6b6a4a43237239c6a5251813d93afc Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Wed, 7 Oct 2026 10:50:51 -0500 Subject: [PATCH 2/5] chore(sendspin-rs adapter): remove the retired request-format dispatch The client-initiated-request-format-* scenarios no longer exist, and the branch behind them sent stream/request-format, a message RC1 does not have. --- adapters/sendspin-rs/client/src/main.rs | 218 +----------------------- 1 file changed, 1 insertion(+), 217 deletions(-) diff --git a/adapters/sendspin-rs/client/src/main.rs b/adapters/sendspin-rs/client/src/main.rs index e723042..92644b9 100644 --- a/adapters/sendspin-rs/client/src/main.rs +++ b/adapters/sendspin-rs/client/src/main.rs @@ -5,7 +5,7 @@ use sendspin::protocol::messages::{ ArtworkChannel, ArtworkSource, ArtworkV1Support, AudioFormatSpec, ClientCommand, ClientHello, ClientState, ClientSyncState, ClientTime, ConnectionReason, ControllerCommand, ControllerCommandType, ControllerState, DeviceInfo, ImageFormat, Message, MetadataState, - PlayerFormatRequest, PlayerState, PlayerV1Support, ServerHello, StreamPlayerConfig, + PlayerState, PlayerV1Support, ServerHello, StreamPlayerConfig, }; use sendspin::ProtocolClientBuilder; use sha2::{Digest, Sha256}; @@ -218,100 +218,6 @@ fn is_artwork_scenario(scenario_id: &str) -> bool { ) } -fn is_request_format_scenario(scenario_id: &str) -> bool { - matches!( - scenario_id, - "client-initiated-request-format-pcm" | "client-initiated-request-format-flac" - ) -} - -/// The formats a renegotiation scenario advertises (priority order) and the -/// format it later requests via `stream/request-format`. The server starts on -/// the first advertised format, then switches to the requested one. -fn request_format_plan(scenario_id: &str) -> (Vec, PlayerFormatRequest) { - let pcm = |bit_depth: u8| AudioFormatSpec { - codec: "pcm".to_string(), - channels: 1, - sample_rate: 8000, - bit_depth, - }; - if scenario_id == "client-initiated-request-format-flac" { - let flac = AudioFormatSpec { - codec: "flac".to_string(), - channels: 1, - sample_rate: 8000, - bit_depth: 16, - }; - return ( - vec![pcm(16), flac], - PlayerFormatRequest { - codec: Some("flac".to_string()), - channels: Some(1), - sample_rate: Some(8000), - bit_depth: Some(16), - }, - ); - } - // Default: PCM bit-depth downgrade (24-bit preferred, request 16-bit). - ( - vec![pcm(24), pcm(16)], - PlayerFormatRequest { - codec: Some("pcm".to_string()), - channels: Some(1), - sample_rate: Some(8000), - bit_depth: Some(16), - }, - ) -} - -fn stream_format_json(stream: &StreamPlayerConfig) -> serde_json::Value { - serde_json::json!({ - "codec": stream.codec, - "sample_rate": stream.sample_rate, - "bit_depth": stream.bit_depth, - "channels": stream.channels, - }) -} - -fn player_format_request_json(request: &PlayerFormatRequest) -> serde_json::Value { - serde_json::json!({ - "codec": request.codec, - "sample_rate": request.sample_rate, - "bit_depth": request.bit_depth, - "channels": request.channels, - }) -} - -fn build_renegotiation_summary( - args: &Args, - status: &str, - reason: Option<&str>, - server_hello: Option<&ServerHello>, - renegotiation: serde_json::Value, -) -> serde_json::Value { - serde_json::json!({ - "status": status, - "reason": reason, - "implementation": "sendspin-rs", - "role": "client", - "scenario_id": args.scenario_id, - "initiator_role": args.initiator_role, - "preferred_codec": args.preferred_codec, - "client_name": args.client_name, - "client_id": args.client_id, - "server": server_hello.map(|hello| { - serde_json::json!({ - "server_id": hello.server_id, - "name": hello.name, - "version": hello.version, - "active_roles": hello.active_roles, - "connection_reason": hello.connection_reason, - }) - }), - "renegotiation": renegotiation, - }) -} - async fn wait_for_server_url( registry_path: &Path, server_name: &str, @@ -1104,125 +1010,6 @@ async fn run_outbound_protocol_client(args: &Args, server_url: &str) -> serde_js } } -async fn run_request_format_client(args: &Args, server_url: &str) -> serde_json::Value { - let (supported_formats, requested) = request_format_plan(&args.scenario_id); - - let builder = ProtocolClientBuilder::builder() - .client_id(args.client_id.clone()) - .name(args.client_name.clone()) - .product_name(Some("sendspin-rs Conformance Client".to_string())) - .manufacturer(Some("Sendspin Conformance".to_string())) - .software_version(Some("0.1.0".to_string())) - .player_v1_support(PlayerV1Support { - supported_formats, - buffer_capacity: 2_000_000, - supported_commands: vec!["volume".to_string(), "mute".to_string()], - }) - .build(); - - let client = match builder.connect(server_url).await { - Ok(client) => client, - Err(err) => { - return build_error_summary(args, &format!("Failed to connect to {server_url}: {err}")); - } - }; - - let conn = client.split(); - let mut message_rx = conn.messages; - let mut audio_rx = conn.audio; - let _artwork_rx = conn.artwork; - let _visualizer_rx = conn.visualizer; - let _clock_sync = conn.clock_sync; - let ws_tx = conn.sender; - let server_hello = conn.server_hello; - let _guard = conn.guard; - - // Renegotiation is verified from stream/start messages alone, but the audio - // channel must still be consumed so the SDK keeps the session flowing. - tokio::spawn(async move { while audio_rx.recv().await.is_some() {} }); - - if let Err(err) = ws_tx - .send_message(Message::ClientState(ClientState { - state: Some(ClientSyncState::Synchronized), - player: Some(PlayerState { - volume: Some(100), - muted: Some(false), - static_delay_ms: None, - min_buffer_ms: None, - required_lead_time_ms: None, - supported_commands: None, - }), - })) - .await - { - return build_error_summary(args, &err.to_string()); - } - - let mut stream_start_count = 0usize; - let mut initial_format: Option = None; - let mut final_format: Option = None; - let timeout = Duration::from_secs_f64(args.timeout_seconds); - - let read_result = tokio::time::timeout(timeout, async { - loop { - let Some(message) = message_rx.recv().await else { - break; - }; - match message { - Message::StreamStart(stream_start) => { - let Some(format) = stream_start.player else { - continue; - }; - stream_start_count += 1; - if stream_start_count == 1 { - initial_format = Some(format); - ws_tx - .request_player_format(requested.clone()) - .await - .map_err(|err| err.to_string())?; - } else { - final_format = Some(format); - } - } - Message::StreamEnd(_) - | Message::StreamClear(_) - | Message::ServerState(_) - | Message::GroupUpdate(_) - | Message::ServerCommand(_) => {} - other => { - return Err(format!("Unexpected server message: {other:?}")); - } - } - } - Ok::<(), String>(()) - }) - .await; - - let renegotiation = serde_json::json!({ - "stream_start_count": stream_start_count, - "requested": player_format_request_json(&requested), - "initial_format": initial_format.as_ref().map(stream_format_json), - "final_format": final_format.as_ref().map(stream_format_json), - }); - - let (status, reason): (&str, Option) = match read_result { - Err(_) => ( - "error", - Some("Timed out waiting for server disconnect".to_string()), - ), - Ok(Err(reason)) => ("error", Some(reason)), - Ok(Ok(())) => ("ok", None), - }; - - build_renegotiation_summary( - args, - status, - reason.as_deref(), - Some(&server_hello), - renegotiation, - ) -} - async fn run(args: Args) -> Result<(), String> { // The outbound paths drive a player session only, so these roles would // report a missing audio stream instead of what their scenario asks for. @@ -1242,9 +1029,6 @@ async fn run(args: Args) -> Result<(), String> { } else if args.initiator_role == "client" { write_json(&args.ready, &build_ready(&args, None))?; match wait_for_server_url(&args.registry, &args.server_name, args.timeout_seconds).await { - Ok(server_url) if is_request_format_scenario(&args.scenario_id) => { - run_request_format_client(&args, &server_url).await - } Ok(server_url) => run_outbound_protocol_client(&args, &server_url).await, Err(reason) => build_error_summary(&args, &reason), } From b9a78bfe843d16a644b3a8451d574f69c721ea15 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Wed, 7 Oct 2026 10:53:35 -0500 Subject: [PATCH 3/5] fix(SendspinKit adapter): key the client/state format preference to the declared scenarios The branch that sends the client/state format preference answered to the retired client-initiated-request-format-* ids, so the scenarios that replaced them never reached it and SendspinKit failed both. --- README.md | 2 +- .../Sources/ConformanceSendspinKitClient/main.swift | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7cc8705..7d31025 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Current scenarios: The OPUS scenario is currently exercised only by the `SendspinKit` and `sendspin-cpp` clients against the `aiosendspin` server until other implementations opt in via `supports_opus`. -The `client-initiated-state-format-*` scenarios have no capability flag of their own: RC1 gives every player the `client/state` `format` preference, so a client that never sends one shows as a failed case. The FLAC variant still needs a client that can list FLAC (`supports_flac`). Only the `aiosendspin` client adapter drives them today. +The `client-initiated-state-format-*` scenarios have no capability flag of their own: RC1 gives every player the `client/state` `format` preference, so a client that never sends one shows as a failed case. The FLAC variant still needs a client that can list FLAC (`supports_flac`). The `aiosendspin` and `SendspinKit` client adapters drive them today. The `client-initiated-metadata`, `client-initiated-artwork` and `client-initiated-controller` scenarios have no capability flag of their own either: RC1 requires every server to support client-initiated connections, whichever roles the client brings. The `aiosendspin`, `sendspin-dotnet`, `SendspinKit`, `sendspin-cpp` and `sendspin-js` client adapters drive them over the outbound path they use for `client-initiated-pcm`. The `sendspin-go` and `sendspin-rs` client adapters have an outbound path for player sessions only, so their cases fail as a harness gap rather than being left out. diff --git a/adapters/SendspinKit/client/Sources/ConformanceSendspinKitClient/main.swift b/adapters/SendspinKit/client/Sources/ConformanceSendspinKitClient/main.swift index 65008b7..a240fbd 100644 --- a/adapters/SendspinKit/client/Sources/ConformanceSendspinKitClient/main.swift +++ b/adapters/SendspinKit/client/Sources/ConformanceSendspinKitClient/main.swift @@ -129,8 +129,8 @@ struct CliOptions { } var isFormatPreferenceScenario: Bool { - scenarioID == "client-initiated-request-format-pcm" - || scenarioID == "client-initiated-request-format-flac" + scenarioID == "client-initiated-state-format-pcm" + || scenarioID == "client-initiated-state-format-flac" } /// Codecs whose conformance check compares the raw encoded chunk bytes the @@ -989,8 +989,8 @@ struct ConformanceSendspinKitClient { if options.scenarioID == "server-initiated-pcm-24bit" { formats = try [AudioFormatSpec(codec: .pcm, channels: 1, sampleRate: 8000, bitDepth: 24)] } else if options.isFormatPreferenceScenario { - let initialDepth = options.scenarioID == "client-initiated-request-format-pcm" ? 24 : 16 - let targetCodec: AudioCodec = options.scenarioID == "client-initiated-request-format-flac" ? .flac : .pcm + let initialDepth = options.scenarioID == "client-initiated-state-format-pcm" ? 24 : 16 + let targetCodec: AudioCodec = options.scenarioID == "client-initiated-state-format-flac" ? .flac : .pcm formats = try [ AudioFormatSpec(codec: .pcm, channels: 1, sampleRate: 8000, bitDepth: initialDepth), AudioFormatSpec(codec: targetCodec, channels: 1, sampleRate: 8000, bitDepth: 16), @@ -1111,7 +1111,7 @@ struct ConformanceSendspinKitClient { await collector.recordStreamFormat(format, codecHeader: await client.currentCodecHeader) if !preferenceSent { preferenceSent = true - let codec: AudioCodec = options.scenarioID == "client-initiated-request-format-flac" ? .flac : .pcm + let codec: AudioCodec = options.scenarioID == "client-initiated-state-format-flac" ? .flac : .pcm let requested = try AudioFormatSpec(codec: codec, channels: 1, sampleRate: 8000, bitDepth: 16) try await client.setPlayerFormatPreference(requested) await collector.recordPreferenceRequest(requested) @@ -1125,7 +1125,7 @@ struct ConformanceSendspinKitClient { await collector.recordStreamFormat(format, codecHeader: codecHeader) if options.isFormatPreferenceScenario, !preferenceSent { preferenceSent = true - let targetCodec: AudioCodec = options.scenarioID == "client-initiated-request-format-flac" ? .flac : .pcm + let targetCodec: AudioCodec = options.scenarioID == "client-initiated-state-format-flac" ? .flac : .pcm let requested = try AudioFormatSpec(codec: targetCodec, channels: 1, sampleRate: 8000, bitDepth: 16) try await client.setPlayerFormatPreference(requested) await collector.recordPreferenceRequest(requested) From b628a7eceb43ab0f305de03b5d3a9b8680ca3d81 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Wed, 7 Oct 2026 11:01:48 -0500 Subject: [PATCH 4/5] fix(sendspin-jvm adapter): wait for the command to be advertised before sending it client-initiated The server sets repeat and shuffle before it lists supported commands, so the first controller state can arrive without the command the scenario sends. --- .../client/src/main/kotlin/com/sendspin/conformance/Main.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/Main.kt b/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/Main.kt index 7670b25..d9057b3 100644 --- a/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/Main.kt +++ b/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/Main.kt @@ -322,7 +322,11 @@ private suspend fun runClientInitiated( val serverHello = client.serverHello.first { it != null }!! if (config.scenarioId == "client-initiated-controller") { - client.controllerState.first { it != null } + // Wait for a controller state that explicitly advertises the expected command — sending + // before the server includes the command in supported_commands causes it to reject it. + while (client.controllerState.value?.supportedCommands?.contains(config.controllerCommand) != true) { + kotlinx.coroutines.delay(10) + } client.sendControllerCommand(config.controllerCommand) onCommandSent(config.controllerCommand) } From 550513b6614b7e02958bd8315a8339c254acf5f6 Mon Sep 17 00:00:00 2001 From: Chris Uthe Date: Wed, 7 Oct 2026 11:01:48 -0500 Subject: [PATCH 5/5] docs: separate the sendspin-js scenarios driven from the one marked unsupported --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7d31025..aadf939 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ The OPUS scenario is currently exercised only by the `SendspinKit` and `sendspin The `client-initiated-state-format-*` scenarios have no capability flag of their own: RC1 gives every player the `client/state` `format` preference, so a client that never sends one shows as a failed case. The FLAC variant still needs a client that can list FLAC (`supports_flac`). The `aiosendspin` and `SendspinKit` client adapters drive them today. -The `client-initiated-metadata`, `client-initiated-artwork` and `client-initiated-controller` scenarios have no capability flag of their own either: RC1 requires every server to support client-initiated connections, whichever roles the client brings. The `aiosendspin`, `sendspin-dotnet`, `SendspinKit`, `sendspin-cpp` and `sendspin-js` client adapters drive them over the outbound path they use for `client-initiated-pcm`. The `sendspin-go` and `sendspin-rs` client adapters have an outbound path for player sessions only, so their cases fail as a harness gap rather than being left out. +The `client-initiated-metadata`, `client-initiated-artwork` and `client-initiated-controller` scenarios have no capability flag of their own either: RC1 requires every server to support client-initiated connections, whichever roles the client brings. The `aiosendspin`, `sendspin-dotnet`, `SendspinKit` and `sendspin-cpp` client adapters drive all three over the outbound path they use for `client-initiated-pcm`. The `sendspin-js` client adapter drives the metadata and controller scenarios the same way; it declares no `artwork` role family, so the harness marks its `client-initiated-artwork` cases unsupported before launching anything. The `sendspin-go` and `sendspin-rs` client adapters have an outbound path for player sessions only, so their cases fail as a harness gap rather than being left out. The `server-initiated-audio-chunk-framing` scenario has no capability flag of its own either. A client adapter that is handed parsed chunks and never sees a raw frame reports that, and its case fails as a harness gap rather than being left out. Only the `aiosendspin` adapters, the `sendspin-go` server and the `sendspin-rs` client observe raw frames today.