212 lines
9.4 KiB
Python
212 lines
9.4 KiB
Python
"""Exercise embedded migration gates against the roles' normal ACL saves."""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
from cpp_source import body
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SOURCE = r'''
|
|
#include <cassert>
|
|
#include <cstdint>
|
|
#include <type_traits>
|
|
#include "helpers/LazyPersistence.h"
|
|
#define LAZY_CONTACTS_WRITE_DELAY 5000
|
|
#define MESH_DEBUG_PRINTLN(...) ((void)0)
|
|
static uint32_t now = 70000;
|
|
static bool millisHasNowPassed(unsigned long deadline) {
|
|
return int32_t(now - uint32_t(deadline)) > 0;
|
|
}
|
|
static unsigned long futureMillis(uint32_t delay) { return uint32_t(now + delay); }
|
|
struct Base { virtual bool prepareForOtaReboot() { return true; } };
|
|
struct Role : Base {
|
|
using MyMesh = Role;
|
|
unsigned long dirty_contacts_expiry = 75000;
|
|
uint8_t contacts_save_failures = 0;
|
|
static bool saveFilter(int*) { return true; }
|
|
void* _fs = nullptr;
|
|
struct Acl {
|
|
bool succeeds = false;
|
|
unsigned saves = 0;
|
|
bool save(void*, bool (*)(int*) = nullptr) { saves++; return succeeds; }
|
|
} acl;
|
|
@GATE@
|
|
void servicePersistence() {
|
|
@SAVE@
|
|
}
|
|
};
|
|
int main() {
|
|
Role role;
|
|
#ifdef OTA_BOOTLOADER_MIGRATION
|
|
static_assert(std::is_same<decltype(&Role::prepareForOtaReboot), bool (Role::*)()>::value,
|
|
"embedded role must override the reboot gate");
|
|
assert(!role.prepareForOtaReboot());
|
|
role.servicePersistence();
|
|
assert(role.acl.saves == 0 && !role.prepareForOtaReboot());
|
|
now = 75001;
|
|
role.servicePersistence();
|
|
assert(role.acl.saves == 1 && role.contacts_save_failures == 1);
|
|
assert(!role.prepareForOtaReboot());
|
|
// Repeated migration polls must not bypass the failed save's backoff.
|
|
now = 80000;
|
|
for (unsigned poll = 0; poll < 100; poll++) {
|
|
assert(!role.prepareForOtaReboot());
|
|
role.servicePersistence();
|
|
}
|
|
assert(role.acl.saves == 1);
|
|
role.acl.succeeds = true;
|
|
now = 80002;
|
|
role.servicePersistence();
|
|
assert(role.acl.saves == 2 && role.contacts_save_failures == 0);
|
|
assert(role.dirty_contacts_expiry == 0 && role.prepareForOtaReboot());
|
|
#else
|
|
// Ordinary firmware retains the base implementation without an override.
|
|
assert(role.prepareForOtaReboot());
|
|
static_assert(std::is_same<decltype(&Role::prepareForOtaReboot), bool (Base::*)()>::value,
|
|
"ordinary role must retain its base reboot gate");
|
|
#endif
|
|
}
|
|
'''
|
|
|
|
MESH_SOURCE = r'''
|
|
#include <cassert>
|
|
namespace ota {
|
|
static bool due = false, pending = false;
|
|
static unsigned context_polls = 0, ticks = 0;
|
|
static bool last_ready = false, last_idle = false;
|
|
struct OtaContext {
|
|
bool idle = true;
|
|
bool embeddedBootloaderIdle() const { return idle; }
|
|
};
|
|
static OtaContext* active = nullptr;
|
|
static bool bootloader_migration_service_due() { return due; }
|
|
static bool bootloader_migration_pending() { return pending; }
|
|
static const OtaContext* ota_context_if_active() { context_polls++; return active; }
|
|
static void bootloader_migration_tick(bool ready, bool idle) {
|
|
ticks++; last_ready = ready; last_idle = idle;
|
|
}
|
|
}
|
|
struct Mesh {
|
|
bool radio_busy = false, persistence_ready = false;
|
|
unsigned persistence_checks = 0;
|
|
bool hasPendingRadioWork() const { return radio_busy; }
|
|
bool prepareForOtaReboot() { persistence_checks++; return persistence_ready; }
|
|
void service() { @SERVICE@ }
|
|
};
|
|
int main() {
|
|
Mesh mesh;
|
|
mesh.service();
|
|
assert(ota::ticks == 0 && ota::context_polls == 0);
|
|
ota::due = true;
|
|
mesh.service();
|
|
// Startup checks work with a released workspace and no persistence flush.
|
|
assert(ota::ticks == 1 && ota::context_polls == 1);
|
|
assert(ota::last_ready && ota::last_idle && mesh.persistence_checks == 0);
|
|
ota::pending = true;
|
|
mesh.service();
|
|
assert(!ota::last_ready && ota::last_idle && mesh.persistence_checks == 1);
|
|
mesh.persistence_ready = true;
|
|
mesh.service();
|
|
assert(ota::last_ready && ota::last_idle && mesh.persistence_checks == 2);
|
|
mesh.radio_busy = true;
|
|
mesh.service();
|
|
assert(!ota::last_ready && ota::last_idle && mesh.persistence_checks == 2);
|
|
mesh.radio_busy = false;
|
|
ota::OtaContext context;
|
|
context.idle = false;
|
|
ota::active = &context;
|
|
mesh.service();
|
|
assert(!ota::last_ready && !ota::last_idle && mesh.persistence_checks == 2);
|
|
ota::pending = false;
|
|
context.idle = true;
|
|
mesh.service();
|
|
assert(ota::last_ready && ota::last_idle && mesh.persistence_checks == 2);
|
|
}
|
|
'''
|
|
|
|
|
|
class BootloaderMigrationRolePersistenceTest(unittest.TestCase):
|
|
def test_both_roles_initialize_the_lock_after_mesh_with_durable_storage_only(self):
|
|
for role in ("simple_repeater", "simple_room_server"):
|
|
with self.subTest(role=role):
|
|
source = (ROOT / "examples" / role / "main.cpp").read_text(encoding="utf-8")
|
|
setup = body(source, "void setup()")
|
|
startup = re.search(
|
|
r"#if defined\(OTA_BOOTLOADER_MIGRATION\)\n"
|
|
r"\s*mesh::ota::bootloader_migration_begin\("
|
|
r"volatile_primary_fs \? nullptr : fs\);\n#endif", setup)
|
|
self.assertIsNotNone(startup)
|
|
self.assertEqual(setup.count("bootloader_migration_begin("), 1)
|
|
self.assertLess(setup.index("the_mesh.begin(fs);"), startup.start())
|
|
self.assertLess(setup.index("internalPrimaryFilesystemReady("), startup.start())
|
|
self.assertLess(setup.index("volatile_primary_fs = true;"), startup.start())
|
|
|
|
def test_mesh_services_startup_checks_without_allocating_an_ota_workspace(self):
|
|
source = (ROOT / "src/Mesh.cpp").read_text(encoding="utf-8")
|
|
maintenance = body(source, "void __attribute__((noinline)) Mesh::serviceLoopMaintenance()")
|
|
service = body(maintenance, "if (ota::bootloader_migration_service_due())")
|
|
dynamic_context = maintenance.index("#if OTA_DYNAMIC_CONTEXT")
|
|
self.assertLess(maintenance.index(service) + len(service), dynamic_context)
|
|
released = body(maintenance[dynamic_context:], "if (!ota::ota_context_if_active())")
|
|
self.assertIn("return;", released)
|
|
# Allocating ota_ctx() merely to poll would defeat dynamic-context RAM savings.
|
|
self.assertNotRegex(service, r"\bota_ctx\s*\(")
|
|
compiler = shutil.which("g++") or shutil.which("clang++")
|
|
if compiler is None:
|
|
self.skipTest("a host C++17 compiler is required")
|
|
sanitize = [] if os.name == "nt" else [
|
|
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
|
|
with tempfile.TemporaryDirectory(prefix="migration-mesh-service-") as directory:
|
|
unit = Path(directory) / "service.cpp"
|
|
binary = Path(directory) / ("service.exe" if os.name == "nt" else "service")
|
|
unit.write_text(MESH_SOURCE.replace("@SERVICE@", service), encoding="ascii")
|
|
built = subprocess.run([
|
|
compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror",
|
|
*sanitize, str(unit), "-o", str(binary),
|
|
], capture_output=True, text=True, timeout=60)
|
|
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
|
tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30)
|
|
self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr)
|
|
|
|
def test_repeater_and_room_wait_for_the_normal_acl_save(self):
|
|
compiler = shutil.which("g++") or shutil.which("clang++")
|
|
if compiler is None:
|
|
self.skipTest("a host C++17 compiler is required")
|
|
sanitize = [] if os.name == "nt" else [
|
|
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
|
|
with tempfile.TemporaryDirectory(prefix="migration-role-persistence-") as directory:
|
|
for role in ("simple_repeater", "simple_room_server"):
|
|
header = (ROOT / "examples" / role / "MyMesh.h").read_text(encoding="utf-8")
|
|
source = (ROOT / "examples" / role / "MyMesh.cpp").read_text(encoding="utf-8")
|
|
gate = re.search(
|
|
r"#if defined\(OTA_BOOTLOADER_MIGRATION\)\n"
|
|
r"\s*bool prepareForOtaReboot\(\) override \{.*?\n\s*\}\n#endif",
|
|
header, re.DOTALL)
|
|
self.assertIsNotNone(gate, role)
|
|
save = body(source, "if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry))")
|
|
generated = SOURCE.replace("@GATE@", gate.group()).replace("@SAVE@", save)
|
|
for embedded in (False, True):
|
|
with self.subTest(role=role, embedded=embedded):
|
|
unit = Path(directory) / (role + ".cpp")
|
|
binary = Path(directory) / (role + (".exe" if os.name == "nt" else ""))
|
|
unit.write_text(generated, encoding="ascii")
|
|
built = subprocess.run([
|
|
compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror",
|
|
"-Wno-unused-function", *sanitize,
|
|
*(["-DOTA_BOOTLOADER_MIGRATION=1"] if embedded else []),
|
|
"-I", str(ROOT / "src"), str(unit), "-o", str(binary),
|
|
], capture_output=True, text=True, timeout=60)
|
|
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
|
tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30)
|
|
self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|