From 9cc69d1dcd859856791b5dd4a8b7c9173d470b54 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Wed, 24 Jun 2026 01:59:40 +0200 Subject: [PATCH] Test the input seqlock and the IPC magic/version handshake New protocol_test covers two previously-untested cross-process contracts: - publish_pads/read_pads seqlock: a concurrent writer + reader run for ~700 ms; the reader (2.5M snapshots) never observes a torn snapshot, and read_pads returns false (bounded, no hang) when the sequence is stuck odd mid-write. - IpcClient::connect refuses a section with a mismatched version or a bad magic, and accepts a matching header -- the ABI safety net that only had happy-path coverage before. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 - tests/CMakeLists.txt | 8 +++ tests/protocol_test.cpp | 128 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 tests/protocol_test.cpp diff --git a/README.md b/README.md index f3a6867..6bf58fe 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,6 @@ From an in-depth review pass. Each item is fixed test-first (a failing test, the as its own commit; "verify" items are confirmed real before any change, and dropped if not. Test coverage: -- **Seqlock reader paths** — torn-read retry, odd-sequence skip, attempt-exhaustion → false. -- **Version/magic mismatch rejection** — negative test that the hook refuses a bad version. - **Dedicated hook tests** — `focus_spoof`, `vk_hook` (present), `d3d9_hook`. - **Misc units** — `SharedMemory` RAII/move, `wav` malformed input, `tool_paths` resolution, injector bitness check; strengthen the `audio_ring` overrun-at-seam case. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 439a96b..ad21659 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -82,6 +82,13 @@ add_executable(log_ring_test log_ring_test.cpp) target_link_libraries(log_ring_test PRIVATE coop_common) add_test(NAME log_ring_test COMMAND log_ring_test) +# Unit test for the input seqlock (publish_pads/read_pads: torn-free concurrent reads + bounded +# give-up) and the hook's IpcClient magic/version handshake rejection. Includes the hook-side header. +add_executable(protocol_test protocol_test.cpp) +target_include_directories(protocol_test PRIVATE ${CMAKE_SOURCE_DIR}/hook/src) +target_link_libraries(protocol_test PRIVATE coop_common) +add_test(NAME protocol_test COMMAND protocol_test) + # Unit test for the audio mixer math (decode/sum/soft-clip/encode). Header-only. add_executable(audio_mix_test audio_mix_test.cpp) target_include_directories(audio_mix_test PRIVATE ${CMAKE_SOURCE_DIR}/host/src) @@ -328,6 +335,7 @@ coop_output_subdir(tests hook_install_test mkb_ring_test log_ring_test + protocol_test mkb_map_test audio_mix_test tone_analysis_test diff --git a/tests/protocol_test.cpp b/tests/protocol_test.cpp new file mode 100644 index 0000000..9f11fde --- /dev/null +++ b/tests/protocol_test.cpp @@ -0,0 +1,128 @@ +// Unit test for the cross-process input contract in coop/protocol.hpp + the hook's IPC handshake: +// - the seqlock (publish_pads / read_pads): a concurrent reader never sees a torn snapshot, and a +// writer stuck mid-update (odd sequence) makes read_pads give up (returns false) rather than hang; +// - IpcClient::connect refuses a section whose magic or version doesn't match (the ABI safety net). +#include +#include +#include +#include + +#include + +#include "coop/protocol.hpp" +#include "coop/shared_memory.hpp" +#include "ipc_client.hpp" // hook-side IpcClient (header-only) + +using namespace coop; +using coop::hook::IpcClient; + +namespace +{ +int g_failures = 0; +void check(bool ok, const char* what) +{ + std::printf("%s %s\n", ok ? " ok:" : "FAIL:", what); + if (!ok) + { + ++g_failures; + } +} +} // namespace + +int main() +{ + // --- Seqlock: concurrent reader never sees a torn snapshot --------------------------------- + { + SharedBlock block{}; + block.sequence.store(0, std::memory_order_relaxed); + std::atomic stop{false}; + std::atomic torn{0}; + std::atomic reads{0}; + + // Writer: publish all slots carrying the SAME generation in `packet`. A torn read would mix two + // generations, so the slots' packet values would disagree. + std::thread writer([&] { + std::uint32_t gen = 1; + while (!stop.load(std::memory_order_relaxed)) + { + CoopPadState pads[kMaxPads]; + for (auto& p : pads) + { + p = CoopPadState{}; + p.connected = 1; + p.packet = gen; + } + publish_pads(block, pads, kMaxPads); + ++gen; + } + }); + std::thread reader([&] { + while (!stop.load(std::memory_order_relaxed)) + { + CoopPadState out[kMaxPads]; + std::uint32_t count = 0; + if (read_pads(block, out, count)) + { + reads.fetch_add(1, std::memory_order_relaxed); + for (std::uint32_t i = 1; i < kMaxPads; ++i) + { + if (out[i].packet != out[0].packet) + { + torn.fetch_add(1, std::memory_order_relaxed); + break; + } + } + } + } + }); + std::this_thread::sleep_for(std::chrono::milliseconds(700)); + stop.store(true, std::memory_order_relaxed); + writer.join(); + reader.join(); + std::printf(" seqlock reads=%lld torn=%lld\n", reads.load(), torn.load()); + check(reads.load() > 1000, "seqlock: reader got many snapshots"); + check(torn.load() == 0, "seqlock: never a torn snapshot under concurrent writes"); + } + + // --- Seqlock: a writer stuck mid-update (odd sequence) makes read_pads give up ---------------- + { + SharedBlock block{}; + block.sequence.store(1, std::memory_order_relaxed); // odd = write in progress, never completed + CoopPadState out[kMaxPads]; + std::uint32_t count = 0; + check(!read_pads(block, out, count), "seqlock: read_pads returns false when stuck mid-write (bounded, no hang)"); + } + + // --- Handshake: IpcClient::connect rejects a wrong magic / version ---------------------------- + { + const unsigned long pid = GetCurrentProcessId(); // per-pid section name; no game needed + SharedMemory shm; + const bool created = shm.create(shared_memory_name(pid), sizeof(SharedBlock)); + check(created, "handshake: created a section"); + auto* block = shm.as(); + + // Wrong version. + block->version = kProtocolVersion - 1; + block->magic = kProtocolMagic; + { + IpcClient c; + check(!c.connect(/*attempts=*/3, /*delay_ms=*/1), "handshake: connect refuses a mismatched version"); + } + // Wrong magic. + block->version = kProtocolVersion; + block->magic = kProtocolMagic ^ 0xDEAD; + { + IpcClient c; + check(!c.connect(3, 1), "handshake: connect refuses a bad magic"); + } + // Correct header -> connects. + block->magic = kProtocolMagic; + { + IpcClient c; + check(c.connect(10, 1), "handshake: connect accepts a matching magic + version"); + } + } + + std::printf(g_failures == 0 ? "PASS protocol_test\n" : "FAILED protocol_test (%d)\n", g_failures); + return g_failures == 0 ? 0 : 1; +}