diff --git a/src/helpers/UsbLoggingWatchdog.cpp b/src/helpers/UsbLoggingWatchdog.cpp index 4334cd1f5..c631363cc 100644 --- a/src/helpers/UsbLoggingWatchdog.cpp +++ b/src/helpers/UsbLoggingWatchdog.cpp @@ -387,7 +387,7 @@ bool handleUsbLoggingWatchdogCommand(const char* command, char* reply, Config candidate = config; candidate.mode = mode; if (!saveConfig(candidate, true)) { - snprintf(reply, capacity, "Error: USB watchdog state not durable; unchanged"); + snprintf(reply, capacity, "Error: save unconfirmed; reboot may differ"); } else { config = candidate; if (config.last_event.sequence) config.last_event.persisted = true; diff --git a/test/fixtures/usb_logging_watchdog/test_usb_logging_watchdog_runtime.cpp b/test/fixtures/usb_logging_watchdog/test_usb_logging_watchdog_runtime.cpp index 41a2a79e0..898ac1ed4 100644 --- a/test/fixtures/usb_logging_watchdog/test_usb_logging_watchdog_runtime.cpp +++ b/test/fixtures/usb_logging_watchdog/test_usb_logging_watchdog_runtime.cpp @@ -203,7 +203,7 @@ static void check_failclosed_load_and_exact_format() { assert(fixture.fs.files.at("/usb_wdg") == image(1)); assert(mesh::usbLoggingStatus().persistence_ready); } else { - assert(reply.find("not durable") != std::string::npos); + assert(reply == "Error: save unconfirmed; reboot may differ"); assert(!mesh::usbLoggingStatus().persistence_ready); } } @@ -236,8 +236,14 @@ static void check_backup_and_volatile_storage() { assert(mesh::usbLoggingStatus().watchdog_auto && !mesh::usbLoggingStatus().persistence_ready); assert(!fixture.run(0) && !fixture.run(Policy::MAX_MS)); assert(recovery_calls.empty() && fixture.fs.write_opens == 0); - assert(fixture.command("set usb.watchdog on").find("not durable") != std::string::npos); - assert(fixture.fs.write_opens == 0); // Explicit repair cannot bless RAM storage. + const auto before = fixture.fs.files; + assert(fixture.command("set usb.watchdog on") + == "Error: save unconfirmed; reboot may differ"); + assert(fixture.fs.files == before && fixture.fs.write_opens == 0 + && fixture.fs.writes == 0 && fixture.fs.renames == 0 && fixture.fs.removes == 0); + // Explicit repair cannot bless RAM storage or authorize automatic actions. + assert(!fixture.run(Policy::MAX_MS + Policy::EARLY_RECOVERY_MS)); + assert(recovery_calls.empty() && probe_calls == 0 && fixture.fs.write_opens == 0); mesh::loadUsbLoggingWatchdog(nullptr); assert(!mesh::usbLoggingStatus().persistence_ready); assert(!mesh::isUsbLoggingWatchdogArmed()); @@ -278,38 +284,94 @@ static void check_cli_is_strict_bounded_and_transactional() { } static void check_failed_setter_and_readback_are_not_success() { - for (unsigned fault = 0; fault != 5; ++fault) { - Fixture fixture(0, 3, 7, 9); - const auto old = fixture.fs.files.at("/usb_wdg"); - switch (fault) { - case 0: fixture.fs.fail_write = true; break; - case 1: fixture.fs.fail_open = "/usb_wdg.tmp"; break; - case 2: - fixture.fs.fail_rename_from = "/usb_wdg.tmp"; - fixture.fs.fail_rename_to = "/usb_wdg"; - break; - case 3: fixture.fs.corrupt_final_image = true; break; - case 4: fixture.fs.fail_final_readback = true; break; + unsigned scenarios = 0; + for (bool initially_on : {false, true}) { + const uint8_t previous_mode = initially_on ? 1 : 0; + const uint8_t candidate_mode = initially_on ? 0 : 1; + const char* command = initially_on ? "set usb.watchdog off" : "set usb.watchdog on"; + const char* healthy_reply = initially_on ? "OK - USB watchdog off (saved)" + : "OK - USB watchdog on (saved)"; + { // Healthy setters retain their exact ACK and survive a fresh load. + Fixture healthy(previous_mode, 3, 7, 9); + assert(healthy.command(command) == healthy_reply); + assert(healthy.fs.files.at("/usb_wdg") == image(candidate_mode, 3, 7, 9)); + assert(healthy.fs.write_opens == 1); + healthy.reload(); + const auto status = mesh::usbLoggingStatus(); + assert(status.watchdog_enabled == !initially_on); + assert(!status.watchdog_auto && status.persistence_ready); + assert(status.backoff_step == 3 && status.recovery_count == 7 && status.reboot_count == 9); + assert(healthy.fs.write_opens == 1); // Reload itself never writes. + ++scenarios; + } + for (unsigned fault = 0; fault != 5; ++fault) { + Fixture fixture(previous_mode, 3, 7, 9); + const auto old = fixture.fs.files.at("/usb_wdg"); + switch (fault) { + case 0: fixture.fs.fail_write = true; break; + case 1: fixture.fs.fail_open = "/usb_wdg.tmp"; break; + case 2: + fixture.fs.fail_rename_from = "/usb_wdg.tmp"; + fixture.fs.fail_rename_to = "/usb_wdg"; + break; + case 3: fixture.fs.corrupt_final_image = true; break; + case 4: fixture.fs.fail_final_readback = true; break; + } + const auto reply = fixture.command(command); + assert(reply == "Error: save unconfirmed; reboot may differ"); + assert(reply.find("unchanged") == std::string::npos && reply.find("OK") == std::string::npos); + const auto status = mesh::usbLoggingStatus(); + assert(status.watchdog_enabled == initially_on); + assert(!status.watchdog_auto && !status.persistence_ready); + assert(status.backoff_step == 3 && status.recovery_count == 7 && status.reboot_count == 9); + if (fault <= 2) assert(fixture.fs.files.at("/usb_wdg") == old); + if (fault == 4) { + // Final readback cannot distinguish failed publication from a complete + // candidate which is already durable. Do not claim disk rollback. + assert(fixture.fs.files.at("/usb_wdg") == image(candidate_mode, 3, 7, 9)); + } + const auto writes = fixture.fs.write_opens; + assert(!fixture.run(0) && !fixture.run(Policy::MAX_MS)); + assert(recovery_calls.empty() && probe_calls == 0 && fixture.fs.write_opens == writes); + fixture.fs.fail_write = false; + fixture.fs.fail_open.clear(); + fixture.fs.fail_rename_from.clear(); + fixture.fs.fail_rename_to.clear(); + fixture.fs.corrupt_final_image = false; + fixture.fs.fail_final_readback = false; + // Merely clearing the fault cannot restore persistence authorization. + assert(!fixture.run(Policy::MAX_MS + Policy::EARLY_RECOVERY_MS)); + assert(recovery_calls.empty() && probe_calls == 0 && fixture.fs.write_opens == writes); + assert(!mesh::usbLoggingStatus().persistence_ready); + assert(fixture.command(command) == healthy_reply); + assert(fixture.fs.write_opens == writes + 1); // Explicit repair only. + assert(fixture.fs.files.at("/usb_wdg") == image(candidate_mode, 3, 7, 9)); + assert(mesh::usbLoggingStatus().persistence_ready); + assert(mesh::usbLoggingStatus().watchdog_enabled == !initially_on); + ++scenarios; + } + { // Independently reboot before retry: the failed setter may be durable. + Fixture fixture(previous_mode, 3, 7, 9); + fixture.fs.fail_final_readback = true; + assert(fixture.command(command) == "Error: save unconfirmed; reboot may differ"); + assert(mesh::usbLoggingStatus().watchdog_enabled == initially_on); + assert(!mesh::usbLoggingStatus().persistence_ready); + const auto writes = fixture.fs.write_opens; + assert(!fixture.run(0) && !fixture.run(Policy::MAX_MS)); + assert(recovery_calls.empty() && probe_calls == 0 && fixture.fs.write_opens == writes); + fixture.fs.fail_final_readback = false; + fixture.reload(); + const auto reboot = mesh::usbLoggingStatus(); + assert(reboot.watchdog_enabled == !initially_on); + assert(!reboot.watchdog_auto && reboot.persistence_ready); + assert(reboot.backoff_step == 3 && reboot.recovery_count == 7 && reboot.reboot_count == 9); + assert(fixture.fs.files.at("/usb_wdg") == image(candidate_mode, 3, 7, 9)); + assert(fixture.fs.write_opens == writes); // No retry was needed to publish. + ++scenarios; } - assert(fixture.command("set usb.watchdog on").find("not durable; unchanged") != std::string::npos); - const auto status = mesh::usbLoggingStatus(); - assert(!status.watchdog_enabled && !status.watchdog_auto && !status.persistence_ready); - assert(status.backoff_step == 3 && status.recovery_count == 7 && status.reboot_count == 9); - if (fault <= 2) assert(fixture.fs.files.at("/usb_wdg") == old); - const auto writes = fixture.fs.write_opens; - assert(!fixture.run(0) && !fixture.run(Policy::MAX_MS)); - assert(recovery_calls.empty() && fixture.fs.write_opens == writes); - fixture.fs.fail_write = false; - fixture.fs.fail_open.clear(); - fixture.fs.fail_rename_from.clear(); - fixture.fs.fail_rename_to.clear(); - fixture.fs.corrupt_final_image = false; - fixture.fs.fail_final_readback = false; - assert(fixture.command("set usb.watchdog on") == "OK - USB watchdog on (saved)"); - assert(fixture.fs.write_opens == writes + 1); // Manual retry, not automatic. - assert(fixture.fs.files.at("/usb_wdg") == image(1, 3, 7, 9)); - assert(mesh::usbLoggingStatus().persistence_ready && mesh::usbLoggingStatus().watchdog_enabled); } + assert(scenarios == 14); + puts("14 USB watchdog setter durability scenarios passed"); } static void check_stages_durable_backoff_and_saturation() { diff --git a/test/test_usb_logging_watchdog_runtime.py b/test/test_usb_logging_watchdog_runtime.py index dce997db8..503223b92 100644 --- a/test/test_usb_logging_watchdog_runtime.py +++ b/test/test_usb_logging_watchdog_runtime.py @@ -159,6 +159,7 @@ int main() { tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) self.assertEqual(tested.returncode, 0, tested.stderr) self.assertIn('USB watchdog runtime and persistence passed', tested.stdout) + self.assertIn('14 USB watchdog setter durability scenarios passed', tested.stdout) def test_unsupported_transport_has_no_recovery_or_storage_work(self): compiler = os.environ.get('CXX') or shutil.which('g++') or shutil.which('clang++')