From 67a71d3a61b7881b07c67fed117b8e3e8253bf64 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 16 Oct 2026 05:18:37 -0700 Subject: [PATCH] Keep Companion reboot deadlines armed across millis rollover --- .github/workflows/run-unit-tests.yml | 1 + examples/companion_radio/MyMesh.cpp | 3 + .../companion_scheduled_reboot/test.cpp | 186 ++++++++++++++++++ test/test_companion_scheduled_reboot.py | 82 ++++++++ 4 files changed, 272 insertions(+) create mode 100644 test/fixtures/companion_scheduled_reboot/test.cpp create mode 100644 test/test_companion_scheduled_reboot.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index cfcfe598b..5d6a38e1d 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -341,6 +341,7 @@ jobs: python3 -B test/test_companion_ack_delivery.py -v python3 -B test/test_companion_lost_reply.py -v python3 -B test/test_companion_delayed_reply_delivery.py -v + python3 -B test/test_companion_scheduled_reboot.py -v python3 -B test/test_companion_hardware_serial.py -v python3 -B test/test_companion_hardware_serial_gps.py -v python3 -B test/test_companion_contact_lifetime.py -v diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index fbe801f5e..932f6ff0c 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -4034,6 +4034,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, "OK - USB logging %s (saved); rebooting to change USB interfaces", enabled ? (packet_stream ? "stream" : "on") : "off"); _scheduled_reboot_at = futureMillis(1000); + if (_scheduled_reboot_at == 0) _scheduled_reboot_at = 1; } else { snprintf(reply, reply_size, "OK - USB logging %s (saved); reboot required to change USB interfaces", @@ -7652,6 +7653,7 @@ void MyMesh::repairInternalExtraFS(Stream& output) { output.println( " > internal ExtraFS repaired; rebooting to reload authoritative data"); _scheduled_reboot_at = futureMillis(1000); + if (_scheduled_reboot_at == 0) _scheduled_reboot_at = 1; return; } @@ -10191,6 +10193,7 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, } else if (strcmp(command, "reboot") == 0) { terminalOutput().print(" OK - rebooting in 1 second\r\n"); _scheduled_reboot_at = futureMillis(1000); + if (_scheduled_reboot_at == 0) _scheduled_reboot_at = 1; #if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) } else if (strcmp(command, "repair extrafs") == 0) { repairInternalExtraFS(terminalOutput()); diff --git a/test/fixtures/companion_scheduled_reboot/test.cpp b/test/fixtures/companion_scheduled_reboot/test.cpp new file mode 100644 index 000000000..7593bbcce --- /dev/null +++ b/test/fixtures/companion_scheduled_reboot/test.cpp @@ -0,0 +1,186 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MESH_DEBUG_PRINTLN(...) ((void)0) + +static uint32_t now_ms; +static bool morse_active = false, boot_save_ok = true; +struct Clock { uint32_t getMillis() const { return now_ms; } } clock_source; +struct Board { unsigned reboots = 0; void reboot() { ++reboots; } } board; +struct Stream { + std::string text; + void print(const char* value) { text += value; } + void println(const char* value) { text += value; text += '\n'; } + void printf(const char* format, ...) { + char value[256]; + va_list args; + va_start(args, format); + vsnprintf(value, sizeof(value), format, args); + va_end(args); + text += value; + } +}; +namespace mesh { +bool morseTimingActive(const void*) { return morse_active; } +bool saveUsbLoggingBootPreference(bool) { return boot_save_ok; } +bool usbLoggingInterfaceRestartRequired() { return true; } +bool isUsbLoggingPacketStream() { return false; } +} +struct Store { + bool incomplete = true, repair_ok = true; + unsigned repairs = 0, contact_saves = 0; + bool hasIncompleteContactLoad() const { return incomplete; } + bool repairInternalExtraFS() { ++repairs; return repair_ok; } + bool saveContacts(void*, int) { ++contact_saves; return true; } + bool hasPendingContactWrites() const { return false; } + uint32_t getStorageTotalKb() const { return 100; } +}; +struct MyMesh { + Clock* _ms = &clock_source; + void* _radio = nullptr; + // Match the MCU's 32-bit unsigned-long deadline on native 64-bit hosts. + uint32_t _scheduled_reboot_at = 0; + Stream output; + Store store; + Store* _store = &store; + struct { uint8_t usb_logging_enabled = 0; } _prefs; + unsigned long dirty_contacts_expiry = 0; + uint8_t dirty_contacts_failures = 0; + int save_filter = 0; + bool prefs_ok = true, flush_ok = true; + unsigned flushes = 0, pref_saves = 0, logging_applies = 0; + Stream& terminalOutput() { return output; } + bool flushContactsBeforeReboot() { ++flushes; return flush_ok; } + bool savePrefs() { ++pref_saves; return prefs_ok; } + bool saveChannels() { return true; } + void scheduleContactWriteRetry() { assert(false); } + void applyUsbLoggingState(bool) { ++logging_applies; } + bool millisHasNowPassed(unsigned long timestamp) const; + unsigned long futureMillis(int millis_from_now) const; + void terminalCommand(const char* command); + bool loggingCommand(const char* command, char* reply, size_t reply_size); + void repairInternalExtraFS(Stream& output); + void service(); +}; +#include "production.inc" + +enum class Arm { Usb, Repair, Terminal }; +static void arm(MyMesh& node, Arm command) { + if (command == Arm::Usb) { + char reply[160] = {}; + assert(node.loggingCommand("set usb.logging stream reboot", reply, sizeof(reply))); + assert(strstr(reply, "rebooting to change USB interfaces")); + assert(node.pref_saves == 1 && node.logging_applies == 1); + } else if (command == Arm::Repair) { + node.repairInternalExtraFS(node.output); + assert(node.output.text.find("rebooting to reload authoritative data") != std::string::npos); + assert(node.store.repairs == 1 && node.store.contact_saves == 0); + } else { + node.terminalCommand("reboot"); + assert(node.output.text.find("rebooting in 1 second") != std::string::npos); + } +} +static void reset(uint32_t now) { + now_ms = now; + morse_active = false; + boot_save_ok = true; + board.reboots = 0; +} +static void timing_checks(Arm command) { + for (uint32_t start : {0U, 100U, UINT32_MAX - 1000U, UINT32_MAX - 999U, + UINT32_MAX - 998U, UINT32_MAX}) { + reset(start); + MyMesh node; + arm(node, command); + const uint32_t raw_deadline = start + 1000U; + const uint32_t deadline = raw_deadline == 0 ? 1 : raw_deadline; + const uint32_t armed = node._scheduled_reboot_at; + now_ms = start + 999U; + node.service(); + assert(board.reboots == 0 && node.flushes == 0); + if (raw_deadline == 0) { + now_ms = 0; + node.service(); + assert(board.reboots == 0 && node.flushes == 0); + } + now_ms = deadline; + node.service(); // Equality must service the arm despite Dispatcher's strict > helper. + assert(board.reboots == 1 && node.flushes == 1 + && "a wrapped reboot deadline must still reboot exactly once"); + assert(armed == deadline && node._scheduled_reboot_at == 0); + ++now_ms; + node.service(); + assert(board.reboots == 1 && node.flushes == 1); + } +} +static void service_gate_checks(Arm command) { + for (uint32_t start : {100U, UINT32_MAX - 999U}) { + reset(start); + MyMesh node; + arm(node, command); + const uint32_t deadline = node._scheduled_reboot_at; + morse_active = true; + now_ms = deadline; + node.service(); + now_ms += 100; + node.service(); + assert(board.reboots == 0 && node.flushes == 0 && node._scheduled_reboot_at == deadline); + morse_active = false; + node.service(); + assert(board.reboots == 1 && node.flushes == 1 && node._scheduled_reboot_at == 0); + } + reset(100); + MyMesh node; + arm(node, command); + node.flush_ok = false; + now_ms = node._scheduled_reboot_at; + node.service(); + assert(board.reboots == 0 && node.flushes == 1 && node._scheduled_reboot_at == 0); + ++now_ms; + node.service(); + assert(board.reboots == 0 && node.flushes == 1); +} +static void rejected_command_checks(Arm command) { + reset(0); + MyMesh node; + if (command == Arm::Usb) { + char reply[160] = {}; + node.prefs_ok = false; + assert(node.loggingCommand("set usb.logging stream reboot", reply, sizeof(reply))); + assert(strstr(reply, "save failed") && node._scheduled_reboot_at == 0); + node.prefs_ok = true; + boot_save_ok = false; + assert(node.loggingCommand("set usb.logging stream reboot", reply, sizeof(reply))); + assert(strstr(reply, "could not be saved") && node._scheduled_reboot_at == 0); + } else if (command == Arm::Repair) { + node.store.repair_ok = false; + node.repairInternalExtraFS(node.output); + assert(node.output.text.find("failed") != std::string::npos); + assert(node._scheduled_reboot_at == 0 && node.store.contact_saves == 0); + } else { + node.terminalCommand("reboot extra"); + assert(node._scheduled_reboot_at == 0 && node.output.text.empty()); + } + // Unarmed time zero and later polls must never flush or reboot. + for (uint32_t now : {0U, 1U, 1000U}) { + now_ms = now; + node.service(); + } + assert(board.reboots == 0 && node.flushes == 0); +} +int main(int argc, char** argv) { + assert(argc == 2); + const Arm command = !strcmp(argv[1], "usb") ? Arm::Usb + : !strcmp(argv[1], "repair") ? Arm::Repair : Arm::Terminal; + timing_checks(command); + service_gate_checks(command); + rejected_command_checks(command); + std::printf("PASS: scheduled Companion reboot (%s)\n", argv[1]); +} diff --git a/test/test_companion_scheduled_reboot.py b/test/test_companion_scheduled_reboot.py new file mode 100644 index 000000000..625e1a929 --- /dev/null +++ b/test/test_companion_scheduled_reboot.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Execute the three real Companion reboot arms and the loop service guard.""" + +from pathlib import Path +import os +import shutil +import subprocess +import sys +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" +FIXTURE = ROOT / "test/fixtures/companion_scheduled_reboot/test.cpp" +NORMALIZE = "if (_scheduled_reboot_at == 0) _scheduled_reboot_at = 1;" + + +def production_methods(source): + terminal = extract_braced(source, '} else if (strcmp(command, "reboot") == 0)') + terminal = terminal.replace("} else if", "if", 1) + # This directive controls the next terminal else-if, outside the reboot + # statements, but precedes the closing brace in the source text. + terminal = terminal.replace( + "#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)\n", "") + logging = extract_braced(source, 'if (strncmp(command, "set usb.logging", 15) == 0') + service = extract_braced(source, + "if (!mesh::morseTimingActive(_radio) && _scheduled_reboot_at != 0") + methods = "void MyMesh::terminalCommand(const char* command) {\n" + terminal + "\n}\n" + methods += "bool MyMesh::loggingCommand(const char* command, char* reply, size_t reply_size) {\n" + methods += logging + "\nreturn false;\n}\n" + methods += extract_braced(source, "void MyMesh::repairInternalExtraFS(") + "\n" + methods += "void MyMesh::service() {\n" + service + "\n}\n" + dispatcher = (ROOT / "src/Dispatcher.cpp").read_text(encoding="ascii") + for signature in ("bool Dispatcher::millisHasNowPassed(", + "unsigned long Dispatcher::futureMillis("): + methods += extract_braced(dispatcher, signature).replace("Dispatcher::", "MyMesh::") + "\n" + return methods + + +class CompanionScheduledRebootTests(unittest.TestCase): + def execute(self, legacy=False): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "host C++17 compiler required") + source = SOURCE.read_text(encoding="ascii") + self.assertEqual(source.count(NORMALIZE), 3) + if legacy: + source = source.replace(NORMALIZE, "") + with tempfile.TemporaryDirectory(prefix="companion-scheduled-reboot-") as directory: + work = Path(directory) + (work / "production.inc").write_text(production_methods(source), encoding="ascii") + binary = work / "reboot.exe" + sanitizer = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) + built = subprocess.run([ + compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", *sanitizer, + f"-I{work}", f"-I{ROOT / 'src'}", str(FIXTURE), "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + for command in ("usb", "repair", "terminal"): + with self.subTest(command=command): + checked = subprocess.run([str(binary), command], capture_output=True, + text=True, timeout=10) + if legacy: + self.assertNotEqual(checked.returncode, 0) + self.assertIn("a wrapped reboot deadline must still reboot exactly once", + checked.stderr) + else: + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn("PASS: scheduled Companion reboot", checked.stdout) + + def test_actual_commands_wrap_equality_morse_and_contact_save_gate(self): + self.execute() + + def test_missing_zero_normalization_reproduces_lost_reboot_at_each_arm(self): + self.execute(legacy=True) + + +if __name__ == "__main__": + unittest.main()