diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetLibrary.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetLibrary.cpp index 346de070..33d73d77 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetLibrary.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetLibrary.cpp @@ -264,6 +264,17 @@ bool Library::set_page_saved(const std::string& url, bool saved) { return false; } +bool Library::remove_heard_node(const std::string& destination_hex) { + if (!is_hex32(destination_hex)) return false; + const std::string normalized = lower_hex(destination_hex); + const auto found = std::find_if(_nodes.begin(), _nodes.end(), [&](const NodeRecord& node) { + return node.destination_hex == normalized; + }); + if (found == _nodes.end() || found->saved) return false; + _nodes.erase(found); + return true; +} + bool Library::node_saved(const std::string& destination_hex) const { if (!is_hex32(destination_hex)) return false; const std::string normalized = lower_hex(destination_hex); diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetLibrary.h b/lib/tdeck_ui/UI/LXMF/NomadNetLibrary.h index 2fd28259..c55441c0 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetLibrary.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetLibrary.h @@ -50,6 +50,7 @@ public: bool record_page(const std::string& url, const std::string& title, uint64_t timestamp); bool set_node_saved(const std::string& destination_hex, bool saved); bool set_page_saved(const std::string& url, bool saved); + bool remove_heard_node(const std::string& destination_hex); bool node_saved(const std::string& destination_hex) const; bool page_saved(const std::string& url) const; diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetRequestPolicy.h b/lib/tdeck_ui/UI/LXMF/NomadNetRequestPolicy.h new file mode 100644 index 00000000..8217f1d4 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NomadNetRequestPolicy.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +namespace UI::LXMF::NomadNet { + +// Browser-owned bounds around the pinned transport state machine. Path waiting +// must never be shorter than microReticulum's 15 second PATH_REQUEST_TIMEOUT. +// A failed cached route gets one fresh discovery attempt, then terminates. +class RequestPolicy { +public: + static constexpr uint32_t PATH_WAIT_MS = 20000; + static constexpr uint32_t LINK_WAIT_MS = 30000; + + enum class LinkTimeoutAction : uint8_t { REFRESH_PATH, FAIL }; + + void reset() { _path_refreshes = 0; } + + LinkTimeoutAction on_link_timeout() { + if (_path_refreshes == 0) { + ++_path_refreshes; + return LinkTimeoutAction::REFRESH_PATH; + } + return LinkTimeoutAction::FAIL; + } + + uint8_t path_refreshes() const { return _path_refreshes; } + static bool path_invalidation_succeeded(bool path_present_after) { + return !path_present_after; + } + +private: + uint8_t _path_refreshes = 0; +}; + +} // namespace UI::LXMF::NomadNet diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp index 319fbc9f..78872869 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp @@ -102,6 +102,7 @@ bool NomadNetScreen::handle_library_back(){ } void NomadNetScreen::show_browser(bool editing){ _view=View::BROWSER; + _directory_visible.store(false,std::memory_order_release); lv_obj_add_flag(_directory,LV_OBJ_FLAG_HIDDEN); for(auto* object:{_address_row,_status,_content,_reload_button})lv_obj_clear_flag(object,LV_OBJ_FLAG_HIDDEN); if(_page_loaded)lv_obj_clear_flag(_save_button,LV_OBJ_FLAG_HIDDEN);else lv_obj_add_flag(_save_button,LV_OBJ_FLAG_HIDDEN); @@ -112,6 +113,7 @@ void NomadNetScreen::render_directory(View view){ auto* group=LVGL::LVGLInit::get_default_group(); if(group)for(auto* object:_directory_focusables)lv_group_remove_obj(object); _directory_focusables.clear();_directory_targets.clear();lv_obj_clean(_directory);_view=view; + _directory_visible.store(true,std::memory_order_release); lv_obj_clear_flag(_directory,LV_OBJ_FLAG_HIDDEN); for(auto* object:{_address_row,_status,_content,_reload_button,_save_button})lv_obj_add_flag(object,LV_OBJ_FLAG_HIDDEN); diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h index d43d4edb..0d703791 100644 --- a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h +++ b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h @@ -1,5 +1,6 @@ #pragma once #ifdef ARDUINO +#include #include #include #include @@ -30,6 +31,7 @@ public: void begin_navigation(const std::string& target); void show_start(); bool handle_library_back(); + bool directory_visible() const { return _directory_visible.load(std::memory_order_acquire); } void show(); void hide(); private: static constexpr std::size_t MAX_UI_OBJECTS = 96; @@ -46,6 +48,7 @@ private: NomadNet::Library _library; enum class View { START, HEARD, SAVED_NODES, SAVED_PAGES, RECENT, BROWSER }; View _view = View::START; + std::atomic _directory_visible{true}; bool _visible = false; bool _editing = true; bool _page_loaded = false; diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index 5ddcb582..b24c4a3a 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -560,11 +560,17 @@ void UIManager::show_nomadnet() { } void UIManager::navigate(Route route) { - if (_navigation.current() == Route::NOMADNET && route != Route::NOMADNET) - nomad_stop_transport(); - LVGL_LOCK(); - _navigation.navigate(route); - render_route(route); + const bool leaving_nomadnet = _navigation.current() == Route::NOMADNET && route != Route::NOMADNET; + if (leaving_nomadnet) { + _nomad_state = NomadState::IDLE; + _nomad_mailbox.seal(); + } + { + LVGL_LOCK(); + _navigation.navigate(route); + render_route(route); + } + if (leaving_nomadnet) nomad_stop_transport(); } void UIManager::replace_route(Route route) { @@ -579,23 +585,41 @@ void UIManager::back() { return; } if (_navigation.current() == Route::NOMADNET) { - nomad_stop_transport(); - LVGL_LOCK(); - if (_nomadnet_screen->handle_library_back()) { + bool handled = false; + { + LVGL_LOCK(); + handled = _nomadnet_screen->handle_library_back(); + } + if (handled) { _nomad_history.clear(); + nomad_stop_transport(); + _nomad_directory_refresh_pending.store(true, std::memory_order_release); return; } + _nomad_state = NomadState::IDLE; + _nomad_mailbox.seal(); } - LVGL_LOCK(); - if (!_navigation.back()) return; - render_route(_navigation.current()); + const bool leaving_nomadnet = _navigation.current() == Route::NOMADNET; + { + LVGL_LOCK(); + if (!_navigation.back()) return; + render_route(_navigation.current()); + } + if (leaving_nomadnet) nomad_stop_transport(); } void UIManager::home() { - if (_navigation.current() == Route::NOMADNET) nomad_stop_transport(); - LVGL_LOCK(); - _navigation.home(); - render_route(Route::HOME); + const bool leaving_nomadnet = _navigation.current() == Route::NOMADNET; + if (leaving_nomadnet) { + _nomad_state = NomadState::IDLE; + _nomad_mailbox.seal(); + } + { + LVGL_LOCK(); + _navigation.home(); + render_route(Route::HOME); + } + if (leaving_nomadnet) nomad_stop_transport(); } void UIManager::render_route(Route route) { @@ -1282,6 +1306,9 @@ void UIManager::nomad_refresh_nodes() { const auto& destinations = Transport::path_table(); for (auto it = destinations.begin(); it != destinations.end(); ++it) { const Bytes& destination_hash = it->first; + // The enumerable mirror in pinned microReticulum is add-only, while + // has_path() consults the authoritative persistent routable store. + if (!Transport::has_path(destination_hash)) continue; Identity identity = Identity::recall(destination_hash); if (!identity) continue; if (destination_hash != Destination::hash(identity, "nomadnetwork", "node")) continue; @@ -1292,6 +1319,15 @@ void UIManager::nomad_refresh_nodes() { changed = _nomad_library.hear_node(destination_hash.toHex(), name, static_cast(entry._timestamp), static_cast(entry._hops)) || changed; } + std::vector unroutable; + for (const auto& node : _nomad_library.nodes()) { + if (node.saved) continue; + Bytes destination_hash; + destination_hash.assignHex(node.destination_hex.c_str()); + if (!Transport::has_path(destination_hash)) unroutable.push_back(node.destination_hex); + } + for (const auto& destination_hex : unroutable) + changed = _nomad_library.remove_heard_node(destination_hex) || changed; if (!changed) return; _nomad_library_dirty = true; LVGL_LOCK(); @@ -1300,7 +1336,7 @@ void UIManager::nomad_refresh_nodes() { void UIManager::nomad_update_library() { const uint32_t now = millis(); - if (_navigation.current() == Route::NOMADNET && + if (_navigation.current() == Route::NOMADNET && _nomadnet_screen->directory_visible() && now - _nomad_last_directory_refresh_ms >= 10000) { _nomad_directory_refresh_pending.store(true, std::memory_order_release); } @@ -1370,12 +1406,30 @@ void UIManager::nomad_release_request() { void UIManager::nomad_stop_transport() { _nomad_state = NomadState::IDLE; _nomad_deadline_ms = 0; - _nomad_mailbox.clear(); + _nomad_mailbox.seal(); nomad_release_request(); if (_nomad_link && _nomad_link.status() != Type::Link::CLOSED) _nomad_link.teardown(); _nomad_link = Link(Type::NONE); } +bool UIManager::nomad_refresh_path_after_link_failure() { + if (_nomad_request_policy.on_link_timeout() != + NomadNet::RequestPolicy::LinkTimeoutAction::REFRESH_PATH) return false; + _nomad_mailbox.seal(); + if (_nomad_link && _nomad_link.status() != Type::Link::CLOSED) _nomad_link.teardown(); + _nomad_link = Link(Type::NONE); + _nomad_request = RequestReceipt(Type::NONE); + Transport::expire_path(_nomad_destination_hash); + if (!NomadNet::RequestPolicy::path_invalidation_succeeded( + Transport::has_path(_nomad_destination_hash))) return false; + Transport::request_path(_nomad_destination_hash); + _nomad_state = NomadState::PATH; + _nomad_deadline_ms = millis() + NomadNet::RequestPolicy::PATH_WAIT_MS; + LVGL_LOCK(); + _nomadnet_screen->set_status("Refreshing stale path..."); + return true; +} + void UIManager::nomad_open(const std::string& address, bool add_history) { NomadNet::Url parsed; std::string error; @@ -1391,6 +1445,7 @@ void UIManager::nomad_open(const std::string& address, bool add_history) { _nomad_link = Link(Type::NONE); _nomad_request = RequestReceipt(Type::NONE); _nomad_response.clear(); + _nomad_request_policy.reset(); _nomad_url = parsed; _nomad_history.open(parsed.str(), add_history); _nomad_destination_hash = Bytes(); @@ -1404,7 +1459,7 @@ void UIManager::nomad_open(const std::string& address, bool add_history) { else { Transport::request_path(_nomad_destination_hash); _nomad_state = NomadState::PATH; - _nomad_deadline_ms = millis() + 10000; + _nomad_deadline_ms = millis() + NomadNet::RequestPolicy::PATH_WAIT_MS; } } @@ -1437,7 +1492,7 @@ void UIManager::nomad_start_link() { _nomad_link = Link(destination, on_nomad_link_established, on_nomad_link_closed); _nomad_mailbox.begin(token(_nomad_link.link_id())); _nomad_state = NomadState::LINK; - _nomad_deadline_ms = millis() + 30000; + _nomad_deadline_ms = millis() + NomadNet::RequestPolicy::LINK_WAIT_MS; LVGL_LOCK(); _nomadnet_screen->set_status("Establishing encrypted link..."); } @@ -1472,6 +1527,7 @@ void UIManager::nomad_update() { nomad_start_link(); } else if (_nomad_state != NomadState::IDLE && _nomad_deadline_ms != 0 && static_cast(now - _nomad_deadline_ms) >= 0) { + if (_nomad_state == NomadState::LINK && nomad_refresh_path_after_link_failure()) return; _nomad_state = NomadState::IDLE; _nomad_mailbox.clear(); nomad_release_request(); @@ -1493,6 +1549,7 @@ void UIManager::nomad_update() { } break; case NomadNet::AsyncMailbox::Kind::LINK_CLOSED: + if (_nomad_state == NomadState::LINK && nomad_refresh_path_after_link_failure()) break; _nomad_state = NomadState::IDLE; _nomad_mailbox.clear(); nomad_release_request(); diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.h b/lib/tdeck_ui/UI/LXMF/UIManager.h index 911341cf..f56c7ce0 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.h +++ b/lib/tdeck_ui/UI/LXMF/UIManager.h @@ -18,6 +18,7 @@ #include "NomadNetProtocol.h" #include "NomadNetHistory.h" #include "NomadNetMailbox.h" +#include "NomadNetRequestPolicy.h" #include "NomadNetActionMailbox.h" #include "NomadNetLibrary.h" #include "ConversationListScreen.h" @@ -358,6 +359,7 @@ private: NomadNet::AsyncMailbox _nomad_mailbox; NomadNet::ActionMailbox _nomad_actions; NomadNet::Library _nomad_library; + NomadNet::RequestPolicy _nomad_request_policy; std::atomic _nomad_directory_refresh_pending{false}; bool _nomad_library_dirty = false; @@ -381,6 +383,7 @@ private: void nomad_send_request(); void nomad_release_request(); void nomad_stop_transport(); + bool nomad_refresh_path_after_link_failure(); void nomad_refresh_nodes(); bool nomad_load_library(); bool nomad_save_library(); diff --git a/src/main.cpp b/src/main.cpp index 97a0f5db..972ac139 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2783,6 +2783,14 @@ void loop() { ui_manager->pump_call_tx(); } + // Service UI-owned actions immediately after transport polling. In + // particular, NomadNet Back/Open must render before the periodic + // persistence boundary below, which can spend 5-15 seconds in flash GC. + LOOP_STEP(5); // UI manager update + if (ui_manager) { + ui_manager->update(); + } + // Periodically persist identity/transport data (display names, paths, etc.) // NOTE: Persistence writes 40-50 entries via microStore (which routes // through the new microStore::FileSystem to SPIFFS or whichever backend @@ -2798,12 +2806,12 @@ void loop() { // dropped here. (If we observe excessive lost-known-destinations after // crashes, revisit microStore's flush cadence rather than re-adding // the fork-only Identity API.) - LOOP_STEP(5); // persist data + LOOP_STEP(6); // persist data uint32_t persistence_started_ms = millis(); reticulum->should_persist_data(); uint32_t persistence_elapsed_ms = millis() - persistence_started_ms; - if (persistence_elapsed_ms > 30000) { - WARNINGF("Reticulum persistence took %lu ms (TWDT limit is 60000 ms)", + if (persistence_elapsed_ms > 1000) { + WARNINGF("Reticulum persistence stalled loopTask for %lu ms (TWDT limit is 60000 ms)", (unsigned long)persistence_elapsed_ms); } esp_task_wdt_reset(); @@ -2813,19 +2821,13 @@ void loop() { // task ownership when its worker is running. // Process LXMF router queues - LOOP_STEP(6); // Router processing + LOOP_STEP(7); // Router processing if (router) { router->process_outbound(); router->process_inbound(); router->process_sync(); } - // Update UI manager (processes LXMF messages) - LOOP_STEP(7); // UI manager update - if (ui_manager) { - ui_manager->update(); - } - LOOP_STEP(8); // Memory monitor // Process deferred memory monitor logging (flag set by timer callback) MEMORY_MONITOR_POLL(); diff --git a/tests/native/test_app_launcher_nomadnet.cpp b/tests/native/test_app_launcher_nomadnet.cpp index cae6f22e..8d83323d 100644 --- a/tests/native/test_app_launcher_nomadnet.cpp +++ b/tests/native/test_app_launcher_nomadnet.cpp @@ -14,6 +14,7 @@ #include "NomadNetActionMailbox.h" #include "NomadNetMailbox.h" #include "NomadNetProtocol.h" +#include "NomadNetRequestPolicy.h" #include "NomadNetUrl.h" using UI::LXMF::NavigationStack; @@ -31,6 +32,7 @@ using UI::LXMF::NomadNet::sanitize_directory_name; using UI::LXMF::NomadNet::page_title; using UI::LXMF::NomadNet::AsyncMailbox; using UI::LXMF::NomadNet::ResponseBuffer; +using UI::LXMF::NomadNet::RequestPolicy; using UI::LXMF::NomadNet::Url; int main(int argc, char** argv) { @@ -344,6 +346,14 @@ int main(int argc, char** argv) { bounded_library.hear_node(hash, "new", i, 0); } check("saved node survives heard-node eviction", bounded_library.node_saved(pinned_hash)); + Library live_library; + live_library.hear_node("11111111111111111111111111111111", "Stale", 100, 1); + live_library.hear_node("22222222222222222222222222222222", "Saved", 100, 1); + live_library.set_node_saved("22222222222222222222222222222222", true); + check("unroutable heard node can be pruned without deleting saved nodes", + live_library.remove_heard_node("11111111111111111111111111111111") && + !live_library.remove_heard_node("22222222222222222222222222222222") && + live_library.nodes().size() == 1 && live_library.nodes()[0].saved); Library sorted_library; sorted_library.hear_node("11111111111111111111111111111111", "Older", 100, 1); sorted_library.hear_node("22222222222222222222222222222222", "Newer", 200, 1); @@ -411,6 +421,22 @@ int main(int argc, char** argv) { check("terminal slot preserves every queued explicit save", retained_saves == ActionMailbox::CAPACITY && action.kind == UserActionKind::BACK); + RequestPolicy request_policy; + check("path discovery deadline does not undercut transport timeout", + RequestPolicy::PATH_WAIT_MS >= 15000); + check("first Link timeout performs one bounded fresh-path retry", + request_policy.on_link_timeout() == RequestPolicy::LinkTimeoutAction::REFRESH_PATH && + request_policy.path_refreshes() == 1); + check("second Link timeout terminates instead of looping forever", + request_policy.on_link_timeout() == RequestPolicy::LinkTimeoutAction::FAIL && + request_policy.path_refreshes() == 1); + check("fresh-path retry rejects failed stale-route invalidation", + !RequestPolicy::path_invalidation_succeeded(true) && + RequestPolicy::path_invalidation_succeeded(false)); + request_policy.reset(); + check("new page navigation resets the bounded retry budget", + request_policy.path_refreshes() == 0); + std::cout << passed << " passed, " << failed << " failed\n"; return failed == 0 ? 0 : 1; } diff --git a/tests/native/test_app_launcher_nomadnet.py b/tests/native/test_app_launcher_nomadnet.py index 05bf5d8f..b695be6d 100644 --- a/tests/native/test_app_launcher_nomadnet.py +++ b/tests/native/test_app_launcher_nomadnet.py @@ -96,3 +96,28 @@ def test_ui_wiring_contract(): assert "handle_library_back" in browser_cpp assert "set_library" in browser_cpp assert "set_save_callback" in manager_cpp + + +def test_nomadnet_latency_and_path_lifecycle_contracts(): + manager_cpp = (INCLUDE / "UIManager.cpp").read_text() + main_cpp = (ROOT / "src" / "main.cpp").read_text() + screen_h = (INCLUDE / "NomadNetScreen.h").read_text() + + # User actions must be serviced before the known 5-15 second persistence + # boundary, otherwise Back/Open remains frozen behind flash erase/GC. + assert main_cpp.index("ui_manager->update();") < main_cpp.index("reticulum->should_persist_data();") + + refresh = manager_cpp[manager_cpp.index("void UIManager::nomad_refresh_nodes()"): + manager_cpp.index("void UIManager::nomad_update_library()")] + assert refresh.index("Transport::has_path(destination_hash)") < refresh.index("Identity::recall(destination_hash)") + + # Browser rendering must not trigger the expensive path-table/library poll. + assert "directory_visible() const" in screen_h + assert "std::atomic _directory_visible" in screen_h + update_library = manager_cpp[manager_cpp.index("void UIManager::nomad_update_library()"): + manager_cpp.index("void UIManager::nomad_update_user_actions()")] + assert "directory_visible()" in update_library + + back = manager_cpp[manager_cpp.index("void UIManager::back()"): + manager_cpp.index("void UIManager::home()")] + assert back.index("handle_library_back()") < back.index("nomad_stop_transport();")