diff --git a/CLAUDE.md b/CLAUDE.md index ecda384..0eae2f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,7 +95,7 @@ script/clang-tidy.sh - clang-tidy over src/ using build/compile_commands.js - Library target `sendspin_server_cpp` (alias `sendspin_server_cpp::sendspin_server_cpp`); CMake options are prefixed `SENDSPIN_SERVER_`. `SENDSPIN_SERVER_ENABLE_WERROR` (default off) makes warnings in this project's targets errors; CI builds with it on, so local builds should too. `SENDSPIN_SERVER_EMBEDDED_PROFILE` (default off) builds with `src/profile.h`'s embedded limits, as the ESP-IDF component (`cmake/esp_idf.cmake`) always does. - Plain: `cmake -B build && cmake --build build`, which builds `examples/` too (`SENDSPIN_SERVER_BUILD_EXAMPLES`, default on when top-level). Uses ccache when installed (`SENDSPIN_SERVER_USE_CCACHE`, default on when top-level). `SENDSPIN_SERVER_ENABLE_DISCOVERY` (default on) builds the mDNS backend: dns_sd on macOS, avahi-client through pkg-config on Linux (`libavahi-client-dev`). `SENDSPIN_SERVER_ENABLE_OPUS` (default on) builds the Opus encoder and the source role's Opus decoder; off compiles them out (`src/frame_encoder.cpp` and `src/source_decoder.cpp` are the places it is guarded) and is refused with the tests on. - Tests never use the network's mDNS: `tests/test_recorder.h` `local_config()` turns advertising and browsing off and listens on a port the system picks, and `test_server_discovery.cpp` supplies its own `DiscoveryProvider` through `ServerConfig::discovery`. -- Tests: `cmake -B build-asan -DSENDSPIN_SERVER_ENABLE_WERROR=ON -DSENDSPIN_SERVER_BUILD_TESTS=ON -DSENDSPIN_SERVER_ENABLE_SANITIZERS=ON`, then `cmake --build build-asan` and `ctest --test-dir build-asan --output-on-failure`. The suites whose assertions depend on players keeping up with real time carry the CTest label `realtime` (`tests/CMakeLists.txt`); CI runs them on their own and the rest in parallel (`-LE realtime -j`). The tests also build `server_cli`'s WAV and FLAC readers, feeders, `--one-group`, timing figures and log and `--web` dashboard; `tests/temp_dir.h` gives a test a temporary directory, and `tests/job_view.h` a copy of what a group's pipelines read of a host view. `tests/test_ports.h` hands out loopback ports for test peers, and holds one that nothing listens on for a dial that must be refused; `tests/raw_client.h` is the raw test client (noise-c and Beast, either suite, scriptable misbehavior), `tests/silent_listener.h` a listener that holds the server's dials in their upgrade or handshake, `tests/test_recorder.h` a recording listener, an in-memory provider and `LocalServer`, a server started with them, `tests/wire_messages.h` the spec's binary message types and fields as the tests decode them, `tests/flac_reader.h` decodes a FLAC stream as a player gets it, `tests/opus_reader.h` an Opus stream, with the test tones and an SNR to compare lossy audio by, `tests/player_sink.h` holds the test signal and a real-time audio output for sendspin-cpp players, `tests/peer.h` what each in-process sendspin-cpp client shares (its network provider and blob store, its config and the URL the server dials, its main loop run on the test thread, the client_id at a URL), `tests/trust_fixtures.h` distinct PSKs and the client record a server saved, `tests/hex.h` hex text for known answers, `tests/gate.h` a gate a job waits at, the fixtures and harnesses a class's two test files share (`tests/host_view_fixture.h`, `tests/group_pipelines.h`, `tests/session_harness.h`, `tests/trust_harness.h`), `tests/group_job.h` a group's pipeline job wrapped between runs, for the files compiled with `-fno-access-control` alone, and `tests/freed_blocks.h` counts the heap blocks freed still holding a secret, through the sanitizer's free hook (a test that needs it skips itself without ASan or TSan). +- Tests: `cmake -B build-asan -DSENDSPIN_SERVER_ENABLE_WERROR=ON -DSENDSPIN_SERVER_BUILD_TESTS=ON -DSENDSPIN_SERVER_ENABLE_SANITIZERS=ON`, then `cmake --build build-asan` and `ctest --test-dir build-asan --output-on-failure`. The suites whose assertions depend on players keeping up with real time carry the CTest label `realtime` (`tests/CMakeLists.txt`); CI runs them on their own and the rest in parallel (`-LE realtime -j`). The tests also build `server_cli`'s WAV and FLAC readers, feeders, `--one-group`, timing figures and log and `--web` dashboard; `tests/temp_dir.h` gives a test a temporary directory, and `tests/job_view.h` a copy of what a group's pipelines read of a host view. `tests/test_ports.h` hands out loopback ports for test peers, and holds one that nothing listens on for a dial that must be refused; `tests/raw_client.h` is the raw test client (noise-c and Beast, either suite, scriptable misbehavior), `tests/silent_listener.h` a listener that holds the server's dials in their upgrade or handshake, `tests/test_recorder.h` a recording listener, an in-memory provider and `LocalServer`, a server started with them, `tests/wire_messages.h` the spec's binary message types and fields as the tests decode them, `tests/flac_reader.h` decodes a FLAC stream as a player gets it, `tests/opus_reader.h` an Opus stream, with the test tones and an SNR to compare lossy audio by, `tests/player_sink.h` holds the test signal and a real-time audio output for sendspin-cpp players, `tests/peer.h` what each in-process sendspin-cpp client shares (its network provider and blob store, its config and the URL the server dials, its main loop run on the test thread, the client_id at a URL), `tests/trust_fixtures.h` distinct PSKs and the client record a server saved, `tests/hex.h` hex text for known answers, `tests/gate.h` a gate a job waits at, the fixtures and harnesses a class's two test files share (`tests/host_view_fixture.h`, `tests/group_pipelines.h`, `tests/session_harness.h`, `tests/trust_harness.h`), `tests/raw_roles.h` the messages a raw client sends to drive the roles and the readers of what it receives, `tests/players_rig.h` the rig, writes and checks of the in-process player tests, `tests/group_job.h` a group's pipeline job wrapped between runs, for the files compiled with `-fno-access-control` alone, and `tests/freed_blocks.h` counts the heap blocks freed still holding a secret, through the sanitizer's free hook (a test that needs it skips itself without ASan or TSan). - Soak: `tests/soak.cpp` (its trace checker in `tests/soak_wire_checker.h`, tested in the default suite), target `sendspin_server_cpp_soak` with its own entry point, registered as the CTest test `Soak` (label `soak`), which skips itself unless `SENDSPIN_SERVER_SOAK_SECONDS` is set (`SENDSPIN_SERVER_SOAK_SEED` picks the script); README.md has the command. - CI also builds the library with Opus and discovery off and memory statistics on; as a subproject (`tests/consumer/`); and as the ESP-IDF component (`tests/esp_idf/`: `idf.py -C tests/esp_idf set-target esp32s3`, then `idf.py -C tests/esp_idf build`). - Memory statistics: `SENDSPIN_SERVER_MEMORY_STATS` (default off; `CONFIG_SENDSPIN_SERVER_MEMORY_STATS` in the component's `Kconfig`) logs the heaps and the tasks' stacks at the server's start (on the thread calling `start()`) and, on the core thread, at a connection's start and end and a group's playback (`profile::MEMORY_STATS`); the tests count log lines and expect it off. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bef2f96..30a1e59 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -31,6 +31,7 @@ add_executable(sendspin_server_cpp_tests test_frame_encoder.cpp test_group_audio.cpp test_group_audio_internals.cpp + test_group_internals.cpp test_host_view.cpp test_host_view_internals.cpp test_inbound_hold.cpp @@ -64,9 +65,11 @@ add_executable(sendspin_server_cpp_tests test_sched_lifecycle.cpp test_sched_lowering.cpp test_sched_property.cpp + test_sched_run_view.cpp test_sched_selection.cpp test_sched_stalls.cpp test_sched_timeline.cpp + test_scheduled_updates.cpp test_send_queues.cpp test_server.cpp test_server_accept.cpp @@ -74,6 +77,7 @@ add_executable(sendspin_server_cpp_tests test_server_core.cpp test_server_core_internals.cpp test_server_discovery.cpp + test_server_groups.cpp test_server_limits.cpp test_server_log.cpp test_server_pairing.cpp @@ -92,6 +96,7 @@ add_executable(sendspin_server_cpp_tests test_session_internals.cpp test_source_clock.cpp test_source_decoder.cpp + test_source_feed.cpp test_source_sink.cpp test_source_stream.cpp test_soak_wire_checker.cpp @@ -100,6 +105,7 @@ add_executable(sendspin_server_cpp_tests test_timing_log.cpp test_timing_stats.cpp test_transport.cpp + test_transport_url.cpp test_trust_policy.cpp test_url_text.cpp test_visualizer.cpp @@ -143,21 +149,7 @@ target_compile_definitions(sendspin_server_cpp_tests PRIVATE # White-box: each file below compiles with -fno-access-control, beside the private members it # reaches and why; a class's other tests are in a file that compiles without it. # test_audio_workers_internals.cpp reads AudioWorkers::pool_ (Pool::mutex and stopping) to let a -# job finish only once a stop has begun, which no observable state shows. It wraps a group's -# pipeline job (Group::audio_) with group_job.h, to hold it running while core erases the group or -# to learn when its next run is over, and reads Group::playing_ and input_ on core to show a -# release ended the run and detached the input. Its stall tests set the state an epoch's start -# sets on a Group with no input (playing_, epoch_, anchor_, rings_, pipelines_.analyses), call its -# on_frame(), on_anchor_shift() and report_stalls() as the group-to-core FIFO and its timer do, -# and replace Group::stalls_ with a count whose last report was made at a chosen time, so a -# held-back report is due within the test rather than after 10 s, and take from it to read the -# counts, which only the log line shows otherwise. It calls Group::on_wake() as the group's wakeup -# does, since a write's signal can fold into a pipeline job's that is not posted yet, and reads -# HostView::slew_ and next_slew_ under HostView::mutex_, as only their storage shows that core -# freed the slew a write left it. -# group_job.h, which only the files given the flag include, waits for a task between runs under -# its pool's mutex (AudioWorkers::Task::pool_, running_ and queued_) to replace its job -# (Task::job_), and reads a group's pipelines from the job (Group::PipelineJob::audio). +# job finish only once a stop has begun, which no observable state shows. set_source_files_properties(test_audio_workers_internals.cpp PROPERTIES COMPILE_OPTIONS -fno-access-control) # test_dispatcher_internals.cpp reads Dispatcher::token_ once the thread has ended: a later thread @@ -173,6 +165,22 @@ set_source_files_properties(test_dispatcher_internals.cpp PROPERTIES COMPILE_OPT # the pipelines publish for admission, which only shows in admission's bounds. set_source_files_properties(test_group_audio_internals.cpp PROPERTIES COMPILE_OPTIONS -fno-access-control) +# test_group_internals.cpp wraps a group's pipeline job (Group::audio_) with group_job.h, to hold +# it running while core erases the group or to learn when its next run is over, and reads +# Group::playing_ and input_ on core to show a release ended the run and detached the input. Its +# stall tests set the state an epoch's start sets on a Group with no input (playing_, epoch_, +# anchor_, rings_, pipelines_.analyses), call its on_frame(), on_anchor_shift() and +# report_stalls() as the group-to-core FIFO and its timer do, and replace Group::stalls_ with a +# count whose last report was made at a chosen time, so a held-back report is due within the test +# rather than after 10 s, and take from it to read the counts, which only the log line shows +# otherwise. It calls Group::on_wake() as the group's wakeup does, since a write's signal can fold +# into a pipeline job's that is not posted yet, and reads HostView::slew_ and next_slew_ under +# HostView::mutex_, as only their storage shows that core freed the slew a write left it. +# group_job.h, which only the files given the flag include, waits for a task between runs under +# its pool's mutex (AudioWorkers::Task::pool_, running_ and queued_) to replace its job +# (Task::job_), and reads a group's pipelines from the job (Group::PipelineJob::audio). +set_source_files_properties(test_group_internals.cpp PROPERTIES COMPILE_OPTIONS + -fno-access-control) # test_host_view_internals.cpp leaves the outbox (HostView::outbox_, outbox_posted_) as a host call # does between deciding a callback and posting it, so that close() racing that host call can be # checked without a race. It sets HostView::lowering_ to a window of live writes already behind diff --git a/tests/players_rig.h b/tests/players_rig.h new file mode 100644 index 0000000..f7cd561 --- /dev/null +++ b/tests/players_rig.h @@ -0,0 +1,627 @@ +// Copyright 2026 Sendspin Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// @file players_rig.h +/// @brief What the in-process player tests run: the host's writes of the test signal in segments, +/// sendspin-cpp players the server dials, what the server sent each player decoded from its trace, +/// what each player played, the checks on both, and the server, its trace and its players together + +#pragma once + +#include "flac_reader.h" +#include "peer.h" +#include "player_sink.h" +#include "test_ports.h" +#include "test_recorder.h" +#include "wire_messages.h" +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sendspin::server::test { + +inline constexpr uint32_t RATE = 48000; +inline constexpr uint32_t CELL = RATE / 50; +inline constexpr int64_t CELL_US = 20000; + +/// A frame's duration at rate: the cells that hold a whole number of samples +inline int64_t frame_us(uint32_t rate) { + return int64_t{50 / std::gcd(rate, 50U)} * CELL_US; +} + +/// A stretch of the signal the host writes: positions first to first + count - 1 +struct Segment { + uint32_t first{0}; + uint32_t count{0}; + + bool holds(uint32_t position) const { + return position >= this->first && position - this->first < this->count; + } +}; + +/// Writes segment, waiting for room, until seek is set +/// @param fed set to the frames written so far +inline void feed(GroupInput& input, const Signal& signal, const Segment& segment, + const std::atomic* seek = nullptr, uint32_t* fed = nullptr) { + constexpr uint32_t BLOCK = 960; + Bytes block(BLOCK * signal.frame_bytes()); + uint32_t written = 0; + while (written < segment.count) { + if (seek != nullptr && seek->load()) { + return; + } + const uint32_t n = std::min(BLOCK, segment.count - written); + for (uint32_t i = 0; i < n; ++i) { + signal.put(&block[i * signal.frame_bytes()], segment.first + written + i); + } + const WriteResult result = input.write(block.data(), n); + written += static_cast(result.frames); + if (fed != nullptr) { + *fed = written; + } + if (result.frames < n) { + ASSERT_EQ(result.state, InputState::FULL); + // Short waits, so a seek is seen while the input is full + input.wait_writable(seek != nullptr ? 20000 : std::numeric_limits::max()); + } + } +} + +/// A sendspin-cpp client with a player role listing formats, listening for the server to dial; +/// its output plays frames of frame_bytes at rate. It asks for 150 ms of buffer unless told +/// otherwise. +class Player { +public: + /// @param min_buffer_ms, required_lead_ms what its client/state reports + /// @param buffer_bytes its audio buffer, of which it advertises two thirds as buffer_capacity + Player(const std::vector& formats, size_t frame_bytes, + uint32_t rate, uint16_t min_buffer_ms = 150, + std::optional required_lead_ms = std::nullopt, + size_t buffer_bytes = sendspin::PlayerRoleConfig::DEFAULT_AUDIO_BUFFER_CAPACITY) + : port_(sendspin::server::test::free_port()), + client_(sendspin::server::test::peer_config("Player " + std::to_string(this->port_), + this->port_)) { + sendspin::PlayerRoleConfig config; + config.audio_formats = formats; + config.min_buffer_ms = min_buffer_ms; + config.required_lead_time_ms = required_lead_ms; + config.audio_buffer_capacity = buffer_bytes; + sendspin::PlayerRole& role = this->client_.add_player(config); + this->sink_ = std::make_unique(role, frame_bytes, rate); + role.set_listener(this->sink_.get()); + this->client_.set_network_provider(&this->network_); + this->client_.set_unpaired_access_enabled(true); + EXPECT_TRUE(this->client_.start()); + } + + ~Player() { + this->client_.stop(); + } + + Player(const Player&) = delete; + Player& operator=(const Player&) = delete; + + std::string url() const { + return sendspin::server::test::peer_url(this->port_); + } + + sendspin::SendspinClient& client() { + return this->client_; + } + + const Sink& sink() const { + return *this->sink_; + } + +private: + uint16_t port_; + AlwaysReady network_; + sendspin::SendspinClient client_; + std::unique_ptr sink_; +}; + +/// Runs every player's main loop on this thread until done() holds, with no timeout +template +void run_until(std::vector>& players, Predicate done) { + sendspin::server::test::run_all_until(players, done); +} + +// ============================================================================== +// What the server sent each player, from its trace +// ============================================================================== + +/// One chunk: its timestamp and the stamp it was sent with, its samples per channel and their +/// rate, its first position when its stream carries the signal as written, whether it is FLAC and +/// then its first frame's number and its frame count, how many stream/start and stream/clear +/// messages its client had been sent before it, and the stamp of the last of those +struct ChunkInfo { + int64_t ts{0}; + int64_t stamp_us{0}; + uint32_t position{0}; + bool flac{false}; + uint64_t number{0}; + size_t frames{1}; + size_t stream{0}; + size_t clears{0}; + int64_t trigger_us{0}; + uint32_t samples{0}; + uint32_t rate{0}; + Bytes pcm; +}; + +/// Decodes each chunk the server sends, PCM or FLAC, through the codec_header of its stream/start. +/// A stream at the input's rate with two channels carries the signal, so its chunks decode to +/// positions, unless the input changes format; another stream's chunks keep only their samples. +class StreamTrace { +public: + /// @param positions whether a stream at the input's rate and channel count carries the signal + StreamTrace(const Signal& signal, uint32_t input_rate, Recorder& recorder, + bool positions = true) + : signal_(signal), input_rate_(input_rate), recorder_(recorder), positions_(positions) {} + + /// On the dispatch thread + void on_message(const TracedMessage& message) { + const bool chunk = message.direction == TraceDirection::OUTBOUND && message.binary && + message.plaintext.size() > wire::AUDIO_CHUNK_HEADER && + message.plaintext[0] == wire::AUDIO_CHUNK_ID; + if (!chunk) { + this->recorder_.trace(message); + } + if (message.direction == TraceDirection::INBOUND) { + // A JSON message after the handshake is binary, behind its message ID 0 + const size_t offset = message.binary ? 1 : 0; + JsonDocument doc; + if (message.plaintext.size() > offset && + (!message.binary || message.plaintext.front() == wire::JSON_ID) && + deserializeJson(doc, message.plaintext.data() + offset, + message.plaintext.size() - offset) == DeserializationError::Ok && + doc["type"] == "client/state") { + const std::lock_guard lock(this->mutex); + this->states.insert(message.client_id); + } + return; + } + if (message.direction == TraceDirection::INBOUND || !message.binary || + message.plaintext.empty()) { + return; + } + const std::lock_guard lock(this->mutex); + if (message.plaintext[0] == wire::JSON_ID) { + JsonDocument doc; + if (deserializeJson(doc, message.plaintext.data() + 1, message.plaintext.size() - 1) != + DeserializationError::Ok) { + return; + } + if (doc["type"] == "stream/clear") { + ++this->clears_[message.client_id]; + this->triggers_[message.client_id] = message.stamp_us; + } + // The server lists the roles a stream/end ends + if (doc["type"] == "stream/end") { + for (const JsonVariantConst role : doc["payload"]["roles"].as()) { + if (role == "player") { + this->active_.erase(message.client_id); + } + } + } + if (doc["type"] == "stream/start" && !doc["payload"]["player"].isNull()) { + const std::string codec = doc["payload"]["player"]["codec"].as(); + this->codecs[message.client_id] = codec; + // A start while the stream is active announces a format in place: the stream goes + // on, with no trigger + if (this->active_.insert(message.client_id).second) { + ++this->streams[message.client_id]; + this->triggers_[message.client_id] = message.stamp_us; + } + const JsonObjectConst player = doc["payload"]["player"]; + this->rates_[message.client_id] = player["sample_rate"].as(); + this->frame_bytes_[message.client_id] = + player["channels"].as() * player["bit_depth"].as() / 8; + this->as_written_[message.client_id] = + this->positions_ && + doc["payload"]["player"]["sample_rate"].as() == this->input_rate_ && + doc["payload"]["player"]["channels"].as() == 2; + this->readers_.erase(message.client_id); + if (codec == "flac" && + !this->readers_[message.client_id].open( + doc["payload"]["player"]["codec_header"].as())) { + this->failed = true; + } + } + return; + } + if (!chunk) { + return; + } + ChunkInfo info; + info.stamp_us = message.stamp_us; + info.stream = this->streams[message.client_id]; + info.clears = this->clears_[message.client_id]; + info.trigger_us = this->triggers_[message.client_id]; + info.rate = this->rates_[message.client_id]; + info.ts = wire::timestamp_at(message.plaintext); + const std::span payload = + wire::payload_after(message.plaintext, wire::AUDIO_CHUNK_HEADER); + const auto reader = this->readers_.find(message.client_id); + if (reader == this->readers_.end()) { + info.pcm.assign(payload.begin(), payload.end()); + } else { + const std::optional> frames = + reader->second.decode(payload); + if (!frames || frames->empty()) { + this->failed = true; + return; + } + info.flac = true; + info.number = frames->front().number; + info.frames = frames->size(); + for (const sendspin::server::test::DecodedFlacFrame& frame : *frames) { + info.pcm.insert(info.pcm.end(), frame.pcm.begin(), frame.pcm.end()); + } + } + if (this->as_written_[message.client_id]) { + const std::optional position = this->signal_.get(info.pcm.data()); + if (!position) { + this->failed = true; + return; + } + info.position = *position; + } + info.samples = + static_cast(info.pcm.size() / this->frame_bytes_[message.client_id]); + this->chunks[message.client_id].push_back(std::move(info)); + } + + std::mutex mutex; + /// The codec of each client's last stream/start + std::map codecs; + /// The stream/start messages each client was sent + std::map streams; + /// The clients whose client/state came in + std::set states; + std::map> chunks; + /// A codec_header or a chunk did not decode to the signal + bool failed{false}; + +private: + const Signal& signal_; + uint32_t input_rate_; + Recorder& recorder_; + bool positions_; + std::map readers_; + /// Whether each client's stream carries the signal as written + std::map as_written_; + /// The clients with a player stream, between its stream/start and stream/end + std::set active_; + /// Each client's stream/clear messages, the stamp of its last stream/start or stream/clear, + /// and its stream's rate and bytes per frame of decoded samples + std::map clears_; + std::map triggers_; + std::map rates_; + std::map frame_bytes_; +}; + +// ============================================================================== +// What a player played +// ============================================================================== + +/// One frame of a player's output +struct Output { + enum class Kind : uint8_t { POSITION, ALTERED, SILENT }; + Kind kind{Kind::POSITION}; + uint32_t position{0}; +}; + +/// A player's output with the silence before and after the stream left out +inline std::vector decode(const Signal& signal, const Bytes& output) { + const size_t size = signal.frame_bytes(); + size_t begin = 0; + size_t end = output.size() - (output.size() % size); + const auto silent = [&](size_t offset) { + return std::all_of(output.begin() + static_cast(offset), + output.begin() + static_cast(offset + size), + [](uint8_t byte) { return byte == 0; }); + }; + while (begin < end && silent(begin)) { + begin += size; + } + while (end > begin && silent(end - size)) { + end -= size; + } + std::vector frames; + for (size_t offset = begin; offset < end; offset += size) { + if (silent(offset)) { + frames.push_back({Output::Kind::SILENT, 0}); + } else if (const std::optional position = signal.get(&output[offset])) { + frames.push_back({Output::Kind::POSITION, *position}); + } else { + frames.push_back({Output::Kind::ALTERED, 0}); + } + } + return frames; +} + +/// The players' startup floor, max(min_buffer_ms + output_delay_ms, J_min): each player asks for +/// 150 ms and no output delay +inline constexpr int64_t START_FLOOR_US = 150000; +/// The latest a stream's first chunk may be after its trigger: the group lead (160 ms for these +/// players) when a group trigger finds the epoch's first frame, else the floor and one 40 ms +/// frame, with room left for a frame that the pump, run late on a loaded machine, finds late +inline constexpr int64_t START_CEILING_US = 500000; + +/// What the server sent one client: the first chunk after each stream/start or stream/clear between +/// START_FLOOR_US and START_CEILING_US after that trigger, and every other one right where the +/// chunk before it ended, so no audio is skipped, repeated or reordered. The timeline has no shift +/// in these tests; a gap only where its last frame had passed by the next chunk's stamp. How far +/// ahead of its time a chunk left is when the pump ran, which a loaded machine delays, so it is not +/// checked here: PlayerStreamPacking checks the hold a player's min_buffer_ms sets, and SchedCursor +/// the skip of a frame already late. That the pump wakes for a held chunk at its room_time +/// (Session::take_message() through next_wake(), which is unit-tested) is covered end to end by +/// nothing. +inline void expect_sent_whole(const std::vector& chunks) { + for (size_t i = 0; i < chunks.size(); ++i) { + const ChunkInfo& chunk = chunks[i]; + SCOPED_TRACE(chunk.ts); + const bool first = + i == 0 || chunks[i - 1].stream != chunk.stream || chunks[i - 1].clears != chunk.clears; + // The pump skips a frame already late when it runs (sched::prepare_cursor()), as when core + // stalls on a loaded machine, so audio may be missing only where it was late + if (first) { + EXPECT_GE(chunk.ts - chunk.trigger_us, START_FLOOR_US); + if (chunk.ts - frame_us(chunk.rate) > chunk.stamp_us) { + EXPECT_LE(chunk.ts - chunk.trigger_us, START_CEILING_US); + } + continue; + } + const ChunkInfo& before = chunks[i - 1]; + const int64_t duration_us = int64_t{before.samples} * 1000000; + ASSERT_EQ(duration_us % before.rate, 0) << "a chunk of part of a cell before another"; + const int64_t expected = before.ts + (duration_us / before.rate); + if (chunk.ts > expected && chunk.ts - frame_us(before.rate) <= chunk.stamp_us) { + continue; + } + EXPECT_EQ(chunk.ts, expected) << "a gap in the chunks"; + } +} + +/// Checks a player's output and the chunks the server sent it, for segments of the signal written +/// in their order (a segment after the first follows a seek or a resume): each chunk on a frame +/// boundary of its segment. The output holds some of every segment, in order, never going back to +/// an earlier one, each frame of the signal bit for bit, its positions rising within a segment, +/// but for one blended frame at a time. +/// +/// Where the output enters a segment, and what it skips or fills with silence within one, depends +/// on timing alone. The server's trigger may go out after the slack its group lead leaves, so the +/// stream starts at a later cell, in sync; a live rebase's gap plays as silence. The sync task +/// blends a frame in or out at chunk boundaries (SyncTask::soft_sync_insert_frame(), +/// soft_sync_drop_frame()), and when it starts, re-aligns after a seek, or loses sync because its +/// threads ran late, it drops the late prefix of a chunk or fills with silence +/// (SyncTask::handle_synchronize_audio()). The startup lead is checked from the stamps with raw +/// players. How much of the end it plays depends on its threads keeping real time: one behind its +/// clock at the stream/end drops what it has not played. So does how much of the end the server +/// sends, since a frame late when the pump runs is skipped; that a stream runs through its last +/// frame is checked with the clock passed in (PlayerStreamSwitch). +/// @param rate the signal's sample rate +inline void expect_heard(const std::vector& frames, const std::vector& chunks, + const std::vector& segments, uint32_t rate = RATE) { + const auto segment_of = [&](uint32_t position) -> size_t { + const auto it = std::find_if(segments.begin(), segments.end(), [&](const Segment& segment) { + return segment.holds(position); + }); + return static_cast(it - segments.begin()); + }; + // A frame's samples: the whole number the fewest cells hold at the rate + const uint32_t frame = rate / std::gcd(rate, 50U); + for (const ChunkInfo& chunk : chunks) { + const size_t segment = segment_of(chunk.position); + ASSERT_LT(segment, segments.size()) << chunk.position; + EXPECT_EQ((chunk.position - segments[segment].first) % frame, 0U) << chunk.position; + } + std::vector heard(segments.size(), 0); + std::optional previous; + size_t altered = 0; + for (const Output& frame : frames) { + if (frame.kind == Output::Kind::ALTERED) { + ++altered; + continue; + } + if (frame.kind == Output::Kind::SILENT) { + continue; + } + // Not a SCOPED_TRACE: a second of output is 48,000 frames + const uint32_t position = frame.position; + const size_t segment = segment_of(position); + ASSERT_LT(segment, segments.size()) << "a frame of no segment: " << position; + if (!previous) { + EXPECT_EQ(segment, 0U) << position; + } else if (segment != segment_of(*previous)) { + ASSERT_GT(segment, segment_of(*previous)) << "back to an earlier segment: " << position; + } else { + ASSERT_GT(position, *previous); + } + EXPECT_LE(altered, 1U) << position; + previous = position; + altered = 0; + ++heard[segment]; + } + // A drop in the last chunk leaves its blended frame last + EXPECT_LE(altered, 1U); + for (size_t i = 0; i < segments.size(); ++i) { + EXPECT_GT(heard[i], 0U) << "segment " << i << " not heard"; + } +} + +/// expect_heard(), with the server having sent each segment whole from where its stream entered +/// it (expect_sent_whole()) +inline void expect_played(const std::vector& frames, const std::vector& chunks, + const std::vector& segments, uint32_t rate = RATE) { + expect_sent_whole(chunks); + expect_heard(frames, chunks, segments, rate); +} + +/// The server, its trace and its players +class Rig { +public: + /// @param input_rate the rate of the input open() opens + /// @param positions whether chunks at the input's rate and channel count decode to positions + /// @param opus ServerConfig::enable_opus + explicit Rig(const Signal& signal, uint32_t input_rate = RATE, bool positions = true, + bool opus = false) + : signal(signal), + input_rate(input_rate), + trace(signal, input_rate, this->recorder, positions) { + ServerConfig config = sendspin::server::test::local_config(); + config.name = "Players"; + config.approve_all_clients = true; + config.enable_opus = opus; + config.trace = [this](const TracedMessage& message) { this->trace.on_message(message); }; + EXPECT_EQ(this->server.start(config, &this->provider, &this->recorder), StartResult::OK); + } + + /// Adds a player, which the server dials, and runs every player until the server has its + /// client/state + /// @param rate, channels its output's, which its stream must have + /// @param min_buffer_ms, required_lead_ms what its client/state reports + /// @param buffer_bytes its audio buffer + Player& add(const std::vector& formats, + uint32_t rate = RATE, uint32_t channels = 2, uint16_t min_buffer_ms = 150, + std::optional required_lead_ms = std::nullopt, + size_t buffer_bytes = sendspin::PlayerRoleConfig::DEFAULT_AUDIO_BUFFER_CAPACITY) { + this->players.push_back( + std::make_unique(formats, this->signal.bits() / 8 * channels, rate, + min_buffer_ms, required_lead_ms, buffer_bytes)); + const std::string url = this->players.back()->url(); + EXPECT_TRUE(this->server.connect_url(url)); + // Its own client/state, not one another player sends meanwhile + run_until(this->players, [&] { + const std::optional id = this->find_client_id(url); + const std::lock_guard lock(this->trace.mutex); + return id && this->trace.states.contains(*id); + }); + return *this->players.back(); + } + + /// Opens an input to group, the server's group by default + std::shared_ptr open(std::optional group = std::nullopt) { + InputOptions options; + options.format = InputFormat{this->input_rate, 2, this->signal.sample_format()}; + return this->server.open_input(group.value_or(*this->server.default_group()), options) + .input; + } + + /// Opens a live input to the server's group, with the default write jitter + std::shared_ptr open_live() { + InputOptions options; + options.format = InputFormat{this->input_rate, 2, this->signal.sample_format()}; + options.kind = sendspin::server::InputKind::LIVE; + // NOLINTNEXTLINE(bugprone-unchecked-optional-access): set while the server runs + return this->server.open_input(*this->server.default_group(), options).input; + } + + std::optional find_client_id(const std::string& url) const { + for (const sendspin::server::ClientInfo& client : this->server.clients()) { + if (client.url == url) { + return client.client_id; + } + } + return std::nullopt; + } + + std::string client_id(const Player& player) const { + const std::optional id = this->find_client_id(player.url()); + if (!id) { + ADD_FAILURE() << "no client at " << player.url(); + } + return id.value_or(std::string{}); + } + + /// Runs every player until the host heard each of drained and every player played out its + /// stream: a drain completes by the server's clock, which can be before a player's output + /// plays its last frames. + void run_until_drained(std::initializer_list drained) { + run_until(this->players, [&] { + return std::all_of( + drained.begin(), drained.end(), + [&](const std::string& event) { return this->recorder.contains(event); }) && + std::all_of(this->players.begin(), this->players.end(), + [](const auto& player) { return player->sink().played_out(); }); + }); + } + + /// Whether the server has sent player a chunk + bool has_chunks(const Player& player) { + const std::string id = this->client_id(player); + const std::lock_guard lock(this->trace.mutex); + const auto it = this->trace.chunks.find(id); + return it != this->trace.chunks.end() && !it->second.empty(); + } + + const Signal& signal; + uint32_t input_rate; + MemoryProvider provider; + Recorder recorder; + StreamTrace trace; + SendspinServer server; + std::vector> players; +}; + +/// Within each segment, every chunk's timestamp is one anchor plus its position's offset in the +/// segment, exactly, the same anchor for every player +/// @return the anchor of each segment with a chunk, by its index +inline std::map expect_one_anchor_per_segment( + const std::map>& chunks, + const std::vector& segments, uint32_t rate = RATE) { + std::map anchors; + for (const auto& [client, list] : chunks) { + SCOPED_TRACE(client); + for (const ChunkInfo& chunk : list) { + const auto segment = + std::find_if(segments.begin(), segments.end(), + [&](const Segment& s) { return s.holds(chunk.position); }); + EXPECT_NE(segment, segments.end()); + if (segment == segments.end()) { + continue; + } + const int64_t offset_us = int64_t{chunk.position - segment->first} * 1000000; + EXPECT_EQ(offset_us % rate, 0) << chunk.position; + const int64_t anchor = chunk.ts - (offset_us / rate); + const auto index = static_cast(segment - segments.begin()); + const auto [it, added] = anchors.emplace(index, anchor); + EXPECT_EQ(it->second, anchor) << chunk.position; + } + } + return anchors; +} + +} // namespace sendspin::server::test diff --git a/tests/raw_roles.h b/tests/raw_roles.h new file mode 100644 index 0000000..1743be5 --- /dev/null +++ b/tests/raw_roles.h @@ -0,0 +1,142 @@ +// Copyright 2026 Sendspin Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// @file raw_roles.h +/// @brief For tests that drive the roles with the raw test client: the client/hello, client/state +/// and controller command it sends, the dial through its client/hello, and readers of the JSON +/// messages it receives + +#pragma once + +#include "raw_client.h" +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace sendspin::server::test { + +/// A client/hello listing roles, with the player's support object when it lists player@v1 +inline std::string hello(const std::string& name, const std::vector& roles, + bool unpaired) { + JsonDocument doc; + doc["type"] = "client/hello"; + JsonObject payload = doc["payload"].to(); + payload["name"] = name; + JsonArray listed = payload["supported_roles"].to(); + for (const std::string& role : roles) { + listed.add(role); + } + if (std::find(roles.begin(), roles.end(), "player@v1") != roles.end()) { + JsonObject format = payload["player@v1_support"]["supported_formats"].add(); + format["codec"] = "pcm"; + format["sample_rate"] = 48000; + format["channels"] = 2; + format["bit_depth"] = 16; + payload["player@v1_support"]["buffer_capacity"] = 200000; + } + payload["supported_pair_methods"]["pairing_psk"].to(); + payload["unpaired_access"]["enabled"] = unpaired; + std::string out; + serializeJson(doc, out); + return out; +} + +/// A client/state with a player object offering commands, reporting volume and muted when set +inline std::string player_state(bool available, const std::vector& commands, + std::optional volume, std::optional muted) { + JsonDocument doc; + doc["type"] = "client/state"; + doc["payload"]["available"] = available; + JsonObject player = doc["payload"]["player"].to(); + player["output_delay_ms"] = 0; + player["required_lead_time_ms"] = 0; + player["min_buffer_ms"] = 0; + JsonArray offered = player["supported_commands"].to(); + for (const std::string& command : commands) { + offered.add(command); + } + if (volume) { + player["volume"] = *volume; + } + if (muted) { + player["muted"] = *muted; + } + std::string out; + serializeJson(doc, out); + return out; +} + +inline std::string controller_command(const std::string& object) { + return R"({"type":"client/command","payload":{"controller":)" + object + "}}"; +} + +/// The next JSON message, parsed +inline JsonDocument next(RawClient& client) { + JsonDocument doc; + const std::string json = client.receive_json(); + EXPECT_FALSE(json.empty()) << "the connection closed"; + deserializeJson(doc, json); + return doc; +} + +/// The next JSON message of type, skipping others +inline JsonDocument next(RawClient& client, std::string_view type) { + for (;;) { + const std::string json = client.receive_json(); + if (json.empty()) { + ADD_FAILURE() << "the connection closed while waiting for " << type; + return JsonDocument(); + } + JsonDocument doc; + deserializeJson(doc, json); + if (doc["type"].as() == type) { + return doc; + } + } +} + +inline std::string payload(const JsonDocument& doc) { + std::string out; + serializeJson(doc["payload"], out); + return out; +} + +inline std::string type(const JsonDocument& doc) { + return doc["type"].as(); +} + +/// Dials client and runs the handshake through client/hello +inline void connect(SendspinServer& server, RawClient& client, const std::string& hello_json) { + ASSERT_TRUE(server.connect_url(client.url())); + ASSERT_NO_FATAL_FAILURE(client.handshake()); + client.send_json(hello_json); +} + +inline std::string client_id_of(const RawClient& client) { + return client.noise.identity().peer_id(); +} + +inline std::string group_id(const GroupId& id) { + return std::to_string(id.value); +} + +} // namespace sendspin::server::test diff --git a/tests/test_audio_workers_internals.cpp b/tests/test_audio_workers_internals.cpp index a601ea9..2770998 100644 --- a/tests/test_audio_workers_internals.cpp +++ b/tests/test_audio_workers_internals.cpp @@ -13,49 +13,22 @@ // limitations under the License. /// @file test_audio_workers_internals.cpp -/// @brief The group audio threads: a stop drops the jobs not started, a group erased on core while -/// its pipeline job runs, an input released while its epoch start is queued, a full ring answered -/// once the job frees it, a slew freed on core, and a group's stall counts and reports. Compiled -/// with -fno-access-control (tests/CMakeLists.txt). +/// @brief The group audio threads: a stop drops the jobs not started and ignores later wakes. +/// Compiled with -fno-access-control (tests/CMakeLists.txt). -#include "artwork_cache.h" #include "audio_workers.h" -#include "clock.h" -#include "dispatcher.h" #include "gate.h" -#include "group.h" -#include "group_audio.h" -#include "group_job.h" -#include "host_view.h" -#include "sched/stalls.h" -#include "test_recorder.h" -#include "transport.h" #include #include -#include -#include -#include -#include #include #include -#include -#include #include -#include -#include -#include namespace { using sendspin::server::AudioWorkers; using sendspin::server::test::Gate; -using sendspin::server::test::wrap_job; - -/// The groups' artwork cache, which these tests never use: it refuses every job -sendspin::server::ArtworkCache unused_artwork() { - return sendspin::server::ArtworkCache([](sendspin::server::ArtworkJob) { return false; }); -} // Stopping the threads drops a job not started and ignores later wakes, and its task can still // be stopped. White-box: the running job is let go only once the stop has begun, which nothing @@ -94,432 +67,4 @@ TEST(AudioWorkers, DropsQueuedJobsWhenStopped) { blocker->stop(); } -// ============================================================================== -// A group erased on core while its pipeline job runs -// ============================================================================== - -// docs/conventions.md "Teardown discards": core erases a group without waiting for the job running -// on a group audio thread; what the job then posts reaches no group, and the job never runs again -TEST(GroupAudioThread, ErasesAGroupWhileItsJobRuns) { - using sendspin::server::Group; - sendspin::server::EventLoop loop; - AudioWorkers workers(1); - std::thread core([&] { loop.run(); }); - uint64_t next_instance = 1; - sendspin::server::ArtworkCache artwork = unused_artwork(); - std::unique_ptr group; - const auto view = std::make_shared( - sendspin::server::GroupId{1}, sendspin::server::InputId{1}, - sendspin::server::InputOptions{ - sendspin::server::InputFormat{48000, 2, sendspin::server::SampleFormat::S16}}, - sendspin::server::GroupParams{300000, 0, 1000000}, - std::make_shared(), nullptr); - std::promise> attached; - loop.post([&] { - group = std::make_unique( - loop, workers, sendspin::server::GroupId{1}, "Erased", next_instance, - sendspin::server::ResamplerQuality::MEDIUM, false, - sendspin::server::GroupHooks{[] {}, [](const sendspin::server::ServerError&) {}, [] {}, - [](const std::string&) {}}, - artwork); - EXPECT_TRUE(group->attach_input(view)); - attached.set_value(group->audio_); - }); - const std::shared_ptr task = attached.get_future().get(); - - // White-box: the job's next run posts what it posts, then waits at a gate, still running; put - // in place once the run the attachment woke is over, so the write below wakes the next - Gate gate; - std::atomic gated_runs{0}; - wrap_job(*task, [&gate, &gated_runs](std::function inner) { - return [inner = std::move(inner), &gate, &gated_runs]() { - const bool more = inner(); - if (++gated_runs == 1) { - gate.pass(); - } - return more; - }; - }); - // Core waits, then erases the group, so the FIFO handler the job posts runs after the erase - std::promise core_gate; - std::promise erased; - loop.post([&] { - core_gate.get_future().wait(); - group.reset(); - erased.set_value(); - }); - // A write anchors a run, so the job posts its start - const std::vector pcm(size_t{960} * 4, 1); - ASSERT_EQ(view->write(pcm.data(), 960).frames, 960U); - gate.wait_reached(); - core_gate.set_value(); - erased.get_future().wait(); - // The FIFO handler the job posted has run once a handler posted now runs, while the job, - // still at its gate, holds the FIFO's state - std::promise handled; - loop.post([&] { handled.set_value(); }); - handled.get_future().wait(); - // The group closed the input on its way out - EXPECT_EQ(view->write(pcm.data(), 960).state, sendspin::server::InputState::CLOSED); - // The one thread runs tasks in the order woken: once a probe task has run behind the job, the - // job has returned; a wake then queues the job ahead of the probe's next run, unless stopped - std::promise probed; - std::promise probed_again; - std::atomic probes{0}; - const std::shared_ptr probe = workers.add([&] { - (++probes == 1 ? probed : probed_again).set_value(); - return false; - }); - probe->wake(); - gate.open(); - probed.get_future().wait(); - task->wake(); - probe->wake(); - probed_again.get_future().wait(); - EXPECT_EQ(gated_runs.load(), 1); - workers.stop(); - loop.post([&] { loop.shutdown(0); }); - core.join(); -} - -/// Runs a handler on core and waits for it -template -auto on_core(sendspin::server::EventLoop& loop, F fn) { - std::promise done; - loop.post([&] { - if constexpr (std::is_void_v) { - fn(); - done.set_value(); - } else { - done.set_value(fn()); - } - }); - return done.get_future().get(); -} - -// When the host releases its input, core ends a playing run itself and detaches the input: an -// EpochStarted the stopped job posted before the release, still in the FIFO when core sees the -// release, never starts a run with no input. Control: an EpochStarted delivered before the release -// starts the run, which the release ends. -TEST(GroupAudioThread, ReleasesAnInputWhoseEpochStartIsStillQueued) { - using sendspin::server::Group; - for (const bool queued : {true, false}) { - SCOPED_TRACE(queued ? "start queued" : "control: start delivered"); - sendspin::server::EventLoop loop; - AudioWorkers workers(1); - std::thread core([&] { loop.run(); }); - uint64_t next_instance = 1; - sendspin::server::ArtworkCache artwork = unused_artwork(); - std::unique_ptr group; - const auto view = std::make_shared( - sendspin::server::GroupId{1}, sendspin::server::InputId{1}, - sendspin::server::InputOptions{ - sendspin::server::InputFormat{48000, 2, sendspin::server::SampleFormat::S16}}, - sendspin::server::GroupParams{300000, 0, 1000000}, - std::make_shared(), nullptr); - const std::shared_ptr task = on_core(loop, [&] { - group = std::make_unique( - loop, workers, sendspin::server::GroupId{1}, "Released", next_instance, - sendspin::server::ResamplerQuality::MEDIUM, false, - sendspin::server::GroupHooks{[] {}, [](const sendspin::server::ServerError&) {}, - [] {}, [](const std::string&) {}}, - artwork); - EXPECT_TRUE(group->attach_input(view)); - return group->audio_; - }); - // White-box: the test learns when the job's next run, which the write below wakes, is over - std::promise ran; - std::atomic runs{0}; - wrap_job(*task, [&ran, &runs](std::function inner) { - return [inner = std::move(inner), &ran, &runs]() { - const bool more = inner(); - if (++runs == 1) { - ran.set_value(); - } - return more; - }; - }); - on_core(loop, [] {}); - // Core waits until the release, so the write's wakeup and the FIFO handler the job posts - // run after it, in that order - std::promise core_gate; - if (queued) { - loop.post([gate = core_gate.get_future().share()] { gate.wait(); }); - } - // A write anchors a run, so the job posts its start - const std::vector pcm(size_t{960} * 4, 1); - ASSERT_EQ(view->write(pcm.data(), 960).frames, 960U); - ran.get_future().wait(); - // The FIFO handler, posted before the job returned, has run once a later handler runs - if (!queued) { - EXPECT_TRUE(on_core(loop, [&] { return group->playing_; })); - } - // The one thread is busy, so the job the release wakes runs only after core saw it - std::promise busy; - std::promise go; - const std::shared_ptr blocker = - workers.add([&busy, gate = go.get_future().share()] { - busy.set_value(); - gate.wait(); - return false; - }); - blocker->wake(); - busy.get_future().wait(); - view->release(); - core_gate.set_value(); - // Core handles the release's wakeup, then the FIFO handler, then this - on_core(loop, [] {}); - const auto [playing, attached] = - on_core(loop, [&] { return std::pair{group->playing_, group->input_ != nullptr}; }); - EXPECT_FALSE(playing); - EXPECT_FALSE(attached); - go.set_value(); - on_core(loop, [&] { group.reset(); }); - workers.stop(); - loop.post([&] { loop.shutdown(0); }); - core.join(); - } -} - -// A paused live input takes every write the staging ring has room for, and its pipelines' job frees -// the ring; core polls for room once the job has run, so on_writable answers a write the full ring -// cut short even when core polled before the job freed it -TEST(GroupAudioThread, AnswersAFullRingOnceTheJobFreesIt) { - using sendspin::server::Group; - constexpr uint32_t RATE = 48000; - sendspin::server::test::Recorder recorder; - const auto dispatcher = std::make_shared(); - sendspin::server::EventLoop loop; - AudioWorkers workers(1); - std::thread core([&] { loop.run(); }); - uint64_t next_instance = 1; - sendspin::server::ArtworkCache artwork = unused_artwork(); - std::unique_ptr group; - sendspin::server::InputOptions options{{RATE, 2, sendspin::server::SampleFormat::S16}}; - options.kind = sendspin::server::InputKind::LIVE; - const auto view = std::make_shared( - sendspin::server::GroupId{1}, sendspin::server::InputId{1}, options, - sendspin::server::GroupParams{300000, 0, 200000}, dispatcher, &recorder); - const std::shared_ptr task = on_core(loop, [&] { - group = std::make_unique( - loop, workers, sendspin::server::GroupId{1}, "Paused", next_instance, - sendspin::server::ResamplerQuality::MEDIUM, false, - sendspin::server::GroupHooks{[] {}, [](const sendspin::server::ServerError&) {}, [] {}, - [](const std::string&) {}}, - artwork); - EXPECT_TRUE(group->attach_input(view)); - return group->audio_; - }); - // More than the ring holds, paused - const std::vector pcm(size_t{13} * RATE * 4, 1); - ASSERT_GT(view->write(pcm.data(), RATE / 10).frames, 0U); - view->pause(); - // The one thread is busy, so the job the full write wakes runs after core polled - std::promise busy; - std::promise go; - const std::shared_ptr blocker = - workers.add([&busy, gate = go.get_future().share()] { - busy.set_value(); - gate.wait(); - return false; - }); - blocker->wake(); - busy.get_future().wait(); - const sendspin::server::WriteResult full = view->write(pcm.data(), size_t{13} * RATE); - ASSERT_EQ(full.state, sendspin::server::InputState::FULL); - ASSERT_GT(full.frames, 0U); - // Core handled the write's wakeup: the ring has no room yet, and no time gives it any - EXPECT_EQ(on_core(loop, [&] { return view->poll_writable(sendspin::server::now_us()); }), - sendspin::server::sched::NEVER); - go.set_value(); - // Never ends unless core polls once the job freed the ring - recorder.wait_for("writable 1 1"); - EXPECT_EQ(view->state(), sendspin::server::InputState::OPEN); - on_core(loop, [&] { group.reset(); }); - workers.stop(); - loop.post([&] { loop.shutdown(0); }); - core.join(); - dispatcher->finish(); - dispatcher->join(); -} - -// The write that takes up set_format()'s slew leaves the slew it replaced in the view, and core -// frees it as it handles the group's wakeup. The pipeline jobs signal that wakeup too, so a write's -// signal can fold into one whose handler is not posted yet: the test runs the handler itself. -// White-box: Group::on_wake(), HostView::slew_, next_slew_, mutex_. -TEST(GroupAudioThread, FreesTheSlewAWriteReplacedOnCore) { - using sendspin::server::Group; - constexpr uint32_t RATE = 48000; - sendspin::server::test::Recorder recorder; - const auto dispatcher = std::make_shared(); - sendspin::server::EventLoop loop; - AudioWorkers workers(1); - std::thread core([&] { loop.run(); }); - uint64_t next_instance = 1; - sendspin::server::ArtworkCache artwork = unused_artwork(); - std::unique_ptr group; - sendspin::server::InputOptions options{{RATE, 2, sendspin::server::SampleFormat::S16}}; - options.kind = sendspin::server::InputKind::LIVE; - const auto view = std::make_shared( - sendspin::server::GroupId{1}, sendspin::server::InputId{1}, options, - sendspin::server::GroupParams{300000, 0, 200000}, dispatcher, &recorder); - on_core(loop, [&] { - group = std::make_unique( - loop, workers, sendspin::server::GroupId{1}, "Live", next_instance, - sendspin::server::ResamplerQuality::MEDIUM, false, - sendspin::server::GroupHooks{[] {}, [](const sendspin::server::ServerError&) {}, [] {}, - [](const std::string&) {}}, - artwork); - EXPECT_TRUE(group->attach_input(view)); - }); - // A cell in either format - const std::vector pcm(size_t{RATE} / 50 * 8, 0); - ASSERT_GT(view->write(pcm.data(), RATE / 50).frames, 0U); - ASSERT_TRUE(view->set_format({RATE, 2, sendspin::server::SampleFormat::F32})); - const uint8_t* made = [&] { - const std::lock_guard lock(view->mutex_); - return view->next_slew_.output(); - }(); - ASSERT_GT(view->write(pcm.data(), RATE / 50).frames, 0U); - const auto [adopted, retired] = on_core(loop, [&] { - group->on_wake(); - const std::lock_guard lock(view->mutex_); - return std::pair{view->slew_.output() == made, view->next_slew_.ready()}; - }); - EXPECT_TRUE(adopted); - EXPECT_FALSE(retired); - on_core(loop, [&] { group.reset(); }); - workers.stop(); - loop.post([&] { loop.shutdown(0); }); - core.join(); - dispatcher->finish(); - dispatcher->join(); -} - -/// A group with no input, playing epoch 1 on its own anchor, with a frame ring for instance 7 and -/// an analysis on instance 8, and its INFO lines in lines. White-box: it sets the state an -/// epoch's start sets (playing_, epoch_, anchor_, rings_, pipelines_.analyses). -struct StallGroup { - sendspin::server::EventLoop loop; - AudioWorkers workers{1}; - uint64_t next_instance = 1; - sendspin::server::ArtworkCache artwork = unused_artwork(); - std::mutex mutex; - std::vector lines; - std::unique_ptr group; - - static constexpr sendspin::server::sched::EpochId EPOCH{1}; - static constexpr sendspin::server::sched::InstanceId PLAYER{7}; - static constexpr sendspin::server::sched::InstanceId ANALYSIS{8}; - - StallGroup() { - sendspin::server::GroupHooks hooks{[] {}, [](const sendspin::server::ServerError&) {}, - [] {}, [](const std::string&) {}}; - hooks.info = [this](std::string line) { - const std::lock_guard lock(this->mutex); - this->lines.push_back(std::move(line)); - }; - this->group = std::make_unique( - this->loop, this->workers, sendspin::server::GroupId{1}, "Kitchen", this->next_instance, - sendspin::server::ResamplerQuality::MEDIUM, false, std::move(hooks), this->artwork); - sendspin::server::Group& g = *this->group; - g.playing_ = true; - g.epoch_ = EPOCH; - g.anchor_ = sendspin::server::sched::make_anchor(0, 0, 48000, 2); - g.rings_.emplace(PLAYER, sendspin::server::sched::FrameRing(PLAYER, 1)); - g.rings_.emplace(ANALYSIS, sendspin::server::sched::FrameRing(ANALYSIS, 1)); - g.pipelines_.analyses.push_back(sendspin::server::AnalysisRequest{ANALYSIS, EPOCH, {}, 0}); - } - - void frame(sendspin::server::sched::InstanceId instance, sendspin::server::sched::EpochId epoch, - int64_t ts_us) { - sendspin::server::sched::Frame frame; - frame.epoch = epoch; - frame.ts_us = ts_us; - sendspin::server::FrameReady ready{instance, std::move(frame)}; - this->group->on_frame(ready); - } - - std::vector taken() { - const std::lock_guard lock(this->mutex); - return std::exchange(this->lines, {}); - } -}; - -// A group counts the rebases its pipelines post in its epoch, with their shifts, and each player -// frame that reaches its ring at or past its play time, once per instance; not a frame still ahead -// of its time, one of another epoch, or an analysis's cell (which skips its ring instead). The -// counts are read from Group::stalls_, as the log line is the only other place they show. -TEST(GroupStalls, CountsRebasesAndPlayerFramesPastTheirPlayTime) { - using sendspin::server::AnchorShift; - using sendspin::server::sched::STALL_REPORT_US; - StallGroup s; - sendspin::server::Group& group = *s.group; - const int64_t now = sendspin::server::now_us(); - // A first stall is reported at once, which holds back the counts that follow - s.frame(StallGroup::PLAYER, StallGroup::EPOCH, now - 1); - ASSERT_EQ(s.taken().size(), 1U); - - s.frame(StallGroup::PLAYER, StallGroup::EPOCH, now - 1); - s.frame(StallGroup::PLAYER, StallGroup::EPOCH, now - 1000); - s.frame(StallGroup::PLAYER, StallGroup::EPOCH, now + 10'000'000); - s.frame(StallGroup::PLAYER, sendspin::server::sched::EpochId{2}, now - 1); - s.frame(StallGroup::ANALYSIS, StallGroup::EPOCH, now - 1); - group.on_anchor_shift(AnchorShift{StallGroup::EPOCH, 10, 300'000}); - group.on_anchor_shift(AnchorShift{StallGroup::EPOCH, 20, 200'000}); - group.on_anchor_shift(AnchorShift{sendspin::server::sched::EpochId{2}, 30, 900'000}); - EXPECT_TRUE(s.taken().empty()); - - const std::optional report = - group.stalls_.take(sendspin::server::now_us() + STALL_REPORT_US).report; - ASSERT_TRUE(report); - EXPECT_EQ(report->rebases, 2U); - EXPECT_EQ(report->shifted_us, 500'000); - EXPECT_EQ(report->late_frames, 2U); -} - -// A stall with no report made (here a rebase) is logged at once; within STALL_REPORT_US of one it -// is logged once the period ends, by the group's timer when no stall follows, and a report with -// nothing counted logs nothing -TEST(GroupStalls, ReportsAtMostEveryPeriodAndHoldsNothingBack) { - using sendspin::server::sched::STALL_REPORT_US; - StallGroup s; - sendspin::server::Group& group = *s.group; - std::thread core([&] { s.loop.run(); }); - const int64_t now = sendspin::server::now_us(); - std::promise counted; - s.loop.post([&] { - group.on_anchor_shift(sendspin::server::AnchorShift{StallGroup::EPOCH, 10, 300'000}); - // White-box: the last report replaced by one made so that the next is due 100 ms from now - group.stalls_ = sendspin::server::sched::StallCount{}; - group.stalls_.add_late_frame(); - (void)group.stalls_.take(sendspin::server::now_us() + 100'000 - STALL_REPORT_US); - s.frame(StallGroup::PLAYER, StallGroup::EPOCH, now - 1); - counted.set_value(); - }); - counted.get_future().get(); - EXPECT_EQ(s.taken().size(), 1U); - - // The held-back frame, by the timer; a regression hangs into the suite watchdog - std::vector lines; - while (lines.empty()) { - std::this_thread::sleep_for(std::chrono::milliseconds(10)); - lines = s.taken(); - } - EXPECT_EQ(lines.size(), 1U); - - std::promise reported; - s.loop.post([&] { - group.report_stalls(sendspin::server::now_us() + (2 * STALL_REPORT_US)); - reported.set_value(); - }); - reported.get_future().get(); - EXPECT_TRUE(s.taken().empty()); - s.loop.post([&] { - s.group.reset(); - s.loop.shutdown(0); - }); - core.join(); -} - } // namespace diff --git a/tests/test_colors.cpp b/tests/test_colors.cpp index 2661002..7bbe5c3 100644 --- a/tests/test_colors.cpp +++ b/tests/test_colors.cpp @@ -15,10 +15,9 @@ /// @file test_colors.cpp /// @brief The color role's pure parts (spec roles/color/v1.md): WCAG contrast, the guarantees /// with_contrast() and palette_colors() keep as a seeded property test with a contrast computed -/// here, the median cut, and the schedule of color states. +/// here, and the median cut. #include "colors.h" -#include "scheduled_updates.h" #include #include @@ -30,7 +29,6 @@ namespace { using sendspin::server::Colors; -using sendspin::server::ColorState; using sendspin::server::RgbColor; /// WCAG 2 contrast, computed here from the definition rather than by colors.cpp @@ -183,29 +181,4 @@ TEST(ColorPalette, PutsTheDominantColorFirst) { sendspin::server::quantize_palette(pixels({{{255, 255, 255, 255}, 10}}), 5, 1).empty()); } -// spec roles/color/v1.md "Scheduled color updates", through the schedule metadata shares -TEST(ColorSchedule, SendsCurrentAndScheduledStates) { - constexpr int64_t S = 1000000; - sendspin::server::ScheduledUpdates colors; - Colors red; - red.primary = RgbColor{255, 0, 0}; - EXPECT_EQ(colors.current(5).timestamp_us, 5); - EXPECT_EQ(colors.current(5).colors, Colors{}); - std::vector sends = colors.set({S, red}, 2 * S); - ASSERT_EQ(sends.size(), 1U); - EXPECT_EQ(sends[0], (ColorState{S, red})); - Colors blue; - blue.primary = RgbColor{0, 0, 255}; - EXPECT_TRUE(colors.set({30 * S, blue}, 3 * S).empty()); - EXPECT_EQ(colors.next_due_us(), 10 * S); - sends = colors.advance(10 * S); - ASSERT_EQ(sends.size(), 1U); - EXPECT_EQ(sends[0].colors, blue); - EXPECT_EQ(colors.sent_pending(), (ColorState{30 * S, blue})); - // Cancelled once sent: the current state again - sends = colors.cancel(11 * S); - ASSERT_EQ(sends.size(), 1U); - EXPECT_EQ(sends[0], (ColorState{S, red})); -} - } // namespace diff --git a/tests/test_group_internals.cpp b/tests/test_group_internals.cpp new file mode 100644 index 0000000..269e3e8 --- /dev/null +++ b/tests/test_group_internals.cpp @@ -0,0 +1,489 @@ +// Copyright 2026 Sendspin Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// @file test_group_internals.cpp +/// @brief A group on core with its pipeline job on the group audio threads: a group erased on +/// core while its job runs, an input released while its epoch start is queued, a full ring +/// answered once the job frees it, a slew freed on core, and a group's stall counts and reports. +/// Compiled with -fno-access-control (tests/CMakeLists.txt). + +#include "artwork_cache.h" +#include "audio_workers.h" +#include "clock.h" +#include "dispatcher.h" +#include "gate.h" +#include "group.h" +#include "group_audio.h" +#include "group_job.h" +#include "host_view.h" +#include "sched/stalls.h" +#include "test_recorder.h" +#include "transport.h" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using sendspin::server::AudioWorkers; +using sendspin::server::test::Gate; +using sendspin::server::test::wrap_job; + +/// The groups' artwork cache, which these tests never use: it refuses every job +sendspin::server::ArtworkCache unused_artwork() { + return sendspin::server::ArtworkCache([](sendspin::server::ArtworkJob) { return false; }); +} + +// ============================================================================== +// A group erased on core while its pipeline job runs +// ============================================================================== + +// docs/conventions.md "Teardown discards": core erases a group without waiting for the job running +// on a group audio thread; what the job then posts reaches no group, and the job never runs again +TEST(GroupAudioThread, ErasesAGroupWhileItsJobRuns) { + using sendspin::server::Group; + sendspin::server::EventLoop loop; + AudioWorkers workers(1); + std::thread core([&] { loop.run(); }); + uint64_t next_instance = 1; + sendspin::server::ArtworkCache artwork = unused_artwork(); + std::unique_ptr group; + const auto view = std::make_shared( + sendspin::server::GroupId{1}, sendspin::server::InputId{1}, + sendspin::server::InputOptions{ + sendspin::server::InputFormat{48000, 2, sendspin::server::SampleFormat::S16}}, + sendspin::server::GroupParams{300000, 0, 1000000}, + std::make_shared(), nullptr); + std::promise> attached; + loop.post([&] { + group = std::make_unique( + loop, workers, sendspin::server::GroupId{1}, "Erased", next_instance, + sendspin::server::ResamplerQuality::MEDIUM, false, + sendspin::server::GroupHooks{[] {}, [](const sendspin::server::ServerError&) {}, [] {}, + [](const std::string&) {}}, + artwork); + EXPECT_TRUE(group->attach_input(view)); + attached.set_value(group->audio_); + }); + const std::shared_ptr task = attached.get_future().get(); + + // White-box: the job's next run posts what it posts, then waits at a gate, still running; put + // in place once the run the attachment woke is over, so the write below wakes the next + Gate gate; + std::atomic gated_runs{0}; + wrap_job(*task, [&gate, &gated_runs](std::function inner) { + return [inner = std::move(inner), &gate, &gated_runs]() { + const bool more = inner(); + if (++gated_runs == 1) { + gate.pass(); + } + return more; + }; + }); + // Core waits, then erases the group, so the FIFO handler the job posts runs after the erase + std::promise core_gate; + std::promise erased; + loop.post([&] { + core_gate.get_future().wait(); + group.reset(); + erased.set_value(); + }); + // A write anchors a run, so the job posts its start + const std::vector pcm(size_t{960} * 4, 1); + ASSERT_EQ(view->write(pcm.data(), 960).frames, 960U); + gate.wait_reached(); + core_gate.set_value(); + erased.get_future().wait(); + // The FIFO handler the job posted has run once a handler posted now runs, while the job, + // still at its gate, holds the FIFO's state + std::promise handled; + loop.post([&] { handled.set_value(); }); + handled.get_future().wait(); + // The group closed the input on its way out + EXPECT_EQ(view->write(pcm.data(), 960).state, sendspin::server::InputState::CLOSED); + // The one thread runs tasks in the order woken: once a probe task has run behind the job, the + // job has returned; a wake then queues the job ahead of the probe's next run, unless stopped + std::promise probed; + std::promise probed_again; + std::atomic probes{0}; + const std::shared_ptr probe = workers.add([&] { + (++probes == 1 ? probed : probed_again).set_value(); + return false; + }); + probe->wake(); + gate.open(); + probed.get_future().wait(); + task->wake(); + probe->wake(); + probed_again.get_future().wait(); + EXPECT_EQ(gated_runs.load(), 1); + workers.stop(); + loop.post([&] { loop.shutdown(0); }); + core.join(); +} + +/// Runs a handler on core and waits for it +template +auto on_core(sendspin::server::EventLoop& loop, F fn) { + std::promise done; + loop.post([&] { + if constexpr (std::is_void_v) { + fn(); + done.set_value(); + } else { + done.set_value(fn()); + } + }); + return done.get_future().get(); +} + +// When the host releases its input, core ends a playing run itself and detaches the input: an +// EpochStarted the stopped job posted before the release, still in the FIFO when core sees the +// release, never starts a run with no input. Control: an EpochStarted delivered before the release +// starts the run, which the release ends. +TEST(GroupAudioThread, ReleasesAnInputWhoseEpochStartIsStillQueued) { + using sendspin::server::Group; + for (const bool queued : {true, false}) { + SCOPED_TRACE(queued ? "start queued" : "control: start delivered"); + sendspin::server::EventLoop loop; + AudioWorkers workers(1); + std::thread core([&] { loop.run(); }); + uint64_t next_instance = 1; + sendspin::server::ArtworkCache artwork = unused_artwork(); + std::unique_ptr group; + const auto view = std::make_shared( + sendspin::server::GroupId{1}, sendspin::server::InputId{1}, + sendspin::server::InputOptions{ + sendspin::server::InputFormat{48000, 2, sendspin::server::SampleFormat::S16}}, + sendspin::server::GroupParams{300000, 0, 1000000}, + std::make_shared(), nullptr); + const std::shared_ptr task = on_core(loop, [&] { + group = std::make_unique( + loop, workers, sendspin::server::GroupId{1}, "Released", next_instance, + sendspin::server::ResamplerQuality::MEDIUM, false, + sendspin::server::GroupHooks{[] {}, [](const sendspin::server::ServerError&) {}, + [] {}, [](const std::string&) {}}, + artwork); + EXPECT_TRUE(group->attach_input(view)); + return group->audio_; + }); + // White-box: the test learns when the job's next run, which the write below wakes, is over + std::promise ran; + std::atomic runs{0}; + wrap_job(*task, [&ran, &runs](std::function inner) { + return [inner = std::move(inner), &ran, &runs]() { + const bool more = inner(); + if (++runs == 1) { + ran.set_value(); + } + return more; + }; + }); + on_core(loop, [] {}); + // Core waits until the release, so the write's wakeup and the FIFO handler the job posts + // run after it, in that order + std::promise core_gate; + if (queued) { + loop.post([gate = core_gate.get_future().share()] { gate.wait(); }); + } + // A write anchors a run, so the job posts its start + const std::vector pcm(size_t{960} * 4, 1); + ASSERT_EQ(view->write(pcm.data(), 960).frames, 960U); + ran.get_future().wait(); + // The FIFO handler, posted before the job returned, has run once a later handler runs + if (!queued) { + EXPECT_TRUE(on_core(loop, [&] { return group->playing_; })); + } + // The one thread is busy, so the job the release wakes runs only after core saw it + std::promise busy; + std::promise go; + const std::shared_ptr blocker = + workers.add([&busy, gate = go.get_future().share()] { + busy.set_value(); + gate.wait(); + return false; + }); + blocker->wake(); + busy.get_future().wait(); + view->release(); + core_gate.set_value(); + // Core handles the release's wakeup, then the FIFO handler, then this + on_core(loop, [] {}); + const auto [playing, attached] = + on_core(loop, [&] { return std::pair{group->playing_, group->input_ != nullptr}; }); + EXPECT_FALSE(playing); + EXPECT_FALSE(attached); + go.set_value(); + on_core(loop, [&] { group.reset(); }); + workers.stop(); + loop.post([&] { loop.shutdown(0); }); + core.join(); + } +} + +// A paused live input takes every write the staging ring has room for, and its pipelines' job frees +// the ring; core polls for room once the job has run, so on_writable answers a write the full ring +// cut short even when core polled before the job freed it +TEST(GroupAudioThread, AnswersAFullRingOnceTheJobFreesIt) { + using sendspin::server::Group; + constexpr uint32_t RATE = 48000; + sendspin::server::test::Recorder recorder; + const auto dispatcher = std::make_shared(); + sendspin::server::EventLoop loop; + AudioWorkers workers(1); + std::thread core([&] { loop.run(); }); + uint64_t next_instance = 1; + sendspin::server::ArtworkCache artwork = unused_artwork(); + std::unique_ptr group; + sendspin::server::InputOptions options{{RATE, 2, sendspin::server::SampleFormat::S16}}; + options.kind = sendspin::server::InputKind::LIVE; + const auto view = std::make_shared( + sendspin::server::GroupId{1}, sendspin::server::InputId{1}, options, + sendspin::server::GroupParams{300000, 0, 200000}, dispatcher, &recorder); + const std::shared_ptr task = on_core(loop, [&] { + group = std::make_unique( + loop, workers, sendspin::server::GroupId{1}, "Paused", next_instance, + sendspin::server::ResamplerQuality::MEDIUM, false, + sendspin::server::GroupHooks{[] {}, [](const sendspin::server::ServerError&) {}, [] {}, + [](const std::string&) {}}, + artwork); + EXPECT_TRUE(group->attach_input(view)); + return group->audio_; + }); + // More than the ring holds, paused + const std::vector pcm(size_t{13} * RATE * 4, 1); + ASSERT_GT(view->write(pcm.data(), RATE / 10).frames, 0U); + view->pause(); + // The one thread is busy, so the job the full write wakes runs after core polled + std::promise busy; + std::promise go; + const std::shared_ptr blocker = + workers.add([&busy, gate = go.get_future().share()] { + busy.set_value(); + gate.wait(); + return false; + }); + blocker->wake(); + busy.get_future().wait(); + const sendspin::server::WriteResult full = view->write(pcm.data(), size_t{13} * RATE); + ASSERT_EQ(full.state, sendspin::server::InputState::FULL); + ASSERT_GT(full.frames, 0U); + // Core handled the write's wakeup: the ring has no room yet, and no time gives it any + EXPECT_EQ(on_core(loop, [&] { return view->poll_writable(sendspin::server::now_us()); }), + sendspin::server::sched::NEVER); + go.set_value(); + // Never ends unless core polls once the job freed the ring + recorder.wait_for("writable 1 1"); + EXPECT_EQ(view->state(), sendspin::server::InputState::OPEN); + on_core(loop, [&] { group.reset(); }); + workers.stop(); + loop.post([&] { loop.shutdown(0); }); + core.join(); + dispatcher->finish(); + dispatcher->join(); +} + +// The write that takes up set_format()'s slew leaves the slew it replaced in the view, and core +// frees it as it handles the group's wakeup. The pipeline jobs signal that wakeup too, so a write's +// signal can fold into one whose handler is not posted yet: the test runs the handler itself. +// White-box: Group::on_wake(), HostView::slew_, next_slew_, mutex_. +TEST(GroupAudioThread, FreesTheSlewAWriteReplacedOnCore) { + using sendspin::server::Group; + constexpr uint32_t RATE = 48000; + sendspin::server::test::Recorder recorder; + const auto dispatcher = std::make_shared(); + sendspin::server::EventLoop loop; + AudioWorkers workers(1); + std::thread core([&] { loop.run(); }); + uint64_t next_instance = 1; + sendspin::server::ArtworkCache artwork = unused_artwork(); + std::unique_ptr group; + sendspin::server::InputOptions options{{RATE, 2, sendspin::server::SampleFormat::S16}}; + options.kind = sendspin::server::InputKind::LIVE; + const auto view = std::make_shared( + sendspin::server::GroupId{1}, sendspin::server::InputId{1}, options, + sendspin::server::GroupParams{300000, 0, 200000}, dispatcher, &recorder); + on_core(loop, [&] { + group = std::make_unique( + loop, workers, sendspin::server::GroupId{1}, "Live", next_instance, + sendspin::server::ResamplerQuality::MEDIUM, false, + sendspin::server::GroupHooks{[] {}, [](const sendspin::server::ServerError&) {}, [] {}, + [](const std::string&) {}}, + artwork); + EXPECT_TRUE(group->attach_input(view)); + }); + // A cell in either format + const std::vector pcm(size_t{RATE} / 50 * 8, 0); + ASSERT_GT(view->write(pcm.data(), RATE / 50).frames, 0U); + ASSERT_TRUE(view->set_format({RATE, 2, sendspin::server::SampleFormat::F32})); + const uint8_t* made = [&] { + const std::lock_guard lock(view->mutex_); + return view->next_slew_.output(); + }(); + ASSERT_GT(view->write(pcm.data(), RATE / 50).frames, 0U); + const auto [adopted, retired] = on_core(loop, [&] { + group->on_wake(); + const std::lock_guard lock(view->mutex_); + return std::pair{view->slew_.output() == made, view->next_slew_.ready()}; + }); + EXPECT_TRUE(adopted); + EXPECT_FALSE(retired); + on_core(loop, [&] { group.reset(); }); + workers.stop(); + loop.post([&] { loop.shutdown(0); }); + core.join(); + dispatcher->finish(); + dispatcher->join(); +} + +/// A group with no input, playing epoch 1 on its own anchor, with a frame ring for instance 7 and +/// an analysis on instance 8, and its INFO lines in lines. White-box: it sets the state an +/// epoch's start sets (playing_, epoch_, anchor_, rings_, pipelines_.analyses). +struct StallGroup { + sendspin::server::EventLoop loop; + AudioWorkers workers{1}; + uint64_t next_instance = 1; + sendspin::server::ArtworkCache artwork = unused_artwork(); + std::mutex mutex; + std::vector lines; + std::unique_ptr group; + + static constexpr sendspin::server::sched::EpochId EPOCH{1}; + static constexpr sendspin::server::sched::InstanceId PLAYER{7}; + static constexpr sendspin::server::sched::InstanceId ANALYSIS{8}; + + StallGroup() { + sendspin::server::GroupHooks hooks{[] {}, [](const sendspin::server::ServerError&) {}, + [] {}, [](const std::string&) {}}; + hooks.info = [this](std::string line) { + const std::lock_guard lock(this->mutex); + this->lines.push_back(std::move(line)); + }; + this->group = std::make_unique( + this->loop, this->workers, sendspin::server::GroupId{1}, "Kitchen", this->next_instance, + sendspin::server::ResamplerQuality::MEDIUM, false, std::move(hooks), this->artwork); + sendspin::server::Group& g = *this->group; + g.playing_ = true; + g.epoch_ = EPOCH; + g.anchor_ = sendspin::server::sched::make_anchor(0, 0, 48000, 2); + g.rings_.emplace(PLAYER, sendspin::server::sched::FrameRing(PLAYER, 1)); + g.rings_.emplace(ANALYSIS, sendspin::server::sched::FrameRing(ANALYSIS, 1)); + g.pipelines_.analyses.push_back(sendspin::server::AnalysisRequest{ANALYSIS, EPOCH, {}, 0}); + } + + void frame(sendspin::server::sched::InstanceId instance, sendspin::server::sched::EpochId epoch, + int64_t ts_us) { + sendspin::server::sched::Frame frame; + frame.epoch = epoch; + frame.ts_us = ts_us; + sendspin::server::FrameReady ready{instance, std::move(frame)}; + this->group->on_frame(ready); + } + + std::vector taken() { + const std::lock_guard lock(this->mutex); + return std::exchange(this->lines, {}); + } +}; + +// A group counts the rebases its pipelines post in its epoch, with their shifts, and each player +// frame that reaches its ring at or past its play time, once per instance; not a frame still ahead +// of its time, one of another epoch, or an analysis's cell (which skips its ring instead). The +// counts are read from Group::stalls_, as the log line is the only other place they show. +TEST(GroupStalls, CountsRebasesAndPlayerFramesPastTheirPlayTime) { + using sendspin::server::AnchorShift; + using sendspin::server::sched::STALL_REPORT_US; + StallGroup s; + sendspin::server::Group& group = *s.group; + const int64_t now = sendspin::server::now_us(); + // A first stall is reported at once, which holds back the counts that follow + s.frame(StallGroup::PLAYER, StallGroup::EPOCH, now - 1); + ASSERT_EQ(s.taken().size(), 1U); + + s.frame(StallGroup::PLAYER, StallGroup::EPOCH, now - 1); + s.frame(StallGroup::PLAYER, StallGroup::EPOCH, now - 1000); + s.frame(StallGroup::PLAYER, StallGroup::EPOCH, now + 10'000'000); + s.frame(StallGroup::PLAYER, sendspin::server::sched::EpochId{2}, now - 1); + s.frame(StallGroup::ANALYSIS, StallGroup::EPOCH, now - 1); + group.on_anchor_shift(AnchorShift{StallGroup::EPOCH, 10, 300'000}); + group.on_anchor_shift(AnchorShift{StallGroup::EPOCH, 20, 200'000}); + group.on_anchor_shift(AnchorShift{sendspin::server::sched::EpochId{2}, 30, 900'000}); + EXPECT_TRUE(s.taken().empty()); + + const std::optional report = + group.stalls_.take(sendspin::server::now_us() + STALL_REPORT_US).report; + ASSERT_TRUE(report); + EXPECT_EQ(report->rebases, 2U); + EXPECT_EQ(report->shifted_us, 500'000); + EXPECT_EQ(report->late_frames, 2U); +} + +// A stall with no report made (here a rebase) is logged at once; within STALL_REPORT_US of one it +// is logged once the period ends, by the group's timer when no stall follows, and a report with +// nothing counted logs nothing +TEST(GroupStalls, ReportsAtMostEveryPeriodAndHoldsNothingBack) { + using sendspin::server::sched::STALL_REPORT_US; + StallGroup s; + sendspin::server::Group& group = *s.group; + std::thread core([&] { s.loop.run(); }); + const int64_t now = sendspin::server::now_us(); + std::promise counted; + s.loop.post([&] { + group.on_anchor_shift(sendspin::server::AnchorShift{StallGroup::EPOCH, 10, 300'000}); + // White-box: the last report replaced by one made so that the next is due 100 ms from now + group.stalls_ = sendspin::server::sched::StallCount{}; + group.stalls_.add_late_frame(); + (void)group.stalls_.take(sendspin::server::now_us() + 100'000 - STALL_REPORT_US); + s.frame(StallGroup::PLAYER, StallGroup::EPOCH, now - 1); + counted.set_value(); + }); + counted.get_future().get(); + EXPECT_EQ(s.taken().size(), 1U); + + // The held-back frame, by the timer; a regression hangs into the suite watchdog + std::vector lines; + while (lines.empty()) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + lines = s.taken(); + } + EXPECT_EQ(lines.size(), 1U); + + std::promise reported; + s.loop.post([&] { + group.report_stalls(sendspin::server::now_us() + (2 * STALL_REPORT_US)); + reported.set_value(); + }); + reported.get_future().get(); + EXPECT_TRUE(s.taken().empty()); + s.loop.post([&] { + s.group.reset(); + s.loop.shutdown(0); + }); + core.join(); +} + +} // namespace diff --git a/tests/test_sched_admission.cpp b/tests/test_sched_admission.cpp index 69ea283..1cab2ff 100644 --- a/tests/test_sched_admission.cpp +++ b/tests/test_sched_admission.cpp @@ -13,11 +13,10 @@ // limitations under the License. /// @file test_sched_admission.cpp -/// @brief Write admission and readiness and the host view's run, latch and drain decisions +/// @brief Write admission and readiness #include "sched/admission.h" #include "sched/constants.h" -#include "sched/run_view.h" #include #include @@ -27,15 +26,10 @@ namespace { using sendspin::server::sched::AdmissionInput; using sendspin::server::sched::Anchor; -using sendspin::server::sched::DrainOutcome; -using sendspin::server::sched::DrainResolution; -using sendspin::server::sched::EpochId; using sendspin::server::sched::InputKind; using sendspin::server::sched::InputState; using sendspin::server::sched::NEVER; -using sendspin::server::sched::RunId; using sendspin::server::sched::RunState; -using sendspin::server::sched::RunView; using sendspin::server::sched::WriteDecision; namespace sched = sendspin::server::sched; @@ -265,172 +259,6 @@ TEST(SchedAdmission, ReadinessTime) { } } -std::optional outcome(const std::optional& resolution) { - return resolution.has_value() ? std::optional(resolution->outcome) : std::nullopt; -} - -RunView playing(uint64_t epoch) { - RunView view; - view.run = RunState::PLAYING; - view.epoch = EpochId{epoch}; - return view; -} - -// Whoever resolves a staged drain first posts its one outcome -TEST(SchedRunView, StagedDrainResolvesOnce) { - struct Row { - const char* name; - std::optional (*first)(RunView&); - DrainOutcome outcome; - }; - const Row rows[] = { - {"Control: completion", - [](RunView& v) { return outcome(sched::view_drain_complete(v, EpochId{1})); }, - DrainOutcome::PLAYED}, - {"flush", [](RunView& v) { return outcome(sched::view_flush(v, EpochId{2})); }, - DrainOutcome::FLUSHED}, - {"host stop", [](RunView& v) { return outcome(sched::view_stop(v)); }, - DrainOutcome::STOPPED}, - {"server stop", [](RunView& v) { return outcome(sched::view_server_stop(v).drain); }, - DrainOutcome::STOPPED}, - }; - for (const Row& row : rows) { - SCOPED_TRACE(row.name); - RunView view = playing(1); - EXPECT_FALSE(sched::view_drain(view).has_value()); - EXPECT_EQ(row.first(view), row.outcome); - // The loser of any race resolves nothing - EXPECT_FALSE(sched::view_drain_complete(view, EpochId{1}).has_value()); - EXPECT_FALSE(sched::view_flush(view, EpochId{3}).has_value()); - EXPECT_FALSE(sched::view_stop(view).has_value()); - } -} - -TEST(SchedRunView, DrainCalls) { - // No run: played at once, nothing staged - RunView none; - EXPECT_EQ(outcome(sched::view_drain(none)), DrainOutcome::PLAYED); - EXPECT_FALSE(none.drain.has_value()); - - // A second drain() joins: two callbacks, one outcome - RunView twice = playing(1); - EXPECT_FALSE(sched::view_drain(twice).has_value()); - EXPECT_FALSE(sched::view_drain(twice).has_value()); - const std::optional both = sched::view_drain_complete(twice, EpochId{1}); - ASSERT_TRUE(both.has_value()); - EXPECT_EQ(both->outcome, DrainOutcome::PLAYED); - EXPECT_EQ(both->callbacks, 2U); - EXPECT_EQ(twice.run, RunState::NONE); - - // A pause keeps it pending: completion is not delivered while paused; the resumed epoch - // drains instead, and the old epoch's completion is stale - RunView paused = playing(1); - sched::view_drain(paused); - EXPECT_TRUE(sched::view_pause(paused)); - EXPECT_FALSE(sched::view_drain_complete(paused, EpochId{1}).has_value()); - EXPECT_TRUE(sched::view_resume(paused, RunId{2}, EpochId{2}, true)); - EXPECT_FALSE(sched::view_drain_complete(paused, EpochId{1}).has_value()); - EXPECT_EQ(outcome(sched::view_drain_complete(paused, EpochId{2})), DrainOutcome::PLAYED); - - // flush() then drain() with no write: the pending epoch drains and completes - RunView flushed = playing(1); - sched::view_flush(flushed, EpochId{2}); - EXPECT_EQ(flushed.run, RunState::PENDING); - sched::view_drain(flushed); - EXPECT_EQ(outcome(sched::view_drain_complete(flushed, EpochId{2})), DrainOutcome::PLAYED); -} - -TEST(SchedRunView, StopsAndTheLatch) { - // The host's stop discards a staged flush and a staged drain; the next write begins a run - RunView host = playing(1); - sched::view_flush(host, EpochId{2}); - sched::view_drain(host); - EXPECT_EQ(outcome(sched::view_stop(host)), DrainOutcome::STOPPED); - EXPECT_EQ(host.run, RunState::NONE); - sched::view_anchor_write(host, RunId{2}, EpochId{3}); - EXPECT_EQ(host.run, RunState::PLAYING); - EXPECT_EQ(host.epoch, EpochId{3}); - - // A server-initiated stop latches; flush, pause and resume do nothing; drain resolves at once - // as stopped and keeps the latch; the host's stop clears it - RunView server = playing(4); - const sched::ServerStopResult stop = sched::view_server_stop(server); - EXPECT_TRUE(stop.applied); - EXPECT_EQ(stop.stopped_epoch, EpochId{4}); - EXPECT_TRUE(server.latched); - EXPECT_FALSE(sched::view_flush(server, EpochId{5}).has_value()); - EXPECT_FALSE(sched::view_pause(server)); - EXPECT_FALSE(sched::view_resume(server, RunId{3}, EpochId{5}, true)); - EXPECT_EQ(server.run, RunState::NONE); - EXPECT_EQ(outcome(sched::view_drain(server)), DrainOutcome::STOPPED); - EXPECT_TRUE(server.latched); - EXPECT_FALSE(sched::view_stop(server).has_value()); - EXPECT_FALSE(server.latched); - sched::view_anchor_write(server, RunId{4}, EpochId{6}); - EXPECT_EQ(server.run, RunState::PLAYING); - - // A server-initiated stop on a paused view does nothing, and resume() still works - RunView paused = playing(7); - sched::view_pause(paused); - EXPECT_FALSE(sched::view_server_stop(paused).applied); - EXPECT_FALSE(paused.latched); - EXPECT_TRUE(sched::view_resume(paused, RunId{5}, EpochId{8}, true)); - EXPECT_EQ(paused.run, RunState::PLAYING); - - // A stop with no run changes nothing - RunView none; - EXPECT_FALSE(sched::view_server_stop(none).applied); - EXPECT_FALSE(none.latched); - EXPECT_FALSE(sched::view_stop(none).has_value()); - EXPECT_EQ(none.run, RunState::NONE); - - // A pending epoch is stopped like a playing run - RunView pending = playing(9); - sched::view_flush(pending, EpochId{10}); - const sched::ServerStopResult pending_stop = sched::view_server_stop(pending); - EXPECT_TRUE(pending_stop.applied); - EXPECT_EQ(pending_stop.stopped_epoch, EpochId{10}); -} - -// With a flush and a pause in either order, resume() anchors at p0 when input exists at or after -// it, and otherwise leaves the epoch pending for the first write -TEST(SchedRunView, FlushAndPauseInEitherOrder) { - struct Row { - const char* name; - bool flush_first; - bool written_while_paused; - RunState after_resume; - }; - const Row rows[] = { - {"Control: flush, pause, writes, resume", true, true, RunState::PLAYING}, - {"flush, pause, no writes, resume", true, false, RunState::PENDING}, - {"pause, flush, writes, resume", false, true, RunState::PLAYING}, - {"pause, flush, no writes, resume", false, false, RunState::PENDING}, - }; - for (const Row& row : rows) { - SCOPED_TRACE(row.name); - RunView view = playing(1); - view.run_id = RunId{1}; - if (row.flush_first) { - sched::view_flush(view, EpochId{2}); - EXPECT_TRUE(sched::view_pause(view)); - } else { - EXPECT_TRUE(sched::view_pause(view)); - sched::view_flush(view, EpochId{2}); - } - EXPECT_EQ(view.run, RunState::PAUSED); - EXPECT_TRUE(sched::view_resume(view, RunId{2}, EpochId{3}, row.written_while_paused)); - EXPECT_EQ(view.run, row.after_resume); - EXPECT_EQ(view.run_id, RunId{2}); - EXPECT_EQ(view.epoch, EpochId{3}); - // The first write anchors a pending epoch in place, keeping the resume's ids - sched::view_anchor_write(view, RunId{9}, EpochId{9}); - EXPECT_EQ(view.run, RunState::PLAYING); - EXPECT_EQ(view.run_id, RunId{2}); - EXPECT_EQ(view.epoch, EpochId{3}); - } -} - // Admission across a flush and a pause: positions restart at p0 = 0, a pause after the flush // freezes at p0 + W, and a pending epoch admits W from p0 however much was written before TEST(SchedAdmission, FlushAndPauseAdmitWFromP0) { diff --git a/tests/test_sched_run_view.cpp b/tests/test_sched_run_view.cpp new file mode 100644 index 0000000..9232c0f --- /dev/null +++ b/tests/test_sched_run_view.cpp @@ -0,0 +1,200 @@ +// Copyright 2026 Sendspin Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// @file test_sched_run_view.cpp +/// @brief The host view's run, latch and drain decisions + +#include "sched/run_view.h" +#include + +#include +#include + +namespace { + +using sendspin::server::sched::DrainOutcome; +using sendspin::server::sched::DrainResolution; +using sendspin::server::sched::EpochId; +using sendspin::server::sched::RunId; +using sendspin::server::sched::RunState; +using sendspin::server::sched::RunView; +namespace sched = sendspin::server::sched; + +std::optional outcome(const std::optional& resolution) { + return resolution.has_value() ? std::optional(resolution->outcome) : std::nullopt; +} + +RunView playing(uint64_t epoch) { + RunView view; + view.run = RunState::PLAYING; + view.epoch = EpochId{epoch}; + return view; +} + +// Whoever resolves a staged drain first posts its one outcome +TEST(SchedRunView, StagedDrainResolvesOnce) { + struct Row { + const char* name; + std::optional (*first)(RunView&); + DrainOutcome outcome; + }; + const Row rows[] = { + {"Control: completion", + [](RunView& v) { return outcome(sched::view_drain_complete(v, EpochId{1})); }, + DrainOutcome::PLAYED}, + {"flush", [](RunView& v) { return outcome(sched::view_flush(v, EpochId{2})); }, + DrainOutcome::FLUSHED}, + {"host stop", [](RunView& v) { return outcome(sched::view_stop(v)); }, + DrainOutcome::STOPPED}, + {"server stop", [](RunView& v) { return outcome(sched::view_server_stop(v).drain); }, + DrainOutcome::STOPPED}, + }; + for (const Row& row : rows) { + SCOPED_TRACE(row.name); + RunView view = playing(1); + EXPECT_FALSE(sched::view_drain(view).has_value()); + EXPECT_EQ(row.first(view), row.outcome); + // The loser of any race resolves nothing + EXPECT_FALSE(sched::view_drain_complete(view, EpochId{1}).has_value()); + EXPECT_FALSE(sched::view_flush(view, EpochId{3}).has_value()); + EXPECT_FALSE(sched::view_stop(view).has_value()); + } +} + +TEST(SchedRunView, DrainCalls) { + // No run: played at once, nothing staged + RunView none; + EXPECT_EQ(outcome(sched::view_drain(none)), DrainOutcome::PLAYED); + EXPECT_FALSE(none.drain.has_value()); + + // A second drain() joins: two callbacks, one outcome + RunView twice = playing(1); + EXPECT_FALSE(sched::view_drain(twice).has_value()); + EXPECT_FALSE(sched::view_drain(twice).has_value()); + const std::optional both = sched::view_drain_complete(twice, EpochId{1}); + ASSERT_TRUE(both.has_value()); + EXPECT_EQ(both->outcome, DrainOutcome::PLAYED); + EXPECT_EQ(both->callbacks, 2U); + EXPECT_EQ(twice.run, RunState::NONE); + + // A pause keeps it pending: completion is not delivered while paused; the resumed epoch + // drains instead, and the old epoch's completion is stale + RunView paused = playing(1); + sched::view_drain(paused); + EXPECT_TRUE(sched::view_pause(paused)); + EXPECT_FALSE(sched::view_drain_complete(paused, EpochId{1}).has_value()); + EXPECT_TRUE(sched::view_resume(paused, RunId{2}, EpochId{2}, true)); + EXPECT_FALSE(sched::view_drain_complete(paused, EpochId{1}).has_value()); + EXPECT_EQ(outcome(sched::view_drain_complete(paused, EpochId{2})), DrainOutcome::PLAYED); + + // flush() then drain() with no write: the pending epoch drains and completes + RunView flushed = playing(1); + sched::view_flush(flushed, EpochId{2}); + EXPECT_EQ(flushed.run, RunState::PENDING); + sched::view_drain(flushed); + EXPECT_EQ(outcome(sched::view_drain_complete(flushed, EpochId{2})), DrainOutcome::PLAYED); +} + +TEST(SchedRunView, StopsAndTheLatch) { + // The host's stop discards a staged flush and a staged drain; the next write begins a run + RunView host = playing(1); + sched::view_flush(host, EpochId{2}); + sched::view_drain(host); + EXPECT_EQ(outcome(sched::view_stop(host)), DrainOutcome::STOPPED); + EXPECT_EQ(host.run, RunState::NONE); + sched::view_anchor_write(host, RunId{2}, EpochId{3}); + EXPECT_EQ(host.run, RunState::PLAYING); + EXPECT_EQ(host.epoch, EpochId{3}); + + // A server-initiated stop latches; flush, pause and resume do nothing; drain resolves at once + // as stopped and keeps the latch; the host's stop clears it + RunView server = playing(4); + const sched::ServerStopResult stop = sched::view_server_stop(server); + EXPECT_TRUE(stop.applied); + EXPECT_EQ(stop.stopped_epoch, EpochId{4}); + EXPECT_TRUE(server.latched); + EXPECT_FALSE(sched::view_flush(server, EpochId{5}).has_value()); + EXPECT_FALSE(sched::view_pause(server)); + EXPECT_FALSE(sched::view_resume(server, RunId{3}, EpochId{5}, true)); + EXPECT_EQ(server.run, RunState::NONE); + EXPECT_EQ(outcome(sched::view_drain(server)), DrainOutcome::STOPPED); + EXPECT_TRUE(server.latched); + EXPECT_FALSE(sched::view_stop(server).has_value()); + EXPECT_FALSE(server.latched); + sched::view_anchor_write(server, RunId{4}, EpochId{6}); + EXPECT_EQ(server.run, RunState::PLAYING); + + // A server-initiated stop on a paused view does nothing, and resume() still works + RunView paused = playing(7); + sched::view_pause(paused); + EXPECT_FALSE(sched::view_server_stop(paused).applied); + EXPECT_FALSE(paused.latched); + EXPECT_TRUE(sched::view_resume(paused, RunId{5}, EpochId{8}, true)); + EXPECT_EQ(paused.run, RunState::PLAYING); + + // A stop with no run changes nothing + RunView none; + EXPECT_FALSE(sched::view_server_stop(none).applied); + EXPECT_FALSE(none.latched); + EXPECT_FALSE(sched::view_stop(none).has_value()); + EXPECT_EQ(none.run, RunState::NONE); + + // A pending epoch is stopped like a playing run + RunView pending = playing(9); + sched::view_flush(pending, EpochId{10}); + const sched::ServerStopResult pending_stop = sched::view_server_stop(pending); + EXPECT_TRUE(pending_stop.applied); + EXPECT_EQ(pending_stop.stopped_epoch, EpochId{10}); +} + +// With a flush and a pause in either order, resume() anchors at p0 when input exists at or after +// it, and otherwise leaves the epoch pending for the first write +TEST(SchedRunView, FlushAndPauseInEitherOrder) { + struct Row { + const char* name; + bool flush_first; + bool written_while_paused; + RunState after_resume; + }; + const Row rows[] = { + {"Control: flush, pause, writes, resume", true, true, RunState::PLAYING}, + {"flush, pause, no writes, resume", true, false, RunState::PENDING}, + {"pause, flush, writes, resume", false, true, RunState::PLAYING}, + {"pause, flush, no writes, resume", false, false, RunState::PENDING}, + }; + for (const Row& row : rows) { + SCOPED_TRACE(row.name); + RunView view = playing(1); + view.run_id = RunId{1}; + if (row.flush_first) { + sched::view_flush(view, EpochId{2}); + EXPECT_TRUE(sched::view_pause(view)); + } else { + EXPECT_TRUE(sched::view_pause(view)); + sched::view_flush(view, EpochId{2}); + } + EXPECT_EQ(view.run, RunState::PAUSED); + EXPECT_TRUE(sched::view_resume(view, RunId{2}, EpochId{3}, row.written_while_paused)); + EXPECT_EQ(view.run, row.after_resume); + EXPECT_EQ(view.run_id, RunId{2}); + EXPECT_EQ(view.epoch, EpochId{3}); + // The first write anchors a pending epoch in place, keeping the resume's ids + sched::view_anchor_write(view, RunId{9}, EpochId{9}); + EXPECT_EQ(view.run, RunState::PLAYING); + EXPECT_EQ(view.run_id, RunId{2}); + EXPECT_EQ(view.epoch, EpochId{3}); + } +} + +} // namespace diff --git a/tests/test_scheduled_updates.cpp b/tests/test_scheduled_updates.cpp new file mode 100644 index 0000000..ef9e13e --- /dev/null +++ b/tests/test_scheduled_updates.cpp @@ -0,0 +1,57 @@ +// Copyright 2026 Sendspin Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// @file test_scheduled_updates.cpp +/// @brief ScheduledUpdates, a current state and at most one scheduled update, through the color +/// schedule. + +#include "colors.h" +#include "scheduled_updates.h" +#include + +#include +#include + +namespace { + +using sendspin::server::Colors; +using sendspin::server::ColorState; +using sendspin::server::RgbColor; + +// spec roles/color/v1.md "Scheduled color updates", through the schedule metadata shares +TEST(ColorSchedule, SendsCurrentAndScheduledStates) { + constexpr int64_t S = 1000000; + sendspin::server::ScheduledUpdates colors; + Colors red; + red.primary = RgbColor{255, 0, 0}; + EXPECT_EQ(colors.current(5).timestamp_us, 5); + EXPECT_EQ(colors.current(5).colors, Colors{}); + std::vector sends = colors.set({S, red}, 2 * S); + ASSERT_EQ(sends.size(), 1U); + EXPECT_EQ(sends[0], (ColorState{S, red})); + Colors blue; + blue.primary = RgbColor{0, 0, 255}; + EXPECT_TRUE(colors.set({30 * S, blue}, 3 * S).empty()); + EXPECT_EQ(colors.next_due_us(), 10 * S); + sends = colors.advance(10 * S); + ASSERT_EQ(sends.size(), 1U); + EXPECT_EQ(sends[0].colors, blue); + EXPECT_EQ(colors.sent_pending(), (ColorState{30 * S, blue})); + // Cancelled once sent: the current state again + sends = colors.cancel(11 * S); + ASSERT_EQ(sends.size(), 1U); + EXPECT_EQ(sends[0], (ColorState{S, red})); +} + +} // namespace diff --git a/tests/test_server_groups.cpp b/tests/test_server_groups.cpp new file mode 100644 index 0000000..925c8b7 --- /dev/null +++ b/tests/test_server_groups.cpp @@ -0,0 +1,607 @@ +// Copyright 2026 Sendspin Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// @file test_server_groups.cpp +/// @brief The host's groups end to end against raw clients: a client's group across a reconnect +/// within the rejoin grace, and the groups the host creates, renames and moves clients between. + +#include "raw_client.h" +#include "raw_roles.h" +#include "test_recorder.h" +#include "wire_messages.h" +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using sendspin::server::ControllerCaps; +using sendspin::server::GroupId; +using sendspin::server::GroupInfo; +using sendspin::server::Metadata; +using sendspin::server::ServerConfig; +using sendspin::server::StartResult; +using sendspin::server::StreamCodec; +using sendspin::server::StreamFormat; +using sendspin::server::TransportCommand; +using sendspin::server::test::client_id_of; +using sendspin::server::test::connect; +using sendspin::server::test::controller_command; +using sendspin::server::test::group_id; +using sendspin::server::test::hello; +using sendspin::server::test::LocalServer; +using sendspin::server::test::next; +using sendspin::server::test::payload; +using sendspin::server::test::player_state; +using sendspin::server::test::RawClient; +using sendspin::server::test::type; +namespace wire = sendspin::server::test::wire; + +// ============================================================================== +// A client's group across a reconnect +// ============================================================================== + +// A player that reconnects within the grace rejoins the group it left, which is kept for it; here +// the solo group it moved to on turning unavailable (messaging.md "Server behavior when a client +// becomes unavailable"), so it is not put back in the server's group. Control: past the grace its +// group is gone, and an approved player joins the server's group; so too after a client/goodbye +// `another_server`, which says it is not coming back. +TEST(ServerGroups, AClientBackWithinTheGraceRejoinsItsGroup) { + enum class Leaves : uint8_t { WITHIN, PAST, ANOTHER_SERVER }; + for (const Leaves leaves : {Leaves::WITHIN, Leaves::PAST, Leaves::ANOTHER_SERVER}) { + SCOPED_TRACE(static_cast(leaves)); + const bool within = leaves == Leaves::WITHIN; + LocalServer s; + ServerConfig config = sendspin::server::test::local_config(); + config.approve_all_clients = true; + if (leaves == Leaves::PAST) { + config.timing.group_rejoin_grace_us = 1; + } + ASSERT_EQ(s.start(config), StartResult::OK); + const std::string default_group = group_id(*s.server.default_group()); + RawClient a; + RawClient b; + const std::string hello_json = hello("A", {"player@v1"}, true); + ASSERT_NO_FATAL_FAILURE(connect(s.server, a, hello_json)); + EXPECT_EQ(next(a, "group/update")["payload"]["group_id"].as(), default_group); + ASSERT_NO_FATAL_FAILURE(connect(s.server, b, hello("B", {"player@v1"}, true))); + next(b, "group/update"); + // The first client/state is an initial value; the second a transition + a.send_json(player_state(true, {}, std::nullopt, std::nullopt)); + b.send_json(player_state(true, {}, std::nullopt, std::nullopt)); + a.send_json(player_state(false, {}, std::nullopt, std::nullopt)); + const JsonDocument moved = next(a, "group/update"); + const std::string solo = moved["payload"]["group_id"].as(); + EXPECT_NE(solo, default_group); + EXPECT_EQ(moved["payload"]["playback_state"].as(), "stopped"); + + if (leaves == Leaves::ANOTHER_SERVER) { + a.send_json(sendspin::server::test::client_goodbye_json("another_server")); + } + a.close(); + s.recorder.wait_for_disconnects(1); + const auto has_solo = [&] { + const std::vector groups = s.server.groups(); + return std::any_of(groups.begin(), groups.end(), + [&](const GroupInfo& group) { return group_id(group.id) == solo; }); + }; + if (leaves == Leaves::PAST) { + while (has_solo()) { + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + } + ASSERT_NO_FATAL_FAILURE(connect(s.server, a, hello_json)); + EXPECT_EQ(type(next(a)), "server/activate"); + const JsonDocument rejoined = next(a); + ASSERT_EQ(type(rejoined), "group/update"); + EXPECT_EQ(rejoined["payload"]["group_id"].as(), within ? solo : default_group); + } +} + +// ============================================================================== +// The host's groups (create_group(), rename_group(), move_client()) +// ============================================================================== + +/// A started server that approves every client +class GroupsServer : public LocalServer { +public: + GroupsServer() { + ServerConfig config = sendspin::server::test::local_config(); + config.name = "Living room"; + config.approve_all_clients = true; + EXPECT_EQ(this->start(config), StartResult::OK); + } + + /// Connects client with every implemented role, reporting a volume of 50 it offers to set, and + /// reads its activation, group/update and roles' server/state + /// @return the group_id its group/update carries + std::string join(RawClient& client, const std::string& name) { + connect(this->server, client, + hello(name, {"player@v1", "controller@v1", "metadata@v1"}, true)); + EXPECT_EQ(type(next(client)), "server/activate"); + client.send_json(player_state(true, {"volume", "mute"}, 50, false)); + const JsonDocument update = next(client); + EXPECT_EQ(type(update), "group/update"); + // The state its group had, then the controller's once its own volume counts + while (next(client, "server/state")["payload"]["controller"]["volume"] != 50) {} + return update["payload"]["group_id"].as(); + } + + /// The group's entry in groups(), sampled until whether it is listed is listed; with no + /// timeout, so a regression hangs into the suite watchdog + std::optional wait_until(GroupId group, bool listed) { + for (;;) { + const std::vector groups = this->server.groups(); + const auto it = std::find_if(groups.begin(), groups.end(), + [&](const GroupInfo& info) { return info.id == group; }); + if ((it != groups.end()) == listed) { + return it == groups.end() ? std::nullopt : std::optional(*it); + } + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + } + + /// The renames expect_nothing_queued() made + int renames{0}; +}; + +/// A host writing silence to a group's input until destroyed; constructed once the group plays +class Silence { +public: + Silence(GroupsServer& s, GroupId group) { + sendspin::server::InputOptions options; + options.format = {48000, 2, sendspin::server::SampleFormat::S16}; + this->input = s.server.open_input(group, options).input; + EXPECT_TRUE(this->input); + this->thread_ = std::thread([this] { + constexpr size_t FRAMES = 960; + const std::vector block(FRAMES * 4, 0); + while (!this->stop_.load()) { + const sendspin::server::WriteResult result = + this->input->write(block.data(), FRAMES); + if (result.state == sendspin::server::InputState::FULL) { + this->input->wait_writable(20000); + } else if (result.frames == 0) { + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + } + }); + s.recorder.wait_for("group " + group_id(group) + " playing"); + } + + ~Silence() { + this->stop_ = true; + this->thread_.join(); + } + + Silence(const Silence&) = delete; + Silence& operator=(const Silence&) = delete; + + std::shared_ptr input; + +private: + std::atomic stop_{false}; + std::thread thread_; +}; + +/// The next JSON message of type, skipping others and audio chunks +JsonDocument next_json(RawClient& client, std::string_view type) { + for (;;) { + const std::optional> plaintext = client.receive_plaintext(); + if (!plaintext) { + ADD_FAILURE() << "the connection closed while waiting for " << type; + return JsonDocument(); + } + if (plaintext->empty() || plaintext->front() != wire::JSON_ID) { + continue; + } + JsonDocument doc; + deserializeJson(doc, plaintext->data() + 1, plaintext->size() - 1); + if (doc["type"].as() == type) { + return doc; + } + } +} + +/// The group/update a move into a playing group gives, read with the stream/start that follows it: +/// a marker queued before the move emits the start when it reaches the head, so the start can come +/// first +JsonDocument next_join(RawClient& client) { + std::optional update; + bool started = false; + while (!update || !started) { + const std::optional> plaintext = client.receive_plaintext(); + if (!plaintext) { + ADD_FAILURE() << "the connection closed while waiting for the join"; + return JsonDocument(); + } + if (plaintext->empty() || plaintext->front() != wire::JSON_ID) { + continue; + } + JsonDocument doc; + deserializeJson(doc, plaintext->data() + 1, plaintext->size() - 1); + const std::string_view type = doc["type"].as(); + if (type == "group/update") { + update = std::move(doc); + } else if (type == "stream/start") { + started = true; + } + } + return *update; +} + +/// Proves client, in group, was sent nothing since the last message read from it: a rename of +/// the group queues a group/update behind anything queued for it, so that update comes next (a +/// time reply would not do: the pump takes one ahead of what is queued) +void expect_nothing_queued(GroupsServer& s, RawClient& client, GroupId group) { + const std::string name = "Renamed " + std::to_string(++s.renames); + ASSERT_TRUE(s.server.rename_group(group, name)); + const JsonDocument update = next(client); + EXPECT_EQ(type(update), "group/update"); + EXPECT_EQ(update["payload"]["group_name"], name); +} + +// messaging.md "group/update": a client the host moves into a group it creates is told with a +// group/update carrying every field, then gets the group's controller and metadata state +// (messaging.md "server/state"; metadata/v1.md: the new group's current state); a rename is told +// to the members the same way; moved back, it gets its old group's state, and the group it left, +// empty and with no input, goes. The member that stays is sent nothing. +TEST(ServerGroups, MovesAClientIntoAGroupTheHostCreatesAndBack) { + GroupsServer s; + const GroupId living = *s.server.default_group(); + RawClient a; + RawClient b; + EXPECT_EQ(s.join(a, "A"), group_id(living)); + EXPECT_EQ(s.join(b, "B"), group_id(living)); + const std::string b_id = client_id_of(b); + Metadata metadata; + metadata.title = "Living room's"; + ASSERT_TRUE(s.server.set_metadata(living, metadata, s.server.now_us())); + EXPECT_EQ(next(a, "server/state")["payload"]["metadata"]["title"], "Living room's"); + EXPECT_EQ(next(b, "server/state")["payload"]["metadata"]["title"], "Living room's"); + + const std::optional kitchen = s.server.create_group("Kitchen", b_id); + ASSERT_TRUE(kitchen); + EXPECT_EQ(payload(next(b)), R"({"playback_state":"stopped","group_id":")" + group_id(*kitchen) + + R"(","group_name":"Kitchen"})"); + JsonDocument state = next(b); + ASSERT_EQ(type(state), "server/state"); + EXPECT_EQ(state["payload"]["controller"]["volume"], 50); + // Nothing set in the kitchen: its timestamp alone + EXPECT_EQ(state["payload"]["metadata"].size(), 1U); + // A stopped group's member declares no activities, keeping its roles + EXPECT_EQ(payload(next(b)), R"({"activities":[]})"); + s.recorder.wait_for("group " + group_id(*kitchen) + " stopped"); + const std::optional listed = s.wait_until(*kitchen, true); + ASSERT_TRUE(listed); + EXPECT_EQ(listed->name, "Kitchen"); + EXPECT_EQ(listed->client_ids, std::vector{b_id}); + // The controller state A sees is unchanged by B leaving, both at 50 + expect_nothing_queued(s, a, living); + + ASSERT_TRUE(s.server.rename_group(*kitchen, "Den")); + EXPECT_EQ(payload(next(b)), R"({"playback_state":"stopped","group_id":")" + group_id(*kitchen) + + R"(","group_name":"Den"})"); + expect_nothing_queued(s, a, living); + + ASSERT_TRUE(s.server.move_client(b_id, living)); + EXPECT_EQ(next(b)["payload"]["group_id"].as(), group_id(living)); + state = next(b); + ASSERT_EQ(type(state), "server/state"); + EXPECT_EQ(state["payload"]["metadata"]["title"], "Living room's"); + EXPECT_FALSE(s.wait_until(*kitchen, false)); + // Moving into the group it is in changes nothing + ASSERT_TRUE(s.server.move_client(b_id, living)); + expect_nothing_queued(s, b, living); +} + +// server.h: what create_group(), rename_group(), move_client() and set_player_format() refuse at +// once, without waiting for core: a client_id that is not one, a group not in groups(), a name +// past config_limits::MAX_NAME_BYTES, and every call while stopped +TEST(ServerGroups, RefusesCallsItCanJudgeAtOnce) { + GroupsServer s; + RawClient a; + s.join(a, "A"); + const std::string a_id = client_id_of(a); + const GroupId living = *s.server.default_group(); + const GroupId none{999}; + const std::string longest(sendspin::server::config_limits::MAX_NAME_BYTES, 'x'); + const std::string too_long = longest + "x"; + const StreamFormat flac{StreamCodec::FLAC, 48000, 2, 16}; + struct Row { + const char* name; + std::function call; + bool accepted; + }; + const std::vector rows = { + {"Control: create_group(), a name at the limit", + [&] { return s.server.create_group(longest, a_id).has_value(); }, true}, + {"create_group(), no client_id", + [&] { return s.server.create_group("Hall", "not a client id").has_value(); }, false}, + {"create_group(), a name too long", + [&] { return s.server.create_group(too_long, a_id).has_value(); }, false}, + {"Control: rename_group()", [&] { return s.server.rename_group(living, longest); }, true}, + {"rename_group(), no group", [&] { return s.server.rename_group(none, "Gone"); }, false}, + {"rename_group(), a name too long", [&] { return s.server.rename_group(living, too_long); }, + false}, + {"Control: move_client()", [&] { return s.server.move_client(a_id, living); }, true}, + {"move_client(), no group", [&] { return s.server.move_client(a_id, none); }, false}, + {"move_client(), no client_id", + [&] { return s.server.move_client("not a client id", living); }, false}, + {"Control: set_player_format()", [&] { return s.server.set_player_format(a_id, flac); }, + true}, + {"set_player_format(), no client_id", + [&] { return s.server.set_player_format("not a client id", flac); }, false}, + }; + for (const Row& row : rows) { + SCOPED_TRACE(row.name); + EXPECT_EQ(row.call(), row.accepted); + } + s.server.stop(); + for (const Row& row : rows) { + SCOPED_TRACE(std::string("stopped: ") + row.name); + EXPECT_FALSE(row.call()); + } +} + +// A client held with no roles (here one without unpaired access) stays in a group of its own: the +// host's create_group() makes no group for it and move_client() leaves it +TEST(ServerGroups, LeavesAClientHeldWithNoRolesInItsOwnGroup) { + GroupsServer s; + RawClient a; + RawClient held; + s.join(a, "A"); + connect(s.server, held, hello("Held", {"player@v1"}, false)); + EXPECT_EQ(payload(next(held)), R"({"activities":[],"active_roles":[]})"); + const std::string own = next(held, "group/update")["payload"]["group_id"].as(); + const std::string held_id = client_id_of(held); + s.recorder.wait_for("pairing required " + held_id); + + const std::optional refused = s.server.create_group("Refused", held_id); + ASSERT_TRUE(refused); + ASSERT_TRUE(s.server.move_client(held_id, *s.server.default_group())); + // Control: A, which may play, moves; core takes the calls in order, so A's move shows the two + // before it were taken + const std::optional taken = s.server.create_group("Taken", client_id_of(a)); + ASSERT_TRUE(taken); + EXPECT_EQ(next(a, "group/update")["payload"]["group_id"].as(), group_id(*taken)); + ASSERT_TRUE(s.wait_until(*taken, true)); + for (const GroupInfo& group : s.server.groups()) { + EXPECT_NE(group.id, *refused); + if (group_id(group.id) == own) { + EXPECT_EQ(group.client_ids, std::vector{held_id}); + } + } + expect_nothing_queued(s, held, GroupId{std::stoull(own)}); +} + +// controller/v1.md: a controller acts on its own group: its volume command sets its group's +// players only, a transport command reaches the host with its group, which offers it with that +// group's caps, and metadata set for a group reaches that group's members only (metadata/v1.md) +TEST(ServerGroups, ScopesControllerCommandsAndMetadataToTheirGroup) { + GroupsServer s; + const GroupId living = *s.server.default_group(); + RawClient a; + RawClient b; + s.join(a, "A"); + s.join(b, "B"); + const std::string b_id = client_id_of(b); + const GroupId kitchen = *s.server.create_group("Kitchen", b_id); + next(b, "group/update"); + next(b, "server/state"); + + a.send_json(controller_command(R"({"command":"volume","volume":80})")); + EXPECT_EQ(payload(next(a, "server/command")), R"({"player":{"command":"volume","volume":80}})"); + b.send_json(controller_command(R"({"command":"mute","mute":true})")); + EXPECT_EQ(payload(next(b, "server/command")), R"({"player":{"command":"mute","mute":true}})"); + expect_nothing_queued(s, a, living); + expect_nothing_queued(s, b, kitchen); + + ControllerCaps caps; + caps.commands = {TransportCommand::NEXT}; + ASSERT_TRUE(s.server.set_controller_caps(kitchen, caps)); + EXPECT_EQ(next(b, "server/state")["payload"]["controller"]["supported_commands"][0], "next"); + // Not offered in A's group: ignored, taken in before the time reply that follows it, while + // B's reaches the host after it, with the kitchen's id + a.send_json(controller_command(R"({"command":"next"})")); + a.send_json(sendspin::server::test::client_time_json(1)); + next(a, "server/time"); + b.send_json(controller_command(R"({"command":"next"})")); + const std::string next_command = std::to_string(static_cast(TransportCommand::NEXT)); + s.recorder.wait_for("command " + group_id(kitchen) + " " + next_command + " " + b_id); + EXPECT_EQ(s.recorder.count("command " + group_id(living) + " " + next_command + " " + + client_id_of(a)), + 0U); + + Metadata metadata; + metadata.title = "Kitchen's"; + ASSERT_TRUE(s.server.set_metadata(kitchen, metadata, s.server.now_us())); + EXPECT_EQ(next(b, "server/state")["payload"]["metadata"]["title"], "Kitchen's"); + expect_nothing_queued(s, a, living); +} + +// The host's move of a client that went away without a goodbye moves the place kept for it in its +// group, so it rejoins the group it was moved to, and the group it left, empty, goes; into a group +// that plays, it joins that group's playback; create_group() with it as the first member makes a +// group for its place +TEST(ServerGroups, MovesThePlaceKeptForADisconnectedClient) { + enum class Into : uint8_t { STOPPED, PLAYING, CREATED }; + struct Row { + const char* name; + Into into; + }; + const Row rows[] = { + {"Control: move_client() into a stopped group", Into::STOPPED}, + {"move_client() into a playing group", Into::PLAYING}, + {"create_group() for it", Into::CREATED}, + }; + for (const Row& row : rows) { + SCOPED_TRACE(row.name); + GroupsServer s; + RawClient a; + RawClient b; + s.join(a, "A"); + s.join(b, "B"); + const std::string a_id = client_id_of(a); + const GroupId kitchen = *s.server.create_group("Kitchen", client_id_of(b)); + next(b, "group/update"); + const GroupId solo = *s.server.create_group("Solo", a_id); + EXPECT_EQ(next(a, "group/update")["payload"]["group_id"].as(), group_id(solo)); + std::optional playing; + if (row.into == Into::PLAYING) { + playing.emplace(s, kitchen); + } + a.close(); + s.recorder.wait_for_disconnects(1); + GroupId joined = kitchen; + if (row.into == Into::CREATED) { + joined = *s.server.create_group("Created", a_id); + ASSERT_TRUE(s.wait_until(joined, true)); + } else { + ASSERT_TRUE(s.server.move_client(a_id, kitchen)); + } + EXPECT_FALSE(s.wait_until(solo, false)); + + connect(s.server, a, hello("A", {"player@v1", "controller@v1", "metadata@v1"}, true)); + EXPECT_EQ(type(next(a)), "server/activate"); + a.send_json(player_state(true, {}, std::nullopt, std::nullopt)); + const JsonDocument update = + row.into == Into::PLAYING ? next_join(a) : next_json(a, "group/update"); + EXPECT_EQ(update["payload"]["group_id"].as(), group_id(joined)); + if (row.into == Into::PLAYING) { + EXPECT_EQ(update["payload"]["playback_state"], "playing"); + } + } +} + +// messaging.md "External Source Handling": only a solo member that turns unavailable or leaves +// stops its group. The host moving the last member out of a playing group leaves it playing, with +// its input open, until the host releases the input, when the group goes. Control: the same +// member's client/leave stops the group and latches the input. +TEST(ServerGroups, KeepsPlayingAGroupItsLastMemberIsMovedOutOf) { + for (const bool leave : {false, true}) { + SCOPED_TRACE(leave ? "Control: client/leave" : "move_client()"); + GroupsServer s; + RawClient b; + s.join(b, "B"); + const std::string b_id = client_id_of(b); + const GroupId kitchen = *s.server.create_group("Kitchen", b_id); + next(b, "group/update"); + std::optional playing(std::in_place, s, kitchen); + if (leave) { + const std::string stopped = "group " + group_id(kitchen) + " stopped"; + const size_t stops = s.recorder.count(stopped); + b.send_json(R"({"type":"client/leave","payload":{}})"); + s.recorder.wait_for_count(stopped, stops + 1); + EXPECT_EQ(playing->input->state(), sendspin::server::InputState::STOP_LATCHED); + continue; + } + ASSERT_TRUE(s.server.move_client(b_id, *s.server.default_group())); + // The move and any stop it caused are published together, with B listed in its new group + for (;;) { + const std::vector groups = s.server.groups(); + const auto living = std::find_if(groups.begin(), groups.end(), [&](const GroupInfo& g) { + return g.id == *s.server.default_group(); + }); + if (living != groups.end() && !living->client_ids.empty()) { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + // Barrier: core answers the client/time after any handler the move posted, so a stop + // posted for later is published before groups() is sampled + b.send_json(sendspin::server::test::client_time_json(1)); + next_json(b, "server/time"); + const std::optional left = s.wait_until(kitchen, true); + ASSERT_TRUE(left); + EXPECT_TRUE(left->client_ids.empty()); + EXPECT_EQ(left->playback_state, sendspin::server::PlaybackState::PLAYING); + EXPECT_NE(playing->input->state(), sendspin::server::InputState::STOP_LATCHED); + playing.reset(); + EXPECT_FALSE(s.wait_until(kitchen, false)); + } +} + +// messaging.md "Server behavior when a client becomes unavailable" forbids the server rejoining an +// unavailable client to a group, not the host: moved into a playing group while its first +// client/state reports it unavailable, a client gets the group's group/update and no stream until +// it reports available. Control: one available on arrival starts at once. +TEST(ServerGroups, StartsAClientMovedWhileUnavailableOnceAvailable) { + for (const bool available : {true, false}) { + SCOPED_TRACE(available ? "Control: available" : "unavailable"); + GroupsServer s; + RawClient a; + RawClient b; + s.join(b, "B"); + const GroupId kitchen = *s.server.create_group("Kitchen", client_id_of(b)); + next(b, "group/update"); + Silence playing(s, kitchen); + connect(s.server, a, hello("A", {"player@v1", "controller@v1", "metadata@v1"}, true)); + EXPECT_EQ(type(next(a)), "server/activate"); + // The first client/state is an initial value, so A stays in the server's group + a.send_json(player_state(available, {}, std::nullopt, std::nullopt)); + next(a, "group/update"); + ASSERT_TRUE(s.server.move_client(client_id_of(a), kitchen)); + const JsonDocument update = available ? next_join(a) : next_json(a, "group/update"); + EXPECT_EQ(update["payload"]["group_id"].as(), group_id(kitchen)); + EXPECT_EQ(update["payload"]["playback_state"], "playing"); + if (!available) { + next(a, "server/state"); + expect_nothing_queued(s, a, kitchen); + a.send_json(player_state(true, {}, std::nullopt, std::nullopt)); + next_json(a, "stream/start"); + } + } +} + +// messaging.md "client/leave", controller/v1.md "Switch command cycle": a client that leaves its +// shared group is moved to a stopped group of its own; its switch then takes it back to the group +// it left first, and the next switch, with no group playing, to a new group of its own +TEST(ServerGroups, SwitchesBackToTheGroupItLeft) { + GroupsServer s; + const std::string living = group_id(*s.server.default_group()); + RawClient a; + RawClient b; + s.join(a, "A"); + s.join(b, "B"); + + b.send_json(R"({"type":"client/leave","payload":{}})"); + const JsonDocument left = next(b, "group/update"); + const std::string solo = left["payload"]["group_id"].as(); + EXPECT_NE(solo, living); + EXPECT_EQ(left["payload"]["playback_state"], "stopped"); + b.send_json(controller_command(R"({"command":"switch"})")); + EXPECT_EQ(next(b, "group/update")["payload"]["group_id"].as(), living); + b.send_json(controller_command(R"({"command":"switch"})")); + const std::string own = next(b, "group/update")["payload"]["group_id"].as(); + EXPECT_NE(own, living); + EXPECT_NE(own, solo); +} + +} // namespace diff --git a/tests/test_server_players.cpp b/tests/test_server_players.cpp index 69bbcf5..e42685c 100644 --- a/tests/test_server_players.cpp +++ b/tests/test_server_players.cpp @@ -17,19 +17,14 @@ /// writes to its audio output is the host's PCM, bit for bit, in order, whether it streams PCM or /// FLAC; and Opus, lossy, close to the PCM player's audio at the same timestamps. -#include "flac_reader.h" #include "opus_reader.h" -#include "peer.h" #include "player_sink.h" +#include "players_rig.h" #include "resampler_reference.h" #include "sched/constants.h" -#include "test_ports.h" #include "test_recorder.h" -#include "wire_messages.h" -#include #include -#include #include #include @@ -38,18 +33,15 @@ #include #include #include -#include +#include #include #include #include -#include #include #include #include #include -#include #include -#include #include #include #include @@ -62,602 +54,33 @@ using sendspin::server::DrainOutcome; using sendspin::server::GroupId; using sendspin::server::GroupInfo; using sendspin::server::GroupInput; -using sendspin::server::InputFormat; -using sendspin::server::InputOptions; using sendspin::server::InputState; using sendspin::server::SampleFormat; using sendspin::server::SendspinServer; -using sendspin::server::ServerConfig; -using sendspin::server::StartResult; using sendspin::server::StreamCodec; using sendspin::server::StreamFormat; -using sendspin::server::TraceDirection; -using sendspin::server::TracedMessage; using sendspin::server::WriteResult; -using sendspin::server::test::AlwaysReady; -using sendspin::server::test::client_clock_us; -using sendspin::server::test::FlacReader; -using sendspin::server::test::MemoryProvider; +using sendspin::server::test::CELL; +using sendspin::server::test::CELL_US; +using sendspin::server::test::ChunkInfo; +using sendspin::server::test::decode; +using sendspin::server::test::expect_heard; +using sendspin::server::test::expect_one_anchor_per_segment; +using sendspin::server::test::expect_played; +using sendspin::server::test::expect_sent_whole; +using sendspin::server::test::feed; +using sendspin::server::test::frame_us; +using sendspin::server::test::Player; +using sendspin::server::test::RATE; using sendspin::server::test::Recorder; using sendspin::server::test::ReferenceResampler; +using sendspin::server::test::Rig; +using sendspin::server::test::run_until; +using sendspin::server::test::Segment; using sendspin::server::test::Signal; -using sendspin::server::test::Sink; +using sendspin::server::test::START_FLOOR_US; using Bytes = std::vector; using Codec = sendspin::SendspinCodecFormat; -namespace wire = sendspin::server::test::wire; - -constexpr uint32_t RATE = 48000; -constexpr uint32_t CELL = RATE / 50; -constexpr int64_t CELL_US = 20000; - -/// A frame's duration at rate: the cells that hold a whole number of samples -int64_t frame_us(uint32_t rate) { - return int64_t{50 / std::gcd(rate, 50U)} * CELL_US; -} - -/// A stretch of the signal the host writes: positions first to first + count - 1 -struct Segment { - uint32_t first{0}; - uint32_t count{0}; - - bool holds(uint32_t position) const { - return position >= this->first && position - this->first < this->count; - } -}; - -/// Writes segment, waiting for room, until seek is set -/// @param fed set to the frames written so far -void feed(GroupInput& input, const Signal& signal, const Segment& segment, - const std::atomic* seek = nullptr, uint32_t* fed = nullptr) { - constexpr uint32_t BLOCK = 960; - Bytes block(BLOCK * signal.frame_bytes()); - uint32_t written = 0; - while (written < segment.count) { - if (seek != nullptr && seek->load()) { - return; - } - const uint32_t n = std::min(BLOCK, segment.count - written); - for (uint32_t i = 0; i < n; ++i) { - signal.put(&block[i * signal.frame_bytes()], segment.first + written + i); - } - const WriteResult result = input.write(block.data(), n); - written += static_cast(result.frames); - if (fed != nullptr) { - *fed = written; - } - if (result.frames < n) { - ASSERT_EQ(result.state, InputState::FULL); - // Short waits, so a seek is seen while the input is full - input.wait_writable(seek != nullptr ? 20000 : std::numeric_limits::max()); - } - } -} - -/// A sendspin-cpp client with a player role listing formats, listening for the server to dial; -/// its output plays frames of frame_bytes at rate. It asks for 150 ms of buffer unless told -/// otherwise. -class Player { -public: - /// @param min_buffer_ms, required_lead_ms what its client/state reports - /// @param buffer_bytes its audio buffer, of which it advertises two thirds as buffer_capacity - Player(const std::vector& formats, size_t frame_bytes, - uint32_t rate, uint16_t min_buffer_ms = 150, - std::optional required_lead_ms = std::nullopt, - size_t buffer_bytes = sendspin::PlayerRoleConfig::DEFAULT_AUDIO_BUFFER_CAPACITY) - : port_(sendspin::server::test::free_port()), - client_(sendspin::server::test::peer_config("Player " + std::to_string(this->port_), - this->port_)) { - sendspin::PlayerRoleConfig config; - config.audio_formats = formats; - config.min_buffer_ms = min_buffer_ms; - config.required_lead_time_ms = required_lead_ms; - config.audio_buffer_capacity = buffer_bytes; - sendspin::PlayerRole& role = this->client_.add_player(config); - this->sink_ = std::make_unique(role, frame_bytes, rate); - role.set_listener(this->sink_.get()); - this->client_.set_network_provider(&this->network_); - this->client_.set_unpaired_access_enabled(true); - EXPECT_TRUE(this->client_.start()); - } - - ~Player() { - this->client_.stop(); - } - - Player(const Player&) = delete; - Player& operator=(const Player&) = delete; - - std::string url() const { - return sendspin::server::test::peer_url(this->port_); - } - - sendspin::SendspinClient& client() { - return this->client_; - } - - const Sink& sink() const { - return *this->sink_; - } - -private: - uint16_t port_; - AlwaysReady network_; - sendspin::SendspinClient client_; - std::unique_ptr sink_; -}; - -/// Runs every player's main loop on this thread until done() holds, with no timeout -template -void run_until(std::vector>& players, Predicate done) { - sendspin::server::test::run_all_until(players, done); -} - -// ============================================================================== -// What the server sent each player, from its trace -// ============================================================================== - -/// One chunk: its timestamp and the stamp it was sent with, its samples per channel and their -/// rate, its first position when its stream carries the signal as written, whether it is FLAC and -/// then its first frame's number and its frame count, how many stream/start and stream/clear -/// messages its client had been sent before it, and the stamp of the last of those -struct ChunkInfo { - int64_t ts{0}; - int64_t stamp_us{0}; - uint32_t position{0}; - bool flac{false}; - uint64_t number{0}; - size_t frames{1}; - size_t stream{0}; - size_t clears{0}; - int64_t trigger_us{0}; - uint32_t samples{0}; - uint32_t rate{0}; - Bytes pcm; -}; - -/// Decodes each chunk the server sends, PCM or FLAC, through the codec_header of its stream/start. -/// A stream at the input's rate with two channels carries the signal, so its chunks decode to -/// positions, unless the input changes format; another stream's chunks keep only their samples. -class StreamTrace { -public: - /// @param positions whether a stream at the input's rate and channel count carries the signal - StreamTrace(const Signal& signal, uint32_t input_rate, Recorder& recorder, - bool positions = true) - : signal_(signal), input_rate_(input_rate), recorder_(recorder), positions_(positions) {} - - /// On the dispatch thread - void on_message(const TracedMessage& message) { - const bool chunk = message.direction == TraceDirection::OUTBOUND && message.binary && - message.plaintext.size() > wire::AUDIO_CHUNK_HEADER && - message.plaintext[0] == wire::AUDIO_CHUNK_ID; - if (!chunk) { - this->recorder_.trace(message); - } - if (message.direction == TraceDirection::INBOUND) { - // A JSON message after the handshake is binary, behind its message ID 0 - const size_t offset = message.binary ? 1 : 0; - JsonDocument doc; - if (message.plaintext.size() > offset && - (!message.binary || message.plaintext.front() == wire::JSON_ID) && - deserializeJson(doc, message.plaintext.data() + offset, - message.plaintext.size() - offset) == DeserializationError::Ok && - doc["type"] == "client/state") { - const std::lock_guard lock(this->mutex); - this->states.insert(message.client_id); - } - return; - } - if (message.direction == TraceDirection::INBOUND || !message.binary || - message.plaintext.empty()) { - return; - } - const std::lock_guard lock(this->mutex); - if (message.plaintext[0] == wire::JSON_ID) { - JsonDocument doc; - if (deserializeJson(doc, message.plaintext.data() + 1, message.plaintext.size() - 1) != - DeserializationError::Ok) { - return; - } - if (doc["type"] == "stream/clear") { - ++this->clears_[message.client_id]; - this->triggers_[message.client_id] = message.stamp_us; - } - // The server lists the roles a stream/end ends - if (doc["type"] == "stream/end") { - for (const JsonVariantConst role : doc["payload"]["roles"].as()) { - if (role == "player") { - this->active_.erase(message.client_id); - } - } - } - if (doc["type"] == "stream/start" && !doc["payload"]["player"].isNull()) { - const std::string codec = doc["payload"]["player"]["codec"].as(); - this->codecs[message.client_id] = codec; - // A start while the stream is active announces a format in place: the stream goes - // on, with no trigger - if (this->active_.insert(message.client_id).second) { - ++this->streams[message.client_id]; - this->triggers_[message.client_id] = message.stamp_us; - } - const JsonObjectConst player = doc["payload"]["player"]; - this->rates_[message.client_id] = player["sample_rate"].as(); - this->frame_bytes_[message.client_id] = - player["channels"].as() * player["bit_depth"].as() / 8; - this->as_written_[message.client_id] = - this->positions_ && - doc["payload"]["player"]["sample_rate"].as() == this->input_rate_ && - doc["payload"]["player"]["channels"].as() == 2; - this->readers_.erase(message.client_id); - if (codec == "flac" && - !this->readers_[message.client_id].open( - doc["payload"]["player"]["codec_header"].as())) { - this->failed = true; - } - } - return; - } - if (!chunk) { - return; - } - ChunkInfo info; - info.stamp_us = message.stamp_us; - info.stream = this->streams[message.client_id]; - info.clears = this->clears_[message.client_id]; - info.trigger_us = this->triggers_[message.client_id]; - info.rate = this->rates_[message.client_id]; - info.ts = wire::timestamp_at(message.plaintext); - const std::span payload = - wire::payload_after(message.plaintext, wire::AUDIO_CHUNK_HEADER); - const auto reader = this->readers_.find(message.client_id); - if (reader == this->readers_.end()) { - info.pcm.assign(payload.begin(), payload.end()); - } else { - const std::optional> frames = - reader->second.decode(payload); - if (!frames || frames->empty()) { - this->failed = true; - return; - } - info.flac = true; - info.number = frames->front().number; - info.frames = frames->size(); - for (const sendspin::server::test::DecodedFlacFrame& frame : *frames) { - info.pcm.insert(info.pcm.end(), frame.pcm.begin(), frame.pcm.end()); - } - } - if (this->as_written_[message.client_id]) { - const std::optional position = this->signal_.get(info.pcm.data()); - if (!position) { - this->failed = true; - return; - } - info.position = *position; - } - info.samples = - static_cast(info.pcm.size() / this->frame_bytes_[message.client_id]); - this->chunks[message.client_id].push_back(std::move(info)); - } - - std::mutex mutex; - /// The codec of each client's last stream/start - std::map codecs; - /// The stream/start messages each client was sent - std::map streams; - /// The clients whose client/state came in - std::set states; - std::map> chunks; - /// A codec_header or a chunk did not decode to the signal - bool failed{false}; - -private: - const Signal& signal_; - uint32_t input_rate_; - Recorder& recorder_; - bool positions_; - std::map readers_; - /// Whether each client's stream carries the signal as written - std::map as_written_; - /// The clients with a player stream, between its stream/start and stream/end - std::set active_; - /// Each client's stream/clear messages, the stamp of its last stream/start or stream/clear, - /// and its stream's rate and bytes per frame of decoded samples - std::map clears_; - std::map triggers_; - std::map rates_; - std::map frame_bytes_; -}; - -// ============================================================================== -// What a player played -// ============================================================================== - -/// One frame of a player's output -struct Output { - enum class Kind : uint8_t { POSITION, ALTERED, SILENT }; - Kind kind{Kind::POSITION}; - uint32_t position{0}; -}; - -/// A player's output with the silence before and after the stream left out -std::vector decode(const Signal& signal, const Bytes& output) { - const size_t size = signal.frame_bytes(); - size_t begin = 0; - size_t end = output.size() - (output.size() % size); - const auto silent = [&](size_t offset) { - return std::all_of(output.begin() + static_cast(offset), - output.begin() + static_cast(offset + size), - [](uint8_t byte) { return byte == 0; }); - }; - while (begin < end && silent(begin)) { - begin += size; - } - while (end > begin && silent(end - size)) { - end -= size; - } - std::vector frames; - for (size_t offset = begin; offset < end; offset += size) { - if (silent(offset)) { - frames.push_back({Output::Kind::SILENT, 0}); - } else if (const std::optional position = signal.get(&output[offset])) { - frames.push_back({Output::Kind::POSITION, *position}); - } else { - frames.push_back({Output::Kind::ALTERED, 0}); - } - } - return frames; -} - -/// The players' startup floor, max(min_buffer_ms + output_delay_ms, J_min): each player asks for -/// 150 ms and no output delay -constexpr int64_t START_FLOOR_US = 150000; -/// The latest a stream's first chunk may be after its trigger: the group lead (160 ms for these -/// players) when a group trigger finds the epoch's first frame, else the floor and one 40 ms -/// frame, with room left for a frame that the pump, run late on a loaded machine, finds late -constexpr int64_t START_CEILING_US = 500000; - -/// What the server sent one client: the first chunk after each stream/start or stream/clear between -/// START_FLOOR_US and START_CEILING_US after that trigger, and every other one right where the -/// chunk before it ended, so no audio is skipped, repeated or reordered. The timeline has no shift -/// in these tests; a gap only where its last frame had passed by the next chunk's stamp. How far -/// ahead of its time a chunk left is when the pump ran, which a loaded machine delays, so it is not -/// checked here: PlayerStreamPacking checks the hold a player's min_buffer_ms sets, and SchedCursor -/// the skip of a frame already late. That the pump wakes for a held chunk at its room_time -/// (Session::take_message() through next_wake(), which is unit-tested) is covered end to end by -/// nothing. -void expect_sent_whole(const std::vector& chunks) { - for (size_t i = 0; i < chunks.size(); ++i) { - const ChunkInfo& chunk = chunks[i]; - SCOPED_TRACE(chunk.ts); - const bool first = - i == 0 || chunks[i - 1].stream != chunk.stream || chunks[i - 1].clears != chunk.clears; - // The pump skips a frame already late when it runs (sched::prepare_cursor()), as when core - // stalls on a loaded machine, so audio may be missing only where it was late - if (first) { - EXPECT_GE(chunk.ts - chunk.trigger_us, START_FLOOR_US); - if (chunk.ts - frame_us(chunk.rate) > chunk.stamp_us) { - EXPECT_LE(chunk.ts - chunk.trigger_us, START_CEILING_US); - } - continue; - } - const ChunkInfo& before = chunks[i - 1]; - const int64_t duration_us = int64_t{before.samples} * 1000000; - ASSERT_EQ(duration_us % before.rate, 0) << "a chunk of part of a cell before another"; - const int64_t expected = before.ts + (duration_us / before.rate); - if (chunk.ts > expected && chunk.ts - frame_us(before.rate) <= chunk.stamp_us) { - continue; - } - EXPECT_EQ(chunk.ts, expected) << "a gap in the chunks"; - } -} - -/// Checks a player's output and the chunks the server sent it, for segments of the signal written -/// in their order (a segment after the first follows a seek or a resume): each chunk on a frame -/// boundary of its segment. The output holds some of every segment, in order, never going back to -/// an earlier one, each frame of the signal bit for bit, its positions rising within a segment, -/// but for one blended frame at a time. -/// -/// Where the output enters a segment, and what it skips or fills with silence within one, depends -/// on timing alone. The server's trigger may go out after the slack its group lead leaves, so the -/// stream starts at a later cell, in sync; a live rebase's gap plays as silence. The sync task -/// blends a frame in or out at chunk boundaries (SyncTask::soft_sync_insert_frame(), -/// soft_sync_drop_frame()), and when it starts, re-aligns after a seek, or loses sync because its -/// threads ran late, it drops the late prefix of a chunk or fills with silence -/// (SyncTask::handle_synchronize_audio()). The startup lead is checked from the stamps with raw -/// players. How much of the end it plays depends on its threads keeping real time: one behind its -/// clock at the stream/end drops what it has not played. So does how much of the end the server -/// sends, since a frame late when the pump runs is skipped; that a stream runs through its last -/// frame is checked with the clock passed in (PlayerStreamSwitch). -/// @param rate the signal's sample rate -void expect_heard(const std::vector& frames, const std::vector& chunks, - const std::vector& segments, uint32_t rate = RATE) { - const auto segment_of = [&](uint32_t position) -> size_t { - const auto it = std::find_if(segments.begin(), segments.end(), [&](const Segment& segment) { - return segment.holds(position); - }); - return static_cast(it - segments.begin()); - }; - // A frame's samples: the whole number the fewest cells hold at the rate - const uint32_t frame = rate / std::gcd(rate, 50U); - for (const ChunkInfo& chunk : chunks) { - const size_t segment = segment_of(chunk.position); - ASSERT_LT(segment, segments.size()) << chunk.position; - EXPECT_EQ((chunk.position - segments[segment].first) % frame, 0U) << chunk.position; - } - std::vector heard(segments.size(), 0); - std::optional previous; - size_t altered = 0; - for (const Output& frame : frames) { - if (frame.kind == Output::Kind::ALTERED) { - ++altered; - continue; - } - if (frame.kind == Output::Kind::SILENT) { - continue; - } - // Not a SCOPED_TRACE: a second of output is 48,000 frames - const uint32_t position = frame.position; - const size_t segment = segment_of(position); - ASSERT_LT(segment, segments.size()) << "a frame of no segment: " << position; - if (!previous) { - EXPECT_EQ(segment, 0U) << position; - } else if (segment != segment_of(*previous)) { - ASSERT_GT(segment, segment_of(*previous)) << "back to an earlier segment: " << position; - } else { - ASSERT_GT(position, *previous); - } - EXPECT_LE(altered, 1U) << position; - previous = position; - altered = 0; - ++heard[segment]; - } - // A drop in the last chunk leaves its blended frame last - EXPECT_LE(altered, 1U); - for (size_t i = 0; i < segments.size(); ++i) { - EXPECT_GT(heard[i], 0U) << "segment " << i << " not heard"; - } -} - -/// expect_heard(), with the server having sent each segment whole from where its stream entered -/// it (expect_sent_whole()) -void expect_played(const std::vector& frames, const std::vector& chunks, - const std::vector& segments, uint32_t rate = RATE) { - expect_sent_whole(chunks); - expect_heard(frames, chunks, segments, rate); -} - -/// The server, its trace and its players -class Rig { -public: - /// @param input_rate the rate of the input open() opens - /// @param positions whether chunks at the input's rate and channel count decode to positions - /// @param opus ServerConfig::enable_opus - explicit Rig(const Signal& signal, uint32_t input_rate = RATE, bool positions = true, - bool opus = false) - : signal(signal), - input_rate(input_rate), - trace(signal, input_rate, this->recorder, positions) { - ServerConfig config = sendspin::server::test::local_config(); - config.name = "Players"; - config.approve_all_clients = true; - config.enable_opus = opus; - config.trace = [this](const TracedMessage& message) { this->trace.on_message(message); }; - EXPECT_EQ(this->server.start(config, &this->provider, &this->recorder), StartResult::OK); - } - - /// Adds a player, which the server dials, and runs every player until the server has its - /// client/state - /// @param rate, channels its output's, which its stream must have - /// @param min_buffer_ms, required_lead_ms what its client/state reports - /// @param buffer_bytes its audio buffer - Player& add(const std::vector& formats, - uint32_t rate = RATE, uint32_t channels = 2, uint16_t min_buffer_ms = 150, - std::optional required_lead_ms = std::nullopt, - size_t buffer_bytes = sendspin::PlayerRoleConfig::DEFAULT_AUDIO_BUFFER_CAPACITY) { - this->players.push_back( - std::make_unique(formats, this->signal.bits() / 8 * channels, rate, - min_buffer_ms, required_lead_ms, buffer_bytes)); - const std::string url = this->players.back()->url(); - EXPECT_TRUE(this->server.connect_url(url)); - // Its own client/state, not one another player sends meanwhile - run_until(this->players, [&] { - const std::optional id = this->find_client_id(url); - const std::lock_guard lock(this->trace.mutex); - return id && this->trace.states.contains(*id); - }); - return *this->players.back(); - } - - /// Opens an input to group, the server's group by default - std::shared_ptr open(std::optional group = std::nullopt) { - InputOptions options; - options.format = InputFormat{this->input_rate, 2, this->signal.sample_format()}; - return this->server.open_input(group.value_or(*this->server.default_group()), options) - .input; - } - - /// Opens a live input to the server's group, with the default write jitter - std::shared_ptr open_live() { - InputOptions options; - options.format = InputFormat{this->input_rate, 2, this->signal.sample_format()}; - options.kind = sendspin::server::InputKind::LIVE; - // NOLINTNEXTLINE(bugprone-unchecked-optional-access): set while the server runs - return this->server.open_input(*this->server.default_group(), options).input; - } - - std::optional find_client_id(const std::string& url) const { - for (const sendspin::server::ClientInfo& client : this->server.clients()) { - if (client.url == url) { - return client.client_id; - } - } - return std::nullopt; - } - - std::string client_id(const Player& player) const { - const std::optional id = this->find_client_id(player.url()); - if (!id) { - ADD_FAILURE() << "no client at " << player.url(); - } - return id.value_or(std::string{}); - } - - /// Runs every player until the host heard each of drained and every player played out its - /// stream: a drain completes by the server's clock, which can be before a player's output - /// plays its last frames. - void run_until_drained(std::initializer_list drained) { - run_until(this->players, [&] { - return std::all_of( - drained.begin(), drained.end(), - [&](const std::string& event) { return this->recorder.contains(event); }) && - std::all_of(this->players.begin(), this->players.end(), - [](const auto& player) { return player->sink().played_out(); }); - }); - } - - /// Whether the server has sent player a chunk - bool has_chunks(const Player& player) { - const std::string id = this->client_id(player); - const std::lock_guard lock(this->trace.mutex); - const auto it = this->trace.chunks.find(id); - return it != this->trace.chunks.end() && !it->second.empty(); - } - - const Signal& signal; - uint32_t input_rate; - MemoryProvider provider; - Recorder recorder; - StreamTrace trace; - SendspinServer server; - std::vector> players; -}; - -/// Within each segment, every chunk's timestamp is one anchor plus its position's offset in the -/// segment, exactly, the same anchor for every player -/// @return the anchor of each segment with a chunk, by its index -std::map expect_one_anchor_per_segment( - const std::map>& chunks, - const std::vector& segments, uint32_t rate = RATE) { - std::map anchors; - for (const auto& [client, list] : chunks) { - SCOPED_TRACE(client); - for (const ChunkInfo& chunk : list) { - const auto segment = - std::find_if(segments.begin(), segments.end(), - [&](const Segment& s) { return s.holds(chunk.position); }); - EXPECT_NE(segment, segments.end()); - if (segment == segments.end()) { - continue; - } - const int64_t offset_us = int64_t{chunk.position - segment->first} * 1000000; - EXPECT_EQ(offset_us % rate, 0) << chunk.position; - const int64_t anchor = chunk.ts - (offset_us / rate); - const auto index = static_cast(segment - segments.begin()); - const auto [it, added] = anchors.emplace(index, anchor); - EXPECT_EQ(it->second, anchor) << chunk.position; - } - } - return anchors; -} // ============================================================================== // Players of each format in one group diff --git a/tests/test_server_raw_client.cpp b/tests/test_server_raw_client.cpp index 31fcecc..8732fc3 100644 --- a/tests/test_server_raw_client.cpp +++ b/tests/test_server_raw_client.cpp @@ -15,14 +15,17 @@ /// @file test_server_raw_client.cpp /// @brief SendspinServer end to end against the raw test client: the handshake in both suites, /// activation and time sync, and what the host is told when a client breaks the rules, says -/// goodbye or goes away at each step, including the handshake backoff. +/// goodbye or goes away at each step, including the goodbye's reason and the handshake backoff; +/// and the trace of completed writes (ServerConfig::trace_writes). #include "fragmentation.h" #include "messages.h" #include "raw_client.h" +#include "raw_roles.h" #include "session.h" #include "test_recorder.h" #include "transport.h" +#include "wire_messages.h" #include #include #include @@ -34,8 +37,12 @@ #include +#include +#include +#include #include #include +#include #include #include #include @@ -44,6 +51,7 @@ namespace { using sendspin::server::CipherSuite; +using sendspin::server::ClientDisconnect; using sendspin::server::DisconnectReason; using sendspin::server::ErrorKind; using sendspin::server::GoodbyeReason; @@ -54,12 +62,17 @@ using sendspin::server::test::Bytes; using sendspin::server::test::bytes_of; using sendspin::server::test::client_goodbye_json; using sendspin::server::test::client_hello_json; +using sendspin::server::test::client_id_of; using sendspin::server::test::client_time_json; +using sendspin::server::test::connect; +using sendspin::server::test::hello; using sendspin::server::test::LocalServer; +using sendspin::server::test::next; using sendspin::server::test::NoiseResponder; using sendspin::server::test::RawClient; using sendspin::server::test::ReceivedMessage; using sendspin::server::test::text_of; +namespace wire = sendspin::server::test::wire; ServerConfig test_config() { ServerConfig config = sendspin::server::test::local_config(); @@ -533,6 +546,35 @@ TEST(ServerRawClient, ReportsAGoodbyeFollowedByAnImmediateClose) { } } +// messaging.md "client/goodbye": the reason reaches the host with the disconnect; one this +// server does not recognize is reported as none. Control: a drop without a goodbye. +TEST(ServerDisconnect, ReportsTheGoodbyeReason) { + LocalServer s; + ASSERT_EQ(s.start(), StartResult::OK); + RawClient client; + const std::string id = client_id_of(client); + const std::string reasons[] = {"another_server", "moving", ""}; + for (size_t i = 0; i < std::size(reasons); ++i) { + SCOPED_TRACE(reasons[i]); + ASSERT_NO_FATAL_FAILURE(connect(s.server, client, hello("Leaving", {}, true))); + next(client, "group/update"); + if (reasons[i].empty()) { + client.drop(); + } else { + client.send_json(sendspin::server::test::client_goodbye_json(reasons[i])); + } + s.recorder.wait_for_disconnects(i + 1); + } + const std::vector disconnects = s.recorder.disconnects(); + ASSERT_EQ(disconnects.size(), 3U); + EXPECT_EQ(disconnects[0].reason, DisconnectReason::GOODBYE); + EXPECT_EQ(disconnects[0].goodbye_reason, GoodbyeReason::ANOTHER_SERVER); + EXPECT_EQ(disconnects[1].reason, DisconnectReason::GOODBYE); + EXPECT_FALSE(disconnects[1].goodbye_reason); + EXPECT_EQ(disconnects[2].reason, DisconnectReason::CONNECTION_LOST); + EXPECT_FALSE(disconnects[2].goodbye_reason); +} + // ============================================================================== // A client that breaks the rules once active // ============================================================================== @@ -662,4 +704,91 @@ TEST(ServerRawClient, DefersTheNextDialAfterAFailedHandshake) { } } +// ============================================================================== +// Message trace (ServerConfig::trace_writes) +// ============================================================================== + +// ServerConfig::trace_writes: each completed write is traced after the messages it carried, and +// a server/time's comes before a client/time the client sent once it had read that reply, which is +// what lets a host place what it received against what was on the wire; without the option the +// trace has no such entry +TEST(ServerTrace, TracesCompletedWritesOnlyWhenAsked) { + struct Seen { + bool written; + bool inbound; + std::string type; + bool empty; + }; + for (const bool trace_writes : {false, true}) { + SCOPED_TRACE(trace_writes ? "trace_writes" : "Control: trace_writes off"); + std::mutex mutex; + std::condition_variable changed; + std::vector seen; + LocalServer s; + ServerConfig config = sendspin::server::test::local_config(); + config.approve_all_clients = true; + config.trace_writes = trace_writes; + config.trace = [&](const sendspin::server::TracedMessage& message) { + std::string message_type; + if (!message.plaintext.empty() && + (!message.binary || message.plaintext.front() == wire::JSON_ID)) { + const size_t offset = message.binary ? 1 : 0; + JsonDocument doc; + if (deserializeJson(doc, message.plaintext.data() + offset, + message.plaintext.size() - offset) == + DeserializationError::Ok) { + message_type = doc["type"].as(); + } + } + { + const std::lock_guard lock(mutex); + seen.push_back({message.written, + message.direction == sendspin::server::TraceDirection::INBOUND, + message_type, message.plaintext.empty()}); + } + changed.notify_all(); + }; + ASSERT_EQ(s.start(config), StartResult::OK); + RawClient client; + ASSERT_NO_FATAL_FAILURE(connect(s.server, client, hello("Timer", {"metadata@v1"}, true))); + next(client, "group/update"); + client.send_json(sendspin::server::test::client_time_json(1)); + next(client, "server/time"); + client.send_json(sendspin::server::test::client_time_json(2)); + + // Waits with no timeout for the second request; a regression hangs into the suite watchdog + std::unique_lock lock(mutex); + changed.wait(lock, [&] { + return std::count_if(seen.begin(), seen.end(), [](const Seen& entry) { + return entry.inbound && entry.type == "client/time"; + }) == 2; + }); + const auto second_request = + std::find_if( + seen.rbegin(), seen.rend(), + [](const Seen& entry) { return entry.inbound && entry.type == "client/time"; }) + .base() - + 1; + const auto reply = std::find_if(seen.begin(), seen.end(), [](const Seen& entry) { + return !entry.inbound && entry.type == "server/time"; + }); + ASSERT_LT(reply, second_request); + const auto reply_written = + std::find_if(reply + 1, seen.end(), [](const Seen& entry) { return entry.written; }); + const size_t writes = static_cast(std::count_if( + seen.begin(), seen.end(), [](const Seen& entry) { return entry.written; })); + if (!trace_writes) { + EXPECT_EQ(writes, 0U); + continue; + } + // A time reply ends its write: no outbound message is taken between the reply and its + // write + ASSERT_NE(reply_written, seen.end()); + EXPECT_TRUE(std::none_of(reply + 1, reply_written, + [](const Seen& entry) { return !entry.inbound; })); + EXPECT_LT(reply_written, second_request); + EXPECT_FALSE(reply_written->inbound); + EXPECT_TRUE(reply_written->empty); + } +} } // namespace diff --git a/tests/test_server_roles.cpp b/tests/test_server_roles.cpp index 2c096d9..f8ada0b 100644 --- a/tests/test_server_roles.cpp +++ b/tests/test_server_roles.cpp @@ -13,15 +13,14 @@ // limitations under the License. /// @file test_server_roles.cpp -/// @brief Approval, the controller and metadata roles, availability and disconnects end to end: -/// the raw client checks the exact messages, and in-process sendspin-cpp clients show what a -/// client library makes of them. +/// @brief Approval and the controller and metadata roles end to end: the raw client checks the +/// exact messages, and in-process sendspin-cpp clients show what a client library makes of them. #include "peer.h" #include "raw_client.h" +#include "raw_roles.h" #include "test_ports.h" #include "test_recorder.h" -#include "wire_messages.h" #include #include @@ -32,17 +31,13 @@ #include #include -#include #include -#include +#include #include -#include #include #include -#include #include #include -#include #include #include #include @@ -50,138 +45,33 @@ namespace { using sendspin::server::ApprovalScope; -using sendspin::server::ClientDisconnect; using sendspin::server::CommandArgs; using sendspin::server::ControllerCaps; using sendspin::server::DisconnectReason; using sendspin::server::ErrorKind; -using sendspin::server::GoodbyeReason; using sendspin::server::GroupId; -using sendspin::server::GroupInfo; using sendspin::server::Metadata; using sendspin::server::MetadataProgress; using sendspin::server::RepeatMode; using sendspin::server::SendspinServer; using sendspin::server::ServerConfig; using sendspin::server::StartResult; -using sendspin::server::StreamCodec; -using sendspin::server::StreamFormat; using sendspin::server::TransportCommand; using sendspin::server::test::AlwaysReady; using sendspin::server::test::client_at; +using sendspin::server::test::client_id_of; +using sendspin::server::test::connect; +using sendspin::server::test::controller_command; +using sendspin::server::test::group_id; +using sendspin::server::test::hello; using sendspin::server::test::LocalServer; +using sendspin::server::test::next; +using sendspin::server::test::payload; +using sendspin::server::test::player_state; using sendspin::server::test::RawClient; using sendspin::server::test::Recorder; +using sendspin::server::test::type; namespace persistence_keys = sendspin::server::persistence_keys; -namespace wire = sendspin::server::test::wire; - -// ============================================================================== -// Raw client messages -// ============================================================================== - -/// A client/hello listing roles, with the player's support object when it lists player@v1 -std::string hello(const std::string& name, const std::vector& roles, bool unpaired) { - JsonDocument doc; - doc["type"] = "client/hello"; - JsonObject payload = doc["payload"].to(); - payload["name"] = name; - JsonArray listed = payload["supported_roles"].to(); - for (const std::string& role : roles) { - listed.add(role); - } - if (std::find(roles.begin(), roles.end(), "player@v1") != roles.end()) { - JsonObject format = payload["player@v1_support"]["supported_formats"].add(); - format["codec"] = "pcm"; - format["sample_rate"] = 48000; - format["channels"] = 2; - format["bit_depth"] = 16; - payload["player@v1_support"]["buffer_capacity"] = 200000; - } - payload["supported_pair_methods"]["pairing_psk"].to(); - payload["unpaired_access"]["enabled"] = unpaired; - std::string out; - serializeJson(doc, out); - return out; -} - -/// A client/state with a player object offering commands, reporting volume and muted when set -std::string player_state(bool available, const std::vector& commands, - std::optional volume, std::optional muted) { - JsonDocument doc; - doc["type"] = "client/state"; - doc["payload"]["available"] = available; - JsonObject player = doc["payload"]["player"].to(); - player["output_delay_ms"] = 0; - player["required_lead_time_ms"] = 0; - player["min_buffer_ms"] = 0; - JsonArray offered = player["supported_commands"].to(); - for (const std::string& command : commands) { - offered.add(command); - } - if (volume) { - player["volume"] = *volume; - } - if (muted) { - player["muted"] = *muted; - } - std::string out; - serializeJson(doc, out); - return out; -} - -std::string controller_command(const std::string& object) { - return R"({"type":"client/command","payload":{"controller":)" + object + "}}"; -} - -/// The next JSON message, parsed -JsonDocument next(RawClient& client) { - JsonDocument doc; - const std::string json = client.receive_json(); - EXPECT_FALSE(json.empty()) << "the connection closed"; - deserializeJson(doc, json); - return doc; -} - -/// The next JSON message of type, skipping others -JsonDocument next(RawClient& client, std::string_view type) { - for (;;) { - const std::string json = client.receive_json(); - if (json.empty()) { - ADD_FAILURE() << "the connection closed while waiting for " << type; - return JsonDocument(); - } - JsonDocument doc; - deserializeJson(doc, json); - if (doc["type"].as() == type) { - return doc; - } - } -} - -std::string payload(const JsonDocument& doc) { - std::string out; - serializeJson(doc["payload"], out); - return out; -} - -std::string type(const JsonDocument& doc) { - return doc["type"].as(); -} - -/// Dials client and runs the handshake through client/hello -void connect(SendspinServer& server, RawClient& client, const std::string& hello_json) { - ASSERT_TRUE(server.connect_url(client.url())); - ASSERT_NO_FATAL_FAILURE(client.handshake()); - client.send_json(hello_json); -} - -std::string client_id_of(const RawClient& client) { - return client.noise.identity().peer_id(); -} - -std::string group_id(const GroupId& id) { - return std::to_string(id.value); -} constexpr const char* ALL_ROLES = R"(["player@v1","controller@v1","metadata@v1"])"; @@ -554,576 +444,6 @@ TEST(ServerMetadata, JoinsWithCurrentThenScheduledAndFollowsPauseAndResume) { EXPECT_GT(resumed["timestamp"].as(), paused_at); } -// ============================================================================== -// Availability and disconnects -// ============================================================================== - -// messaging.md "client/goodbye": the reason reaches the host with the disconnect; one this -// server does not recognize is reported as none. Control: a drop without a goodbye. -TEST(ServerDisconnect, ReportsTheGoodbyeReason) { - LocalServer s; - ASSERT_EQ(s.start(), StartResult::OK); - RawClient client; - const std::string id = client_id_of(client); - const std::string reasons[] = {"another_server", "moving", ""}; - for (size_t i = 0; i < std::size(reasons); ++i) { - SCOPED_TRACE(reasons[i]); - ASSERT_NO_FATAL_FAILURE(connect(s.server, client, hello("Leaving", {}, true))); - next(client, "group/update"); - if (reasons[i].empty()) { - client.drop(); - } else { - client.send_json(sendspin::server::test::client_goodbye_json(reasons[i])); - } - s.recorder.wait_for_disconnects(i + 1); - } - const std::vector disconnects = s.recorder.disconnects(); - ASSERT_EQ(disconnects.size(), 3U); - EXPECT_EQ(disconnects[0].reason, DisconnectReason::GOODBYE); - EXPECT_EQ(disconnects[0].goodbye_reason, GoodbyeReason::ANOTHER_SERVER); - EXPECT_EQ(disconnects[1].reason, DisconnectReason::GOODBYE); - EXPECT_FALSE(disconnects[1].goodbye_reason); - EXPECT_EQ(disconnects[2].reason, DisconnectReason::CONNECTION_LOST); - EXPECT_FALSE(disconnects[2].goodbye_reason); -} - -// A player that reconnects within the grace rejoins the group it left, which is kept for it; here -// the solo group it moved to on turning unavailable (messaging.md "Server behavior when a client -// becomes unavailable"), so it is not put back in the server's group. Control: past the grace its -// group is gone, and an approved player joins the server's group; so too after a client/goodbye -// `another_server`, which says it is not coming back. -TEST(ServerGroups, AClientBackWithinTheGraceRejoinsItsGroup) { - enum class Leaves : uint8_t { WITHIN, PAST, ANOTHER_SERVER }; - for (const Leaves leaves : {Leaves::WITHIN, Leaves::PAST, Leaves::ANOTHER_SERVER}) { - SCOPED_TRACE(static_cast(leaves)); - const bool within = leaves == Leaves::WITHIN; - LocalServer s; - ServerConfig config = sendspin::server::test::local_config(); - config.approve_all_clients = true; - if (leaves == Leaves::PAST) { - config.timing.group_rejoin_grace_us = 1; - } - ASSERT_EQ(s.start(config), StartResult::OK); - const std::string default_group = group_id(*s.server.default_group()); - RawClient a; - RawClient b; - const std::string hello_json = hello("A", {"player@v1"}, true); - ASSERT_NO_FATAL_FAILURE(connect(s.server, a, hello_json)); - EXPECT_EQ(next(a, "group/update")["payload"]["group_id"].as(), default_group); - ASSERT_NO_FATAL_FAILURE(connect(s.server, b, hello("B", {"player@v1"}, true))); - next(b, "group/update"); - // The first client/state is an initial value; the second a transition - a.send_json(player_state(true, {}, std::nullopt, std::nullopt)); - b.send_json(player_state(true, {}, std::nullopt, std::nullopt)); - a.send_json(player_state(false, {}, std::nullopt, std::nullopt)); - const JsonDocument moved = next(a, "group/update"); - const std::string solo = moved["payload"]["group_id"].as(); - EXPECT_NE(solo, default_group); - EXPECT_EQ(moved["payload"]["playback_state"].as(), "stopped"); - - if (leaves == Leaves::ANOTHER_SERVER) { - a.send_json(sendspin::server::test::client_goodbye_json("another_server")); - } - a.close(); - s.recorder.wait_for_disconnects(1); - const auto has_solo = [&] { - const std::vector groups = s.server.groups(); - return std::any_of(groups.begin(), groups.end(), - [&](const GroupInfo& group) { return group_id(group.id) == solo; }); - }; - if (leaves == Leaves::PAST) { - while (has_solo()) { - std::this_thread::sleep_for(std::chrono::milliseconds(2)); - } - } - ASSERT_NO_FATAL_FAILURE(connect(s.server, a, hello_json)); - EXPECT_EQ(type(next(a)), "server/activate"); - const JsonDocument rejoined = next(a); - ASSERT_EQ(type(rejoined), "group/update"); - EXPECT_EQ(rejoined["payload"]["group_id"].as(), within ? solo : default_group); - } -} - -// ============================================================================== -// The host's groups (create_group(), rename_group(), move_client()) -// ============================================================================== - -/// A started server that approves every client -class GroupsServer : public LocalServer { -public: - GroupsServer() { - ServerConfig config = sendspin::server::test::local_config(); - config.name = "Living room"; - config.approve_all_clients = true; - EXPECT_EQ(this->start(config), StartResult::OK); - } - - /// Connects client with every implemented role, reporting a volume of 50 it offers to set, and - /// reads its activation, group/update and roles' server/state - /// @return the group_id its group/update carries - std::string join(RawClient& client, const std::string& name) { - connect(this->server, client, - hello(name, {"player@v1", "controller@v1", "metadata@v1"}, true)); - EXPECT_EQ(type(next(client)), "server/activate"); - client.send_json(player_state(true, {"volume", "mute"}, 50, false)); - const JsonDocument update = next(client); - EXPECT_EQ(type(update), "group/update"); - // The state its group had, then the controller's once its own volume counts - while (next(client, "server/state")["payload"]["controller"]["volume"] != 50) {} - return update["payload"]["group_id"].as(); - } - - /// The group's entry in groups(), sampled until whether it is listed is listed; with no - /// timeout, so a regression hangs into the suite watchdog - std::optional wait_until(GroupId group, bool listed) { - for (;;) { - const std::vector groups = this->server.groups(); - const auto it = std::find_if(groups.begin(), groups.end(), - [&](const GroupInfo& info) { return info.id == group; }); - if ((it != groups.end()) == listed) { - return it == groups.end() ? std::nullopt : std::optional(*it); - } - std::this_thread::sleep_for(std::chrono::milliseconds(2)); - } - } - - /// The renames expect_nothing_queued() made - int renames{0}; -}; - -/// A host writing silence to a group's input until destroyed; constructed once the group plays -class Silence { -public: - Silence(GroupsServer& s, GroupId group) { - sendspin::server::InputOptions options; - options.format = {48000, 2, sendspin::server::SampleFormat::S16}; - this->input = s.server.open_input(group, options).input; - EXPECT_TRUE(this->input); - this->thread_ = std::thread([this] { - constexpr size_t FRAMES = 960; - const std::vector block(FRAMES * 4, 0); - while (!this->stop_.load()) { - const sendspin::server::WriteResult result = - this->input->write(block.data(), FRAMES); - if (result.state == sendspin::server::InputState::FULL) { - this->input->wait_writable(20000); - } else if (result.frames == 0) { - std::this_thread::sleep_for(std::chrono::milliseconds(2)); - } - } - }); - s.recorder.wait_for("group " + group_id(group) + " playing"); - } - - ~Silence() { - this->stop_ = true; - this->thread_.join(); - } - - Silence(const Silence&) = delete; - Silence& operator=(const Silence&) = delete; - - std::shared_ptr input; - -private: - std::atomic stop_{false}; - std::thread thread_; -}; - -/// The next JSON message of type, skipping others and audio chunks -JsonDocument next_json(RawClient& client, std::string_view type) { - for (;;) { - const std::optional> plaintext = client.receive_plaintext(); - if (!plaintext) { - ADD_FAILURE() << "the connection closed while waiting for " << type; - return JsonDocument(); - } - if (plaintext->empty() || plaintext->front() != wire::JSON_ID) { - continue; - } - JsonDocument doc; - deserializeJson(doc, plaintext->data() + 1, plaintext->size() - 1); - if (doc["type"].as() == type) { - return doc; - } - } -} - -/// The group/update a move into a playing group gives, read with the stream/start that follows it: -/// a marker queued before the move emits the start when it reaches the head, so the start can come -/// first -JsonDocument next_join(RawClient& client) { - std::optional update; - bool started = false; - while (!update || !started) { - const std::optional> plaintext = client.receive_plaintext(); - if (!plaintext) { - ADD_FAILURE() << "the connection closed while waiting for the join"; - return JsonDocument(); - } - if (plaintext->empty() || plaintext->front() != wire::JSON_ID) { - continue; - } - JsonDocument doc; - deserializeJson(doc, plaintext->data() + 1, plaintext->size() - 1); - const std::string_view type = doc["type"].as(); - if (type == "group/update") { - update = std::move(doc); - } else if (type == "stream/start") { - started = true; - } - } - return *update; -} - -/// Proves client, in group, was sent nothing since the last message read from it: a rename of -/// the group queues a group/update behind anything queued for it, so that update comes next (a -/// time reply would not do: the pump takes one ahead of what is queued) -void expect_nothing_queued(GroupsServer& s, RawClient& client, GroupId group) { - const std::string name = "Renamed " + std::to_string(++s.renames); - ASSERT_TRUE(s.server.rename_group(group, name)); - const JsonDocument update = next(client); - EXPECT_EQ(type(update), "group/update"); - EXPECT_EQ(update["payload"]["group_name"], name); -} - -// messaging.md "group/update": a client the host moves into a group it creates is told with a -// group/update carrying every field, then gets the group's controller and metadata state -// (messaging.md "server/state"; metadata/v1.md: the new group's current state); a rename is told -// to the members the same way; moved back, it gets its old group's state, and the group it left, -// empty and with no input, goes. The member that stays is sent nothing. -TEST(ServerGroups, MovesAClientIntoAGroupTheHostCreatesAndBack) { - GroupsServer s; - const GroupId living = *s.server.default_group(); - RawClient a; - RawClient b; - EXPECT_EQ(s.join(a, "A"), group_id(living)); - EXPECT_EQ(s.join(b, "B"), group_id(living)); - const std::string b_id = client_id_of(b); - Metadata metadata; - metadata.title = "Living room's"; - ASSERT_TRUE(s.server.set_metadata(living, metadata, s.server.now_us())); - EXPECT_EQ(next(a, "server/state")["payload"]["metadata"]["title"], "Living room's"); - EXPECT_EQ(next(b, "server/state")["payload"]["metadata"]["title"], "Living room's"); - - const std::optional kitchen = s.server.create_group("Kitchen", b_id); - ASSERT_TRUE(kitchen); - EXPECT_EQ(payload(next(b)), R"({"playback_state":"stopped","group_id":")" + group_id(*kitchen) + - R"(","group_name":"Kitchen"})"); - JsonDocument state = next(b); - ASSERT_EQ(type(state), "server/state"); - EXPECT_EQ(state["payload"]["controller"]["volume"], 50); - // Nothing set in the kitchen: its timestamp alone - EXPECT_EQ(state["payload"]["metadata"].size(), 1U); - // A stopped group's member declares no activities, keeping its roles - EXPECT_EQ(payload(next(b)), R"({"activities":[]})"); - s.recorder.wait_for("group " + group_id(*kitchen) + " stopped"); - const std::optional listed = s.wait_until(*kitchen, true); - ASSERT_TRUE(listed); - EXPECT_EQ(listed->name, "Kitchen"); - EXPECT_EQ(listed->client_ids, std::vector{b_id}); - // The controller state A sees is unchanged by B leaving, both at 50 - expect_nothing_queued(s, a, living); - - ASSERT_TRUE(s.server.rename_group(*kitchen, "Den")); - EXPECT_EQ(payload(next(b)), R"({"playback_state":"stopped","group_id":")" + group_id(*kitchen) + - R"(","group_name":"Den"})"); - expect_nothing_queued(s, a, living); - - ASSERT_TRUE(s.server.move_client(b_id, living)); - EXPECT_EQ(next(b)["payload"]["group_id"].as(), group_id(living)); - state = next(b); - ASSERT_EQ(type(state), "server/state"); - EXPECT_EQ(state["payload"]["metadata"]["title"], "Living room's"); - EXPECT_FALSE(s.wait_until(*kitchen, false)); - // Moving into the group it is in changes nothing - ASSERT_TRUE(s.server.move_client(b_id, living)); - expect_nothing_queued(s, b, living); -} - -// server.h: what create_group(), rename_group(), move_client() and set_player_format() refuse at -// once, without waiting for core: a client_id that is not one, a group not in groups(), a name -// past config_limits::MAX_NAME_BYTES, and every call while stopped -TEST(ServerGroups, RefusesCallsItCanJudgeAtOnce) { - GroupsServer s; - RawClient a; - s.join(a, "A"); - const std::string a_id = client_id_of(a); - const GroupId living = *s.server.default_group(); - const GroupId none{999}; - const std::string longest(sendspin::server::config_limits::MAX_NAME_BYTES, 'x'); - const std::string too_long = longest + "x"; - const StreamFormat flac{StreamCodec::FLAC, 48000, 2, 16}; - struct Row { - const char* name; - std::function call; - bool accepted; - }; - const std::vector rows = { - {"Control: create_group(), a name at the limit", - [&] { return s.server.create_group(longest, a_id).has_value(); }, true}, - {"create_group(), no client_id", - [&] { return s.server.create_group("Hall", "not a client id").has_value(); }, false}, - {"create_group(), a name too long", - [&] { return s.server.create_group(too_long, a_id).has_value(); }, false}, - {"Control: rename_group()", [&] { return s.server.rename_group(living, longest); }, true}, - {"rename_group(), no group", [&] { return s.server.rename_group(none, "Gone"); }, false}, - {"rename_group(), a name too long", [&] { return s.server.rename_group(living, too_long); }, - false}, - {"Control: move_client()", [&] { return s.server.move_client(a_id, living); }, true}, - {"move_client(), no group", [&] { return s.server.move_client(a_id, none); }, false}, - {"move_client(), no client_id", - [&] { return s.server.move_client("not a client id", living); }, false}, - {"Control: set_player_format()", [&] { return s.server.set_player_format(a_id, flac); }, - true}, - {"set_player_format(), no client_id", - [&] { return s.server.set_player_format("not a client id", flac); }, false}, - }; - for (const Row& row : rows) { - SCOPED_TRACE(row.name); - EXPECT_EQ(row.call(), row.accepted); - } - s.server.stop(); - for (const Row& row : rows) { - SCOPED_TRACE(std::string("stopped: ") + row.name); - EXPECT_FALSE(row.call()); - } -} - -// A client held with no roles (here one without unpaired access) stays in a group of its own: the -// host's create_group() makes no group for it and move_client() leaves it -TEST(ServerGroups, LeavesAClientHeldWithNoRolesInItsOwnGroup) { - GroupsServer s; - RawClient a; - RawClient held; - s.join(a, "A"); - connect(s.server, held, hello("Held", {"player@v1"}, false)); - EXPECT_EQ(payload(next(held)), R"({"activities":[],"active_roles":[]})"); - const std::string own = next(held, "group/update")["payload"]["group_id"].as(); - const std::string held_id = client_id_of(held); - s.recorder.wait_for("pairing required " + held_id); - - const std::optional refused = s.server.create_group("Refused", held_id); - ASSERT_TRUE(refused); - ASSERT_TRUE(s.server.move_client(held_id, *s.server.default_group())); - // Control: A, which may play, moves; core takes the calls in order, so A's move shows the two - // before it were taken - const std::optional taken = s.server.create_group("Taken", client_id_of(a)); - ASSERT_TRUE(taken); - EXPECT_EQ(next(a, "group/update")["payload"]["group_id"].as(), group_id(*taken)); - ASSERT_TRUE(s.wait_until(*taken, true)); - for (const GroupInfo& group : s.server.groups()) { - EXPECT_NE(group.id, *refused); - if (group_id(group.id) == own) { - EXPECT_EQ(group.client_ids, std::vector{held_id}); - } - } - expect_nothing_queued(s, held, GroupId{std::stoull(own)}); -} - -// controller/v1.md: a controller acts on its own group: its volume command sets its group's -// players only, a transport command reaches the host with its group, which offers it with that -// group's caps, and metadata set for a group reaches that group's members only (metadata/v1.md) -TEST(ServerGroups, ScopesControllerCommandsAndMetadataToTheirGroup) { - GroupsServer s; - const GroupId living = *s.server.default_group(); - RawClient a; - RawClient b; - s.join(a, "A"); - s.join(b, "B"); - const std::string b_id = client_id_of(b); - const GroupId kitchen = *s.server.create_group("Kitchen", b_id); - next(b, "group/update"); - next(b, "server/state"); - - a.send_json(controller_command(R"({"command":"volume","volume":80})")); - EXPECT_EQ(payload(next(a, "server/command")), R"({"player":{"command":"volume","volume":80}})"); - b.send_json(controller_command(R"({"command":"mute","mute":true})")); - EXPECT_EQ(payload(next(b, "server/command")), R"({"player":{"command":"mute","mute":true}})"); - expect_nothing_queued(s, a, living); - expect_nothing_queued(s, b, kitchen); - - ControllerCaps caps; - caps.commands = {TransportCommand::NEXT}; - ASSERT_TRUE(s.server.set_controller_caps(kitchen, caps)); - EXPECT_EQ(next(b, "server/state")["payload"]["controller"]["supported_commands"][0], "next"); - // Not offered in A's group: ignored, taken in before the time reply that follows it, while - // B's reaches the host after it, with the kitchen's id - a.send_json(controller_command(R"({"command":"next"})")); - a.send_json(sendspin::server::test::client_time_json(1)); - next(a, "server/time"); - b.send_json(controller_command(R"({"command":"next"})")); - const std::string next_command = std::to_string(static_cast(TransportCommand::NEXT)); - s.recorder.wait_for("command " + group_id(kitchen) + " " + next_command + " " + b_id); - EXPECT_EQ(s.recorder.count("command " + group_id(living) + " " + next_command + " " + - client_id_of(a)), - 0U); - - Metadata metadata; - metadata.title = "Kitchen's"; - ASSERT_TRUE(s.server.set_metadata(kitchen, metadata, s.server.now_us())); - EXPECT_EQ(next(b, "server/state")["payload"]["metadata"]["title"], "Kitchen's"); - expect_nothing_queued(s, a, living); -} - -// The host's move of a client that went away without a goodbye moves the place kept for it in its -// group, so it rejoins the group it was moved to, and the group it left, empty, goes; into a group -// that plays, it joins that group's playback; create_group() with it as the first member makes a -// group for its place -TEST(ServerGroups, MovesThePlaceKeptForADisconnectedClient) { - enum class Into : uint8_t { STOPPED, PLAYING, CREATED }; - struct Row { - const char* name; - Into into; - }; - const Row rows[] = { - {"Control: move_client() into a stopped group", Into::STOPPED}, - {"move_client() into a playing group", Into::PLAYING}, - {"create_group() for it", Into::CREATED}, - }; - for (const Row& row : rows) { - SCOPED_TRACE(row.name); - GroupsServer s; - RawClient a; - RawClient b; - s.join(a, "A"); - s.join(b, "B"); - const std::string a_id = client_id_of(a); - const GroupId kitchen = *s.server.create_group("Kitchen", client_id_of(b)); - next(b, "group/update"); - const GroupId solo = *s.server.create_group("Solo", a_id); - EXPECT_EQ(next(a, "group/update")["payload"]["group_id"].as(), group_id(solo)); - std::optional playing; - if (row.into == Into::PLAYING) { - playing.emplace(s, kitchen); - } - a.close(); - s.recorder.wait_for_disconnects(1); - GroupId joined = kitchen; - if (row.into == Into::CREATED) { - joined = *s.server.create_group("Created", a_id); - ASSERT_TRUE(s.wait_until(joined, true)); - } else { - ASSERT_TRUE(s.server.move_client(a_id, kitchen)); - } - EXPECT_FALSE(s.wait_until(solo, false)); - - connect(s.server, a, hello("A", {"player@v1", "controller@v1", "metadata@v1"}, true)); - EXPECT_EQ(type(next(a)), "server/activate"); - a.send_json(player_state(true, {}, std::nullopt, std::nullopt)); - const JsonDocument update = - row.into == Into::PLAYING ? next_join(a) : next_json(a, "group/update"); - EXPECT_EQ(update["payload"]["group_id"].as(), group_id(joined)); - if (row.into == Into::PLAYING) { - EXPECT_EQ(update["payload"]["playback_state"], "playing"); - } - } -} - -// messaging.md "External Source Handling": only a solo member that turns unavailable or leaves -// stops its group. The host moving the last member out of a playing group leaves it playing, with -// its input open, until the host releases the input, when the group goes. Control: the same -// member's client/leave stops the group and latches the input. -TEST(ServerGroups, KeepsPlayingAGroupItsLastMemberIsMovedOutOf) { - for (const bool leave : {false, true}) { - SCOPED_TRACE(leave ? "Control: client/leave" : "move_client()"); - GroupsServer s; - RawClient b; - s.join(b, "B"); - const std::string b_id = client_id_of(b); - const GroupId kitchen = *s.server.create_group("Kitchen", b_id); - next(b, "group/update"); - std::optional playing(std::in_place, s, kitchen); - if (leave) { - const std::string stopped = "group " + group_id(kitchen) + " stopped"; - const size_t stops = s.recorder.count(stopped); - b.send_json(R"({"type":"client/leave","payload":{}})"); - s.recorder.wait_for_count(stopped, stops + 1); - EXPECT_EQ(playing->input->state(), sendspin::server::InputState::STOP_LATCHED); - continue; - } - ASSERT_TRUE(s.server.move_client(b_id, *s.server.default_group())); - // The move and any stop it caused are published together, with B listed in its new group - for (;;) { - const std::vector groups = s.server.groups(); - const auto living = std::find_if(groups.begin(), groups.end(), [&](const GroupInfo& g) { - return g.id == *s.server.default_group(); - }); - if (living != groups.end() && !living->client_ids.empty()) { - break; - } - std::this_thread::sleep_for(std::chrono::milliseconds(2)); - } - // Barrier: core answers the client/time after any handler the move posted, so a stop - // posted for later is published before groups() is sampled - b.send_json(sendspin::server::test::client_time_json(1)); - next_json(b, "server/time"); - const std::optional left = s.wait_until(kitchen, true); - ASSERT_TRUE(left); - EXPECT_TRUE(left->client_ids.empty()); - EXPECT_EQ(left->playback_state, sendspin::server::PlaybackState::PLAYING); - EXPECT_NE(playing->input->state(), sendspin::server::InputState::STOP_LATCHED); - playing.reset(); - EXPECT_FALSE(s.wait_until(kitchen, false)); - } -} - -// messaging.md "Server behavior when a client becomes unavailable" forbids the server rejoining an -// unavailable client to a group, not the host: moved into a playing group while its first -// client/state reports it unavailable, a client gets the group's group/update and no stream until -// it reports available. Control: one available on arrival starts at once. -TEST(ServerGroups, StartsAClientMovedWhileUnavailableOnceAvailable) { - for (const bool available : {true, false}) { - SCOPED_TRACE(available ? "Control: available" : "unavailable"); - GroupsServer s; - RawClient a; - RawClient b; - s.join(b, "B"); - const GroupId kitchen = *s.server.create_group("Kitchen", client_id_of(b)); - next(b, "group/update"); - Silence playing(s, kitchen); - connect(s.server, a, hello("A", {"player@v1", "controller@v1", "metadata@v1"}, true)); - EXPECT_EQ(type(next(a)), "server/activate"); - // The first client/state is an initial value, so A stays in the server's group - a.send_json(player_state(available, {}, std::nullopt, std::nullopt)); - next(a, "group/update"); - ASSERT_TRUE(s.server.move_client(client_id_of(a), kitchen)); - const JsonDocument update = available ? next_join(a) : next_json(a, "group/update"); - EXPECT_EQ(update["payload"]["group_id"].as(), group_id(kitchen)); - EXPECT_EQ(update["payload"]["playback_state"], "playing"); - if (!available) { - next(a, "server/state"); - expect_nothing_queued(s, a, kitchen); - a.send_json(player_state(true, {}, std::nullopt, std::nullopt)); - next_json(a, "stream/start"); - } - } -} - -// messaging.md "client/leave", controller/v1.md "Switch command cycle": a client that leaves its -// shared group is moved to a stopped group of its own; its switch then takes it back to the group -// it left first, and the next switch, with no group playing, to a new group of its own -TEST(ServerGroups, SwitchesBackToTheGroupItLeft) { - GroupsServer s; - const std::string living = group_id(*s.server.default_group()); - RawClient a; - RawClient b; - s.join(a, "A"); - s.join(b, "B"); - - b.send_json(R"({"type":"client/leave","payload":{}})"); - const JsonDocument left = next(b, "group/update"); - const std::string solo = left["payload"]["group_id"].as(); - EXPECT_NE(solo, living); - EXPECT_EQ(left["payload"]["playback_state"], "stopped"); - b.send_json(controller_command(R"({"command":"switch"})")); - EXPECT_EQ(next(b, "group/update")["payload"]["group_id"].as(), living); - b.send_json(controller_command(R"({"command":"switch"})")); - const std::string own = next(b, "group/update")["payload"]["group_id"].as(); - EXPECT_NE(own, living); - EXPECT_NE(own, solo); -} - // ============================================================================== // In-process sendspin-cpp clients // ============================================================================== @@ -1351,93 +671,4 @@ TEST(ServerRolesInProcess, ControllerAndMetadataReachASendspinCppClient) { ASSERT_TRUE(s.server.set_metadata(group, next_track, s.server.now_us() + AHEAD_US)); run_until(peers, [&] { return a.events.metadata && a.events.metadata->title == "Next"; }); } - -// ============================================================================== -// Message trace (ServerConfig::trace_writes) -// ============================================================================== - -// ServerConfig::trace_writes: each completed write is traced after the messages it carried, and -// a server/time's comes before a client/time the client sent once it had read that reply, which is -// what lets a host place what it received against what was on the wire; without the option the -// trace has no such entry -TEST(ServerTrace, TracesCompletedWritesOnlyWhenAsked) { - struct Seen { - bool written; - bool inbound; - std::string type; - bool empty; - }; - for (const bool trace_writes : {false, true}) { - SCOPED_TRACE(trace_writes ? "trace_writes" : "Control: trace_writes off"); - std::mutex mutex; - std::condition_variable changed; - std::vector seen; - LocalServer s; - ServerConfig config = sendspin::server::test::local_config(); - config.approve_all_clients = true; - config.trace_writes = trace_writes; - config.trace = [&](const sendspin::server::TracedMessage& message) { - std::string message_type; - if (!message.plaintext.empty() && - (!message.binary || message.plaintext.front() == wire::JSON_ID)) { - const size_t offset = message.binary ? 1 : 0; - JsonDocument doc; - if (deserializeJson(doc, message.plaintext.data() + offset, - message.plaintext.size() - offset) == - DeserializationError::Ok) { - message_type = doc["type"].as(); - } - } - { - const std::lock_guard lock(mutex); - seen.push_back({message.written, - message.direction == sendspin::server::TraceDirection::INBOUND, - message_type, message.plaintext.empty()}); - } - changed.notify_all(); - }; - ASSERT_EQ(s.start(config), StartResult::OK); - RawClient client; - ASSERT_NO_FATAL_FAILURE(connect(s.server, client, hello("Timer", {"metadata@v1"}, true))); - next(client, "group/update"); - client.send_json(sendspin::server::test::client_time_json(1)); - next(client, "server/time"); - client.send_json(sendspin::server::test::client_time_json(2)); - - // Waits with no timeout for the second request; a regression hangs into the suite watchdog - std::unique_lock lock(mutex); - changed.wait(lock, [&] { - return std::count_if(seen.begin(), seen.end(), [](const Seen& entry) { - return entry.inbound && entry.type == "client/time"; - }) == 2; - }); - const auto second_request = - std::find_if( - seen.rbegin(), seen.rend(), - [](const Seen& entry) { return entry.inbound && entry.type == "client/time"; }) - .base() - - 1; - const auto reply = std::find_if(seen.begin(), seen.end(), [](const Seen& entry) { - return !entry.inbound && entry.type == "server/time"; - }); - ASSERT_LT(reply, second_request); - const auto reply_written = - std::find_if(reply + 1, seen.end(), [](const Seen& entry) { return entry.written; }); - const size_t writes = static_cast(std::count_if( - seen.begin(), seen.end(), [](const Seen& entry) { return entry.written; })); - if (!trace_writes) { - EXPECT_EQ(writes, 0U); - continue; - } - // A time reply ends its write: no outbound message is taken between the reply and its - // write - ASSERT_NE(reply_written, seen.end()); - EXPECT_TRUE(std::none_of(reply + 1, reply_written, - [](const Seen& entry) { return !entry.inbound; })); - EXPECT_LT(reply_written, second_request); - EXPECT_FALSE(reply_written->inbound); - EXPECT_TRUE(reply_written->empty); - } -} - } // namespace diff --git a/tests/test_source_decoder.cpp b/tests/test_source_decoder.cpp index 9a1ae9b..9826065 100644 --- a/tests/test_source_decoder.cpp +++ b/tests/test_source_decoder.cpp @@ -13,9 +13,9 @@ // limitations under the License. /// @file test_source_decoder.cpp -/// @brief A routed source's feed and decoder: the formats it takes, PCM and FLAC decoded bit for -/// bit and Opus decoded, each block with its capture time, and the chunks that break the stream's -/// framing or are longer than 150 ms reported as protocol errors (roles/source/v1.md +/// @brief A routed source's decoder: the formats it takes, PCM and FLAC decoded bit for bit and +/// Opus decoded, each block with its capture time, and the chunks that break the stream's framing +/// or are longer than 150 ms reported as protocol errors (roles/source/v1.md /// "client-stream/start", "Source Audio Chunks (Binary)") #include "crypto/base64url.h" @@ -50,49 +50,6 @@ using sendspin::server::SourceItem; using sendspin::server::test::job_view_of; using Bytes = std::vector; -// ============================================================================== -// The feed -// ============================================================================== - -// A bounded FIFO: items come out in order with their bytes, across the storage's wrap, and a push -// past the items or the bytes it holds is refused -TEST(SourceFeed, HoldsItemsInOrderWithinItsBounds) { - SourceFeed feed(10, 3); - const auto push = [&](uint64_t stream, size_t size) { - SourceItem item; - item.kind = SourceItem::Kind::CHUNK; - item.stream = stream; - return feed.push(item, Bytes(size, static_cast(stream))); - }; - EXPECT_TRUE(push(1, 4)); - EXPECT_TRUE(push(2, 4)); - EXPECT_FALSE(push(3, 4)) << "past the bytes"; - EXPECT_TRUE(push(3, 2)); - EXPECT_FALSE(push(4, 1)) << "past the items"; - for (uint64_t expected = 1; expected <= 2; ++expected) { - SourceItem item; - std::span bytes; - ASSERT_TRUE(feed.front(item, bytes)); - EXPECT_EQ(item.stream, expected); - EXPECT_TRUE( - std::all_of(bytes.begin(), bytes.end(), [&](uint8_t b) { return b == expected; })); - feed.pop(); - } - // Wraps to the start of the storage, behind the item left - EXPECT_TRUE(push(5, 6)); - SourceItem item; - std::span bytes; - ASSERT_TRUE(feed.front(item, bytes)); - EXPECT_EQ(item.stream, 3U); - feed.pop(); - ASSERT_TRUE(feed.front(item, bytes)); - EXPECT_EQ(item.stream, 5U); - EXPECT_EQ(bytes.size(), 6U); - feed.pop(); - EXPECT_TRUE(feed.empty()); - EXPECT_TRUE(push(6, 10)) << "an empty feed holds its whole storage"; -} - // ============================================================================== // Formats // ============================================================================== diff --git a/tests/test_source_feed.cpp b/tests/test_source_feed.cpp new file mode 100644 index 0000000..0234e55 --- /dev/null +++ b/tests/test_source_feed.cpp @@ -0,0 +1,77 @@ +// Copyright 2026 Sendspin Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// @file test_source_feed.cpp +/// @brief SourceFeed, the bounded FIFO a routed source's stream items cross from core to its +/// decoder. + +#include "source_feed.h" +#include + +#include +#include +#include +#include +#include + +namespace { + +using sendspin::server::SourceFeed; +using sendspin::server::SourceItem; +using Bytes = std::vector; + +// ============================================================================== +// The feed +// ============================================================================== + +// A bounded FIFO: items come out in order with their bytes, across the storage's wrap, and a push +// past the items or the bytes it holds is refused +TEST(SourceFeed, HoldsItemsInOrderWithinItsBounds) { + SourceFeed feed(10, 3); + const auto push = [&](uint64_t stream, size_t size) { + SourceItem item; + item.kind = SourceItem::Kind::CHUNK; + item.stream = stream; + return feed.push(item, Bytes(size, static_cast(stream))); + }; + EXPECT_TRUE(push(1, 4)); + EXPECT_TRUE(push(2, 4)); + EXPECT_FALSE(push(3, 4)) << "past the bytes"; + EXPECT_TRUE(push(3, 2)); + EXPECT_FALSE(push(4, 1)) << "past the items"; + for (uint64_t expected = 1; expected <= 2; ++expected) { + SourceItem item; + std::span bytes; + ASSERT_TRUE(feed.front(item, bytes)); + EXPECT_EQ(item.stream, expected); + EXPECT_TRUE( + std::all_of(bytes.begin(), bytes.end(), [&](uint8_t b) { return b == expected; })); + feed.pop(); + } + // Wraps to the start of the storage, behind the item left + EXPECT_TRUE(push(5, 6)); + SourceItem item; + std::span bytes; + ASSERT_TRUE(feed.front(item, bytes)); + EXPECT_EQ(item.stream, 3U); + feed.pop(); + ASSERT_TRUE(feed.front(item, bytes)); + EXPECT_EQ(item.stream, 5U); + EXPECT_EQ(bytes.size(), 6U); + feed.pop(); + EXPECT_TRUE(feed.empty()); + EXPECT_TRUE(push(6, 10)) << "an empty feed holds its whole storage"; +} + +} // namespace diff --git a/tests/test_transport.cpp b/tests/test_transport.cpp index e544330..15b27af 100644 --- a/tests/test_transport.cpp +++ b/tests/test_transport.cpp @@ -67,7 +67,6 @@ namespace websocket = beast::websocket; using tcp = asio::ip::tcp; using boost::system::error_code; -using sendspin::server::close_reason_for_errno; using sendspin::server::CloseReason; using sendspin::server::Connection; using sendspin::server::ConnectionHandler; @@ -471,137 +470,6 @@ int tcp_option(int fd, int option) { return value; } -// ============================================================================== -// URL parsing -// ============================================================================== - -TEST(TransportUrl, ParsesWebSocketUrls) { - struct Row { - const char* url; - bool valid; - const char* host; - uint16_t port; - const char* path; - }; - const Row rows[] = { - // Control: - {"ws://192.168.1.20:8928/sendspin", true, "192.168.1.20", 8928, "/sendspin"}, - {"ws://speaker.local", true, "speaker.local", 80, "/"}, - {"ws://[fe80::1]:9000/a/b?x=1", true, "fe80::1", 9000, "/a/b?x=1"}, - {"ws://[::1]/p", true, "::1", 80, "/p"}, - {"ws://[::ffff:192.0.2.1]/p", true, "::ffff:192.0.2.1", 80, "/p"}, - {"wss://host/p", false, "", 0, ""}, - {"http://host/p", false, "", 0, ""}, - {"ws://", false, "", 0, ""}, - {"ws://:8928/p", false, "", 0, ""}, - {"ws://host:/p", false, "", 0, ""}, - {"ws://host:0/p", false, "", 0, ""}, - {"ws://host:65536/p", false, "", 0, ""}, - {"ws://host:12ab/p", false, "", 0, ""}, - {"ws://[::1/p", false, "", 0, ""}, - {"ws://[::1]x/p", false, "", 0, ""}, - {"ws://user@host/p", false, "", 0, ""}, - {"ws://host?x=1", false, "", 0, ""}, - {"ws://host/p#frag", false, "", 0, ""}, - {"ws://host#frag", false, "", 0, ""}, - // RFC 3986 sections 2 and 3: nothing that could break the upgrade request's lines. - // Control: every character class a host and a path may hold - {"ws://h-1.local/a_b~c.%2F;x=1!$&'()*+,@:?q=/?", true, "h-1.local", 80, - "/a_b~c.%2F;x=1!$&'()*+,@:?q=/?"}, - // An IPv6 zone identifier, kept as the system resolver takes it. - // Control: percent-encoded (RFC 6874 section 2) - {"ws://[fe80::1%25en0]/p", true, "fe80::1%en0", 80, "/p"}, - // Control: raw, by name and by index - {"ws://[fe80::1%en0]:8928/sendspin", true, "fe80::1%en0", 8928, "/sendspin"}, - {"ws://[fe80::1%1]/", true, "fe80::1%1", 80, "/"}, - {"ws://[fe80::1%]/p", false, "", 0, ""}, - {"ws://[fe80::1%25]/p", false, "", 0, ""}, - {"ws://[fe80::1%en 0]/p", false, "", 0, ""}, - {"ws://[fe80::1%en\x01]/p", false, "", 0, ""}, - {"ws://[fe80::1%en0\r\n]/p", false, "", 0, ""}, - // A zone holding '%', before or after decoding - {"ws://[fe%80::1%en0]/p", false, "", 0, ""}, - {"ws://[fe80::1%25en%250]/p", false, "", 0, ""}, - {"ws://[fe80::1%%en0]/p", false, "", 0, ""}, - // An address that is empty or holds a character besides hex digits, ':' and '.' - {"ws://[%en0]/p", false, "", 0, ""}, - {"ws://[fe80::g]/p", false, "", 0, ""}, - // RFC 3986 section 3.2.2: a bracketed address is IPv6, in a form is_ipv6_text() takes - // (its own table is in test_url_text.cpp). - // Control: the forms with and without "::", in either case, with an IPv4 tail - {"ws://[::]/p", true, "::", 80, "/p"}, - {"ws://[1:2:3:4:5:6:7:8]/p", true, "1:2:3:4:5:6:7:8", 80, "/p"}, - {"ws://[FE80::1]/p", true, "FE80::1", 80, "/p"}, - {"ws://[64:ff9b::1.2.3.4]/p", true, "64:ff9b::1.2.3.4", 80, "/p"}, - {"ws://[1]/p", false, "", 0, ""}, - {"ws://[::::]/p", false, "", 0, ""}, - {"ws://[1.2.3.4]/p", false, "", 0, ""}, - {"ws://[1.2.3.4%en0]/p", false, "", 0, ""}, - // Forms macOS's inet_pton takes that RFC 4291 does not - {"ws://[0fac9::]/p", false, "", 0, ""}, - {"ws://[::1.2.3.04]/p", false, "", 0, ""}, - {"ws://host/a\r\nX-Injected: 1", false, "", 0, ""}, - {"ws://host/a\nb", false, "", 0, ""}, - {"ws://host/a b", false, "", 0, ""}, - {"ws://host/a\x7F", false, "", 0, ""}, - {"ws://host/a\x01", false, "", 0, ""}, - {"ws://host/a\"b", false, "", 0, ""}, - {"ws://host/caf\xC3\xA9", false, "", 0, ""}, - {"ws://host/%2", false, "", 0, ""}, - {"ws://host/%zz", false, "", 0, ""}, - {"ws://host/%g0", false, "", 0, ""}, - {"ws://host/%0g", false, "", 0, ""}, - {"ws://ho\r\nst/p", false, "", 0, ""}, - // A percent-encoding in a host outside brackets, which no one would decode. - // Control: in the path, it stays as written (above) - {"ws://h%20st/p", false, "", 0, ""}, - {"ws://h%41st/p", false, "", 0, ""}, - {"ws://1.2.3.4%251/p", false, "", 0, ""}, - {"ws://ho st/p", false, "", 0, ""}, - {"ws://[::1\r\n]/p", false, "", 0, ""}, - {"ws://[::1 ]/p", false, "", 0, ""}, - }; - for (const Row& row : rows) { - SCOPED_TRACE(row.url); - const std::optional target = parse_ws_url(row.url); - ASSERT_EQ(target.has_value(), row.valid); - if (row.valid) { - EXPECT_EQ(target->host, row.host); - EXPECT_EQ(target->port, row.port); - EXPECT_EQ(target->path, row.path); - EXPECT_TRUE(target->addresses.empty()); - } - } -} - -// ============================================================================== -// Socket errors -// ============================================================================== - -// A socket call's errno as both transports report it: the host's for an Asio system error -// (PeerResetIsReportedAsPeerClosed), the ESP-IDF transport's for its sends, waits and reads -TEST(TransportErrno, MapsASocketErrorToItsCloseReason) { - struct Row { - const char* name; - int error; - CloseReason reason; - }; - const Row rows[] = { - {"connection reset", ECONNRESET, CloseReason::PEER_CLOSED}, - {"connection aborted", ECONNABORTED, CloseReason::PEER_CLOSED}, - {"broken pipe", EPIPE, CloseReason::PEER_CLOSED}, - {"not connected", ENOTCONN, CloseReason::PEER_CLOSED}, - {"Control: timed out", ETIMEDOUT, CloseReason::FAILED}, - {"Control: host unreachable", EHOSTUNREACH, CloseReason::FAILED}, - {"Control: bad descriptor", EBADF, CloseReason::FAILED}, - {"Control: no error", 0, CloseReason::FAILED}, - }; - for (const Row& row : rows) { - SCOPED_TRACE(row.name); - EXPECT_EQ(close_reason_for_errno(row.error), row.reason); - } -} - // ============================================================================== // Accept and dial // ============================================================================== diff --git a/tests/test_transport_url.cpp b/tests/test_transport_url.cpp new file mode 100644 index 0000000..5775e05 --- /dev/null +++ b/tests/test_transport_url.cpp @@ -0,0 +1,164 @@ +// Copyright 2026 Sendspin Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// @file test_transport_url.cpp +/// @brief The transport's platform-free parts: a dial URL parsed into its target, and a socket +/// call's errno as the close reason both transports report. + +#include "transport.h" +#include + +#include +#include +#include + +namespace { + +using sendspin::server::close_reason_for_errno; +using sendspin::server::CloseReason; +using sendspin::server::DialTarget; +using sendspin::server::parse_ws_url; + +// ============================================================================== +// URL parsing +// ============================================================================== + +TEST(TransportUrl, ParsesWebSocketUrls) { + struct Row { + const char* url; + bool valid; + const char* host; + uint16_t port; + const char* path; + }; + const Row rows[] = { + // Control: + {"ws://192.168.1.20:8928/sendspin", true, "192.168.1.20", 8928, "/sendspin"}, + {"ws://speaker.local", true, "speaker.local", 80, "/"}, + {"ws://[fe80::1]:9000/a/b?x=1", true, "fe80::1", 9000, "/a/b?x=1"}, + {"ws://[::1]/p", true, "::1", 80, "/p"}, + {"ws://[::ffff:192.0.2.1]/p", true, "::ffff:192.0.2.1", 80, "/p"}, + {"wss://host/p", false, "", 0, ""}, + {"http://host/p", false, "", 0, ""}, + {"ws://", false, "", 0, ""}, + {"ws://:8928/p", false, "", 0, ""}, + {"ws://host:/p", false, "", 0, ""}, + {"ws://host:0/p", false, "", 0, ""}, + {"ws://host:65536/p", false, "", 0, ""}, + {"ws://host:12ab/p", false, "", 0, ""}, + {"ws://[::1/p", false, "", 0, ""}, + {"ws://[::1]x/p", false, "", 0, ""}, + {"ws://user@host/p", false, "", 0, ""}, + {"ws://host?x=1", false, "", 0, ""}, + {"ws://host/p#frag", false, "", 0, ""}, + {"ws://host#frag", false, "", 0, ""}, + // RFC 3986 sections 2 and 3: nothing that could break the upgrade request's lines. + // Control: every character class a host and a path may hold + {"ws://h-1.local/a_b~c.%2F;x=1!$&'()*+,@:?q=/?", true, "h-1.local", 80, + "/a_b~c.%2F;x=1!$&'()*+,@:?q=/?"}, + // An IPv6 zone identifier, kept as the system resolver takes it. + // Control: percent-encoded (RFC 6874 section 2) + {"ws://[fe80::1%25en0]/p", true, "fe80::1%en0", 80, "/p"}, + // Control: raw, by name and by index + {"ws://[fe80::1%en0]:8928/sendspin", true, "fe80::1%en0", 8928, "/sendspin"}, + {"ws://[fe80::1%1]/", true, "fe80::1%1", 80, "/"}, + {"ws://[fe80::1%]/p", false, "", 0, ""}, + {"ws://[fe80::1%25]/p", false, "", 0, ""}, + {"ws://[fe80::1%en 0]/p", false, "", 0, ""}, + {"ws://[fe80::1%en\x01]/p", false, "", 0, ""}, + {"ws://[fe80::1%en0\r\n]/p", false, "", 0, ""}, + // A zone holding '%', before or after decoding + {"ws://[fe%80::1%en0]/p", false, "", 0, ""}, + {"ws://[fe80::1%25en%250]/p", false, "", 0, ""}, + {"ws://[fe80::1%%en0]/p", false, "", 0, ""}, + // An address that is empty or holds a character besides hex digits, ':' and '.' + {"ws://[%en0]/p", false, "", 0, ""}, + {"ws://[fe80::g]/p", false, "", 0, ""}, + // RFC 3986 section 3.2.2: a bracketed address is IPv6, in a form is_ipv6_text() takes + // (its own table is in test_url_text.cpp). + // Control: the forms with and without "::", in either case, with an IPv4 tail + {"ws://[::]/p", true, "::", 80, "/p"}, + {"ws://[1:2:3:4:5:6:7:8]/p", true, "1:2:3:4:5:6:7:8", 80, "/p"}, + {"ws://[FE80::1]/p", true, "FE80::1", 80, "/p"}, + {"ws://[64:ff9b::1.2.3.4]/p", true, "64:ff9b::1.2.3.4", 80, "/p"}, + {"ws://[1]/p", false, "", 0, ""}, + {"ws://[::::]/p", false, "", 0, ""}, + {"ws://[1.2.3.4]/p", false, "", 0, ""}, + {"ws://[1.2.3.4%en0]/p", false, "", 0, ""}, + // Forms macOS's inet_pton takes that RFC 4291 does not + {"ws://[0fac9::]/p", false, "", 0, ""}, + {"ws://[::1.2.3.04]/p", false, "", 0, ""}, + {"ws://host/a\r\nX-Injected: 1", false, "", 0, ""}, + {"ws://host/a\nb", false, "", 0, ""}, + {"ws://host/a b", false, "", 0, ""}, + {"ws://host/a\x7F", false, "", 0, ""}, + {"ws://host/a\x01", false, "", 0, ""}, + {"ws://host/a\"b", false, "", 0, ""}, + {"ws://host/caf\xC3\xA9", false, "", 0, ""}, + {"ws://host/%2", false, "", 0, ""}, + {"ws://host/%zz", false, "", 0, ""}, + {"ws://host/%g0", false, "", 0, ""}, + {"ws://host/%0g", false, "", 0, ""}, + {"ws://ho\r\nst/p", false, "", 0, ""}, + // A percent-encoding in a host outside brackets, which no one would decode. + // Control: in the path, it stays as written (above) + {"ws://h%20st/p", false, "", 0, ""}, + {"ws://h%41st/p", false, "", 0, ""}, + {"ws://1.2.3.4%251/p", false, "", 0, ""}, + {"ws://ho st/p", false, "", 0, ""}, + {"ws://[::1\r\n]/p", false, "", 0, ""}, + {"ws://[::1 ]/p", false, "", 0, ""}, + }; + for (const Row& row : rows) { + SCOPED_TRACE(row.url); + const std::optional target = parse_ws_url(row.url); + ASSERT_EQ(target.has_value(), row.valid); + if (row.valid) { + EXPECT_EQ(target->host, row.host); + EXPECT_EQ(target->port, row.port); + EXPECT_EQ(target->path, row.path); + EXPECT_TRUE(target->addresses.empty()); + } + } +} + +// ============================================================================== +// Socket errors +// ============================================================================== + +// A socket call's errno as both transports report it: the host's for an Asio system error +// (PeerResetIsReportedAsPeerClosed), the ESP-IDF transport's for its sends, waits and reads +TEST(TransportErrno, MapsASocketErrorToItsCloseReason) { + struct Row { + const char* name; + int error; + CloseReason reason; + }; + const Row rows[] = { + {"connection reset", ECONNRESET, CloseReason::PEER_CLOSED}, + {"connection aborted", ECONNABORTED, CloseReason::PEER_CLOSED}, + {"broken pipe", EPIPE, CloseReason::PEER_CLOSED}, + {"not connected", ENOTCONN, CloseReason::PEER_CLOSED}, + {"Control: timed out", ETIMEDOUT, CloseReason::FAILED}, + {"Control: host unreachable", EHOSTUNREACH, CloseReason::FAILED}, + {"Control: bad descriptor", EBADF, CloseReason::FAILED}, + {"Control: no error", 0, CloseReason::FAILED}, + }; + for (const Row& row : rows) { + SCOPED_TRACE(row.name); + EXPECT_EQ(close_reason_for_errno(row.error), row.reason); + } +} + +} // namespace