Keep Companion reboot deadlines armed across millis rollover

This commit is contained in:
mikecarper committed 2026-10-16 05:18:37 -07:00
1 parent c2fd934e1f
commit 67a71d3a61
4 files changed
+272

No files matched your search

+1
View File
@@ -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
+3
View File
@@ -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());
+186
View File
@@ -0,0 +1,186 @@
#include <cassert>
#include <cstdarg>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <initializer_list>
#include <string>
#include <helpers/CLICommandUtils.h>
#include <helpers/LazyPersistence.h>
#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]);
}
+82
View File
@@ -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()