diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/BoundedPriorityQueue.hpp b/packages/react-native-audio-api/common/cpp/audioapi/utils/BoundedPriorityQueue.hpp index 63125bd9e..e8a563e39 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/utils/BoundedPriorityQueue.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/BoundedPriorityQueue.hpp @@ -4,12 +4,133 @@ #include #include #include -#include #include #include +// --- std::pmr availability gate --------------------------------------------- +// std::pmr's runtime symbols ship in Apple's system libc++ only from +// iOS 17.0 / macOS 14.0 / tvOS 17.0 / watchOS 10.0 onward +// (_LIBCPP_AVAILABILITY_HAS_PMR maps to _LIBCPP_INTRODUCED_IN_LLVM_16). +// libc++ currently leaves the pmr availability *markup* empty +// (see llvm/llvm-project#40340), so using std::pmr below those versions +// compiles without any diagnostic but fails at dyld load time: +// Symbol not found: std::__1::pmr::memory_resource::~memory_resource() +// On those targets we fall back to a std::multiset backed by an in-object, +// non-pmr pool allocator that preserves the original design intent: a fixed +// buffer, node recycling, and no heap allocation (audio-thread / real-time +// safe). Define AUDIOAPI_HAS_PMR before including this header to override. +#if !defined(AUDIOAPI_HAS_PMR) +# if defined(_LIBCPP_VERSION) && defined(_LIBCPP_AVAILABILITY_HAS_PMR) && \ + !_LIBCPP_AVAILABILITY_HAS_PMR +# define AUDIOAPI_HAS_PMR 0 +# else +# define AUDIOAPI_HAS_PMR 1 +# endif +#endif + +#if AUDIOAPI_HAS_PMR +# include +#else +# include +#endif + namespace audioapi { +#if !AUDIOAPI_HAS_PMR +namespace bpq_detail { + +/// @brief Fixed-capacity, in-object block pool with an intrusive free list. +/// Hands out fixed-size slots and recycles freed ones; never touches the heap. +/// Throws std::bad_alloc on exhaustion, matching the std::pmr path's +/// null_memory_resource upstream behaviour. +template +class FixedBlockPool { + public: + FixedBlockPool() = default; + FixedBlockPool(const FixedBlockPool &) = delete; + FixedBlockPool &operator=(const FixedBlockPool &) = delete; + + void *allocate(std::size_t bytes) { + // The multiset allocates tree nodes one at a time, all the same size. + if (bytes > kSlotSize) [[unlikely]] { + throw std::bad_alloc(); + } + if (freeList_ != nullptr) { + void *block = freeList_; + freeList_ = nextOf(block); + return block; + } + if (used_ >= Capacity) [[unlikely]] { + throw std::bad_alloc(); + } + return &storage_[used_++ * kSlotSize]; + } + + void deallocate(void *block) noexcept { + nextOf(block) = freeList_; + freeList_ = block; + } + + private: + // Round the requested block size up to the pool alignment. A slot is always + // >= sizeof(void*), so the free list is stored intrusively in freed slots. + static constexpr std::size_t kSlotSize = + ((BlockSize + Align - 1) / Align) * Align; + + static void *&nextOf(void *block) noexcept { + return *static_cast(block); + } + + alignas(Align) std::array storage_{}; + void *freeList_ = nullptr; + std::size_t used_ = 0; +}; + +/// @brief Stateful allocator that draws node storage from a FixedBlockPool. +/// Satisfies the C++ Allocator requirements used by std::multiset. +template +class PoolAllocator { + public: + using value_type = T; + + template + struct rebind { + using other = PoolAllocator; + }; + + explicit PoolAllocator(Pool *pool) noexcept : pool_(pool) {} + + template + PoolAllocator(const PoolAllocator &other) noexcept // NOLINT(runtime/explicit) + : pool_(other.pool_) {} + + T *allocate(std::size_t n) { + return static_cast(pool_->allocate(n * sizeof(T))); + } + + void deallocate(T *p, std::size_t /*n*/) noexcept { + pool_->deallocate(p); + } + + template + bool operator==(const PoolAllocator &o) const noexcept { + return pool_ == o.pool_; + } + template + bool operator!=(const PoolAllocator &o) const noexcept { + return pool_ != o.pool_; + } + + private: + template + friend class PoolAllocator; + + Pool *pool_; +}; + +} // namespace bpq_detail +#endif // !AUDIOAPI_HAS_PMR + /// @brief A bounded priority queue with fixed capacity backed by a static pool allocator. /// Elements are kept in ascending sorted order (smallest element at front). /// All operations avoid heap allocation. Uses std::multiset under the hood @@ -22,6 +143,7 @@ namespace audioapi { template > class BoundedPriorityQueue { private: +#if AUDIOAPI_HAS_PMR using SetType = std::pmr::multiset; static constexpr size_t kNodeOverhead = 96; @@ -39,6 +161,22 @@ class BoundedPriorityQueue { std::pmr::pool_options{.max_blocks_per_chunk = 1, .largest_required_pool_block = 0}, &mono_}; SetType set_{Compare{}, &pool_}; +#else + static constexpr size_t kNodeOverhead = 96; + // Upper bound on a std::multiset red-black-tree node holding a T (value plus + // tree links/color). Generous headroom, matching the std::pmr path above. + static constexpr size_t kBlockSize = sizeof(T) + kNodeOverhead; + + using PoolType = + bpq_detail::FixedBlockPool; + using AllocType = bpq_detail::PoolAllocator; + using SetType = std::multiset; + + // Members must be declared in this order: pool_ → set_. + // set_ allocates its nodes from pool_, so pool_ must outlive it. + PoolType pool_; + SetType set_{Compare{}, AllocType{&pool_}}; +#endif public: explicit BoundedPriorityQueue() = default; diff --git a/packages/react-native-audio-api/common/cpp/test/src/utils/BoundedPriorityQueueTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/utils/BoundedPriorityQueueTest.cpp new file mode 100644 index 000000000..8039ec666 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/utils/BoundedPriorityQueueTest.cpp @@ -0,0 +1,114 @@ +// Exercises the non-pmr fallback path of BoundedPriorityQueue (the std::multiset +// + FixedBlockPool implementation used on runtimes whose libc++ has no std::pmr, +// e.g. iOS < 17). AUDIOAPI_HAS_PMR is forced to 0 so this path is covered even on +// hosts whose libc++ does have pmr; the default (std::pmr) path is exercised by +// the rest of the suite, which compiles the library with the auto-detected gate. +#define AUDIOAPI_HAS_PMR 0 + +#include +#include + +#include +#include +#include + +using namespace audioapi; + +namespace { + +struct Event { + int64_t time; + int id; +}; + +// Transparent comparator (heterogeneous key lookups), mirroring how the audio +// param queues use lowerBound/upperBound with a raw time key. +struct ByTime { + using is_transparent = void; + bool operator()(const Event &a, const Event &b) const { return a.time < b.time; } + bool operator()(const Event &a, int64_t b) const { return a.time < b; } + bool operator()(int64_t a, const Event &b) const { return a < b.time; } +}; + +} // namespace + +TEST(BoundedPriorityQueueTest, KeepsAscendingOrderAndRespectsBounds) { + BoundedPriorityQueue q; + EXPECT_TRUE(q.isEmpty()); + + // Insert with descending keys; the queue must keep them ascending. + for (int i = 0; i < 8; ++i) { + EXPECT_TRUE(q.push(Event{static_cast(100 - i * 10), i})); + } + EXPECT_TRUE(q.isFull()); + EXPECT_EQ(q.size(), 8u); + EXPECT_FALSE(q.push(Event{5, 99})); // rejected when full + EXPECT_EQ(q.size(), 8u); + + EXPECT_EQ(q.peekFront().time, 30); // 100 - 7*10 + EXPECT_EQ(q.peekBack().time, 100); + + int64_t prev = -1; + for (auto it = q.begin(); it != q.end(); ++it) { + EXPECT_LT(prev, it->time); + prev = it->time; + } +} + +TEST(BoundedPriorityQueueTest, PopReturnsSmallestFirst) { + BoundedPriorityQueue q; + q.push(Event{30, 0}); + q.push(Event{10, 1}); + q.push(Event{20, 2}); + + Event out{}; + EXPECT_TRUE(q.pop(out)); + EXPECT_EQ(out.time, 10); + EXPECT_TRUE(q.pop(out)); + EXPECT_EQ(out.time, 20); + EXPECT_TRUE(q.pop()); // drop 30 without retrieving + EXPECT_TRUE(q.isEmpty()); + EXPECT_FALSE(q.pop(out)); // empty +} + +TEST(BoundedPriorityQueueTest, RecyclesNodesUnderChurn) { + // Far more insert/erase cycles than Capacity. If freed nodes were not + // recycled the fixed pool would exhaust and allocate() would throw. + BoundedPriorityQueue q; + for (int i = 0; i < 6; ++i) { + q.push(Event{static_cast(i), i}); + } + int64_t key = 1000; + for (int round = 0; round < 100000; ++round) { + ASSERT_TRUE(q.push(Event{key++, round})); + ASSERT_TRUE(q.pop()); + } + EXPECT_EQ(q.size(), 6u); +} + +TEST(BoundedPriorityQueueTest, BoundsExtractMutateAndReinsert) { + BoundedPriorityQueue q; + for (int i = 0; i < 5; ++i) { + q.push(Event{static_cast(i * 10), i}); // 0,10,20,30,40 + } + + auto it = q.upperBound(static_cast(15)); // first key > 15 -> 20 + ASSERT_NE(it, q.end()); + EXPECT_EQ(it->time, 20); + + auto node = q.extract(it); // detach node holding 20 + node.value().time = 25; // mutate key while detached (no reallocation) + q.insert(q.upperBound(static_cast(25)), std::move(node)); + EXPECT_EQ(q.size(), 5u); + + std::vector times; + for (auto i = q.begin(); i != q.end(); ++i) { + times.push_back(i->time); + } + EXPECT_EQ(times, (std::vector{0, 10, 25, 30, 40})); + + // Range erase: drop everything >= 30. + q.erase(q.lowerBound(static_cast(30)), q.end()); + EXPECT_EQ(q.size(), 3u); + EXPECT_EQ(q.peekBack().time, 25); +}