From 0dd6be1b90c57dd6ea0b5f985029f9658a943921 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 15 Oct 2026 16:16:44 -0400 Subject: [PATCH] Compile only the white-box tests without access control Each test file that reached private members compiled whole with -fno-access-control. The tests that need it now sit in test_*_internals.cpp files of their own, beside the class's other tests, which compile with access control; the lines compiled without it fall from about 9.7k to 3.5k. Each flagged file's reason in tests/CMakeLists.txt names every private member it reaches. The three copies of the loop that wrapped a group's next pipeline job are one helper, group_job.h's wrap_job(), which yields between tries. Fixtures and harnesses a class's two files share move into headers, and hex.h replaces the hex helpers copied across the crypto tests. Two tests that reached a private member for no essential reason now use the public API instead. --- .claude/skills/test-standards/SKILL.md | 2 +- CLAUDE.md | 4 +- docs/conventions.md | 2 +- src/ORIGIN.md | 2 +- tests/CMakeLists.txt | 160 +++-- tests/gate.h | 47 ++ tests/group_job.h | 56 ++ tests/group_pipelines.h | 222 +++++++ tests/hex.h | 67 ++ tests/host_view_fixture.h | 91 +++ tests/session_harness.h | 158 +++++ tests/test_audio_workers.cpp | 521 +-------------- tests/test_audio_workers_internals.cpp | 525 +++++++++++++++ tests/test_crypto.cpp | 13 +- tests/test_dispatcher.cpp | 33 +- tests/test_dispatcher_internals.cpp | 56 ++ tests/test_group_audio.cpp | 412 +----------- tests/test_group_audio_internals.cpp | 281 ++++++++ tests/test_host_view.cpp | 455 +------------ tests/test_host_view_internals.cpp | 438 +++++++++++++ tests/test_live_slew.cpp | 28 - tests/test_live_slew_internals.cpp | 64 ++ tests/test_noise_initiator.cpp | 264 +------- tests/test_noise_initiator_internals.cpp | 272 ++++++++ tests/test_pairing_crypto.cpp | 126 +--- tests/test_pairing_crypto_internals.cpp | 127 ++++ tests/test_server_core.cpp | 766 +--------------------- tests/test_server_core_internals.cpp | 796 +++++++++++++++++++++++ tests/test_server_raw_client.cpp | 4 +- tests/test_server_trust.cpp | 775 +--------------------- tests/test_server_trust_internals.cpp | 481 ++++++++++++++ tests/test_session.cpp | 337 +--------- tests/test_session_driver.cpp | 138 +--- tests/test_session_driver_internals.cpp | 172 +++++ tests/test_session_internals.cpp | 255 ++++++++ tests/trust_harness.h | 395 +++++++++++ 36 files changed, 4663 insertions(+), 3882 deletions(-) create mode 100644 tests/gate.h create mode 100644 tests/group_job.h create mode 100644 tests/group_pipelines.h create mode 100644 tests/hex.h create mode 100644 tests/host_view_fixture.h create mode 100644 tests/session_harness.h create mode 100644 tests/test_audio_workers_internals.cpp create mode 100644 tests/test_dispatcher_internals.cpp create mode 100644 tests/test_group_audio_internals.cpp create mode 100644 tests/test_host_view_internals.cpp create mode 100644 tests/test_live_slew_internals.cpp create mode 100644 tests/test_noise_initiator_internals.cpp create mode 100644 tests/test_pairing_crypto_internals.cpp create mode 100644 tests/test_server_core_internals.cpp create mode 100644 tests/test_server_trust_internals.cpp create mode 100644 tests/test_session_driver_internals.cpp create mode 100644 tests/test_session_internals.cpp create mode 100644 tests/trust_harness.h diff --git a/.claude/skills/test-standards/SKILL.md b/.claude/skills/test-standards/SKILL.md index 5b11382..05209d9 100644 --- a/.claude/skills/test-standards/SKILL.md +++ b/.claude/skills/test-standards/SKILL.md @@ -32,7 +32,7 @@ Back every claim with a command: a "checked and clean" statement, a mutation res ## No test seams in production - `src/` and `include/` acquire no friends, test-only hooks, widened visibility, extra template parameters, injectable clocks or transports, or fixture-aware naming for a test. The fix is extraction, a test-side technique, or a named gap. -- Allowed test-side techniques: tests include private headers from `src/` and construct internal classes directly (`HostView`, `GroupAudio`, `Session`); a test file that reaches private members compiles with `-fno-access-control`, set on that file in `tests/CMakeLists.txt` beside a comment naming each member it reaches and why. A test that reaches a private member the comment does not name is a finding. +- Allowed test-side techniques: tests include private headers from `src/` and construct internal classes directly (`HostView`, `GroupAudio`, `Session`); a test file that reaches private members compiles with `-fno-access-control`, set on that file in `tests/CMakeLists.txt` beside a comment naming each member it reaches and why. A test that reaches a private member the comment does not name is a finding, and so is a test that needs no private member in a file compiled with the flag when the class has a file without it. ## Mutation survival diff --git a/CLAUDE.md b/CLAUDE.md index ce6d37d..ecda384 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, 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/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. @@ -124,4 +124,4 @@ All pinned; see `cmake/`. - `docs/comment-style.md` for every comment; ASCII punctuation only (no em or en dashes). - Apache 2.0 license header on every source file. - clang-format (v18, `.clang-format`), clang-tidy (`.clang-tidy`), markdownlint (`.markdownlint.yaml`); hooks in `.pre-commit-config.yaml`. -- Tests are host-only and white-box. A test file that reaches private members compiles with `-fno-access-control`, set per file in `tests/CMakeLists.txt` with the reason; never add a seam to production code. +- Tests are host-only and white-box. A test file that reaches private members compiles with `-fno-access-control`, set per file in `tests/CMakeLists.txt` with the reason; such tests go in a `test_*_internals.cpp` file of their own, so a class's other tests compile without it. Never add a seam to production code. diff --git a/docs/conventions.md b/docs/conventions.md index e1d196a..e7d3a58 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -78,7 +78,7 @@ The design standards that code in this repository is held to. This document is n ## Testing - **Host-only and white-box.** Tests live in `tests/`, include private headers from `src/`, and run under ASan/UBSan and, in their own build, ThreadSanitizer; never run the two suites at once. -- **No seams in production.** `src/` and `include/` acquire nothing for tests: no friends, test-only hooks, widened visibility, extra template parameters, injectable clocks or transports, or fixture-aware naming. Hard-to-reach logic is extracted into a pure function and tested directly. A test that must reach a private member compiles with `-fno-access-control`, set on that file in `tests/CMakeLists.txt` beside a comment naming every member it reaches and why. +- **No seams in production.** `src/` and `include/` acquire nothing for tests: no friends, test-only hooks, widened visibility, extra template parameters, injectable clocks or transports, or fixture-aware naming. Hard-to-reach logic is extracted into a pure function and tested directly. A test that must reach a private member compiles with `-fno-access-control`, set on that file in `tests/CMakeLists.txt` beside a comment naming every member it reaches and why; such tests go in a `test_*_internals.cpp` file of their own, so a class's other tests compile without it. - **Each layer has its harness:** pure tables for `src/sched/` and the policy files (seeded where randomized, as `test_sched_property.cpp`); `Session` driven with messages and deadlines and no I/O; the transport and `SessionDriver` over real loopback sockets; the raw test client (`tests/raw_client.h`) for malformed, out-of-order and adversarial peers and for AES-GCM; sendspin-cpp clients in process as real players, whose audio output shows what a player received. Servers listen on port 0 with discovery off (`local_config()`); a peer that cannot report an ephemeral port takes one from `tests/test_ports.h`. - **A test defends a specific line** and fails when that line is deleted or its condition inverted; a test that cannot fail that way is filler and is deleted. - **The unit of a test is a behavior.** A family of related guards is one table whose rows are the branches, with at least one accepting row marked `Control:` beside them; a guard gets its own test only for its own spec citation or failure mode. diff --git a/src/ORIGIN.md b/src/ORIGIN.md index ad41b65..7e2d735 100644 --- a/src/ORIGIN.md +++ b/src/ORIGIN.md @@ -13,4 +13,4 @@ Sources copied or adapted from [sendspin-cpp](https://github.com/Sendspin/sendsp | `crypto/psk_wrap.h`, `crypto/psk_wrap.cpp` | `src/crypto/psk_wrap.h`, `src/crypto/psk_wrap.cpp` | | `fragmentation.h`, `fragmentation.cpp` | The reassembly and fragmentation of `src/noise_transport.cpp` (`NoiseTransport::accept_plaintext()`, `NoiseTransport::fragment_and_send()`) | -The known answers in `tests/test_pairing_crypto.cpp` come from sendspin-cpp's `tests/test_cpace.cpp`, `tests/test_pairing_code.cpp` and `tests/test_psk_wrap.cpp` at the same commit. +The known answers in `tests/test_pairing_crypto.cpp` and `tests/test_pairing_crypto_internals.cpp` come from sendspin-cpp's `tests/test_cpace.cpp`, `tests/test_pairing_code.cpp` and `tests/test_psk_wrap.cpp` at the same commit. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2871b6a..bef2f96 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -14,6 +14,7 @@ add_executable(sendspin_server_cpp_tests test_artwork.cpp test_audio_source.cpp test_audio_workers.cpp + test_audio_workers_internals.cpp test_backoff.cpp test_beast.cpp test_byte_ring.cpp @@ -23,16 +24,20 @@ add_executable(sendspin_server_cpp_tests test_controller.cpp test_crypto.cpp test_dispatcher.cpp + test_dispatcher_internals.cpp test_discovery_policy.cpp test_feeder.cpp test_fragmentation.cpp test_frame_encoder.cpp test_group_audio.cpp + test_group_audio_internals.cpp test_host_view.cpp + test_host_view_internals.cpp test_inbound_hold.cpp test_inbound_ring.cpp test_live_feeder.cpp test_live_slew.cpp + test_live_slew_internals.cpp test_messages.cpp test_memory.cpp test_metadata.cpp @@ -40,9 +45,11 @@ add_executable(sendspin_server_cpp_tests test_micro_resampler.cpp test_noise.cpp test_noise_initiator.cpp + test_noise_initiator_internals.cpp test_one_group.cpp test_pairing.cpp test_pairing_crypto.cpp + test_pairing_crypto_internals.cpp test_pcm_convert.cpp test_peer.cpp test_pipeline_stages.cpp @@ -65,6 +72,7 @@ add_executable(sendspin_server_cpp_tests test_server_accept.cpp test_server_artwork.cpp test_server_core.cpp + test_server_core_internals.cpp test_server_discovery.cpp test_server_limits.cpp test_server_log.cpp @@ -76,9 +84,12 @@ add_executable(sendspin_server_cpp_tests test_server_roles.cpp test_server_source.cpp test_server_trust.cpp + test_server_trust_internals.cpp test_server_visualizer.cpp test_session.cpp test_session_driver.cpp + test_session_driver_internals.cpp + test_session_internals.cpp test_source_clock.cpp test_source_decoder.cpp test_source_sink.cpp @@ -129,75 +140,94 @@ target_include_directories(sendspin_server_cpp_tests PRIVATE ${PROJECT_SOURCE_DI target_compile_definitions(sendspin_server_cpp_tests PRIVATE ${_sendspin_server_profile_definitions}) -# White-box: test_dispatcher.cpp reads Dispatcher::token_ once the thread has ended: a later -# thread given the ended thread's token would show it, but a host need not hand a token out again. -set_source_files_properties(test_dispatcher.cpp PROPERTIES COMPILE_OPTIONS -fno-access-control) -# White-box: test_noise_initiator.cpp fixes the initiator's ephemeral key for the cacophony -# vectors through NoiseInitiator::state_, the only holder of the handshake state, and flips a byte -# of NoiseInitiator::payload_1_ or message_1_ to show the replay refusing a changed message 1. -set_source_files_properties(test_noise_initiator.cpp PROPERTIES COMPILE_OPTIONS -fno-access-control) -# White-box: test_pairing_crypto.cpp fixes a CPace run's scalar through CPace::scalar_ and -# public_share_ to reach the known answers, since start() samples the scalar from the CSPRNG. -set_source_files_properties(test_pairing_crypto.cpp PROPERTIES COMPILE_OPTIONS -fno-access-control) -# White-box: test_host_view.cpp leaves the outbox 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 the input, so a latency slew is -# checked without waiting out the window in real time (under HostView::mutex_ where a writer -# thread runs), and runs a slewed write's steps (HostView::begin_slewed(), write_slewed()) apart, -# so a call between them is checked without a race. It reads HostView::slew_ and next_slew_, as -# only their storage shows which slew a write left for core to free. It calls -# HostView::deliver_outbox() and reads the outbox's capacity, as only its storage shows that a -# delivery left it reusable. -set_source_files_properties(test_host_view.cpp PROPERTIES COMPILE_OPTIONS -fno-access-control) -# White-box: test_live_slew.cpp reads LiveSlew::asrc_ to show an ASRC that refused is kept, not -# freed in the write it refused in; the slew no longer runs it, so nothing else shows it. -set_source_files_properties(test_live_slew.cpp PROPERTIES COMPILE_OPTIONS -fno-access-control) -# White-box: test_group_audio.cpp swaps an instance's encoder (GroupAudio::instances_) for one that +# 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). +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 +# given the ended thread's token would show it, but a host need not hand a token out again. +set_source_files_properties(test_dispatcher_internals.cpp PROPERTIES COMPILE_OPTIONS + -fno-access-control) +# test_group_audio_internals.cpp swaps an instance's encoder (GroupAudio::instances_) for one that # refuses, the only way to show a pipeline failure: no format the server selects makes an encoder -# or the resampler refuse. It calls GroupAudio::cut_to() on its own to extend a short stretch, -# which the stages never give, and sets HostView::p_pause_ to resume from a chosen position, which -# pause() takes from the time. It reads HostView::rebase_latency_us_, the `L_p` the pipelines -# publish for admission, which only shows in admission's bounds. -set_source_files_properties(test_group_audio.cpp PROPERTIES COMPILE_OPTIONS -fno-access-control) -# White-box: test_session.cpp queues a control message behind the initial server/activate through -# Session::queue_json(), since the server queues no other control message there. It reads -# Session::scratch_, the buffer_ of Session::reassembler_ and of a Reassembler of its own to their -# capacity to show a pairing message's PSK, or a longer message's bytes, wiped from them, and -# reads the capacity of the first two to show a longer message grew them, which nothing the session -# sends or reports shows. -# test_server_core.cpp reads ServerCore::targets_ (a target's phase: ServerCore::RetryArmed, a -# PendingDial) to show a dial waiting out its backoff, and a lost instance's target gone with its -# wait, and a dial that waited not started once no longer due, -# which only the absence of a dial would otherwise show, calls ServerCore::prune_targets() with a chosen -# time, and shortens HandshakeBackoff::next_dial_us_ so the deferred dial needs no full wait; it -# also wraps a group's pipeline job (Group::audio_, AudioWorkers::Task::job_, replaced under the -# pool's mutex between two jobs) with a step that swaps its encoder (GroupAudio::instances_) for -# one that refuses on the group audio thread, to show core's handling of a pipeline failure. It -# finds a connection in ServerCore::clients_ and, once its SessionDriver::writing_ and waiting_ +# or the resampler refuse, and counts the instances configured there to show a job builds one +# resampler. It calls GroupAudio::cut_to() on a GroupAudio::Instance of its own to extend a short +# stretch, which the stages never give, and sets HostView::p_pause_ to resume from a chosen +# position, which pause() takes from the time. It reads HostView::rebase_latency_us_, the `L_p` +# 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_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 +# the input, so a latency slew is checked without waiting out the window in real time (under +# HostView::mutex_ where a writer thread runs), and runs a slewed write's steps +# (HostView::begin_slewed(), its HostView::SlewedWrite, write_slewed()) apart, so a call between +# them is checked without a race. It reads HostView::slew_ and next_slew_, as only their storage +# shows which slew a write left for core to free. It calls HostView::deliver_outbox() and reads +# the outbox's capacity, as only its storage shows that a delivery left it reusable. +set_source_files_properties(test_host_view_internals.cpp PROPERTIES COMPILE_OPTIONS + -fno-access-control) +# test_live_slew_internals.cpp reads LiveSlew::asrc_ to show an ASRC that refused is kept, not +# freed in the write it refused in; the slew no longer runs it, so nothing else shows it. +set_source_files_properties(test_live_slew_internals.cpp PROPERTIES COMPILE_OPTIONS + -fno-access-control) +# test_noise_initiator_internals.cpp fixes the initiator's ephemeral key for the cacophony vectors +# through NoiseInitiator::state_, the only holder of the handshake state, and flips a byte of +# NoiseInitiator::payload_1_ or message_1_ to show the replay refusing a changed message 1. +set_source_files_properties(test_noise_initiator_internals.cpp PROPERTIES COMPILE_OPTIONS + -fno-access-control) +# test_pairing_crypto_internals.cpp fixes a CPace run's scalar through CPace::scalar_ and +# public_share_ to reach the known answers, since start() samples the scalar from the CSPRNG. +set_source_files_properties(test_pairing_crypto_internals.cpp PROPERTIES COMPILE_OPTIONS + -fno-access-control) +# test_server_core_internals.cpp reads ServerCore::targets_ (a ServerCore::Target's phase: +# ServerCore::RetryArmed, a PendingDial) to show a dial waiting out its backoff, and a lost +# instance's target gone with its wait, and a dial that waited not started once no longer due, +# which only the absence of a dial would otherwise show; it calls ServerCore::prune_targets() with +# a chosen time and ServerCore::dial_if_due() on a target, and shortens +# HandshakeBackoff::next_dial_us_ so the deferred dial needs no full wait. It finds the group in +# ServerCore::groups_ and wraps its pipeline job (Group::audio_) with group_job.h, with a step that +# swaps the encoders of its pipelines (GroupAudio::instances_) for ones that refuse, on the group +# audio thread, to show core's handling of a pipeline failure. It finds a connection (a +# ServerCore::Client) in ServerCore::clients_ and, once its SessionDriver::writing_ and waiting_ # show no write outstanding, so that its end closes it at once, caps its control FIFO at what it # holds (Session::queues_, SendQueues::caps_ and control_bytes_), so the next message queued, # inside a group's move or stop, overflows it: no client can time the FIFO's last free byte. -# test_session_driver.cpp reads SessionDriver::writing_ and waiting_ and the session's fragment -# progress to send a goodbye while a write is stalled, and writing_ again at the close to tell -# which end closed, states nothing else shows; ActiveDriver::activate() reads writing_ and waiting_ -# to wait until the activation's write has completed, which the client cannot see. -# test_server_trust.cpp sets ServerCore::pairing_psks_, the state an operator-started Pairing PSK -# pairing sets, calls ServerCore::rekey() and handshake_psk(), which pairing drives, and reads -# ServerCore::records_ for a record, which no listener call carries. -# test_audio_workers.cpp wraps a group's pipeline job the same way, to hold it running while core -# erases the group, and reads AudioWorkers::Pool::stopping to let a job finish only once a stop -# has begun, which no observable state shows. 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. -set_source_files_properties(test_session.cpp test_server_core.cpp test_session_driver.cpp - test_server_trust.cpp test_audio_workers.cpp - PROPERTIES COMPILE_OPTIONS -fno-access-control) +set_source_files_properties(test_server_core_internals.cpp PROPERTIES COMPILE_OPTIONS + -fno-access-control) +# test_server_trust_internals.cpp sets ServerCore::pairing_psks_, the state an operator-started +# Pairing PSK pairing sets, calls ServerCore::rekey() and handshake_psk(), which pairing drives, +# and reads ServerCore::records_ for a record, which no listener call carries. +set_source_files_properties(test_server_trust_internals.cpp PROPERTIES COMPILE_OPTIONS + -fno-access-control) +# test_session_driver_internals.cpp reads SessionDriver::writing_ and waiting_ and the session's +# fragment progress (Session::queues_, SendQueues::fragmenting_) to send a goodbye while a write is +# stalled, and writing_ again at the close to tell which end closed, states nothing else shows. +set_source_files_properties(test_session_driver_internals.cpp PROPERTIES COMPILE_OPTIONS + -fno-access-control) +# test_session_internals.cpp reads Session::scratch_, the buffer_ of Session::reassembler_ and of a +# Reassembler of its own to their capacity to show a pairing message's PSK, or a longer message's +# bytes, wiped from them, and reads the capacity of the first two to show a longer message grew +# them, which nothing the session sends or reports shows. +set_source_files_properties(test_session_internals.cpp PROPERTIES COMPILE_OPTIONS + -fno-access-control) # sendspin_server_cpp_beast last: a system Boost's include directory (e.g. /opt/homebrew/include) must # not shadow the fetched dependencies' headers. diff --git a/tests/gate.h b/tests/gate.h new file mode 100644 index 0000000..c360b1f --- /dev/null +++ b/tests/gate.h @@ -0,0 +1,47 @@ +// 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 gate.h +/// @brief Gate, which a job waits at until the test opens it + +#pragma once + +#include + +namespace sendspin::server::test { + +/// @brief A gate a job waits at until the test opens it, and which tells the test the job reached +/// it +class Gate { +public: + /// On the job's thread + void pass() { + this->reached_.set_value(); + this->open_.get_future().wait(); + } + + void wait_reached() { + this->reached_.get_future().wait(); + } + + void open() { + this->open_.set_value(); + } + +private: + std::promise reached_; + std::promise open_; +}; + +} // namespace sendspin::server::test diff --git a/tests/group_job.h b/tests/group_job.h new file mode 100644 index 0000000..d63f575 --- /dev/null +++ b/tests/group_job.h @@ -0,0 +1,56 @@ +// 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 group_job.h +/// @brief White-box: a group's pipeline job wrapped between two runs, and the pipelines it runs. +/// Only a test file compiled with -fno-access-control (tests/CMakeLists.txt) includes it. + +#pragma once + +#include "audio_workers.h" +#include "group.h" +#include "group_audio.h" + +#include +#include +#include +#include + +namespace sendspin::server::test { + +/// @brief Once task is between runs, replaces its job with wrap(job), so the next wake runs the +/// wrapped job. Between runs means neither running nor queued (AudioWorkers::Task::running_ and +/// queued_, read under AudioWorkers::Pool::mutex), so a run already woken runs the job unwrapped. +/// Yields between tries. +/// @param wrap takes the job (Task::job_) and returns the job that replaces it +template +void wrap_job(AudioWorkers::Task& task, Wrap wrap) { + for (;;) { + { + const std::lock_guard lock(task.pool_->mutex); + if (!task.running_ && !task.queued_) { + task.job_ = wrap(std::move(task.job_)); + return; + } + } + std::this_thread::yield(); + } +} + +/// @brief The pipelines a group's job runs (Group::PipelineJob::audio), from wrap_job()'s wrap +inline std::shared_ptr pipelines_of(const std::function& job) { + return job.target()->audio; +} + +} // namespace sendspin::server::test diff --git a/tests/group_pipelines.h b/tests/group_pipelines.h new file mode 100644 index 0000000..7278800 --- /dev/null +++ b/tests/group_pipelines.h @@ -0,0 +1,222 @@ +// 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 group_pipelines.h +/// @brief Pipelines, a group's pipelines over an input of any format and kind, with their jobs and +/// the FIFO's handler run on the test thread, and what the tests read of their output: frames, +/// samples, a reference resampling, a test tone + +#pragma once + +#include "clock.h" +#include "dispatcher.h" +#include "group_audio.h" +#include "host_view.h" +#include "resampler_reference.h" +#include "transport.h" +#include "transport_asio.h" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sendspin::server::test { + +/// Each instance's frames among events, in order +inline std::map> frames_by_instance( + const std::vector& events) { + std::map> out; + for (const GroupEvent& event : events) { + if (const auto* ready = std::get_if(&event)) { + out[ready->instance].push_back(ready->frame); + } + } + return out; +} + +/// A group's pipelines over an input of any format and kind +struct Pipelines { + /// The group lead `G` + static constexpr int64_t LEAD_US = 300000; + + explicit Pipelines(const InputFormat& format, int64_t write_ahead_us = 1000000) + : Pipelines(InputOptions{format}, write_ahead_us) {} + + explicit Pipelines(const InputOptions& options, int64_t write_ahead_us = 1000000) + : view(std::make_shared(GroupId{1}, InputId{1}, options, + GroupParams{LEAD_US, 0, write_ahead_us}, + std::make_shared(), nullptr)), + fifo(this->loop, [this](GroupEvent& event) { this->events.push_back(event); }), + audio(this->view, this->fifo.producer(), this->slot, ResamplerQuality::MEDIUM) {} + + /// @param late_us how far past now_us() the jobs run + std::vector run(int64_t late_us = 0) { + while (this->audio.run_job(now_us() + late_us)) {} + this->loop.impl().io().restart(); + this->loop.impl().io().poll(); + return std::exchange(this->events, {}); + } + + EventLoop loop; + std::shared_ptr view; + std::vector events; + GroupToCoreFifo fifo; + std::shared_ptr slot = std::make_shared(); + GroupAudio audio; +}; + +/// A little-endian signed sample of bytes bytes +inline int32_t sample_at(const std::vector& payload, size_t index, size_t bytes) { + uint32_t value = 0; + for (size_t i = 0; i < bytes; ++i) { + value |= uint32_t{payload[(index * bytes) + i]} << (8 * i); + } + const uint32_t sign = 1U << ((8 * bytes) - 1); + return static_cast(value ^ sign) - static_cast(sign); +} + +/// Every frame's samples of one instance, concatenated +inline std::vector concatenated(const std::vector& frames) { + std::vector out; + for (const sched::Frame& frame : frames) { + out.insert(out.end(), frame.payload.begin(), frame.payload.end()); + } + return out; +} + +/// micro-resampler run directly, as the pipelines configure it, over a whole stream: primed with +/// the first block, fed in blocks of block frames, flushed, then cut to `round(N * out / in)` +inline std::vector resample_offline(const std::vector& input, uint32_t channels, + uint32_t in_rate, uint32_t out_rate, size_t block) { + auto resampler = std::make_unique(in_rate, out_rate, channels, block); + EXPECT_TRUE(resampler->valid()); + const size_t frames = input.size() / channels; + const size_t capacity = (block * out_rate / in_rate) + 2; + std::vector scratch(capacity * channels); + std::vector out; + EXPECT_TRUE(resampler->prime(input.data(), std::min(block, frames))); + for (size_t at = 0; at < frames;) { + size_t consumed = 0; + size_t generated = 0; + EXPECT_TRUE(resampler->process_block(input.data() + (at * channels), + std::min(block, frames - at), scratch.data(), capacity, + consumed, generated)); + out.insert(out.end(), scratch.begin(), + scratch.begin() + static_cast(generated * channels)); + at += consumed; + } + while (!resampler->is_flushed()) { + size_t generated = 0; + EXPECT_TRUE(resampler->flush(scratch.data(), capacity, generated)); + out.insert(out.end(), scratch.begin(), + scratch.begin() + static_cast(generated * channels)); + if (generated == 0) { + break; + } + } + const uint64_t total = ((uint64_t{frames} * 2 * out_rate) + in_rate) / (2 * uint64_t{in_rate}); + EXPECT_GE(out.size(), total * channels); + out.resize(total * channels); + return out; +} + +/// Each instance's InstanceFinal among events +inline std::map final_indices(const std::vector& events) { + std::map out; + for (const GroupEvent& event : events) { + if (const auto* final = std::get_if(&event)) { + out[final->instance] = final->final_index; + } + } + return out; +} + +/// The last cell a drain reports, alone or, staged before the epoch's first job, with its start +inline std::optional drained_last_cell(const std::vector& events) { + for (const GroupEvent& event : events) { + if (const auto* drain = std::get_if(&event)) { + return drain->last_cell; + } + if (const auto* started = std::get_if(&event); started && started->drain) { + return started->drain->last_cell; + } + } + return std::nullopt; +} + +/// The quantize stage's rule for a float at 16 bits: rounded, clipped, no dither +inline int32_t quantize16(float sample) { + const double scaled = std::clamp(static_cast(sample), -2.0, 2.0) * 32768.0; + return static_cast(std::clamp(std::llround(scaled), -32768, 32767)); +} + +/// Two sines a channel at 16 bits, from sample first on, and the same as the stages' floats +struct Tone { + Tone(uint32_t rate, uint32_t channels, uint64_t samples, uint64_t first = 0) + : pcm(samples * channels * 2), floats(samples * channels) { + for (uint64_t i = 0; i < samples; ++i) { + for (uint64_t channel = 0; channel < channels; ++channel) { + const double t = static_cast(first + i) / rate; + const auto value = static_cast( + std::lround((12000.0 * std::sin(2 * M_PI * (440.0 + (110.0 * channel)) * t)) + + (4000.0 * std::sin(2 * M_PI * 3000.0 * t)))); + const auto bits = static_cast(value); + const uint64_t at = (i * channels) + channel; + this->pcm[at * 2] = static_cast(bits); + this->pcm[(at * 2) + 1] = static_cast(bits >> 8); + this->floats[at] = static_cast(value) / 32768.0F; + } + } + } + + std::vector pcm; + std::vector floats; +}; + +/// Every 16-bit sample of out from at on is the reference's, quantized +inline void expect_resampled(const std::vector& out, size_t at, + const std::vector& reference) { + ASSERT_GE(out.size() / 2, at + reference.size()); + for (size_t i = 0; i < reference.size(); ++i) { + ASSERT_EQ(sample_at(out, at + i, 2), quantize16(reference[i])) << i; + } +} + +/// Every event the pipelines post until they post no more +inline std::vector run_all(Pipelines& p) { + std::vector events = p.run(); + for (std::vector more = p.run(); !more.empty(); more = p.run()) { + events.insert(events.end(), more.begin(), more.end()); + } + return events; +} + +/// A live input's options: its write jitter `J_in` +inline InputOptions live_input(const InputFormat& format, int64_t jitter_us) { + InputOptions options{format}; + options.kind = InputKind::LIVE; + options.write_jitter_us = jitter_us; + return options; +} + +} // namespace sendspin::server::test diff --git a/tests/hex.h b/tests/hex.h new file mode 100644 index 0000000..a6a394e --- /dev/null +++ b/tests/hex.h @@ -0,0 +1,67 @@ +// 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 hex.h +/// @brief Lowercase hex text of bytes and bytes of hex text, for the known-answer tests + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sendspin::server::test { + +/// @brief size bytes from data as lowercase hex +inline std::string hex(const uint8_t* data, size_t size) { + std::string out; + for (size_t i = 0; i < size; ++i) { + char byte[3]; + std::snprintf(byte, sizeof(byte), "%02x", data[i]); + out += byte; + } + return out; +} + +/// @brief A contiguous container's bytes as lowercase hex +template +std::string hex(const Bytes& bytes) { + return hex(bytes.data(), bytes.size()); +} + +/// @brief The bytes of hex text; an odd last digit is dropped +inline std::vector from_hex(std::string_view text) { + std::vector out; + for (size_t i = 0; i + 1 < text.size(); i += 2) { + out.push_back(static_cast(std::stoi(std::string(text.substr(i, 2)), nullptr, 16))); + } + return out; +} + +/// @brief The first N bytes of hex text, zero-filled past its end +template +std::array from_hex_array(std::string_view text) { + const std::vector bytes = from_hex(text); + std::array out{}; + std::memcpy(out.data(), bytes.data(), std::min(N, bytes.size())); + return out; +} + +} // namespace sendspin::server::test diff --git a/tests/host_view_fixture.h b/tests/host_view_fixture.h new file mode 100644 index 0000000..1a177d1 --- /dev/null +++ b/tests/host_view_fixture.h @@ -0,0 +1,91 @@ +// 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 host_view_fixture.h +/// @brief HostViewTest, the fixture of a host view of its own, with a recorder and a dispatcher + +#pragma once + +#include "dispatcher.h" +#include "host_view.h" +#include "test_recorder.h" +#include + +#include +#include +#include + +namespace sendspin::server::test { + +/// @brief A host view of its own, made by make(), make_live() or make_slewing() +class HostViewTest : public testing::Test { +public: + static constexpr uint32_t RATE = 48000; + static constexpr int64_t LEAD_US = 300000; + static constexpr int64_t AHEAD_US = 1000000; + static constexpr uint64_t AHEAD_FRAMES = RATE; + static constexpr int64_t LIVE_AHEAD_US = 200000; + /// W_live widened to 3 s, so that one write holds 2 s, which the slew takes enough frames out + /// of to count + static constexpr int64_t SLEW_AHEAD_US = 3000000; + /// A cell of frames + static constexpr uint64_t CELL = RATE / 50; + +protected: + // A posted outbox task holds its view, and the view the dispatcher: the dispatch thread may + // still call the recorder, so it finishes before the members go + ~HostViewTest() override { + this->dispatcher->finish(); + this->dispatcher->join(); + } + + HostView& make(const GroupParams& params = {LEAD_US, 0, AHEAD_US}, + const InputFormat& format = {RATE, 2, SampleFormat::S16}) { + this->view = std::make_shared(GroupId{1}, InputId{2}, InputOptions{format}, + params, this->dispatcher, &this->recorder); + return *this->view; + } + + /// A live input: `W_live` 200 ms, as core writes it + HostView& make_live() { + InputOptions options{{RATE, 2, SampleFormat::S16}}; + options.kind = sendspin::server::InputKind::LIVE; + this->view = std::make_shared(GroupId{1}, InputId{2}, options, + GroupParams{LEAD_US, 0, LIVE_AHEAD_US}, + this->dispatcher, &this->recorder); + return *this->view; + } + + /// A live input with SLEW_AHEAD_US and a least lead + HostView& make_slewing(int64_t min_lead_us) { + InputOptions options{{RATE, 2, SampleFormat::S16}}; + options.kind = sendspin::server::InputKind::LIVE; + this->view = std::make_shared( + GroupId{1}, InputId{2}, options, GroupParams{LEAD_US, 0, SLEW_AHEAD_US}, + this->dispatcher, &this->recorder, sendspin::server::LiveOptions{min_lead_us}); + return *this->view; + } + + WriteResult write(uint64_t frames) { + const std::vector pcm(frames * 4, 1); + return this->view->write(pcm.data(), frames); + } + + Recorder recorder; + std::shared_ptr dispatcher = + std::make_shared(); + std::shared_ptr view; +}; + +} // namespace sendspin::server::test diff --git a/tests/session_harness.h b/tests/session_harness.h new file mode 100644 index 0000000..3cefa52 --- /dev/null +++ b/tests/session_harness.h @@ -0,0 +1,158 @@ +// 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 session_harness.h +/// @brief A Session and the raw test client's NoiseResponder driving it in-process, and the +/// fixture its tests run under for each cipher suite + +#pragma once + +#include "fragmentation.h" +#include "messages.h" +#include "raw_client.h" +#include "session.h" +#include "wire_messages.h" +#include +#include + +#include +#include +#include +#include +#include + +namespace sendspin::server::test { + +/// The session's start, and the receive stamp a message gets by default +inline constexpr int64_t START_US = 1000; +/// Each handshake step's timeout +inline constexpr int64_t TIMEOUT_US = 5000; + +/// The settings a Harness runs with by default +inline SessionSettings test_settings() { + SessionSettings settings; + settings.hello.name = "Test server"; + settings.step_timeout_us = TIMEOUT_US; + return settings; +} + +/// A session and its client. Not movable: the session refers to the identity and settings. +class Harness { +public: + explicit Harness(CipherSuite suite, SessionSettings settings = test_settings(), + SessionTrace* trace = nullptr, + sendspin::server::CredentialSource* credentials = nullptr) + : settings_(std::move(settings)), + session(this->identity, this->settings_, trace, credentials), + client(suite) { + this->session.start(START_US); + } + + Harness(const Harness&) = delete; + Harness& operator=(const Harness&) = delete; + + std::vector take_all() { + std::vector out; + while (std::optional message = this->session.take_message()) { + out.push_back(std::move(*message)); + } + return out; + } + + // Sends client/init and has the client read Noise message 1 under the spec's prologue + void exchange_init(int64_t rx_us = START_US) { + this->client_init = this->client.client_init(); + this->session.on_message(MessageKind::TEXT, bytes_of(this->client_init), rx_us); + ASSERT_EQ(this->session.phase(), SessionPhase::AWAIT_NOISE_MSG2); + const std::vector sent = this->take_all(); + ASSERT_EQ(sent.size(), 2U); + ASSERT_EQ(sent[0].kind, MessageKind::TEXT); + ASSERT_EQ(sent[1].kind, MessageKind::TEXT); + this->server_init = text_of(sent[0].bytes); + this->noise_message_1 = text_of(sent[1].bytes); + ASSERT_TRUE(this->client.read_message_1(bytes_of(this->client_init + this->server_init), + this->server_init, this->noise_message_1)); + } + + // Through Noise message 2 and the server/hello it releases + void complete_handshake(int64_t rx_us = START_US) { + ASSERT_NO_FATAL_FAILURE(this->exchange_init(rx_us)); + this->session.on_message(MessageKind::TEXT, bytes_of(this->client.message_2()), rx_us); + ASSERT_EQ(this->session.phase(), SessionPhase::AWAIT_CLIENT_HELLO); + const std::vector sent = this->take_all(); + ASSERT_EQ(sent.size(), 1U); + ASSERT_EQ(sent[0].kind, MessageKind::BINARY); + this->server_hello = this->client.decrypt(sent[0].bytes); + ASSERT_TRUE(this->server_hello); + } + + // Through client/hello and the initial server/activate, which the client decrypts + void activate() { + ASSERT_NO_FATAL_FAILURE(this->complete_handshake()); + this->send(this->client.encrypt_json(client_hello_json())); + ASSERT_EQ(this->session.phase(), SessionPhase::READY_FOR_ACTIVATION); + this->session.activate(ServerActivate{false, std::vector{}, std::nullopt}); + ASSERT_EQ(this->session.phase(), SessionPhase::ACTIVATING); + const std::vector sent = this->take_all(); + ASSERT_EQ(sent.size(), 1U); + ASSERT_EQ(this->session.phase(), SessionPhase::ACTIVE); + EXPECT_EQ(this->decrypt_json(sent[0]), + R"({"type":"server/activate","payload":{"activities":[],"active_roles":[]}})"); + } + + // The JSON a binary server message carries; empty when it is not one + std::string decrypt_json(const OutboundMessage& message) { + EXPECT_EQ(message.kind, MessageKind::BINARY); + const std::optional plaintext = this->client.decrypt(message.bytes); + if (!plaintext || plaintext->empty() || plaintext->front() != wire::JSON_ID) { + ADD_FAILURE() << "not an encrypted JSON message"; + return {}; + } + return {plaintext->begin() + 1, plaintext->end()}; + } + + void send(const Bytes& message, int64_t rx_us = START_US) { + this->session.on_message(MessageKind::BINARY, message, rx_us); + } + + // A fragment of a message, encrypted + Bytes fragment(uint8_t flags, const Bytes& data) { + Bytes plaintext{sendspin::server::MESSAGE_ID_FRAGMENT, flags}; + plaintext.insert(plaintext.end(), data.begin(), data.end()); + return this->client.encrypt(plaintext); + } + + Identity identity{*Identity::generate()}; + +private: + SessionSettings settings_; + +public: + Session session; + NoiseResponder client; + std::string client_init; + std::string server_init; + std::string noise_message_1; + std::optional server_hello; +}; + +/// Each test runs under both cipher suites +class SessionTest : public testing::TestWithParam { +protected: + static void SetUpTestSuite() { + ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); + } +}; + +} // namespace sendspin::server::test diff --git a/tests/test_audio_workers.cpp b/tests/test_audio_workers.cpp index deb2a39..13eea78 100644 --- a/tests/test_audio_workers.cpp +++ b/tests/test_audio_workers.cpp @@ -14,64 +14,21 @@ /// @file test_audio_workers.cpp /// @brief The group audio threads: a task's jobs one at a time and in order, a wake during a job, -/// groups taking turns, a task stopped while its job runs, and a group erased on core while its -/// pipeline job runs. White-box: the last wraps the group's job (see tests/CMakeLists.txt). +/// groups taking turns and a task stopped while its job runs. -#include "artwork_cache.h" #include "audio_workers.h" -#include "clock.h" -#include "dispatcher.h" -#include "group.h" -#include "group_audio.h" -#include "host_view.h" -#include "sched/stalls.h" -#include "test_recorder.h" -#include "transport.h" +#include "gate.h" #include +#include #include -#include -#include #include #include -#include -#include -#include -#include -#include -#include -#include namespace { using sendspin::server::AudioWorkers; - -/// 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 gate a job waits at until the test opens it, and which tells the test the job reached it -class Gate { -public: - /// On the job's thread - void pass() { - this->reached_.set_value(); - this->open_.get_future().wait(); - } - - void wait_reached() { - this->reached_.get_future().wait(); - } - - void open() { - this->open_.set_value(); - } - -private: - std::promise reached_; - std::promise open_; -}; +using sendspin::server::test::Gate; // A wake while the job runs runs it once more after it returns, never alongside it; two wakes then // are one more run, not two @@ -190,474 +147,4 @@ TEST(AudioWorkers, StopsATaskWhileItsJobRuns) { EXPECT_TRUE(job_state.expired()); } -// 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 -// else shows. -TEST(AudioWorkers, DropsQueuedJobsWhenStopped) { - auto workers = std::make_unique(1); - Gate gate; - std::atomic waiting_runs{0}; - const std::shared_ptr blocker = workers->add([&] { - gate.pass(); - return false; - }); - const std::shared_ptr waiting = workers->add([&] { - ++waiting_runs; - return false; - }); - blocker->wake(); - gate.wait_reached(); - waiting->wake(); - std::thread stopper([&] { workers->stop(); }); - for (bool stopping = false; !stopping;) { - const std::lock_guard lock(workers->pool_->mutex); - stopping = workers->pool_->stopping; - } - gate.open(); - stopper.join(); - waiting->wake(); - EXPECT_EQ(waiting_runs.load(), 0); - // A task the stop took off the queue can still be stopped - waiting->stop(); - 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}; - for (bool wrapped = false; !wrapped;) { - const std::lock_guard lock(task->pool_->mutex); - if (task->running_ || task->queued_) { - continue; - } - task->job_ = [inner = std::move(task->job_), &gate, &gated_runs]() { - const bool more = inner(); - if (++gated_runs == 1) { - gate.pass(); - } - return more; - }; - wrapped = true; - } - // 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}; - for (bool wrapped = false; !wrapped;) { - const std::lock_guard lock(task->pool_->mutex); - if (task->running_ || task->queued_) { - continue; - } - task->job_ = [inner = std::move(task->job_), &ran, &runs]() { - const bool more = inner(); - if (++runs == 1) { - ran.set_value(); - } - return more; - }; - wrapped = true; - } - 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_audio_workers_internals.cpp b/tests/test_audio_workers_internals.cpp new file mode 100644 index 0000000..a601ea9 --- /dev/null +++ b/tests/test_audio_workers_internals.cpp @@ -0,0 +1,525 @@ +// 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_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). + +#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 +// else shows. +TEST(AudioWorkers, DropsQueuedJobsWhenStopped) { + auto workers = std::make_unique(1); + Gate gate; + std::atomic waiting_runs{0}; + const std::shared_ptr blocker = workers->add([&] { + gate.pass(); + return false; + }); + const std::shared_ptr waiting = workers->add([&] { + ++waiting_runs; + return false; + }); + blocker->wake(); + gate.wait_reached(); + waiting->wake(); + std::thread stopper([&] { workers->stop(); }); + for (;;) { + { + const std::lock_guard lock(workers->pool_->mutex); + if (workers->pool_->stopping) { + break; + } + } + std::this_thread::yield(); + } + gate.open(); + stopper.join(); + waiting->wake(); + EXPECT_EQ(waiting_runs.load(), 0); + // A task the stop took off the queue can still be stopped + waiting->stop(); + 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_crypto.cpp b/tests/test_crypto.cpp index 9a85a62..3a07634 100644 --- a/tests/test_crypto.cpp +++ b/tests/test_crypto.cpp @@ -20,9 +20,9 @@ #include "crypto/crypto.h" #include "crypto/keys.h" #include "freed_blocks.h" +#include "hex.h" #include -#include #include #include #include @@ -38,16 +38,7 @@ using sendspin::server::PEER_ID_SIZE; using sendspin::server::public_key_from_peer_id; using sendspin::server::PublicKey; using sendspin::server::secure_reserve; - -std::string hex(const uint8_t* data, size_t size) { - std::string out; - for (size_t i = 0; i < size; ++i) { - char byte[3]; - std::snprintf(byte, sizeof(byte), "%02x", data[i]); - out += byte; - } - return out; -} +using sendspin::server::test::hex; constexpr std::string_view SECRET = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY"; diff --git a/tests/test_dispatcher.cpp b/tests/test_dispatcher.cpp index 08a8835..79e8def 100644 --- a/tests/test_dispatcher.cpp +++ b/tests/test_dispatcher.cpp @@ -13,12 +13,10 @@ // limitations under the License. /// @file test_dispatcher.cpp -/// @brief Dispatcher::on_thread(): true on the dispatch thread alone, and on no thread once it -/// ended; a task a task posts runs behind those queued before it, and one posted after finish() -/// is dropped. Compiled with -fno-access-control (tests/CMakeLists.txt). +/// @brief Dispatcher's queue: a task a task posts runs behind those queued before it, and one +/// posted after finish() is dropped. #include "dispatcher.h" -#include "thread_setup.h" #include #include @@ -29,33 +27,6 @@ namespace { using sendspin::server::Dispatcher; -/// Whether watched's on_thread() is true on the thread running dispatcher's tasks -bool on_thread_of(Dispatcher& dispatcher, const Dispatcher& watched) { - std::promise seen; - dispatcher.post([&seen, &watched] { seen.set_value(watched.on_thread()); }); - return seen.get_future().get(); -} - -// The dispatch thread alone is on its thread, and no thread is once it ended. A later thread given -// the ended thread's token would show a token left set, but a host need not hand one out again, so -// the test reads the token. -TEST(Dispatcher, KnowsItsThreadWhileItRunsAndNoneOnceItEnded) { - Dispatcher first; - EXPECT_TRUE(on_thread_of(first, first)); - EXPECT_FALSE(first.on_thread()); - { - Dispatcher second; - EXPECT_FALSE(on_thread_of(second, first)); - EXPECT_FALSE(on_thread_of(first, second)); - } - first.finish(); - first.join(); - EXPECT_FALSE(first.on_thread()); - Dispatcher later; - EXPECT_FALSE(on_thread_of(later, first)); - EXPECT_EQ(first.token_.load(), nullptr); -} - // A task posted from a task goes behind the tasks already queued TEST(Dispatcher, RunsATasksPostBehindTheQueue) { Dispatcher dispatcher; diff --git a/tests/test_dispatcher_internals.cpp b/tests/test_dispatcher_internals.cpp new file mode 100644 index 0000000..e29e4b4 --- /dev/null +++ b/tests/test_dispatcher_internals.cpp @@ -0,0 +1,56 @@ +// 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_dispatcher_internals.cpp +/// @brief Dispatcher::on_thread(): true on the dispatch thread alone, and on no thread once it +/// ended. Compiled with -fno-access-control (tests/CMakeLists.txt). + +#include "dispatcher.h" +#include "thread_setup.h" +#include + +#include + +namespace { + +using sendspin::server::Dispatcher; + +/// Whether watched's on_thread() is true on the thread running dispatcher's tasks +bool on_thread_of(Dispatcher& dispatcher, const Dispatcher& watched) { + std::promise seen; + dispatcher.post([&seen, &watched] { seen.set_value(watched.on_thread()); }); + return seen.get_future().get(); +} + +// The dispatch thread alone is on its thread, and no thread is once it ended. A later thread given +// the ended thread's token would show a token left set, but a host need not hand one out again, so +// the test reads the token. +TEST(Dispatcher, KnowsItsThreadWhileItRunsAndNoneOnceItEnded) { + Dispatcher first; + EXPECT_TRUE(on_thread_of(first, first)); + EXPECT_FALSE(first.on_thread()); + { + Dispatcher second; + EXPECT_FALSE(on_thread_of(second, first)); + EXPECT_FALSE(on_thread_of(first, second)); + } + first.finish(); + first.join(); + EXPECT_FALSE(first.on_thread()); + Dispatcher later; + EXPECT_FALSE(on_thread_of(later, first)); + EXPECT_EQ(first.token_.load(), nullptr); +} + +} // namespace diff --git a/tests/test_group_audio.cpp b/tests/test_group_audio.cpp index 860fabf..0a43edc 100644 --- a/tests/test_group_audio.cpp +++ b/tests/test_group_audio.cpp @@ -15,14 +15,14 @@ /// @file test_group_audio.cpp /// @brief A group's pipelines on their own: what they post to core, in order, as the host view /// moves through epochs, through the channel mix and the resampler as well. Jobs and the FIFO's -/// handler run on the test thread. White-box: swaps an instance's encoder to show a pipeline -/// failure, cuts a stretch directly and sets a paused position (see tests/CMakeLists.txt). +/// handler run on the test thread. #include "clock.h" #include "dispatcher.h" #include "flac_reader.h" #include "frame_encoder.h" #include "group_audio.h" +#include "group_pipelines.h" #include "host_view.h" #include "job_view.h" #include "opus_reader.h" @@ -67,15 +67,27 @@ using sendspin::server::InputId; using sendspin::server::InstanceFinal; using sendspin::server::now_us; using sendspin::server::SampleFormat; +using sendspin::server::test::concatenated; +using sendspin::server::test::drained_last_cell; +using sendspin::server::test::expect_resampled; +using sendspin::server::test::final_indices; +using sendspin::server::test::frames_by_instance; using sendspin::server::test::job_view_of; +using sendspin::server::test::live_input; +using sendspin::server::test::Pipelines; +using sendspin::server::test::quantize16; using sendspin::server::test::ReferenceResampler; +using sendspin::server::test::resample_offline; +using sendspin::server::test::run_all; +using sendspin::server::test::sample_at; +using sendspin::server::test::Tone; namespace wire = sendspin::server::test::wire; namespace sched = sendspin::server::sched; using Bytes = std::vector; constexpr uint32_t RATE = 48000; constexpr uint64_t CELL = RATE / 50; -constexpr int64_t LEAD_US = 300000; +constexpr int64_t LEAD_US = Pipelines::LEAD_US; constexpr sched::InstanceId INSTANCE{7}; class GroupAudioTest : public testing::Test { @@ -166,18 +178,6 @@ TEST_F(GroupAudioTest, CutsFramesOnTheCellGridThroughADrain) { EXPECT_EQ(this->run().size(), 0U); } -/// Each instance's frames among events, in order -std::map> frames_by_instance( - const std::vector& events) { - std::map> out; - for (const GroupEvent& event : events) { - if (const auto* ready = std::get_if(&event)) { - out[ready->instance].push_back(ready->frame); - } - } - return out; -} - // A FLAC instance beside a PCM one cuts the same cells at the same timestamps. Its frames, numbered // from 0 in each epoch, decode through one decoder to the PCM instance's samples, which are the // host's, bit for bit: the drained epoch's short last frame included (FLAC finish()). An instance @@ -373,117 +373,6 @@ TEST_F(GroupAudioTest, LeavesThePlainHorizonWhenNoInstanceProduces) { using sendspin::server::PipelineFailed; using sendspin::server::PipelineRequest; -/// A group's pipelines over an input of any format and kind -struct Pipelines { - explicit Pipelines(const InputFormat& format, int64_t write_ahead_us = 1000000) - : Pipelines(sendspin::server::InputOptions{format}, write_ahead_us) {} - - explicit Pipelines(const sendspin::server::InputOptions& options, - int64_t write_ahead_us = 1000000) - : view(std::make_shared(GroupId{1}, InputId{1}, options, - sendspin::server::GroupParams{LEAD_US, 0, write_ahead_us}, - std::make_shared(), - nullptr)), - fifo(this->loop, [this](GroupEvent& event) { this->events.push_back(event); }), - audio(this->view, this->fifo.producer(), this->slot, - sendspin::server::ResamplerQuality::MEDIUM) {} - - /// @param late_us how far past now_us() the jobs run - std::vector run(int64_t late_us = 0) { - while (this->audio.run_job(now_us() + late_us)) {} - this->loop.impl().io().restart(); - this->loop.impl().io().poll(); - return std::exchange(this->events, {}); - } - - sendspin::server::EventLoop loop; - std::shared_ptr view; - std::vector events; - sendspin::server::GroupToCoreFifo fifo; - std::shared_ptr slot = - std::make_shared(); - sendspin::server::GroupAudio audio; -}; - -/// A little-endian signed sample of bytes bytes -int32_t sample_at(const Bytes& payload, size_t index, size_t bytes) { - uint32_t value = 0; - for (size_t i = 0; i < bytes; ++i) { - value |= uint32_t{payload[(index * bytes) + i]} << (8 * i); - } - const uint32_t sign = 1U << ((8 * bytes) - 1); - return static_cast(value ^ sign) - static_cast(sign); -} - -/// Every frame's samples of one instance, concatenated -Bytes concatenated(const std::vector& frames) { - Bytes out; - for (const sched::Frame& frame : frames) { - out.insert(out.end(), frame.payload.begin(), frame.payload.end()); - } - return out; -} - -/// micro-resampler run directly, as the pipelines configure it, over a whole stream: primed with -/// the first block, fed in blocks of block frames, flushed, then cut to `round(N * out / in)` -std::vector resample_offline(const std::vector& input, uint32_t channels, - uint32_t in_rate, uint32_t out_rate, size_t block) { - auto resampler = std::make_unique(in_rate, out_rate, channels, block); - EXPECT_TRUE(resampler->valid()); - const size_t frames = input.size() / channels; - const size_t capacity = (block * out_rate / in_rate) + 2; - std::vector scratch(capacity * channels); - std::vector out; - EXPECT_TRUE(resampler->prime(input.data(), std::min(block, frames))); - for (size_t at = 0; at < frames;) { - size_t consumed = 0; - size_t generated = 0; - EXPECT_TRUE(resampler->process_block(input.data() + (at * channels), - std::min(block, frames - at), scratch.data(), capacity, - consumed, generated)); - out.insert(out.end(), scratch.begin(), - scratch.begin() + static_cast(generated * channels)); - at += consumed; - } - while (!resampler->is_flushed()) { - size_t generated = 0; - EXPECT_TRUE(resampler->flush(scratch.data(), capacity, generated)); - out.insert(out.end(), scratch.begin(), - scratch.begin() + static_cast(generated * channels)); - if (generated == 0) { - break; - } - } - const uint64_t total = ((uint64_t{frames} * 2 * out_rate) + in_rate) / (2 * uint64_t{in_rate}); - EXPECT_GE(out.size(), total * channels); - out.resize(total * channels); - return out; -} - -/// Each instance's InstanceFinal among events -std::map final_indices(const std::vector& events) { - std::map out; - for (const GroupEvent& event : events) { - if (const auto* final = std::get_if(&event)) { - out[final->instance] = final->final_index; - } - } - return out; -} - -/// The last cell a drain reports, alone or, staged before the epoch's first job, with its start -std::optional drained_last_cell(const std::vector& events) { - for (const GroupEvent& event : events) { - if (const auto* drain = std::get_if(&event)) { - return drain->last_cell; - } - if (const auto* started = std::get_if(&event); started && started->drain) { - return started->drain->last_cell; - } - } - return std::nullopt; -} - /// One letter per event, as GroupAudioTest::kinds() std::string kinds_of(const std::vector& events) { std::string out; @@ -493,12 +382,6 @@ std::string kinds_of(const std::vector& events) { return out; } -/// The quantize stage's rule for a float at 16 bits: rounded, clipped, no dither -int32_t quantize16(float sample) { - const double scaled = std::clamp(static_cast(sample), -2.0, 2.0) * 32768.0; - return static_cast(std::clamp(std::llround(scaled), -32768, 32767)); -} - // From a 44.1 kHz stereo input, every instance cuts the same cells at the same timestamps: 882 // samples a cell at 44.1 kHz and 960 at 48 kHz, as PCM or FLAC, stereo or mixed to mono. 120,406 // input samples (2.7 s, which either profile's staging ring holds) drained give 136 full cells and @@ -623,29 +506,6 @@ TEST(GroupAudioPipelines, CutEveryFormatOnTheCellGridThroughADrain) { } } -/// Two sines a channel at 16 bits, from sample first on, and the same as the stages' floats -struct Tone { - Tone(uint32_t rate, uint32_t channels, uint64_t samples, uint64_t first = 0) - : pcm(samples * channels * 2), floats(samples * channels) { - for (uint64_t i = 0; i < samples; ++i) { - for (uint64_t channel = 0; channel < channels; ++channel) { - const double t = static_cast(first + i) / rate; - const auto value = static_cast( - std::lround((12000.0 * std::sin(2 * M_PI * (440.0 + (110.0 * channel)) * t)) + - (4000.0 * std::sin(2 * M_PI * 3000.0 * t)))); - const auto bits = static_cast(value); - const uint64_t at = (i * channels) + channel; - this->pcm[at * 2] = static_cast(bits); - this->pcm[(at * 2) + 1] = static_cast(bits >> 8); - this->floats[at] = static_cast(value) / 32768.0F; - } - } - } - - Bytes pcm; - std::vector floats; -}; - /// Each frame of one instance: its index steps by cells from 0, its timestamp is its cell's on /// the anchor, and every frame but the last holds full samples /// @return the frames' samples, concatenated @@ -662,14 +522,6 @@ Bytes expect_on_grid(const std::vector& frames, const sched::Ancho return concatenated(frames); } -/// Every 16-bit sample of out from at on is the reference's, quantized -void expect_resampled(const Bytes& out, size_t at, const std::vector& reference) { - ASSERT_GE(out.size() / 2, at + reference.size()); - for (size_t i = 0; i < reference.size(); ++i) { - ASSERT_EQ(sample_at(out, at + i, 2), quantize16(reference[i])) << i; - } -} - // At 11,025 Hz a frame spans two cells, 441 samples, and starts on the even cells, as PCM and as // FLAC; 11,325 samples drained end in cell 51, so the last frame starts at cell 50 with the 300 // left. The PCM frames are the input, bit for bit, and the FLAC frames decode to them. The same @@ -798,15 +650,6 @@ TEST(GroupAudioPipelines, RunsEachSegmentThroughTheStagesItNeeds) { EXPECT_EQ(finals.at(at11), 74); } -/// Every event the pipelines post until they post no more -std::vector run_all(Pipelines& p) { - std::vector events = p.run(); - for (std::vector more = p.run(); !more.empty(); more = p.run()) { - events.insert(events.end(), more.begin(), more.end()); - } - return events; -} - /// The frames of one instance from index on, by index std::map payloads_from(const std::vector& frames, int64_t index) { std::map out; @@ -924,60 +767,6 @@ TEST(GroupAudioPipelines, CrossesABoundaryOnNoCell) { expect_resampled(out44, size_t{44643} * 2, second44); } -// A resume whose paused position lies in a later segment starts the new epoch in that segment's -// format: one inside the 48 kHz segment, and one exactly on its first position. The 48 kHz -// instance's frames are the input from the paused position, bit for bit; on the boundary the 44.1 -// kHz instance has no history in the segment, so it is micro-resampler's output over the segment -// from its start, primed, sample for sample -TEST(GroupAudioPipelines, ResumesInALaterSegment) { - struct Row { - const char* name; - uint64_t paused; - }; - const std::array rows{{{"inside the segment", 44100 + 9600}, {"on its start", 44100}}}; - const Tone first(44100, 2, 44100); - const Tone second(48000, 2, 48000, 48000); - for (const Row& row : rows) { - SCOPED_TRACE(row.name); - Pipelines p(InputFormat{44100, 2, SampleFormat::S16}, 10000000); - const sched::InstanceId at48{190}; - const sched::InstanceId at44{191}; - p.slot->publish({{at48, {}, {sched::Codec::PCM, 48000, 2, 16}, 0}, - {at44, {}, {sched::Codec::PCM, 44100, 2, 16}, 0}}); - ASSERT_EQ(p.view->write(first.pcm.data(), 44100).frames, 44100U); - ASSERT_TRUE(p.view->set_format({48000, 2, SampleFormat::S16})); - ASSERT_EQ(p.view->write(second.pcm.data(), 48000).frames, 48000U); - run_all(p); - p.view->pause(); - // White-box: the paused position, which pause() takes from the time - p.view->p_pause_ = row.paused; - run_all(p); - p.view->resume(); - p.view->drain(); - const std::vector events = run_all(p); - const auto started = std::find_if(events.begin(), events.end(), [](const GroupEvent& e) { - return std::holds_alternative(e); - }); - ASSERT_NE(started, events.end()); - const sched::Anchor& anchor = std::get(*started).anchor; - EXPECT_EQ(anchor.segments.front().position, row.paused); - EXPECT_EQ(anchor.segments.front().rate, 48000U); - std::map> frames = frames_by_instance(events); - - const Bytes out48 = concatenated(frames[at48]); - const size_t skipped = (row.paused - 44100) * 4; - ASSERT_EQ(out48.size(), second.pcm.size() - skipped); - EXPECT_TRUE(std::equal(out48.begin(), out48.end(), second.pcm.begin() + skipped)); - if (row.paused == 44100) { - const Bytes out44 = concatenated(frames[at44]); - const std::vector reference = - resample_offline(second.floats, 2, 48000, 44100, 960); - ASSERT_EQ(out44.size() / 2, reference.size()); - expect_resampled(out44, 0, reference); - } - } -} - /// One seeded run of GroupAudioProperty: input segments of random rates, channel counts and /// lengths, written in random blocks with a set_format() between them, then drained, through /// three instances of random formats, the last requested from a random cell of the epoch, on a @@ -1146,53 +935,6 @@ TEST(GroupAudioProperty, KeepsEveryInstanceOnTheGridAcrossSegments) { } } -// A stretch's output is cut or extended with its last sample to the boundary's output index; a -// warm-up's samples, below the next cell's first, are counted but never kept; an extension past two -// samples, which no stage gives, is refused and changes nothing. The stages give no short stretch -// through the property test above, so the extension is reached directly -TEST(GroupAudioPipelines, CutsOrExtendsAStretchToItsBoundaryCount) { - struct Row { - const char* name; - int64_t produced; - size_t pending; - int64_t total; - bool accepted; - size_t kept; - size_t extended; - }; - // Mono 16-bit samples of 1 and a last sample of 7; cell 1's first output sample is 882 - const std::array rows{{ - {"cut", 900, 18, 895, true, 13, 0}, - {"extended", 900, 18, 902, true, 18, 2}, - {"extended past a warm-up", 881, 0, 883, true, 0, 1}, - {"refused", 900, 18, 903, false, 18, 0}, - // Control: at the boundary already - {"exact", 900, 18, 900, true, 18, 0}, - }}; - Pipelines p(InputFormat{44100, 1, SampleFormat::S16}); - for (const Row& row : rows) { - SCOPED_TRACE(row.name); - // White-box: an instance with only what the cut reads - sendspin::server::GroupAudio::Instance instance; - instance.cells = 1; - instance.frame_samples = 882; - instance.sample_bytes = 2; - instance.next = 1; - instance.produced = row.produced; - instance.pending.assign(row.pending * 2, 0); - for (size_t i = 0; i < row.pending; ++i) { - instance.pending[2 * i] = 1; - } - instance.last_sample = {7, 0}; - EXPECT_EQ(p.audio.cut_to(instance, row.total), row.accepted); - EXPECT_EQ(instance.produced, row.accepted ? row.total : row.produced); - ASSERT_EQ(instance.pending.size(), (row.kept + row.extended) * 2); - for (size_t i = 0; i < row.kept + row.extended; ++i) { - EXPECT_EQ(instance.pending[2 * i], i < row.kept ? 1 : 7) << i; - } - } -} - // A mono input reaches a stereo player in both channels TEST_F(GroupAudioTest, DuplicatesAMonoInputIntoEveryChannel) { constexpr sched::InstanceId STEREO{30}; @@ -1511,79 +1253,10 @@ TEST(GroupAudioPipelines, LiftsTheHoldAtADrainShortOfTheReach) { EXPECT_EQ(finals.at(resampled), 2); } -// A job builds at most one resampler, whose filter bank is the costly allocation, and reports more -// work while another waits; an instance with none, listed after one that waits, is built at once -TEST(GroupAudioPipelines, BuildsOneResamplerPerJob) { - Pipelines p(InputFormat{44100, 2, SampleFormat::S16}); - p.slot->publish({{sched::InstanceId{120}, {}, {sched::Codec::PCM, 48000, 2, 16}, 0}, - {sched::InstanceId{122}, {}, {sched::Codec::PCM, 96000, 2, 16}, 0}, - {sched::InstanceId{121}, {}, {sched::Codec::PCM, 44100, 2, 24}, 0}}); - const auto built = [&] { - return std::count_if(p.audio.instances_.begin(), p.audio.instances_.end(), - [](const auto& instance) { return instance.configured; }); - }; - // White-box: one job at a time, before any input - EXPECT_TRUE(p.audio.run_job(now_us())); - EXPECT_EQ(built(), 2); - EXPECT_FALSE(p.audio.run_job(now_us())); - EXPECT_EQ(built(), 3); -} - -// An encoder that refuses fails its instance once, which then produces nothing and no longer holds -// a drain back; the group's other instance carries on through the drain -TEST(GroupAudioPipelines, FailsAnInstanceWhoseEncoderRefuses) { - Pipelines p(InputFormat{44100, 2, SampleFormat::S16}); - const sched::InstanceId native{80}; - const sched::InstanceId resampled{81}; - p.slot->publish({{native, {}, {sched::Codec::PCM, 44100, 2, 16}, 0}, - {resampled, {}, {sched::Codec::PCM, 48000, 2, 16}, 0}}); - const Bytes pcm(6 * 882 * 4, 3); - ASSERT_EQ(p.view->write(pcm.data(), 2 * 882).frames, 2 * 882U); - std::map> frames = frames_by_instance(p.run()); - ASSERT_EQ(frames[resampled].size(), 1U); - // Cell 1 waits for the resampler's lookahead in both instances - ASSERT_EQ(frames[native].size(), 1U); - - // White-box: an encoder whose frame is shorter refuses a 48 kHz frame - auto& instances = p.audio.instances_; - const auto it = std::find_if(instances.begin(), instances.end(), - [&](const auto& instance) { return instance.id == resampled; }); - ASSERT_NE(it, instances.end()); - it->encoder = sendspin::server::FrameEncoder::create({sched::Codec::FLAC, 8000, 2, 16}); - ASSERT_TRUE(it->encoder); - ASSERT_EQ(p.view->write(pcm.data(), 4 * 882).frames, 4 * 882U); - p.view->drain(); - const std::vector events = p.run(); - size_t failures = 0; - for (const GroupEvent& event : events) { - if (const auto* failed = std::get_if(&event)) { - EXPECT_EQ(failed->instance, resampled); - ++failures; - } - } - EXPECT_EQ(failures, 1U); - frames = frames_by_instance(events); - EXPECT_TRUE(frames[resampled].empty()); - EXPECT_EQ(frames[native].size(), 5U); - EXPECT_EQ(drained_last_cell(events), 5); - std::map finals = final_indices(events); - EXPECT_EQ(finals.count(resampled), 0U); - EXPECT_EQ(finals[native], 5); - EXPECT_TRUE(p.run().empty()); -} - // ============================================================================== // Live inputs // ============================================================================== -/// A live input's options: its write jitter `J_in` -sendspin::server::InputOptions live_input(const InputFormat& format, int64_t jitter_us) { - sendspin::server::InputOptions options{format}; - options.kind = sendspin::server::InputKind::LIVE; - options.write_jitter_us = jitter_us; - return options; -} - constexpr int64_t JITTER_US = 20000; /// `L_p` of a one-cell frame with no resampler: the frame and `J_in` constexpr int64_t LIVE_LATENCY_US = 20000 + JITTER_US; @@ -1624,61 +1297,6 @@ TEST(GroupAudioLive, ShiftsALateFrameToTheOnTimeLead) { EXPECT_EQ(p.view->time_of(2 * CELL), frame.ts_us); } -// A live rebase takes the largest `L_p` over the frame instances. From 44.1 kHz, a 48 kHz -// instance's resampler reads 53 input frames, 1202 us, past each cell, which a native 44.1 kHz -// instance does not, so the late frame shifts to G - (20000 + 1202 + J) + J in both, and admission -// is published that `L_p` -TEST(GroupAudioLive, RebasesByTheResampledInstancesLookahead) { - constexpr uint32_t IN_RATE = 44100; - constexpr uint64_t IN_CELL = IN_RATE / 50; - constexpr int64_t RESAMPLED_LATENCY_US = 20000 + 1202 + JITTER_US; - ASSERT_EQ( - sched::pipeline_latency_us({1, - sendspin::server::Resampler::filter_length( - IN_RATE, 48000, sendspin::server::ResamplerQuality::MEDIUM), - IN_RATE}, - sendspin::server::InputKind::LIVE, JITTER_US), - RESAMPLED_LATENCY_US); - Pipelines p(live_input({IN_RATE, 1, SampleFormat::S16}, JITTER_US), 200000); - const sched::InstanceId resampled{120}; - const sched::InstanceId native{121}; - p.slot->publish({{resampled, {}, {sched::Codec::PCM, 48000, 1, 24}, 0}, - {native, {}, {sched::Codec::PCM, IN_RATE, 1, 24}, 0}}); - const Bytes cells(5 * IN_CELL * 2, 1); - ASSERT_EQ(p.view->write(cells.data(), 2 * IN_CELL).frames, 2 * IN_CELL); - std::vector events = p.run(); - ASSERT_FALSE(events.empty()); - const int64_t t0 = std::get(events[0]).anchor.t0_us; - - // Cells 2 to 4 come 190 ms late. The shift index is 1, which the resampled instance could not - // make without the input past cell 1, and the native one holds at until the rebase is decided - constexpr int64_t LATE_US = 190000; - ASSERT_EQ(p.view->write(cells.data(), 3 * IN_CELL).frames, 3 * IN_CELL); - const int64_t before = now_us() + LATE_US; - events = p.run(LATE_US); - const int64_t after = now_us() + LATE_US; - std::optional shift; - for (const GroupEvent& event : events) { - if (const auto* found = std::get_if(&event)) { - ASSERT_FALSE(shift) << "a second shift"; - shift = *found; - } - } - ASSERT_TRUE(shift); - ASSERT_EQ(shift->index, 1); - const int64_t at = t0 + 20000 + shift->delta_us; - EXPECT_GE(at, before + LEAD_US - RESAMPLED_LATENCY_US + JITTER_US); - EXPECT_LE(at, after + LEAD_US - RESAMPLED_LATENCY_US + JITTER_US); - std::map> frames = frames_by_instance(events); - for (const sched::InstanceId instance : {resampled, native}) { - const auto frame = std::find_if(frames[instance].begin(), frames[instance].end(), - [](const sched::Frame& f) { return f.index == 1; }); - ASSERT_NE(frame, frames[instance].end()) << instance.value; - EXPECT_EQ(frame->ts_us, at) << instance.value; - } - EXPECT_EQ(p.view->rebase_latency_us_, RESAMPLED_LATENCY_US); -} - // A joiner raising the group lead raises a live epoch's G_eff, so the next frame shifts by the // deficit, to the new on-time lead. Control: a buffered input keeps its timeline, its frames far // enough ahead already. diff --git a/tests/test_group_audio_internals.cpp b/tests/test_group_audio_internals.cpp new file mode 100644 index 0000000..26ed698 --- /dev/null +++ b/tests/test_group_audio_internals.cpp @@ -0,0 +1,281 @@ +// 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_audio_internals.cpp +/// @brief A group's pipelines on their own, reached inside: a resume from a paused position the +/// test sets, a stretch cut or extended directly, the resamplers a job builds, an instance whose +/// encoder is swapped for one that refuses, and the `L_p` a live rebase publishes. Compiled with +/// -fno-access-control (tests/CMakeLists.txt). + +#include "clock.h" +#include "frame_encoder.h" +#include "group_audio.h" +#include "group_pipelines.h" +#include "host_view.h" +#include "pipeline_stages.h" +#include "sched/leads.h" +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using sendspin::server::EpochStarted; +using sendspin::server::GroupEvent; +using sendspin::server::InputFormat; +using sendspin::server::now_us; +using sendspin::server::PipelineFailed; +using sendspin::server::SampleFormat; +using sendspin::server::test::concatenated; +using sendspin::server::test::drained_last_cell; +using sendspin::server::test::expect_resampled; +using sendspin::server::test::final_indices; +using sendspin::server::test::frames_by_instance; +using sendspin::server::test::live_input; +using sendspin::server::test::Pipelines; +using sendspin::server::test::resample_offline; +using sendspin::server::test::run_all; +using sendspin::server::test::Tone; +namespace sched = sendspin::server::sched; +using Bytes = std::vector; + +constexpr int64_t LEAD_US = Pipelines::LEAD_US; +/// A live input's write jitter `J_in` +constexpr int64_t JITTER_US = 20000; + +// A resume whose paused position lies in a later segment starts the new epoch in that segment's +// format: one inside the 48 kHz segment, and one exactly on its first position. The 48 kHz +// instance's frames are the input from the paused position, bit for bit; on the boundary the 44.1 +// kHz instance has no history in the segment, so it is micro-resampler's output over the segment +// from its start, primed, sample for sample +TEST(GroupAudioPipelines, ResumesInALaterSegment) { + struct Row { + const char* name; + uint64_t paused; + }; + const std::array rows{{{"inside the segment", 44100 + 9600}, {"on its start", 44100}}}; + const Tone first(44100, 2, 44100); + const Tone second(48000, 2, 48000, 48000); + for (const Row& row : rows) { + SCOPED_TRACE(row.name); + Pipelines p(InputFormat{44100, 2, SampleFormat::S16}, 10000000); + const sched::InstanceId at48{190}; + const sched::InstanceId at44{191}; + p.slot->publish({{at48, {}, {sched::Codec::PCM, 48000, 2, 16}, 0}, + {at44, {}, {sched::Codec::PCM, 44100, 2, 16}, 0}}); + ASSERT_EQ(p.view->write(first.pcm.data(), 44100).frames, 44100U); + ASSERT_TRUE(p.view->set_format({48000, 2, SampleFormat::S16})); + ASSERT_EQ(p.view->write(second.pcm.data(), 48000).frames, 48000U); + run_all(p); + p.view->pause(); + // White-box: the paused position, which pause() takes from the time + p.view->p_pause_ = row.paused; + run_all(p); + p.view->resume(); + p.view->drain(); + const std::vector events = run_all(p); + const auto started = std::find_if(events.begin(), events.end(), [](const GroupEvent& e) { + return std::holds_alternative(e); + }); + ASSERT_NE(started, events.end()); + const sched::Anchor& anchor = std::get(*started).anchor; + EXPECT_EQ(anchor.segments.front().position, row.paused); + EXPECT_EQ(anchor.segments.front().rate, 48000U); + std::map> frames = frames_by_instance(events); + + const Bytes out48 = concatenated(frames[at48]); + const size_t skipped = (row.paused - 44100) * 4; + ASSERT_EQ(out48.size(), second.pcm.size() - skipped); + EXPECT_TRUE(std::equal(out48.begin(), out48.end(), second.pcm.begin() + skipped)); + if (row.paused == 44100) { + const Bytes out44 = concatenated(frames[at44]); + const std::vector reference = + resample_offline(second.floats, 2, 48000, 44100, 960); + ASSERT_EQ(out44.size() / 2, reference.size()); + expect_resampled(out44, 0, reference); + } + } +} + +// A stretch's output is cut or extended with its last sample to the boundary's output index; a +// warm-up's samples, below the next cell's first, are counted but never kept; an extension past two +// samples, which no stage gives, is refused and changes nothing. The stages give no short stretch +// through the property test above, so the extension is reached directly +TEST(GroupAudioPipelines, CutsOrExtendsAStretchToItsBoundaryCount) { + struct Row { + const char* name; + int64_t produced; + size_t pending; + int64_t total; + bool accepted; + size_t kept; + size_t extended; + }; + // Mono 16-bit samples of 1 and a last sample of 7; cell 1's first output sample is 882 + const std::array rows{{ + {"cut", 900, 18, 895, true, 13, 0}, + {"extended", 900, 18, 902, true, 18, 2}, + {"extended past a warm-up", 881, 0, 883, true, 0, 1}, + {"refused", 900, 18, 903, false, 18, 0}, + // Control: at the boundary already + {"exact", 900, 18, 900, true, 18, 0}, + }}; + Pipelines p(InputFormat{44100, 1, SampleFormat::S16}); + for (const Row& row : rows) { + SCOPED_TRACE(row.name); + // White-box: an instance with only what the cut reads + sendspin::server::GroupAudio::Instance instance; + instance.cells = 1; + instance.frame_samples = 882; + instance.sample_bytes = 2; + instance.next = 1; + instance.produced = row.produced; + instance.pending.assign(row.pending * 2, 0); + for (size_t i = 0; i < row.pending; ++i) { + instance.pending[2 * i] = 1; + } + instance.last_sample = {7, 0}; + EXPECT_EQ(p.audio.cut_to(instance, row.total), row.accepted); + EXPECT_EQ(instance.produced, row.accepted ? row.total : row.produced); + ASSERT_EQ(instance.pending.size(), (row.kept + row.extended) * 2); + for (size_t i = 0; i < row.kept + row.extended; ++i) { + EXPECT_EQ(instance.pending[2 * i], i < row.kept ? 1 : 7) << i; + } + } +} + +// A job builds at most one resampler, whose filter bank is the costly allocation, and reports more +// work while another waits; an instance with none, listed after one that waits, is built at once +TEST(GroupAudioPipelines, BuildsOneResamplerPerJob) { + Pipelines p(InputFormat{44100, 2, SampleFormat::S16}); + p.slot->publish({{sched::InstanceId{120}, {}, {sched::Codec::PCM, 48000, 2, 16}, 0}, + {sched::InstanceId{122}, {}, {sched::Codec::PCM, 96000, 2, 16}, 0}, + {sched::InstanceId{121}, {}, {sched::Codec::PCM, 44100, 2, 24}, 0}}); + const auto built = [&] { + return std::count_if(p.audio.instances_.begin(), p.audio.instances_.end(), + [](const auto& instance) { return instance.configured; }); + }; + // White-box: one job at a time, before any input + EXPECT_TRUE(p.audio.run_job(now_us())); + EXPECT_EQ(built(), 2); + EXPECT_FALSE(p.audio.run_job(now_us())); + EXPECT_EQ(built(), 3); +} + +// An encoder that refuses fails its instance once, which then produces nothing and no longer holds +// a drain back; the group's other instance carries on through the drain +TEST(GroupAudioPipelines, FailsAnInstanceWhoseEncoderRefuses) { + Pipelines p(InputFormat{44100, 2, SampleFormat::S16}); + const sched::InstanceId native{80}; + const sched::InstanceId resampled{81}; + p.slot->publish({{native, {}, {sched::Codec::PCM, 44100, 2, 16}, 0}, + {resampled, {}, {sched::Codec::PCM, 48000, 2, 16}, 0}}); + const Bytes pcm(6 * 882 * 4, 3); + ASSERT_EQ(p.view->write(pcm.data(), 2 * 882).frames, 2 * 882U); + std::map> frames = frames_by_instance(p.run()); + ASSERT_EQ(frames[resampled].size(), 1U); + // Cell 1 waits for the resampler's lookahead in both instances + ASSERT_EQ(frames[native].size(), 1U); + + // White-box: an encoder whose frame is shorter refuses a 48 kHz frame + auto& instances = p.audio.instances_; + const auto it = std::find_if(instances.begin(), instances.end(), + [&](const auto& instance) { return instance.id == resampled; }); + ASSERT_NE(it, instances.end()); + it->encoder = sendspin::server::FrameEncoder::create({sched::Codec::FLAC, 8000, 2, 16}); + ASSERT_TRUE(it->encoder); + ASSERT_EQ(p.view->write(pcm.data(), 4 * 882).frames, 4 * 882U); + p.view->drain(); + const std::vector events = p.run(); + size_t failures = 0; + for (const GroupEvent& event : events) { + if (const auto* failed = std::get_if(&event)) { + EXPECT_EQ(failed->instance, resampled); + ++failures; + } + } + EXPECT_EQ(failures, 1U); + frames = frames_by_instance(events); + EXPECT_TRUE(frames[resampled].empty()); + EXPECT_EQ(frames[native].size(), 5U); + EXPECT_EQ(drained_last_cell(events), 5); + std::map finals = final_indices(events); + EXPECT_EQ(finals.count(resampled), 0U); + EXPECT_EQ(finals[native], 5); + EXPECT_TRUE(p.run().empty()); +} + +// A live rebase takes the largest `L_p` over the frame instances. From 44.1 kHz, a 48 kHz +// instance's resampler reads 53 input frames, 1202 us, past each cell, which a native 44.1 kHz +// instance does not, so the late frame shifts to G - (20000 + 1202 + J) + J in both, and admission +// is published that `L_p` +TEST(GroupAudioLive, RebasesByTheResampledInstancesLookahead) { + constexpr uint32_t IN_RATE = 44100; + constexpr uint64_t IN_CELL = IN_RATE / 50; + constexpr int64_t RESAMPLED_LATENCY_US = 20000 + 1202 + JITTER_US; + ASSERT_EQ( + sched::pipeline_latency_us({1, + sendspin::server::Resampler::filter_length( + IN_RATE, 48000, sendspin::server::ResamplerQuality::MEDIUM), + IN_RATE}, + sendspin::server::InputKind::LIVE, JITTER_US), + RESAMPLED_LATENCY_US); + Pipelines p(live_input({IN_RATE, 1, SampleFormat::S16}, JITTER_US), 200000); + const sched::InstanceId resampled{120}; + const sched::InstanceId native{121}; + p.slot->publish({{resampled, {}, {sched::Codec::PCM, 48000, 1, 24}, 0}, + {native, {}, {sched::Codec::PCM, IN_RATE, 1, 24}, 0}}); + const Bytes cells(5 * IN_CELL * 2, 1); + ASSERT_EQ(p.view->write(cells.data(), 2 * IN_CELL).frames, 2 * IN_CELL); + std::vector events = p.run(); + ASSERT_FALSE(events.empty()); + const int64_t t0 = std::get(events[0]).anchor.t0_us; + + // Cells 2 to 4 come 190 ms late. The shift index is 1, which the resampled instance could not + // make without the input past cell 1, and the native one holds at until the rebase is decided + constexpr int64_t LATE_US = 190000; + ASSERT_EQ(p.view->write(cells.data(), 3 * IN_CELL).frames, 3 * IN_CELL); + const int64_t before = now_us() + LATE_US; + events = p.run(LATE_US); + const int64_t after = now_us() + LATE_US; + std::optional shift; + for (const GroupEvent& event : events) { + if (const auto* found = std::get_if(&event)) { + ASSERT_FALSE(shift) << "a second shift"; + shift = *found; + } + } + ASSERT_TRUE(shift); + ASSERT_EQ(shift->index, 1); + const int64_t at = t0 + 20000 + shift->delta_us; + EXPECT_GE(at, before + LEAD_US - RESAMPLED_LATENCY_US + JITTER_US); + EXPECT_LE(at, after + LEAD_US - RESAMPLED_LATENCY_US + JITTER_US); + std::map> frames = frames_by_instance(events); + for (const sched::InstanceId instance : {resampled, native}) { + const auto frame = std::find_if(frames[instance].begin(), frames[instance].end(), + [](const sched::Frame& f) { return f.index == 1; }); + ASSERT_NE(frame, frames[instance].end()) << instance.value; + EXPECT_EQ(frame->ts_us, at) << instance.value; + } + EXPECT_EQ(p.view->rebase_latency_us_, RESAMPLED_LATENCY_US); +} + +} // namespace diff --git a/tests/test_host_view.cpp b/tests/test_host_view.cpp index 05c2f26..29c816e 100644 --- a/tests/test_host_view.cpp +++ b/tests/test_host_view.cpp @@ -14,105 +14,38 @@ /// @file test_host_view.cpp /// @brief A group input's host view on its own: admission, anchoring in the host call, the -/// provisional times, readiness and what core does to it. White-box: sets the outbox as a host call -/// leaves it before posting (see tests/CMakeLists.txt). +/// provisional times, readiness and what core does to it. #include "clock.h" -#include "dispatcher.h" #include "host_view.h" +#include "host_view_fixture.h" #include "job_view.h" #include "test_recorder.h" #include #include -#include #include -#include #include -#include #include #include -#include -#include -#include #include #include namespace { using sendspin::server::DrainOutcome; -using sendspin::server::GroupId; -using sendspin::server::GroupParams; using sendspin::server::HostView; using sendspin::server::InputFormat; using sendspin::server::InputId; -using sendspin::server::InputOptions; using sendspin::server::InputState; using sendspin::server::now_us; using sendspin::server::SampleFormat; using sendspin::server::WriteResult; +using sendspin::server::test::HostViewTest; using sendspin::server::test::job_view_of; using sendspin::server::test::Recorder; using Bytes = std::vector; -constexpr uint32_t RATE = 48000; -constexpr int64_t LEAD_US = 300000; -constexpr int64_t AHEAD_US = 1000000; -constexpr uint64_t AHEAD_FRAMES = RATE; -constexpr int64_t LIVE_AHEAD_US = 200000; -/// W_live widened to 3 s, so that one write holds 2 s, which the slew takes enough frames out of -/// to count -constexpr int64_t SLEW_AHEAD_US = 3000000; -/// A cell of frames -constexpr uint64_t CELL = RATE / 50; - -class HostViewTest : public testing::Test { -protected: - // A posted outbox task holds its view, and the view the dispatcher: the dispatch thread may - // still call the recorder, so it finishes before the members go - ~HostViewTest() override { - this->dispatcher->finish(); - this->dispatcher->join(); - } - - HostView& make(const GroupParams& params = {LEAD_US, 0, AHEAD_US}, - const InputFormat& format = {RATE, 2, SampleFormat::S16}) { - this->view = std::make_shared(GroupId{1}, InputId{2}, InputOptions{format}, - params, this->dispatcher, &this->recorder); - return *this->view; - } - - /// A live input: `W_live` 200 ms, as core writes it - HostView& make_live() { - InputOptions options{{RATE, 2, SampleFormat::S16}}; - options.kind = sendspin::server::InputKind::LIVE; - this->view = std::make_shared(GroupId{1}, InputId{2}, options, - GroupParams{LEAD_US, 0, LIVE_AHEAD_US}, - this->dispatcher, &this->recorder); - return *this->view; - } - - /// A live input with SLEW_AHEAD_US and a least lead - HostView& make_slewing(int64_t min_lead_us) { - InputOptions options{{RATE, 2, SampleFormat::S16}}; - options.kind = sendspin::server::InputKind::LIVE; - this->view = std::make_shared( - GroupId{1}, InputId{2}, options, GroupParams{LEAD_US, 0, SLEW_AHEAD_US}, - this->dispatcher, &this->recorder, sendspin::server::LiveOptions{min_lead_us}); - return *this->view; - } - - WriteResult write(uint64_t frames) { - const Bytes pcm(frames * 4, 1); - return this->view->write(pcm.data(), frames); - } - - Recorder recorder; - std::shared_ptr dispatcher = - std::make_shared(); - std::shared_ptr view; -}; - // The write that begins a run anchors it at now plus the group lead in the call itself, and admits // the write-ahead from there; a view with no write-ahead admits nothing TEST_F(HostViewTest, AnchorsTheRunInTheWriteThatBeginsIt) { @@ -206,328 +139,6 @@ TEST_F(HostViewTest, AdmitsAStalledLiveInputAsIfRebased) { EXPECT_EQ(result.state, InputState::FULL); } -/// A frame's time at 48 kHz, rounded up -constexpr int64_t FRAME_US = (1000000 + RATE - 1) / RATE; - -/// A 200 Hz tone at half scale at frame k, from its peak, as a 16-bit sample -int16_t tone_at(double frame) { - return static_cast( - std::lround(16384.0 * std::cos(2.0 * std::numbers::pi * 200.0 * frame / RATE))); -} - -/// Frames [from, from + count) of the tone, 16-bit stereo -Bytes tone_pcm(uint64_t from, uint64_t count) { - Bytes pcm(count * 4); - for (uint64_t k = 0; k < count; ++k) { - const int16_t sample = tone_at(static_cast(from + k)); - std::memcpy(&pcm[(k * 4)], &sample, 2); - std::memcpy(&pcm[(k * 4) + 2], &sample, 2); - } - return pcm; -} - -// A live input whose group lead fell owes the excess its writes land past a fresh anchor; a write -// that owes it plays LOWERING_SLEW_PPM fast, or as much slower as plays out only what it owes: it -// stages fewer positions and reports those taken, and `G_eff` follows its lead down by the time -// taken out, never below `G_next` nor the input's least lead, against which what is owed is also -// measured. A window of writes is seeded behind the input, and each row's write closes none; the -// anchor's half second keeps the excess above any row's debt unless the thread runs that late. -TEST_F(HostViewTest, PlaysALiveInputFastWhileItOwesLatency) { - using sendspin::server::sched::LOWERING_SLEW_PPM; - constexpr uint64_t WRITE = 2 * RATE; - constexpr int64_t WRITE_US = 2000000; - constexpr uint64_t ANCHOR = RATE / 2; - struct Row { - const char* name; - /// How far the group lead falls after the anchor, the input's least lead, the debt seeded - /// and the frames the anchoring write holds - int64_t fall_us; - int64_t min_lead_us; - int64_t debt_us; - uint64_t anchor_frames; - /// The time the slew takes out of the write - int64_t slewed_us; - }; - const Row rows[] = { - {"Control: nothing owed stages the write as it came", 200000, 0, 0, ANCHOR, 0}, - {"Owing more than the write's share plays at the slew rate", 200000, 0, 1000000, ANCHOR, - WRITE_US * LOWERING_SLEW_PPM / 1000000}, - // All but a frame of it, for the part frame a frame drop carries: 80 us, 40 ppm - {"Owing less plays only that out", 200000, 0, 101, ANCHOR, 101 - FRAME_US}, - {"G_eff stops at G_next", 50, 0, 1000000, ANCHOR, WRITE_US * LOWERING_SLEW_PPM / 1000000}, - {"G_eff stops at the least lead", 200000, LEAD_US - 50, 1000000, ANCHOR, - WRITE_US * LOWERING_SLEW_PPM / 1000000}, - // Twenty frames staged, fewer than the ASRC's filter spans - {"A short history starts the slew from what there is", 200000, 0, 1000000, 20, - WRITE_US * LOWERING_SLEW_PPM / 1000000}, - // A frame's anchor and a millisecond's wait: its first sample lands under the least lead, - // however late the thread, and 200 ms past G_next - {"What is owed is measured against the least lead", 200000, LEAD_US, 1000000, 1, 0}, - }; - for (const Row& row : rows) { - SCOPED_TRACE(row.name); - HostView& view = this->make_slewing(row.min_lead_us); - const Bytes anchor = tone_pcm(0, row.anchor_frames); - const Bytes pcm = tone_pcm(row.anchor_frames, WRITE); - ASSERT_EQ(view.write(anchor.data(), row.anchor_frames).frames, row.anchor_frames); - if (row.anchor_frames == 1) { - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - } - view.set_group_params({LEAD_US - row.fall_us, 0, SLEW_AHEAD_US}); - view.lowering_ = {now_us(), std::numeric_limits::max(), 0, row.debt_us}; - const WriteResult result = view.write(pcm.data(), WRITE); - // Admission admits the whole write, so all it lacks is what the slew took out; the - // positions staged are what it reports taken - EXPECT_EQ(result.state, InputState::OPEN); - const sendspin::server::JobView job = job_view_of(view); - EXPECT_EQ(job.head, row.anchor_frames + result.frames); - const auto missing = static_cast(WRITE - result.frames); - const int64_t least_lead = std::max(LEAD_US - row.fall_us, row.min_lead_us); - if (row.slewed_us == 0) { - EXPECT_EQ(missing, 0); - EXPECT_EQ(job.group_lead_eff_us, LEAD_US); - Bytes staged; - ASSERT_TRUE(view.read(job.run.epoch, row.anchor_frames, WRITE, staged)); - EXPECT_EQ(staged, pcm); - continue; - } - // A frame's rounding either way, and what the ASRC's filter holds where it runs - EXPECT_GE(missing, (row.slewed_us / FRAME_US) - 1) << missing; - EXPECT_LE(missing, (row.slewed_us * RATE / 1000000) + 2 + - static_cast(sendspin::server::LIVE_SLEW_BLOCK_FRAMES)) - << missing; - EXPECT_GE(job.group_lead_eff_us, - std::max(least_lead, LEAD_US - row.slewed_us - FRAME_US - 1)); - EXPECT_LE(job.group_lead_eff_us, - std::max(least_lead, LEAD_US - row.slewed_us + (2 * FRAME_US) + 1)); - // Staged frame j is the input's frame at anchor + j times the rate, the slew starting - // where the anchor's frames ended: through the ASRC to its filter's error and a 16-bit - // step, by dropped frames one input frame as it came within a frame of it - Bytes staged; - ASSERT_TRUE(view.read(job.run.epoch, row.anchor_frames, result.frames, staged)); - const double ratio = 1.0 + (static_cast(row.slewed_us) / WRITE_US); - for (uint64_t j = 0; j < result.frames; ++j) { - int16_t sample = 0; - std::memcpy(&sample, &staged[j * 4], 2); - const double at = static_cast(row.anchor_frames) + (j * ratio); - if (sendspin::server::profile::SOURCE_ASRC) { - ASSERT_NEAR(sample, tone_at(at), 70) << j; - } else { - const auto low = static_cast(at); - ASSERT_TRUE(sample == tone_at(static_cast(low)) || - sample == tone_at(static_cast(low + 1))) - << j; - } - } - } -} - -/// Frames [from, from + count) of the tone, float stereo -Bytes tone_floats(uint64_t from, uint64_t count) { - Bytes pcm(count * 8); - for (uint64_t k = 0; k < count; ++k) { - const auto sample = static_cast(tone_at(static_cast(from + k)) / 32768.0); - std::memcpy(&pcm[(k * 8)], &sample, 4); - std::memcpy(&pcm[(k * 8) + 4], &sample, 4); - } - return pcm; -} - -// A format set while a live input slews starts a new segment, and the slew starts over in it, in -// the new format, from the segment's first frame: the next write's staged audio is the tone at the -// slew's rate from where that write began, in floats, with none of the old segment's frames -TEST_F(HostViewTest, StartsTheSlewOverInANewFormat) { - using sendspin::server::sched::LOWERING_SLEW_PPM; - constexpr uint64_t ANCHOR = RATE / 2; - HostView& view = this->make_slewing(0); - const Bytes anchor = tone_pcm(0, ANCHOR); - const Bytes before = tone_pcm(ANCHOR, RATE); - // From a peak of the tone, as the slew's first frame primes it - const Bytes after = tone_floats(ANCHOR + RATE, RATE); - ASSERT_EQ(view.write(anchor.data(), ANCHOR).frames, ANCHOR); - view.set_group_params({LEAD_US - 200000, 0, SLEW_AHEAD_US}); - view.lowering_ = {now_us(), std::numeric_limits::max(), 0, 1000000}; - ASSERT_LT(view.write(before.data(), RATE).frames, RATE); - const uint64_t segment = job_view_of(view).head; - ASSERT_TRUE(view.set_format({RATE, 2, SampleFormat::F32})); - const WriteResult result = view.write(after.data(), RATE); - ASSERT_EQ(result.state, InputState::OPEN); - ASSERT_LT(result.frames, RATE); - const sendspin::server::JobView job = job_view_of(view); - ASSERT_EQ(job.head, segment + result.frames); - Bytes staged; - ASSERT_TRUE(view.read(job.run.epoch, segment, result.frames, staged)); - const double ratio = 1.0 + (static_cast(LOWERING_SLEW_PPM) * 1e-6); - for (uint64_t j = 0; j < result.frames; ++j) { - float sample = 0.0F; - std::memcpy(&sample, &staged[j * 8], 4); - const double at = static_cast(ANCHOR + RATE) + (j * ratio); - if (sendspin::server::profile::SOURCE_ASRC) { - ASSERT_NEAR(sample * 32768.0, tone_at(at), 70) << j; - } else { - const auto low = static_cast(at); - const auto exact = [&](uint64_t k) { - return sample == static_cast(tone_at(static_cast(k)) / 32768.0); - }; - ASSERT_TRUE(exact(low) || exact(low + 1)) << j; - } - } -} - -// The write that takes up set_format()'s slew frees nothing: the slew it replaced stays in the view -// until core frees it (free_retired_slew()), which leaves alone a slew set_format() made that no -// write has taken up yet. White-box: HostView::slew_, next_slew_. -TEST_F(HostViewTest, LeavesTheSlewAFormatReplacedForCore) { - HostView& view = this->make_slewing(0); - ASSERT_EQ(this->write(CELL).frames, CELL); - const uint8_t* replaced = view.slew_.output(); - ASSERT_TRUE(view.set_format({RATE, 2, SampleFormat::F32})); - const uint8_t* made = view.next_slew_.output(); - view.free_retired_slew(); - ASSERT_EQ(view.next_slew_.output(), made); - const Bytes pcm(CELL * 8, 0); - ASSERT_EQ(view.write(pcm.data(), CELL).frames, CELL); - EXPECT_EQ(view.slew_.output(), made); - EXPECT_EQ(view.next_slew_.output(), replaced); - view.free_retired_slew(); - EXPECT_FALSE(view.next_slew_.ready()); - EXPECT_EQ(view.slew_.output(), made); -} - -// While a write runs through the slew, between the step that takes it up and the steps that stage -// its blocks, the input is FULL: another write takes nothing, and a writer waiting for room is -// woken as the slewed write ends, the input OPEN again -TEST_F(HostViewTest, HoldsOffWritersWhileAWriteSlews) { - constexpr uint64_t ANCHOR = RATE / 2; - HostView& view = this->make_slewing(0); - const Bytes anchor = tone_pcm(0, ANCHOR); - const Bytes pcm = tone_pcm(ANCHOR, RATE); - ASSERT_EQ(view.write(anchor.data(), ANCHOR).frames, ANCHOR); - const HostView::SlewedWrite slewed = view.begin_slewed(RATE, 300, InputState::OPEN); - EXPECT_EQ(view.state(), InputState::FULL); - const WriteResult between = view.write(pcm.data(), RATE); - EXPECT_EQ(between.frames, 0U); - EXPECT_EQ(between.state, InputState::FULL); - // No timeout: only the slewed write's end wakes it - InputState woken = InputState::FULL; - std::thread waiter([&] { woken = view.wait_writable(std::numeric_limits::max()); }); - EXPECT_EQ(view.write_slewed(pcm.data(), slewed).state, InputState::OPEN); - waiter.join(); - EXPECT_EQ(woken, InputState::OPEN); - EXPECT_EQ(view.state(), InputState::OPEN); -} - -// A write runs through the slew with the mutex released, so it takes up the slew in one step and -// stages its blocks in others. A write made in between takes nothing (FULL), whatever the call -// before it left. A flush, pause, format, drain, close or server stop in between ends the -// staging, so nothing more lands past what that call left, and the write reports the state the -// call left and takes no slew time into `G_eff`. After a flush the next writes take the slew up -// again. -TEST_F(HostViewTest, EndsASlewedWriteACallOverran) { - constexpr uint64_t ANCHOR = RATE / 2; - struct Row { - const char* name; - std::function call; - InputState state; - }; - const Row rows[] = { - {"a flush", [](HostView& view) { view.flush(); }, InputState::OPEN}, - {"a pause", [](HostView& view) { (void)view.pause(); }, InputState::OPEN}, - {"a format", - [](HostView& view) { ASSERT_TRUE(view.set_format({RATE, 2, SampleFormat::F32})); }, - InputState::OPEN}, - {"a drain", [](HostView& view) { (void)view.drain(); }, InputState::DRAINING}, - {"a close", [](HostView& view) { view.close(); }, InputState::CLOSED}, - {"a server stop", [](HostView& view) { (void)view.server_stop(); }, - InputState::STOP_LATCHED}, - }; - for (const Row& row : rows) { - SCOPED_TRACE(row.name); - HostView& view = this->make_slewing(0); - const Bytes anchor = tone_pcm(0, ANCHOR); - const Bytes pcm = tone_pcm(ANCHOR, RATE); - ASSERT_EQ(view.write(anchor.data(), ANCHOR).frames, ANCHOR); - // So that G_eff could follow a slew down - view.set_group_params({LEAD_US - 200000, 0, SLEW_AHEAD_US}); - const HostView::SlewedWrite slewed = view.begin_slewed(RATE, 300, InputState::OPEN); - row.call(view); - const sendspin::server::JobView before = job_view_of(view); - const WriteResult between = view.write(pcm.data(), RATE); - EXPECT_EQ(between.frames, 0U); - EXPECT_EQ(between.state, - row.state == InputState::CLOSED ? InputState::CLOSED : InputState::FULL); - const WriteResult result = view.write_slewed(pcm.data(), slewed); - EXPECT_EQ(result.frames, 0U); - EXPECT_EQ(result.state, row.state); - const sendspin::server::JobView after = job_view_of(view); - EXPECT_EQ(after.head, before.head); - EXPECT_EQ(after.group_lead_eff_us, before.group_lead_eff_us); - } - // The flush's epoch anchors with the next write, and the one after takes the slew up again - HostView& view = this->make_slewing(0); - const Bytes anchor = tone_pcm(0, ANCHOR); - const Bytes pcm = tone_pcm(ANCHOR, RATE); - ASSERT_EQ(view.write(anchor.data(), ANCHOR).frames, ANCHOR); - const HostView::SlewedWrite slewed = view.begin_slewed(RATE, 300, InputState::OPEN); - view.flush(); - ASSERT_EQ(view.write_slewed(pcm.data(), slewed).frames, 0U); - ASSERT_EQ(view.write(anchor.data(), ANCHOR).frames, ANCHOR); - EXPECT_EQ(view.write(pcm.data(), RATE).frames, RATE); - EXPECT_EQ(job_view_of(view).head, ANCHOR + RATE); -} - -// One live input slewing under a writer while another thread flushes, pauses and resumes, sets -// formats and finally closes it, each call landing wherever the writer is: every write reports no -// more than it was given, some writes are played fast, and the input still takes writes until it -// closes. Bounded, a few hundred milliseconds; its point is the ThreadSanitizer run. -TEST_F(HostViewTest, SlewsUnderCallsFromAnotherThread) { - HostView& view = this->make_slewing(0); - view.set_group_params({LEAD_US - 200000, 0, SLEW_AHEAD_US}); - std::atomic done{false}; - // Writes the slew played fast, so the run shows it engaged - uint64_t slewed = 0; - std::thread writer([&] { - const Bytes pcm = tone_floats(0, RATE / 10); - while (!done.load()) { - { - // Owing a second, so most writes go through the slew - const std::lock_guard lock(view.mutex_); - view.lowering_ = {now_us(), std::numeric_limits::max(), 0, 1000000}; - } - // Either format's frames fit the buffer: S16 reads half of it - const WriteResult result = view.write(pcm.data(), RATE / 10); - ASSERT_LE(result.frames, RATE / 10); - if (result.state == InputState::OPEN && result.frames < RATE / 10) { - ++slewed; - } - std::this_thread::yield(); - } - }); - for (int i = 0; i < 1000; ++i) { - switch (i % 4) { - case 0: - view.flush(); - break; - case 1: - (void)view.pause(); - view.resume(); - break; - case 2: - (void)view.set_format( - {RATE, 2, (i % 8 == 2) ? SampleFormat::F32 : SampleFormat::S16}); - break; - default: - std::this_thread::sleep_for(std::chrono::microseconds(500)); - break; - } - } - view.close(); - done.store(true); - writer.join(); - EXPECT_GT(slewed, 0U); - EXPECT_EQ(view.write(tone_pcm(0, CELL).data(), CELL).state, InputState::CLOSED); -} - // A live input with a least lead above the group's anchors there, takes that lead for its // provisional times before then, and keeps a raised lead above it as any TEST_F(HostViewTest, AnchorsALiveInputAtItsLeastLead) { @@ -545,41 +156,6 @@ TEST_F(HostViewTest, AnchorsALiveInputAtItsLeastLead) { EXPECT_EQ(job_view_of(view).group_lead_eff_us, LEAST_US + 200000); } -// What a live epoch owes ends with it, and so does its slew: the epoch a flush, or a pause and -// resume, begins, and its writes, owe nothing of the one before and stage their audio as it came, -// bit for bit. Owed a second, the old epoch's debt would slew the new epoch's 1 s write, and an -// ASRC left running would resample it, starting with the frames it held from the old epoch. -TEST_F(HostViewTest, EndsALiveInputsSlewWithItsEpoch) { - constexpr uint64_t ANCHOR = RATE / 10; - for (const bool flush : {true, false}) { - SCOPED_TRACE(flush ? "a flush" : "a pause and resume"); - HostView& view = this->make_slewing(0); - ASSERT_EQ(this->write(ANCHOR).frames, ANCHOR); - view.set_group_params({LEAD_US - 200000, 0, SLEW_AHEAD_US}); - view.lowering_ = {now_us(), std::numeric_limits::max(), 0, 1000000}; - ASSERT_LT(this->write(RATE).frames, RATE); - view.lowering_.debt_us = 1000000; - if (flush) { - view.flush(); - // The new epoch's first write anchors it - ASSERT_EQ(this->write(ANCHOR).frames, ANCHOR); - } else { - (void)view.pause(); - ASSERT_EQ(this->write(ANCHOR).frames, ANCHOR); - view.resume(); - } - const uint64_t from = job_view_of(view).head; - const Bytes pcm = tone_pcm(ANCHOR, RATE); - ASSERT_EQ(view.write(pcm.data(), RATE).frames, RATE); - const sendspin::server::JobView job = job_view_of(view); - EXPECT_EQ(job.head, from + RATE); - EXPECT_EQ(job.group_lead_eff_us, LEAD_US - 200000); - Bytes staged; - ASSERT_TRUE(view.read(job.run.epoch, from, RATE, staged)); - EXPECT_EQ(staged, pcm); - } -} - // MAX_ANCHOR_SHIFTS: releasing the ring folds the shifts below the cell it then holds first into // S_base, so an epoch takes a shift a cell for longer than the cap, and every time from the cell // kept on reads as before the fold. 8 kHz mono: 160 frames a cell. @@ -907,29 +483,4 @@ TEST_F(HostViewTest, WaitsAgainOnceAWaitAnsweredACall) { EXPECT_EQ(state, InputState::FULL); } -// A host call that queued on_drained and has not posted it when the input closes, and the -// dispatcher then finishes, still has it delivered: close() posts too -TEST_F(HostViewTest, DeliversWhatAHostCallQueuedWhenTheInputCloses) { - HostView& view = this->make(); - view.outbox_.push_back({false, {}, DrainOutcome::PLAYED}); - view.outbox_posted_ = true; - view.close(); - this->dispatcher->finish(); - this->dispatcher->join(); - EXPECT_EQ(this->recorder.count(Recorder::drained(InputId{2}, DrainOutcome::PLAYED)), 1U); -} - -// A delivery swaps the outbox with the list it delivers from, so once both have grown a callback -// queued under the mutex allocates nothing -TEST_F(HostViewTest, KeepsTheOutboxCapacityAcrossDeliveries) { - HostView& view = this->make(); - for (int i = 0; i < 2; ++i) { - view.outbox_.push_back({false, {}, DrainOutcome::PLAYED}); - view.deliver_outbox(); - } - EXPECT_EQ(view.outbox_.size(), 0U); - EXPECT_GT(view.outbox_.capacity(), 0U); - EXPECT_EQ(this->recorder.count(Recorder::drained(InputId{2}, DrainOutcome::PLAYED)), 2U); -} - } // namespace diff --git a/tests/test_host_view_internals.cpp b/tests/test_host_view_internals.cpp new file mode 100644 index 0000000..1eb2477 --- /dev/null +++ b/tests/test_host_view_internals.cpp @@ -0,0 +1,438 @@ +// 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_host_view_internals.cpp +/// @brief A host view's latency slew, set going through the latency a live input owes, and its +/// outbox as a host call leaves it before posting. Compiled with -fno-access-control +/// (tests/CMakeLists.txt). + +#include "clock.h" +#include "host_view.h" +#include "host_view_fixture.h" +#include "job_view.h" +#include "test_recorder.h" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using sendspin::server::DrainOutcome; +using sendspin::server::HostView; +using sendspin::server::InputId; +using sendspin::server::InputState; +using sendspin::server::now_us; +using sendspin::server::SampleFormat; +using sendspin::server::WriteResult; +using sendspin::server::test::HostViewTest; +using sendspin::server::test::job_view_of; +using sendspin::server::test::Recorder; +using Bytes = std::vector; + +constexpr uint32_t RATE = HostViewTest::RATE; + +/// A frame's time at 48 kHz, rounded up +constexpr int64_t FRAME_US = (1000000 + RATE - 1) / RATE; + +/// A 200 Hz tone at half scale at frame k, from its peak, as a 16-bit sample +int16_t tone_at(double frame) { + return static_cast( + std::lround(16384.0 * std::cos(2.0 * std::numbers::pi * 200.0 * frame / RATE))); +} + +/// Frames [from, from + count) of the tone, 16-bit stereo +Bytes tone_pcm(uint64_t from, uint64_t count) { + Bytes pcm(count * 4); + for (uint64_t k = 0; k < count; ++k) { + const int16_t sample = tone_at(static_cast(from + k)); + std::memcpy(&pcm[(k * 4)], &sample, 2); + std::memcpy(&pcm[(k * 4) + 2], &sample, 2); + } + return pcm; +} + +// A live input whose group lead fell owes the excess its writes land past a fresh anchor; a write +// that owes it plays LOWERING_SLEW_PPM fast, or as much slower as plays out only what it owes: it +// stages fewer positions and reports those taken, and `G_eff` follows its lead down by the time +// taken out, never below `G_next` nor the input's least lead, against which what is owed is also +// measured. A window of writes is seeded behind the input, and each row's write closes none; the +// anchor's half second keeps the excess above any row's debt unless the thread runs that late. +TEST_F(HostViewTest, PlaysALiveInputFastWhileItOwesLatency) { + using sendspin::server::sched::LOWERING_SLEW_PPM; + constexpr uint64_t WRITE = 2 * RATE; + constexpr int64_t WRITE_US = 2000000; + constexpr uint64_t ANCHOR = RATE / 2; + struct Row { + const char* name; + /// How far the group lead falls after the anchor, the input's least lead, the debt seeded + /// and the frames the anchoring write holds + int64_t fall_us; + int64_t min_lead_us; + int64_t debt_us; + uint64_t anchor_frames; + /// The time the slew takes out of the write + int64_t slewed_us; + }; + const Row rows[] = { + {"Control: nothing owed stages the write as it came", 200000, 0, 0, ANCHOR, 0}, + {"Owing more than the write's share plays at the slew rate", 200000, 0, 1000000, ANCHOR, + WRITE_US * LOWERING_SLEW_PPM / 1000000}, + // All but a frame of it, for the part frame a frame drop carries: 80 us, 40 ppm + {"Owing less plays only that out", 200000, 0, 101, ANCHOR, 101 - FRAME_US}, + {"G_eff stops at G_next", 50, 0, 1000000, ANCHOR, WRITE_US * LOWERING_SLEW_PPM / 1000000}, + {"G_eff stops at the least lead", 200000, LEAD_US - 50, 1000000, ANCHOR, + WRITE_US * LOWERING_SLEW_PPM / 1000000}, + // Twenty frames staged, fewer than the ASRC's filter spans + {"A short history starts the slew from what there is", 200000, 0, 1000000, 20, + WRITE_US * LOWERING_SLEW_PPM / 1000000}, + // A frame's anchor and a millisecond's wait: its first sample lands under the least lead, + // however late the thread, and 200 ms past G_next + {"What is owed is measured against the least lead", 200000, LEAD_US, 1000000, 1, 0}, + }; + for (const Row& row : rows) { + SCOPED_TRACE(row.name); + HostView& view = this->make_slewing(row.min_lead_us); + const Bytes anchor = tone_pcm(0, row.anchor_frames); + const Bytes pcm = tone_pcm(row.anchor_frames, WRITE); + ASSERT_EQ(view.write(anchor.data(), row.anchor_frames).frames, row.anchor_frames); + if (row.anchor_frames == 1) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + view.set_group_params({LEAD_US - row.fall_us, 0, SLEW_AHEAD_US}); + view.lowering_ = {now_us(), std::numeric_limits::max(), 0, row.debt_us}; + const WriteResult result = view.write(pcm.data(), WRITE); + // Admission admits the whole write, so all it lacks is what the slew took out; the + // positions staged are what it reports taken + EXPECT_EQ(result.state, InputState::OPEN); + const sendspin::server::JobView job = job_view_of(view); + EXPECT_EQ(job.head, row.anchor_frames + result.frames); + const auto missing = static_cast(WRITE - result.frames); + const int64_t least_lead = std::max(LEAD_US - row.fall_us, row.min_lead_us); + if (row.slewed_us == 0) { + EXPECT_EQ(missing, 0); + EXPECT_EQ(job.group_lead_eff_us, LEAD_US); + Bytes staged; + ASSERT_TRUE(view.read(job.run.epoch, row.anchor_frames, WRITE, staged)); + EXPECT_EQ(staged, pcm); + continue; + } + // A frame's rounding either way, and what the ASRC's filter holds where it runs + EXPECT_GE(missing, (row.slewed_us / FRAME_US) - 1) << missing; + EXPECT_LE(missing, (row.slewed_us * RATE / 1000000) + 2 + + static_cast(sendspin::server::LIVE_SLEW_BLOCK_FRAMES)) + << missing; + EXPECT_GE(job.group_lead_eff_us, + std::max(least_lead, LEAD_US - row.slewed_us - FRAME_US - 1)); + EXPECT_LE(job.group_lead_eff_us, + std::max(least_lead, LEAD_US - row.slewed_us + (2 * FRAME_US) + 1)); + // Staged frame j is the input's frame at anchor + j times the rate, the slew starting + // where the anchor's frames ended: through the ASRC to its filter's error and a 16-bit + // step, by dropped frames one input frame as it came within a frame of it + Bytes staged; + ASSERT_TRUE(view.read(job.run.epoch, row.anchor_frames, result.frames, staged)); + const double ratio = 1.0 + (static_cast(row.slewed_us) / WRITE_US); + for (uint64_t j = 0; j < result.frames; ++j) { + int16_t sample = 0; + std::memcpy(&sample, &staged[j * 4], 2); + const double at = static_cast(row.anchor_frames) + (j * ratio); + if (sendspin::server::profile::SOURCE_ASRC) { + ASSERT_NEAR(sample, tone_at(at), 70) << j; + } else { + const auto low = static_cast(at); + ASSERT_TRUE(sample == tone_at(static_cast(low)) || + sample == tone_at(static_cast(low + 1))) + << j; + } + } + } +} + +/// Frames [from, from + count) of the tone, float stereo +Bytes tone_floats(uint64_t from, uint64_t count) { + Bytes pcm(count * 8); + for (uint64_t k = 0; k < count; ++k) { + const auto sample = static_cast(tone_at(static_cast(from + k)) / 32768.0); + std::memcpy(&pcm[(k * 8)], &sample, 4); + std::memcpy(&pcm[(k * 8) + 4], &sample, 4); + } + return pcm; +} + +// A format set while a live input slews starts a new segment, and the slew starts over in it, in +// the new format, from the segment's first frame: the next write's staged audio is the tone at the +// slew's rate from where that write began, in floats, with none of the old segment's frames +TEST_F(HostViewTest, StartsTheSlewOverInANewFormat) { + using sendspin::server::sched::LOWERING_SLEW_PPM; + constexpr uint64_t ANCHOR = RATE / 2; + HostView& view = this->make_slewing(0); + const Bytes anchor = tone_pcm(0, ANCHOR); + const Bytes before = tone_pcm(ANCHOR, RATE); + // From a peak of the tone, as the slew's first frame primes it + const Bytes after = tone_floats(ANCHOR + RATE, RATE); + ASSERT_EQ(view.write(anchor.data(), ANCHOR).frames, ANCHOR); + view.set_group_params({LEAD_US - 200000, 0, SLEW_AHEAD_US}); + view.lowering_ = {now_us(), std::numeric_limits::max(), 0, 1000000}; + ASSERT_LT(view.write(before.data(), RATE).frames, RATE); + const uint64_t segment = job_view_of(view).head; + ASSERT_TRUE(view.set_format({RATE, 2, SampleFormat::F32})); + const WriteResult result = view.write(after.data(), RATE); + ASSERT_EQ(result.state, InputState::OPEN); + ASSERT_LT(result.frames, RATE); + const sendspin::server::JobView job = job_view_of(view); + ASSERT_EQ(job.head, segment + result.frames); + Bytes staged; + ASSERT_TRUE(view.read(job.run.epoch, segment, result.frames, staged)); + const double ratio = 1.0 + (static_cast(LOWERING_SLEW_PPM) * 1e-6); + for (uint64_t j = 0; j < result.frames; ++j) { + float sample = 0.0F; + std::memcpy(&sample, &staged[j * 8], 4); + const double at = static_cast(ANCHOR + RATE) + (j * ratio); + if (sendspin::server::profile::SOURCE_ASRC) { + ASSERT_NEAR(sample * 32768.0, tone_at(at), 70) << j; + } else { + const auto low = static_cast(at); + const auto exact = [&](uint64_t k) { + return sample == static_cast(tone_at(static_cast(k)) / 32768.0); + }; + ASSERT_TRUE(exact(low) || exact(low + 1)) << j; + } + } +} + +// The write that takes up set_format()'s slew frees nothing: the slew it replaced stays in the view +// until core frees it (free_retired_slew()), which leaves alone a slew set_format() made that no +// write has taken up yet. White-box: HostView::slew_, next_slew_. +TEST_F(HostViewTest, LeavesTheSlewAFormatReplacedForCore) { + HostView& view = this->make_slewing(0); + ASSERT_EQ(this->write(CELL).frames, CELL); + const uint8_t* replaced = view.slew_.output(); + ASSERT_TRUE(view.set_format({RATE, 2, SampleFormat::F32})); + const uint8_t* made = view.next_slew_.output(); + view.free_retired_slew(); + ASSERT_EQ(view.next_slew_.output(), made); + const Bytes pcm(CELL * 8, 0); + ASSERT_EQ(view.write(pcm.data(), CELL).frames, CELL); + EXPECT_EQ(view.slew_.output(), made); + EXPECT_EQ(view.next_slew_.output(), replaced); + view.free_retired_slew(); + EXPECT_FALSE(view.next_slew_.ready()); + EXPECT_EQ(view.slew_.output(), made); +} + +// While a write runs through the slew, between the step that takes it up and the steps that stage +// its blocks, the input is FULL: another write takes nothing, and a writer waiting for room is +// woken as the slewed write ends, the input OPEN again +TEST_F(HostViewTest, HoldsOffWritersWhileAWriteSlews) { + constexpr uint64_t ANCHOR = RATE / 2; + HostView& view = this->make_slewing(0); + const Bytes anchor = tone_pcm(0, ANCHOR); + const Bytes pcm = tone_pcm(ANCHOR, RATE); + ASSERT_EQ(view.write(anchor.data(), ANCHOR).frames, ANCHOR); + const HostView::SlewedWrite slewed = view.begin_slewed(RATE, 300, InputState::OPEN); + EXPECT_EQ(view.state(), InputState::FULL); + const WriteResult between = view.write(pcm.data(), RATE); + EXPECT_EQ(between.frames, 0U); + EXPECT_EQ(between.state, InputState::FULL); + // No timeout: only the slewed write's end wakes it + InputState woken = InputState::FULL; + std::thread waiter([&] { woken = view.wait_writable(std::numeric_limits::max()); }); + EXPECT_EQ(view.write_slewed(pcm.data(), slewed).state, InputState::OPEN); + waiter.join(); + EXPECT_EQ(woken, InputState::OPEN); + EXPECT_EQ(view.state(), InputState::OPEN); +} + +// A write runs through the slew with the mutex released, so it takes up the slew in one step and +// stages its blocks in others. A write made in between takes nothing (FULL), whatever the call +// before it left. A flush, pause, format, drain, close or server stop in between ends the +// staging, so nothing more lands past what that call left, and the write reports the state the +// call left and takes no slew time into `G_eff`. After a flush the next writes take the slew up +// again. +TEST_F(HostViewTest, EndsASlewedWriteACallOverran) { + constexpr uint64_t ANCHOR = RATE / 2; + struct Row { + const char* name; + std::function call; + InputState state; + }; + const Row rows[] = { + {"a flush", [](HostView& view) { view.flush(); }, InputState::OPEN}, + {"a pause", [](HostView& view) { (void)view.pause(); }, InputState::OPEN}, + {"a format", + [](HostView& view) { ASSERT_TRUE(view.set_format({RATE, 2, SampleFormat::F32})); }, + InputState::OPEN}, + {"a drain", [](HostView& view) { (void)view.drain(); }, InputState::DRAINING}, + {"a close", [](HostView& view) { view.close(); }, InputState::CLOSED}, + {"a server stop", [](HostView& view) { (void)view.server_stop(); }, + InputState::STOP_LATCHED}, + }; + for (const Row& row : rows) { + SCOPED_TRACE(row.name); + HostView& view = this->make_slewing(0); + const Bytes anchor = tone_pcm(0, ANCHOR); + const Bytes pcm = tone_pcm(ANCHOR, RATE); + ASSERT_EQ(view.write(anchor.data(), ANCHOR).frames, ANCHOR); + // So that G_eff could follow a slew down + view.set_group_params({LEAD_US - 200000, 0, SLEW_AHEAD_US}); + const HostView::SlewedWrite slewed = view.begin_slewed(RATE, 300, InputState::OPEN); + row.call(view); + const sendspin::server::JobView before = job_view_of(view); + const WriteResult between = view.write(pcm.data(), RATE); + EXPECT_EQ(between.frames, 0U); + EXPECT_EQ(between.state, + row.state == InputState::CLOSED ? InputState::CLOSED : InputState::FULL); + const WriteResult result = view.write_slewed(pcm.data(), slewed); + EXPECT_EQ(result.frames, 0U); + EXPECT_EQ(result.state, row.state); + const sendspin::server::JobView after = job_view_of(view); + EXPECT_EQ(after.head, before.head); + EXPECT_EQ(after.group_lead_eff_us, before.group_lead_eff_us); + } + // The flush's epoch anchors with the next write, and the one after takes the slew up again + HostView& view = this->make_slewing(0); + const Bytes anchor = tone_pcm(0, ANCHOR); + const Bytes pcm = tone_pcm(ANCHOR, RATE); + ASSERT_EQ(view.write(anchor.data(), ANCHOR).frames, ANCHOR); + const HostView::SlewedWrite slewed = view.begin_slewed(RATE, 300, InputState::OPEN); + view.flush(); + ASSERT_EQ(view.write_slewed(pcm.data(), slewed).frames, 0U); + ASSERT_EQ(view.write(anchor.data(), ANCHOR).frames, ANCHOR); + EXPECT_EQ(view.write(pcm.data(), RATE).frames, RATE); + EXPECT_EQ(job_view_of(view).head, ANCHOR + RATE); +} + +// One live input slewing under a writer while another thread flushes, pauses and resumes, sets +// formats and finally closes it, each call landing wherever the writer is: every write reports no +// more than it was given, some writes are played fast, and the input still takes writes until it +// closes. Bounded, a few hundred milliseconds; its point is the ThreadSanitizer run. +TEST_F(HostViewTest, SlewsUnderCallsFromAnotherThread) { + HostView& view = this->make_slewing(0); + view.set_group_params({LEAD_US - 200000, 0, SLEW_AHEAD_US}); + std::atomic done{false}; + // Writes the slew played fast, so the run shows it engaged + uint64_t slewed = 0; + std::thread writer([&] { + const Bytes pcm = tone_floats(0, RATE / 10); + while (!done.load()) { + { + // Owing a second, so most writes go through the slew + const std::lock_guard lock(view.mutex_); + view.lowering_ = {now_us(), std::numeric_limits::max(), 0, 1000000}; + } + // Either format's frames fit the buffer: S16 reads half of it + const WriteResult result = view.write(pcm.data(), RATE / 10); + ASSERT_LE(result.frames, RATE / 10); + if (result.state == InputState::OPEN && result.frames < RATE / 10) { + ++slewed; + } + std::this_thread::yield(); + } + }); + for (int i = 0; i < 1000; ++i) { + switch (i % 4) { + case 0: + view.flush(); + break; + case 1: + (void)view.pause(); + view.resume(); + break; + case 2: + (void)view.set_format( + {RATE, 2, (i % 8 == 2) ? SampleFormat::F32 : SampleFormat::S16}); + break; + default: + std::this_thread::sleep_for(std::chrono::microseconds(500)); + break; + } + } + view.close(); + done.store(true); + writer.join(); + EXPECT_GT(slewed, 0U); + EXPECT_EQ(view.write(tone_pcm(0, CELL).data(), CELL).state, InputState::CLOSED); +} + +// What a live epoch owes ends with it, and so does its slew: the epoch a flush, or a pause and +// resume, begins, and its writes, owe nothing of the one before and stage their audio as it came, +// bit for bit. Owed a second, the old epoch's debt would slew the new epoch's 1 s write, and an +// ASRC left running would resample it, starting with the frames it held from the old epoch. +TEST_F(HostViewTest, EndsALiveInputsSlewWithItsEpoch) { + constexpr uint64_t ANCHOR = RATE / 10; + for (const bool flush : {true, false}) { + SCOPED_TRACE(flush ? "a flush" : "a pause and resume"); + HostView& view = this->make_slewing(0); + ASSERT_EQ(this->write(ANCHOR).frames, ANCHOR); + view.set_group_params({LEAD_US - 200000, 0, SLEW_AHEAD_US}); + view.lowering_ = {now_us(), std::numeric_limits::max(), 0, 1000000}; + ASSERT_LT(this->write(RATE).frames, RATE); + view.lowering_.debt_us = 1000000; + if (flush) { + view.flush(); + // The new epoch's first write anchors it + ASSERT_EQ(this->write(ANCHOR).frames, ANCHOR); + } else { + (void)view.pause(); + ASSERT_EQ(this->write(ANCHOR).frames, ANCHOR); + view.resume(); + } + const uint64_t from = job_view_of(view).head; + const Bytes pcm = tone_pcm(ANCHOR, RATE); + ASSERT_EQ(view.write(pcm.data(), RATE).frames, RATE); + const sendspin::server::JobView job = job_view_of(view); + EXPECT_EQ(job.head, from + RATE); + EXPECT_EQ(job.group_lead_eff_us, LEAD_US - 200000); + Bytes staged; + ASSERT_TRUE(view.read(job.run.epoch, from, RATE, staged)); + EXPECT_EQ(staged, pcm); + } +} + +// A host call that queued on_drained and has not posted it when the input closes, and the +// dispatcher then finishes, still has it delivered: close() posts too +TEST_F(HostViewTest, DeliversWhatAHostCallQueuedWhenTheInputCloses) { + HostView& view = this->make(); + view.outbox_.push_back({false, {}, DrainOutcome::PLAYED}); + view.outbox_posted_ = true; + view.close(); + this->dispatcher->finish(); + this->dispatcher->join(); + EXPECT_EQ(this->recorder.count(Recorder::drained(InputId{2}, DrainOutcome::PLAYED)), 1U); +} + +// A delivery swaps the outbox with the list it delivers from, so once both have grown a callback +// queued under the mutex allocates nothing +TEST_F(HostViewTest, KeepsTheOutboxCapacityAcrossDeliveries) { + HostView& view = this->make(); + for (int i = 0; i < 2; ++i) { + view.outbox_.push_back({false, {}, DrainOutcome::PLAYED}); + view.deliver_outbox(); + } + EXPECT_EQ(view.outbox_.size(), 0U); + EXPECT_GT(view.outbox_.capacity(), 0U); + EXPECT_EQ(this->recorder.count(Recorder::drained(InputId{2}, DrainOutcome::PLAYED)), 2U); +} + +} // namespace diff --git a/tests/test_live_slew.cpp b/tests/test_live_slew.cpp index af71076..8a4fba9 100644 --- a/tests/test_live_slew.cpp +++ b/tests/test_live_slew.cpp @@ -185,32 +185,4 @@ TEST(LiveSlew, WritesNoMoreThanItsCapacityAndKeepsBackTheRest) { EXPECT_TRUE(std::equal(outputs[1].begin(), outputs[1].end(), outputs[0].begin())); } -// An ASRC that refuses a block falls the slew back to dropped frames until it is prepared again, -// restarted or not, and the slew keeps it, so the write it refused in frees nothing. White-box: -// LiveSlew::asrc_. -TEST(LiveSlew, FallsBackToDroppedFramesKeepingTheAsrc) { - std::vector in; - for (size_t k = 0; k < BLOCK; ++k) { - const std::vector bytes = tone_frame(k, SampleFormat::F32); - in.insert(in.end(), bytes.begin(), bytes.end()); - } - LiveSlew slew; - slew.prepare({RATE, 1, SampleFormat::F32}, true, ResamplerQuality::MEDIUM); - slew.start(0); - ASSERT_TRUE(slew.engaged()); - // An empty first block cannot prime it - EXPECT_EQ(slew.process(in.data(), 0, 300, 1000000).written, 0U); - EXPECT_FALSE(slew.engaged()); - EXPECT_NE(slew.asrc_, nullptr); - slew.restart(); - EXPECT_FALSE(slew.awaits_start()); - // At no slew a block passes as it came - const LiveSlew::Block block = slew.process(in.data(), BLOCK, 0, 1000000); - ASSERT_EQ(block.written, BLOCK); - EXPECT_EQ(std::memcmp(slew.output(), in.data(), in.size()), 0); - // Control: prepared again, it waits for start() - slew.prepare({RATE, 1, SampleFormat::F32}, true, ResamplerQuality::MEDIUM); - EXPECT_TRUE(slew.awaits_start()); -} - } // namespace diff --git a/tests/test_live_slew_internals.cpp b/tests/test_live_slew_internals.cpp new file mode 100644 index 0000000..827f5fc --- /dev/null +++ b/tests/test_live_slew_internals.cpp @@ -0,0 +1,64 @@ +// 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_live_slew_internals.cpp +/// @brief A live slew whose ASRC refuses a block keeps it. Compiled with -fno-access-control +/// (tests/CMakeLists.txt). + +#include "live_slew.h" +#include + +#include +#include +#include +#include + +namespace { + +using sendspin::server::LiveSlew; +using sendspin::server::ResamplerQuality; +using sendspin::server::SampleFormat; + +constexpr uint32_t RATE = 48000; +constexpr size_t BLOCK = RATE / 100; + +// An ASRC that refuses a block falls the slew back to dropped frames until it is prepared again, +// restarted or not, and the slew keeps it, so the write it refused in frees nothing. +TEST(LiveSlew, FallsBackToDroppedFramesKeepingTheAsrc) { + // A block of a mono float ramp + std::vector in(BLOCK * sizeof(float)); + for (size_t k = 0; k < BLOCK; ++k) { + const float sample = (static_cast(k) / static_cast(BLOCK)) - 0.5F; + std::memcpy(&in[k * sizeof(float)], &sample, sizeof(float)); + } + LiveSlew slew; + slew.prepare({RATE, 1, SampleFormat::F32}, true, ResamplerQuality::MEDIUM); + slew.start(0); + ASSERT_TRUE(slew.engaged()); + // An empty first block cannot prime it + EXPECT_EQ(slew.process(in.data(), 0, 300, 1000000).written, 0U); + EXPECT_FALSE(slew.engaged()); + EXPECT_NE(slew.asrc_, nullptr); + slew.restart(); + EXPECT_FALSE(slew.awaits_start()); + // At no slew a block passes as it came + const LiveSlew::Block block = slew.process(in.data(), BLOCK, 0, 1000000); + ASSERT_EQ(block.written, BLOCK); + EXPECT_EQ(std::memcmp(slew.output(), in.data(), in.size()), 0); + // Control: prepared again, it waits for start() + slew.prepare({RATE, 1, SampleFormat::F32}, true, ResamplerQuality::MEDIUM); + EXPECT_TRUE(slew.awaits_start()); +} + +} // namespace diff --git a/tests/test_noise_initiator.cpp b/tests/test_noise_initiator.cpp index d908d05..57c8b4a 100644 --- a/tests/test_noise_initiator.cpp +++ b/tests/test_noise_initiator.cpp @@ -13,276 +13,16 @@ // limitations under the License. /// @file test_noise_initiator.cpp -/// @brief NoiseInitiator against the cacophony KKpsk2 vectors for both suites (noise-c's -/// tests/vector/cacophony.txt), the lazy replay of the Sentinel Fallback against the same vectors, -/// and the suite names of spec connection.md "Cipher Suites". -/// -/// White-box: the vectors fix the initiator's ephemeral key, which only the handshake state -/// (NoiseInitiator::state_) can take, and the replay is shown refusing a changed message 1 by -/// flipping NoiseInitiator::payload_1_ or message_1_. +/// @brief The cipher suite names of spec connection.md "Cipher Suites", as client/init carries them -#include "crypto/keys.h" -#include "fragmentation.h" -#include "noise.h" +#include "noise_types.h" #include -#include -#include -#include #include -#include -#include namespace { using sendspin::server::CipherSuite; -using sendspin::server::Identity; -using sendspin::server::NoiseInitiator; -using sendspin::server::Psk; -using sendspin::server::TransportCiphers; - -std::vector from_hex(const std::string& text) { - std::vector out; - for (size_t i = 0; i + 1 < text.size(); i += 2) { - out.push_back(static_cast(std::stoul(text.substr(i, 2), nullptr, 16))); - } - return out; -} - -template -std::array array_from_hex(const std::string& text) { - const std::vector bytes = from_hex(text); - std::array out{}; - std::copy(bytes.begin(), bytes.end(), out.begin()); - return out; -} - -struct Vector { - CipherSuite suite; - const char* name; - const char* handshake_hash; - // Ciphertexts of the six messages; the payloads are the same for both suites - const char* ciphertexts[6]; -}; - -constexpr const char* PROLOGUE = "4a6f686e2047616c74"; -constexpr const char* PSK = "54686973206973206d7920417573747269616e20706572737065637469766521"; -constexpr const char* INIT_STATIC = - "e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1"; -constexpr const char* INIT_EPHEMERAL = - "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a"; -constexpr const char* RESP_STATIC = - "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893"; -constexpr const char* PAYLOADS[6] = { - "4c756477696720766f6e204d69736573", - "4d757272617920526f746862617264", - "462e20412e20486179656b", - "4361726c204d656e676572", - "4a65616e2d426170746973746520536179", - "457567656e2042f6686d20766f6e2042617765726b", -}; - -const Vector VECTORS[] = { - {CipherSuite::CHACHAPOLY_SHA256, - "ChaChaPoly", - "7f3c5fdcdd3767e2835473a2683971490339f5bbeee82c3690bc606e14db70ed", - {"ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c7944babf6443250c604872e33233c3b9" - "a29df5c6d334ae2d53f1bd7f0b265a716b37", - "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f14480884366a1f5f0d79fe93ae476bd1897a7" - "a8ae92764898aa5d49e07b5849f35865ba", - "2eb2686b8814a7c0178fe18bfeeafe3e07312d69486d45e6572546", - "eea5791a890cd573a5c2e2345a8f98b0d1f0727acd24584fcddde5", - "ab2e1a411abaaa3df9cb497dffe4cfb70af6c71f0815b3c33b35e22329dee72f3e", - "ed22a0392c6afbfd6a6adea92b1faf13c4df24072f7060a20b1500609621c6957ac86d82f9"}}, - {CipherSuite::AESGCM_SHA256, - "AESGCM", - "1d2be5b73c43962fa87db816e6b5a73a0d67053e29b3d89fcee6ae299b5a5c1f", - {"ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c794483cf298f1bfed916ea20d8f528bd" - "30670670c7245e236a68f021b5a521d8bf31", - "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f14480884332b32e0f483ae58a9c36e93390a9" - "c2238918bda6ae0ebfe48a400b8f565f5f", - "f7cf1f47885fa5ff7cefecf3db3066f89b996834069b8f1517c55a", - "ce7de28a6b54b559d3db2f7482f1be3264a62165f12a73e377ebde", - "da4173c5aa84756d0eab64adfbe81f10538b099966897771a071502ee2899e2fbd", - "1a1e3408acea689f5cced09b246e13e68e1ce146e8f1b3478482593b988587be99ba7595d2"}}, -}; - -class NoiseInitiatorVector : public testing::TestWithParam { -protected: - static void SetUpTestSuite() { - ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); - } - - // The initiator with the vector's keys, prologue and fixed ephemeral key, and psk - static std::optional make_initiator(CipherSuite suite, - const Psk& psk = array_from_hex<32>(PSK)) { - static const std::optional local = - Identity::from_private_bytes(array_from_hex<32>(INIT_STATIC)); - const std::optional remote = - Identity::from_private_bytes(array_from_hex<32>(RESP_STATIC)); - EXPECT_TRUE(local && remote); - std::optional initiator = - NoiseInitiator::create(suite, *local, remote->public_bytes, from_hex(PROLOGUE), psk); - EXPECT_TRUE(initiator); - const std::vector ephemeral = from_hex(INIT_EPHEMERAL); - EXPECT_EQ(noise_dhstate_set_keypair_private( - noise_handshakestate_get_fixed_ephemeral_dh(initiator->state_.get()), - ephemeral.data(), ephemeral.size()), - NOISE_ERROR_NONE); - return initiator; - } -}; - -TEST_P(NoiseInitiatorVector, ProducesTheVectorsHandshakeAndTransportMessages) { - const Vector& vector = GetParam(); - std::optional initiator = make_initiator(vector.suite); - ASSERT_TRUE(initiator); - - EXPECT_EQ(initiator->write_message_1(from_hex(PAYLOADS[0])), from_hex(vector.ciphertexts[0])); - EXPECT_EQ(initiator->read_message_2(from_hex(vector.ciphertexts[1])), from_hex(PAYLOADS[1])); - std::optional ciphers = initiator->split(); - ASSERT_TRUE(ciphers); - EXPECT_EQ( - std::vector(ciphers->handshake_hash().begin(), ciphers->handshake_hash().end()), - from_hex(vector.handshake_hash)); - - // Transport messages alternate, initiator first - for (size_t i = 2; i < 6; ++i) { - SCOPED_TRACE(i); - std::vector message; - if (i % 2 == 0) { - message = from_hex(PAYLOADS[i]); - ASSERT_TRUE(ciphers->encrypt(message)); - EXPECT_EQ(message, from_hex(vector.ciphertexts[i])); - } else { - message = from_hex(vector.ciphertexts[i]); - ASSERT_TRUE(ciphers->decrypt(message)); - EXPECT_EQ(message, from_hex(PAYLOADS[i])); - } - } -} - -// connection.md "Sentinel Fallback", by lazy replay: an initiator bound to another PSK writes the -// vector's message 1 all the same, since KKpsk2 mixes the PSK in only at message 2; its read of the -// vector's message 2 fails, and the replay under the vector's PSK, with the same ephemeral key, -// reads it and splits into the vector's handshake hash and transport messages -TEST_P(NoiseInitiatorVector, ReplaysMessage1UnderAnotherPsk) { - const Vector& vector = GetParam(); - Psk other{}; - other.fill(0x42); - std::optional initiator = make_initiator(vector.suite, other); - ASSERT_TRUE(initiator); - EXPECT_EQ(initiator->write_message_1(from_hex(PAYLOADS[0])), from_hex(vector.ciphertexts[0])); - EXPECT_FALSE(initiator->read_message_2(from_hex(vector.ciphertexts[1]))); - // Control: the replay under the PSK that failed fails again - EXPECT_FALSE(initiator->replay_message_2(other, from_hex(vector.ciphertexts[1]))); - EXPECT_EQ(initiator->replay_message_2(array_from_hex<32>(PSK), from_hex(vector.ciphertexts[1])), - from_hex(PAYLOADS[1])); - std::optional ciphers = initiator->split(); - ASSERT_TRUE(ciphers); - EXPECT_EQ( - std::vector(ciphers->handshake_hash().begin(), ciphers->handshake_hash().end()), - from_hex(vector.handshake_hash)); - std::vector sent = from_hex(PAYLOADS[2]); - ASSERT_TRUE(ciphers->encrypt(sent)); - EXPECT_EQ(sent, from_hex(vector.ciphertexts[2])); - std::vector received = from_hex(vector.ciphertexts[3]); - ASSERT_TRUE(ciphers->decrypt(received)); - EXPECT_EQ(received, from_hex(PAYLOADS[3])); -} - -// The replay must write the message 1 the client read, or the transcripts differ: one whose kept -// payload or message 1 changed is refused. White-box: flips a byte of NoiseInitiator::payload_1_ -// or message_1_. Only the message 1 row is refused by the comparison alone, since the replay of -// the original payload reads message 2. Control: nothing flipped. -TEST_P(NoiseInitiatorVector, ReplaysOnlyTheMessage1TheClientRead) { - const Vector& vector = GetParam(); - struct Row { - const char* name; - std::vector NoiseInitiator::*kept; - bool replayed; - }; - const Row rows[] = { - {"Control: nothing flipped", nullptr, true}, - {"the payload flipped", &NoiseInitiator::payload_1_, false}, - {"message 1 flipped", &NoiseInitiator::message_1_, false}, - }; - for (const Row& row : rows) { - SCOPED_TRACE(row.name); - Psk other{}; - other.fill(0x42); - std::optional initiator = make_initiator(vector.suite, other); - ASSERT_TRUE(initiator); - ASSERT_TRUE(initiator->write_message_1(from_hex(PAYLOADS[0]))); - if (row.kept != nullptr) { - ((*initiator).*row.kept).back() ^= 0x01; - } - EXPECT_EQ( - initiator->replay_message_2(array_from_hex<32>(PSK), from_hex(vector.ciphertexts[1])) - .has_value(), - row.replayed); - } -} - -// The replay rebuilds message 1 from the ephemeral key write_message_1() kept: without a message -// 1 there is nothing to replay -TEST_P(NoiseInitiatorVector, ReplaysOnlyAfterMessage1) { - const Vector& vector = GetParam(); - std::optional initiator = make_initiator(vector.suite); - ASSERT_TRUE(initiator); - EXPECT_FALSE( - initiator->replay_message_2(array_from_hex<32>(PSK), from_hex(vector.ciphertexts[1]))); -} - -TEST_P(NoiseInitiatorVector, RejectsATamperedMessage2) { - const Vector& vector = GetParam(); - std::optional initiator = make_initiator(vector.suite); - ASSERT_TRUE(initiator); - ASSERT_TRUE(initiator->write_message_1(from_hex(PAYLOADS[0]))); - std::vector message_2 = from_hex(vector.ciphertexts[1]); - message_2.back() ^= 0x01; - EXPECT_FALSE(initiator->read_message_2(message_2)); - EXPECT_FALSE(initiator->split()); -} - -TEST_P(NoiseInitiatorVector, RejectsATamperedTransportMessage) { - const Vector& vector = GetParam(); - std::optional initiator = make_initiator(vector.suite); - ASSERT_TRUE(initiator); - ASSERT_TRUE(initiator->write_message_1(from_hex(PAYLOADS[0]))); - ASSERT_TRUE(initiator->read_message_2(from_hex(vector.ciphertexts[1]))); - std::optional ciphers = initiator->split(); - ASSERT_TRUE(ciphers); - // The responder's first transport message, with one bit flipped - std::vector message = from_hex(vector.ciphertexts[3]); - message.front() ^= 0x01; - EXPECT_FALSE(ciphers->decrypt(message)); -} - -// A plaintext with room reserved for the tag is encrypted where it lies, into the vector's -// transport message -TEST_P(NoiseInitiatorVector, EncryptsInPlaceIntoTheRoomReservedForTheTag) { - const Vector& vector = GetParam(); - std::optional initiator = make_initiator(vector.suite); - ASSERT_TRUE(initiator); - ASSERT_TRUE(initiator->write_message_1(from_hex(PAYLOADS[0]))); - ASSERT_TRUE(initiator->read_message_2(from_hex(vector.ciphertexts[1]))); - std::optional ciphers = initiator->split(); - ASSERT_TRUE(ciphers); - const std::vector payload = from_hex(PAYLOADS[2]); - std::vector message; - sendspin::server::reserve_plaintext(message, payload.size()); - message.assign(payload.begin(), payload.end()); - const uint8_t* const buffer = message.data(); - ASSERT_TRUE(ciphers->encrypt(message)); - EXPECT_EQ(message, from_hex(vector.ciphertexts[2])); - EXPECT_EQ(message.data(), buffer); -} - -INSTANTIATE_TEST_SUITE_P(Suites, NoiseInitiatorVector, testing::ValuesIn(VECTORS), - [](const testing::TestParamInfo& info) { - return std::string(info.param.name); - }); TEST(CipherSuiteNames, MapTheClientInitSuiteNames) { struct Row { diff --git a/tests/test_noise_initiator_internals.cpp b/tests/test_noise_initiator_internals.cpp new file mode 100644 index 0000000..ac806bf --- /dev/null +++ b/tests/test_noise_initiator_internals.cpp @@ -0,0 +1,272 @@ +// 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_noise_initiator_internals.cpp +/// @brief NoiseInitiator against the cacophony KKpsk2 vectors for both suites (noise-c's +/// tests/vector/cacophony.txt), and the lazy replay of the Sentinel Fallback against the same +/// vectors. +/// +/// White-box (tests/CMakeLists.txt): the vectors fix the initiator's ephemeral key, which only the +/// handshake state (NoiseInitiator::state_) can take, and the replay is shown refusing a changed +/// message 1 by flipping NoiseInitiator::payload_1_ or message_1_. + +#include "crypto/keys.h" +#include "fragmentation.h" +#include "hex.h" +#include "noise.h" +#include +#include + +#include +#include +#include + +namespace { + +using sendspin::server::CipherSuite; +using sendspin::server::Identity; +using sendspin::server::NoiseInitiator; +using sendspin::server::Psk; +using sendspin::server::TransportCiphers; +using sendspin::server::test::from_hex; +using sendspin::server::test::from_hex_array; + +struct Vector { + CipherSuite suite; + const char* name; + const char* handshake_hash; + // Ciphertexts of the six messages; the payloads are the same for both suites + const char* ciphertexts[6]; +}; + +constexpr const char* PROLOGUE = "4a6f686e2047616c74"; +constexpr const char* PSK = "54686973206973206d7920417573747269616e20706572737065637469766521"; +constexpr const char* INIT_STATIC = + "e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1"; +constexpr const char* INIT_EPHEMERAL = + "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a"; +constexpr const char* RESP_STATIC = + "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893"; +constexpr const char* PAYLOADS[6] = { + "4c756477696720766f6e204d69736573", + "4d757272617920526f746862617264", + "462e20412e20486179656b", + "4361726c204d656e676572", + "4a65616e2d426170746973746520536179", + "457567656e2042f6686d20766f6e2042617765726b", +}; + +const Vector VECTORS[] = { + {CipherSuite::CHACHAPOLY_SHA256, + "ChaChaPoly", + "7f3c5fdcdd3767e2835473a2683971490339f5bbeee82c3690bc606e14db70ed", + {"ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c7944babf6443250c604872e33233c3b9" + "a29df5c6d334ae2d53f1bd7f0b265a716b37", + "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f14480884366a1f5f0d79fe93ae476bd1897a7" + "a8ae92764898aa5d49e07b5849f35865ba", + "2eb2686b8814a7c0178fe18bfeeafe3e07312d69486d45e6572546", + "eea5791a890cd573a5c2e2345a8f98b0d1f0727acd24584fcddde5", + "ab2e1a411abaaa3df9cb497dffe4cfb70af6c71f0815b3c33b35e22329dee72f3e", + "ed22a0392c6afbfd6a6adea92b1faf13c4df24072f7060a20b1500609621c6957ac86d82f9"}}, + {CipherSuite::AESGCM_SHA256, + "AESGCM", + "1d2be5b73c43962fa87db816e6b5a73a0d67053e29b3d89fcee6ae299b5a5c1f", + {"ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c794483cf298f1bfed916ea20d8f528bd" + "30670670c7245e236a68f021b5a521d8bf31", + "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f14480884332b32e0f483ae58a9c36e93390a9" + "c2238918bda6ae0ebfe48a400b8f565f5f", + "f7cf1f47885fa5ff7cefecf3db3066f89b996834069b8f1517c55a", + "ce7de28a6b54b559d3db2f7482f1be3264a62165f12a73e377ebde", + "da4173c5aa84756d0eab64adfbe81f10538b099966897771a071502ee2899e2fbd", + "1a1e3408acea689f5cced09b246e13e68e1ce146e8f1b3478482593b988587be99ba7595d2"}}, +}; + +class NoiseInitiatorVector : public testing::TestWithParam { +protected: + static void SetUpTestSuite() { + ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); + } + + // The initiator with the vector's keys, prologue and fixed ephemeral key, and psk + static std::optional make_initiator(CipherSuite suite, + const Psk& psk = from_hex_array<32>(PSK)) { + static const std::optional local = + Identity::from_private_bytes(from_hex_array<32>(INIT_STATIC)); + const std::optional remote = + Identity::from_private_bytes(from_hex_array<32>(RESP_STATIC)); + EXPECT_TRUE(local && remote); + std::optional initiator = + NoiseInitiator::create(suite, *local, remote->public_bytes, from_hex(PROLOGUE), psk); + EXPECT_TRUE(initiator); + const std::vector ephemeral = from_hex(INIT_EPHEMERAL); + EXPECT_EQ(noise_dhstate_set_keypair_private( + noise_handshakestate_get_fixed_ephemeral_dh(initiator->state_.get()), + ephemeral.data(), ephemeral.size()), + NOISE_ERROR_NONE); + return initiator; + } +}; + +TEST_P(NoiseInitiatorVector, ProducesTheVectorsHandshakeAndTransportMessages) { + const Vector& vector = GetParam(); + std::optional initiator = make_initiator(vector.suite); + ASSERT_TRUE(initiator); + + EXPECT_EQ(initiator->write_message_1(from_hex(PAYLOADS[0])), from_hex(vector.ciphertexts[0])); + EXPECT_EQ(initiator->read_message_2(from_hex(vector.ciphertexts[1])), from_hex(PAYLOADS[1])); + std::optional ciphers = initiator->split(); + ASSERT_TRUE(ciphers); + EXPECT_EQ( + std::vector(ciphers->handshake_hash().begin(), ciphers->handshake_hash().end()), + from_hex(vector.handshake_hash)); + + // Transport messages alternate, initiator first + for (size_t i = 2; i < 6; ++i) { + SCOPED_TRACE(i); + std::vector message; + if (i % 2 == 0) { + message = from_hex(PAYLOADS[i]); + ASSERT_TRUE(ciphers->encrypt(message)); + EXPECT_EQ(message, from_hex(vector.ciphertexts[i])); + } else { + message = from_hex(vector.ciphertexts[i]); + ASSERT_TRUE(ciphers->decrypt(message)); + EXPECT_EQ(message, from_hex(PAYLOADS[i])); + } + } +} + +// connection.md "Sentinel Fallback", by lazy replay: an initiator bound to another PSK writes the +// vector's message 1 all the same, since KKpsk2 mixes the PSK in only at message 2; its read of the +// vector's message 2 fails, and the replay under the vector's PSK, with the same ephemeral key, +// reads it and splits into the vector's handshake hash and transport messages +TEST_P(NoiseInitiatorVector, ReplaysMessage1UnderAnotherPsk) { + const Vector& vector = GetParam(); + Psk other{}; + other.fill(0x42); + std::optional initiator = make_initiator(vector.suite, other); + ASSERT_TRUE(initiator); + EXPECT_EQ(initiator->write_message_1(from_hex(PAYLOADS[0])), from_hex(vector.ciphertexts[0])); + EXPECT_FALSE(initiator->read_message_2(from_hex(vector.ciphertexts[1]))); + // Control: the replay under the PSK that failed fails again + EXPECT_FALSE(initiator->replay_message_2(other, from_hex(vector.ciphertexts[1]))); + EXPECT_EQ(initiator->replay_message_2(from_hex_array<32>(PSK), from_hex(vector.ciphertexts[1])), + from_hex(PAYLOADS[1])); + std::optional ciphers = initiator->split(); + ASSERT_TRUE(ciphers); + EXPECT_EQ( + std::vector(ciphers->handshake_hash().begin(), ciphers->handshake_hash().end()), + from_hex(vector.handshake_hash)); + std::vector sent = from_hex(PAYLOADS[2]); + ASSERT_TRUE(ciphers->encrypt(sent)); + EXPECT_EQ(sent, from_hex(vector.ciphertexts[2])); + std::vector received = from_hex(vector.ciphertexts[3]); + ASSERT_TRUE(ciphers->decrypt(received)); + EXPECT_EQ(received, from_hex(PAYLOADS[3])); +} + +// The replay must write the message 1 the client read, or the transcripts differ: one whose kept +// payload or message 1 changed is refused. White-box: flips a byte of NoiseInitiator::payload_1_ +// or message_1_. Only the message 1 row is refused by the comparison alone, since the replay of +// the original payload reads message 2. Control: nothing flipped. +TEST_P(NoiseInitiatorVector, ReplaysOnlyTheMessage1TheClientRead) { + const Vector& vector = GetParam(); + struct Row { + const char* name; + std::vector NoiseInitiator::*kept; + bool replayed; + }; + const Row rows[] = { + {"Control: nothing flipped", nullptr, true}, + {"the payload flipped", &NoiseInitiator::payload_1_, false}, + {"message 1 flipped", &NoiseInitiator::message_1_, false}, + }; + for (const Row& row : rows) { + SCOPED_TRACE(row.name); + Psk other{}; + other.fill(0x42); + std::optional initiator = make_initiator(vector.suite, other); + ASSERT_TRUE(initiator); + ASSERT_TRUE(initiator->write_message_1(from_hex(PAYLOADS[0]))); + if (row.kept != nullptr) { + ((*initiator).*row.kept).back() ^= 0x01; + } + EXPECT_EQ( + initiator->replay_message_2(from_hex_array<32>(PSK), from_hex(vector.ciphertexts[1])) + .has_value(), + row.replayed); + } +} + +// The replay rebuilds message 1 from the ephemeral key write_message_1() kept: without a message +// 1 there is nothing to replay +TEST_P(NoiseInitiatorVector, ReplaysOnlyAfterMessage1) { + const Vector& vector = GetParam(); + std::optional initiator = make_initiator(vector.suite); + ASSERT_TRUE(initiator); + EXPECT_FALSE( + initiator->replay_message_2(from_hex_array<32>(PSK), from_hex(vector.ciphertexts[1]))); +} + +TEST_P(NoiseInitiatorVector, RejectsATamperedMessage2) { + const Vector& vector = GetParam(); + std::optional initiator = make_initiator(vector.suite); + ASSERT_TRUE(initiator); + ASSERT_TRUE(initiator->write_message_1(from_hex(PAYLOADS[0]))); + std::vector message_2 = from_hex(vector.ciphertexts[1]); + message_2.back() ^= 0x01; + EXPECT_FALSE(initiator->read_message_2(message_2)); + EXPECT_FALSE(initiator->split()); +} + +TEST_P(NoiseInitiatorVector, RejectsATamperedTransportMessage) { + const Vector& vector = GetParam(); + std::optional initiator = make_initiator(vector.suite); + ASSERT_TRUE(initiator); + ASSERT_TRUE(initiator->write_message_1(from_hex(PAYLOADS[0]))); + ASSERT_TRUE(initiator->read_message_2(from_hex(vector.ciphertexts[1]))); + std::optional ciphers = initiator->split(); + ASSERT_TRUE(ciphers); + // The responder's first transport message, with one bit flipped + std::vector message = from_hex(vector.ciphertexts[3]); + message.front() ^= 0x01; + EXPECT_FALSE(ciphers->decrypt(message)); +} + +// A plaintext with room reserved for the tag is encrypted where it lies, into the vector's +// transport message +TEST_P(NoiseInitiatorVector, EncryptsInPlaceIntoTheRoomReservedForTheTag) { + const Vector& vector = GetParam(); + std::optional initiator = make_initiator(vector.suite); + ASSERT_TRUE(initiator); + ASSERT_TRUE(initiator->write_message_1(from_hex(PAYLOADS[0]))); + ASSERT_TRUE(initiator->read_message_2(from_hex(vector.ciphertexts[1]))); + std::optional ciphers = initiator->split(); + ASSERT_TRUE(ciphers); + const std::vector payload = from_hex(PAYLOADS[2]); + std::vector message; + sendspin::server::reserve_plaintext(message, payload.size()); + message.assign(payload.begin(), payload.end()); + const uint8_t* const buffer = message.data(); + ASSERT_TRUE(ciphers->encrypt(message)); + EXPECT_EQ(message, from_hex(vector.ciphertexts[2])); + EXPECT_EQ(message.data(), buffer); +} + +INSTANTIATE_TEST_SUITE_P(Suites, NoiseInitiatorVector, testing::ValuesIn(VECTORS), + [](const testing::TestParamInfo& info) { + return std::string(info.param.name); + }); + +} // namespace diff --git a/tests/test_pairing_crypto.cpp b/tests/test_pairing_crypto.cpp index 786fcba..f0d4804 100644 --- a/tests/test_pairing_crypto.cpp +++ b/tests/test_pairing_crypto.cpp @@ -21,10 +21,8 @@ /// draft-irtf-cfrg-cpace-21 testvectors.json G_25519 and X25519_points sets, and the known /// answers of sendspin-cpp's tests/test_cpace.cpp, test_pairing_code.cpp, test_psk_wrap.cpp at /// commit 91038b72892596b9ec0cccfec1b9e6ebc58ba148, which that repository derived from the -/// aiosendspin Python reference. -/// -/// White-box: the fixed-scalar CPace tests overwrite CPace::scalar_ and public_share_, since -/// start() samples its scalar (see tests/CMakeLists.txt). +/// aiosendspin Python reference. The fixed-scalar CPace runs are in +/// test_pairing_crypto_internals.cpp. #include "crypto/cpace.h" #include "crypto/crypto.h" @@ -32,14 +30,13 @@ #include "crypto/pairing_code.h" #include "crypto/pairing_token.h" #include "crypto/psk_wrap.h" +#include "hex.h" #include #include #include #include #include -#include -#include #include #include #include @@ -51,51 +48,14 @@ using sendspin::server::CPace; using sendspin::server::CPACE_SHARE_SIZE; using sendspin::server::CPACE_TAG_SIZE; using sendspin::server::CPaceRole; - -std::string hex(const uint8_t* data, size_t size) { - std::string out; - for (size_t i = 0; i < size; ++i) { - char byte[3]; - std::snprintf(byte, sizeof(byte), "%02x", data[i]); - out += byte; - } - return out; -} - -template -std::string hex(const Bytes& bytes) { - return hex(bytes.data(), bytes.size()); -} - -std::vector from_hex(std::string_view text) { - std::vector out; - for (size_t i = 0; i + 1 < text.size(); i += 2) { - out.push_back(static_cast(std::stoi(std::string(text.substr(i, 2)), nullptr, 16))); - } - return out; -} - -template -std::array from_hex_array(std::string_view text) { - const std::vector bytes = from_hex(text); - std::array out{}; - std::memcpy(out.data(), bytes.data(), std::min(N, bytes.size())); - return out; -} +using sendspin::server::test::from_hex; +using sendspin::server::test::from_hex_array; +using sendspin::server::test::hex; std::vector bytes_of(std::string_view text) { return {text.begin(), text.end()}; } -/// Replaces a started CPace's scalar with a fixed one and recomputes its share, leaving it as if -/// start() had sampled that scalar; gen is the generator of the side's (prs, ci, sid) -bool force_scalar(CPace& side, const std::array& scalar, - const std::array& gen) { - side.scalar_ = scalar; - return sendspin::server::x25519_scalar_mult(side.scalar_.data(), gen.data(), - side.public_share_.data()); -} - // ============================================================================== // CPace building blocks (sendspin-cpp test_cpace.cpp) // ============================================================================== @@ -259,80 +219,6 @@ TEST(CPacePrimitives, MultipliesWithAClampedScalar) { // CPace runs // ============================================================================== -// sendspin-cpp test_cpace.cpp FixedScalarDrivesProductionDeriveAndTag: the shares, ISK and tags -// of a run with fixed scalars, through derive(), isk(), tag() and verify() -TEST(CPaceRun, MatchesTheFixedScalarKnownAnswers) { - const std::vector prs = bytes_of("1234"); - const std::vector sid = bytes_of("test-session-id-1234567890123456"); - const std::vector none; - CPace a; - CPace b; - ASSERT_TRUE(a.start(CPaceRole::INITIATOR, prs, sid, none, none, none)); - ASSERT_TRUE(b.start(CPaceRole::RESPONDER, prs, sid, none, none, none)); - const auto gen = sendspin::server::cpace_calculate_generator(prs.data(), prs.size(), nullptr, 0, - sid.data(), sid.size()); - std::array scalar_a{}; - std::array scalar_b{}; - scalar_a.fill(0x10); - scalar_b.fill(0x20); - ASSERT_TRUE(force_scalar(a, scalar_a, gen)); - ASSERT_TRUE(force_scalar(b, scalar_b, gen)); - EXPECT_EQ(hex(a.public_share()), - "57551a92c995ea9b792f4e89a18c34459491c1484deddbb1498c29b499286c0f"); - EXPECT_EQ(hex(b.public_share()), - "a057a00be47920856f4ae0574971590e9fb0b5d97b89dba12237957dd8ec577f"); - ASSERT_TRUE(a.derive(b.public_share().data(), CPACE_SHARE_SIZE)); - ASSERT_TRUE(b.derive(a.public_share().data(), CPACE_SHARE_SIZE)); - const std::string isk = "fab2965a5e084943c7d286b66a998daf6af5e370040aec16f544f3e8dbc9ba0f" - "87149a1a88bc73f0f5c3f9a8fa2cfc78ee88a53941614c69b68c827665f53d3b"; - EXPECT_EQ(hex(*a.isk()), isk); - EXPECT_EQ(hex(*b.isk()), isk); - const std::string ta = "fe47abf1649b3f91ce6e3b38870ac3947a209a1dc1dab2c88db77791e9432426" - "111e9555562a6f7a41a14079de95617f1396d82c8066f19c8aa486e7f70b8ef0"; - const std::string tb = "806b304d1cc3f232ed45afd6d2dc7e08ffd69aeb57ca3c78de75c5d280024a6c" - "263ec2c6da7414899a91d085b826d63ca320bf50f7b49d985a3db00c8c891e45"; - EXPECT_EQ(hex(*a.tag()), ta); - EXPECT_EQ(hex(*b.tag()), tb); - EXPECT_TRUE(b.verify(from_hex(ta).data(), CPACE_TAG_SIZE)); - EXPECT_TRUE(a.verify(from_hex(tb).data(), CPACE_TAG_SIZE)); -} - -// draft-irtf-cfrg-cpace-21 testvectors.json, G_25519: the generator, shares, K and ISK_IR -TEST(CPaceRun, MatchesTheDraft21Vectors) { - const std::vector prs = from_hex("50617373776f7264"); - const std::vector ci = from_hex("0b415f696e69746961746f720b425f726573706f6e646572"); - const std::vector sid = from_hex("7e4b4791d6a8ef019b936c79fb7f2c57"); - const std::vector ada = from_hex("414461"); - const std::vector adb = from_hex("414462"); - const auto gen = sendspin::server::cpace_calculate_generator(prs.data(), prs.size(), ci.data(), - ci.size(), sid.data(), sid.size()); - EXPECT_EQ(hex(gen), "d04bf6d41f6a289632a2e929fa29bebd51092512a7829fdde7d314b62f05a73f"); - CPace a; - CPace b; - ASSERT_TRUE(a.start(CPaceRole::INITIATOR, prs, sid, ci, ada, adb)); - ASSERT_TRUE(b.start(CPaceRole::RESPONDER, prs, sid, ci, adb, ada)); - const auto scalar_a = - from_hex_array<32>("21b4f4bd9e64ed355c3eb676a28ebedaf6d8f17bdc365995b319097153044080"); - const auto scalar_b = - from_hex_array<32>("848b0779ff415f0af4ea14df9dd1d3c29ac41d836c7808896c4eba19c51ac40a"); - ASSERT_TRUE(force_scalar(a, scalar_a, gen)); - ASSERT_TRUE(force_scalar(b, scalar_b, gen)); - EXPECT_EQ(hex(a.public_share()), - "1d13c89278cdadd826f6d8d7f887701430f8380ddc17611cdd6dc989ce0c9f32"); - EXPECT_EQ(hex(b.public_share()), - "248cccf6d5cdc3646f0ad593f9e6cef4e69d4945f8372e623512ecea32185623"); - std::array k{}; - ASSERT_TRUE( - sendspin::server::x25519_scalar_mult(scalar_a.data(), b.public_share().data(), k.data())); - EXPECT_EQ(hex(k), "5b067effbdc0b2a0e1d907b21ebb25cfedb96a852179a847c37e43ee71322c6b"); - ASSERT_TRUE(a.derive(b.public_share().data(), CPACE_SHARE_SIZE)); - ASSERT_TRUE(b.derive(a.public_share().data(), CPACE_SHARE_SIZE)); - const std::string isk = "6e19b875f7a561d6b3ca3dbb9ef42ac55de3e717881018204b8922b4d5e53bb2" - "aa82c300bea7b65d2b671da71922ddf6472301b79bc270adfa8bf413285f2263"; - EXPECT_EQ(hex(*a.isk()), isk); - EXPECT_EQ(hex(*b.isk()), isk); -} - // draft-irtf-cfrg-cpace-21 testvectors.json, X25519_points: shares that yield K = 0 are refused, // and so are a share of the wrong size, a second derive() and a derive() before start(). The // draft's five further points with bit 255 set are weak only for an X25519 that does not mask that diff --git a/tests/test_pairing_crypto_internals.cpp b/tests/test_pairing_crypto_internals.cpp new file mode 100644 index 0000000..7b05c9c --- /dev/null +++ b/tests/test_pairing_crypto_internals.cpp @@ -0,0 +1,127 @@ +// 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_pairing_crypto_internals.cpp +/// @brief CPace runs with fixed scalars against sendspin-cpp's known answers and the +/// draft-irtf-cfrg-cpace-21 G_25519 vectors. Compiled with -fno-access-control +/// (tests/CMakeLists.txt): the scalar is overwritten, since start() samples it from the CSPRNG. + +#include "crypto/cpace.h" +#include "hex.h" +#include + +#include +#include +#include +#include +#include + +namespace { + +using sendspin::server::CPace; +using sendspin::server::CPACE_SHARE_SIZE; +using sendspin::server::CPACE_TAG_SIZE; +using sendspin::server::CPaceRole; +using sendspin::server::test::from_hex; +using sendspin::server::test::from_hex_array; +using sendspin::server::test::hex; + +std::vector bytes_of(std::string_view text) { + return {text.begin(), text.end()}; +} + +/// Replaces a started CPace's scalar with a fixed one and recomputes its share, leaving it as if +/// start() had sampled that scalar; gen is the generator of the side's (prs, ci, sid) +bool force_scalar(CPace& side, const std::array& scalar, + const std::array& gen) { + side.scalar_ = scalar; + return sendspin::server::x25519_scalar_mult(side.scalar_.data(), gen.data(), + side.public_share_.data()); +} + +// sendspin-cpp test_cpace.cpp FixedScalarDrivesProductionDeriveAndTag: the shares, ISK and tags +// of a run with fixed scalars, through derive(), isk(), tag() and verify() +TEST(CPaceRun, MatchesTheFixedScalarKnownAnswers) { + const std::vector prs = bytes_of("1234"); + const std::vector sid = bytes_of("test-session-id-1234567890123456"); + const std::vector none; + CPace a; + CPace b; + ASSERT_TRUE(a.start(CPaceRole::INITIATOR, prs, sid, none, none, none)); + ASSERT_TRUE(b.start(CPaceRole::RESPONDER, prs, sid, none, none, none)); + const auto gen = sendspin::server::cpace_calculate_generator(prs.data(), prs.size(), nullptr, 0, + sid.data(), sid.size()); + std::array scalar_a{}; + std::array scalar_b{}; + scalar_a.fill(0x10); + scalar_b.fill(0x20); + ASSERT_TRUE(force_scalar(a, scalar_a, gen)); + ASSERT_TRUE(force_scalar(b, scalar_b, gen)); + EXPECT_EQ(hex(a.public_share()), + "57551a92c995ea9b792f4e89a18c34459491c1484deddbb1498c29b499286c0f"); + EXPECT_EQ(hex(b.public_share()), + "a057a00be47920856f4ae0574971590e9fb0b5d97b89dba12237957dd8ec577f"); + ASSERT_TRUE(a.derive(b.public_share().data(), CPACE_SHARE_SIZE)); + ASSERT_TRUE(b.derive(a.public_share().data(), CPACE_SHARE_SIZE)); + const std::string isk = "fab2965a5e084943c7d286b66a998daf6af5e370040aec16f544f3e8dbc9ba0f" + "87149a1a88bc73f0f5c3f9a8fa2cfc78ee88a53941614c69b68c827665f53d3b"; + EXPECT_EQ(hex(*a.isk()), isk); + EXPECT_EQ(hex(*b.isk()), isk); + const std::string ta = "fe47abf1649b3f91ce6e3b38870ac3947a209a1dc1dab2c88db77791e9432426" + "111e9555562a6f7a41a14079de95617f1396d82c8066f19c8aa486e7f70b8ef0"; + const std::string tb = "806b304d1cc3f232ed45afd6d2dc7e08ffd69aeb57ca3c78de75c5d280024a6c" + "263ec2c6da7414899a91d085b826d63ca320bf50f7b49d985a3db00c8c891e45"; + EXPECT_EQ(hex(*a.tag()), ta); + EXPECT_EQ(hex(*b.tag()), tb); + EXPECT_TRUE(b.verify(from_hex(ta).data(), CPACE_TAG_SIZE)); + EXPECT_TRUE(a.verify(from_hex(tb).data(), CPACE_TAG_SIZE)); +} + +// draft-irtf-cfrg-cpace-21 testvectors.json, G_25519: the generator, shares, K and ISK_IR +TEST(CPaceRun, MatchesTheDraft21Vectors) { + const std::vector prs = from_hex("50617373776f7264"); + const std::vector ci = from_hex("0b415f696e69746961746f720b425f726573706f6e646572"); + const std::vector sid = from_hex("7e4b4791d6a8ef019b936c79fb7f2c57"); + const std::vector ada = from_hex("414461"); + const std::vector adb = from_hex("414462"); + const auto gen = sendspin::server::cpace_calculate_generator(prs.data(), prs.size(), ci.data(), + ci.size(), sid.data(), sid.size()); + EXPECT_EQ(hex(gen), "d04bf6d41f6a289632a2e929fa29bebd51092512a7829fdde7d314b62f05a73f"); + CPace a; + CPace b; + ASSERT_TRUE(a.start(CPaceRole::INITIATOR, prs, sid, ci, ada, adb)); + ASSERT_TRUE(b.start(CPaceRole::RESPONDER, prs, sid, ci, adb, ada)); + const auto scalar_a = + from_hex_array<32>("21b4f4bd9e64ed355c3eb676a28ebedaf6d8f17bdc365995b319097153044080"); + const auto scalar_b = + from_hex_array<32>("848b0779ff415f0af4ea14df9dd1d3c29ac41d836c7808896c4eba19c51ac40a"); + ASSERT_TRUE(force_scalar(a, scalar_a, gen)); + ASSERT_TRUE(force_scalar(b, scalar_b, gen)); + EXPECT_EQ(hex(a.public_share()), + "1d13c89278cdadd826f6d8d7f887701430f8380ddc17611cdd6dc989ce0c9f32"); + EXPECT_EQ(hex(b.public_share()), + "248cccf6d5cdc3646f0ad593f9e6cef4e69d4945f8372e623512ecea32185623"); + std::array k{}; + ASSERT_TRUE( + sendspin::server::x25519_scalar_mult(scalar_a.data(), b.public_share().data(), k.data())); + EXPECT_EQ(hex(k), "5b067effbdc0b2a0e1d907b21ebb25cfedb96a852179a847c37e43ee71322c6b"); + ASSERT_TRUE(a.derive(b.public_share().data(), CPACE_SHARE_SIZE)); + ASSERT_TRUE(b.derive(a.public_share().data(), CPACE_SHARE_SIZE)); + const std::string isk = "6e19b875f7a561d6b3ca3dbb9ef42ac55de3e717881018204b8922b4d5e53bb2" + "aa82c300bea7b65d2b671da71922ddf6472301b79bc270adfa8bf413285f2263"; + EXPECT_EQ(hex(*a.isk()), isk); + EXPECT_EQ(hex(*b.isk()), isk); +} + +} // namespace diff --git a/tests/test_server_core.cpp b/tests/test_server_core.cpp index c9727cf..ef8f6fc 100644 --- a/tests/test_server_core.cpp +++ b/tests/test_server_core.cpp @@ -13,346 +13,27 @@ // limitations under the License. /// @file test_server_core.cpp -/// @brief ServerCore on its own core thread: a dial toward a URL whose dials keep failing waits -/// out the URL's backoff, and pruning keeps a failed target; a lost instance's target goes with -/// its wait for a dial, and a dial that waited checks again before it starts. White-box: reaches -/// ServerCore's targets_ and prune_targets() and HandshakeBackoff::next_dial_us_ (see -/// tests/CMakeLists.txt). Shutdown closes an input whose attachment has not run. A pipeline -/// failure, forced by swapping a group's encoder, ends the stream on its format and leaves a drain -/// to complete. A member whose leaving or stopping its group overflows its control FIFO, its cap -/// lowered through Session::queues_, closes inside that step, and so does a client's second -/// connection inside the host's move of its first. +/// @brief ServerCore on its own core thread: shutdown closes an input whose attachment has not +/// run, and a group takes an input over one the host released whose detach has not run. -#include "clock.h" #include "crypto/keys.h" #include "dispatcher.h" -#include "frame_encoder.h" #include "host_view.h" -#include "raw_client.h" -#include "sched/constants.h" #include "server_core.h" -#include "server_core_client.h" -#include "test_ports.h" #include "test_recorder.h" #include "transport.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::ServerError; -namespace wire = sendspin::server::test::wire; - -class ErrorCounter : public sendspin::server::ServerListener { -public: - void on_error(const ServerError& /*error*/) override { - { - const std::lock_guard lock(this->mutex_); - ++this->errors_; - } - this->changed_.notify_all(); - } - - /// Waits with no timeout; a regression hangs into the suite watchdog - void wait_for(int errors) { - std::unique_lock lock(this->mutex_); - this->changed_.wait(lock, [&] { return this->errors_ >= errors; }); - } - -private: - std::mutex mutex_; - std::condition_variable changed_; - int errors_{0}; -}; - -struct TargetState { - bool exists{false}; - bool dialing{false}; - bool retry_pending{false}; - uint32_t failures{0}; - bool waiting{false}; // next_dial_us is in the future -}; - -bool dial_in_progress(const sendspin::server::ServerCore::Target& target) { - return std::holds_alternative(target.phase); -} - -bool retry_armed(const sendspin::server::ServerCore::Target& target) { - return std::holds_alternative(target.phase); -} - -// messaging.md "client/goodbye": backoff on repeated failures toward one target -TEST(ServerCoreBackoff, ADialAfterAFailureWaitsOutTheBackoff) { - ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); - sendspin::server::EventLoop loop; - sendspin::server::AudioWorkers workers(1); - // Outlives the dispatcher, whose thread calls it until joined - ErrorCounter listener; - sendspin::server::Dispatcher dispatcher; - const sendspin::server::Identity identity = *sendspin::server::Identity::generate(); - const sendspin::server::CoreSettings settings; - sendspin::server::ClientSnapshot snapshot; - sendspin::server::GroupSnapshot groups; - sendspin::server::InputRegistry inputs; - std::atomic group_ids{1}; - sendspin::server::ServerCore core(loop, workers, dispatcher, &listener, identity, settings, - snapshot, groups, inputs, group_ids); - std::thread core_thread([&] { loop.run(); }); - - const sendspin::server::test::UnlistenedPort unlistened; - const std::string url = "ws://127.0.0.1:" + std::to_string(unlistened.port()) + "/sendspin"; - const sendspin::server::DialTarget target = *sendspin::server::parse_ws_url(url); - const auto connect_and_inspect = [&] { - std::promise inspected; - loop.post([&] { - core.connect_url(url, target); - TargetState state; - const auto it = core.targets_.find(url); - if (it != core.targets_.end()) { - state = {true, dial_in_progress(it->second), retry_armed(it->second), - it->second.backoff.failures(), - it->second.backoff.next_dial_us() > sendspin::server::now_us()}; - } - inspected.set_value(state); - }); - return inspected.get_future().get(); - }; - - // Control: the first dial starts at once - const TargetState first = connect_and_inspect(); - EXPECT_TRUE(first.dialing); - EXPECT_FALSE(first.retry_pending); - listener.wait_for(1); - - // On the core thread: a failed target outlives a prune just after its wait ends, so a host - // retrying then still escalates the backoff. Its wait is then cut short, so the deferred dial - // below comes soon. - std::promise> pruned; - loop.post([&] { - sendspin::server::HandshakeBackoff& backoff = core.targets_.at(url).backoff; - core.prune_targets(backoff.next_dial_us() + 1); - const auto it = core.targets_.find(url); - pruned.set_value({it != core.targets_.end(), - it != core.targets_.end() ? it->second.backoff.failures() : 0U}); - if (it != core.targets_.end()) { - constexpr int64_t SHORT_WAIT_US = 50000; - it->second.backoff.next_dial_us_ = sendspin::server::now_us() + SHORT_WAIT_US; - } - }); - const std::pair after_prune = pruned.get_future().get(); - EXPECT_TRUE(after_prune.first); - EXPECT_EQ(after_prune.second, 1U); - - // Within the backoff the dial is deferred, not started - const TargetState second = connect_and_inspect(); - ASSERT_TRUE(second.exists); - EXPECT_EQ(second.failures, 1U); - EXPECT_TRUE(second.waiting); - EXPECT_FALSE(second.dialing); - EXPECT_TRUE(second.retry_pending); - - // The deferred dial runs when the backoff ends, and its failure counts again - listener.wait_for(2); - std::promise> last; - loop.post([&] { - const sendspin::server::HandshakeBackoff backoff = core.targets_.at(url).backoff; - // Control: a full maximum wait after its last wait ended, the idle target goes - core.prune_targets(backoff.next_dial_us() + settings.backoff.max_us); - last.set_value({backoff.failures(), core.targets_.count(url) == 0}); - }); - const std::pair final_state = last.get_future().get(); - EXPECT_EQ(final_state.first, 2U); - EXPECT_TRUE(final_state.second); - - loop.post([&] { core.shutdown(0); }); - core_thread.join(); -} - -// ConnectOptions::retry_until_connected: a URL target whose dial failed is dialed again on the -// server's own after its backoff (a retry scheduled), and cancel_connect_url() withdraws it; -// without the option a failed dial ends the operator's request -TEST(ServerCoreBackoff, RetriesAUrlUntilConnectedUntilCancelled) { - ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); - struct Row { - const char* name; - bool retry; - bool scheduled; - }; - const Row rows[] = { - {"Control: a plain dial", false, false}, - {"retry until connected", true, true}, - }; - for (const Row& row : rows) { - SCOPED_TRACE(row.name); - sendspin::server::EventLoop loop; - sendspin::server::AudioWorkers workers(1); - // Outlives the dispatcher, whose thread calls it until joined - ErrorCounter listener; - sendspin::server::Dispatcher dispatcher; - const sendspin::server::Identity identity = *sendspin::server::Identity::generate(); - const sendspin::server::CoreSettings settings; - sendspin::server::ClientSnapshot snapshot; - sendspin::server::GroupSnapshot groups; - sendspin::server::InputRegistry inputs; - std::atomic group_ids{1}; - sendspin::server::ServerCore core(loop, workers, dispatcher, &listener, identity, settings, - snapshot, groups, inputs, group_ids); - std::thread core_thread([&] { loop.run(); }); - - const sendspin::server::test::UnlistenedPort unlistened; - const std::string url = "ws://127.0.0.1:" + std::to_string(unlistened.port()) + "/sendspin"; - loop.post([&] { core.connect_url(url, *sendspin::server::parse_ws_url(url), row.retry); }); - listener.wait_for(1); - const auto inspect = [&] { - std::promise inspected; - loop.post([&] { - TargetState state; - const auto it = core.targets_.find(url); - if (it != core.targets_.end()) { - state = {true, dial_in_progress(it->second), retry_armed(it->second), - it->second.backoff.failures(), - it->second.backoff.next_dial_us() > sendspin::server::now_us()}; - } - inspected.set_value(state); - }); - return inspected.get_future().get(); - }; - const TargetState failed = inspect(); - ASSERT_TRUE(failed.exists); - EXPECT_EQ(failed.failures, 1U); - EXPECT_EQ(failed.retry_pending, row.scheduled); - - loop.post([&] { core.cancel_connect_url(url); }); - const TargetState cancelled = inspect(); - EXPECT_FALSE(cancelled.retry_pending); - EXPECT_FALSE(cancelled.dialing); - - loop.post([&] { core.shutdown(0); }); - core_thread.join(); - } -} - -// A client found by browsing whose dial failed waits out its backoff; once its instance is lost the -// target goes, which cancels the wait, so it is not dialed again -TEST(ServerCoreDiscovery, ALostInstanceCancelsItsDialWait) { - ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); - sendspin::server::EventLoop loop; - sendspin::server::AudioWorkers workers(1); - // Outlives the dispatcher, whose thread calls it until joined - ErrorCounter listener; - sendspin::server::Dispatcher dispatcher; - const sendspin::server::Identity identity = *sendspin::server::Identity::generate(); - const sendspin::server::CoreSettings settings; - sendspin::server::ClientSnapshot snapshot; - sendspin::server::GroupSnapshot groups; - sendspin::server::InputRegistry inputs; - std::atomic group_ids{1}; - sendspin::server::ServerCore core(loop, workers, dispatcher, &listener, identity, settings, - snapshot, groups, inputs, group_ids); - std::thread core_thread([&] { loop.run(); }); - - const sendspin::server::test::UnlistenedPort unlistened; - sendspin::server::DiscoveredClient found; - found.instance = "Gone"; - found.addresses = {"127.0.0.1"}; - found.port = unlistened.port(); - found.path = "/sendspin"; - loop.post([&] { core.on_client_found(found); }); - listener.wait_for(1); - const std::string key = "mdns:Gone"; - std::promise> waiting; - loop.post([&] { - const auto it = core.targets_.find(key); - waiting.set_value( - {it != core.targets_.end(), it != core.targets_.end() && retry_armed(it->second)}); - }); - const std::pair before = waiting.get_future().get(); - EXPECT_TRUE(before.first); - EXPECT_TRUE(before.second); - - std::promise after; - loop.post([&] { - core.on_client_lost("Gone"); - after.set_value(core.targets_.count(key) == 0); - }); - EXPECT_TRUE(after.get_future().get()); - EXPECT_TRUE(snapshot.read().empty()); - - loop.post([&] { core.shutdown(0); }); - core_thread.join(); -} - -// A dial on the server's own that waited out its backoff checks again before it starts, so one no -// longer due (here a URL target that stopped reconnecting) is not made -TEST(ServerCoreDiscovery, ADialThatWaitedChecksAgainBeforeItStarts) { - ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); - sendspin::server::EventLoop loop; - sendspin::server::AudioWorkers workers(1); - // Outlives the dispatcher, whose thread calls it until joined - ErrorCounter listener; - sendspin::server::Dispatcher dispatcher; - const sendspin::server::Identity identity = *sendspin::server::Identity::generate(); - const sendspin::server::CoreSettings settings; - sendspin::server::ClientSnapshot snapshot; - sendspin::server::GroupSnapshot groups; - sendspin::server::InputRegistry inputs; - std::atomic group_ids{1}; - sendspin::server::ServerCore core(loop, workers, dispatcher, &listener, identity, settings, - snapshot, groups, inputs, group_ids); - std::thread core_thread([&] { loop.run(); }); - - const sendspin::server::test::UnlistenedPort unlistened; - const std::string url = "ws://127.0.0.1:" + std::to_string(unlistened.port()) + "/sendspin"; - const sendspin::server::DialTarget target = *sendspin::server::parse_ws_url(url); - loop.post([&] { core.connect_url(url, target); }); - listener.wait_for(1); - // Reconnecting, so the failure's backoff schedules a dial on the server's own; then no longer - // reconnecting before the short wait ends - loop.post([&] { - auto& entry = core.targets_.at(url); - entry.reconnecting = true; - constexpr int64_t SHORT_WAIT_US = 50000; - entry.backoff.next_dial_us_ = sendspin::server::now_us() + SHORT_WAIT_US; - core.dial_if_due(entry); - entry.reconnecting = false; - }); - struct State { - bool retry_pending; - bool dialing; - uint32_t failures; - }; - State state{true, false, 0}; - while (state.retry_pending) { - std::promise read; - loop.post([&] { - const auto& entry = core.targets_.at(url); - read.set_value({retry_armed(entry), dial_in_progress(entry), entry.backoff.failures()}); - }); - state = read.get_future().get(); - } - EXPECT_FALSE(state.dialing); - EXPECT_EQ(state.failures, 1U); - - loop.post([&] { core.shutdown(0); }); - core_thread.join(); -} - // An input open_input() registered whose attachment is still queued, or lands after the loop ended, // is closed by the stop, so its pending drain resolves as STOPPED before the dispatch thread // finishes, and no input is registered after it @@ -445,447 +126,4 @@ TEST(ServerCoreInputs, AttachesAnInputOverOneTheHostReleased) { dispatcher->join(); } -// A pipeline that fails mid-stream (here its encoder, swapped on the core thread for one that -// refuses its frames) excludes its format for the run: on_error names the player and the format, -// and the player streaming it gets stream/end, then a join stream/start on the other format it -// lists, which needs the resampler, and whose chunks follow from an instance that catches up at -// once. A drain then completes as PLAYED. Control: a member with no playable format is reported -// once, not again when the failure's exclusion ends with the run. -TEST(ServerCorePipelines, MovesAPlayerOffAFailedFormatAndStillCompletesADrain) { - using sendspin::server::DrainOutcome; - using sendspin::server::ErrorKind; - using sendspin::server::StreamCodec; - using sendspin::server::StreamFormat; - using sendspin::server::test::RawClient; - using sendspin::server::test::Recorder; - ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); - sendspin::server::EventLoop loop; - sendspin::server::AudioWorkers workers(1); - // Outlives the dispatcher, whose thread calls it until joined - Recorder recorder; - auto dispatcher = std::make_shared(); - const sendspin::server::Identity identity = *sendspin::server::Identity::generate(); - sendspin::server::CoreSettings settings; - settings.approve_all_clients = true; - sendspin::server::ClientSnapshot snapshot; - sendspin::server::GroupSnapshot groups; - sendspin::server::InputRegistry inputs; - std::atomic group_ids{1}; - sendspin::server::ServerCore core(loop, workers, *dispatcher, &recorder, identity, settings, - snapshot, groups, inputs, group_ids); - std::thread core_thread([&] { loop.run(); }); - - // A player of a format at the input's rate and one at another, and one of none the input - // gives - RawClient client; - RawClient unplayable; - const std::string url = client.url(); - const std::string unplayable_url = unplayable.url(); - loop.post([&] { - core.connect_url(url, *sendspin::server::parse_ws_url(url)); - core.connect_url(unplayable_url, *sendspin::server::parse_ws_url(unplayable_url)); - }); - client.activate(sendspin::server::test::player_hello_json( - "Failing", - R"([{"codec":"pcm","sample_rate":44100,"channels":2,"bit_depth":16},)" - R"({"codec":"pcm","sample_rate":48000,"channels":2,"bit_depth":24}])", - 200000)); - client.send_json(sendspin::server::test::client_state_json( - true, sendspin::server::test::player_state_json(150, 300, 0))); - unplayable.activate(sendspin::server::test::player_hello_json( - "Unplayable", R"([{"codec":"pcm","sample_rate":44101,"channels":2,"bit_depth":16}])", - 200000)); - unplayable.send_json(sendspin::server::test::client_state_json( - true, sendspin::server::test::player_state_json(150, 300, 0))); - constexpr uint32_t IN_RATE = 44100; - const auto view = std::make_shared( - core.default_group(), sendspin::server::InputId{9}, - sendspin::server::InputOptions{ - sendspin::server::InputFormat{IN_RATE, 2, sendspin::server::SampleFormat::S16}}, - sendspin::server::GroupParams{250000, 0, 0}, dispatcher, &recorder); - ASSERT_TRUE(inputs.add(view)); - loop.post([&] { core.attach_input(view); }); - const std::vector pcm(size_t{IN_RATE} * 4, 1); - // Written once the group has attached the input and set its write-ahead - const auto write = [&](uint64_t frames) { - uint64_t written = 0; - while (written < frames) { - written += view->write(pcm.data(), frames - written).frames; - if (written < frames) { - view->wait_writable(INT64_MAX); - } - } - }; - size_t starts = 0; - std::string json; - /// Reads messages until the one wanted, counting the stream/start messages - const auto read_until = [&](const std::string& wanted) { - for (;;) { - const std::optional> plaintext = client.receive_plaintext(); - if (!plaintext) { - return false; - } - if (plaintext->empty() || plaintext->front() != wire::JSON_ID) { - if (wanted == "chunk" && !plaintext->empty() && - plaintext->front() == wire::AUDIO_CHUNK_ID) { - return true; - } - continue; - } - json.assign(plaintext->begin() + 1, plaintext->end()); - starts += json.find(R"("type":"stream/start")") != std::string::npos ? 1 : 0; - if (json.find(R"("type":")" + wanted + '"') != std::string::npos) { - return true; - } - } - }; - write(IN_RATE / 5); - ASSERT_TRUE(read_until("stream/start")); - ASSERT_TRUE(read_until("chunk")); - - // White-box: an encoder whose frame is shorter refuses the next 44.1 kHz frame. The group's - // job gets a first step that swaps it on the group audio thread, put in place under the pool's - // mutex between two jobs, so nothing races the swap. - std::promise> task_of; - loop.post([&] { task_of.set_value(core.groups_.at(core.default_group())->audio_); }); - const std::shared_ptr task = task_of.get_future().get(); - std::promise swapped; - for (bool wrapped = false; !wrapped;) { - const std::lock_guard lock(task->pool_->mutex); - if (task->running_) { - continue; - } - task->job_ = [inner = std::move(task->job_), &swapped, first = true]() mutable { - if (std::exchange(first, false)) { - size_t count = 0; - using Job = sendspin::server::Group::PipelineJob; - for (auto& instance : inner.target()->audio->instances_) { - instance.encoder = sendspin::server::FrameEncoder::create( - {sendspin::server::sched::Codec::FLAC, 8000, 2, 16}); - ++count; - } - swapped.set_value(count); - } - return inner(); - }; - wrapped = true; - } - task->wake(); - ASSERT_EQ(swapped.get_future().get(), 1U); - write(IN_RATE / 5); - ASSERT_TRUE(read_until("stream/end")); - const sendspin::server::ServerError error = recorder.wait_for_error(url); - EXPECT_EQ(error.kind, ErrorKind::PIPELINE_FAILED); - EXPECT_FALSE(error.client_id.empty()); - EXPECT_EQ(error.format, (StreamFormat{StreamCodec::PCM, IN_RATE, 2, 16})); - ASSERT_TRUE(read_until("stream/start")); - EXPECT_NE(json.find(R"("sample_rate":48000)"), std::string::npos) << json; - write(IN_RATE / 2); - ASSERT_TRUE(read_until("chunk")); - view->drain(); - // The run's end reaches the player after anything sent before it. Read before waiting for the - // drain, which needs the stream/end written, and an unread socket would stall that write. - ASSERT_TRUE(read_until("group/update")); - recorder.wait_for(Recorder::drained(sendspin::server::InputId{9}, DrainOutcome::PLAYED)); - EXPECT_EQ( - recorder.count("error " + std::to_string(static_cast(ErrorKind::PIPELINE_FAILED)) + - " " + url), - 1U); - EXPECT_EQ(starts, 2U); - const std::string no_format = - "error " + std::to_string(static_cast(ErrorKind::NO_PLAYABLE_FORMAT)) + " "; - EXPECT_EQ(recorder.count(no_format + unplayable_url), 1U); - EXPECT_EQ(recorder.count(no_format + url), 0U); - - loop.post([&] { core.shutdown(0); }); - core_thread.join(); - dispatcher->finish(); - dispatcher->join(); -} - -sendspin::server::CoreSettings approving_settings() { - sendspin::server::CoreSettings settings; - settings.approve_all_clients = true; - return settings; -} - -/// A ServerCore approving every client on its own core thread, shut down when destroyed -struct RunningCore { - RunningCore() : thread([this] { this->loop.run(); }) {} - - ~RunningCore() { - this->loop.post([this] { this->core.shutdown(0); }); - this->thread.join(); - this->dispatcher->finish(); - this->dispatcher->join(); - } - - RunningCore(const RunningCore&) = delete; - RunningCore& operator=(const RunningCore&) = delete; - - /// Dials client, which takes the connection with hello and state - void dial(sendspin::server::test::RawClient& client, const std::string& hello, - const std::string& state) { - const std::string url = client.url(); - this->loop.post( - [this, url] { this->core.connect_url(url, *sendspin::server::parse_ws_url(url)); }); - ASSERT_NO_FATAL_FAILURE(client.activate(hello)); - client.send_json(state); - } - - /// Dials client, which takes the connection with hello and state; the host hears it connected - void connect(sendspin::server::test::RawClient& client, const std::string& hello, - const std::string& state = sendspin::server::test::client_state_json(true)) { - ASSERT_NO_FATAL_FAILURE(this->dial(client, hello, state)); - this->recorder.wait_for("connected " + client.noise.identity().peer_id()); - } - - /// Runs fn on the core thread and waits for it - template - auto on_core(Fn fn) { - std::promise done; - this->loop.post([&] { - if constexpr (std::is_void_v) { - fn(); - done.set_value(); - } else { - done.set_value(fn()); - } - }); - return done.get_future().get(); - } - - /// The group as the groups() snapshot shows it - sendspin::server::GroupInfo group(sendspin::server::GroupId id) const { - for (const sendspin::server::GroupInfo& info : this->groups.read()) { - if (info.id == id) { - return info; - } - } - return {}; - } - - sendspin::server::GroupInfo server_group() const { - return this->group(this->core.default_group()); - } - - /// The connection dialed to client; core thread only - sendspin::server::ServerCore::Client* client_of( - const sendspin::server::test::RawClient& client) { - for (const auto& [id, connection] : this->core.clients_) { - if (connection->url == client.url()) { - return connection.get(); - } - } - return nullptr; - } - - /// White-box: once client's connection has no write or wait outstanding and nothing to send, - /// so that its session's end closes it at once, caps its control FIFO at the bytes it holds, - /// so the next message queued overflows it - void fill_control(const sendspin::server::test::RawClient& client) { - while (!this->on_core([&] { - sendspin::server::SessionDriver& driver = *this->client_of(client)->driver; - if (driver.writing_ || driver.waiting_ || driver.session().has_message()) { - return false; - } - sendspin::server::SendQueues& queues = driver.session().queues_; - queues.caps_.control_bytes = queues.control_bytes_; - return true; - })) { - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - } - } - - sendspin::server::EventLoop loop; - sendspin::server::AudioWorkers workers{1}; - // Outlives the dispatcher, whose thread calls it until joined - sendspin::server::test::Recorder recorder; - std::shared_ptr dispatcher = - std::make_shared(); - const sendspin::server::Identity identity = *sendspin::server::Identity::generate(); - const sendspin::server::CoreSettings settings = approving_settings(); - sendspin::server::ClientSnapshot snapshot; - sendspin::server::GroupSnapshot groups; - sendspin::server::InputRegistry inputs; - std::atomic group_ids{1}; - sendspin::server::ServerCore core{loop, workers, *dispatcher, &recorder, identity, - settings, snapshot, groups, inputs, group_ids}; - std::thread thread; -}; - -// messaging.md "Server behavior when a client becomes unavailable", "client/leave": a member of a -// shared group that turns unavailable or leaves moves into a group of its own, and the solo -// group's join messages can pass its control FIFO's cap. The pump closes the connection inside -// the move, which clears its group; the core thread goes on and the other member is untouched. -// Control: with room in the FIFO the member stays connected. -TEST(ServerCoreGroups, ClosesAMemberWhoseMoveOverflowsItsQueue) { - using sendspin::server::test::RawClient; - ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); - struct Row { - const char* name; - const char* message; - bool full; - }; - const Row rows[] = { - {"Control: client/state unavailable with room", - R"({"type":"client/state","payload":{"available":false}})", false}, - {"client/state unavailable", R"({"type":"client/state","payload":{"available":false}})", - true}, - {"client/leave", R"({"type":"client/leave","payload":{}})", true}, - }; - const std::string player = sendspin::server::test::player_hello_json( - "Player", R"([{"codec":"pcm","sample_rate":48000,"channels":2,"bit_depth":16}])", 200000); - for (const Row& row : rows) { - SCOPED_TRACE(row.name); - RunningCore c; - RawClient leaving; - RawClient staying; - ASSERT_NO_FATAL_FAILURE(c.connect(leaving, player)); - ASSERT_NO_FATAL_FAILURE(c.connect(staying, player)); - ASSERT_EQ(c.server_group().client_ids.size(), 2U); - if (row.full) { - c.fill_control(leaving); - } - leaving.send_json(row.message); - if (row.full) { - leaving.read_until_closed(); - const std::vector disconnects = - c.recorder.wait_for_disconnects(1); - EXPECT_EQ(disconnects.front().reason, - sendspin::server::DisconnectReason::CLOSED_BY_SERVER); - } else { - // The solo group's group/update follows the one of the first activation - EXPECT_FALSE(leaving.receive_until("group/update").empty()); - leaving.send_json(sendspin::server::test::client_time_json(1)); - EXPECT_FALSE(leaving.receive_until("server/time").empty()); - } - staying.send_json(sendspin::server::test::client_time_json(1)); - EXPECT_FALSE(staying.receive_until("server/time").empty()); - EXPECT_EQ(c.server_group().client_ids, - std::vector{staying.noise.identity().peer_id()}); - } -} - -// messaging.md "Server behavior when a client becomes unavailable": the only member of a playing -// group that turns unavailable stops its playback, and the stop's group/update can pass its -// control FIFO's cap. The pump closes the connection inside the stop, which clears its group, and -// the core thread goes on. Control: with room in the FIFO the member gets the stopped -// group/update. -TEST(ServerCoreGroups, ClosesALoneMemberWhoseStopOverflowsItsQueue) { - using sendspin::server::test::RawClient; - ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); - for (const bool full : {false, true}) { - SCOPED_TRACE(full ? "full" : "Control: with room"); - RunningCore c; - // No player, so the server's group plays with no stream to it - RawClient client; - ASSERT_NO_FATAL_FAILURE(c.connect(client, sendspin::server::test::client_hello_json())); - const sendspin::server::GroupId group = c.core.default_group(); - ASSERT_EQ(c.server_group().client_ids.size(), 1U); - constexpr uint32_t RATE = 48000; - const auto view = std::make_shared( - group, sendspin::server::InputId{3}, - sendspin::server::InputOptions{ - sendspin::server::InputFormat{RATE, 2, sendspin::server::SampleFormat::S16}}, - sendspin::server::GroupParams{250000, 0, 0}, c.dispatcher, &c.recorder); - ASSERT_TRUE(c.inputs.add(view)); - c.loop.post([&] { c.core.attach_input(view); }); - // Past the write-ahead a group with no player takes, so the run starts; the last writes - // wait for room as the run plays - constexpr uint64_t FRAMES = - (sendspin::server::sched::timing_defaults::WRITE_AHEAD_MIN_US + 200'000) * RATE / - 1'000'000; - const std::vector pcm(FRAMES * 4, 0); - for (uint64_t written = 0; written < FRAMES;) { - written += view->write(pcm.data(), FRAMES - written).frames; - if (written < FRAMES) { - view->wait_writable(INT64_MAX); - } - } - for (;;) { - const std::string update = client.receive_until("group/update"); - ASSERT_FALSE(update.empty()); - if (update.find(R"("playback_state":"playing")") != std::string::npos) { - break; - } - } - if (full) { - c.fill_control(client); - } - client.send_json(R"({"type":"client/state","payload":{"available":false}})"); - if (full) { - client.read_until_closed(); - const std::vector disconnects = - c.recorder.wait_for_disconnects(1); - EXPECT_EQ(disconnects.front().reason, - sendspin::server::DisconnectReason::CLOSED_BY_SERVER); - // The core thread still runs its handlers, and the group stopped - c.on_core([] {}); - EXPECT_EQ(c.server_group().playback_state, sendspin::server::PlaybackState::STOPPED); - EXPECT_TRUE(c.server_group().client_ids.empty()); - } else { - for (;;) { - const std::string update = client.receive_until("group/update"); - ASSERT_FALSE(update.empty()); - if (update.find(R"("playback_state":"stopped")") != std::string::npos) { - break; - } - } - } - view->release(); - } -} - -// A host's move judges each connection of a client in turn (move_action()). Moving the first out -// of the server's group sends the controller state it changes to the members left, and when the -// second connection's control FIFO cannot take it, that connection closes inside the first one's -// move; the move goes on without it. Control: with room both connections move. -TEST(ServerCoreGroups, MovesPastAConnectionAnEarlierMoveClosed) { - using sendspin::server::Identity; - using sendspin::server::test::RawClient; - ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); - const std::string hello = - R"({"type":"client/hello","payload":{"name":"Two","supported_roles":["player@v1",)" - R"("controller@v1"],"player@v1_support":{"supported_formats":[{"codec":"pcm",)" - R"("sample_rate":48000,"channels":2,"bit_depth":16}],"buffer_capacity":200000},)" - R"("supported_pair_methods":{"pairing_psk":{}},"unpaired_access":{"enabled":true}}})"; - const auto state = [](uint32_t volume) { - return sendspin::server::test::client_state_json( - true, R"({"output_delay_ms":0,"required_lead_time_ms":0,"min_buffer_ms":0,)" - R"("supported_commands":["volume"],"volume":)" + - std::to_string(volume) + "}"); - }; - for (const bool full : {false, true}) { - SCOPED_TRACE(full ? "full" : "Control: with room"); - RunningCore c; - const Identity identity = *Identity::generate(); - RawClient first(sendspin::server::CipherSuite::CHACHAPOLY_SHA256, - *Identity::from_private_bytes(identity.private_bytes)); - RawClient second(sendspin::server::CipherSuite::CHACHAPOLY_SHA256, - *Identity::from_private_bytes(identity.private_bytes)); - const std::string id = identity.peer_id(); - ASSERT_NO_FATAL_FAILURE(c.connect(first, hello, state(30))); - // A second connection of a client already reported is not reported again - ASSERT_NO_FATAL_FAILURE(c.dial(second, hello, state(70))); - second.send_json(sendspin::server::test::client_time_json(1)); - ASSERT_FALSE(second.receive_until("server/time").empty()); - ASSERT_EQ(c.server_group().client_ids, (std::vector{id, id})); - if (full) { - c.fill_control(second); - } - const sendspin::server::GroupId moved{c.group_ids.fetch_add(1)}; - c.on_core([&] { c.core.create_group(moved, "Moved", id); }); - if (full) { - second.read_until_closed(); - EXPECT_EQ(c.group(moved).client_ids, std::vector{id}); - } else { - EXPECT_EQ(c.group(moved).client_ids, (std::vector{id, id})); - } - EXPECT_TRUE(c.server_group().client_ids.empty()); - first.send_json(sendspin::server::test::client_time_json(1)); - EXPECT_FALSE(first.receive_until("server/time").empty()); - } -} - } // namespace diff --git a/tests/test_server_core_internals.cpp b/tests/test_server_core_internals.cpp new file mode 100644 index 0000000..f36a6bd --- /dev/null +++ b/tests/test_server_core_internals.cpp @@ -0,0 +1,796 @@ +// 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_core_internals.cpp +/// @brief ServerCore on its own core thread: a dial toward a URL whose dials keep failing waits +/// out the URL's backoff, and pruning keeps a failed target; a lost instance's target goes with +/// its wait for a dial, and a dial that waited checks again before it starts. A pipeline failure, +/// forced by swapping a group's encoder, ends the stream on its format and leaves a drain to +/// complete. A member whose leaving or stopping its group overflows its control FIFO, its cap +/// lowered through Session::queues_, closes inside that step, and so does a client's second +/// connection inside the host's move of its first. Compiled with -fno-access-control +/// (tests/CMakeLists.txt). + +#include "clock.h" +#include "crypto/keys.h" +#include "dispatcher.h" +#include "frame_encoder.h" +#include "group_job.h" +#include "host_view.h" +#include "raw_client.h" +#include "sched/constants.h" +#include "server_core.h" +#include "server_core_client.h" +#include "test_ports.h" +#include "test_recorder.h" +#include "transport.h" +#include "wire_messages.h" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using sendspin::server::ServerError; +namespace wire = sendspin::server::test::wire; + +class ErrorCounter : public sendspin::server::ServerListener { +public: + void on_error(const ServerError& /*error*/) override { + { + const std::lock_guard lock(this->mutex_); + ++this->errors_; + } + this->changed_.notify_all(); + } + + /// Waits with no timeout; a regression hangs into the suite watchdog + void wait_for(int errors) { + std::unique_lock lock(this->mutex_); + this->changed_.wait(lock, [&] { return this->errors_ >= errors; }); + } + +private: + std::mutex mutex_; + std::condition_variable changed_; + int errors_{0}; +}; + +struct TargetState { + bool exists{false}; + bool dialing{false}; + bool retry_pending{false}; + uint32_t failures{0}; + bool waiting{false}; // next_dial_us is in the future +}; + +bool dial_in_progress(const sendspin::server::ServerCore::Target& target) { + return std::holds_alternative(target.phase); +} + +bool retry_armed(const sendspin::server::ServerCore::Target& target) { + return std::holds_alternative(target.phase); +} + +// messaging.md "client/goodbye": backoff on repeated failures toward one target +TEST(ServerCoreBackoff, ADialAfterAFailureWaitsOutTheBackoff) { + ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); + sendspin::server::EventLoop loop; + sendspin::server::AudioWorkers workers(1); + // Outlives the dispatcher, whose thread calls it until joined + ErrorCounter listener; + sendspin::server::Dispatcher dispatcher; + const sendspin::server::Identity identity = *sendspin::server::Identity::generate(); + const sendspin::server::CoreSettings settings; + sendspin::server::ClientSnapshot snapshot; + sendspin::server::GroupSnapshot groups; + sendspin::server::InputRegistry inputs; + std::atomic group_ids{1}; + sendspin::server::ServerCore core(loop, workers, dispatcher, &listener, identity, settings, + snapshot, groups, inputs, group_ids); + std::thread core_thread([&] { loop.run(); }); + + const sendspin::server::test::UnlistenedPort unlistened; + const std::string url = "ws://127.0.0.1:" + std::to_string(unlistened.port()) + "/sendspin"; + const sendspin::server::DialTarget target = *sendspin::server::parse_ws_url(url); + const auto connect_and_inspect = [&] { + std::promise inspected; + loop.post([&] { + core.connect_url(url, target); + TargetState state; + const auto it = core.targets_.find(url); + if (it != core.targets_.end()) { + state = {true, dial_in_progress(it->second), retry_armed(it->second), + it->second.backoff.failures(), + it->second.backoff.next_dial_us() > sendspin::server::now_us()}; + } + inspected.set_value(state); + }); + return inspected.get_future().get(); + }; + + // Control: the first dial starts at once + const TargetState first = connect_and_inspect(); + EXPECT_TRUE(first.dialing); + EXPECT_FALSE(first.retry_pending); + listener.wait_for(1); + + // On the core thread: a failed target outlives a prune just after its wait ends, so a host + // retrying then still escalates the backoff. Its wait is then cut short, so the deferred dial + // below comes soon. + std::promise> pruned; + loop.post([&] { + sendspin::server::HandshakeBackoff& backoff = core.targets_.at(url).backoff; + core.prune_targets(backoff.next_dial_us() + 1); + const auto it = core.targets_.find(url); + pruned.set_value({it != core.targets_.end(), + it != core.targets_.end() ? it->second.backoff.failures() : 0U}); + if (it != core.targets_.end()) { + constexpr int64_t SHORT_WAIT_US = 50000; + it->second.backoff.next_dial_us_ = sendspin::server::now_us() + SHORT_WAIT_US; + } + }); + const std::pair after_prune = pruned.get_future().get(); + EXPECT_TRUE(after_prune.first); + EXPECT_EQ(after_prune.second, 1U); + + // Within the backoff the dial is deferred, not started + const TargetState second = connect_and_inspect(); + ASSERT_TRUE(second.exists); + EXPECT_EQ(second.failures, 1U); + EXPECT_TRUE(second.waiting); + EXPECT_FALSE(second.dialing); + EXPECT_TRUE(second.retry_pending); + + // The deferred dial runs when the backoff ends, and its failure counts again + listener.wait_for(2); + std::promise> last; + loop.post([&] { + const sendspin::server::HandshakeBackoff backoff = core.targets_.at(url).backoff; + // Control: a full maximum wait after its last wait ended, the idle target goes + core.prune_targets(backoff.next_dial_us() + settings.backoff.max_us); + last.set_value({backoff.failures(), core.targets_.count(url) == 0}); + }); + const std::pair final_state = last.get_future().get(); + EXPECT_EQ(final_state.first, 2U); + EXPECT_TRUE(final_state.second); + + loop.post([&] { core.shutdown(0); }); + core_thread.join(); +} + +// ConnectOptions::retry_until_connected: a URL target whose dial failed is dialed again on the +// server's own after its backoff (a retry scheduled), and cancel_connect_url() withdraws it; +// without the option a failed dial ends the operator's request +TEST(ServerCoreBackoff, RetriesAUrlUntilConnectedUntilCancelled) { + ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); + struct Row { + const char* name; + bool retry; + bool scheduled; + }; + const Row rows[] = { + {"Control: a plain dial", false, false}, + {"retry until connected", true, true}, + }; + for (const Row& row : rows) { + SCOPED_TRACE(row.name); + sendspin::server::EventLoop loop; + sendspin::server::AudioWorkers workers(1); + // Outlives the dispatcher, whose thread calls it until joined + ErrorCounter listener; + sendspin::server::Dispatcher dispatcher; + const sendspin::server::Identity identity = *sendspin::server::Identity::generate(); + const sendspin::server::CoreSettings settings; + sendspin::server::ClientSnapshot snapshot; + sendspin::server::GroupSnapshot groups; + sendspin::server::InputRegistry inputs; + std::atomic group_ids{1}; + sendspin::server::ServerCore core(loop, workers, dispatcher, &listener, identity, settings, + snapshot, groups, inputs, group_ids); + std::thread core_thread([&] { loop.run(); }); + + const sendspin::server::test::UnlistenedPort unlistened; + const std::string url = "ws://127.0.0.1:" + std::to_string(unlistened.port()) + "/sendspin"; + loop.post([&] { core.connect_url(url, *sendspin::server::parse_ws_url(url), row.retry); }); + listener.wait_for(1); + const auto inspect = [&] { + std::promise inspected; + loop.post([&] { + TargetState state; + const auto it = core.targets_.find(url); + if (it != core.targets_.end()) { + state = {true, dial_in_progress(it->second), retry_armed(it->second), + it->second.backoff.failures(), + it->second.backoff.next_dial_us() > sendspin::server::now_us()}; + } + inspected.set_value(state); + }); + return inspected.get_future().get(); + }; + const TargetState failed = inspect(); + ASSERT_TRUE(failed.exists); + EXPECT_EQ(failed.failures, 1U); + EXPECT_EQ(failed.retry_pending, row.scheduled); + + loop.post([&] { core.cancel_connect_url(url); }); + const TargetState cancelled = inspect(); + EXPECT_FALSE(cancelled.retry_pending); + EXPECT_FALSE(cancelled.dialing); + + loop.post([&] { core.shutdown(0); }); + core_thread.join(); + } +} + +// A client found by browsing whose dial failed waits out its backoff; once its instance is lost the +// target goes, which cancels the wait, so it is not dialed again +TEST(ServerCoreDiscovery, ALostInstanceCancelsItsDialWait) { + ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); + sendspin::server::EventLoop loop; + sendspin::server::AudioWorkers workers(1); + // Outlives the dispatcher, whose thread calls it until joined + ErrorCounter listener; + sendspin::server::Dispatcher dispatcher; + const sendspin::server::Identity identity = *sendspin::server::Identity::generate(); + const sendspin::server::CoreSettings settings; + sendspin::server::ClientSnapshot snapshot; + sendspin::server::GroupSnapshot groups; + sendspin::server::InputRegistry inputs; + std::atomic group_ids{1}; + sendspin::server::ServerCore core(loop, workers, dispatcher, &listener, identity, settings, + snapshot, groups, inputs, group_ids); + std::thread core_thread([&] { loop.run(); }); + + const sendspin::server::test::UnlistenedPort unlistened; + sendspin::server::DiscoveredClient found; + found.instance = "Gone"; + found.addresses = {"127.0.0.1"}; + found.port = unlistened.port(); + found.path = "/sendspin"; + loop.post([&] { core.on_client_found(found); }); + listener.wait_for(1); + const std::string key = "mdns:Gone"; + std::promise> waiting; + loop.post([&] { + const auto it = core.targets_.find(key); + waiting.set_value( + {it != core.targets_.end(), it != core.targets_.end() && retry_armed(it->second)}); + }); + const std::pair before = waiting.get_future().get(); + EXPECT_TRUE(before.first); + EXPECT_TRUE(before.second); + + std::promise after; + loop.post([&] { + core.on_client_lost("Gone"); + after.set_value(core.targets_.count(key) == 0); + }); + EXPECT_TRUE(after.get_future().get()); + EXPECT_TRUE(snapshot.read().empty()); + + loop.post([&] { core.shutdown(0); }); + core_thread.join(); +} + +// A dial on the server's own that waited out its backoff checks again before it starts, so one no +// longer due (here a URL target that stopped reconnecting) is not made +TEST(ServerCoreDiscovery, ADialThatWaitedChecksAgainBeforeItStarts) { + ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); + sendspin::server::EventLoop loop; + sendspin::server::AudioWorkers workers(1); + // Outlives the dispatcher, whose thread calls it until joined + ErrorCounter listener; + sendspin::server::Dispatcher dispatcher; + const sendspin::server::Identity identity = *sendspin::server::Identity::generate(); + const sendspin::server::CoreSettings settings; + sendspin::server::ClientSnapshot snapshot; + sendspin::server::GroupSnapshot groups; + sendspin::server::InputRegistry inputs; + std::atomic group_ids{1}; + sendspin::server::ServerCore core(loop, workers, dispatcher, &listener, identity, settings, + snapshot, groups, inputs, group_ids); + std::thread core_thread([&] { loop.run(); }); + + const sendspin::server::test::UnlistenedPort unlistened; + const std::string url = "ws://127.0.0.1:" + std::to_string(unlistened.port()) + "/sendspin"; + const sendspin::server::DialTarget target = *sendspin::server::parse_ws_url(url); + loop.post([&] { core.connect_url(url, target); }); + listener.wait_for(1); + // Reconnecting, so the failure's backoff schedules a dial on the server's own; then no longer + // reconnecting before the short wait ends + loop.post([&] { + auto& entry = core.targets_.at(url); + entry.reconnecting = true; + constexpr int64_t SHORT_WAIT_US = 50000; + entry.backoff.next_dial_us_ = sendspin::server::now_us() + SHORT_WAIT_US; + core.dial_if_due(entry); + entry.reconnecting = false; + }); + struct State { + bool retry_pending; + bool dialing; + uint32_t failures; + }; + State state{true, false, 0}; + while (state.retry_pending) { + std::promise read; + loop.post([&] { + const auto& entry = core.targets_.at(url); + read.set_value({retry_armed(entry), dial_in_progress(entry), entry.backoff.failures()}); + }); + state = read.get_future().get(); + } + EXPECT_FALSE(state.dialing); + EXPECT_EQ(state.failures, 1U); + + loop.post([&] { core.shutdown(0); }); + core_thread.join(); +} + +// A pipeline that fails mid-stream (here its encoder, swapped on the core thread for one that +// refuses its frames) excludes its format for the run: on_error names the player and the format, +// and the player streaming it gets stream/end, then a join stream/start on the other format it +// lists, which needs the resampler, and whose chunks follow from an instance that catches up at +// once. A drain then completes as PLAYED. Control: a member with no playable format is reported +// once, not again when the failure's exclusion ends with the run. +TEST(ServerCorePipelines, MovesAPlayerOffAFailedFormatAndStillCompletesADrain) { + using sendspin::server::DrainOutcome; + using sendspin::server::ErrorKind; + using sendspin::server::StreamCodec; + using sendspin::server::StreamFormat; + using sendspin::server::test::RawClient; + using sendspin::server::test::Recorder; + ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); + sendspin::server::EventLoop loop; + sendspin::server::AudioWorkers workers(1); + // Outlives the dispatcher, whose thread calls it until joined + Recorder recorder; + auto dispatcher = std::make_shared(); + const sendspin::server::Identity identity = *sendspin::server::Identity::generate(); + sendspin::server::CoreSettings settings; + settings.approve_all_clients = true; + sendspin::server::ClientSnapshot snapshot; + sendspin::server::GroupSnapshot groups; + sendspin::server::InputRegistry inputs; + std::atomic group_ids{1}; + sendspin::server::ServerCore core(loop, workers, *dispatcher, &recorder, identity, settings, + snapshot, groups, inputs, group_ids); + std::thread core_thread([&] { loop.run(); }); + + // A player of a format at the input's rate and one at another, and one of none the input + // gives + RawClient client; + RawClient unplayable; + const std::string url = client.url(); + const std::string unplayable_url = unplayable.url(); + loop.post([&] { + core.connect_url(url, *sendspin::server::parse_ws_url(url)); + core.connect_url(unplayable_url, *sendspin::server::parse_ws_url(unplayable_url)); + }); + client.activate(sendspin::server::test::player_hello_json( + "Failing", + R"([{"codec":"pcm","sample_rate":44100,"channels":2,"bit_depth":16},)" + R"({"codec":"pcm","sample_rate":48000,"channels":2,"bit_depth":24}])", + 200000)); + client.send_json(sendspin::server::test::client_state_json( + true, sendspin::server::test::player_state_json(150, 300, 0))); + unplayable.activate(sendspin::server::test::player_hello_json( + "Unplayable", R"([{"codec":"pcm","sample_rate":44101,"channels":2,"bit_depth":16}])", + 200000)); + unplayable.send_json(sendspin::server::test::client_state_json( + true, sendspin::server::test::player_state_json(150, 300, 0))); + constexpr uint32_t IN_RATE = 44100; + const auto view = std::make_shared( + core.default_group(), sendspin::server::InputId{9}, + sendspin::server::InputOptions{ + sendspin::server::InputFormat{IN_RATE, 2, sendspin::server::SampleFormat::S16}}, + sendspin::server::GroupParams{250000, 0, 0}, dispatcher, &recorder); + ASSERT_TRUE(inputs.add(view)); + loop.post([&] { core.attach_input(view); }); + const std::vector pcm(size_t{IN_RATE} * 4, 1); + // Written once the group has attached the input and set its write-ahead + const auto write = [&](uint64_t frames) { + uint64_t written = 0; + while (written < frames) { + written += view->write(pcm.data(), frames - written).frames; + if (written < frames) { + view->wait_writable(INT64_MAX); + } + } + }; + size_t starts = 0; + std::string json; + /// Reads messages until the one wanted, counting the stream/start messages + const auto read_until = [&](const std::string& wanted) { + for (;;) { + const std::optional> plaintext = client.receive_plaintext(); + if (!plaintext) { + return false; + } + if (plaintext->empty() || plaintext->front() != wire::JSON_ID) { + if (wanted == "chunk" && !plaintext->empty() && + plaintext->front() == wire::AUDIO_CHUNK_ID) { + return true; + } + continue; + } + json.assign(plaintext->begin() + 1, plaintext->end()); + starts += json.find(R"("type":"stream/start")") != std::string::npos ? 1 : 0; + if (json.find(R"("type":")" + wanted + '"') != std::string::npos) { + return true; + } + } + }; + write(IN_RATE / 5); + ASSERT_TRUE(read_until("stream/start")); + ASSERT_TRUE(read_until("chunk")); + + // White-box: an encoder whose frame is shorter refuses the next 44.1 kHz frame. The group's + // job gets a first step that swaps it on the group audio thread, put in place between two + // jobs, so nothing races the swap. + std::promise> task_of; + loop.post([&] { task_of.set_value(core.groups_.at(core.default_group())->audio_); }); + const std::shared_ptr task = task_of.get_future().get(); + std::promise swapped; + sendspin::server::test::wrap_job(*task, [&swapped](std::function inner) { + const std::shared_ptr audio = + sendspin::server::test::pipelines_of(inner); + return [inner = std::move(inner), audio, &swapped, first = true]() mutable { + if (std::exchange(first, false)) { + size_t count = 0; + for (auto& instance : audio->instances_) { + instance.encoder = sendspin::server::FrameEncoder::create( + {sendspin::server::sched::Codec::FLAC, 8000, 2, 16}); + ++count; + } + swapped.set_value(count); + } + return inner(); + }; + }); + task->wake(); + ASSERT_EQ(swapped.get_future().get(), 1U); + write(IN_RATE / 5); + ASSERT_TRUE(read_until("stream/end")); + const sendspin::server::ServerError error = recorder.wait_for_error(url); + EXPECT_EQ(error.kind, ErrorKind::PIPELINE_FAILED); + EXPECT_FALSE(error.client_id.empty()); + EXPECT_EQ(error.format, (StreamFormat{StreamCodec::PCM, IN_RATE, 2, 16})); + ASSERT_TRUE(read_until("stream/start")); + EXPECT_NE(json.find(R"("sample_rate":48000)"), std::string::npos) << json; + write(IN_RATE / 2); + ASSERT_TRUE(read_until("chunk")); + view->drain(); + // The run's end reaches the player after anything sent before it. Read before waiting for the + // drain, which needs the stream/end written, and an unread socket would stall that write. + ASSERT_TRUE(read_until("group/update")); + recorder.wait_for(Recorder::drained(sendspin::server::InputId{9}, DrainOutcome::PLAYED)); + EXPECT_EQ( + recorder.count("error " + std::to_string(static_cast(ErrorKind::PIPELINE_FAILED)) + + " " + url), + 1U); + EXPECT_EQ(starts, 2U); + const std::string no_format = + "error " + std::to_string(static_cast(ErrorKind::NO_PLAYABLE_FORMAT)) + " "; + EXPECT_EQ(recorder.count(no_format + unplayable_url), 1U); + EXPECT_EQ(recorder.count(no_format + url), 0U); + + loop.post([&] { core.shutdown(0); }); + core_thread.join(); + dispatcher->finish(); + dispatcher->join(); +} + +sendspin::server::CoreSettings approving_settings() { + sendspin::server::CoreSettings settings; + settings.approve_all_clients = true; + return settings; +} + +/// A ServerCore approving every client on its own core thread, shut down when destroyed +struct RunningCore { + RunningCore() : thread([this] { this->loop.run(); }) {} + + ~RunningCore() { + this->loop.post([this] { this->core.shutdown(0); }); + this->thread.join(); + this->dispatcher->finish(); + this->dispatcher->join(); + } + + RunningCore(const RunningCore&) = delete; + RunningCore& operator=(const RunningCore&) = delete; + + /// Dials client, which takes the connection with hello and state + void dial(sendspin::server::test::RawClient& client, const std::string& hello, + const std::string& state) { + const std::string url = client.url(); + this->loop.post( + [this, url] { this->core.connect_url(url, *sendspin::server::parse_ws_url(url)); }); + ASSERT_NO_FATAL_FAILURE(client.activate(hello)); + client.send_json(state); + } + + /// Dials client, which takes the connection with hello and state; the host hears it connected + void connect(sendspin::server::test::RawClient& client, const std::string& hello, + const std::string& state = sendspin::server::test::client_state_json(true)) { + ASSERT_NO_FATAL_FAILURE(this->dial(client, hello, state)); + this->recorder.wait_for("connected " + client.noise.identity().peer_id()); + } + + /// Runs fn on the core thread and waits for it + template + auto on_core(Fn fn) { + std::promise done; + this->loop.post([&] { + if constexpr (std::is_void_v) { + fn(); + done.set_value(); + } else { + done.set_value(fn()); + } + }); + return done.get_future().get(); + } + + /// The group as the groups() snapshot shows it + sendspin::server::GroupInfo group(sendspin::server::GroupId id) const { + for (const sendspin::server::GroupInfo& info : this->groups.read()) { + if (info.id == id) { + return info; + } + } + return {}; + } + + sendspin::server::GroupInfo server_group() const { + return this->group(this->core.default_group()); + } + + /// The connection dialed to client; core thread only + sendspin::server::ServerCore::Client* client_of( + const sendspin::server::test::RawClient& client) { + for (const auto& [id, connection] : this->core.clients_) { + if (connection->url == client.url()) { + return connection.get(); + } + } + return nullptr; + } + + /// White-box: once client's connection has no write or wait outstanding and nothing to send, + /// so that its session's end closes it at once, caps its control FIFO at the bytes it holds, + /// so the next message queued overflows it + void fill_control(const sendspin::server::test::RawClient& client) { + while (!this->on_core([&] { + sendspin::server::SessionDriver& driver = *this->client_of(client)->driver; + if (driver.writing_ || driver.waiting_ || driver.session().has_message()) { + return false; + } + sendspin::server::SendQueues& queues = driver.session().queues_; + queues.caps_.control_bytes = queues.control_bytes_; + return true; + })) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + } + + sendspin::server::EventLoop loop; + sendspin::server::AudioWorkers workers{1}; + // Outlives the dispatcher, whose thread calls it until joined + sendspin::server::test::Recorder recorder; + std::shared_ptr dispatcher = + std::make_shared(); + const sendspin::server::Identity identity = *sendspin::server::Identity::generate(); + const sendspin::server::CoreSettings settings = approving_settings(); + sendspin::server::ClientSnapshot snapshot; + sendspin::server::GroupSnapshot groups; + sendspin::server::InputRegistry inputs; + std::atomic group_ids{1}; + sendspin::server::ServerCore core{loop, workers, *dispatcher, &recorder, identity, + settings, snapshot, groups, inputs, group_ids}; + std::thread thread; +}; + +// messaging.md "Server behavior when a client becomes unavailable", "client/leave": a member of a +// shared group that turns unavailable or leaves moves into a group of its own, and the solo +// group's join messages can pass its control FIFO's cap. The pump closes the connection inside +// the move, which clears its group; the core thread goes on and the other member is untouched. +// Control: with room in the FIFO the member stays connected. +TEST(ServerCoreGroups, ClosesAMemberWhoseMoveOverflowsItsQueue) { + using sendspin::server::test::RawClient; + ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); + struct Row { + const char* name; + const char* message; + bool full; + }; + const Row rows[] = { + {"Control: client/state unavailable with room", + R"({"type":"client/state","payload":{"available":false}})", false}, + {"client/state unavailable", R"({"type":"client/state","payload":{"available":false}})", + true}, + {"client/leave", R"({"type":"client/leave","payload":{}})", true}, + }; + const std::string player = sendspin::server::test::player_hello_json( + "Player", R"([{"codec":"pcm","sample_rate":48000,"channels":2,"bit_depth":16}])", 200000); + for (const Row& row : rows) { + SCOPED_TRACE(row.name); + RunningCore c; + RawClient leaving; + RawClient staying; + ASSERT_NO_FATAL_FAILURE(c.connect(leaving, player)); + ASSERT_NO_FATAL_FAILURE(c.connect(staying, player)); + ASSERT_EQ(c.server_group().client_ids.size(), 2U); + if (row.full) { + c.fill_control(leaving); + } + leaving.send_json(row.message); + if (row.full) { + leaving.read_until_closed(); + const std::vector disconnects = + c.recorder.wait_for_disconnects(1); + EXPECT_EQ(disconnects.front().reason, + sendspin::server::DisconnectReason::CLOSED_BY_SERVER); + } else { + // The solo group's group/update follows the one of the first activation + EXPECT_FALSE(leaving.receive_until("group/update").empty()); + leaving.send_json(sendspin::server::test::client_time_json(1)); + EXPECT_FALSE(leaving.receive_until("server/time").empty()); + } + staying.send_json(sendspin::server::test::client_time_json(1)); + EXPECT_FALSE(staying.receive_until("server/time").empty()); + EXPECT_EQ(c.server_group().client_ids, + std::vector{staying.noise.identity().peer_id()}); + } +} + +// messaging.md "Server behavior when a client becomes unavailable": the only member of a playing +// group that turns unavailable stops its playback, and the stop's group/update can pass its +// control FIFO's cap. The pump closes the connection inside the stop, which clears its group, and +// the core thread goes on. Control: with room in the FIFO the member gets the stopped +// group/update. +TEST(ServerCoreGroups, ClosesALoneMemberWhoseStopOverflowsItsQueue) { + using sendspin::server::test::RawClient; + ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); + for (const bool full : {false, true}) { + SCOPED_TRACE(full ? "full" : "Control: with room"); + RunningCore c; + // No player, so the server's group plays with no stream to it + RawClient client; + ASSERT_NO_FATAL_FAILURE(c.connect(client, sendspin::server::test::client_hello_json())); + const sendspin::server::GroupId group = c.core.default_group(); + ASSERT_EQ(c.server_group().client_ids.size(), 1U); + constexpr uint32_t RATE = 48000; + const auto view = std::make_shared( + group, sendspin::server::InputId{3}, + sendspin::server::InputOptions{ + sendspin::server::InputFormat{RATE, 2, sendspin::server::SampleFormat::S16}}, + sendspin::server::GroupParams{250000, 0, 0}, c.dispatcher, &c.recorder); + ASSERT_TRUE(c.inputs.add(view)); + c.loop.post([&] { c.core.attach_input(view); }); + // Past the write-ahead a group with no player takes, so the run starts; the last writes + // wait for room as the run plays + constexpr uint64_t FRAMES = + (sendspin::server::sched::timing_defaults::WRITE_AHEAD_MIN_US + 200'000) * RATE / + 1'000'000; + const std::vector pcm(FRAMES * 4, 0); + for (uint64_t written = 0; written < FRAMES;) { + written += view->write(pcm.data(), FRAMES - written).frames; + if (written < FRAMES) { + view->wait_writable(INT64_MAX); + } + } + for (;;) { + const std::string update = client.receive_until("group/update"); + ASSERT_FALSE(update.empty()); + if (update.find(R"("playback_state":"playing")") != std::string::npos) { + break; + } + } + if (full) { + c.fill_control(client); + } + client.send_json(R"({"type":"client/state","payload":{"available":false}})"); + if (full) { + client.read_until_closed(); + const std::vector disconnects = + c.recorder.wait_for_disconnects(1); + EXPECT_EQ(disconnects.front().reason, + sendspin::server::DisconnectReason::CLOSED_BY_SERVER); + // The core thread still runs its handlers, and the group stopped + c.on_core([] {}); + EXPECT_EQ(c.server_group().playback_state, sendspin::server::PlaybackState::STOPPED); + EXPECT_TRUE(c.server_group().client_ids.empty()); + } else { + for (;;) { + const std::string update = client.receive_until("group/update"); + ASSERT_FALSE(update.empty()); + if (update.find(R"("playback_state":"stopped")") != std::string::npos) { + break; + } + } + } + view->release(); + } +} + +// A host's move judges each connection of a client in turn (move_action()). Moving the first out +// of the server's group sends the controller state it changes to the members left, and when the +// second connection's control FIFO cannot take it, that connection closes inside the first one's +// move; the move goes on without it. Control: with room both connections move. +TEST(ServerCoreGroups, MovesPastAConnectionAnEarlierMoveClosed) { + using sendspin::server::Identity; + using sendspin::server::test::RawClient; + ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); + const std::string hello = + R"({"type":"client/hello","payload":{"name":"Two","supported_roles":["player@v1",)" + R"("controller@v1"],"player@v1_support":{"supported_formats":[{"codec":"pcm",)" + R"("sample_rate":48000,"channels":2,"bit_depth":16}],"buffer_capacity":200000},)" + R"("supported_pair_methods":{"pairing_psk":{}},"unpaired_access":{"enabled":true}}})"; + const auto state = [](uint32_t volume) { + return sendspin::server::test::client_state_json( + true, R"({"output_delay_ms":0,"required_lead_time_ms":0,"min_buffer_ms":0,)" + R"("supported_commands":["volume"],"volume":)" + + std::to_string(volume) + "}"); + }; + for (const bool full : {false, true}) { + SCOPED_TRACE(full ? "full" : "Control: with room"); + RunningCore c; + const Identity identity = *Identity::generate(); + RawClient first(sendspin::server::CipherSuite::CHACHAPOLY_SHA256, + *Identity::from_private_bytes(identity.private_bytes)); + RawClient second(sendspin::server::CipherSuite::CHACHAPOLY_SHA256, + *Identity::from_private_bytes(identity.private_bytes)); + const std::string id = identity.peer_id(); + ASSERT_NO_FATAL_FAILURE(c.connect(first, hello, state(30))); + // A second connection of a client already reported is not reported again + ASSERT_NO_FATAL_FAILURE(c.dial(second, hello, state(70))); + second.send_json(sendspin::server::test::client_time_json(1)); + ASSERT_FALSE(second.receive_until("server/time").empty()); + ASSERT_EQ(c.server_group().client_ids, (std::vector{id, id})); + if (full) { + c.fill_control(second); + } + const sendspin::server::GroupId moved{c.group_ids.fetch_add(1)}; + c.on_core([&] { c.core.create_group(moved, "Moved", id); }); + if (full) { + second.read_until_closed(); + EXPECT_EQ(c.group(moved).client_ids, std::vector{id}); + } else { + EXPECT_EQ(c.group(moved).client_ids, (std::vector{id, id})); + } + EXPECT_TRUE(c.server_group().client_ids.empty()); + first.send_json(sendspin::server::test::client_time_json(1)); + EXPECT_FALSE(first.receive_until("server/time").empty()); + } +} + +} // namespace diff --git a/tests/test_server_raw_client.cpp b/tests/test_server_raw_client.cpp index 6f91b76..31fcecc 100644 --- a/tests/test_server_raw_client.cpp +++ b/tests/test_server_raw_client.cpp @@ -496,8 +496,8 @@ TEST(ServerRawClient, ReportsAGoodbyeAtEachPhase) { } // messaging.md "client/goodbye": a client that closes right after its goodbye, however it closes, -// is reported as leaving with the goodbye. test_session_driver.cpp has the goodbye arrive while a -// write is in flight. +// is reported as leaving with the goodbye. test_session_driver_internals.cpp has the goodbye arrive +// while a write is in flight. TEST(ServerRawClient, ReportsAGoodbyeFollowedByAnImmediateClose) { enum class Close : uint8_t { RESET, HALF_CLOSE, CLOSE_FRAME }; struct Row { diff --git a/tests/test_server_trust.cpp b/tests/test_server_trust.cpp index f8dfb9e..4464caa 100644 --- a/tests/test_server_trust.cpp +++ b/tests/test_server_trust.cpp @@ -15,35 +15,27 @@ /// @file test_server_trust.cpp /// @brief Trust end to end: a paired sendspin-cpp client plays under its long-term PSK, one that /// lost its record falls back to the Sentinel and is held, newer clients are reported, and -/// through ServerCore on its own core thread a pairing PSK is referenced, a long-term PSK is -/// used only once it is durable, one change at a time, and holds the client's unpaired sessions, -/// a save may outlive the core, and re-handshakes switch keys under playback and end a mismatch. -/// -/// White-box: the ServerCore tests set ServerCore::pairing_psks_, the state an operator-started -/// Pairing PSK pairing sets, call ServerCore::rekey() and handshake_psk(), which pairing drives, -/// and read ServerCore::records_ for the record (see tests/CMakeLists.txt). +/// through ServerCore on its own core thread a long-term PSK holds the client's unpaired sessions, +/// a save may outlive the core, a failed removal or put-back is kept and retried, an unconfirmed +/// long-term PSK stays off disk and an attempt whose client stops answering ends. #include "client_records.h" #include "crypto/base64url.h" #include "crypto/keys.h" #include "dispatcher.h" -#include "host_view.h" #include "peer.h" -#include "player_sink.h" #include "raw_client.h" #include "server_core.h" #include "test_ports.h" #include "test_recorder.h" #include "transport.h" #include "trust_fixtures.h" -#include "wire_messages.h" -#include +#include "trust_harness.h" #include #include #include #include -#include #include #include @@ -52,13 +44,10 @@ #include #include #include -#include #include #include #include #include -#include -#include #include #include @@ -66,24 +55,26 @@ namespace { using sendspin::server::ClientInfo; using sendspin::server::ClientRecord; -using sendspin::server::HandshakePsk; using sendspin::server::Identity; using sendspin::server::Psk; -using sendspin::server::PskCategory; using sendspin::server::SendspinServer; using sendspin::server::ServerConfig; using sendspin::server::StartResult; -using sendspin::server::TraceDirection; using sendspin::server::TracedMessage; using sendspin::server::test::Bytes; +using sendspin::server::test::Capture; using sendspin::server::test::ClientStore; +using sendspin::server::test::Core; +using sendspin::server::test::HeldProvider; using sendspin::server::test::MemoryProvider; +using sendspin::server::test::Peer; +using sendspin::server::test::player_hello; using sendspin::server::test::psk_of; using sendspin::server::test::RawClient; using sendspin::server::test::Recorder; +using sendspin::server::test::ScriptedProvider; using sendspin::server::test::stored_record; namespace persistence_keys = sendspin::server::persistence_keys; -namespace wire = sendspin::server::test::wire; Bytes record_blob(const ClientRecord& record) { sendspin::server::ClientRecordBlob blob{}; @@ -91,128 +82,6 @@ Bytes record_blob(const ClientRecord& record) { return {blob.begin(), blob.end()}; } -constexpr uint32_t RATE = 48000; - -/// The trust a sendspin-cpp client reports for its connection: USER for a long-term PSK, NONE for -/// the Sentinel or a pairing PSK -class TrustListener : public sendspin::SendspinClientListener { -public: - void on_trust_changed(sendspin::ConnectionTrust trust) override { - this->trust = trust; - ++this->reports; - } - - std::atomic trust{sendspin::ConnectionTrust::NONE}; - std::atomic reports{0}; -}; - -/// A sendspin-cpp player listening on a loopback port for the server to dial, with a real-time -/// audio output. Its main loop runs on the test thread through run_until(). -class Peer { -public: - struct Options { - bool unpaired_access{true}; - std::optional pairing_psk; - }; - - explicit Peer(ClientStore& store, const Options& options) - : port_(sendspin::server::test::free_port()), client_(make_config(port_, options)) { - sendspin::PlayerRoleConfig player; - // One format, assigned by count: GCC 13 -Warray-bounds misreads an initializer-list - // assignment - player.audio_formats.assign(1, sendspin::AudioSupportedFormatObject{ - sendspin::SendspinCodecFormat::PCM, 2, RATE, 16}); - player.min_buffer_ms = 150; - sendspin::PlayerRole& role = this->client_.add_player(player); - this->sink_ = std::make_unique(role, 4, RATE); - role.set_listener(this->sink_.get()); - this->client_.add_metadata(); - this->client_.set_network_provider(&this->network_); - this->client_.set_persistence_provider(&store); - this->client_.set_listener(&this->trust_); - this->client_.set_unpaired_access_enabled(options.unpaired_access); - EXPECT_TRUE(this->client_.start()); - } - - ~Peer() { - this->client_.stop(); - } - - Peer(const Peer&) = delete; - Peer& operator=(const Peer&) = delete; - - std::string url() const { - return sendspin::server::test::peer_url(this->port_); - } - - sendspin::SendspinClient& client() { - return this->client_; - } - - const sendspin::server::test::Sink& sink() const { - return *this->sink_; - } - - const TrustListener& trust() const { - return this->trust_; - } - - /// Runs the client's main loop until done() holds, with no timeout - template - void run_until(Predicate done) { - sendspin::server::test::run_until(this->client_, done); - } - -private: - static sendspin::SendspinClientConfig make_config(uint16_t port, const Options& options) { - sendspin::SendspinClientConfig config = - sendspin::server::test::peer_config("sendspin-cpp peer", port); - if (options.pairing_psk) { - config.pairing_psk = sendspin::SendspinPsk(*options.pairing_psk); - } - return config; - } - - uint16_t port_; - sendspin::server::test::AlwaysReady network_; - TrustListener trust_; - sendspin::SendspinClient client_; - std::unique_ptr sink_; -}; - -/// Collects the plaintext of the server/activate messages and the log lines a server produces -class Capture { -public: - void trace(const TracedMessage& message) { - const std::string text(message.plaintext.begin(), message.plaintext.end()); - if (message.direction == TraceDirection::OUTBOUND && - text.find(R"("type":"server/activate")") != std::string::npos) { - const std::lock_guard lock(this->mutex_); - this->activations_.push_back(text.substr(1)); - } - } - - void log(std::string_view line) { - const std::lock_guard lock(this->mutex_); - this->lines_.emplace_back(line); - } - - std::vector activations() const { - const std::lock_guard lock(this->mutex_); - return this->activations_; - } - - std::vector lines() const { - const std::lock_guard lock(this->mutex_); - return this->lines_; - } - -private: - mutable std::mutex mutex_; - std::vector activations_; - std::vector lines_; -}; - /// A server whose key is known before it starts, so a client can hold a record for it struct KnownServer { KnownServer() : identity(*Identity::generate()) { @@ -379,406 +248,6 @@ TEST(ServerTrust, ReportsTheIdentifiersOfANewerClient) { // ServerCore on its own core thread // ============================================================================== -/// A ServerCore with every client approved, running on its own thread until stopped, which then -/// destroys it as a run's core thread does -class Core { -public: - explicit Core(sendspin::server::ClientRecords records = {}, - sendspin::server::PersistenceProvider* provider = nullptr) - : dispatcher(std::make_shared()), - identity(*Identity::generate()) { - this->settings.approve_all_clients = true; - // The capture first, so that an activation the recorder lists is captured - this->settings.trace = [this](const TracedMessage& message) { - this->capture.trace(message); - this->recorder.trace(message); - }; - this->core = std::make_unique( - this->loop, this->workers, *this->dispatcher, &this->recorder, this->identity, - this->settings, this->snapshot, this->groups, this->inputs, this->group_ids, provider, - std::move(records)); - this->thread = std::thread([this] { - this->loop.run(); - this->core.reset(); - }); - } - - ~Core() { - this->stop(); - this->dispatcher->finish(); - this->dispatcher->join(); - } - - /// Shuts the core down and waits until its thread destroyed it; the dispatcher runs on - void stop() { - if (this->thread.joinable()) { - this->loop.post([this] { this->core->shutdown(0); }); - this->thread.join(); - } - } - - Core(const Core&) = delete; - Core& operator=(const Core&) = delete; - - /// Runs fn on the core thread and returns its result - template - auto on_core(Fn fn) { - std::promise result; - this->loop.post([&] { - if constexpr (std::is_void_v) { - fn(); - result.set_value(); - } else { - result.set_value(fn()); - } - }); - return result.get_future().get(); - } - - void dial(const std::string& url) { - this->loop.post( - [this, url] { this->core->connect_url(url, *sendspin::server::parse_ws_url(url)); }); - } - - /// Opens an input on the server's group - std::shared_ptr open_input(uint32_t rate) { - auto view = std::make_shared( - this->core->default_group(), sendspin::server::InputId{this->next_input++}, - sendspin::server::InputOptions{ - sendspin::server::InputFormat{rate, 2, sendspin::server::SampleFormat::S16}}, - sendspin::server::GroupParams{250000, 0, 0}, this->dispatcher, &this->recorder); - EXPECT_TRUE(this->inputs.add(view)); - this->loop.post([this, view] { this->core->attach_input(view); }); - return view; - } - - sendspin::server::EventLoop loop; - sendspin::server::AudioWorkers workers{1}; - std::shared_ptr dispatcher; - Identity identity; - sendspin::server::CoreSettings settings; - sendspin::server::ClientSnapshot snapshot; - sendspin::server::GroupSnapshot groups; - sendspin::server::InputRegistry inputs; - std::atomic group_ids{1}; - Recorder recorder; - Capture capture; - std::unique_ptr core; - std::thread thread; - uint64_t next_input{1}; -}; - -/// Writes the test signal into an input until stopped, from a thread of its own -class Feeder { -public: - explicit Feeder(std::shared_ptr view) - : view_(std::move(view)), thread_([this] { this->run(); }) {} - - ~Feeder() { - this->stop_ = true; - this->thread_.join(); - } - - Feeder(const Feeder&) = delete; - Feeder& operator=(const Feeder&) = delete; - -private: - void run() { - constexpr uint32_t BLOCK = 960; - constexpr int64_t WAIT_US = 20000; - const sendspin::server::test::Signal signal(16); - Bytes block(size_t{BLOCK} * signal.frame_bytes()); - uint32_t position = 0; - while (!this->stop_) { - for (uint32_t i = 0; i < BLOCK; ++i) { - signal.put(&block[i * signal.frame_bytes()], position + i); - } - uint32_t written = 0; - while (written < BLOCK && !this->stop_) { - written += static_cast( - this->view_->write(&block[written * signal.frame_bytes()], BLOCK - written) - .frames); - if (written < BLOCK) { - this->view_->wait_writable(WAIT_US); - } - } - position += BLOCK; - } - } - - std::shared_ptr view_; - std::atomic stop_{false}; - std::thread thread_; -}; - -std::string player_hello(const std::string& name) { - return sendspin::server::test::player_hello_json( - name, R"([{"codec":"pcm","sample_rate":48000,"channels":2,"bit_depth":16}])", 400000); -} - -/// What a raw client player reads: chunks by timestamp and duration, JSON messages by text -struct Reading { - std::optional ts; - int64_t duration_us{0}; - std::string json; -}; - -/// The next message the raw client reads, as a Reading; json empty and ts unset once closed -Reading read_one(RawClient& client) { - Reading reading; - const std::optional plaintext = client.receive_plaintext(); - if (!plaintext || plaintext->empty()) { - return reading; - } - if (plaintext->front() == wire::JSON_ID) { - reading.json.assign(plaintext->begin() + 1, plaintext->end()); - return reading; - } - if (plaintext->front() == wire::AUDIO_CHUNK_ID && - plaintext->size() > wire::AUDIO_CHUNK_HEADER) { - reading.ts = wire::timestamp_at(*plaintext); - const auto frames = - static_cast((plaintext->size() - wire::AUDIO_CHUNK_HEADER) / 4); - reading.duration_us = frames * 1000000 / RATE; - } - return reading; -} - -bool is_type(const std::string& json, const std::string& type) { - return json.find(R"("type":")" + type + '"') != std::string::npos; -} - -// While the operator has started a Pairing PSK pairing for a client, message 1 references its -// pairing PSK as 'pr', and a client holding it keys the session with it, which unpaired access and -// approval let play (messaging.md "server/activate"). Control: another client is referenced under -// the Sentinel. -TEST(ServerCoreTrust, ReferencesAPairingPskTheOperatorStarted) { - ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); - Core core; - const Psk pairing = psk_of(0x70); - RawClient paired; - RawClient other; - const std::string id = paired.noise.identity().peer_id(); - core.on_core( - [&] { core.core->pairing_psks_.emplace(id, HandshakePsk{PskCategory::PAIRING, pairing}); }); - core.dial(paired.url()); - core.dial(other.url()); - paired.noise.psk = pairing; - ASSERT_NO_FATAL_FAILURE(paired.activate(player_hello("Paired"))); - ASSERT_NO_FATAL_FAILURE(other.activate(player_hello("Other"))); - JsonDocument payload; - ASSERT_EQ(deserializeJson(payload, paired.noise.message_1_payload), DeserializationError::Ok); - EXPECT_EQ(payload["psk_category"], "pr"); - EXPECT_EQ(payload["psk_id"], sendspin::server::psk_id_for(pairing)); - ASSERT_EQ(deserializeJson(payload, other.noise.message_1_payload), DeserializationError::Ok); - EXPECT_EQ(payload["psk_category"], "sn"); -} - -// The same with sendspin-cpp, which keeps its pairing PSK among its candidates at all times -// (pairing.md "Pairing PSK Flow"): matched, the session plays as an approved unpaired one. A -// client holding another pairing PSK answers under the Sentinel, and that session is held with no -// roles (a wrong pairing token). -TEST(ServerCoreTrust, KeysASendspinCppSessionWithItsPairingPsk) { - ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); - struct Row { - const char* name; - Psk client_psk; - bool roles; - }; - const Psk pairing = psk_of(0x90); - const Row rows[] = { - {"Control: the client holds the pairing PSK", pairing, true}, - {"the client holds another", psk_of(0x91), false}, - }; - for (const Row& row : rows) { - SCOPED_TRACE(row.name); - Core core; - ClientStore store; - Peer peer(store, {true, row.client_psk}); - const std::string id = peer.client().client_id(); - core.on_core([&] { - core.core->pairing_psks_.emplace(id, HandshakePsk{PskCategory::PAIRING, pairing}); - }); - core.dial(peer.url()); - // A client held with no roles is connected before its activation is written - peer.run_until([&] { - return core.recorder.contains("connected " + id) && - core.recorder.contains("out server/activate"); - }); - const std::vector activations = core.capture.activations(); - ASSERT_FALSE(activations.empty()); - EXPECT_EQ(activations.front().find("player@v1") != std::string::npos, row.roles) - << activations.front(); - EXPECT_EQ(peer.trust().trust, sendspin::ConnectionTrust::NONE); - } -} - -/// A provider whose first saves succeed or fail as scripted; every later save succeeds -class ScriptedProvider : public MemoryProvider { -public: - explicit ScriptedProvider(std::vector accepted = {}) : accepted_(std::move(accepted)) {} - - bool save_blob(const std::string& key, const uint8_t* data, size_t len) override { - const auto index = static_cast(this->saves); - if (index < this->accepted_.size() && !this->accepted_[index]) { - ++this->saves; - return false; - } - return MemoryProvider::save_blob(key, data, len); - } - -private: - std::vector accepted_; -}; - -/// A provider whose saves wait for release(), then succeed or fail as scripted, and fail when told -/// to -class HeldProvider : public ScriptedProvider { -public: - using ScriptedProvider::ScriptedProvider; - - bool save_blob(const std::string& key, const uint8_t* data, size_t len) override { - std::unique_lock lock(this->mutex_); - this->changed_.wait(lock, [this] { return this->released_ > 0; }); - --this->released_; - lock.unlock(); - return ScriptedProvider::save_blob(key, data, len); - } - - /// Lets count saves through - void release(int count) { - { - const std::lock_guard lock(this->mutex_); - this->released_ += count; - } - this->changed_.notify_all(); - } - -private: - std::mutex mutex_; - std::condition_variable changed_; - int released_{0}; -}; - -// pairing.md "server/pair-finalize": a long-term PSK is used only once its record is durable. -// While the save is in flight handshakes reference the record as it was; a durable save makes the -// new PSK the one referenced and discards the approval (pairing.md "Unpaired Access"); a failed -// save or commit puts the record back, saves it again and reports the failure. -TEST(ServerCoreTrust, UsesALongTermPskOnlyOnceItIsDurable) { - ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); - struct Row { - const char* name; - bool refuse_save; - bool refuse_commit; - bool durable; - }; - const Row rows[] = { - {"Control: saved and committed", false, false, true}, - {"the save refused", true, false, false}, - {"the commit refused", false, true, false}, - }; - for (const Row& row : rows) { - SCOPED_TRACE(row.name); - const std::string id = Identity::generate()->peer_id(); - sendspin::server::ClientRecords records; - records.add_loaded(0, ClientRecord{id, sendspin::server::ApprovalScope::PLAYBACK}); - HeldProvider provider; - provider.refuse_save = row.refuse_save; - provider.refuse_commit = row.refuse_commit; - Core core(std::move(records), &provider); - const Psk long_term = psk_of(0xA0); - std::promise done; - const PskCategory in_flight = core.on_core([&] { - core.core->store_long_term_psk( - id, long_term, [&](bool saved, const auto& /*unsaved*/) { done.set_value(saved); }); - return core.core->handshake_psk(id).category; - }); - EXPECT_EQ(in_flight, PskCategory::SENTINEL); - // The pairing's save, and for a failure the save that puts the record back and its retry - // at shutdown - provider.release(row.durable ? 2 : 3); - EXPECT_EQ(done.get_future().get(), row.durable); - const HandshakePsk referenced = core.on_core([&] { return core.core->handshake_psk(id); }); - const ClientRecord record = core.on_core([&] { return *core.core->records_.find(id); }); - if (row.durable) { - EXPECT_EQ(referenced.category, PskCategory::LONG_TERM); - EXPECT_EQ(referenced.psk, long_term); - EXPECT_EQ(record.long_term_psk, long_term); - EXPECT_FALSE(record.approval); - const std::optional stored = stored_record(provider, 0); - ASSERT_TRUE(stored); - EXPECT_EQ(stored->long_term_psk, long_term); - EXPECT_FALSE(stored->approval); - } else { - EXPECT_EQ(referenced.category, PskCategory::SENTINEL); - EXPECT_FALSE(record.long_term_psk); - EXPECT_EQ(record.approval, sendspin::server::ApprovalScope::PLAYBACK); - // Both saves fail, each reported - core.recorder.wait_for_count("error " + - std::to_string(static_cast( - sendspin::server::ErrorKind::PERSISTENCE_FAILED)) + - " ", - 2); - EXPECT_EQ(provider.saves, 2); - } - } -} - -// One long-term PSK change per client is in flight: a second store meanwhile is refused at once -// and never applies. A failed save puts back only what the change set, so a name a connection -// refreshed and an approval the operator set meanwhile stay (pairing.md "server/pair-finalize"). -// Control: a durable save keeps the first PSK with those changes. -TEST(ServerCoreTrust, HoldsOneLongTermPskChangePerClient) { - ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); - struct Row { - const char* name; - bool refuse_save; - }; - const Row rows[] = { - {"Control: saved", false}, - {"the save refused", true}, - }; - const Psk first = psk_of(0xA0); - for (const Row& row : rows) { - SCOPED_TRACE(row.name); - RawClient client; - const std::string id = client.noise.identity().peer_id(); - sendspin::server::ClientRecords records; - ClientRecord loaded{id, std::nullopt}; - loaded.name = "Old"; - records.add_loaded(0, loaded); - HeldProvider provider; - provider.refuse_save = row.refuse_save; - Core core(std::move(records), &provider); - std::promise done; - std::optional second; - core.on_core([&] { - core.core->store_long_term_psk( - id, first, [&](bool saved, const auto& /*unsaved*/) { done.set_value(saved); }); - core.core->store_long_term_psk( - id, psk_of(0xB0), [&](bool saved, const auto& /*unsaved*/) { second = saved; }); - }); - EXPECT_EQ(second, std::optional(false)); - // Meanwhile the client connects under a new name and the operator approves it - core.dial(client.url()); - ASSERT_NO_FATAL_FAILURE(client.activate(player_hello("New"))); - core.on_core( - [&] { core.core->set_approval(id, sendspin::server::ApprovalScope::PLAYBACK); }); - // The pairing's, the name's, the approval's, then the pairing's again for a durable save, - // or the restoring save and its retry at shutdown for a failure - provider.release(row.refuse_save ? 5 : 4); - EXPECT_EQ(done.get_future().get(), !row.refuse_save); - const ClientRecord record = core.on_core([&] { return *core.core->records_.find(id); }); - if (row.refuse_save) { - EXPECT_FALSE(record.long_term_psk); - } else { - EXPECT_EQ(record.long_term_psk, first); - } - EXPECT_EQ(record.name, "New"); - EXPECT_EQ(record.approval, sendspin::server::ApprovalScope::PLAYBACK); - } -} - // A save that finishes after the core thread destroyed the core reaches only the loop, whose // handler never runs: done is dropped and nothing touches the core (ASan reports a use after free // otherwise). The PSK was never finalized, so the stopping core puts the change back on disk @@ -1182,228 +651,4 @@ TEST(ServerCoreTrust, HoldsTheUnpairedSessionOfAClientOncePaired) { R"({"type":"server/activate","payload":{"activities":[],"active_roles":[]}})"); } -// connection.md "Sentinel Fallback": a client that fell back from its long-term PSK reports the -// mismatch until a re-handshake completes, not once it is queued -TEST(ServerCoreTrust, ClearsTheMismatchOnceARekeyCompletes) { - ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); - RawClient client; - const std::string id = client.noise.identity().peer_id(); - sendspin::server::ClientRecords records; - ClientRecord record{id, std::nullopt}; - record.long_term_psk = psk_of(0xF0); - records.add_loaded(0, record); - Core core(std::move(records)); - core.dial(client.url()); - ASSERT_NO_FATAL_FAILURE(client.activate(player_hello("Lost"))); - const auto mismatch = [&] { - for (const ClientInfo& info : core.snapshot.read()) { - if (info.client_id == id) { - return info.credential_mismatch; - } - } - return false; - }; - ASSERT_TRUE(core.on_core(mismatch)); - EXPECT_TRUE(core.on_core([&] { - core.core->rekey(id, HandshakePsk{}); - return mismatch(); - })); - const std::string message_1 = client.receive_until("noise/handshake"); - ASSERT_TRUE(client.noise.read_rekey_message_1(message_1)); - client.send_binary(client.noise.rekey_message_2()); - EXPECT_FALSE(client.receive_until("server/activate").empty()); - EXPECT_FALSE(core.on_core(mismatch)); -} - -// connection.md "Re-handshake", end to end during playback: the raw client reads message 1 between -// chunks, sends a time request under the old keys before message 2, and gets the frozen -// server/activate first under the new keys (no active_roles, which persist), then the time reply, -// then chunks that continue the timeline where they left off, with no stream message in between -TEST(ServerCoreTrust, RekeysDuringPlaybackAndPlaysOn) { - ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); - Core core; - RawClient client; - const std::string id = client.noise.identity().peer_id(); - core.dial(client.url()); - ASSERT_NO_FATAL_FAILURE(client.activate(player_hello("Player"))); - client.send_json(sendspin::server::test::client_state_json( - true, sendspin::server::test::player_state_json(150, 300, 0))); - const Feeder feeder(core.open_input(RATE)); - ASSERT_FALSE(client.receive_until("stream/start").empty()); - int64_t next_ts = 0; - for (int chunks = 0; chunks < 3;) { - const Reading reading = read_one(client); - ASSERT_TRUE(reading.ts || !reading.json.empty()); - if (reading.ts) { - EXPECT_TRUE(chunks == 0 || *reading.ts == next_ts) << *reading.ts << " " << next_ts; - next_ts = *reading.ts + reading.duration_us; - ++chunks; - } - } - - core.on_core([&] { core.core->rekey(id, HandshakePsk{}); }); - std::string message_1; - while (message_1.empty()) { - const Reading reading = read_one(client); - ASSERT_TRUE(reading.ts || !reading.json.empty()); - if (reading.ts) { - EXPECT_EQ(*reading.ts, next_ts); - next_ts = *reading.ts + reading.duration_us; - } else if (is_type(reading.json, "noise/handshake")) { - message_1 = reading.json; - } else { - EXPECT_FALSE(is_type(reading.json, "stream/end")) << reading.json; - } - } - ASSERT_TRUE(client.noise.read_rekey_message_1(message_1)); - client.send_json(sendspin::server::test::client_time_json(77)); - client.send_binary(client.noise.rekey_message_2()); - - EXPECT_EQ(client.receive_json(), - R"({"type":"server/activate","payload":{"activities":["playback"]}})"); - JsonDocument time; - ASSERT_EQ(deserializeJson(time, client.receive_json()), DeserializationError::Ok); - EXPECT_EQ(time["type"], "server/time"); - EXPECT_EQ(time["payload"]["client_transmitted"], 77); - EXPECT_LE(time["payload"]["server_received"].as(), - time["payload"]["server_transmitted"].as()); - for (int chunks = 0; chunks < 3;) { - const Reading reading = read_one(client); - ASSERT_TRUE(reading.ts || !reading.json.empty()); - if (reading.ts) { - EXPECT_EQ(*reading.ts, next_ts); - next_ts = *reading.ts + reading.duration_us; - ++chunks; - } else { - EXPECT_FALSE(is_type(reading.json, "stream/start") || - is_type(reading.json, "stream/clear") || - is_type(reading.json, "stream/end")) - << reading.json; - } - } - JsonDocument payload; - ASSERT_EQ(deserializeJson(payload, client.noise.message_1_payload), DeserializationError::Ok); - EXPECT_EQ(payload["psk_category"], "sn"); -} - -// messaging.md "server/activate": when the activation after a re-handshake removes a stream role, -// its stream/end goes before message 1. Here a paired client playing under its long-term PSK is -// re-handshaken to the Sentinel, as before a code pairing: the session becomes unpaired while the -// record exists, so the frozen activation removes every role and 'playback' (connection.md -// "Sentinel Fallback", "Re-handshake": the activation rules apply under the newly matched PSK), and -// the client is moved alone into a stopped group. -TEST(ServerCoreTrust, EndsTheStreamsARekeyRemovesBeforeMessage1) { - ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); - const Psk long_term = psk_of(0xC0); - RawClient client; - const std::string id = client.noise.identity().peer_id(); - sendspin::server::ClientRecords records; - ClientRecord record{id, std::nullopt}; - record.long_term_psk = long_term; - records.add_loaded(0, record); - Core core(std::move(records)); - core.dial(client.url()); - client.noise.psk = long_term; - ASSERT_NO_FATAL_FAILURE(client.activate(player_hello("Paired"))); - JsonDocument payload; - ASSERT_EQ(deserializeJson(payload, client.noise.message_1_payload), DeserializationError::Ok); - EXPECT_EQ(payload["psk_category"], "lt"); - client.send_json(sendspin::server::test::client_state_json( - true, sendspin::server::test::player_state_json(150, 300, 0))); - const Feeder feeder(core.open_input(RATE)); - ASSERT_FALSE(client.receive_until("stream/start").empty()); - - core.on_core([&] { core.core->rekey(id, HandshakePsk{}); }); - bool ended = false; - std::string message_1; - while (message_1.empty()) { - const Reading reading = read_one(client); - ASSERT_TRUE(reading.ts || !reading.json.empty()); - if (reading.ts) { - EXPECT_FALSE(ended) << "a chunk after stream/end"; - } else if (is_type(reading.json, "stream/end")) { - ended = true; - } else if (is_type(reading.json, "noise/handshake")) { - message_1 = reading.json; - } - } - EXPECT_TRUE(ended); - client.noise.psk = sendspin::server::SENTINEL_PSK; - ASSERT_TRUE(client.noise.read_rekey_message_1(message_1)); - ASSERT_EQ(deserializeJson(payload, client.noise.message_1_payload), DeserializationError::Ok); - EXPECT_EQ(payload["psk_category"], "sn"); - client.send_binary(client.noise.rekey_message_2()); - EXPECT_EQ(client.receive_json(), - R"({"type":"server/activate","payload":{"activities":[],"active_roles":[]}})"); - const std::string update = client.receive_until("group/update"); - EXPECT_NE(update.find(R"("playback_state":"stopped")"), std::string::npos) << update; -} - -// The step after a pairing (pairing.md "Pairing"), with sendspin-cpp: an approved player streaming -// under the Sentinel gets a long-term PSK it already holds, the record is saved, and the server -// re-handshakes to it. The player keeps playing the signal in order and stays time-synced, and its -// record, saved, holds the PSK and no approval. The player plays in real time, so the test takes -// the 400 ms of audio it waits for on either side of the switch, and more. -TEST(ServerCoreTrust, PromotesAPlayingSendspinCppClientToItsLongTermPsk) { - ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); - MemoryProvider provider; - Core core({}, &provider); - const Psk long_term = psk_of(0xE0); - ClientStore store; - store.hold_record(core.identity.peer_id(), long_term); - Peer peer(store, {true, std::nullopt}); - const std::string id = peer.client().client_id(); - core.dial(peer.url()); - peer.run_until([&] { return core.recorder.contains("connected " + id); }); - EXPECT_EQ(peer.trust().trust, sendspin::ConnectionTrust::NONE); - const Feeder feeder(core.open_input(RATE)); - peer.run_until([&] { return peer.sink().audible() >= RATE / 5; }); - - const size_t replies = core.recorder.count("out server/time"); - std::atomic stored{false}; - core.on_core([&] { - core.core->store_long_term_psk(id, long_term, [&](bool saved, const auto& /*unsaved*/) { - EXPECT_TRUE(saved); - stored = true; - core.core->rekey(id, HandshakePsk{PskCategory::LONG_TERM, long_term}); - }); - }); - peer.run_until([&] { return core.recorder.count("out server/activate") >= 2; }); - const size_t audible = peer.sink().audible(); - peer.run_until([&] { - return peer.sink().audible() >= audible + RATE / 5 && - core.recorder.count("out server/time") >= replies + 2; - }); - EXPECT_TRUE(stored); - EXPECT_TRUE(peer.client().is_connected()); - EXPECT_TRUE(peer.client().is_time_synced()); - EXPECT_EQ(peer.trust().trust, sendspin::ConnectionTrust::USER); - EXPECT_EQ(core.recorder.count("out stream/start"), 1U); - EXPECT_EQ(core.recorder.count("out stream/end"), 0U); - - // The signal plays in order across the switch: no frame repeated or out of order - const sendspin::server::test::Signal signal(16); - const Bytes played = peer.sink().played(); - std::optional previous; - size_t positions = 0; - for (size_t offset = 0; offset + 4 <= played.size(); offset += 4) { - const std::optional position = signal.get(&played[offset]); - if (!position) { - continue; - } - if (previous) { - EXPECT_GT(*position, *previous); - } - previous = position; - ++positions; - } - EXPECT_GE(positions, size_t{RATE} / 5); - - // Saved on the dispatch thread before the activation it led to was traced there - const std::optional saved = stored_record(provider, 0); - ASSERT_TRUE(saved); - EXPECT_EQ(saved->long_term_psk, long_term); - EXPECT_FALSE(saved->approval); -} - } // namespace diff --git a/tests/test_server_trust_internals.cpp b/tests/test_server_trust_internals.cpp new file mode 100644 index 0000000..79aba7a --- /dev/null +++ b/tests/test_server_trust_internals.cpp @@ -0,0 +1,481 @@ +// 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_trust_internals.cpp +/// @brief Trust through ServerCore on its own core thread, reached inside: a pairing PSK the +/// operator started is referenced, a long-term PSK is used only once it is durable, one change at a +/// time, and re-handshakes switch keys under playback and end a mismatch. Compiled with +/// -fno-access-control (tests/CMakeLists.txt). + +#include "client_records.h" +#include "crypto/base64url.h" +#include "crypto/keys.h" +#include "host_view.h" +#include "peer.h" +#include "raw_client.h" +#include "server_core.h" +#include "test_ports.h" +#include "test_recorder.h" +#include "transport.h" +#include "trust_fixtures.h" +#include "trust_harness.h" +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using sendspin::server::ClientInfo; +using sendspin::server::ClientRecord; +using sendspin::server::HandshakePsk; +using sendspin::server::Identity; +using sendspin::server::Psk; +using sendspin::server::PskCategory; +using sendspin::server::test::Bytes; +using sendspin::server::test::ClientStore; +using sendspin::server::test::Core; +using sendspin::server::test::Feeder; +using sendspin::server::test::HeldProvider; +using sendspin::server::test::is_type; +using sendspin::server::test::MemoryProvider; +using sendspin::server::test::Peer; +using sendspin::server::test::player_hello; +using sendspin::server::test::PLAYER_RATE; +using sendspin::server::test::psk_of; +using sendspin::server::test::RawClient; +using sendspin::server::test::read_one; +using sendspin::server::test::Reading; +using sendspin::server::test::stored_record; + +// While the operator has started a Pairing PSK pairing for a client, message 1 references its +// pairing PSK as 'pr', and a client holding it keys the session with it, which unpaired access and +// approval let play (messaging.md "server/activate"). Control: another client is referenced under +// the Sentinel. +TEST(ServerCoreTrust, ReferencesAPairingPskTheOperatorStarted) { + ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); + Core core; + const Psk pairing = psk_of(0x70); + RawClient paired; + RawClient other; + const std::string id = paired.noise.identity().peer_id(); + core.on_core( + [&] { core.core->pairing_psks_.emplace(id, HandshakePsk{PskCategory::PAIRING, pairing}); }); + core.dial(paired.url()); + core.dial(other.url()); + paired.noise.psk = pairing; + ASSERT_NO_FATAL_FAILURE(paired.activate(player_hello("Paired"))); + ASSERT_NO_FATAL_FAILURE(other.activate(player_hello("Other"))); + JsonDocument payload; + ASSERT_EQ(deserializeJson(payload, paired.noise.message_1_payload), DeserializationError::Ok); + EXPECT_EQ(payload["psk_category"], "pr"); + EXPECT_EQ(payload["psk_id"], sendspin::server::psk_id_for(pairing)); + ASSERT_EQ(deserializeJson(payload, other.noise.message_1_payload), DeserializationError::Ok); + EXPECT_EQ(payload["psk_category"], "sn"); +} + +// The same with sendspin-cpp, which keeps its pairing PSK among its candidates at all times +// (pairing.md "Pairing PSK Flow"): matched, the session plays as an approved unpaired one. A +// client holding another pairing PSK answers under the Sentinel, and that session is held with no +// roles (a wrong pairing token). +TEST(ServerCoreTrust, KeysASendspinCppSessionWithItsPairingPsk) { + ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); + struct Row { + const char* name; + Psk client_psk; + bool roles; + }; + const Psk pairing = psk_of(0x90); + const Row rows[] = { + {"Control: the client holds the pairing PSK", pairing, true}, + {"the client holds another", psk_of(0x91), false}, + }; + for (const Row& row : rows) { + SCOPED_TRACE(row.name); + Core core; + ClientStore store; + Peer peer(store, {true, row.client_psk}); + const std::string id = peer.client().client_id(); + core.on_core([&] { + core.core->pairing_psks_.emplace(id, HandshakePsk{PskCategory::PAIRING, pairing}); + }); + core.dial(peer.url()); + // A client held with no roles is connected before its activation is written + peer.run_until([&] { + return core.recorder.contains("connected " + id) && + core.recorder.contains("out server/activate"); + }); + const std::vector activations = core.capture.activations(); + ASSERT_FALSE(activations.empty()); + EXPECT_EQ(activations.front().find("player@v1") != std::string::npos, row.roles) + << activations.front(); + EXPECT_EQ(peer.trust().trust, sendspin::ConnectionTrust::NONE); + } +} + +// pairing.md "server/pair-finalize": a long-term PSK is used only once its record is durable. +// While the save is in flight handshakes reference the record as it was; a durable save makes the +// new PSK the one referenced and discards the approval (pairing.md "Unpaired Access"); a failed +// save or commit puts the record back, saves it again and reports the failure. +TEST(ServerCoreTrust, UsesALongTermPskOnlyOnceItIsDurable) { + ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); + struct Row { + const char* name; + bool refuse_save; + bool refuse_commit; + bool durable; + }; + const Row rows[] = { + {"Control: saved and committed", false, false, true}, + {"the save refused", true, false, false}, + {"the commit refused", false, true, false}, + }; + for (const Row& row : rows) { + SCOPED_TRACE(row.name); + const std::string id = Identity::generate()->peer_id(); + sendspin::server::ClientRecords records; + records.add_loaded(0, ClientRecord{id, sendspin::server::ApprovalScope::PLAYBACK}); + HeldProvider provider; + provider.refuse_save = row.refuse_save; + provider.refuse_commit = row.refuse_commit; + Core core(std::move(records), &provider); + const Psk long_term = psk_of(0xA0); + std::promise done; + const PskCategory in_flight = core.on_core([&] { + core.core->store_long_term_psk( + id, long_term, [&](bool saved, const auto& /*unsaved*/) { done.set_value(saved); }); + return core.core->handshake_psk(id).category; + }); + EXPECT_EQ(in_flight, PskCategory::SENTINEL); + // The pairing's save, and for a failure the save that puts the record back and its retry + // at shutdown + provider.release(row.durable ? 2 : 3); + EXPECT_EQ(done.get_future().get(), row.durable); + const HandshakePsk referenced = core.on_core([&] { return core.core->handshake_psk(id); }); + const ClientRecord record = core.on_core([&] { return *core.core->records_.find(id); }); + if (row.durable) { + EXPECT_EQ(referenced.category, PskCategory::LONG_TERM); + EXPECT_EQ(referenced.psk, long_term); + EXPECT_EQ(record.long_term_psk, long_term); + EXPECT_FALSE(record.approval); + const std::optional stored = stored_record(provider, 0); + ASSERT_TRUE(stored); + EXPECT_EQ(stored->long_term_psk, long_term); + EXPECT_FALSE(stored->approval); + } else { + EXPECT_EQ(referenced.category, PskCategory::SENTINEL); + EXPECT_FALSE(record.long_term_psk); + EXPECT_EQ(record.approval, sendspin::server::ApprovalScope::PLAYBACK); + // Both saves fail, each reported + core.recorder.wait_for_count("error " + + std::to_string(static_cast( + sendspin::server::ErrorKind::PERSISTENCE_FAILED)) + + " ", + 2); + EXPECT_EQ(provider.saves, 2); + } + } +} + +// One long-term PSK change per client is in flight: a second store meanwhile is refused at once +// and never applies. A failed save puts back only what the change set, so a name a connection +// refreshed and an approval the operator set meanwhile stay (pairing.md "server/pair-finalize"). +// Control: a durable save keeps the first PSK with those changes. +TEST(ServerCoreTrust, HoldsOneLongTermPskChangePerClient) { + ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); + struct Row { + const char* name; + bool refuse_save; + }; + const Row rows[] = { + {"Control: saved", false}, + {"the save refused", true}, + }; + const Psk first = psk_of(0xA0); + for (const Row& row : rows) { + SCOPED_TRACE(row.name); + RawClient client; + const std::string id = client.noise.identity().peer_id(); + sendspin::server::ClientRecords records; + ClientRecord loaded{id, std::nullopt}; + loaded.name = "Old"; + records.add_loaded(0, loaded); + HeldProvider provider; + provider.refuse_save = row.refuse_save; + Core core(std::move(records), &provider); + std::promise done; + std::optional second; + core.on_core([&] { + core.core->store_long_term_psk( + id, first, [&](bool saved, const auto& /*unsaved*/) { done.set_value(saved); }); + core.core->store_long_term_psk( + id, psk_of(0xB0), [&](bool saved, const auto& /*unsaved*/) { second = saved; }); + }); + EXPECT_EQ(second, std::optional(false)); + // Meanwhile the client connects under a new name and the operator approves it + core.dial(client.url()); + ASSERT_NO_FATAL_FAILURE(client.activate(player_hello("New"))); + core.on_core( + [&] { core.core->set_approval(id, sendspin::server::ApprovalScope::PLAYBACK); }); + // The pairing's, the name's, the approval's, then the pairing's again for a durable save, + // or the restoring save and its retry at shutdown for a failure + provider.release(row.refuse_save ? 5 : 4); + EXPECT_EQ(done.get_future().get(), !row.refuse_save); + const ClientRecord record = core.on_core([&] { return *core.core->records_.find(id); }); + if (row.refuse_save) { + EXPECT_FALSE(record.long_term_psk); + } else { + EXPECT_EQ(record.long_term_psk, first); + } + EXPECT_EQ(record.name, "New"); + EXPECT_EQ(record.approval, sendspin::server::ApprovalScope::PLAYBACK); + } +} + +// connection.md "Sentinel Fallback": a client that fell back from its long-term PSK reports the +// mismatch until a re-handshake completes, not once it is queued +TEST(ServerCoreTrust, ClearsTheMismatchOnceARekeyCompletes) { + ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); + RawClient client; + const std::string id = client.noise.identity().peer_id(); + sendspin::server::ClientRecords records; + ClientRecord record{id, std::nullopt}; + record.long_term_psk = psk_of(0xF0); + records.add_loaded(0, record); + Core core(std::move(records)); + core.dial(client.url()); + ASSERT_NO_FATAL_FAILURE(client.activate(player_hello("Lost"))); + const auto mismatch = [&] { + for (const ClientInfo& info : core.snapshot.read()) { + if (info.client_id == id) { + return info.credential_mismatch; + } + } + return false; + }; + ASSERT_TRUE(core.on_core(mismatch)); + EXPECT_TRUE(core.on_core([&] { + core.core->rekey(id, HandshakePsk{}); + return mismatch(); + })); + const std::string message_1 = client.receive_until("noise/handshake"); + ASSERT_TRUE(client.noise.read_rekey_message_1(message_1)); + client.send_binary(client.noise.rekey_message_2()); + EXPECT_FALSE(client.receive_until("server/activate").empty()); + EXPECT_FALSE(core.on_core(mismatch)); +} + +// connection.md "Re-handshake", end to end during playback: the raw client reads message 1 between +// chunks, sends a time request under the old keys before message 2, and gets the frozen +// server/activate first under the new keys (no active_roles, which persist), then the time reply, +// then chunks that continue the timeline where they left off, with no stream message in between +TEST(ServerCoreTrust, RekeysDuringPlaybackAndPlaysOn) { + ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); + Core core; + RawClient client; + const std::string id = client.noise.identity().peer_id(); + core.dial(client.url()); + ASSERT_NO_FATAL_FAILURE(client.activate(player_hello("Player"))); + client.send_json(sendspin::server::test::client_state_json( + true, sendspin::server::test::player_state_json(150, 300, 0))); + const Feeder feeder(core.open_input(PLAYER_RATE)); + ASSERT_FALSE(client.receive_until("stream/start").empty()); + int64_t next_ts = 0; + for (int chunks = 0; chunks < 3;) { + const Reading reading = read_one(client); + ASSERT_TRUE(reading.ts || !reading.json.empty()); + if (reading.ts) { + EXPECT_TRUE(chunks == 0 || *reading.ts == next_ts) << *reading.ts << " " << next_ts; + next_ts = *reading.ts + reading.duration_us; + ++chunks; + } + } + + core.on_core([&] { core.core->rekey(id, HandshakePsk{}); }); + std::string message_1; + while (message_1.empty()) { + const Reading reading = read_one(client); + ASSERT_TRUE(reading.ts || !reading.json.empty()); + if (reading.ts) { + EXPECT_EQ(*reading.ts, next_ts); + next_ts = *reading.ts + reading.duration_us; + } else if (is_type(reading.json, "noise/handshake")) { + message_1 = reading.json; + } else { + EXPECT_FALSE(is_type(reading.json, "stream/end")) << reading.json; + } + } + ASSERT_TRUE(client.noise.read_rekey_message_1(message_1)); + client.send_json(sendspin::server::test::client_time_json(77)); + client.send_binary(client.noise.rekey_message_2()); + + EXPECT_EQ(client.receive_json(), + R"({"type":"server/activate","payload":{"activities":["playback"]}})"); + JsonDocument time; + ASSERT_EQ(deserializeJson(time, client.receive_json()), DeserializationError::Ok); + EXPECT_EQ(time["type"], "server/time"); + EXPECT_EQ(time["payload"]["client_transmitted"], 77); + EXPECT_LE(time["payload"]["server_received"].as(), + time["payload"]["server_transmitted"].as()); + for (int chunks = 0; chunks < 3;) { + const Reading reading = read_one(client); + ASSERT_TRUE(reading.ts || !reading.json.empty()); + if (reading.ts) { + EXPECT_EQ(*reading.ts, next_ts); + next_ts = *reading.ts + reading.duration_us; + ++chunks; + } else { + EXPECT_FALSE(is_type(reading.json, "stream/start") || + is_type(reading.json, "stream/clear") || + is_type(reading.json, "stream/end")) + << reading.json; + } + } + JsonDocument payload; + ASSERT_EQ(deserializeJson(payload, client.noise.message_1_payload), DeserializationError::Ok); + EXPECT_EQ(payload["psk_category"], "sn"); +} + +// messaging.md "server/activate": when the activation after a re-handshake removes a stream role, +// its stream/end goes before message 1. Here a paired client playing under its long-term PSK is +// re-handshaken to the Sentinel, as before a code pairing: the session becomes unpaired while the +// record exists, so the frozen activation removes every role and 'playback' (connection.md +// "Sentinel Fallback", "Re-handshake": the activation rules apply under the newly matched PSK), and +// the client is moved alone into a stopped group. +TEST(ServerCoreTrust, EndsTheStreamsARekeyRemovesBeforeMessage1) { + ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); + const Psk long_term = psk_of(0xC0); + RawClient client; + const std::string id = client.noise.identity().peer_id(); + sendspin::server::ClientRecords records; + ClientRecord record{id, std::nullopt}; + record.long_term_psk = long_term; + records.add_loaded(0, record); + Core core(std::move(records)); + core.dial(client.url()); + client.noise.psk = long_term; + ASSERT_NO_FATAL_FAILURE(client.activate(player_hello("Paired"))); + JsonDocument payload; + ASSERT_EQ(deserializeJson(payload, client.noise.message_1_payload), DeserializationError::Ok); + EXPECT_EQ(payload["psk_category"], "lt"); + client.send_json(sendspin::server::test::client_state_json( + true, sendspin::server::test::player_state_json(150, 300, 0))); + const Feeder feeder(core.open_input(PLAYER_RATE)); + ASSERT_FALSE(client.receive_until("stream/start").empty()); + + core.on_core([&] { core.core->rekey(id, HandshakePsk{}); }); + bool ended = false; + std::string message_1; + while (message_1.empty()) { + const Reading reading = read_one(client); + ASSERT_TRUE(reading.ts || !reading.json.empty()); + if (reading.ts) { + EXPECT_FALSE(ended) << "a chunk after stream/end"; + } else if (is_type(reading.json, "stream/end")) { + ended = true; + } else if (is_type(reading.json, "noise/handshake")) { + message_1 = reading.json; + } + } + EXPECT_TRUE(ended); + client.noise.psk = sendspin::server::SENTINEL_PSK; + ASSERT_TRUE(client.noise.read_rekey_message_1(message_1)); + ASSERT_EQ(deserializeJson(payload, client.noise.message_1_payload), DeserializationError::Ok); + EXPECT_EQ(payload["psk_category"], "sn"); + client.send_binary(client.noise.rekey_message_2()); + EXPECT_EQ(client.receive_json(), + R"({"type":"server/activate","payload":{"activities":[],"active_roles":[]}})"); + const std::string update = client.receive_until("group/update"); + EXPECT_NE(update.find(R"("playback_state":"stopped")"), std::string::npos) << update; +} + +// The step after a pairing (pairing.md "Pairing"), with sendspin-cpp: an approved player streaming +// under the Sentinel gets a long-term PSK it already holds, the record is saved, and the server +// re-handshakes to it. The player keeps playing the signal in order and stays time-synced, and its +// record, saved, holds the PSK and no approval. The player plays in real time, so the test takes +// the 400 ms of audio it waits for on either side of the switch, and more. +TEST(ServerCoreTrust, PromotesAPlayingSendspinCppClientToItsLongTermPsk) { + ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); + MemoryProvider provider; + Core core({}, &provider); + const Psk long_term = psk_of(0xE0); + ClientStore store; + store.hold_record(core.identity.peer_id(), long_term); + Peer peer(store, {true, std::nullopt}); + const std::string id = peer.client().client_id(); + core.dial(peer.url()); + peer.run_until([&] { return core.recorder.contains("connected " + id); }); + EXPECT_EQ(peer.trust().trust, sendspin::ConnectionTrust::NONE); + const Feeder feeder(core.open_input(PLAYER_RATE)); + peer.run_until([&] { return peer.sink().audible() >= PLAYER_RATE / 5; }); + + const size_t replies = core.recorder.count("out server/time"); + std::atomic stored{false}; + core.on_core([&] { + core.core->store_long_term_psk(id, long_term, [&](bool saved, const auto& /*unsaved*/) { + EXPECT_TRUE(saved); + stored = true; + core.core->rekey(id, HandshakePsk{PskCategory::LONG_TERM, long_term}); + }); + }); + peer.run_until([&] { return core.recorder.count("out server/activate") >= 2; }); + const size_t audible = peer.sink().audible(); + peer.run_until([&] { + return peer.sink().audible() >= audible + PLAYER_RATE / 5 && + core.recorder.count("out server/time") >= replies + 2; + }); + EXPECT_TRUE(stored); + EXPECT_TRUE(peer.client().is_connected()); + EXPECT_TRUE(peer.client().is_time_synced()); + EXPECT_EQ(peer.trust().trust, sendspin::ConnectionTrust::USER); + EXPECT_EQ(core.recorder.count("out stream/start"), 1U); + EXPECT_EQ(core.recorder.count("out stream/end"), 0U); + + // The signal plays in order across the switch: no frame repeated or out of order + const sendspin::server::test::Signal signal(16); + const Bytes played = peer.sink().played(); + std::optional previous; + size_t positions = 0; + for (size_t offset = 0; offset + 4 <= played.size(); offset += 4) { + const std::optional position = signal.get(&played[offset]); + if (!position) { + continue; + } + if (previous) { + EXPECT_GT(*position, *previous); + } + previous = position; + ++positions; + } + EXPECT_GE(positions, size_t{PLAYER_RATE} / 5); + + // Saved on the dispatch thread before the activation it led to was traced there + const std::optional saved = stored_record(provider, 0); + ASSERT_TRUE(saved); + EXPECT_EQ(saved->long_term_psk, long_term); + EXPECT_FALSE(saved->approval); +} + +} // namespace diff --git a/tests/test_session.cpp b/tests/test_session.cpp index 687c321..3a92e01 100644 --- a/tests/test_session.cpp +++ b/tests/test_session.cpp @@ -20,10 +20,10 @@ #include "clock.h" #include "crypto/keys.h" #include "fragmentation.h" -#include "freed_blocks.h" #include "messages.h" #include "raw_client.h" #include "session.h" +#include "session_harness.h" #include "trust_fixtures.h" #include "wire_messages.h" #include @@ -41,7 +41,6 @@ namespace { using sendspin::server::CipherSuite; -using sendspin::server::Identity; using sendspin::server::MessageKind; using sendspin::server::OutboundMessage; using sendspin::server::Reassembler; @@ -55,21 +54,16 @@ using sendspin::server::test::Bytes; using sendspin::server::test::bytes_of; using sendspin::server::test::client_hello_json; using sendspin::server::test::client_time_json; +using sendspin::server::test::Harness; using sendspin::server::test::NoiseResponder; using sendspin::server::test::psk_of; +using sendspin::server::test::SessionTest; +using sendspin::server::test::START_US; +using sendspin::server::test::test_settings; using sendspin::server::test::text_of; +using sendspin::server::test::TIMEOUT_US; namespace wire = sendspin::server::test::wire; -constexpr int64_t START_US = 1000; -constexpr int64_t TIMEOUT_US = 5000; - -SessionSettings test_settings() { - SessionSettings settings; - settings.hello.name = "Test server"; - settings.step_timeout_us = TIMEOUT_US; - return settings; -} - /// A CredentialSource that references one PSK class FixedCredentials : public sendspin::server::CredentialSource { public: @@ -84,113 +78,6 @@ class FixedCredentials : public sendspin::server::CredentialSource { sendspin::server::HandshakePsk psk_; }; -// A session and its client. Not movable: the session refers to the identity and settings. -class Harness { -public: - explicit Harness(CipherSuite suite, SessionSettings settings = test_settings(), - SessionTrace* trace = nullptr, - sendspin::server::CredentialSource* credentials = nullptr) - : settings_(std::move(settings)), - session(this->identity, this->settings_, trace, credentials), - client(suite) { - this->session.start(START_US); - } - - Harness(const Harness&) = delete; - Harness& operator=(const Harness&) = delete; - - std::vector take_all() { - std::vector out; - while (std::optional message = this->session.take_message()) { - out.push_back(std::move(*message)); - } - return out; - } - - // Sends client/init and has the client read Noise message 1 under the spec's prologue - void exchange_init(int64_t rx_us = START_US) { - this->client_init = this->client.client_init(); - this->session.on_message(MessageKind::TEXT, bytes_of(this->client_init), rx_us); - ASSERT_EQ(this->session.phase(), SessionPhase::AWAIT_NOISE_MSG2); - const std::vector sent = this->take_all(); - ASSERT_EQ(sent.size(), 2U); - ASSERT_EQ(sent[0].kind, MessageKind::TEXT); - ASSERT_EQ(sent[1].kind, MessageKind::TEXT); - this->server_init = text_of(sent[0].bytes); - this->noise_message_1 = text_of(sent[1].bytes); - ASSERT_TRUE(this->client.read_message_1(bytes_of(this->client_init + this->server_init), - this->server_init, this->noise_message_1)); - } - - // Through Noise message 2 and the server/hello it releases - void complete_handshake(int64_t rx_us = START_US) { - ASSERT_NO_FATAL_FAILURE(this->exchange_init(rx_us)); - this->session.on_message(MessageKind::TEXT, bytes_of(this->client.message_2()), rx_us); - ASSERT_EQ(this->session.phase(), SessionPhase::AWAIT_CLIENT_HELLO); - const std::vector sent = this->take_all(); - ASSERT_EQ(sent.size(), 1U); - ASSERT_EQ(sent[0].kind, MessageKind::BINARY); - this->server_hello = this->client.decrypt(sent[0].bytes); - ASSERT_TRUE(this->server_hello); - } - - // Through client/hello and the initial server/activate, which the client decrypts - void activate() { - ASSERT_NO_FATAL_FAILURE(this->complete_handshake()); - this->send(this->client.encrypt_json(client_hello_json())); - ASSERT_EQ(this->session.phase(), SessionPhase::READY_FOR_ACTIVATION); - this->session.activate(ServerActivate{false, std::vector{}, std::nullopt}); - ASSERT_EQ(this->session.phase(), SessionPhase::ACTIVATING); - const std::vector sent = this->take_all(); - ASSERT_EQ(sent.size(), 1U); - ASSERT_EQ(this->session.phase(), SessionPhase::ACTIVE); - EXPECT_EQ(this->decrypt_json(sent[0]), - R"({"type":"server/activate","payload":{"activities":[],"active_roles":[]}})"); - } - - // The JSON a binary server message carries; empty when it is not one - std::string decrypt_json(const OutboundMessage& message) { - EXPECT_EQ(message.kind, MessageKind::BINARY); - const std::optional plaintext = this->client.decrypt(message.bytes); - if (!plaintext || plaintext->empty() || plaintext->front() != wire::JSON_ID) { - ADD_FAILURE() << "not an encrypted JSON message"; - return {}; - } - return {plaintext->begin() + 1, plaintext->end()}; - } - - void send(const Bytes& message, int64_t rx_us = START_US) { - this->session.on_message(MessageKind::BINARY, message, rx_us); - } - - // A fragment of a message, encrypted - Bytes fragment(uint8_t flags, const Bytes& data) { - Bytes plaintext{sendspin::server::MESSAGE_ID_FRAGMENT, flags}; - plaintext.insert(plaintext.end(), data.begin(), data.end()); - return this->client.encrypt(plaintext); - } - - Identity identity{*Identity::generate()}; - -private: - SessionSettings settings_; - -public: - Session session; - NoiseResponder client; - std::string client_init; - std::string server_init; - std::string noise_message_1; - std::optional server_hello; -}; - -class SessionTest : public testing::TestWithParam { -protected: - static void SetUpTestSuite() { - ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); - } -}; - TEST_P(SessionTest, CompletesTheHandshakeAndTheHelloExchange) { Harness h(GetParam()); ASSERT_NO_FATAL_FAILURE(h.exchange_init(2000)); @@ -620,7 +507,7 @@ TEST_P(SessionTest, BecomesActiveWhenTheActivationIsTaken) { h.send(h.client.encrypt_json(client_hello_json())); h.session.activate(ServerActivate{false, std::vector{}, std::nullopt}); const std::string follower = R"({"type":"group/update","payload":{}})"; - ASSERT_TRUE(h.session.queue_json(follower)); + h.session.queue_json_message(follower); const std::optional activation = h.session.take_message(); ASSERT_TRUE(activation); EXPECT_NE(h.decrypt_json(*activation).find("server/activate"), std::string::npos); @@ -1144,216 +1031,6 @@ TEST_P(SessionTest, CountsPairingActivationsAndHandsOverPairingMessages) { EXPECT_EQ(h.session.end(), SessionEnd::PROTOCOL_ERROR); } -// Whether a vector's bytes, read to its capacity, hold needle. The spare capacity is outside the -// container's annotation, so the scan is not instrumented and calls nothing that is. -__attribute__((no_sanitize("address"))) bool holds_to_capacity(const std::vector& bytes, - std::string_view needle) { - const uint8_t* data = bytes.data(); - for (size_t start = 0; start + needle.size() <= bytes.capacity(); ++start) { - size_t matched = 0; - while (matched < needle.size() && - data[start + matched] == static_cast(needle[matched])) { - ++matched; - } - if (matched == needle.size()) { - return true; - } - } - return false; -} - -// pairing.md "client/pair-finalize": a pair-finalize carrying both long_term_psk and wrapped_psk -// fails validation, and its PSK is wiped from the decrypted message and the reassembly buffer, -// spare capacity included, as a valid one's is (the Control), and as it is from a body whose type -// cannot be read and from a fragment sequence that fails or outgrows the cap with the PSK buffered. -// The first fragment's check that the scan finds the PSK is the Control of the scan. -TEST_P(SessionTest, WipesTheSecretOfAPairingMessageWhetherOrNotItPassesValidation) { - using sendspin::server::FRAGMENT_FLAG_FIRST; - using sendspin::server::FRAGMENT_FLAG_LAST; - using sendspin::server::FRAGMENT_FLAGS_RESERVED; - const std::string psk = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY"; - const std::string valid_head = - R"({"type":"client/pair-finalize","payload":{"long_term_psk":")" + psk; - const std::string valid_tail = R"("}})"; - const std::string head = valid_head + R"(","wrapped_psk":"AAAA)"; - const std::string tail = R"("}})"; - struct Case { - const char* name; - std::string head; - std::string tail; - // Whether head and tail go as two fragments rather than one message - bool fragmented; - // The second fragment's flags: LAST completes the message, a reserved bit fails it - uint8_t second_flags; - std::optional end; - // The cap on a reassembled message: the head fits, head and tail do not - size_t max_reassembled_bytes{sendspin::server::DEFAULT_MAX_REASSEMBLED_BYTES}; - }; - const std::vector cases{ - {"Control: a valid one in one message", valid_head, valid_tail, false, 0, std::nullopt}, - {"Control: a valid one in two fragments", valid_head, valid_tail, true, FRAGMENT_FLAG_LAST, - std::nullopt}, - {"in one message", head, tail, false, 0, SessionEnd::PROTOCOL_ERROR}, - {"in a body that is not JSON, its type unread", head, "", false, 0, - SessionEnd::PROTOCOL_ERROR}, - {"in two fragments, the PSK in the first", head, tail, true, FRAGMENT_FLAG_LAST, - SessionEnd::PROTOCOL_ERROR}, - {"in a fragment sequence that fails", head, tail, true, FRAGMENT_FLAGS_RESERVED, - SessionEnd::MALFORMED_FRAGMENTS}, - {"in a fragment sequence that outgrows the cap", head, tail, true, FRAGMENT_FLAG_LAST, - SessionEnd::MESSAGE_TOO_LARGE, head.size()}, - }; - for (const Case& c : cases) { - SCOPED_TRACE(c.name); - SessionSettings settings = test_settings(); - settings.max_reassembled_bytes = c.max_reassembled_bytes; - Harness h(GetParam(), settings); - ASSERT_NO_FATAL_FAILURE(h.activate()); - if (c.fragmented) { - Bytes first{sendspin::server::MESSAGE_ID_JSON}; - const Bytes head_bytes = bytes_of(c.head); - first.insert(first.end(), head_bytes.begin(), head_bytes.end()); - h.send(h.fragment(FRAGMENT_FLAG_FIRST, first)); - EXPECT_TRUE(holds_to_capacity(h.session.scratch_, psk)); - EXPECT_TRUE(holds_to_capacity(h.session.reassembler_.buffer_, psk)); - h.send(h.fragment(c.second_flags, bytes_of(c.tail))); - } else { - h.send(h.client.encrypt_json(c.head + c.tail)); - } - EXPECT_EQ(h.session.end(), c.end); - EXPECT_EQ(h.session.take_pairing_messages().size(), c.end ? 0U : 1U); - EXPECT_FALSE(holds_to_capacity(h.session.scratch_, psk)); - EXPECT_FALSE(holds_to_capacity(h.session.reassembler_.buffer_, psk)); - } -} - -// A client/pair-finalize whose tag fails leaves no PSK in the decrypted message's buffer: AES-GCM -// decrypts in place before its tag check (ChaChaPoly checks first), and the buffer is wiped -TEST_P(SessionTest, WipesAPairingMessageWhoseTagFails) { - const std::string psk = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY"; - Harness h(GetParam()); - ASSERT_NO_FATAL_FAILURE(h.activate()); - Bytes message = h.client.encrypt_json( - R"({"type":"client/pair-finalize","payload":{"long_term_psk":")" + psk + R"("}})"); - message.back() ^= 0x01; - h.send(message); - EXPECT_EQ(h.session.end(), SessionEnd::DECRYPT_FAILED); - EXPECT_FALSE(holds_to_capacity(h.session.scratch_, psk)); -} - -// A session that ends while a pairing message's first fragment is pending leaves its PSK in -// neither buffer, whether the host closes it or the next message fails to decrypt. The scan finds -// the PSK before the end: the Control. -TEST_P(SessionTest, WipesAPendingPairingFragmentAtTheEnd) { - using sendspin::server::FRAGMENT_FLAG_FIRST; - const std::string psk = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY"; - struct Case { - const char* name; - std::function end_session; - SessionEnd end; - }; - const std::vector cases{ - {"closed by the host", [](Harness& h) { h.session.close_by_host(); }, - SessionEnd::CLOSED_BY_HOST}, - {"a message that fails to decrypt", [](Harness& h) { h.send(Bytes(32, 0)); }, - SessionEnd::DECRYPT_FAILED}, - }; - for (const Case& c : cases) { - SCOPED_TRACE(c.name); - Harness h(GetParam()); - ASSERT_NO_FATAL_FAILURE(h.activate()); - Bytes first{sendspin::server::MESSAGE_ID_JSON}; - const Bytes head = - bytes_of(R"({"type":"client/pair-finalize","payload":{"long_term_psk":")" + psk); - first.insert(first.end(), head.begin(), head.end()); - h.send(h.fragment(FRAGMENT_FLAG_FIRST, first)); - EXPECT_TRUE(holds_to_capacity(h.session.scratch_, psk)); - EXPECT_TRUE(holds_to_capacity(h.session.reassembler_.buffer_, psk)); - c.end_session(h); - EXPECT_EQ(h.session.end(), c.end); - EXPECT_FALSE(holds_to_capacity(h.session.scratch_, psk)); - EXPECT_FALSE(holds_to_capacity(h.session.reassembler_.buffer_, psk)); - } -} - -// A pairing message's first fragment, its PSK in it, followed by a longer one, grows both the -// decrypted message's buffer and the reassembly buffer; neither frees a block holding the PSK. -// The scan's Control is SecureReserve.WipesTheBlockItLeaves. -TEST_P(SessionTest, GrowsItsBuffersWithoutFreeingAPairingSecret) { - using sendspin::server::FRAGMENT_FLAG_FIRST; - if (!FreedBlockScan::available()) { - GTEST_SKIP() << "needs the sanitizer's free hook"; - } - const std::string psk = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY"; - Harness h(GetParam()); - ASSERT_NO_FATAL_FAILURE(h.activate()); - Bytes first{sendspin::server::MESSAGE_ID_JSON}; - const Bytes head = - bytes_of(R"({"type":"client/pair-finalize","payload":{"long_term_psk":")" + psk); - first.insert(first.end(), head.begin(), head.end()); - h.send(h.fragment(FRAGMENT_FLAG_FIRST, first)); - ASSERT_TRUE(holds_to_capacity(h.session.scratch_, psk)); - ASSERT_TRUE(holds_to_capacity(h.session.reassembler_.buffer_, psk)); - const size_t scratch_capacity = h.session.scratch_.capacity(); - const size_t buffer_capacity = h.session.reassembler_.buffer_.capacity(); - const Bytes longer = h.fragment(0, Bytes(4 * scratch_capacity, 'a')); - { - const FreedBlockScan scan(psk); - h.send(longer); - EXPECT_EQ(scan.holding(), 0); - } - EXPECT_FALSE(h.session.end()); - EXPECT_GT(h.session.scratch_.capacity(), scratch_capacity); - EXPECT_GT(h.session.reassembler_.buffer_.capacity(), buffer_capacity); -} - -// A session destroyed while a pairing message's first fragment is pending, with no end before, -// frees neither buffer holding the PSK. The scan's Control is SecureReserve.WipesTheBlockItLeaves. -TEST_P(SessionTest, WipesAPendingPairingFragmentWhenDestroyed) { - using sendspin::server::FRAGMENT_FLAG_FIRST; - if (!FreedBlockScan::available()) { - GTEST_SKIP() << "needs the sanitizer's free hook"; - } - const std::string psk = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY"; - std::optional h; - h.emplace(GetParam()); - ASSERT_NO_FATAL_FAILURE(h->activate()); - Bytes first{sendspin::server::MESSAGE_ID_JSON}; - const Bytes head = - bytes_of(R"({"type":"client/pair-finalize","payload":{"long_term_psk":")" + psk); - first.insert(first.end(), head.begin(), head.end()); - h->send(h->fragment(FRAGMENT_FLAG_FIRST, first)); - ASSERT_TRUE(holds_to_capacity(h->session.scratch_, psk)); - ASSERT_TRUE(holds_to_capacity(h->session.reassembler_.buffer_, psk)); - ASSERT_FALSE(h->session.end()); - const FreedBlockScan scan(psk); - h.reset(); - EXPECT_EQ(scan.holding(), 0); -} - -// A wipe reaches the reassembly buffer's storage past a shorter message, where a longer one -// written since the last wipe left its bytes. Before the wipe the scan finds them: the Control. -TEST(Reassembler, WipesTheStoragePastTheMessage) { - using sendspin::server::FRAGMENT_FLAG_FIRST; - using sendspin::server::FRAGMENT_FLAG_LAST; - const std::string secret = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY"; - Reassembler reassembler(sendspin::server::DEFAULT_MAX_REASSEMBLED_BYTES, - [](uint8_t) { return true; }); - const auto whole = [](const std::string& text) { - Bytes plaintext{sendspin::server::MESSAGE_ID_FRAGMENT, - FRAGMENT_FLAG_FIRST | FRAGMENT_FLAG_LAST, - sendspin::server::MESSAGE_ID_JSON}; - plaintext.insert(plaintext.end(), text.begin(), text.end()); - return plaintext; - }; - ASSERT_EQ(reassembler.accept(whole("{\"longer\":\"" + secret + "\"}")), - Reassembler::Outcome::MESSAGE); - ASSERT_EQ(reassembler.accept(whole("{}")), Reassembler::Outcome::MESSAGE); - EXPECT_TRUE(holds_to_capacity(reassembler.buffer_, secret)); - reassembler.wipe(); - EXPECT_FALSE(holds_to_capacity(reassembler.buffer_, secret)); -} - // A session that ends between message 2 and the frozen activation's write, here on a client/goodbye // under the new keys, writes nothing more TEST_P(SessionTest, EndsBeforeTheFrozenActivationWithNothingMoreWritten) { diff --git a/tests/test_session_driver.cpp b/tests/test_session_driver.cpp index 8be6fce..963f2cf 100644 --- a/tests/test_session_driver.cpp +++ b/tests/test_session_driver.cpp @@ -15,12 +15,9 @@ /// @file test_session_driver.cpp /// @brief SessionDriver over loopback: the server dials a sendspin-cpp client through the hello /// exchange, a raw Beast peer on its own thread sees server/error before the close (traced with its -/// completed write) and a silent close on a timeout, and a goodbye that arrives while a write is in -/// flight ends the session, and an active session's messages go out as many to a write as are -/// ready, but for a time reply, which ends its write. White-box: reads SessionDriver::writing_ and -/// the session's fragment progress (see tests/CMakeLists.txt). +/// completed write) and a silent close on a timeout, and an active session's messages go out as +/// many to a write as are ready, but for a time reply, which ends its write. -#include "backoff.h" #include "clock.h" #include "crypto/keys.h" #include "fragmentation.h" @@ -48,7 +45,6 @@ #include #include #include -#include #include #include #include @@ -391,7 +387,7 @@ class ActiveDriver { ASSERT_NE(activation.find("server/activate"), std::string::npos) << activation; // Until the activation's write has completed and nothing more is ready while (!this->on_core([this] { - return !this->driver_->writing_ && !this->driver_->waiting_ && + return !this->trace.events.empty() && this->trace.events.back() == "written" && !this->driver_->session().has_message(); })) { std::this_thread::sleep_for(std::chrono::milliseconds(1)); @@ -402,8 +398,9 @@ class ActiveDriver { void queue(const std::vector& messages) { this->on_core([&] { for (const std::string& json : messages) { - ASSERT_TRUE(this->driver_->session().queue_json(json)); + this->driver_->session().queue_json_message(json); } + ASSERT_FALSE(this->driver_->session().end()); this->driver_->pump(); }); } @@ -497,129 +494,4 @@ TEST(SessionDriverWrites, ATimeReplyEndsItsWrite) { EXPECT_EQ(std::count(reply, events.end(), "out"), 4); } -// messaging.md "client/goodbye": a goodbye that arrives while a write is stalled, from a client -// that closes right after it without reading, ends the session before the transport can report -// the close, so the connection is reported as the goodbye -TEST(SessionDriverGoodbye, EndsWithAGoodbyeThatArrivesWhileAWriteIsStalled) { - ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); - struct Closed { - std::optional end; - std::optional transport; - sendspin::server::ServerError error; - bool writing{false}; - }; - class Observer : public SessionObserver { - public: - void on_ready(SessionDriver& /*driver*/) override { - ADD_FAILURE() << "ready before client/hello"; - } - - void on_closed(SessionDriver& driver) override { - this->closed.set_value( - {driver.session().end(), driver.transport_close(), - sendspin::server::error_before_activation("url", driver.session().outcome()), - driver.writing_}); - } - - std::promise closed; - }; - - for (const bool half_close : {true, false}) { - SCOPED_TRACE(half_close ? "half-close" : "close frame"); - sendspin::server::EventLoop loop; - const Identity identity = *Identity::generate(); - SessionSettings settings; - // server/hello far larger than the socket buffers can grow to, in fragments larger than - // the room the socket reports under the default low-water mark, so that a write stays in - // flight. Each language tag stays within ArduinoJson's string limit. - constexpr size_t TAG_BYTES = 60000; - constexpr size_t TAGS = 280; - settings.hello.languages.assign(TAGS, std::string(TAG_BYTES, 'l')); - settings.queue_caps.control_bytes = 2 * TAGS * TAG_BYTES; - settings.max_fragment_plaintext = sendspin::server::MAX_TRANSPORT_PLAINTEXT; - sendspin::server::TransportSettings transport; - transport.notsent_lowat_bytes = 0; - Observer observer; - std::unique_ptr driver; - std::optional pending; - std::thread core_thread([&] { loop.run(); }); - // Ends the loop and joins its thread on every exit, a failed assertion's included - struct Stop { - std::function stop; - ~Stop() { - this->stop(); - } - }; - const Stop stop{[&] { - loop.post([&] { - driver.reset(); - pending.reset(); - loop.shutdown(0); - }); - core_thread.join(); - }}; - - sendspin::server::test::RawClient client; - loop.post([&] { - pending.emplace( - sendspin::server::dial(loop, *sendspin::server::parse_ws_url(client.url()), - transport, [&](sendspin::server::DialResult result) { - driver = std::make_unique( - loop, std::move(std::get(result)), - identity, settings, observer); - driver->start(); - })); - }); - client.accept(); - client.send_client_init(); - ASSERT_NO_FATAL_FAILURE(client.receive_message_1()); - client.send_message_2(); - - // The client reads nothing, so once the socket buffers stop growing the writes stall: a - // write in flight (macOS) or a wait for room (Linux) seen twice, with no fragment taken in - // between - struct Progress { - bool stalled{false}; - size_t taken{0}; - }; - const auto inspect = [&] { - std::promise inspected; - loop.post([&] { - Progress progress; - if (driver) { - const auto& fragmenting = driver->session().queues_.fragmenting_; - progress = {driver->writing_ || driver->waiting_, - fragmenting ? fragmenting->offset : 0}; - } - inspected.set_value(progress); - }); - return inspected.get_future().get(); - }; - for (;;) { - const Progress first = inspect(); - std::this_thread::sleep_for(std::chrono::milliseconds(first.stalled ? 200 : 1)); - const Progress second = inspect(); - if (first.stalled && second.stalled && first.taken == second.taken) { - break; - } - } - client.send_json(sendspin::server::test::client_goodbye_json("restart")); - if (half_close) { - client.half_close(); - } else { - client.close(); - } - - const Closed closed = observer.closed.get_future().get(); - EXPECT_EQ(closed.end, SessionEnd::GOODBYE); - if (half_close) { - // The driver closes the connection itself only once no write is in flight, so a write - // still in flight at the close leaves it to the transport, after the goodbye - EXPECT_EQ(closed.transport.has_value(), closed.writing); - } - EXPECT_EQ(closed.error.kind, sendspin::server::ErrorKind::GOODBYE_BEFORE_ACTIVATION); - EXPECT_EQ(closed.error.goodbye_reason, sendspin::server::GoodbyeReason::RESTART); - } -} - } // namespace diff --git a/tests/test_session_driver_internals.cpp b/tests/test_session_driver_internals.cpp new file mode 100644 index 0000000..1dae170 --- /dev/null +++ b/tests/test_session_driver_internals.cpp @@ -0,0 +1,172 @@ +// 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_session_driver_internals.cpp +/// @brief SessionDriver over loopback: a goodbye that arrives while a write is stalled ends the +/// session. Compiled with -fno-access-control (tests/CMakeLists.txt). + +#include "backoff.h" +#include "crypto/keys.h" +#include "fragmentation.h" +#include "raw_client.h" +#include "session_driver.h" +#include "transport.h" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using sendspin::server::Connection; +using sendspin::server::Identity; +using sendspin::server::SessionDriver; +using sendspin::server::SessionEnd; +using sendspin::server::SessionObserver; +using sendspin::server::SessionSettings; + +// messaging.md "client/goodbye": a goodbye that arrives while a write is stalled, from a client +// that closes right after it without reading, ends the session before the transport can report +// the close, so the connection is reported as the goodbye +TEST(SessionDriverGoodbye, EndsWithAGoodbyeThatArrivesWhileAWriteIsStalled) { + ASSERT_EQ(noise_init_framework(), NOISE_ERROR_NONE); + struct Closed { + std::optional end; + std::optional transport; + sendspin::server::ServerError error; + bool writing{false}; + }; + class Observer : public SessionObserver { + public: + void on_ready(SessionDriver& /*driver*/) override { + ADD_FAILURE() << "ready before client/hello"; + } + + void on_closed(SessionDriver& driver) override { + this->closed.set_value( + {driver.session().end(), driver.transport_close(), + sendspin::server::error_before_activation("url", driver.session().outcome()), + driver.writing_}); + } + + std::promise closed; + }; + + for (const bool half_close : {true, false}) { + SCOPED_TRACE(half_close ? "half-close" : "close frame"); + sendspin::server::EventLoop loop; + const Identity identity = *Identity::generate(); + SessionSettings settings; + // server/hello far larger than the socket buffers can grow to, in fragments larger than + // the room the socket reports under the default low-water mark, so that a write stays in + // flight. Each language tag stays within ArduinoJson's string limit. + constexpr size_t TAG_BYTES = 60000; + constexpr size_t TAGS = 280; + settings.hello.languages.assign(TAGS, std::string(TAG_BYTES, 'l')); + settings.queue_caps.control_bytes = 2 * TAGS * TAG_BYTES; + settings.max_fragment_plaintext = sendspin::server::MAX_TRANSPORT_PLAINTEXT; + sendspin::server::TransportSettings transport; + transport.notsent_lowat_bytes = 0; + Observer observer; + std::unique_ptr driver; + std::optional pending; + std::thread core_thread([&] { loop.run(); }); + // Ends the loop and joins its thread on every exit, a failed assertion's included + struct Stop { + std::function stop; + ~Stop() { + this->stop(); + } + }; + const Stop stop{[&] { + loop.post([&] { + driver.reset(); + pending.reset(); + loop.shutdown(0); + }); + core_thread.join(); + }}; + + sendspin::server::test::RawClient client; + loop.post([&] { + pending.emplace( + sendspin::server::dial(loop, *sendspin::server::parse_ws_url(client.url()), + transport, [&](sendspin::server::DialResult result) { + driver = std::make_unique( + loop, std::move(std::get(result)), + identity, settings, observer); + driver->start(); + })); + }); + client.accept(); + client.send_client_init(); + ASSERT_NO_FATAL_FAILURE(client.receive_message_1()); + client.send_message_2(); + + // The client reads nothing, so once the socket buffers stop growing the writes stall: a + // write in flight (macOS) or a wait for room (Linux) seen twice, with no fragment taken in + // between + struct Progress { + bool stalled{false}; + size_t taken{0}; + }; + const auto inspect = [&] { + std::promise inspected; + loop.post([&] { + Progress progress; + if (driver) { + const auto& fragmenting = driver->session().queues_.fragmenting_; + progress = {driver->writing_ || driver->waiting_, + fragmenting ? fragmenting->offset : 0}; + } + inspected.set_value(progress); + }); + return inspected.get_future().get(); + }; + for (;;) { + const Progress first = inspect(); + std::this_thread::sleep_for(std::chrono::milliseconds(first.stalled ? 200 : 1)); + const Progress second = inspect(); + if (first.stalled && second.stalled && first.taken == second.taken) { + break; + } + } + client.send_json(sendspin::server::test::client_goodbye_json("restart")); + if (half_close) { + client.half_close(); + } else { + client.close(); + } + + const Closed closed = observer.closed.get_future().get(); + EXPECT_EQ(closed.end, SessionEnd::GOODBYE); + if (half_close) { + // The driver closes the connection itself only once no write is in flight, so a write + // still in flight at the close leaves it to the transport, after the goodbye + EXPECT_EQ(closed.transport.has_value(), closed.writing); + } + EXPECT_EQ(closed.error.kind, sendspin::server::ErrorKind::GOODBYE_BEFORE_ACTIVATION); + EXPECT_EQ(closed.error.goodbye_reason, sendspin::server::GoodbyeReason::RESTART); + } +} + +} // namespace diff --git a/tests/test_session_internals.cpp b/tests/test_session_internals.cpp new file mode 100644 index 0000000..d9238b7 --- /dev/null +++ b/tests/test_session_internals.cpp @@ -0,0 +1,255 @@ +// 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_session_internals.cpp +/// @brief A pairing message's secret wiped from the session's buffers, spare capacity included, +/// however the message or the session ends, and from a reassembly buffer's storage past a shorter +/// message. Compiled with -fno-access-control (tests/CMakeLists.txt). + +#include "fragmentation.h" +#include "freed_blocks.h" +#include "session.h" +#include "session_harness.h" +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using sendspin::server::Reassembler; +using sendspin::server::SessionEnd; +using sendspin::server::SessionSettings; +using sendspin::server::test::Bytes; +using sendspin::server::test::bytes_of; +using sendspin::server::test::Harness; +using sendspin::server::test::SessionTest; +using sendspin::server::test::test_settings; + +// Whether a vector's bytes, read to its capacity, hold needle. The spare capacity is outside the +// container's annotation, so the scan is not instrumented and calls nothing that is. +__attribute__((no_sanitize("address"))) bool holds_to_capacity(const std::vector& bytes, + std::string_view needle) { + const uint8_t* data = bytes.data(); + for (size_t start = 0; start + needle.size() <= bytes.capacity(); ++start) { + size_t matched = 0; + while (matched < needle.size() && + data[start + matched] == static_cast(needle[matched])) { + ++matched; + } + if (matched == needle.size()) { + return true; + } + } + return false; +} + +// pairing.md "client/pair-finalize": a pair-finalize carrying both long_term_psk and wrapped_psk +// fails validation, and its PSK is wiped from the decrypted message and the reassembly buffer, +// spare capacity included, as a valid one's is (the Control), and as it is from a body whose type +// cannot be read and from a fragment sequence that fails or outgrows the cap with the PSK buffered. +// The first fragment's check that the scan finds the PSK is the Control of the scan. +TEST_P(SessionTest, WipesTheSecretOfAPairingMessageWhetherOrNotItPassesValidation) { + using sendspin::server::FRAGMENT_FLAG_FIRST; + using sendspin::server::FRAGMENT_FLAG_LAST; + using sendspin::server::FRAGMENT_FLAGS_RESERVED; + const std::string psk = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY"; + const std::string valid_head = + R"({"type":"client/pair-finalize","payload":{"long_term_psk":")" + psk; + const std::string valid_tail = R"("}})"; + const std::string head = valid_head + R"(","wrapped_psk":"AAAA)"; + const std::string tail = R"("}})"; + struct Case { + const char* name; + std::string head; + std::string tail; + // Whether head and tail go as two fragments rather than one message + bool fragmented; + // The second fragment's flags: LAST completes the message, a reserved bit fails it + uint8_t second_flags; + std::optional end; + // The cap on a reassembled message: the head fits, head and tail do not + size_t max_reassembled_bytes{sendspin::server::DEFAULT_MAX_REASSEMBLED_BYTES}; + }; + const std::vector cases{ + {"Control: a valid one in one message", valid_head, valid_tail, false, 0, std::nullopt}, + {"Control: a valid one in two fragments", valid_head, valid_tail, true, FRAGMENT_FLAG_LAST, + std::nullopt}, + {"in one message", head, tail, false, 0, SessionEnd::PROTOCOL_ERROR}, + {"in a body that is not JSON, its type unread", head, "", false, 0, + SessionEnd::PROTOCOL_ERROR}, + {"in two fragments, the PSK in the first", head, tail, true, FRAGMENT_FLAG_LAST, + SessionEnd::PROTOCOL_ERROR}, + {"in a fragment sequence that fails", head, tail, true, FRAGMENT_FLAGS_RESERVED, + SessionEnd::MALFORMED_FRAGMENTS}, + {"in a fragment sequence that outgrows the cap", head, tail, true, FRAGMENT_FLAG_LAST, + SessionEnd::MESSAGE_TOO_LARGE, head.size()}, + }; + for (const Case& c : cases) { + SCOPED_TRACE(c.name); + SessionSettings settings = test_settings(); + settings.max_reassembled_bytes = c.max_reassembled_bytes; + Harness h(GetParam(), settings); + ASSERT_NO_FATAL_FAILURE(h.activate()); + if (c.fragmented) { + Bytes first{sendspin::server::MESSAGE_ID_JSON}; + const Bytes head_bytes = bytes_of(c.head); + first.insert(first.end(), head_bytes.begin(), head_bytes.end()); + h.send(h.fragment(FRAGMENT_FLAG_FIRST, first)); + EXPECT_TRUE(holds_to_capacity(h.session.scratch_, psk)); + EXPECT_TRUE(holds_to_capacity(h.session.reassembler_.buffer_, psk)); + h.send(h.fragment(c.second_flags, bytes_of(c.tail))); + } else { + h.send(h.client.encrypt_json(c.head + c.tail)); + } + EXPECT_EQ(h.session.end(), c.end); + EXPECT_EQ(h.session.take_pairing_messages().size(), c.end ? 0U : 1U); + EXPECT_FALSE(holds_to_capacity(h.session.scratch_, psk)); + EXPECT_FALSE(holds_to_capacity(h.session.reassembler_.buffer_, psk)); + } +} + +// A client/pair-finalize whose tag fails leaves no PSK in the decrypted message's buffer: AES-GCM +// decrypts in place before its tag check (ChaChaPoly checks first), and the buffer is wiped +TEST_P(SessionTest, WipesAPairingMessageWhoseTagFails) { + const std::string psk = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY"; + Harness h(GetParam()); + ASSERT_NO_FATAL_FAILURE(h.activate()); + Bytes message = h.client.encrypt_json( + R"({"type":"client/pair-finalize","payload":{"long_term_psk":")" + psk + R"("}})"); + message.back() ^= 0x01; + h.send(message); + EXPECT_EQ(h.session.end(), SessionEnd::DECRYPT_FAILED); + EXPECT_FALSE(holds_to_capacity(h.session.scratch_, psk)); +} + +// A session that ends while a pairing message's first fragment is pending leaves its PSK in +// neither buffer, whether the host closes it or the next message fails to decrypt. The scan finds +// the PSK before the end: the Control. +TEST_P(SessionTest, WipesAPendingPairingFragmentAtTheEnd) { + using sendspin::server::FRAGMENT_FLAG_FIRST; + const std::string psk = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY"; + struct Case { + const char* name; + std::function end_session; + SessionEnd end; + }; + const std::vector cases{ + {"closed by the host", [](Harness& h) { h.session.close_by_host(); }, + SessionEnd::CLOSED_BY_HOST}, + {"a message that fails to decrypt", [](Harness& h) { h.send(Bytes(32, 0)); }, + SessionEnd::DECRYPT_FAILED}, + }; + for (const Case& c : cases) { + SCOPED_TRACE(c.name); + Harness h(GetParam()); + ASSERT_NO_FATAL_FAILURE(h.activate()); + Bytes first{sendspin::server::MESSAGE_ID_JSON}; + const Bytes head = + bytes_of(R"({"type":"client/pair-finalize","payload":{"long_term_psk":")" + psk); + first.insert(first.end(), head.begin(), head.end()); + h.send(h.fragment(FRAGMENT_FLAG_FIRST, first)); + EXPECT_TRUE(holds_to_capacity(h.session.scratch_, psk)); + EXPECT_TRUE(holds_to_capacity(h.session.reassembler_.buffer_, psk)); + c.end_session(h); + EXPECT_EQ(h.session.end(), c.end); + EXPECT_FALSE(holds_to_capacity(h.session.scratch_, psk)); + EXPECT_FALSE(holds_to_capacity(h.session.reassembler_.buffer_, psk)); + } +} + +// A pairing message's first fragment, its PSK in it, followed by a longer one, grows both the +// decrypted message's buffer and the reassembly buffer; neither frees a block holding the PSK. +// The scan's Control is SecureReserve.WipesTheBlockItLeaves. +TEST_P(SessionTest, GrowsItsBuffersWithoutFreeingAPairingSecret) { + using sendspin::server::FRAGMENT_FLAG_FIRST; + if (!FreedBlockScan::available()) { + GTEST_SKIP() << "needs the sanitizer's free hook"; + } + const std::string psk = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY"; + Harness h(GetParam()); + ASSERT_NO_FATAL_FAILURE(h.activate()); + Bytes first{sendspin::server::MESSAGE_ID_JSON}; + const Bytes head = + bytes_of(R"({"type":"client/pair-finalize","payload":{"long_term_psk":")" + psk); + first.insert(first.end(), head.begin(), head.end()); + h.send(h.fragment(FRAGMENT_FLAG_FIRST, first)); + ASSERT_TRUE(holds_to_capacity(h.session.scratch_, psk)); + ASSERT_TRUE(holds_to_capacity(h.session.reassembler_.buffer_, psk)); + const size_t scratch_capacity = h.session.scratch_.capacity(); + const size_t buffer_capacity = h.session.reassembler_.buffer_.capacity(); + const Bytes longer = h.fragment(0, Bytes(4 * scratch_capacity, 'a')); + { + const FreedBlockScan scan(psk); + h.send(longer); + EXPECT_EQ(scan.holding(), 0); + } + EXPECT_FALSE(h.session.end()); + EXPECT_GT(h.session.scratch_.capacity(), scratch_capacity); + EXPECT_GT(h.session.reassembler_.buffer_.capacity(), buffer_capacity); +} + +// A session destroyed while a pairing message's first fragment is pending, with no end before, +// frees neither buffer holding the PSK. The scan's Control is SecureReserve.WipesTheBlockItLeaves. +TEST_P(SessionTest, WipesAPendingPairingFragmentWhenDestroyed) { + using sendspin::server::FRAGMENT_FLAG_FIRST; + if (!FreedBlockScan::available()) { + GTEST_SKIP() << "needs the sanitizer's free hook"; + } + const std::string psk = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY"; + std::optional h; + h.emplace(GetParam()); + ASSERT_NO_FATAL_FAILURE(h->activate()); + Bytes first{sendspin::server::MESSAGE_ID_JSON}; + const Bytes head = + bytes_of(R"({"type":"client/pair-finalize","payload":{"long_term_psk":")" + psk); + first.insert(first.end(), head.begin(), head.end()); + h->send(h->fragment(FRAGMENT_FLAG_FIRST, first)); + ASSERT_TRUE(holds_to_capacity(h->session.scratch_, psk)); + ASSERT_TRUE(holds_to_capacity(h->session.reassembler_.buffer_, psk)); + ASSERT_FALSE(h->session.end()); + const FreedBlockScan scan(psk); + h.reset(); + EXPECT_EQ(scan.holding(), 0); +} + +// A wipe reaches the reassembly buffer's storage past a shorter message, where a longer one +// written since the last wipe left its bytes. Before the wipe the scan finds them: the Control. +TEST(Reassembler, WipesTheStoragePastTheMessage) { + using sendspin::server::FRAGMENT_FLAG_FIRST; + using sendspin::server::FRAGMENT_FLAG_LAST; + const std::string secret = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY"; + Reassembler reassembler(sendspin::server::DEFAULT_MAX_REASSEMBLED_BYTES, + [](uint8_t) { return true; }); + const auto whole = [](const std::string& text) { + Bytes plaintext{sendspin::server::MESSAGE_ID_FRAGMENT, + FRAGMENT_FLAG_FIRST | FRAGMENT_FLAG_LAST, + sendspin::server::MESSAGE_ID_JSON}; + plaintext.insert(plaintext.end(), text.begin(), text.end()); + return plaintext; + }; + ASSERT_EQ(reassembler.accept(whole("{\"longer\":\"" + secret + "\"}")), + Reassembler::Outcome::MESSAGE); + ASSERT_EQ(reassembler.accept(whole("{}")), Reassembler::Outcome::MESSAGE); + EXPECT_TRUE(holds_to_capacity(reassembler.buffer_, secret)); + reassembler.wipe(); + EXPECT_FALSE(holds_to_capacity(reassembler.buffer_, secret)); +} + +} // namespace diff --git a/tests/trust_harness.h b/tests/trust_harness.h new file mode 100644 index 0000000..f6ac293 --- /dev/null +++ b/tests/trust_harness.h @@ -0,0 +1,395 @@ +// 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 trust_harness.h +/// @brief What the trust tests run: a sendspin-cpp player the server dials, a ServerCore on its own +/// core thread, an input fed the test signal, a raw client player's readings, and providers whose +/// saves fail or wait as scripted + +#pragma once + +#include "dispatcher.h" +#include "host_view.h" +#include "peer.h" +#include "player_sink.h" +#include "raw_client.h" +#include "server_core.h" +#include "test_ports.h" +#include "test_recorder.h" +#include "transport.h" +#include "wire_messages.h" +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sendspin::server::test { + +/// The rate the players play at +inline constexpr uint32_t PLAYER_RATE = 48000; + +/// The trust a sendspin-cpp client reports for its connection: USER for a long-term PSK, NONE for +/// the Sentinel or a pairing PSK +class TrustListener : public sendspin::SendspinClientListener { +public: + void on_trust_changed(sendspin::ConnectionTrust trust) override { + this->trust = trust; + ++this->reports; + } + + std::atomic trust{sendspin::ConnectionTrust::NONE}; + std::atomic reports{0}; +}; + +/// A sendspin-cpp player listening on a loopback port for the server to dial, with a real-time +/// audio output. Its main loop runs on the test thread through run_until(). +class Peer { +public: + struct Options { + bool unpaired_access{true}; + std::optional pairing_psk; + }; + + explicit Peer(ClientStore& store, const Options& options) + : port_(sendspin::server::test::free_port()), client_(make_config(port_, options)) { + sendspin::PlayerRoleConfig player; + // One format, assigned by count: GCC 13 -Warray-bounds misreads an initializer-list + // assignment + player.audio_formats.assign(1, sendspin::AudioSupportedFormatObject{ + sendspin::SendspinCodecFormat::PCM, 2, PLAYER_RATE, 16}); + player.min_buffer_ms = 150; + sendspin::PlayerRole& role = this->client_.add_player(player); + this->sink_ = std::make_unique(role, 4, PLAYER_RATE); + role.set_listener(this->sink_.get()); + this->client_.add_metadata(); + this->client_.set_network_provider(&this->network_); + this->client_.set_persistence_provider(&store); + this->client_.set_listener(&this->trust_); + this->client_.set_unpaired_access_enabled(options.unpaired_access); + EXPECT_TRUE(this->client_.start()); + } + + ~Peer() { + this->client_.stop(); + } + + Peer(const Peer&) = delete; + Peer& operator=(const Peer&) = delete; + + std::string url() const { + return sendspin::server::test::peer_url(this->port_); + } + + sendspin::SendspinClient& client() { + return this->client_; + } + + const sendspin::server::test::Sink& sink() const { + return *this->sink_; + } + + const TrustListener& trust() const { + return this->trust_; + } + + /// Runs the client's main loop until done() holds, with no timeout + template + void run_until(Predicate done) { + sendspin::server::test::run_until(this->client_, done); + } + +private: + static sendspin::SendspinClientConfig make_config(uint16_t port, const Options& options) { + sendspin::SendspinClientConfig config = + sendspin::server::test::peer_config("sendspin-cpp peer", port); + if (options.pairing_psk) { + config.pairing_psk = sendspin::SendspinPsk(*options.pairing_psk); + } + return config; + } + + uint16_t port_; + sendspin::server::test::AlwaysReady network_; + TrustListener trust_; + sendspin::SendspinClient client_; + std::unique_ptr sink_; +}; + +/// Collects the plaintext of the server/activate messages and the log lines a server produces +class Capture { +public: + void trace(const TracedMessage& message) { + const std::string text(message.plaintext.begin(), message.plaintext.end()); + if (message.direction == TraceDirection::OUTBOUND && + text.find(R"("type":"server/activate")") != std::string::npos) { + const std::lock_guard lock(this->mutex_); + this->activations_.push_back(text.substr(1)); + } + } + + void log(std::string_view line) { + const std::lock_guard lock(this->mutex_); + this->lines_.emplace_back(line); + } + + std::vector activations() const { + const std::lock_guard lock(this->mutex_); + return this->activations_; + } + + std::vector lines() const { + const std::lock_guard lock(this->mutex_); + return this->lines_; + } + +private: + mutable std::mutex mutex_; + std::vector activations_; + std::vector lines_; +}; + +/// A ServerCore with every client approved, running on its own thread until stopped, which then +/// destroys it as a run's core thread does +class Core { +public: + explicit Core(sendspin::server::ClientRecords records = {}, + sendspin::server::PersistenceProvider* provider = nullptr) + : dispatcher(std::make_shared()), + identity(*Identity::generate()) { + this->settings.approve_all_clients = true; + // The capture first, so that an activation the recorder lists is captured + this->settings.trace = [this](const TracedMessage& message) { + this->capture.trace(message); + this->recorder.trace(message); + }; + this->core = std::make_unique( + this->loop, this->workers, *this->dispatcher, &this->recorder, this->identity, + this->settings, this->snapshot, this->groups, this->inputs, this->group_ids, provider, + std::move(records)); + this->thread = std::thread([this] { + this->loop.run(); + this->core.reset(); + }); + } + + ~Core() { + this->stop(); + this->dispatcher->finish(); + this->dispatcher->join(); + } + + /// Shuts the core down and waits until its thread destroyed it; the dispatcher runs on + void stop() { + if (this->thread.joinable()) { + this->loop.post([this] { this->core->shutdown(0); }); + this->thread.join(); + } + } + + Core(const Core&) = delete; + Core& operator=(const Core&) = delete; + + /// Runs fn on the core thread and returns its result + template + auto on_core(Fn fn) { + std::promise result; + this->loop.post([&] { + if constexpr (std::is_void_v) { + fn(); + result.set_value(); + } else { + result.set_value(fn()); + } + }); + return result.get_future().get(); + } + + void dial(const std::string& url) { + this->loop.post( + [this, url] { this->core->connect_url(url, *sendspin::server::parse_ws_url(url)); }); + } + + /// Opens an input on the server's group + std::shared_ptr open_input(uint32_t rate) { + auto view = std::make_shared( + this->core->default_group(), sendspin::server::InputId{this->next_input++}, + sendspin::server::InputOptions{ + sendspin::server::InputFormat{rate, 2, sendspin::server::SampleFormat::S16}}, + sendspin::server::GroupParams{250000, 0, 0}, this->dispatcher, &this->recorder); + EXPECT_TRUE(this->inputs.add(view)); + this->loop.post([this, view] { this->core->attach_input(view); }); + return view; + } + + sendspin::server::EventLoop loop; + sendspin::server::AudioWorkers workers{1}; + std::shared_ptr dispatcher; + Identity identity; + sendspin::server::CoreSettings settings; + sendspin::server::ClientSnapshot snapshot; + sendspin::server::GroupSnapshot groups; + sendspin::server::InputRegistry inputs; + std::atomic group_ids{1}; + Recorder recorder; + Capture capture; + std::unique_ptr core; + std::thread thread; + uint64_t next_input{1}; +}; + +/// Writes the test signal into an input until stopped, from a thread of its own +class Feeder { +public: + explicit Feeder(std::shared_ptr view) + : view_(std::move(view)), thread_([this] { this->run(); }) {} + + ~Feeder() { + this->stop_ = true; + this->thread_.join(); + } + + Feeder(const Feeder&) = delete; + Feeder& operator=(const Feeder&) = delete; + +private: + void run() { + constexpr uint32_t BLOCK = 960; + constexpr int64_t WAIT_US = 20000; + const sendspin::server::test::Signal signal(16); + Bytes block(size_t{BLOCK} * signal.frame_bytes()); + uint32_t position = 0; + while (!this->stop_) { + for (uint32_t i = 0; i < BLOCK; ++i) { + signal.put(&block[i * signal.frame_bytes()], position + i); + } + uint32_t written = 0; + while (written < BLOCK && !this->stop_) { + written += static_cast( + this->view_->write(&block[written * signal.frame_bytes()], BLOCK - written) + .frames); + if (written < BLOCK) { + this->view_->wait_writable(WAIT_US); + } + } + position += BLOCK; + } + } + + std::shared_ptr view_; + std::atomic stop_{false}; + std::thread thread_; +}; + +inline std::string player_hello(const std::string& name) { + return sendspin::server::test::player_hello_json( + name, R"([{"codec":"pcm","sample_rate":48000,"channels":2,"bit_depth":16}])", 400000); +} + +/// What a raw client player reads: chunks by timestamp and duration, JSON messages by text +struct Reading { + std::optional ts; + int64_t duration_us{0}; + std::string json; +}; + +/// The next message the raw client reads, as a Reading; json empty and ts unset once closed +inline Reading read_one(RawClient& client) { + Reading reading; + const std::optional plaintext = client.receive_plaintext(); + if (!plaintext || plaintext->empty()) { + return reading; + } + if (plaintext->front() == wire::JSON_ID) { + reading.json.assign(plaintext->begin() + 1, plaintext->end()); + return reading; + } + if (plaintext->front() == wire::AUDIO_CHUNK_ID && + plaintext->size() > wire::AUDIO_CHUNK_HEADER) { + reading.ts = wire::timestamp_at(*plaintext); + const auto frames = + static_cast((plaintext->size() - wire::AUDIO_CHUNK_HEADER) / 4); + reading.duration_us = frames * 1000000 / PLAYER_RATE; + } + return reading; +} + +inline bool is_type(const std::string& json, const std::string& type) { + return json.find(R"("type":")" + type + '"') != std::string::npos; +} + +/// A provider whose first saves succeed or fail as scripted; every later save succeeds +class ScriptedProvider : public MemoryProvider { +public: + explicit ScriptedProvider(std::vector accepted = {}) : accepted_(std::move(accepted)) {} + + bool save_blob(const std::string& key, const uint8_t* data, size_t len) override { + const auto index = static_cast(this->saves); + if (index < this->accepted_.size() && !this->accepted_[index]) { + ++this->saves; + return false; + } + return MemoryProvider::save_blob(key, data, len); + } + +private: + std::vector accepted_; +}; + +/// A provider whose saves wait for release(), then succeed or fail as scripted, and fail when told +/// to +class HeldProvider : public ScriptedProvider { +public: + using ScriptedProvider::ScriptedProvider; + + bool save_blob(const std::string& key, const uint8_t* data, size_t len) override { + std::unique_lock lock(this->mutex_); + this->changed_.wait(lock, [this] { return this->released_ > 0; }); + --this->released_; + lock.unlock(); + return ScriptedProvider::save_blob(key, data, len); + } + + /// Lets count saves through + void release(int count) { + { + const std::lock_guard lock(this->mutex_); + this->released_ += count; + } + this->changed_.notify_all(); + } + +private: + std::mutex mutex_; + std::condition_variable changed_; + int released_{0}; +}; + +} // namespace sendspin::server::test