"""Exercise actual scope table owners, persistence and rollback in both RAM backends.""" from pathlib import Path import os import resource import signal import subprocess import tempfile import unittest from cpp_source import body from test_flood_advert_psram import HEAP ROOT = Path(__file__).resolve().parents[1] SDK = ("#define CONFIG_IDF_TARGET_ESP32S3 1\n" "#define CONFIG_SPIRAM_USE_MALLOC 1\n") DEFINES = ["ESP32_PLATFORM=1", "BOARD_HAS_PSRAM=1", "STATION_G3_ESP32=1", "MESH_CLIENT_REPEATER_ONLY=1"] PRELUDE = r''' #include #include #include #include #include #include #include #include #include #include #define CHECK(value) do { if (!(value)) std::abort(); } while (0) #define FLOOD_CHANNEL_SCOPE_SLOTS 255 #define FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS 32 #define FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS 255 #define FLOOD_PACKET_FILTER_SCOPE_NAME_LEN 32 #define FLOOD_CHANNEL_SCOPE_TXT_ANY 0 #define FLOOD_CHANNEL_SCOPE_OTHER_ANY 2 #define MESH_ENABLE_FLOOD_RULE_ENGINE 0 #define FLOOD_CHANNEL_SCOPE_FILE "/scopes" #define FLOOD_CHANNEL_SCOPE_TEMP_FILE "/scopes.tmp" #define FLOOD_CHANNEL_SCOPE_REQUIRE_FILE "/require" #define FLOOD_CHANNEL_SCOPE_REQUIRE_TEMP_FILE "/require.tmp" struct RegionMap { static bool is_name_char(uint8_t c) { return std::isalnum(c) || c == '_' || c == '-'; } }; namespace mesh { struct Utils { static void sha256(void* out, size_t size, const uint8_t*, size_t length) { CHECK(size == 1 && (length == 16 || length == 32)); std::memset(out, 0, size); } }; } static unsigned external_io = 0; static void inspectSpan(const void* data, size_t size) { const uintptr_t address = reinterpret_cast(data); for (const auto& block : heap_test::heap().blocks) { const uintptr_t start = reinterpret_cast(block.first); if (address >= start && address < start + block.second.bytes) { CHECK(size <= start + block.second.bytes - address); if (block.second.caps & MALLOC_CAP_SPIRAM) ++external_io; } } } struct MemoryFS; struct File { MemoryFS* fs = nullptr; std::string path; size_t cursor = 0; bool valid = false; explicit operator bool() const { return valid; } size_t read(uint8_t*, size_t); size_t write(const uint8_t*, size_t); void close() { valid = false; } }; struct MemoryFS { std::map> files; bool fail_rename = false, short_write = false; bool exists(const char* path) { return files.count(path); } bool remove(const char* path) { return files.erase(path); } bool rename(const char* from, const char* to) { if (fail_rename || !files.count(from)) return false; files[to] = files[from]; files.erase(from); return true; } }; size_t File::read(uint8_t* out, size_t size) { if (!valid) return 0; const auto& bytes = fs->files[path]; size = std::min(size, bytes.size() - cursor); inspectSpan(out, size); if (size) std::memcpy(out, bytes.data() + cursor, size); cursor += size; return size; } size_t File::write(const uint8_t* data, size_t size) { if (!valid) return 0; if (fs->short_write && size) --size; inspectSpan(data, size); auto& bytes = fs->files[path]; bytes.insert(bytes.end(), data, data + size); return size; } static File openFloodSettingsRead(MemoryFS* fs, const char* path) { return File{fs, path, 0, fs->exists(path)}; } static File openFloodSettingsWrite(MemoryFS* fs, const char* path) { fs->files[path].clear(); return File{fs, path, 0, true}; } ''' SCENARIOS = r''' static std::vector snapshot(const MyMesh& mesh) { std::vector bytes; const auto add = [&](const void* data, size_t size) { const auto first = static_cast(data); bytes.insert(bytes.end(), first, first + size); }; add(mesh.floodChannelScopes(), sizeof(mesh.floodChannelScopes())); add(mesh.floodChannelDirectScopes(), sizeof(mesh.floodChannelDirectScopes())); add(mesh.floodChannelScopeRequirements(), sizeof(mesh.floodChannelScopeRequirements())); return bytes; } static void populate(MyMesh& mesh) { for (unsigned i = 0; i < 32; ++i) std::snprintf(mesh.floodChannelDirectScopes()[i], 32, "#Scope%u", i); for (unsigned i = 0; i < 255; ++i) { auto& row = mesh.floodChannelScopes()[i]; row.target_id = i % 32 + 1; row.selector = FloodFilterPolicy::encodeChannelScopeTargetSelector(0, true); auto& requirement = mesh.floodChannelScopeRequirements()[i]; requirement.key_len = 32; std::memset(requirement.secret, uint8_t(i + 1), sizeof(requirement.secret)); } } static void exercise(const char* mode) { MemoryFS disk; { const unsigned constructor_calls = heap_test::heap().external_calls + heap_test::heap().internal_calls; MyMesh writer(disk); CHECK(heap_test::heap().external_calls + heap_test::heap().internal_calls == constructor_calls); writer.begin(); static_assert(sizeof(writer.floodChannelScopes()) == 255 * 36, "all scope slots"); static_assert(sizeof(writer.floodChannelDirectScopes()) == 32 * 32, "all direct names"); static_assert(sizeof(writer.floodChannelScopeRequirements()) == 255 * 34, "all requirements"); #if MESH_REPEATER_PSRAM_TABLES static_assert(sizeof(MyMesh) < 256, "no inline fallback arrays"); auto& heap = heap_test::heap(); CHECK(heap.blocks.size() == 1); CHECK(heap.last_bytes == 18874); const auto caps = heap.blocks.begin()->second.caps; CHECK(caps == (!std::strcmp(mode, "fallback") ? (MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) : (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT))); const unsigned calls = heap.external_calls + heap.internal_calls; CHECK(writer.flood_channel_storage.begin()); CHECK(heap.external_calls + heap.internal_calls == calls); #else CHECK(heap_test::heap().blocks.empty()); (void)mode; #endif CHECK(snapshot(writer) == std::vector(18874, 0)); const void* scopes = writer.floodChannelScopes(); const void* direct = writer.floodChannelDirectScopes(); const void* require = writer.floodChannelScopeRequirements(); populate(writer); const auto initial = snapshot(writer); writer.begin(); CHECK(snapshot(writer) == initial); CHECK(writer.saveFloodChannelScopes()); CHECK(writer.saveFloodChannelScopeRequirements()); CHECK(disk.files["/scopes"].size() == 10210); CHECK(disk.files["/require"].size() == 8675); std::vector expected = {'F','C','S','5',255}; const auto* start = reinterpret_cast(writer.floodChannelScopes()); expected.insert(expected.end(), start, start + 9180); expected.push_back(32); start = reinterpret_cast(writer.floodChannelDirectScopes()); expected.insert(expected.end(), start, start + 1024); CHECK(disk.files["/scopes"] == expected); expected = {'F','C','R','1',255}; start = reinterpret_cast(writer.floodChannelScopeRequirements()); expected.insert(expected.end(), start, start + 8670); CHECK(disk.files["/require"] == expected); const auto saved = disk.files; char reply[160]; for (bool short_write : {false, true}) { disk.fail_rename = !short_write; disk.short_write = short_write; writer.deleteFloodChannelScope(".255", reply); CHECK(!std::strcmp(reply, "Err - save failed")); CHECK(snapshot(writer) == initial && disk.files == saved); writer.deleteFloodChannelScopeRequirement(".255", reply); CHECK(!std::strcmp(reply, "Err - save failed")); CHECK(snapshot(writer) == initial && disk.files == saved); writer.deleteFloodChannelScope("all", reply); CHECK(!std::strcmp(reply, "Err - save failed")); CHECK(snapshot(writer) == initial && disk.files == saved); writer.deleteFloodChannelScopeRequirement("all", reply); CHECK(!std::strcmp(reply, "Err - save failed")); CHECK(snapshot(writer) == initial && disk.files == saved); } disk.fail_rename = disk.short_write = false; std::memset(writer.floodChannelScopes(), 0xa5, sizeof(writer.floodChannelScopes())); std::memset(writer.floodChannelDirectScopes(), 0xa5, sizeof(writer.floodChannelDirectScopes())); std::memset(writer.floodChannelScopeRequirements(), 0xa5, sizeof(writer.floodChannelScopeRequirements())); CHECK(writer.loadFloodChannelScopes()); writer.loadFloodChannelScopeRequirements(); CHECK(snapshot(writer) == initial); disk.files["/scopes"].pop_back(); disk.files["/require"].pop_back(); CHECK(!writer.loadFloodChannelScopes()); writer.loadFloodChannelScopeRequirements(); CHECK(snapshot(writer) == std::vector(18874, 0)); disk.files = saved; CHECK(writer.loadFloodChannelScopes()); writer.loadFloodChannelScopeRequirements(); CHECK(snapshot(writer) == initial); CHECK(scopes == writer.floodChannelScopes() && direct == writer.floodChannelDirectScopes() && require == writer.floodChannelScopeRequirements()); // Compare the complete production file payloads between inline and PSRAM runs. for (const char* path : {"/scopes", "/require"}) { const auto& bytes = disk.files[path]; CHECK(std::fwrite(bytes.data(), 1, bytes.size(), stdout) == bytes.size()); } writer.deleteFloodChannelScope(".255", reply); CHECK(!std::strcmp(reply, "OK") && writer.floodChannelScopes()[254].target_id == 0); writer.deleteFloodChannelScopeRequirement(".255", reply); CHECK(!std::strcmp(reply, "OK") && writer.floodChannelScopeRequirements()[254].key_len == 0); writer.deleteFloodChannelScope("all", reply); CHECK(!std::strcmp(reply, "OK")); writer.deleteFloodChannelScopeRequirement("all", reply); CHECK(!std::strcmp(reply, "OK")); CHECK(snapshot(writer) == std::vector(18874, 0)); } CHECK(heap_test::heap().blocks.empty()); MyMesh recreated(disk); CHECK(heap_test::heap().blocks.empty()); recreated.begin(); CHECK(snapshot(recreated) == std::vector(18874, 0)); } int main(int argc, char** argv) { const char* mode = argc > 1 ? argv[1] : "trace"; auto& heap = heap_test::heap(); if (std::strcmp(mode, "trace")) heap.psram = false; if (!std::strcmp(mode, "no-memory")) heap.internal = false; if (!std::strcmp(mode, "reserve")) heap.dma_free = 16384 + 18874 - 1; if (!std::strcmp(mode, "fragmented")) heap.dma_largest = 18874 - 1; if (!std::strcmp(mode, "boundary")) { heap.dma_free = 16384 + 18874; heap.dma_largest = 18874; mode = "fallback"; } const size_t free_before = heap.dma_free; exercise(mode); CHECK(heap.blocks.empty() && heap.dma_free == free_before); #if MESH_REPEATER_PSRAM_TABLES CHECK(heap.external_calls == 2 && heap.frees == 2); CHECK(heap.internal_calls == (!std::strcmp(mode, "fallback") ? 2u : 0u)); CHECK(external_io > 0 || !std::strcmp(mode, "fallback")); #else CHECK(heap.external_calls == 0 && heap.internal_calls == 0 && heap.frees == 0); #endif } ''' def production_program(): header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text(encoding="ascii") source = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text(encoding="ascii") location = header.index(" struct FloodChannelStorage {") start = header.rfind("#if MESH_REPEATER_PSRAM_TABLES", 0, location) declarations = header[start:header.index("#if MESH_ENABLE_FLOOD_GROUP_MODERATION", location)] constructor = source.index("MyMesh::MyMesh(") start = source.index("#if !MESH_REPEATER_PSRAM_TABLES\n memset(flood_channel_scopes", constructor) resets = source[start:source.index("#if MESH_ENABLE_FLOOD_GROUP_MODERATION", start)] begin_start = source.index("void MyMesh::begin(FILESYSTEM *fs) {") begin_start = source.index("{", begin_start) + 1 begin = source[begin_start:source.index("#if MESH_ENABLE_FLOOD_RULE_ENGINE", begin_start)] methods = [ "bool MyMesh::loadFloodChannelScopes(", "bool MyMesh::saveFloodChannelScopes(", "void MyMesh::loadFloodChannelScopeRequirements(", "bool MyMesh::saveFloodChannelScopeRequirements(", "void MyMesh::deleteFloodChannelScope(", "void MyMesh::deleteFloodChannelScopeRequirement(", ] helpers = [ "static const char* skipFloodFilterSpaces(", "static bool floodFilterAsciiEqual(", "static bool parseFloodFilterUnsigned(", "static bool parseFloodChannelScopeIndex(", "static bool isValidStoredFloodFilterScopeName(", "static bool isExactFloodChannelScopeSelector(", ] owner = ("class MyMesh {\npublic:\nMemoryFS* _fs;\n" "struct Limiter { bool begin() { return true; } } flood_advert_limiter;\n") for name in ("FloodChannelScopeEntry", "FloodChannelScopeRequireEntry"): owner += body(header, "struct " + name + " {") + ";\n" owner += declarations owner += "explicit MyMesh(MemoryFS& fs) : _fs(&fs) {\n" + resets + "}\n" owner += "void begin() {\n" + begin + "}\n" owner += ("bool loadFloodChannelScopes();\n" "bool saveFloodChannelScopes(bool empty_table=false);\n" "void loadFloodChannelScopeRequirements();\n" "bool saveFloodChannelScopeRequirements(bool empty_table=false);\n" "void deleteFloodChannelScope(const char*, char*);\n" "void deleteFloodChannelScopeRequirement(const char*, char*);\n};\n") return "\n".join([PRELUDE, owner] + [body(source, item) for item in helpers + methods] + [SCENARIOS]) class RepeaterScopePsramTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.temporary = tempfile.TemporaryDirectory(prefix="repeater-scope-psram-") cls.work = Path(cls.temporary.name) (cls.work / "esp_heap_caps.h").write_text(HEAP, encoding="ascii") (cls.work / "sdkconfig.h").write_text(SDK, encoding="ascii") (cls.work / "scope.cpp").write_text(production_program(), encoding="ascii") cls.compiler = [os.environ.get("CXX", "g++")] cls.binaries = {} @classmethod def tearDownClass(cls): cls.temporary.cleanup() def build(self, enabled): if enabled not in self.binaries: binary = self.work / ("scope-%d" % enabled) command = self.compiler + ["-std=c++17", "-O1", "-g", "-DNDEBUG", "-Wall", "-Wextra", "-Werror", "-Wno-sign-compare", "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", "-I", str(self.work), "-isystem", str(ROOT / "src"), "-isystem", str(ROOT / "test/mocks")] command += ["-D" + define for define in DEFINES] command += ["-DMESH_REPEATER_PSRAM_TABLES=%d" % enabled, str(self.work / "scope.cpp"), "-o", str(binary)] result = subprocess.run(command, capture_output=True, text=True, timeout=60) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.binaries[enabled] = binary return self.binaries[enabled] def test_full_table_file_bytes_loads_resets_lifetime_and_rollback_match(self): baseline = subprocess.run([str(self.build(0))], capture_output=True, timeout=20) self.assertEqual(baseline.returncode, 0, baseline.stderr.decode()) self.assertEqual(len(baseline.stdout), 18885) for mode in ("trace", "fallback", "boundary"): with self.subTest(mode=mode): result = subprocess.run([str(self.build(1)), mode], capture_output=True, timeout=20) self.assertEqual(result.returncode, 0, result.stderr.decode()) self.assertEqual(result.stdout, baseline.stdout) def test_no_capacity_reduction_or_null_references_on_allocation_failure(self): def no_core(): resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) for mode in ("no-memory", "reserve", "fragmented"): with self.subTest(mode=mode): result = subprocess.run([str(self.build(1)), mode], capture_output=True, text=True, timeout=20, preexec_fn=no_core) self.assertEqual(result.returncode, -signal.SIGABRT, result.stderr) self.assertIn("Cannot allocate channel scope tables (18874 bytes)", result.stderr) self.assertNotIn("runtime error:", result.stderr) self.assertNotIn("AddressSanitizer", result.stderr) def test_default_selection_and_explicit_sdk_prerequisites(self): probe = self.work / "guard.cpp" probe.write_text('#include \n' 'static_assert(MESH_REPEATER_PSRAM_TABLES == EXPECTED, "selection");\n', encoding="ascii") cases = [([], 0, True, SDK), (["ESP32_PLATFORM=1", "STATION_G3_ESP32=1"], 0, True, SDK), (["ESP32_PLATFORM=1", "BOARD_HAS_PSRAM=1"], 0, True, SDK), (DEFINES[:-1], 0, True, SDK), ([define for define in DEFINES if not define.startswith("STATION_G3")], 1, True, SDK), (DEFINES, 1, True, SDK), (DEFINES + ["MESH_REPEATER_PSRAM_TABLES=0"], 0, True, ""), (["MESH_REPEATER_PSRAM_TABLES=1"], 1, False, SDK), (DEFINES[:-1] + ["MESH_REPEATER_PSRAM_TABLES=1"], 1, False, SDK), (DEFINES[:-1] + ["MESH_CLIENT_REPEATER_ONLY=0", "MESH_REPEATER_PSRAM_TABLES=1"], 1, False, SDK), (DEFINES + ["MESH_REPEATER_PSRAM_TABLES=2"], 2, False, SDK), (DEFINES, 0, True, SDK.replace("CONFIG_IDF_TARGET_ESP32S3 1", "CONFIG_IDF_TARGET_ESP32 1")), (DEFINES + ["MESH_REPEATER_PSRAM_TABLES=1"], 1, False, SDK.replace("CONFIG_IDF_TARGET_ESP32S3 1", "CONFIG_IDF_TARGET_ESP32 1")), (DEFINES, 1, True, SDK + "#define CONFIG_SPIRAM_BOOT_INIT 1\n"), (DEFINES, 1, False, SDK.replace("CONFIG_SPIRAM_USE_MALLOC 1", "CONFIG_SPIRAM_USE_MALLOC 0"))] for index, (defines, expected, success, config) in enumerate(cases): with self.subTest(index=index, defines=defines): config_dir = self.work / ("guard-%d" % index) config_dir.mkdir() (config_dir / "sdkconfig.h").write_text(config, encoding="ascii") command = self.compiler + ["-std=c++17", "-fsyntax-only", "-I", str(config_dir), "-I", str(self.work), "-I", str(ROOT / "src"), "-DEXPECTED=%d" % expected] command += ["-D" + define for define in defines] + [str(probe)] result = subprocess.run(command, capture_output=True, text=True, timeout=20) self.assertEqual(result.returncode == 0, success, result.stdout + result.stderr) if __name__ == "__main__": unittest.main()