From c2c6ae5641d85660221fe90169c4a25a36a4d4d5 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:32:01 +0000 Subject: [PATCH] fix(voice): synchronize call termination and TCP announces --- docs/lxst_termination_extension.md | 27 ++++++++ docs/serial_commands.md | 4 +- lib/lxst_audio/codec_wrapper.cpp | 8 +++ lib/tdeck_ui/UI/LXMF/CallLivenessWatchdog.h | 41 +++++++++++ lib/tdeck_ui/UI/LXMF/UIManager.cpp | 68 ++++++++++++++++--- lib/tdeck_ui/UI/LXMF/UIManager.h | 11 +++ src/main.cpp | 51 ++++++++------ .../test_voice_announce_contract.py | 32 +++++++++ .../test_voice_liveness_contract.py | 56 +++++++++++++++ .../test_voice_terminal_signal_contract.py | 51 ++++++++++++++ tests/native/test_call_liveness_watchdog.cpp | 43 ++++++++++++ tests/native/test_call_liveness_watchdog.py | 32 +++++++++ 12 files changed, 392 insertions(+), 32 deletions(-) create mode 100644 docs/lxst_termination_extension.md create mode 100644 lib/tdeck_ui/UI/LXMF/CallLivenessWatchdog.h create mode 100644 tests/build_scripts/test_voice_announce_contract.py create mode 100644 tests/build_scripts/test_voice_liveness_contract.py create mode 100644 tests/build_scripts/test_voice_terminal_signal_contract.py create mode 100644 tests/native/test_call_liveness_watchdog.cpp create mode 100644 tests/native/test_call_liveness_watchdog.py diff --git a/docs/lxst_termination_extension.md b/docs/lxst_termination_extension.md new file mode 100644 index 00000000..b67c3cf0 --- /dev/null +++ b/docs/lxst_termination_extension.md @@ -0,0 +1,27 @@ +# LXST call-termination extension + +Pyxis and compatible Columba builds use signalling status `0x07` (`TERMINATED`) as a backward-compatible active-call termination extension. + +## Wire behavior + +The frame uses the existing LXST signalling field: + +```text +{ 0x00: [0x07] } +``` + +A normal local hangup sends three terminal frames with 20 ms bounded drain intervals, then performs the standard Reticulum `Link.teardown()`. The Link close remains the compatibility fallback. + +## Receiver behavior + +A compatible receiver accepts `TERMINATED` in every owned, non-idle call state and performs idempotent call cleanup. A later Link-close callback or duplicate terminal frame is harmless. + +Columba intercepts `TERMINATED` before LXST-kt's legacy status handler. A link-scoped terminal gate suppresses duplicate terminal frames, while one atomic admission transaction and explicit call-generation tokens arbitrate outbound, inbound, terminal, Link-close, local-hangup, disable, and shutdown races. New calls are rejected until the prior Telephone state and Link ownership are both released. Every signalling and media callback must match the exact active Link and generation, so stale frames cannot mutate a replacement call. Exactly one path may invoke `Telephone.hangup()` in setup or active states. This keeps the extension outside the pinned LXST-kt dependency without translating it to `STATUS_AVAILABLE`. + +## Compatibility + +Current canonical Python LXST and older LXST-kt/Pyxis implementations do not define status `0x07`. Their existing decoders ignore unknown status values, after which the standard Link-close fallback remains available. + +The terminal burst substantially reduces dependence on Reticulum's single unacknowledged `LINKCLOSE`, but it is not a remote acknowledgement. Pyxis therefore also expires an `ACTIVE` call after 90 seconds without valid inbound Codec2 media. A liveness refresh requires a non-empty, exactly frame-aligned Codec2 batch that the decoder accepts; truncated or trailing partial subframes do not count. LXST capture streams continuously even during silence, so this is a conservative orphan-call fallback rather than a short silence timer. + +Physical acceptance must still verify both call directions, deliberate Link-close loss where practical, terminal loss, and the 90-second liveness path. diff --git a/docs/serial_commands.md b/docs/serial_commands.md index c112003c..ec0bdf11 100644 --- a/docs/serial_commands.md +++ b/docs/serial_commands.md @@ -29,8 +29,8 @@ or `` is misspelled. | `T:ID` | — | `T:OK ` | Local Reticulum identity hash (32 hex). | | `T:DEST` | — | `T:OK ` | LXMF delivery destination hash (16 hex). | | `T:LXSTDEST` | — | `T:OK ` | LXST telephony destination hash. Used to share with the test bot before `T:CALL`. | -| `T:ANN` | — | `T:OK announced` | Force a fresh LXMF delivery announce. | -| `T:ANNLXST` | — | `T:OK announced` | Force a fresh LXST telephony announce. Required before pyxis-as-callee tests because the TCP-reconnect path only re-announces LXMF. | +| `T:ANN` | — | `T:OK announced` | Force paired fresh LXMF delivery and LXST telephony announces. | +| `T:ANNLXST` | — | `T:OK announced` | Force only a fresh LXST telephony announce for protocol diagnostics. Normal announce paths publish LXMF and LXST together. | | `T:PATHS` | — | `T:OK count=N` then `T:PATH ` per row | Dump the in-memory path table. | | `T:HASPATH` | `` | `T:OK 0/1 mem=0/1 mem_count=N` | Has-path check + diagnostic split between disk-backed `Transport::has_path` and the in-memory `_path_table`. | | `T:RECALL` | `` | `T:OK ` or `T:ERR not recallable` | Try to resolve `` to its identity hash via `Identity::recall`. | diff --git a/lib/lxst_audio/codec_wrapper.cpp b/lib/lxst_audio/codec_wrapper.cpp index f879f2f7..68244fe3 100644 --- a/lib/lxst_audio/codec_wrapper.cpp +++ b/lib/lxst_audio/codec_wrapper.cpp @@ -106,6 +106,14 @@ int Codec2Wrapper::decode(const uint8_t* encoded, int encodedBytes, // Skip header byte, decode remaining sub-frames const uint8_t* data = encoded + 1; int dataLen = encodedBytes - 1; + if (dataLen <= 0 || bytesPerFrame_ <= 0 || dataLen % bytesPerFrame_ != 0) { + LOGW("Codec2 decode: invalid sub-frame batch (%d bytes, frame size %d)", + dataLen, bytesPerFrame_); +#ifdef ARDUINO + if (mutex_) xSemaphoreGive(mutex_); +#endif + return -1; + } int numFrames = dataLen / bytesPerFrame_; int totalSamples = numFrames * samplesPerFrame_; diff --git a/lib/tdeck_ui/UI/LXMF/CallLivenessWatchdog.h b/lib/tdeck_ui/UI/LXMF/CallLivenessWatchdog.h new file mode 100644 index 00000000..22654bc9 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/CallLivenessWatchdog.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +/** + * Wrap-safe liveness guard for continuously streamed LXST call media. + * + * It is armed only after a call becomes ACTIVE. Every valid inbound audio frame + * refreshes the observation timestamp. A long absence therefore provides a + * conservative fallback when both the application terminal signal and + * Reticulum Link-close notification are lost. + */ +class CallLivenessWatchdog { +public: + void arm(uint32_t now_ms) { + _last_observed_ms.store(now_ms, std::memory_order_relaxed); + _armed.store(true, std::memory_order_release); + } + + void observe(uint32_t now_ms) { + if (_armed.load(std::memory_order_acquire)) { + _last_observed_ms.store(now_ms, std::memory_order_relaxed); + } + } + + void disarm() { + _armed.store(false, std::memory_order_release); + _last_observed_ms.store(0, std::memory_order_relaxed); + } + + bool expired(uint32_t now_ms, uint32_t timeout_ms) const { + if (!_armed.load(std::memory_order_acquire)) return false; + const uint32_t last = _last_observed_ms.load(std::memory_order_relaxed); + return static_cast(now_ms - last) >= timeout_ms; + } + +private: + std::atomic _last_observed_ms{0}; + std::atomic _armed{false}; +}; diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index 99e45a44..2616484d 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -227,6 +227,7 @@ bool UIManager::init() { INFO("Sending LXMF announce..."); try { _router.announce(); + announce_lxst(); INFO("LXMF announce sent successfully"); } catch (const std::exception& e) { ERRORF("LXMF announce failed: %s", e.what()); @@ -1193,6 +1194,12 @@ void UIManager::call_hangup() { const uint32_t generation = call_current_generation(); + // Normal call termination gets an application-level terminal signal before + // the one-shot, unacknowledged Reticulum LINKCLOSE. Repetition and the + // bounded drain interval reduce packet-loss races without blocking the + // owner task indefinitely. Older peers ignore unknown status 0x07. + call_send_terminal_burst(); + // Detach callback ownership before touching the shared Link. Keep call // admission reserved until all owner-side teardown completes. _call_link_ownership.clear(generation); @@ -1220,6 +1227,7 @@ void UIManager::call_hangup() { _call_audio_rx_count = 0; _call_audio_tx_count = 0; _call_commands.take(); + _call_liveness.disarm(); _call_state = CallState::IDLE; call_clear_generation(generation); @@ -1303,6 +1311,17 @@ void UIManager::call_send_signal(int signal) { call_send_signal_on_link(_call_link, signal); } +void UIManager::call_send_terminal_burst() { + if (!_call_link || _call_link.status() != Type::Link::ACTIVE) return; + + for (int attempt = 0; attempt < TERMINAL_SIGNAL_SEND_COUNT; ++attempt) { + call_send_signal(LXST_STATUS_TERMINATED); + if (attempt + 1 < TERMINAL_SIGNAL_SEND_COUNT) { + delay(TERMINAL_SIGNAL_DRAIN_MS); + } + } +} + void UIManager::call_send_signal_on_link(const Link& link, int signal) { if (!link || link.status() != Type::Link::ACTIVE) return; @@ -1435,6 +1454,10 @@ void UIManager::call_rx_audio_frame(const uint8_t* frame, size_t frame_len) { // Guard: packets can arrive after hangup from the network pipeline. // In loopback mode _call_state stays IDLE, so bypass the IDLE guard. if (!_lxst_audio || (!_call_loopback && _call_state == CallState::IDLE)) return; + if (!frame || frame_len < 2) { + WARNING("LXST: RX audio dropped (truncated frame)"); + return; + } // Wire format: [codec_type_byte] + [mode_header + codec2_subframes...] // codec_type: 0x00=Raw, 0x01=Opus, 0x02=Codec2 (matches LXST Codecs/__init__.py) @@ -1453,14 +1476,20 @@ void UIManager::call_rx_audio_frame(const uint8_t* frame, size_t frame_len) { return; // Can't decode Opus (0x01) or Raw (0x00) — only Codec2 } - if (_lxst_audio && _lxst_audio->isPlaying()) { - _lxst_audio->writeEncodedPacket(codec_data, codec_data_len); - _call_audio_rx_count++; - if (_call_audio_rx_count <= 3) { - char dbg[80]; - snprintf(dbg, sizeof(dbg), "LXST: RX audio #%lu mode=0x%02X len=%d", - (unsigned long)_call_audio_rx_count, codec_data[0], (int)codec_data_len); - INFO(dbg); + if (_lxst_audio->isPlaying()) { + if (_lxst_audio->writeEncodedPacket(codec_data, codec_data_len)) { + _call_audio_rx_count++; + if (!_call_loopback && _call_state == CallState::ACTIVE) { + _call_liveness.observe(millis()); + } + if (_call_audio_rx_count <= 3) { + char dbg[80]; + snprintf(dbg, sizeof(dbg), "LXST: RX audio #%lu mode=0x%02X len=%d", + (unsigned long)_call_audio_rx_count, codec_data[0], (int)codec_data_len); + INFO(dbg); + } + } else { + WARNING("LXST: RX audio dropped (invalid Codec2 frame)"); } } else if (_call_audio_rx_count == 0) { WARNING("LXST: RX audio dropped (playback not active)"); @@ -1616,6 +1645,18 @@ void UIManager::call_process_signal(uint8_t signal) { INFO(dbg); } + // Terminal is generation-scoped and valid in every owned non-idle call + // state. call_ended() is idempotent with the subsequent Link-close callback + // because it clears link ownership before teardown. + if (signal == LXST_STATUS_TERMINATED) { + if (_call_state != CallState::IDLE || + call_current_generation() != 0) { + INFO("LXST: Remote terminated call"); + call_ended(); + } + return; + } + switch (_call_state) { case CallState::WAIT_AVAILABLE: if (signal == LXST_STATUS_AVAILABLE) { @@ -1677,6 +1718,7 @@ void UIManager::call_process_signal(uint8_t signal) { INFO("LXST: Call established!"); _call_state = CallState::ACTIVE; _call_start_ms = millis(); + _call_liveness.arm(_call_start_ms); _call_screen->set_state(CallScreen::CallState::ACTIVE); lxst_breadcrumb(24, ESP.getFreeHeap()); @@ -1713,6 +1755,7 @@ void UIManager::call_process_signal(uint8_t signal) { INFO("LXST: Call established!"); _call_state = CallState::ACTIVE; _call_start_ms = millis(); + _call_liveness.arm(_call_start_ms); _call_screen->set_state(CallScreen::CallState::ACTIVE); // Ensure full-duplex is running @@ -1762,6 +1805,7 @@ void UIManager::call_ended() { _call_audio_rx_count = 0; _call_audio_tx_count = 0; _call_commands.take(); + _call_liveness.disarm(); _call_state = CallState::IDLE; call_clear_generation(generation); @@ -1965,6 +2009,13 @@ void UIManager::call_update() { return; } + if (_call_state == CallState::ACTIVE && + _call_liveness.expired(now, CALL_MEDIA_LIVENESS_TIMEOUT_MS)) { + WARNING("LXST: Call media liveness timed out"); + call_ended(); + return; + } + // Update duration display (ACTIVE only) if (_call_state == CallState::ACTIVE) { uint32_t duration_secs = (now - _call_start_ms) / 1000; @@ -2253,6 +2304,7 @@ void UIManager::call_answer() { // Transition to active call _call_state = CallState::ACTIVE; _call_start_ms = millis(); + _call_liveness.arm(_call_start_ms); INFO("LXST: Call active (answerer, full-duplex)"); } diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.h b/lib/tdeck_ui/UI/LXMF/UIManager.h index ab75311a..ab732ded 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.h +++ b/lib/tdeck_ui/UI/LXMF/UIManager.h @@ -22,6 +22,7 @@ #include "CallStartMailbox.h" #include "CallGenerationGuard.h" #include "CallLinkOwnership.h" +#include "CallLivenessWatchdog.h" #include "LXMF/LXMRouter.h" #include "LXMF/PropagationNodeManager.h" #include "LXMF/MessageStore.h" @@ -362,9 +363,17 @@ private: static constexpr uint8_t LXST_STATUS_RINGING = 0x04; static constexpr uint8_t LXST_STATUS_CONNECTING = 0x05; static constexpr uint8_t LXST_STATUS_ESTABLISHED = 0x06; + // Backward-compatible extension. Legacy peers ignore this status and still + // receive the Reticulum Link-close fallback. + static constexpr uint8_t LXST_STATUS_TERMINATED = 0x07; + static constexpr int TERMINAL_SIGNAL_SEND_COUNT = 3; + static constexpr uint32_t TERMINAL_SIGNAL_DRAIN_MS = 20; // An accepted incoming link must identify before it can start ringing. static constexpr uint32_t INCOMING_IDENTIFY_TIMEOUT_MS = 15000; + // Codec2 media is continuous even during silence. Ninety seconds permits + // substantial temporary impairment while bounding orphaned active calls. + static constexpr uint32_t CALL_MEDIA_LIVENESS_TIMEOUT_MS = 90000; // LXST codec type bytes (match LXST Codecs/__init__.py) static constexpr uint8_t LXST_CODEC_CODEC2 = 0x02; @@ -415,6 +424,7 @@ private: CallCommandMailbox _call_commands; CallGenerationGuard _call_generation_guard; CallLinkOwnership _call_link_ownership; + CallLivenessWatchdog _call_liveness; #ifdef PYXIS_TEST_HOOKS TestCallInitiateResult _test_call_initiate_result = TestCallInitiateResult::FAILED; @@ -459,6 +469,7 @@ private: // Send a signalling byte over the call link void call_send_signal(int signal); static void call_send_signal_on_link(const RNS::Link& link, int signal); + void call_send_terminal_burst(); // Send batched audio frames over the call link (10 sub-frames per batch) void call_send_audio_batch(const uint8_t* batch_data, int batch_len, int batch_count, int total_frames); diff --git a/src/main.cpp b/src/main.cpp index 8cd300b6..0b821cbc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -138,6 +138,16 @@ uint32_t last_announce = 0; uint32_t last_sync = 0; uint32_t last_status_check = 0; const uint32_t STATUS_CHECK_INTERVAL = 1000; // 1 second + +void announce_reachable_destinations() { + if (!router) return; + + router->announce(); + if (ui_manager) { + ui_manager->announce_lxst(); + } + last_announce = millis(); +} const uint32_t INITIAL_SYNC_DELAY = 45000; // 45 seconds after boot before first sync bool initial_sync_done = false; @@ -1231,13 +1241,10 @@ void setup_lxmf() { delay(3000); BOOT_PROFILE_WAIT_END("tcp_stabilize"); - // Check TCP status before announcing + // Record whether TCP stabilized during boot. Destination announces are + // deferred until UIManager has constructed lxst.telephony. if (tcp_interface->online()) { INFO("TCP interface online: YES"); - // Announce delivery destination - INFO("Sending LXMF announce..."); - router->announce(); - last_announce = millis(); } else { INFO("TCP interface online: NO"); } @@ -1785,10 +1792,9 @@ void setup() { // gate because they are recoverable runtime operations. confirm_running_firmware(); - // Send initial LXST voice destination announce - if (ui_manager) { - ui_manager->announce_lxst(); - } + // Publish both reachable destinations together after UIManager has created + // the LXST destination. Earlier boot stages deliberately do not announce. + announce_reachable_destinations(); // Register delivered callback to update message status in storage and UI router->register_delivered_callback([](LXMF::LXMessage& msg) { @@ -1995,16 +2001,13 @@ static void handle_test_hook_command(const String& line) { } else if (cmd == "T:ANN") { if (!router) { Serial.println("T:ERR no router"); return; } - router->announce(); + announce_reachable_destinations(); Serial.println("T:OK announced"); } else if (cmd == "T:ANNLXST") { // T:ANNLXST — force a fresh announce of the lxst.telephony - // destination. Required before pyxis-as-callee tests because - // the TCP-reconnect path at main.cpp:963 only announces LXMF; - // a brand-new boot ends up with the LXST destination absent - // from rnsd's cache, so the bot can resolve a path but the - // path doesn't actually route to pyxis. + // destination independently for protocol diagnostics. Normal LXMF + // announce paths publish both destinations together. if (!ui_manager) { Serial.println("T:ERR no ui_manager"); return; } ui_manager->announce_lxst(); Serial.println("T:OK announced"); @@ -2754,11 +2757,7 @@ void loop() { (lora_interface && lora_interface->online()) || (ble_interface && ble_interface->online()); if (router && has_online_interface) { - router->announce(); - if (ui_manager) { - ui_manager->announce_lxst(); - } - last_announce = millis(); + announce_reachable_destinations(); INFO("Periodic announce sent (interval: " + std::to_string(app_settings.announce_interval) + "s)"); } } @@ -2799,8 +2798,7 @@ void loop() { INFO("TCP interface reconnected - sending announce"); if (router) { delay(500); // Brief stabilization delay - router->announce(); - last_announce = millis(); + announce_reachable_destinations(); } last_tcp_online = true; } @@ -2864,6 +2862,15 @@ void loop() { bool tcp_online = tcp_interface && tcp_interface->online(); bool lora_online = lora_interface && lora_interface->online(); + // The boot-time LXST announce can run before the asynchronous TCP + // client reaches ONLINE. Re-announce both destinations on every TCP + // offline->online edge so peers that can exchange LXMF messages also + // receive a routable lxst.telephony path without waiting for the + // periodic announce interval. + if (tcp_online && !last_tcp_online) { + announce_reachable_destinations(); + } + // Check if status changed if (tcp_online != last_tcp_online || lora_online != last_lora_online) { last_tcp_online = tcp_online; diff --git a/tests/build_scripts/test_voice_announce_contract.py b/tests/build_scripts/test_voice_announce_contract.py new file mode 100644 index 00000000..81c1e7d1 --- /dev/null +++ b/tests/build_scripts/test_voice_announce_contract.py @@ -0,0 +1,32 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +MAIN = (ROOT / "src" / "main.cpp").read_text() +UI_MANAGER = (ROOT / "lib" / "tdeck_ui" / "UI" / "LXMF" / "UIManager.cpp").read_text() + + +def test_all_main_loop_announces_use_the_paired_destination_helper(): + helper_start = MAIN.index("void announce_reachable_destinations()") + helper_end = MAIN.index("\n}\n", helper_start) + 2 + helper = MAIN[helper_start:helper_end] + + assert "router->announce();" in helper + assert "ui_manager->announce_lxst();" in helper + assert MAIN.count("router->announce();") == 1 + + +def test_tcp_online_edge_reannounces_both_destinations(): + status_block = MAIN[MAIN.index("bool tcp_online ="): MAIN.index("// Update BLE peer info")] + + assert "tcp_online && !last_tcp_online" in status_block + assert "announce_reachable_destinations();" in status_block + + +def test_manual_announce_action_pairs_lxmf_and_lxst(): + callback_start = UI_MANAGER.index("_announce_list_screen->set_send_announce_callback") + callback_end = UI_MANAGER.index("// Set up callbacks for status screen", callback_start) + callback = UI_MANAGER[callback_start:callback_end] + + assert "_router.announce();" in callback + assert "announce_lxst();" in callback diff --git a/tests/build_scripts/test_voice_liveness_contract.py b/tests/build_scripts/test_voice_liveness_contract.py new file mode 100644 index 00000000..971a92d7 --- /dev/null +++ b/tests/build_scripts/test_voice_liveness_contract.py @@ -0,0 +1,56 @@ +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[2] +HEADER = ROOT / "lib/tdeck_ui/UI/LXMF/UIManager.h" +SOURCE = ROOT / "lib/tdeck_ui/UI/LXMF/UIManager.cpp" +CODEC_SOURCE = ROOT / "lib/lxst_audio/codec_wrapper.cpp" + + +def _function_body(source: str, signature: str) -> str: + start = source.index(signature) + brace = source.index("{", start) + depth = 0 + for index in range(brace, len(source)): + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + if depth == 0: + return source[brace + 1 : index] + raise AssertionError(f"unterminated function: {signature}") + + +def test_voice_liveness_watchdog_is_conservative_and_wired_to_media(): + header = HEADER.read_text() + source = SOURCE.read_text() + update = _function_body(source, "void UIManager::call_update()") + receive = _function_body(source, "void UIManager::call_rx_audio_frame") + + assert "CALL_MEDIA_LIVENESS_TIMEOUT_MS = 90000" in header + assert source.count("_call_liveness.arm(_call_start_ms);") == 3 + assert "frame_len < 2" in receive + assert re.search( + r"if\s*\(_lxst_audio->writeEncodedPacket\([^;]+\)\)\s*\{[^}]*" + r"_call_liveness\.observe\(millis\(\)\);", + receive, + re.DOTALL, + ) + assert "_call_liveness.expired(now, CALL_MEDIA_LIVENESS_TIMEOUT_MS)" in update + assert "call_ended();" in update + + +def test_every_owner_cleanup_disarms_liveness_before_releasing_generation(): + source = SOURCE.read_text() + for signature in ("void UIManager::call_hangup()", "void UIManager::call_ended()"): + body = _function_body(source, signature) + assert body.index("_call_liveness.disarm();") < body.index("call_clear_generation(generation);") + + +def test_codec2_decoder_rejects_empty_and_partial_subframe_batches(): + source = CODEC_SOURCE.read_text() + decode = _function_body(source, "int Codec2Wrapper::decode") + + validation = decode.index("dataLen <= 0 || bytesPerFrame_ <= 0 || dataLen % bytesPerFrame_ != 0") + frame_count = decode.index("int numFrames = dataLen / bytesPerFrame_;") + assert validation < frame_count diff --git a/tests/build_scripts/test_voice_terminal_signal_contract.py b/tests/build_scripts/test_voice_terminal_signal_contract.py new file mode 100644 index 00000000..33c2e7f3 --- /dev/null +++ b/tests/build_scripts/test_voice_terminal_signal_contract.py @@ -0,0 +1,51 @@ +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[2] +HEADER = ROOT / "lib/tdeck_ui/UI/LXMF/UIManager.h" +SOURCE = ROOT / "lib/tdeck_ui/UI/LXMF/UIManager.cpp" + + +def _function_body(source: str, signature: str) -> str: + start = source.index(signature) + brace = source.index("{", start) + depth = 0 + for index in range(brace, len(source)): + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + if depth == 0: + return source[brace + 1 : index] + raise AssertionError(f"unterminated function: {signature}") + + +def test_terminal_status_is_backward_compatible_extension(): + header = HEADER.read_text() + assert re.search(r"LXST_STATUS_TERMINATED\s*=\s*0x07", header) + + +def test_local_hangup_sends_bounded_terminal_burst_before_link_teardown(): + source = SOURCE.read_text() + hangup = _function_body(source, "void UIManager::call_hangup()") + burst = _function_body(source, "void UIManager::call_send_terminal_burst()") + + assert hangup.index("call_send_terminal_burst();") < hangup.index("_call_link_ownership.clear") + assert hangup.index("call_send_terminal_burst();") < hangup.index("_call_link.teardown()") + assert "TERMINAL_SIGNAL_SEND_COUNT = 3" in HEADER.read_text() + assert "TERMINAL_SIGNAL_DRAIN_MS = 20" in HEADER.read_text() + assert "call_send_signal(LXST_STATUS_TERMINATED)" in burst + assert "delay(TERMINAL_SIGNAL_DRAIN_MS)" in burst + + +def test_terminal_signal_ends_every_owned_non_idle_call_before_state_switch(): + source = SOURCE.read_text() + handler = _function_body(source, "void UIManager::call_process_signal(uint8_t signal)") + + terminal = handler.index("signal == LXST_STATUS_TERMINATED") + state_switch = handler.index("switch (_call_state)") + assert terminal < state_switch + terminal_block = handler[terminal:state_switch] + assert "call_current_generation() != 0" in terminal_block + assert "call_ended();" in terminal_block + assert "return;" in terminal_block diff --git a/tests/native/test_call_liveness_watchdog.cpp b/tests/native/test_call_liveness_watchdog.cpp new file mode 100644 index 00000000..60fbe702 --- /dev/null +++ b/tests/native/test_call_liveness_watchdog.cpp @@ -0,0 +1,43 @@ +#include +#include + +#include "../../lib/tdeck_ui/UI/LXMF/CallLivenessWatchdog.h" + +namespace { +int passed = 0; +int failed = 0; + +void expect(bool condition, const char* name) { + if (condition) { + ++passed; + } else { + ++failed; + std::cerr << "FAIL: " << name << '\n'; + } +} +} + +int main() { + constexpr uint32_t timeout = 90000; + CallLivenessWatchdog watchdog; + + expect(!watchdog.expired(100000, timeout), "disarmed watchdog does not expire"); + + watchdog.arm(1000); + expect(!watchdog.expired(90999, timeout), "active just below timeout"); + expect(watchdog.expired(91000, timeout), "active expires at timeout"); + + watchdog.observe(50000); + expect(!watchdog.expired(100000, timeout), "received media resets deadline"); + expect(watchdog.expired(140000, timeout), "reset deadline eventually expires"); + + watchdog.disarm(); + expect(!watchdog.expired(UINT32_MAX, timeout), "disarm suppresses expiry"); + + watchdog.arm(UINT32_MAX - 1000); + expect(!watchdog.expired(1000, timeout), "millis wrap below timeout"); + expect(watchdog.expired(90000, timeout), "millis wrap reaches timeout"); + + std::cout << passed << " passed, " << failed << " failed\n"; + return failed == 0 ? 0 : 1; +} diff --git a/tests/native/test_call_liveness_watchdog.py b/tests/native/test_call_liveness_watchdog.py new file mode 100644 index 00000000..e7ced2f5 --- /dev/null +++ b/tests/native/test_call_liveness_watchdog.py @@ -0,0 +1,32 @@ +"""Compile and execute the portable LXST call-liveness watchdog regression.""" + +import shutil +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +TEST_SOURCE = HERE / "test_call_liveness_watchdog.cpp" + + +def test_call_liveness_watchdog(tmp_path): + cxx = shutil.which("c++") + if not cxx: + pytest.skip("no C++ compiler found") + binary = tmp_path / "test_call_liveness_watchdog" + cmd = [ + cxx, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + 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