diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 565ca8227..29da2c749 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -82,6 +82,7 @@ jobs: sudo apt-get install -y libssl-dev python3 -B test/test_companion_tx_routing.py -v python3 -B test/test_companion_preferences_transaction.py -v + python3 -B test/test_companion_radio_settings_transaction.py -v python3 -B test/test_contact_cache.py -v - name: Verify receive recovery and pinned radio status transport @@ -111,6 +112,7 @@ jobs: python3 -B test/test_ota_apply_delivery.py -v python3 -B test/test_ota_install_preflight.py -v python3 -B test/test_ota_store_resume.py -v + python3 -B test/test_ota_catalog_lifetime.py -v python3 -B test/test_ota_store_flash_nrf52_hybrid.py -v python3 -B test/test_ota_dev_staging.py -v python3 -B test/test_ota_dev_reboot_guard.py -v diff --git a/docs/flood_filtering.md b/docs/flood_filtering.md index 616b9ed54..54cab1bca 100644 --- a/docs/flood_filtering.md +++ b/docs/flood_filtering.md @@ -56,6 +56,12 @@ physical distance. - A full public key that exceeds the normal level in one window receives a first strike. If it exceeds that level again in the immediately following window, it enters the bad list. A quiet intervening window breaks this chain. +- Escalation is deliberately conservative: once a key enters the bad list, + a later shorter route does **not** undo that decision, even if the increased + quota makes the current window fall below the normal level. This applies to + shorter duplicate and new adverts, including a matching-prefix key's route. + The existing restriction remains until seven-day recovery, manual clear, or + reboot; a shorter route alone never erases a recorded violation. - The bad list matches **all 32 public-key bytes**, not the prefix. Different full keys sharing a prefix share the ordinary forwarding quota, but their received counts are not combined to accuse an innocent key of ongoing abuse. diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index 80297df00..b1a0413d4 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -509,8 +509,10 @@ fetched per-mid only on commit (Section 8.3). Fragment numbers are canonical pages of the complete catalog sorted by `manifest_id`. `filter_target` may remove rows from a requested page (and can therefore produce an empty fragment), but it never renumbers pages -or changes `frag_total`. This keeps missing-fragment recovery unambiguous when filtered and unfiltered queries -for the same `{seeder, set_digest}` are overheard together. +or changes `frag_total`. Receivers derive the unfiltered page count and each page's row count from the +source's advertised `n_motas`. A page counts as received only if both counts match that complete geometry; +filtered rows are still cached, but omitted rows leave the page eligible for an unfiltered recovery query. +This also prevents legacy filtered replies which renumber pages from completing an unfiltered catalog. ### 8.2 Anti-storm (mandatory at mesh scale) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 2afdb4fc9..eb47ee888 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2015,7 +2015,7 @@ void MyMesh::configureRadioFromPrefs() { radio_driver.setCADScanTimeoutMillis(_prefs.cad_scan_timeout_ms); _radio->setCADEnabled(_prefs.cad_enabled != 0); if (!saved_radio_apply_pending) { - radio_driver.setTxPower(_prefs.tx_power_dbm); + saved_radio_apply_pending = !radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); } const bool fem_gain_changed = board.canControlLoRaFemLna() @@ -2062,6 +2062,14 @@ bool MyMesh::applySavedRadioParams() { void MyMesh::finishRadioParamApply(float freq, float bw, uint8_t sf, uint8_t cr, uint8_t repeat, BaseSerialInterface* route) { + const float previous_freq = _prefs.freq; + const float previous_bw = _prefs.bw; + const uint8_t previous_sf = _prefs.sf; + const uint8_t previous_cr = _prefs.cr; + const uint8_t previous_repeat = _prefs.client_repeat; + const uint32_t previous_rx_us = _prefs.rx_ps_rx_us; + const uint32_t previous_sleep_us = _prefs.rx_ps_sleep_us; + const bool previous_recovery_pending = saved_radio_apply_pending; _prefs.sf = sf; _prefs.cr = cr; _prefs.freq = freq; @@ -2070,9 +2078,28 @@ void MyMesh::finishRadioParamApply(float freq, float bw, uint8_t sf, recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw, _prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us, &_prefs.rx_ps_sleep_us); - savePrefs(); + if (!savePrefs()) { + _prefs.freq = previous_freq; + _prefs.bw = previous_bw; + _prefs.sf = previous_sf; + _prefs.cr = previous_cr; + _prefs.client_repeat = previous_repeat; + _prefs.rx_ps_rx_us = previous_rx_us; + _prefs.rx_ps_sleep_us = previous_sleep_us; + // Hardware accepted the candidate before this save. Restore the durable + // tuple now, or let the existing backed-off recovery finish once RX is + // idle. A failed save must never acknowledge a persistent radio change. + const bool restored = applySavedRadioParams(); + saved_radio_apply_pending = !restored || previous_recovery_pending; + radio_apply_retry_at = 0; + radio_apply_failures = 0; + writeErrFrame(ERR_CODE_FILE_IO_ERROR, route); + return; + } - saved_radio_apply_pending = false; + // Preserve an outstanding power rollback; the full saved-radio recovery + // checks both the tuple and TX power before clearing this latch. + saved_radio_apply_pending = previous_recovery_pending; radio_apply_retry_at = 0; radio_apply_failures = 0; @@ -3247,8 +3274,8 @@ void MyMesh::serviceTempRadio() { if (hasOutbound() || !retry_ready) return; mesh::RadioParamApplyResult result = tryApplyRadioParams( _prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - if (result == mesh::RadioParamApplyResult::APPLIED) { - radio_driver.setTxPower(_prefs.tx_power_dbm); + if (result == mesh::RadioParamApplyResult::APPLIED + && radio_driver.setTxPower(_prefs.tx_power_dbm)) { radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); _temp_radio_set_at = 0; _temp_radio_revert_at = 0; @@ -3766,10 +3793,11 @@ void MyMesh::execCommand(char* cmd, char* reply) { if (!wcParseLong(value, -9, MAX_LORA_TX_POWER, parsed)) { snprintf(reply, 160, "Error: TX power must be -9 to %d", MAX_LORA_TX_POWER); } else { - _prefs.tx_power_dbm = static_cast(parsed); - if (_radio_available) radio_driver.setTxPower(_prefs.tx_power_dbm); - savePrefs(); - strcpy(reply, "OK"); + const auto result = applyAndSaveTxPower(static_cast(parsed)); + strcpy(reply, result == RadioSettingResult::Saved ? "OK" + : result == RadioSettingResult::RadioRejected + ? "Error: radio busy or TX power rejected" + : "Error: TX power could not be saved"); } return; } @@ -4811,10 +4839,10 @@ void MyMesh::handleCmdFrame(size_t len) { if (power < -9 || power > MAX_LORA_TX_POWER) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else { - _prefs.tx_power_dbm = power; - savePrefs(); - if (_radio_available) radio_driver.setTxPower(_prefs.tx_power_dbm); - writeOKFrame(); + const auto result = applyAndSaveTxPower(power); + if (result == RadioSettingResult::Saved) writeOKFrame(); + else writeErrFrame(result == RadioSettingResult::RadioRejected + ? ERR_CODE_BAD_STATE : ERR_CODE_FILE_IO_ERROR); } } else if (cmd_frame[0] == CMD_SET_TUNING_PARAMS) { if (len < 9) { @@ -6288,6 +6316,27 @@ bool MyMesh::handleExtraFsHilCommand(const char* command, char* reply, #endif #endif +MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(int8_t power) { + const int8_t previous = _prefs.tx_power_dbm; + if (_radio_available && !radio_driver.setTxPower(power)) { + return RadioSettingResult::RadioRejected; + } + _prefs.tx_power_dbm = power; + if (savePrefs()) return RadioSettingResult::Saved; + + _prefs.tx_power_dbm = previous; + if (_radio_available && !radio_driver.setTxPower(previous)) { + // An RX packet can arrive between the apply and its failed flash commit. + // Keep retrying the durable power rather than leaving the rejected value + // active until reboot. Recovery also restores the saved tuple, so an + // active TempRadio lease deliberately delays it until the lease ends. + saved_radio_apply_pending = true; + radio_apply_retry_at = 0; + radio_apply_failures = 0; + } + return RadioSettingResult::SaveFailed; +} + bool MyMesh::applyAndSaveFemRxGain(bool enabled) { if (!board.canControlLoRaFemLna()) return false; @@ -8025,9 +8074,8 @@ void MyMesh::handleTerminalCommand(char* command) { terminalOutput().print(" OK\r\n"); } } else if (strncmp(config, "tx ", 3) == 0) { - _prefs.tx_power_dbm = constrain(atoi(config + 3), -9, MAX_LORA_TX_POWER); - savePrefs(); - terminalOutput().print(" OK - reboot to apply\r\n"); + handleCommand(command, 0, local_reply); + terminalOutput().printf(" %s\r\n", local_reply); } else if (strncmp(config, "freq ", 5) == 0) { handleCommand(command, 0, local_reply); terminalOutput().printf(" %s\r\n", local_reply); @@ -8411,19 +8459,14 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, snprintf(reply, reply_capacity, "Error: TX power must be -9 to %d", MAX_LORA_TX_POWER); } else { - const int8_t previous = _prefs.tx_power_dbm; const int8_t requested = static_cast(parsed); - if (_radio_available && !radio_driver.setTxPower(requested)) { + const auto result = applyAndSaveTxPower(requested); + if (result == RadioSettingResult::RadioRejected) { strcpy(reply, "Error: radio busy or TX power rejected"); + } else if (result == RadioSettingResult::SaveFailed) { + strcpy(reply, "Error: TX power could not be saved"); } else { - _prefs.tx_power_dbm = requested; - if (!savePrefs()) { - _prefs.tx_power_dbm = previous; - if (_radio_available) radio_driver.setTxPower(previous); - strcpy(reply, "Error: TX power changed but save failed"); - } else { - strcpy(reply, "OK"); - } + strcpy(reply, "OK"); } } return true; @@ -8849,8 +8892,8 @@ void MyMesh::loop() { // waiting. Preserve that packet, then apply the persisted radio settings // once the receive/response path is idle. radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); - if (applySavedRadioParams()) { - radio_driver.setTxPower(_prefs.tx_power_dbm); + if (applySavedRadioParams() + && radio_driver.setTxPower(_prefs.tx_power_dbm)) { saved_radio_apply_pending = false; radio_apply_retry_at = 0; radio_apply_failures = 0; @@ -8938,6 +8981,12 @@ bool MyMesh::hasPendingWork() const { || sign_data != NULL || hasQueuedWorkDue() || hasRetryWorkDue() || (saved_radio_apply_pending +#if COMPANION_FEATURE_TEMP_RADIO + // Match loop()'s restore gate: a lease-blocked rollback is not + // runnable work. The lease's own timers below wake it when due. + && !_temp_radio_applied && _temp_radio_set_at == 0 + && _temp_radio_revert_at == 0 +#endif && (!radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at))) || contact_write_needs_polling || (emergency_client_repeat_packet != NULL diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index c4dd9cc4f..649c2f6bb 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -384,6 +384,8 @@ private: bool applyAndSaveFemRxGain(bool enabled); bool applyAndSaveFemTxGain(bool enabled); bool applyAndSaveRxBoostedGain(bool enabled); + enum class RadioSettingResult { Saved, RadioRejected, SaveFailed }; + RadioSettingResult applyAndSaveTxPower(int8_t power); bool handleCadCommand(const char* command, char* reply, size_t reply_size); bool saveBluetoothNameOverride(const char* name); bool applyAndSaveBluetoothName(const char* value, char* reply, diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 1ab03742c..c783eb20a 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -27,6 +27,13 @@ namespace mesh { #define MIN_CAD_FAIL_RETRY_DELAY_MS 50UL #define MIN_CAD_FAIL_MAX_DURATION_MS 500UL +#define RADIO_PREPARE_BUSY_GRACE_MS 8000UL + +static uint32_t validPrepareBusyAirtime(uint32_t airtime) { + // RadioLib estimates originate as 32-bit microseconds. An impossible or + // error-sentinel estimate must not retain a failed packet for days. + return airtime <= UINT32_MAX / 1000 ? airtime : 0; +} static uint32_t scaleCADDelayForQueue(uint32_t normal_delay, int ready_count, uint32_t minimum_delay) { @@ -65,6 +72,7 @@ void Dispatcher::begin() { last_meshcore_recv_millis = 0; _err_flags = 0; outbound_radio_retry_at = 0; + outbound_radio_prepare_deadline = 0; outbound_radio_retry_pending = false; outbound_radio_retry_used = false; outbound_cancellation = OutboundCancellation::None; @@ -192,6 +200,7 @@ bool Dispatcher::scheduleOutboundRadioRetry() { outbound_radio_retry_used = true; outbound_radio_retry_pending = true; + outbound_radio_prepare_deadline = 0; outbound_radio_retry_at = futureMillis(getCADFailRetryDelay()); MESH_DEBUG_PRINTLN("%s Dispatcher: retrying packet once after radio fault", getLogDateTime()); @@ -216,6 +225,7 @@ void Dispatcher::failOutboundTransmit() { outbound = NULL; outbound_radio_retry_pending = false; outbound_radio_retry_used = false; + outbound_radio_prepare_deadline = 0; outbound_cancellation = OutboundCancellation::None; } @@ -377,9 +387,33 @@ void Dispatcher::loop() { const auto prepared = _radio->prepareTransmitProfile(outbound->radio_profile, outbound->radio_reply && outbound->radio_reply_force); if (prepared == RadioParamApplyResult::BUSY) { + if (outbound_radio_prepare_deadline == 0) { + // A frame already being received may legitimately own either scan + // profile. Allow its longest full-packet airtime plus recovery grace. + // This timer covers only prepare BUSY, not ordinary CAD backoff. + uint32_t airtime = validPrepareBusyAirtime(_radio->getProfileAirtime(0, MAX_TRANS_UNIT)); + if (isDualRadioActive()) { + const uint32_t other = validPrepareBusyAirtime(_radio->getProfileAirtime(1, MAX_TRANS_UNIT)); + if (other > airtime) airtime = other; + } + const uint32_t timeout = airtime * 3 / 2 + RADIO_PREPARE_BUSY_GRACE_MS; + outbound_radio_prepare_deadline = _ms->getMillis() + timeout; + if (outbound_radio_prepare_deadline == 0) outbound_radio_prepare_deadline = 1; + } else if (millisHasNowPassed(outbound_radio_prepare_deadline)) { + // Retaining outbound disables the non-RX watchdog, and this backoff + // bypasses the liveness watchdog. Release ownership before recovery + // so even a failed reset cannot pin all later queue entries forever. + _err_flags |= ERR_EVENT_RADIO_WATCHDOG; + MESH_DEBUG_PRINTLN("%s Dispatcher: radio retry profile remained busy", getLogDateTime()); + failOutboundTransmit(); + _radio->recoverRadio(true); + radio_nonrx_start = _ms->getMillis(); + return; + } outbound_radio_retry_at = futureMillis(10); return; } + outbound_radio_prepare_deadline = 0; if (prepared == RadioParamApplyResult::FAILED || !isPacketRadioCurrent(outbound) || !allowPacketTransmit(outbound)) { @@ -1027,7 +1061,8 @@ bool Dispatcher::sendPacket(Packet* packet, uint8_t priority, uint32_t delay_mil // Utility function -- handles the case where millis() wraps around back to zero // 2's complement arithmetic will handle any unsigned subtraction up to HALF the word size (32-bits in this case) bool Dispatcher::millisHasNowPassed(unsigned long timestamp) const { - return (long)(_ms->getMillis() - timestamp) > 0; + // millis is a 32-bit counter even on native hosts where long is 64 bits. + return (int32_t)((uint32_t)_ms->getMillis() - (uint32_t)timestamp) > 0; } unsigned long Dispatcher::futureMillis(int millis_from_now) const { diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 80587f2b7..3b4d605df 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -281,6 +281,7 @@ class Dispatcher { Packet* outbound; // current outbound packet unsigned long outbound_expiry, outbound_start, total_air_time, rx_air_time; unsigned long outbound_radio_retry_at; + uint32_t outbound_radio_prepare_deadline = 0; #ifdef RADIO_LIVENESS_SOFT_ONLY unsigned long rx_watchdog_window_start; #else diff --git a/src/Mesh.cpp b/src/Mesh.cpp index eac90ecaf..d5abf5373 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -328,8 +328,11 @@ void Mesh::loop() { for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; ++i) { if (!_flood_retries[i].active) continue; const auto* packet = _flood_retries[i].queued ? _flood_retries[i].packet : _flood_retries[i].trigger_packet; - if ((_flood_retries[i].waiting_final_echo && _flood_retries[i].packet - && !isPacketRadioCurrent(_flood_retries[i].packet)) + // Final flood echoes also retain metadata only. Release the old + // session's retry key even though there is no packet left to inspect. + const uint8_t profile = i / MAX_FLOOD_RETRY_SLOTS; + if ((_flood_retries[i].waiting_final_echo + && _retry_radio_generations[profile] != p->generation[profile]) || (packet && !isPacketRadioCurrent(packet))) retireFloodRetrySlot(i); } for (int i = _mgr->getOutboundTotal() - 1; i >= 0; --i) { diff --git a/src/helpers/FloodAdvertLimiter.cpp b/src/helpers/FloodAdvertLimiter.cpp index a428205b7..022ba0187 100644 --- a/src/helpers/FloodAdvertLimiter.cpp +++ b/src/helpers/FloodAdvertLimiter.cpp @@ -130,6 +130,9 @@ void FloodAdvertLimiter::observe(const uint8_t* key, const uint8_t* hash, uint8_ // The normal level belongs to the prefix, including its shortest RX path. // Keep that minimum on every colliding full key for consistent window-close // abuse accounting, but never combine their received counts into a strike. + // Be conservative once BAD is set: a later shorter path can raise the normal + // quota, but cannot retract an already-triggered penalty or recorded violation. + // Only seven-day recovery, an explicit clear, or reboot removes that penalty. if (hops < entry->min_hops) entry->min_hops = hops; for (size_t i = 0; i < _capacity; ++i) { if ((_entries[i].flags & ACTIVE) && memcmp(_entries[i].key, key, PREFIX_BYTES) == 0 diff --git a/src/helpers/ota/OtaManager.cpp b/src/helpers/ota/OtaManager.cpp index c8ae4d2a3..38d60560c 100644 --- a/src/helpers/ota/OtaManager.cpp +++ b/src/helpers/ota/OtaManager.cpp @@ -861,7 +861,8 @@ void OtaManager::invalidateCatalogSeeder(const uint8_t* seeder) { row.last_ms = 0; for (uint8_t k = 0; k < row.n_seeders; k++) { if (row.seeder_have[k] > row.have_max) row.have_max = row.seeder_have[k]; - if (row.seeder_last_ms[k] > row.last_ms) row.last_ms = row.seeder_last_ms[k]; + if (k == 0 || (uint32_t)(_now_ms - row.seeder_last_ms[k]) + < (uint32_t)(_now_ms - row.last_ms)) row.last_ms = row.seeder_last_ms[k]; } i++; } @@ -888,7 +889,9 @@ void OtaManager::handleAdv(const uint8_t* m, uint16_t n) { int slot = -1, lru = 0; // find/insert the source (LRU evict) for (int i = 0; i < _n_src; i++) { if (memcmp(_sources[i].seeder, a.seeder_id, 4) == 0) { slot = i; break; } - if (_sources[i].last_ms < _sources[lru].last_ms) lru = i; + // Compare elapsed ages: raw millis ordering reverses when uptime wraps. + if ((uint32_t)(_now_ms - _sources[i].last_ms) + > (uint32_t)(_now_ms - _sources[lru].last_ms)) lru = i; } bool fresh = (slot < 0); if (fresh) { @@ -994,18 +997,25 @@ void OtaManager::handleHave(const uint8_t* m, uint16_t n) { if (memcmp(_sources[i].digest, hv.set_digest, 4) != 0) return; break; } - // PASSIVE: every node caches rows it overhears. A source is catalogued only after EVERY advertised - // fragment arrived; otherwise a timed recovery QUERY asks for just the missing bitmap. + // PASSIVE: every node caches rows it overhears. Only complete canonical pages + // count toward discovery: filtered replies have the same fragment positions, + // but omit rows and cannot suppress a later unfiltered query for those pages. + const uint8_t per = (uint8_t)((MAX_PACKET_PAYLOAD - 12) / OTA_HAVE_ROW_BYTES); for (uint8_t i = 0; i < _n_src; i++) { Source& s = _sources[i]; if (memcmp(s.seeder, hv.seeder_id, 4) != 0 || memcmp(s.digest, hv.set_digest, 4) != 0) continue; - if (s.have_total != hv.frag_total) { - s.have_total = hv.frag_total; + const uint8_t total = (uint8_t)((s.n_motas + per - 1) / per); + if (s.have_total != total) { + s.have_total = total; s.have_mask = 0; s.have_catalog = false; } - s.have_mask |= 1UL << hv.frag_idx; - uint32_t full = hv.frag_total >= 32 ? UINT32_MAX : ((1UL << hv.frag_total) - 1); + if (hv.frag_total == total && hv.frag_idx < total) { + const uint16_t remaining = s.n_motas - (uint16_t)hv.frag_idx * per; + const uint8_t expected_rows = remaining < per ? (uint8_t)remaining : per; + if (hv.n_rows == expected_rows) s.have_mask |= 1UL << hv.frag_idx; + } + uint32_t full = total >= 32 ? UINT32_MAX : ((1UL << total) - 1); s.have_catalog = s.have_mask == full; if (s.have_catalog) { s.query_pending = false; @@ -1026,7 +1036,8 @@ void OtaManager::handleHave(const uint8_t* m, uint16_t n) { int slot = -1, lru = 0; // upsert into the catalog (dedup by mid) for (int i = 0; i < _n_cat; i++) { if (memcmp(catalog[i].mid, mid, 4) == 0) { slot = i; break; } - if (catalog[i].last_ms < catalog[lru].last_ms) lru = i; + if ((uint32_t)(_now_ms - catalog[i].last_ms) + > (uint32_t)(_now_ms - catalog[lru].last_ms)) lru = i; } if (slot < 0) { if (_n_cat >= catalogCapacity() && expandCatalog()) catalog = catalogData(); @@ -1056,7 +1067,8 @@ void OtaManager::handleHave(const uint8_t* m, uint16_t n) { c.have_max = 0; c.last_ms = 0; for (uint8_t k = 0; k < c.n_seeders; k++) { if (c.seeder_have[k] > c.have_max) c.have_max = c.seeder_have[k]; - if (c.seeder_last_ms[k] > c.last_ms) c.last_ms = c.seeder_last_ms[k]; + if (k == 0 || (uint32_t)(_now_ms - c.seeder_last_ms[k]) + < (uint32_t)(_now_ms - c.last_ms)) c.last_ms = c.seeder_last_ms[k]; } if (wantRow(mid, target, fwver, codec, flags)) startFetch(mid, target); } diff --git a/src/helpers/ota/OtaStoreFlashNrf52.cpp b/src/helpers/ota/OtaStoreFlashNrf52.cpp index e6052eec3..641bcbf91 100644 --- a/src/helpers/ota/OtaStoreFlashNrf52.cpp +++ b/src/helpers/ota/OtaStoreFlashNrf52.cpp @@ -69,6 +69,7 @@ void OtaStoreFlashNrf52::reset_session() { _ram_len = 0; _total = 0; _hybrid = false; + _preserve_payload_pages = false; _pay_idx = 0; _flushed = false; _io_ok = true; @@ -309,7 +310,14 @@ uint8_t* OtaStoreFlashNrf52::write_slot(uint32_t pos) { if (page == 0) return _meta_page + pos; if (page > _pay_idx) { if (!flush_pay()) return nullptr; - _pay_idx = page; memset(_pay_page, 0xFF, PG); // advance, fresh page + _pay_idx = page; + // A resumed page can contain verified blocks on either side of this hole. + // Those blocks retain their leaf markers, so erasing their cached bytes here + // would silently corrupt them when this page is checkpointed or finalized. + if (_preserve_payload_pages) + memcpy(_pay_page, (const uint8_t*)(uintptr_t)(_write_start + page * PG), PG); + else + memset(_pay_page, 0xFF, PG); // fresh capture, no committed bytes yet } if (page == _pay_idx) return _pay_page + (pos - page * PG); return nullptr; // page < _pay_idx: already flushed @@ -598,6 +606,7 @@ bool OtaStoreFlashNrf52::reopen() { _ram_len = 0; _total = total; _hybrid = false; + _preserve_payload_pages = true; memcpy(_meta_page, p, PG); // load page 0 (header+manifest+leaves) into RAM to continue // The tail is deferred until finalize and can still hold old flash bytes. // Reconstruct only fixed framing; the manager must verify the manifest and diff --git a/src/helpers/ota/OtaStoreFlashNrf52.h b/src/helpers/ota/OtaStoreFlashNrf52.h index 6af60a774..65a070398 100644 --- a/src/helpers/ota/OtaStoreFlashNrf52.h +++ b/src/helpers/ota/OtaStoreFlashNrf52.h @@ -22,6 +22,8 @@ // (set_meta_size enforces it; true for <= ~979 blocks, i.e. any realistic MeshCore image). // - `_pay_page` is a single sliding buffer for one payload page (index >= 1). It advances // monotonically with the (mostly in-order) block stream and flushes the page it leaves behind. +// After reopen, newly opened pages load their existing flash bytes to preserve committed blocks +// which share a page with missing blocks; only fresh captures initialize pages to erased bytes. // Rare out-of-order writes to an already-flushed page go straight to flash as a safe read-modify- // write (flash_nrf5x erases before programming, so re-touching a page never violates the // writes-per-word limit - it just costs one extra erase). @@ -42,6 +44,7 @@ class OtaStoreFlashNrf52 : public OtaStore { uint32_t _ram_len = 0; // logical suffix backed by the fixed SRAM arena uint32_t _total = 0; // staged container size (0 = none) bool _hybrid = false; // flash prefix + reset-retained SRAM suffix + bool _preserve_payload_pages = false; // reopened pages can contain committed blocks around later holes bool _flushed = false; // finalize() committed everything to flash bool _io_ok = true; // cleared on a failed/out-of-bounds flash write or readback mismatch bool _planned_bootloader = false; // manifest-kind decision made before begin()/any erase diff --git a/test/fixtures/ota_catalog_lifetime/test.cpp b/test/fixtures/ota_catalog_lifetime/test.cpp new file mode 100644 index 000000000..2655a2d68 --- /dev/null +++ b/test/fixtures/ota_catalog_lifetime/test.cpp @@ -0,0 +1,183 @@ +#include +#include +#include +#include +#include +#include +using namespace mesh::ota; + +static bool send(void*, const uint8_t*, uint16_t, bool) { return true; } + +static void advert(OtaManager& manager, uint32_t source, uint8_t count = 1) { + AdvMsg message{}; + wr_u32le(message.seeder_id, source); + wr_u32le(message.set_digest, 123); + message.n_motas = count; + uint8_t wire[24]; + manager.on_message(wire, encode_adv(wire, sizeof(wire), message)); +} + +static void have(OtaManager& manager, uint32_t source, uint32_t mid) { + uint8_t row[OTA_HAVE_ROW_BYTES] = {}; + wr_u32le(row, mid); + wr_u32le(row + 4, 1234); + row[12] = CODEC_FULL; row[13] = MFLAG_FULL; + row[14] = 1; + HaveMsg message{}; + wr_u32le(message.seeder_id, source); + wr_u32le(message.set_digest, 123); + message.frag_total = 1; message.n_rows = 1; message.rows = row; + uint8_t wire[64]; + manager.on_message(wire, encode_have(wire, sizeof(wire), message)); +} + +static const OtaManager::CatRow* row(OtaManager& manager, uint32_t mid) { + for (unsigned i = 0; i < manager.catalogCount(); ++i) { + const auto* entry = manager.catalogRow(i); + if (rd_u32le(entry->mid) == mid) return entry; + } + return nullptr; +} + +using Messages = std::vector>; +static bool capture(void* context, const uint8_t* message, uint16_t length, bool) { + static_cast(context)->emplace_back(message, message + length); + return true; +} + +class MixedCatalogSource : public MotaSource { + uint8_t _count; +public: + explicit MixedCatalogSource(uint8_t count) : _count(count) {} + uint8_t count() override { return _count; } + bool describe(uint8_t index, MotaDesc& out) override { + if (index >= _count) return false; + wr_u32le(out.mid, 100u + index); + // Three pages: fully matching, partially matching, and empty for target 1. + out.target_id = _count == 2 ? index + 1u + : index < 10 ? 1u : index < 20 ? 1u + index % 2u : 2u; + out.fw_version = 1; out.codec_id = CODEC_FULL; out.flags = MFLAG_FULL; + out.block_size_log2 = 7; out.block_count = 1; + out.leaves_off = 8 + MOTA_MFL; out.payload_off = out.leaves_off + 4; + out.payload_size = 128; out.total_size = out.payload_off + out.payload_size + 5; + return true; + } + bool read(uint8_t, uint32_t, uint8_t*, uint32_t) override { return false; } +}; + +static void filtered_catalog_remains_queryable(int scenario, bool automatic) { + Messages from_server, from_client; + OtaManager server, client; + server.begin(0, capture, &from_server); + client.begin(0, capture, &from_client); + client.set_archive_interest(automatic); + uint8_t sid[4] = {1, 2, 3, 4}; server.set_seeder_id(sid); + MixedCatalogSource source(scenario == 4 ? 2 : 24); + assert(server.add_source(&source)); + server.announce(); + assert(from_server.size() == 1); + AdvMsg adv{}; + assert(decode_adv(from_server[0].data(), from_server[0].size(), adv)); + if (scenario != 8) client.on_message(from_server[0].data(), from_server[0].size()); + from_server.clear(); + QueryMsg query{}; + memcpy(query.seeder_id, sid, 4); memcpy(query.set_digest, adv.set_digest, 4); + query.filter_target = scenario == 6 ? 99 : 1; + uint8_t wire[256]; + server.on_message(wire, encode_query(wire, sizeof(wire), query)); + Messages filtered = from_server; + if (scenario == 7) { + // Legacy filtered servers packed only the matching rows into fewer pages. + filtered.pop_back(); + for (auto& message : filtered) { + HaveMsg legacy{}; + assert(decode_have(message.data(), message.size(), legacy)); + legacy.frag_total = 2; + const uint16_t length = encode_have(wire, sizeof(wire), legacy); + message.assign(wire, wire + length); + } + } + for (const auto& message : filtered) client.on_message(message.data(), message.size()); + assert(client.catalogCount() == (scenario == 4 ? 1 : scenario == 6 ? 0 : 15)); + if (scenario == 8) { + // Passive rows arriving before a beacon must not confer completeness on its new digest. + client.on_message(wire, encode_adv(wire, sizeof(wire), adv)); + assert(client.catalogCount() == 0); + } + if (automatic) { + client.set_clock(5000); + client.loop(); + } else { + client.queryAll(); + } + assert(from_client.size() == 1); + QueryMsg recovery{}; + assert(decode_query(from_client[0].data(), from_client[0].size(), recovery)); + assert(recovery.filter_target == 0); + assert(recovery.want_fragments == (scenario == 4 ? 1u : scenario == 5 ? 6u : scenario == 8 ? 0u : 7u)); + from_server.clear(); + server.on_message(from_client[0].data(), from_client[0].size()); + for (const auto& message : from_server) client.on_message(message.data(), message.size()); + assert(client.catalogCount() == source.count()); + from_client.clear(); + // Late filtered packets must not downgrade already-complete pages either. + for (const auto& message : filtered) client.on_message(message.data(), message.size()); + client.queryAll(); + client.set_clock(5000 + OTA_CATALOG_RETRY_MS + 1); + client.loop(); + assert(from_client.empty()); +} + +int main(int argc, char** argv) { + assert(argc == 3); + const int scenario = atoi(argv[1]); + const bool rollover = atoi(argv[2]) != 0; + if (scenario >= 4) { + filtered_catalog_remains_queryable(scenario, rollover); + return 0; + } + const uint32_t old = rollover ? 0xFFFF0000u : 1000u; + const uint32_t recent = rollover ? 0u : 100000u; + OtaManager manager; + manager.begin(1234, send, nullptr); + if (scenario == 0) { + for (unsigned i = 1; i <= OTA_MAX_SOURCES; ++i) { + manager.set_clock(old + i * 100); + advert(manager, i); have(manager, i, i); + } + manager.set_clock(recent); + advert(manager, 1); // freshest source is no longer the eviction victim + manager.set_clock(recent + 10); + advert(manager, OTA_MAX_SOURCES + 1); + assert(row(manager, 1)); + assert(!row(manager, 2)); + assert(manager.sourceCount() == OTA_MAX_SOURCES); + } else if (scenario == 1) { + for (unsigned i = 1; i <= OTA_MAX_CATALOG; ++i) { + manager.set_clock(old + i * 100); + have(manager, 1, i); + } + manager.set_clock(recent); + have(manager, 1, 1); + manager.set_clock(recent + 10); + have(manager, 1, OTA_MAX_CATALOG + 1); + assert(row(manager, 1)); + assert(!row(manager, 2)); + assert(manager.catalogCount() == OTA_MAX_CATALOG); + } else { + manager.set_clock(old); + have(manager, 1, 1); + manager.set_clock(recent); + have(manager, 2, 1); + if (scenario == 2) { + assert(row(manager, 1)->last_ms == recent); + } else { + manager.set_clock(recent + 10); + have(manager, 3, 1); + manager.set_clock(recent + 20); + advert(manager, 3, 0); // withdrawal recomputes newest surviving source + assert(row(manager, 1)->last_ms == recent); + assert(row(manager, 1)->n_seeders == 2); + } + } +} diff --git a/test/fixtures/ota_store_flash_nrf52_hybrid/test_ota_store_flash_nrf52_hybrid.cpp b/test/fixtures/ota_store_flash_nrf52_hybrid/test_ota_store_flash_nrf52_hybrid.cpp index 1d8c77f98..69a828e04 100644 --- a/test/fixtures/ota_store_flash_nrf52_hybrid/test_ota_store_flash_nrf52_hybrid.cpp +++ b/test/fixtures/ota_store_flash_nrf52_hybrid/test_ota_store_flash_nrf52_hybrid.cpp @@ -286,6 +286,90 @@ static void dirty_flash_partial_checkpoint_finishes(bool reboot) { CHECK(std::memcmp(active.data(), bytes.data(), total) == 0); } +static void resumed_partial_payload_pages_preserve_committed_prefixes() { + using namespace mesh::ota; + const uint32_t total = 4u * MOTA_NRF52_FLASH_PAGE; + for (uint32_t prefix : {MOTA_NRF52_FLASH_PAGE + 1u, + MOTA_NRF52_FLASH_PAGE + 1024u, + 2u * MOTA_NRF52_FLASH_PAGE, + 2u * MOTA_NRF52_FLASH_PAGE + 227u}) { + reset_target_memory(); + auto bytes = patterned_container(total); + std::memcpy(bytes.data() + total - 5u, MOTA_TRAILER, 5u); + { + OtaStoreFlashNrf52 original; + CHECK(original.begin(total)); + CHECK(original.write(0, bytes.data(), prefix)); + CHECK(original.write(total - 5u, MOTA_TRAILER, 5u)); + original.checkpoint(); + } + OtaStoreFlashNrf52 resumed; + CHECK(resumed.reopen()); + CHECK(resumed.write(prefix, bytes.data() + prefix, total - prefix - 5u)); + CHECK(resumed.finalize()); + std::vector output(total); + CHECK(resumed.read(0, output.data(), total)); + CHECK(output == bytes); + CHECK(std::memcmp(resumed.data(), bytes.data(), total) == 0); + } +} + +static void resumed_out_of_order_writes_survive_another_checkpoint() { + using namespace mesh::ota; + reset_target_memory(); + const uint32_t total = 4u * MOTA_NRF52_FLASH_PAGE; + auto bytes = patterned_container(total); + std::memcpy(bytes.data() + total - 5u, MOTA_TRAILER, 5u); + { + OtaStoreFlashNrf52 original; + CHECK(original.begin(total)); + CHECK(original.write(0, bytes.data(), total)); + original.checkpoint(); + } + const uint32_t offsets[] = {MOTA_NRF52_FLASH_PAGE + 11u, + 3u * MOTA_NRF52_FLASH_PAGE + 17u, + 2u * MOTA_NRF52_FLASH_PAGE + 25u, + MOTA_NRF52_FLASH_PAGE + 53u}; + { + OtaStoreFlashNrf52 resumed; + CHECK(resumed.reopen()); + for (uint32_t offset : offsets) { + for (unsigned i = 0; i < 13; ++i) bytes[offset + i] ^= 0x5A; + CHECK(resumed.write(offset, bytes.data() + offset, 13)); + } + resumed.checkpoint(); + } + OtaStoreFlashNrf52 resumed_again; + CHECK(resumed_again.reopen()); + // Cross a page boundary after the second restart, preserving bytes on both sides. + const uint32_t offset = 2u * MOTA_NRF52_FLASH_PAGE - 7u; + for (unsigned i = 0; i < 19; ++i) bytes[offset + i] ^= 0xA5; + CHECK(resumed_again.write(offset, bytes.data() + offset, 19)); + CHECK(resumed_again.finalize()); + std::vector output(total); + CHECK(resumed_again.read(0, output.data(), total)); + CHECK(output == bytes); + CHECK(std::memcmp(resumed_again.data(), bytes.data(), total) == 0); +} + +static void fresh_begin_after_reopen_does_not_retain_old_payload_bytes() { + using namespace mesh::ota; + reset_target_memory(); + const uint32_t total = 3u * MOTA_NRF52_FLASH_PAGE; + const auto bytes = patterned_container(total); + OtaStoreFlashNrf52 store; + CHECK(store.begin(total)); + CHECK(store.write(0, bytes.data(), total)); + store.checkpoint(); + CHECK(store.reopen()); + CHECK(store.begin(total)); + const uint32_t offset = MOTA_NRF52_FLASH_PAGE + 128u; + CHECK(store.write(offset, bytes.data() + offset, 16)); + uint8_t preceding[128]; + CHECK(store.read(MOTA_NRF52_FLASH_PAGE, preceding, sizeof(preceding))); + for (uint8_t byte : preceding) CHECK(byte == 0xFF); +} + int main() { map_target_region(FLASH_MAP_START, FLASH_MAP_SIZE); map_target_region(RAM_MAP_START, RAM_MAP_SIZE); @@ -303,6 +387,12 @@ int main() { std::puts("PASS: dirty flash partial checkpoint resumes and finishes"); dirty_flash_partial_checkpoint_finishes(false); std::puts("PASS: dirty flash partial checkpoint continues without reboot"); - std::puts("7 OtaStoreFlashNrf52 hybrid lifecycle checks passed"); + resumed_partial_payload_pages_preserve_committed_prefixes(); + std::puts("PASS: resumed payload pages preserve checkpointed prefixes"); + resumed_out_of_order_writes_survive_another_checkpoint(); + std::puts("PASS: resumed out-of-order writes survive repeated checkpoints"); + fresh_begin_after_reopen_does_not_retain_old_payload_bytes(); + std::puts("PASS: fresh captures after reopen discard stale payload bytes"); + std::puts("10 OtaStoreFlashNrf52 hybrid lifecycle checks passed"); return 0; } diff --git a/test/fixtures/radio_profiles/mesh_tests.h b/test/fixtures/radio_profiles/mesh_tests.h index 3b0e7cc5b..e60b93f06 100644 --- a/test/fixtures/radio_profiles/mesh_tests.h +++ b/test/fixtures/radio_profiles/mesh_tests.h @@ -6,19 +6,29 @@ class DualProfileTestRadio : public RetryCodingRateRadio { int busy_profile = -1; bool fail_next_send = false; bool hold_prepare_busy = false; + bool receive_mode = true, recovery_succeeds = true, clear_prepare_on_recovery = false; bool carrier = false, carrier_on_receive = false; unsigned carrier_services = 0, recoveries = 0; bool isCarrierWaveActive() const override { return carrier; } - bool isInRecvMode() const override { return !carrier && !sending; } + bool isInRecvMode() const override { return receive_mode && !carrier && !sending; } void loop() override { ++carrier_services; } - bool recoverRadio(bool) override { ++recoveries; return true; } + bool recoverRadio(bool) override { + ++recoveries; + if (recovery_succeeds && clear_prepare_on_recovery) { + hold_prepare_busy = false; + receive_mode = true; + } + return recovery_succeeds; + } uint32_t estimated_airtime = 10; + uint32_t secondary_airtime = 0; float receive_score = 0; float packetScore(float, int) override { return receive_score; } TraceTestClock* sensing_clock = nullptr; uint32_t sensing_delay = 0; bool distinguish_airtime = false; uint32_t getProfileAirtime(uint8_t profile, int bytes, uint8_t cr = 0) override { + if (profile == 1 && secondary_airtime) return secondary_airtime; return distinguish_airtime ? (profile ? 70 : 900) : mesh::Radio::getProfileAirtime(profile, bytes, cr); } @@ -79,6 +89,7 @@ class DualProfileTestMesh : public RetryCodingRateMesh { bool cross_filter_allows = true; float ota_speed = 1.0f; bool suppress_tx = false; + bool enqueue_on_send_fail = false; unsigned send_failures = 0; unsigned send_completions = 0; void onSendComplete(mesh::Packet* packet) override { @@ -88,11 +99,24 @@ class DualProfileTestMesh : public RetryCodingRateMesh { unsigned tx_failure_logs = 0; void logTxFail(mesh::Packet*, int) override { ++tx_failure_logs; } unsigned direct_successes = 0, direct_failures = 0; + unsigned flood_successes = 0, flood_failures = 0; void onDirectRetrySucceeded(const uint8_t*, uint8_t, int8_t) override { ++direct_successes; } void onDirectRetryFailed(const uint8_t*, uint8_t) override { ++direct_failures; } + void onFloodRetryEvent(const char* event, const mesh::Packet*, uint32_t, uint8_t) override { + if (strcmp(event, "good") == 0) ++flood_successes; + if (strcmp(event, "failure") == 0) ++flood_failures; + } void onSendFail(mesh::Packet* packet) override { ++send_failures; RetryCodingRateMesh::onSendFail(packet); + if (enqueue_on_send_fail) { + enqueue_on_send_fail = false; + auto* replacement = obtainNewPacket(); + if (replacement) { + *replacement = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT); + sendPacket(replacement, 0); + } + } } bool allowPacketTransmit(const mesh::Packet* packet) const override { return !suppress_tx && RetryCodingRateMesh::allowPacketTransmit(packet); @@ -268,6 +292,157 @@ TEST_F(DualProfileTest, RadioFaultRetryRetainsBoundedBusyEscapeAndSingleRetryLim EXPECT_TRUE(radio.transmissions.empty()); } +TEST_F(DualProfileTest, StuckRadioRetryPreparationReleasesPacketAndRecoversQueue) { + node.flood_attempts = 0; + radio.config.cross = mesh::RadioCrossMode::Off; + radio.fail_next_send = true; + ASSERT_NE(nullptr, queue()); + tick(); ASSERT_TRUE(node.hasOutbound()); + ASSERT_NE(nullptr, queue()); // later work must not inherit the stuck packet + radio.hold_prepare_busy = true; + radio.receive_mode = false; + radio.clear_prepare_on_recovery = true; + tick(500); // arm only after the first BUSY preparation + tick(8000); + EXPECT_TRUE(node.hasOutbound()); + EXPECT_EQ(radio.recoveries, 0u); + tick(20); + EXPECT_FALSE(node.hasOutbound()); + EXPECT_EQ(node.send_failures, 1u); + EXPECT_EQ(node.tx_failure_logs, 1u); + EXPECT_EQ(radio.recoveries, 1u); + EXPECT_EQ(manager.getOutboundTotal(), 1); + EXPECT_EQ(manager.getFreeCount(), 39); + tick(); ASSERT_TRUE(radio.sending); + radio.complete = true; tick(); + EXPECT_EQ(manager.getFreeCount(), 40); +} + +TEST_F(DualProfileTest, FailedBusyRecoveryDoesNotKeepRetryOwnershipOrDisableWatchdogs) { + node.flood_attempts = 0; + radio.config.cross = mesh::RadioCrossMode::Off; + radio.fail_next_send = true; + ASSERT_NE(nullptr, queue()); + tick(); ASSERT_TRUE(node.hasOutbound()); + radio.hold_prepare_busy = true; + radio.receive_mode = false; + radio.recovery_succeeds = false; + tick(500); + tick(9000); + EXPECT_FALSE(node.hasOutbound()); + EXPECT_EQ(node.send_failures, 1u); + EXPECT_EQ(manager.getFreeCount(), 40); + EXPECT_EQ(radio.recoveries, 1u); + tick(9000); // the ordinary non-RX watchdog is no longer suppressed + EXPECT_EQ(radio.recoveries, 2u); + ASSERT_NE(nullptr, queue()); + radio.recovery_succeeds = radio.clear_prepare_on_recovery = true; + tick(9000); + EXPECT_EQ(radio.recoveries, 3u); + ASSERT_TRUE(radio.sending); + radio.complete = true; tick(); + EXPECT_EQ(manager.getFreeCount(), 40); +} + +TEST_F(DualProfileTest, BusyTimeoutPreservesPacketsEnqueuedByFailureCallback) { + node.flood_attempts = 0; + radio.config.cross = mesh::RadioCrossMode::Off; + radio.fail_next_send = true; + ASSERT_NE(nullptr, queue()); + tick(); ASSERT_TRUE(node.hasOutbound()); + node.enqueue_on_send_fail = true; + radio.hold_prepare_busy = true; + radio.clear_prepare_on_recovery = true; + tick(500); + tick(9000); + EXPECT_FALSE(node.hasOutbound()); + EXPECT_EQ(node.send_failures, 1u); + EXPECT_EQ(manager.getOutboundTotal(), 1); + EXPECT_EQ(manager.getFreeCount(), 39); + tick(); ASSERT_TRUE(radio.sending); + radio.complete = true; tick(); + EXPECT_EQ(node.send_completions, 1u); + EXPECT_EQ(manager.getFreeCount(), 40); +} + +TEST_F(DualProfileTest, InvalidPrepareAirtimeDoesNotCreateUnboundedDeadline) { + node.flood_attempts = 0; + radio.config.cross = mesh::RadioCrossMode::Off; + radio.fail_next_send = true; + ASSERT_NE(nullptr, queue()); + tick(); ASSERT_TRUE(node.hasOutbound()); + radio.estimated_airtime = UINT32_MAX; + radio.secondary_airtime = 20000; + radio.hold_prepare_busy = true; + tick(500); + tick(30000); // invalid primary must not erase the secondary's valid RX grace + EXPECT_TRUE(node.hasOutbound()); + EXPECT_EQ(node.send_failures, 0u); + tick(8001); + EXPECT_FALSE(node.hasOutbound()); + EXPECT_EQ(node.send_failures, 1u); + EXPECT_EQ(radio.recoveries, 1u); + EXPECT_EQ(manager.getFreeCount(), 40); +} + +TEST_F(DualProfileTest, FinitePrepareBusyAndCadBackoffHaveSeparateAllowances) { + node.flood_attempts = 0; + radio.config.cross = mesh::RadioCrossMode::Off; + radio.fail_next_send = true; + ASSERT_NE(nullptr, queue()); + tick(); + radio.hold_prepare_busy = true; + tick(500); + tick(5000); + radio.hold_prepare_busy = false; + radio.busy_profile = 0; + tick(1000); // successful preparation clears the first BUSY allowance + radio.hold_prepare_busy = true; + tick(500); + tick(7000); // exceeds the original deadline, but not the new allowance + EXPECT_TRUE(node.hasOutbound()); + EXPECT_EQ(node.send_failures, 0u); + EXPECT_EQ(radio.recoveries, 0u); + radio.hold_prepare_busy = false; + radio.busy_profile = -1; + tick(500); ASSERT_TRUE(radio.sending); + radio.complete = true; tick(); + EXPECT_EQ(manager.getFreeCount(), 40); +} + +TEST_F(DualProfileTest, PrepareBusyAllowanceCoversLongFramesAndMillisWrap) { + TraceTestClock wrap_clock; + wrap_clock.now = UINT32_MAX - 1000UL; + DualProfileTestRadio wrap_radio; + StaticPoolPacketManager wrap_manager(40); + DualProfileTestMesh wrap_node(wrap_radio, wrap_clock, rng, rtc, wrap_manager, tables); + wrap_node.begin(); + wrap_node.flood_attempts = 0; + wrap_radio.config.cross = mesh::RadioCrossMode::Off; + wrap_radio.estimated_airtime = 20000; + wrap_radio.fail_next_send = true; + auto* packet = wrap_node.obtainNewPacket(); + ASSERT_NE(packet, nullptr); + *packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT); + ASSERT_TRUE(wrap_node.sendFlood(packet)); + auto advance = [&](uint32_t ms) { + wrap_clock.now = uint32_t(wrap_clock.now + ms); + wrap_node.loop(); + }; + advance(1); ASSERT_TRUE(wrap_node.hasOutbound()); + wrap_radio.hold_prepare_busy = true; + advance(500); + advance(30000); // a slow full frame must not hit an eight-second fixed timeout + EXPECT_TRUE(wrap_node.hasOutbound()); + EXPECT_EQ(wrap_radio.recoveries, 0u); + EXPECT_EQ(wrap_node.send_failures, 0u); + advance(8001); // 1.5 * 20 seconds + eight seconds, crossing millis wrap + EXPECT_FALSE(wrap_node.hasOutbound()); + EXPECT_EQ(wrap_radio.recoveries, 1u); + EXPECT_EQ(wrap_node.send_failures, 1u); + EXPECT_EQ(wrap_manager.getFreeCount(), 40); +} + TEST_F(DualProfileTest, EchoDuringRadioFaultBackoffCancelsDirectAndFloodRetry) { radio.config.cross = mesh::RadioCrossMode::Off; radio.receive_score = 1; // strong echo is processed without a score delay @@ -999,6 +1174,86 @@ TEST_F(DualProfileTest, ExpiredSecondaryPacketsAreDiscarded) { EXPECT_EQ(40, manager.getFreeCount()); } +TEST_F(DualProfileTest, FinalFloodEchoWaitOnlyExpiresWhenItsOwnProfileChanges) { + node.flood_attempts = 1; + for (uint8_t origin : {0, 1}) for (unsigned change = 0; change < 3; ++change) { + SCOPED_TRACE(testing::Message() << "origin=" << unsigned(origin) << " change=" << change); + radio.config.cross = mesh::RadioCrossMode::Off; + tick(); + auto* packet = node.obtainNewPacket(); ASSERT_NE(nullptr, packet); + *packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT); + packet->radio_profile = origin; + packet->tx_radio = origin ? mesh::RADIO_TX_SECONDARY : mesh::RADIO_TX_PRIMARY; + ASSERT_TRUE(node.sendFlood(packet)); + tick(); ASSERT_TRUE(radio.sending); + radio.complete = true; tick(); + ASSERT_EQ(manager.getOutboundTotal(), 1); + mesh::Packet echo = *manager.getOutboundByIdx(0); + echo.radio_bound = false; echo.radio_local = false; + echo.setPathHashCount(echo.getPathHashCount() + 1); + tick(10000); ASSERT_TRUE(radio.sending); + radio.complete = true; tick(); + ASSERT_EQ(manager.getOutboundTotal(), 0); + ASSERT_EQ(manager.getFreeCount(), 40); // final echo waits own metadata only + + const auto successes = node.flood_successes; + if (change == 1) { + radio.config.cross = mesh::RadioCrossMode::On; + } else if ((change == 2 ? origin : origin ^ 1) == 0) { + auto primary = radio.config.primary; primary.freq += 1; + radio.config.setPrimary(primary, false); + } else { + auto secondary = radio.config.secondary; secondary.params.freq += 1; + radio.config.setSecondary(secondary, false); + } + tick(); + node.receivePacket(&echo); + EXPECT_EQ(node.flood_successes, successes + (change == 2 ? 0 : 1)); + EXPECT_EQ(node.flood_failures, 0u); // profile changes are not link failures + EXPECT_EQ(manager.getFreeCount(), 40); + } +} + +TEST_F(DualProfileTest, ExpiredFinalFloodEchoCannotReserveKeyOnReplacementChannel) { + node.flood_attempts = 1; + radio.config.cross = mesh::RadioCrossMode::Off; + for (uint8_t origin : {0, 1}) { + SCOPED_TRACE(testing::Message() << "origin=" << unsigned(origin)); + auto send = [&] { + auto* packet = node.obtainNewPacket(); + EXPECT_NE(nullptr, packet); + if (!packet) return; + *packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT); + packet->radio_profile = origin; + packet->tx_radio = origin ? mesh::RADIO_TX_SECONDARY : mesh::RADIO_TX_PRIMARY; + EXPECT_TRUE(node.sendFlood(packet)); + }; + send(); + tick(); radio.complete = true; tick(); + tick(10000); radio.complete = true; tick(); + ASSERT_EQ(manager.getOutboundTotal(), 0); + ASSERT_EQ(manager.getFreeCount(), 40); + if (origin) { + const auto previous = radio.config.secondary; + radio.config.setSecondary({}, false); + tick(); + radio.config.setSecondary(previous, false); + } else { + auto primary = radio.config.primary; primary.freq += 1; + radio.config.setPrimary(primary, false); + } + tick(); + send(); // exact same logical packet, sent in a new radio session + tick(); ASSERT_TRUE(radio.sending); + radio.complete = true; tick(); + EXPECT_EQ(manager.getOutboundTotal(), 1); // fresh channel owns its own retry + node.cancelAllFloodRetries(); + tick(10000); + EXPECT_EQ(node.flood_failures, 0u); + EXPECT_EQ(manager.getFreeCount(), 40); + } +} + TEST_F(DualProfileTest, EchoOnOneProfileDoesNotCancelOtherProfileRetry) { ASSERT_NE(nullptr, queue()); tick(); radio.complete = true; tick(); diff --git a/test/test_companion_radio_settings_transaction.py b/test/test_companion_radio_settings_transaction.py new file mode 100644 index 000000000..d412e4925 --- /dev/null +++ b/test/test_companion_radio_settings_transaction.py @@ -0,0 +1,365 @@ +"""Execute Companion radio commands with production commit/recovery methods.""" +from pathlib import Path +import os +import re +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / 'examples/companion_radio/MyMesh.cpp' + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#include +#define MESH_DEBUG_PRINTLN(...) ((void)0) +#define MAX_LORA_TX_POWER 22 +#define COMPANION_FEATURE_TEMP_RADIO 0 +#define COMMAND_RADIO_APPLY_TIMEOUT_MS 3000 +@CODES@ +namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; } +using Apply = mesh::RadioParamApplyResult; +static uint32_t now_ms = 100; +struct Clock { uint32_t getMillis() const { return now_ms; } } clock_source; +struct BaseSerialInterface { + BaseSerialInterface* current = this; + BaseSerialInterface* disconnected = nullptr; + bool busy = false; + BaseSerialInterface* captureReplyRoute() { return current; } + bool isReplyRouteAvailable(BaseSerialInterface* route) { return route && route != disconnected; } + bool isReplyRouteWriteBusy(BaseSerialInterface*) { return busy; } +}; +struct Prefs { + float freq=915, bw=250; + uint8_t sf=7, cr=5, client_repeat=0, rx_ps_level=1, rx_ps_preamble=16; + uint32_t rx_ps_rx_us=700, rx_ps_sleep_us=25000; + int8_t tx_power_dbm=3; + bool rx_boosted_gain=false; +}; +void recalcRxPowerSavingFromLevel(uint8_t,uint8_t sf,float bw,uint8_t, + uint32_t* rx,uint32_t* sleep) { + *rx=sf*100; *sleep=uint32_t(bw*100); +} +struct Driver { + int8_t power=3; + unsigned calls=0; + std::deque accepted; + bool setTxPower(int8_t requested) { + ++calls; + bool ok=true; + if (!accepted.empty()) { ok=accepted.front(); accepted.pop_front(); } + if (ok) power=requested; + return ok; + } + void setRxBoostedGainMode(bool) {} +} radio_driver; +@RETRY_DELAY@ +struct MyMesh { + Prefs _prefs, durable, live; + BaseSerialInterface serial, original, other; + BaseSerialInterface* _serial=&serial; + Clock* _ms=&clock_source; + bool storage_accepts=true, _radio_available=true, outbound=false; + unsigned saves=0, applies=0; + std::deque accepted; + bool saved_radio_apply_pending=false, command_radio_apply_pending=false; + uint32_t radio_apply_retry_at=0, command_radio_apply_deadline=0; + uint8_t radio_apply_failures=0; + float command_radio_freq=0, command_radio_bw=0; + uint8_t command_radio_sf=0, command_radio_cr=0, command_radio_repeat=0; + BaseSerialInterface* command_radio_reply_route=nullptr; + uint8_t cmd_frame[180]={}; + struct Reply { uint8_t error; BaseSerialInterface* route; }; + std::vector replies; + @RESULT_ENUM@; + MyMesh() { serial.current=&original; radio_driver=Driver(); now_ms=100; } + uint32_t futureMillis(uint32_t delay) const { return now_ms+delay; } + bool millisHasNowPassed(uint32_t deadline) const { return int32_t(now_ms-deadline)>=0; } + bool hasOutbound() const { return outbound; } + bool isValidClientRepeatFreq(uint32_t) const { return true; } + bool savePrefs() { ++saves; if(!storage_accepts)return false; durable=_prefs; return true; } + void writeOKFrame(BaseSerialInterface* route=nullptr) { replies.push_back({0,route?route:serial.current}); } + void writeErrFrame(uint8_t code,BaseSerialInterface* route=nullptr) { replies.push_back({code,route?route:serial.current}); } + Apply tryApplyRadioParams(float freq,float bw,uint8_t sf,uint8_t cr) { + ++applies; + Apply result=Apply::APPLIED; + if(!accepted.empty()) { result=accepted.front(); accepted.pop_front(); } + if(result==Apply::APPLIED) { live.freq=freq;live.bw=bw;live.sf=sf;live.cr=cr; } + return result; + } + bool applySavedRadioParams(); + RadioSettingResult applyAndSaveTxPower(int8_t); + void finishRadioParamApply(float,float,uint8_t,uint8_t,uint8_t,BaseSerialInterface* = nullptr); + void servicePendingRadioParamApply(); + void cancelPendingRadioParamApply(); + void frame(size_t len) { @FRAME_BRANCHES@ } + void recover() { @RECOVERY@ } + bool command(const char* command,char* reply) { + const size_t reply_capacity=160; + @CLI_BRANCH@ + return false; + } + void powerFrame(uint8_t power,size_t size=2) { + cmd_frame[0]=CMD_SET_RADIO_TX_POWER;cmd_frame[1]=power;frame(size); + } + void tupleFrame() { + cmd_frame[0]=CMD_SET_RADIO_PARAMS; + uint32_t freq=920000,bw=125000; + memcpy(cmd_frame+1,&freq,4);memcpy(cmd_frame+5,&bw,4); + cmd_frame[9]=9;cmd_frame[10]=6;cmd_frame[11]=1; + frame(12); + } + void expectError(uint8_t code,BaseSerialInterface* route=nullptr) { + assert(replies.size()==1 && replies[0].error==code); + assert(replies[0].route==(route?route:&original)); + } + void expectOriginalPrefs() { + assert(_prefs.freq==915 && _prefs.bw==250 && _prefs.sf==7 && _prefs.cr==5); + assert(_prefs.client_repeat==0 && _prefs.rx_ps_rx_us==700 && _prefs.rx_ps_sleep_us==25000); + assert(durable.freq==915 && durable.bw==250 && durable.sf==7 && durable.cr==5); + } +}; +@METHODS@ +int main() { + // Reject unavailable/busy hardware before either persisting or reporting it. + { + MyMesh m;radio_driver.accepted={false};m.powerFrame(17); + m.expectError(ERR_CODE_BAD_STATE); + assert(m.saves==0 && m._prefs.tx_power_dbm==3 && radio_driver.power==3); + } + for (int value : {-10,23}) { + MyMesh m;m.powerFrame(uint8_t(value));m.expectError(ERR_CODE_ILLEGAL_ARG); + assert(m.saves==0 && radio_driver.calls==0); + } + { + MyMesh m;m.powerFrame(17,1);m.expectError(ERR_CODE_ILLEGAL_ARG); + assert(m.saves==0 && radio_driver.calls==0); + } + { + MyMesh m;m.powerFrame(17);m.expectError(0); + assert(m.saves==1 && m.durable.tx_power_dbm==17 && radio_driver.power==17); + } + { + MyMesh m;m._radio_available=false;m.powerFrame(17);m.expectError(0); + assert(m.durable.tx_power_dbm==17 && radio_driver.calls==0); + } + // A failed commit restores RAM and the physical output-power setting. + { + MyMesh m;m.storage_accepts=false;m.powerFrame(17);m.expectError(ERR_CODE_FILE_IO_ERROR); + assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3 && radio_driver.power==3); + assert(radio_driver.calls==2 && !m.saved_radio_apply_pending); + } + // RX may become busy during flash I/O. Retain recovery through a rejected + // rollback and through a later successful tuple restore but failed power apply. + { + MyMesh m;m.storage_accepts=false;radio_driver.accepted={true,false,false,true}; + m.powerFrame(17);m.expectError(ERR_CODE_FILE_IO_ERROR); + assert(m._prefs.tx_power_dbm==3 && radio_driver.power==17 && m.saved_radio_apply_pending); + m.recover();assert(m.saved_radio_apply_pending && radio_driver.power==17); + assert(m.radio_apply_failures==1 && m.radio_apply_retry_at>now_ms); + unsigned calls=radio_driver.calls;m.recover();assert(radio_driver.calls==calls); + now_ms=m.radio_apply_retry_at;m.recover(); + assert(!m.saved_radio_apply_pending && radio_driver.power==3 && m.saves==1); + assert(m.replies.size()==1); // no later success for the failed command + } + // ASCII and framed setters share identical hardware/durability semantics. + { + MyMesh m;char reply[160]={};radio_driver.accepted={false}; + assert(m.command("set tx 17",reply) && strstr(reply,"Error")); + assert(m.saves==0 && m._prefs.tx_power_dbm==3); + m.storage_accepts=false; + assert(m.command("set tx 17",reply) && strstr(reply,"saved")); + assert(strstr(reply,"Error") && radio_driver.power==3); + assert(m.command("set tx 17junk",reply) && strstr(reply,"Error")); + assert(m.saves==1); + } + // Both immediate and deferred tuple changes must commit before OK. + { + MyMesh m;m.tupleFrame();m.expectError(0); + assert(m._prefs.freq==920 && m.durable.freq==920 && m.live.freq==920); + assert(m.durable.rx_ps_rx_us==900 && m.durable.rx_ps_sleep_us==12500); + assert(!m.saved_radio_apply_pending); + } + { + MyMesh m;m.storage_accepts=false;m.tupleFrame();m.expectError(ERR_CODE_FILE_IO_ERROR); + m.expectOriginalPrefs();assert(m.live.freq==915 && !m.saved_radio_apply_pending); + } + { + MyMesh m;m.accepted={Apply::BUSY,Apply::APPLIED};m.tupleFrame(); + assert(m.replies.empty() && m.command_radio_apply_pending && m.saves==0); + m.serial.current=&m.other;m.servicePendingRadioParamApply();m.expectError(0); + assert(!m.command_radio_apply_pending && m.durable.freq==920); + } + { + MyMesh m;m.storage_accepts=false;m.accepted={Apply::BUSY,Apply::APPLIED,Apply::BUSY}; + m.tupleFrame();assert(m.replies.empty() && m.command_radio_apply_pending); + m.serial.current=&m.other;m.servicePendingRadioParamApply(); + m.expectError(ERR_CODE_FILE_IO_ERROR);m.expectOriginalPrefs(); + assert(m.saved_radio_apply_pending && m.live.freq==920 && !m.command_radio_apply_pending); + m.recover();assert(!m.saved_radio_apply_pending && m.live.freq==915); + assert(m.saves==1 && m.replies.size()==1); + } + // Disconnect cancels a deferred command without persisting its candidate. + { + MyMesh m;m.accepted={Apply::BUSY};m.tupleFrame(); + m.serial.disconnected=&m.original;m.servicePendingRadioParamApply(); + assert(!m.command_radio_apply_pending && m.replies.empty() && m.saves==0); + m.expectOriginalPrefs(); + } + // Tuple completion must not swallow an outstanding power rollback. + { + MyMesh m;m.saved_radio_apply_pending=true;radio_driver.power=17; + m.tupleFrame();m.expectError(0);assert(m.saved_radio_apply_pending); + m.recover();assert(!m.saved_radio_apply_pending && radio_driver.power==3); + assert(m.live.freq==920 && m.durable.freq==920); + } + puts("Companion radio-setting commit and recovery regressions passed"); +} +''' + +SLEEP_HARNESS = r''' +#include +#include +#include +static uint32_t now_ms=100; +struct Serial { + bool hasPendingIO() const { return false; } + bool isConnected() const { return false; } +}; +struct { + bool isWatchdogObserving() const { return false; } + bool isCalibratingNoiseFloor() const { return false; } +} radio_driver; +struct MyMesh { + bool _radio_available=true, _iter_started=false, command_radio_apply_pending=false; + bool binary_trace_pending=false, saved_radio_apply_pending=false; + uint32_t radio_apply_retry_at=0, _scheduled_reboot_at=0; + uint32_t emergency_client_repeat_send_at=0, dirty_contacts_expiry=0; + uint8_t dirty_contacts_failures=0; + const void *pending_serial_reply_route=nullptr, *sign_data=nullptr; + const void *emergency_client_repeat_packet=nullptr; + Serial* _serial=nullptr; +#if COMPANION_FEATURE_TEMP_RADIO + bool _temp_radio_applied=false; + uint32_t _temp_radio_set_at=0, _temp_radio_revert_at=0, _temp_radio_retry_at=0; +#endif + bool millisHasNowPassed(uint32_t at) const { return int32_t(now_ms-at)>=0; } + bool isDualRadioActive() const { return false; } + bool isContactWriteDue() const { return false; } + bool hasPendingOtaApply() const { return false; } + bool hasQueuedWorkDue() const { return false; } + bool hasRetryWorkDue() const { return false; } + bool hasPendingWork() const; +}; +@PENDING_WORK@ +int main() { + MyMesh m; + assert(!m.hasPendingWork()); + m.saved_radio_apply_pending=true; + assert(m.hasPendingWork()); + m.radio_apply_retry_at=now_ms+10; + assert(!m.hasPendingWork()); + now_ms+=10; + assert(m.hasPendingWork()); + m.radio_apply_retry_at=0; +#if COMPANION_FEATURE_TEMP_RADIO + // An already active lease can last hours; a rejected power-save rollback + // must not keep spinning solely because saved radio restoration is pending. + m._temp_radio_applied=true; + m._temp_radio_revert_at=now_ms+3600000; + assert(!m.hasPendingWork()); + // A future scheduled TempRadio entry also blocks normal-radio restoration. + m._temp_radio_applied=false;m._temp_radio_revert_at=0; + m._temp_radio_set_at=now_ms+20; + assert(!m.hasPendingWork()); + now_ms=m._temp_radio_set_at; + assert(m.hasPendingWork()); // the entry timer, not the blocked restore + m._temp_radio_set_at=0;m._temp_radio_applied=true; + m._temp_radio_revert_at=now_ms+20; + assert(!m.hasPendingWork()); + now_ms=m._temp_radio_revert_at; + assert(m.hasPendingWork()); // lease expiry still gets serviced + m._temp_radio_applied=false;m._temp_radio_revert_at=0; + assert(m.hasPendingWork()); // rollback is runnable after the lease clears +#endif + m.saved_radio_apply_pending=false; + assert(!m.hasPendingWork()); +} +''' + + +class CompanionRadioSettingsTransactionTests(unittest.TestCase): + def test_lease_blocked_recovery_does_not_prevent_sleep(self): + text = SOURCE.read_text(encoding='utf-8') + pending = extract_braced(text, 'bool MyMesh::hasPendingWork() const') + harness = SLEEP_HARNESS.replace('@PENDING_WORK@', pending) + with tempfile.TemporaryDirectory(prefix='mesh-companion-radio-sleep-') as tmp: + cpp = Path(tmp) / 'test.cpp' + binary = Path(tmp) / ('test.exe' if os.name == 'nt' else 'test') + cpp.write_text(harness, encoding='utf-8') + for macros in (['-DCOMPANION_FEATURE_TEMP_RADIO=0'], + ['-DCOMPANION_FEATURE_TEMP_RADIO=1'], + ['-DCOMPANION_FEATURE_TEMP_RADIO=1', '-DNRF52_PLATFORM=1']): + with self.subTest(macros=macros): + subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17', + '-Wall', '-Wextra', '-Werror', *macros, + str(cpp), '-o', str(binary)], check=True) + subprocess.run([str(binary)], check=True) + + def test_production_commands_commit_and_restore(self): + text = SOURCE.read_text(encoding='utf-8') + header = (SOURCE.parent / 'MyMesh.h').read_text(encoding='utf-8') + codes = '\n'.join(re.findall( + r'^#define (?:CMD_SET_RADIO_PARAMS|CMD_SET_RADIO_TX_POWER|ERR_CODE_\w+)\s+\d+', + text, flags=re.MULTILINE)) + methods = '\n'.join(extract_braced(text, signature) for signature in ( + 'bool MyMesh::applySavedRadioParams(', + 'MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(', + 'void MyMesh::finishRadioParamApply(', + 'void MyMesh::cancelPendingRadioParamApply(', + 'void MyMesh::servicePendingRadioParamApply(', + )) + frame_branches = ' else '.join(extract_braced(text, f'if (cmd_frame[0] == {command})') + for command in ('CMD_SET_RADIO_PARAMS', 'CMD_SET_RADIO_TX_POWER')) + replacements = { + '@CODES@': codes, + '@RESULT_ENUM@': extract_braced(header, 'enum class RadioSettingResult'), + '@RETRY_DELAY@': extract_braced(text, 'static uint32_t nextRadioApplyRetryDelay('), + '@METHODS@': methods, + '@FRAME_BRANCHES@': frame_branches, + '@CLI_BRANCH@': extract_braced(text, 'if (strncmp(command, "set tx ", 7) == 0)'), + '@RECOVERY@': extract_braced(text, 'if (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound()'), + } + harness = HARNESS + for marker, value in replacements.items(): + harness = harness.replace(marker, value) + with tempfile.TemporaryDirectory(prefix='mesh-companion-radio-settings-') as tmp: + cpp = Path(tmp) / 'test.cpp' + binary = Path(tmp) / ('test.exe' if os.name == 'nt' else 'test') + cpp.write_text(harness, encoding='utf-8') + subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17', '-Wall', '-Wextra', '-Werror', + '-Wno-sign-compare', # existing frame parser compares size_t and byte offsets + '-I', str(ROOT / 'src'), str(cpp), '-o', str(binary)], check=True) + subprocess.run([str(binary)], check=True) + + def test_all_tx_power_frontends_use_checked_handler(self): + text = SOURCE.read_text(encoding='utf-8') + terminal = extract_braced(text, 'if (strncmp(config, "tx ", 3) == 0)') + self.assertIn('handleCommand(command, 0, local_reply)', terminal) + web = extract_braced(text, 'if (strcmp(key, "tx") == 0)') + self.assertIn('applyAndSaveTxPower(', web) + self.assertIn('RadioSettingResult::SaveFailed', + extract_braced(text, 'if (strncmp(command, "set tx ", 7) == 0)')) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_companion_terminal_profile.py b/test/test_companion_terminal_profile.py index 1f601d8be..743a9c589 100644 --- a/test/test_companion_terminal_profile.py +++ b/test/test_companion_terminal_profile.py @@ -94,14 +94,19 @@ assert re.search( # Terminal-specific handlers keep precedence, then both the generic `set` # fallthrough and the final unknown-command fallthrough delegate to the shared -# framed/rescue command surface. The explicit frequency branch also delegates -# to the strict shared frequency parser; it is not a third fallthrough. +# framed/rescue command surface. Frequency and power also delegate explicitly +# to the strict, failure-aware shared handlers; they are not extra fallthroughs. freq_branch = re.search( r'else if \(strncmp\(config, "freq ", 5\) == 0\) \{([^{}]*)\}', terminal ) assert freq_branch is not None assert "handleCommand(command, 0, local_reply)" in freq_branch.group(1) -assert terminal.replace(freq_branch.group(0), "").count( +tx_branch = re.search( + r'else if \(strncmp\(config, "tx ", 3\) == 0\) \{([^{}]*)\}', terminal +) +assert tx_branch is not None +assert "handleCommand(command, 0, local_reply)" in tx_branch.group(1) +assert terminal.replace(freq_branch.group(0), "").replace(tx_branch.group(0), "").count( "handleCommand(command, 0, local_reply)" ) == 2 assert terminal.index('strncmp(config, "tx ", 3)') < terminal.index( diff --git a/test/test_flood_advert_limiter/test_flood_advert_limiter.cpp b/test/test_flood_advert_limiter/test_flood_advert_limiter.cpp index f5e43528e..9c8840b22 100644 --- a/test/test_flood_advert_limiter/test_flood_advert_limiter.cpp +++ b/test/test_flood_advert_limiter/test_flood_advert_limiter.cpp @@ -120,6 +120,46 @@ TEST_F(FloodAdvertLimit, AggregatePrefixExcessDoesNotBlameAnInnocentFullKey) { } } +TEST_F(FloodAdvertLimit, LateShorterDuplicateKeepsPenaltyUntilSevenDayRecovery) { + for (uint32_t start : {0U, UINT32_MAX - W / 2}) { + limiter.reset(); + bad(start); // consecutive over-limit windows at eight hops + uint8_t hash[MAX_HASH_SIZE] = {1}; + limiter.observe(key.data(), hash, 1, start + W + 1, true); + Limiter::LimitedEntry row; + ASSERT_EQ(1U, limiter.listLimited(start + W + 1, 0, &row, 1)); + EXPECT_EQ(1, row.hops); + EXPECT_EQ(10, row.quota); + EXPECT_TRUE(row.reasons & Limiter::BadListRule); + EXPECT_EQ(D7 - 1, row.recovery_ms); + // Raising the allowance does not forgive the escalation, including when + // the now-compliant window ends. New adverts still obey the 12-hour rule. + EXPECT_EQ(Decision::BadList, receive(4, 1, start + W + 2)); + EXPECT_TRUE(limiter.isBad(key.data(), start + 2 * W)); + EXPECT_TRUE(limiter.isBad(key.data(), start + W + D7 - 1)); + EXPECT_FALSE(limiter.isBad(key.data(), start + W + D7)); + } +} + +TEST_F(FloodAdvertLimit, ShorterNewAndMatchingPrefixAdvertsCannotLiftPenalty) { + bad(); + auto collision = key; + collision[31] = 1; + EXPECT_EQ(Decision::Allow, receive(1, 1, W + 1, collision.data())); + EXPECT_FALSE(limiter.isBad(collision.data(), W + 1)); + EXPECT_TRUE(limiter.isBad(key.data(), W + 1)); + EXPECT_EQ(Decision::BadList, receive(4, 0, W + 2)); + Limiter::LimitedEntry row; + ASSERT_EQ(1U, limiter.listLimited(W + 2, 0, &row, 1)); + EXPECT_EQ(0, row.hops); + EXPECT_EQ(10, row.quota); + EXPECT_TRUE(row.reasons & Limiter::BadListRule); + EXPECT_EQ(0, memcmp(row.key, key.data(), PUB_KEY_SIZE)); + EXPECT_EQ(Decision::BadList, receive(5, 0, W + H12 - 1)); + EXPECT_EQ(Decision::Allow, receive(6, 0, W + H12)); + EXPECT_TRUE(limiter.isBad(key.data(), W + H12)); +} + TEST_F(FloodAdvertLimit, ShortestPrefixPathAlsoDefinesTheNormalRecoveryLevel) { auto collision = key; collision[31] = 1; diff --git a/test/test_local_cli_access.py b/test/test_local_cli_access.py index d7f2b5881..ed0b116b8 100644 --- a/test/test_local_cli_access.py +++ b/test/test_local_cli_access.py @@ -102,7 +102,12 @@ struct MyMesh { int value=0; int* _ms=&value; int* _radio=&value; int* _mgr=&value; uint16_t _err_flags=0; bool _radio_available=false, save_ok=true; + bool saved_radio_apply_pending=false; + unsigned radio_apply_retry_at=0; + uint8_t radio_apply_failures=0; int resets=0, saves=0; + enum class RadioSettingResult { Saved, RadioRejected, SaveFailed }; + RadioSettingResult applyAndSaveTxPower(int8_t); bool handleCommand(const char*,uint32_t,char*); bool handleDirectCommand(const char*,char*,size_t); void onCLICommandRecv(const ContactInfo&,mesh::Packet*,uint32_t,const char*,char*); @@ -303,6 +308,7 @@ int main() { source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() implementation = "\n".join(extract_braced(source, signature) for signature in ( "static bool isCompanionRadioPrefsCommand(", + "MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(", "bool MyMesh::handleDirectCommand(", "bool MyMesh::handleCommand(", "void MyMesh::onCLICommandRecv(")) for export, webconfig in ((0, False), (1, False), (0, True), (1, True)): diff --git a/test/test_ota/test_ota_core.cpp b/test/test_ota/test_ota_core.cpp index 3b07cadaf..d247807ed 100644 --- a/test/test_ota/test_ota_core.cpp +++ b/test/test_ota/test_ota_core.cpp @@ -4043,7 +4043,7 @@ TEST(OtaCatalog, IncompleteCatalogRetriesOnlyMissingFragment) { client.set_archive_interest(true); uint8_t sid[4] = {0x44, 0x33, 0x22, 0x11}; uint8_t digest[4] = {0xDE, 0xAD, 0xBE, 0xEF}; - AdvMsg adv{}; memcpy(adv.seeder_id, sid, 4); memcpy(adv.set_digest, digest, 4); adv.n_motas = 3; + AdvMsg adv{}; memcpy(adv.seeder_id, sid, 4); memcpy(adv.set_digest, digest, 4); adv.n_motas = 23; uint8_t wire[MAX_PACKET_PAYLOAD]; uint16_t n = encode_adv(wire, sizeof(wire), adv); client.set_clock(100); @@ -4057,13 +4057,17 @@ TEST(OtaCatalog, IncompleteCatalogRetriesOnlyMissingFragment) { sent.items.clear(); auto deliver_have = [&](uint8_t frag) { - uint8_t row[OTA_HAVE_ROW_BYTES] = {0}; - row[0] = (uint8_t)(frag + 1); row[1] = 0x77; - wr_u32le(row + 4, SIM_TARGET_ID); wr_u32le(row + 8, 0x01000000u + frag); - row[12] = CODEC_FULL; row[13] = MFLAG_FULL; row[14] = 1; + const uint8_t count = frag == 2 ? 3 : 10; + uint8_t rows[10 * OTA_HAVE_ROW_BYTES] = {0}; + for (uint8_t index = 0; index < count; ++index) { + uint8_t* row = rows + index * OTA_HAVE_ROW_BYTES; + row[0] = (uint8_t)(frag * 10 + index + 1); row[1] = 0x77; + wr_u32le(row + 4, SIM_TARGET_ID); wr_u32le(row + 8, 0x01000000u + frag); + row[12] = CODEC_FULL; row[13] = MFLAG_FULL; row[14] = 1; + } HaveMsg have{}; memcpy(have.seeder_id, sid, 4); memcpy(have.set_digest, digest, 4); - have.frag_idx = frag; have.frag_total = 3; have.n_rows = 1; have.rows = row; + have.frag_idx = frag; have.frag_total = 3; have.n_rows = count; have.rows = rows; uint16_t have_len = encode_have(wire, sizeof(wire), have); ASSERT_GT(have_len, 0); client.on_message(wire, have_len); @@ -4080,7 +4084,7 @@ TEST(OtaCatalog, IncompleteCatalogRetriesOnlyMissingFragment) { sent.items.clear(); deliver_have(1); - EXPECT_EQ(client.catalogCount(), 3); + EXPECT_EQ(client.catalogCount(), 23); client.set_clock(5000 + OTA_CATALOG_RETRY_MS * 2 + 2); client.loop(); EXPECT_TRUE(sent.items.empty()); // complete means no further catalog retries diff --git a/test/test_ota_catalog_lifetime.py b/test/test_ota_catalog_lifetime.py new file mode 100644 index 000000000..e9c6fd6b8 --- /dev/null +++ b/test/test_ota_catalog_lifetime.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Exercise OTA catalog lifetime, rollover, and filtered-page recovery.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class OtaCatalogLifetimeTest(unittest.TestCase): + def test_source_and_catalog_ownership_survive_clock_rollover(self): + with tempfile.TemporaryDirectory(prefix="ota-catalog-") as directory: + path = Path(directory) + flags = [] if os.name == "nt" else ["-fsanitize=address,undefined"] + tinf = path / "tinf.o" + subprocess.run([shutil.which("cc") or "gcc", "-DENABLE_OTA=1", *flags, + "-c", str(ROOT / "src/helpers/ota/OtaTinf.c"), + "-o", str(tinf)], check=True) + sources = ["OtaManager.cpp", "OtaProtocol.cpp", "MotaContainer.cpp", + "MerkleTree.cpp", "OtaDeflate.cpp"] + binary = path / "catalog.exe" + result = subprocess.run([ + "c++", "-std=c++17", *flags, "-DENABLE_OTA=1", + "-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"), + str(ROOT / "test/fixtures/ota_catalog_lifetime/test.cpp"), + *[str(ROOT / "src/helpers/ota" / name) for name in sources], + str(ROOT / "src/Utils.cpp"), str(tinf), "-o", str(binary), + ], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + for rollover in (0, 1): + for scenario in range(9): + with self.subTest(scenario=scenario, rollover=rollover): + subprocess.run([str(binary), str(scenario), str(rollover)], + check=True, timeout=10) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ota_store_flash_nrf52_hybrid.py b/test/test_ota_store_flash_nrf52_hybrid.py index 7f2a0d7e5..1ab411ff3 100644 --- a/test/test_ota_store_flash_nrf52_hybrid.py +++ b/test/test_ota_store_flash_nrf52_hybrid.py @@ -51,10 +51,10 @@ class OtaStoreFlashNrf52HybridTest(unittest.TestCase): ) self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) self.assertIn( - "7 OtaStoreFlashNrf52 hybrid lifecycle checks passed", + "10 OtaStoreFlashNrf52 hybrid lifecycle checks passed", checked.stdout, ) - self.assertEqual(checked.stdout.count("PASS:"), 7) + self.assertEqual(checked.stdout.count("PASS:"), 10) if __name__ == "__main__":