diff --git a/lib/tdeck_ui/UI/LXMF/CallCommandMailbox.h b/lib/tdeck_ui/UI/LXMF/CallCommandMailbox.h new file mode 100644 index 00000000..b131d111 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/CallCommandMailbox.h @@ -0,0 +1,93 @@ +// Copyright (c) 2024 microReticulum contributors +// SPDX-License-Identifier: MIT + +#ifndef UI_LXMF_CALLCOMMANDMAILBOX_H +#define UI_LXMF_CALLCOMMANDMAILBOX_H + +#include +#include + +namespace UI { +namespace LXMF { + +// Single-consumer mailbox for commands issued outside loopTask. Mute and +// hangup use separate atomic slots so publishing a mute can never overwrite a +// pending hangup. Generation zero means "no active call" and is never queued. +class CallCommandMailbox { +public: + static constexpr uint32_t MAX_GENERATION = 0x7fffffffu; + + enum class Action { + NONE, + MUTE, + HANGUP, + }; + + struct Command { + Action action = Action::NONE; + bool muted = false; + }; + + struct Commands { + uint32_t hangupGeneration = 0; + uint32_t muteGeneration = 0; + bool muted = false; + }; + + void requestHangup(uint32_t generation) { + if (!validGeneration(generation)) return; + _hangupGeneration.store(generation, std::memory_order_release); + } + + void requestMute(uint32_t generation, bool muted) { + if (!validGeneration(generation)) return; + const uint32_t packed = generation | (muted ? MUTE_BIT : 0u); + _mute.store(packed, std::memory_order_release); + } + + // Atomically consumes each slot. A producer racing this exchange either + // appears in this result or remains pending for the next take(). + Commands take() { + Commands commands; + commands.hangupGeneration = + _hangupGeneration.exchange(0, std::memory_order_acq_rel); + const uint32_t mute = _mute.exchange(0, std::memory_order_acq_rel); + commands.muteGeneration = mute & MAX_GENERATION; + commands.muted = (mute & MUTE_BIT) != 0; + return commands; + } + + // Consume all pending slots and select the command applicable to the + // active call. Stale generations are discarded, and hangup deliberately + // wins when both commands target the current generation. + Command takeForGeneration(uint32_t activeGeneration) { + const Commands commands = take(); + if (!validGeneration(activeGeneration)) return {}; + Command command; + if (commands.hangupGeneration == activeGeneration) { + command.action = Action::HANGUP; + return command; + } + if (commands.muteGeneration == activeGeneration) { + command.action = Action::MUTE; + command.muted = commands.muted; + return command; + } + return {}; + } + +private: + static constexpr uint32_t MUTE_BIT = 0x80000000u; + + static bool validGeneration(uint32_t generation) { + return generation != 0 && generation <= MAX_GENERATION; + } + + std::atomic _hangupGeneration{0}; + std::atomic _mute{0}; +}; + +} // namespace LXMF +} // namespace UI + +#endif // UI_LXMF_CALLCOMMANDMAILBOX_H diff --git a/lib/tdeck_ui/UI/LXMF/CallScreen.cpp b/lib/tdeck_ui/UI/LXMF/CallScreen.cpp index 7143b4fc..d498432e 100644 --- a/lib/tdeck_ui/UI/LXMF/CallScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/CallScreen.cpp @@ -219,8 +219,9 @@ void CallScreen::on_mute_clicked(lv_event_t* event) { return; } if (self->_mute_callback) { + // Track requested state for rapid-toggle parity, but leave appearance + // unchanged until UIManager confirms the owner-applied mute state. self->_muted = !self->_muted; - self->set_muted(self->_muted); self->_mute_callback(self->_muted); } } diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index 06761c81..5570a09d 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -103,10 +103,11 @@ UIManager::UIManager(Reticulum& reticulum, ::LXMF::LXMRouter& router, ::LXMF::Me UIManager::~UIManager() { // Clean up call state - if (_call_state != CallState::IDLE) { + if (_call_state != CallState::IDLE || + _call_generation.load(std::memory_order_acquire) != 0) { call_hangup(); } - delete _lxst_audio; + call_teardown_audio(); // This pointer owns the long-lived incoming-destination callback, not only // the current call. Clear it only when the manager itself is destroyed. @@ -250,11 +251,11 @@ bool UIManager::init() { // Set up callbacks for call screen _call_screen->set_hangup_callback( - [this]() { call_hangup(); } + [this]() { call_request_hangup(); } ); _call_screen->set_mute_callback( - [this](bool muted) { call_set_mute(muted); } + [this](bool muted) { call_request_mute(muted); } ); // Load settings from NVS @@ -364,8 +365,21 @@ void UIManager::update() { // Process inbound LXMF messages _router.process_inbound(); - // Pump voice call state machine - if (_call_state != CallState::IDLE) { + // Consume UI commands unconditionally. LVGL callbacks only publish into + // the mailbox; loopTask remains the sole owner of the audio pipeline. + const uint32_t generation = _call_generation.load(std::memory_order_acquire); + const CallCommandMailbox::Command command = + _call_commands.takeForGeneration(generation); + if (command.action == CallCommandMailbox::Action::HANGUP) { + call_hangup(); + } else if (command.action == CallCommandMailbox::Action::MUTE) { + call_set_mute(command.muted); + } + + // Pump voice call state while a call is active or its generation remains + // reserved during owner-side teardown. + if (_call_state != CallState::IDLE || + _call_generation.load(std::memory_order_acquire) != 0) { call_update(); } @@ -946,9 +960,6 @@ void UIManager::call_initiate(const Bytes& peer_hash) { return; } - _call_peer_hash = peer_hash; - _call_muted = false; - lxst_breadcrumb(2, ESP.getFreeHeap()); // Look up peer identity @@ -966,6 +977,16 @@ void UIManager::call_initiate(const Bytes& peer_hash) { lxst_breadcrumb(4, ESP.getFreeHeap()); + // Reserve a generation only after the outgoing call has passed its + // acceptance checks. Incoming-link callbacks can race this LVGL path, so + // the atomic reservation is the definitive admission check. + if (!call_begin_generation()) { + WARNING("LXST: Another call was accepted concurrently"); + return; + } + _call_peer_hash = peer_hash; + _call_muted = false; + // Show call screen _call_screen->set_peer(peer_dest.hash()); _call_screen->set_state(CallScreen::CallState::CONNECTING); @@ -1066,19 +1087,14 @@ const char* UIManager::test_call_state_name() const { void UIManager::call_hangup() { INFO("LXST: Hanging up"); - // Set IDLE first — prevents pump_call_tx() (which runs without LVGL lock) - // from accessing _lxst_audio after we delete it. s_call_instance remains - // installed because it owns the long-lived incoming destination callback. - _call_state = CallState::IDLE; + const uint32_t generation = + _call_generation.load(std::memory_order_acquire); - // Stop audio - if (_lxst_audio) { - _lxst_audio->stopCapture(); - _lxst_audio->stopPlayback(); - _lxst_audio->deinit(); - delete _lxst_audio; - _lxst_audio = nullptr; - } + // call_hangup() is an owner operation: production UI paths reach it only + // through update() on loopTask, which also owns pump_call_tx(). Keep the + // generation reserved until teardown is complete so a concurrent incoming + // callback cannot install a newer call over the old audio pointer. + call_teardown_audio(); // Teardown link if (_call_link) { @@ -1087,6 +1103,9 @@ void UIManager::call_hangup() { } _call_peer_hash = Bytes(); + _call_link_closed_pending = false; + _call_state = CallState::IDLE; + call_clear_generation(generation); // Return to chat screen if (_call_screen) { @@ -1102,9 +1121,50 @@ void UIManager::call_set_mute(bool muted) { if (_lxst_audio) { _lxst_audio->setCaptureMute(muted); } + if (_call_screen) { + _call_screen->set_muted(muted); + } INFO(muted ? "LXST: Mic muted" : "LXST: Mic unmuted"); } +void UIManager::call_request_hangup() { + _call_commands.requestHangup( + _call_generation.load(std::memory_order_acquire)); +} + +void UIManager::call_request_mute(bool muted) { + _call_commands.requestMute( + _call_generation.load(std::memory_order_acquire), muted); +} + +bool UIManager::call_begin_generation() { + uint32_t generation = 0; + while (generation == 0) { + generation = _call_generation_counter.fetch_add( + 1, std::memory_order_relaxed) & CallCommandMailbox::MAX_GENERATION; + } + uint32_t expected = 0; + return _call_generation.compare_exchange_strong( + expected, generation, std::memory_order_acq_rel, + std::memory_order_acquire); +} + +void UIManager::call_clear_generation(uint32_t expected_generation) { + if (expected_generation == 0) return; + _call_generation.compare_exchange_strong( + expected_generation, 0, std::memory_order_acq_rel, + std::memory_order_acquire); +} + +void UIManager::call_teardown_audio() { + if (!_lxst_audio) return; + _lxst_audio->stopCapture(); + _lxst_audio->stopPlayback(); + _lxst_audio->deinit(); + delete _lxst_audio; + _lxst_audio = nullptr; +} + void UIManager::call_send_signal(int signal) { if (!_call_link || _call_link.status() != Type::Link::ACTIVE) return; @@ -1472,6 +1532,7 @@ void UIManager::call_process_signal(uint8_t signal) { call_ended(); return; } + _lxst_audio->setCaptureMute(_call_muted); lxst_breadcrumb(23, ESP.getFreeHeap()); } else if (signal == LXST_STATUS_ESTABLISHED) { @@ -1498,6 +1559,7 @@ void UIManager::call_process_signal(uint8_t signal) { call_ended(); return; } + _lxst_audio->setCaptureMute(_call_muted); } lxst_breadcrumb(26, ESP.getFreeHeap()); INFO("LXST: Call active (caller, full-duplex)"); @@ -1522,6 +1584,7 @@ void UIManager::call_process_signal(uint8_t signal) { call_ended(); return; } + _lxst_audio->setCaptureMute(_call_muted); } INFO("LXST: Call active (full-duplex)"); } @@ -1535,19 +1598,10 @@ void UIManager::call_process_signal(uint8_t signal) { void UIManager::call_ended() { INFO("LXST: Call ended"); - // Set IDLE first — prevents pump_call_tx() (which runs without LVGL lock) - // from accessing _lxst_audio after we delete it. s_call_instance remains - // installed because it owns the long-lived incoming destination callback. - _call_state = CallState::IDLE; + const uint32_t generation = + _call_generation.load(std::memory_order_acquire); - // Stop audio - if (_lxst_audio) { - _lxst_audio->stopCapture(); - _lxst_audio->stopPlayback(); - _lxst_audio->deinit(); - delete _lxst_audio; - _lxst_audio = nullptr; - } + call_teardown_audio(); // Teardown link if (_call_link) { @@ -1556,6 +1610,9 @@ void UIManager::call_ended() { } _call_peer_hash = Bytes(); + _call_link_closed_pending = false; + _call_state = CallState::IDLE; + call_clear_generation(generation); _call_screen->set_state(CallScreen::CallState::ENDED); @@ -1605,20 +1662,15 @@ void UIManager::pump_call_tx() { void UIManager::start_loopback() { // Don't stomp a live real call. (The harness never overlaps the two, but // be defensive: a real call owns _lxst_audio and must not be torn down.) - if (_call_state != CallState::IDLE) { + if (_call_state != CallState::IDLE || + _call_generation.load(std::memory_order_acquire) != 0) { WARNING("LXST: Loopback refused — call in progress"); return; } // Always (re)create the pipeline so it picks up the currently selected // profile/codec mode (driven by T:CALL_PROFILE). Mirrors call_answer(). - if (_lxst_audio) { - _lxst_audio->stopCapture(); - _lxst_audio->stopPlayback(); - _lxst_audio->deinit(); - delete _lxst_audio; - _lxst_audio = nullptr; - } + call_teardown_audio(); _call_audio_rx_count = 0; _call_audio_tx_count = 0; @@ -1627,17 +1679,14 @@ void UIManager::start_loopback() { if (codec_mode < 0) codec_mode = CODEC2_MODE_700C; if (!_lxst_audio->init(codec_mode)) { WARNING("LXST: Loopback audio init failed"); - delete _lxst_audio; - _lxst_audio = nullptr; + call_teardown_audio(); return; } // Same start path a real call uses: mic + speaker simultaneously, so // isCapturing() (pump_call_tx) and isPlaying() (writeEncodedPacket) hold. if (!_lxst_audio->startFullDuplex()) { WARNING("LXST: Loopback full-duplex start failed"); - _lxst_audio->deinit(); - delete _lxst_audio; - _lxst_audio = nullptr; + call_teardown_audio(); return; } @@ -1652,13 +1701,7 @@ void UIManager::stop_loopback() { _call_loopback = false; pyxis_audio_dump_arm(false); - if (_lxst_audio) { - _lxst_audio->stopCapture(); - _lxst_audio->stopPlayback(); - _lxst_audio->deinit(); - delete _lxst_audio; - _lxst_audio = nullptr; - } + call_teardown_audio(); INFO("LXST: Loopback stopped"); } @@ -1813,16 +1856,17 @@ void UIManager::on_call_link_established(Link& link) { void UIManager::on_call_link_closed(Link& link) { if (!s_call_instance) return; - // Ignore stale link closures (e.g. old link teardown completing after new call started) - if (s_call_instance->_call_link && link != s_call_instance->_call_link) { + // Ignore stale link closures (e.g. old link teardown completing after a + // new call started). A missing current link is stale too: teardown may + // have reset _call_link before its later close callback is dispatched. + if (!s_call_instance->_call_link || link != s_call_instance->_call_link) { WARNING("LXST: Stale link closed (ignoring)"); return; } WARNING("LXST: Link closed (deferred)"); - // Don't call call_ended() here — runs on Reticulum thread without LVGL lock. - // Defer to call_update() which runs under LVGL lock. + // Don't call call_ended() here — defer to call_update() on loopTask. if (s_call_instance->_call_state != CallState::IDLE) { s_call_instance->_call_link_closed_pending = true; } @@ -1873,9 +1917,27 @@ void UIManager::on_lxst_caller_identified(const Link& link, const Identity& iden auto* self = s_call_instance; lxst_breadcrumb(15, ESP.getFreeHeap()); + // Identification can arrive after another call replaced this pending link. + // Reticulum invokes this callback synchronously from loopTask, so equality + // with the current owner link is sufficient here; do not mutate newer state. + if (!self->_call_link || link != self->_call_link) { + WARNING("LXST: Stale caller identified (ignoring)"); + return; + } + std::string hash_hex = identity.hash().toHex().substr(0, 16); INFO(("LXST: Caller identified: " + hash_hex + "...").c_str()); + // Reserve admission only once the incoming call is identified and is about + // to become actionable. A newer accepted call wins this CAS. + if (!self->call_begin_generation()) { + WARNING("LXST: Caller identified after another call was accepted (ignoring)"); + return; + } + + // The generation is now reserved for this current identified link. + self->_call_state = CallState::INCOMING_RINGING; + // Store peer info self->_call_peer_hash = identity.hash(); @@ -1885,8 +1947,7 @@ void UIManager::on_lxst_caller_identified(const Link& link, const Identity& iden // Send STATUS_RINGING self->call_send_signal(LXST_STATUS_RINGING); - // Transition to incoming ringing — UI will be shown in call_update() - self->_call_state = CallState::INCOMING_RINGING; + // Incoming ringing UI will be shown in call_update(). self->_call_timeout_ms = millis() + 60000; // 60s ring timeout lxst_breadcrumb(16, ESP.getFreeHeap()); } @@ -1936,6 +1997,7 @@ void UIManager::call_answer() { call_ended(); return; } + _lxst_audio->setCaptureMute(_call_muted); INFOF("LXST: Full-duplex ready (internal=%u largest=%u)", (unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL), (unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)); diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.h b/lib/tdeck_ui/UI/LXMF/UIManager.h index 1e832b65..fa1b7e3e 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.h +++ b/lib/tdeck_ui/UI/LXMF/UIManager.h @@ -17,6 +17,7 @@ #include "SettingsScreen.h" #include "PropagationNodesScreen.h" #include "CallScreen.h" +#include "CallCommandMailbox.h" #include "LXMF/LXMRouter.h" #include "LXMF/PropagationNodeManager.h" #include "LXMF/MessageStore.h" @@ -200,7 +201,7 @@ public: /** Initiate an outgoing call to peer (calls private call_initiate). */ void test_call_initiate(const RNS::Bytes& peer_hash) { call_initiate(peer_hash); } - /** Hang up the active call (calls private call_hangup). */ + /** Hang up the active call on loopTask (calls private call_hangup). */ void test_call_hangup() { call_hangup(); } /** @@ -393,6 +394,9 @@ private: // takes the safe NoneConstructor branch. Same fix as DirectLinkSlot. RNS::Link _call_link{RNS::Type::NONE}; LXSTAudio* _lxst_audio; + CallCommandMailbox _call_commands; + std::atomic _call_generation{0}; + std::atomic _call_generation_counter{1}; uint32_t _call_start_ms; // millis() when call became ACTIVE uint32_t _call_timeout_ms; // millis() deadline for current wait state bool _call_muted; @@ -413,6 +417,11 @@ private: void call_initiate(const RNS::Bytes& peer_hash); void call_hangup(); void call_set_mute(bool muted); + void call_request_hangup(); + void call_request_mute(bool muted); + bool call_begin_generation(); + void call_clear_generation(uint32_t expected_generation); + void call_teardown_audio(); void call_update(); // Called from update() — pumps audio packets + state machine // Process a received signalling byte (runs under LVGL lock in call_update) diff --git a/tests/README.md b/tests/README.md index ee293c40..6b6dc8e8 100644 --- a/tests/README.md +++ b/tests/README.md @@ -19,6 +19,7 @@ System Python 3.9 has pytest pre-installed; Homebrew Python does not. - `native/test_ble_operation_queue.{cpp,py}` — GATT op queue: FIFO, busy-state, timeout, clearForConnection, builder - `native/test_ring_buffers.{cpp,py}` — PCM + encoded SPSC ring buffers, including 100k-frame multithreaded producer/consumer stress - `native/test_audio_filters.{cpp,py}` — VoiceFilterChain frequency response, peak limiting, multichannel +- `native/test_call_command_mailbox.{cpp,py}` — generation-scoped LXST hangup/mute command handoff and producer/consumer stress ### Adding a new native C++ test diff --git a/tests/native/test_call_command_mailbox.cpp b/tests/native/test_call_command_mailbox.cpp new file mode 100644 index 00000000..da40dbdf --- /dev/null +++ b/tests/native/test_call_command_mailbox.cpp @@ -0,0 +1,182 @@ +#include "../../lib/tdeck_ui/UI/LXMF/CallCommandMailbox.h" + +#include +#include +#include +#include +#include + +using UI::LXMF::CallCommandMailbox; +using Action = CallCommandMailbox::Action; + +static int g_pass = 0; +static int g_fail = 0; + +#define EXPECT_EQ(actual, expected) \ + do { \ + auto _a = (actual); \ + auto _e = (expected); \ + if (!(_a == _e)) { \ + char buf[256]; \ + std::snprintf(buf, sizeof(buf), "%s:%d: %s != %s", \ + __FILE__, __LINE__, #actual, #expected); \ + throw std::runtime_error(buf); \ + } \ + } while (0) + +#define EXPECT_TRUE(cond) \ + do { \ + if (!(cond)) { \ + char buf[256]; \ + std::snprintf(buf, sizeof(buf), "%s:%d: expected %s", \ + __FILE__, __LINE__, #cond); \ + throw std::runtime_error(buf); \ + } \ + } while (0) + +#define RUN(name) \ + do { \ + try { \ + name(); \ + ++g_pass; \ + std::printf("PASS %s\n", #name); \ + } catch (const std::exception& e) { \ + ++g_fail; \ + std::printf("FAIL %s: %s\n", #name, e.what()); \ + } \ + } while (0) + +static void zero_generation_is_ignored() { + CallCommandMailbox mailbox; + mailbox.requestHangup(0); + mailbox.requestMute(0, true); + const auto commands = mailbox.take(); + EXPECT_EQ(commands.hangupGeneration, 0u); + EXPECT_EQ(commands.muteGeneration, 0u); +} + +static void hangup_is_one_shot() { + CallCommandMailbox mailbox; + mailbox.requestHangup(17); + EXPECT_EQ(mailbox.take().hangupGeneration, 17u); + EXPECT_EQ(mailbox.take().hangupGeneration, 0u); +} + +static void latest_mute_wins() { + CallCommandMailbox mailbox; + mailbox.requestMute(21, true); + mailbox.requestMute(21, false); + mailbox.requestMute(21, true); + const auto commands = mailbox.take(); + EXPECT_EQ(commands.muteGeneration, 21u); + EXPECT_TRUE(commands.muted); + EXPECT_EQ(mailbox.take().muteGeneration, 0u); +} + +static void hangup_and_mute_are_independent() { + CallCommandMailbox mailbox; + mailbox.requestHangup(31); + mailbox.requestMute(31, true); + const auto commands = mailbox.take(); + EXPECT_EQ(commands.hangupGeneration, 31u); + EXPECT_EQ(commands.muteGeneration, 31u); + EXPECT_TRUE(commands.muted); +} + +static void stale_generation_is_discarded_by_consumer() { + CallCommandMailbox mailbox; + mailbox.requestHangup(40); + mailbox.requestMute(40, false); + EXPECT_EQ(mailbox.takeForGeneration(41).action, Action::NONE); + EXPECT_EQ(mailbox.takeForGeneration(40).action, Action::NONE); +} + +static void current_hangup_takes_precedence_over_mute() { + CallCommandMailbox mailbox; + mailbox.requestMute(51, true); + mailbox.requestHangup(51); + const auto command = mailbox.takeForGeneration(51); + EXPECT_EQ(command.action, Action::HANGUP); + EXPECT_EQ(mailbox.takeForGeneration(51).action, Action::NONE); +} + +static void stale_hangup_does_not_hide_current_mute() { + CallCommandMailbox mailbox; + mailbox.requestHangup(60); + mailbox.requestMute(61, true); + const auto command = mailbox.takeForGeneration(61); + EXPECT_EQ(command.action, Action::MUTE); + EXPECT_TRUE(command.muted); +} + +static void producer_consumer_stress() { + CallCommandMailbox mailbox; + constexpr uint32_t total = 100000; + std::atomic done{false}; + std::atomic observedMute{0}; + std::atomic observedHangup{0}; + std::atomic errors{0}; + + std::thread producer([&] { + for (uint32_t generation = 1; generation <= total; ++generation) { + mailbox.requestMute(generation, (generation & 1u) != 0); + if ((generation % 7u) == 0) mailbox.requestHangup(generation); + if ((generation % 64u) == 0) std::this_thread::yield(); + } + done.store(true, std::memory_order_release); + }); + + std::thread consumer([&] { + for (;;) { + const auto commands = mailbox.take(); + if (commands.muteGeneration != 0) { + if (commands.muted != ((commands.muteGeneration & 1u) != 0)) { + errors.fetch_add(1, std::memory_order_relaxed); + } + observedMute.fetch_add(1, std::memory_order_relaxed); + } + if (commands.hangupGeneration != 0) { + if ((commands.hangupGeneration % 7u) != 0) { + errors.fetch_add(1, std::memory_order_relaxed); + } + observedHangup.fetch_add(1, std::memory_order_relaxed); + } + if (done.load(std::memory_order_acquire)) { + const auto finalCommands = mailbox.take(); + if (finalCommands.muteGeneration != 0) { + if (finalCommands.muted != ((finalCommands.muteGeneration & 1u) != 0)) { + errors.fetch_add(1, std::memory_order_relaxed); + } + observedMute.fetch_add(1, std::memory_order_relaxed); + } + if (finalCommands.hangupGeneration != 0) { + if ((finalCommands.hangupGeneration % 7u) != 0) { + errors.fetch_add(1, std::memory_order_relaxed); + } + observedHangup.fetch_add(1, std::memory_order_relaxed); + } + break; + } + std::this_thread::yield(); + } + }); + + producer.join(); + consumer.join(); + EXPECT_EQ(errors.load(), 0u); + EXPECT_TRUE(observedMute.load() > 0); + EXPECT_TRUE(observedHangup.load() > 0); +} + +int main() { + RUN(zero_generation_is_ignored); + RUN(hangup_is_one_shot); + RUN(latest_mute_wins); + RUN(hangup_and_mute_are_independent); + RUN(stale_generation_is_discarded_by_consumer); + RUN(current_hangup_takes_precedence_over_mute); + RUN(stale_hangup_does_not_hide_current_mute); + RUN(producer_consumer_stress); + std::printf("%d passed, %d failed\n", g_pass, g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/native/test_call_command_mailbox.py b/tests/native/test_call_command_mailbox.py new file mode 100644 index 00000000..a8a5051e --- /dev/null +++ b/tests/native/test_call_command_mailbox.py @@ -0,0 +1,32 @@ +"""Compile and execute the portable LXST call-command mailbox regression.""" + +import shutil +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +TEST_SOURCE = HERE / "test_call_command_mailbox.cpp" + + +def test_call_command_mailbox(tmp_path): + cxx = shutil.which("clang++") or shutil.which("g++") + if not cxx: + pytest.skip("no C++ compiler found") + binary = tmp_path / "test_call_command_mailbox" + cmd = [ + cxx, + "-std=c++17", + "-Wall", + "-Wextra", + "-pthread", + str(TEST_SOURCE), + "-o", + str(binary), + ] + compiled = subprocess.run(cmd, capture_output=True, text=True) + assert compiled.returncode == 0, compiled.stderr + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + assert ran.returncode == 0, ran.stdout + ran.stderr + assert "8 passed, 0 failed" in ran.stdout