390 lines
18 KiB
Python
390 lines
18 KiB
Python
"""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 <algorithm>
|
|
#include <cctype>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <map>
|
|
#include <string>
|
|
#include <vector>
|
|
#include <esp_heap_caps.h>
|
|
#include <helpers/RepeaterPsramStorage.h>
|
|
#include <helpers/FloodFilterPolicy.h>
|
|
#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<uintptr_t>(data);
|
|
for (const auto& block : heap_test::heap().blocks) {
|
|
const uintptr_t start = reinterpret_cast<uintptr_t>(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<std::string, std::vector<uint8_t>> 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<uint8_t> snapshot(const MyMesh& mesh) {
|
|
std::vector<uint8_t> bytes;
|
|
const auto add = [&](const void* data, size_t size) {
|
|
const auto first = static_cast<const uint8_t*>(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<uint8_t>(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<uint8_t> expected = {'F','C','S','5',255};
|
|
const auto* start = reinterpret_cast<const uint8_t*>(writer.floodChannelScopes());
|
|
expected.insert(expected.end(), start, start + 9180);
|
|
expected.push_back(32);
|
|
start = reinterpret_cast<const uint8_t*>(writer.floodChannelDirectScopes());
|
|
expected.insert(expected.end(), start, start + 1024);
|
|
CHECK(disk.files["/scopes"] == expected);
|
|
expected = {'F','C','R','1',255};
|
|
start = reinterpret_cast<const uint8_t*>(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<uint8_t>(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<uint8_t>(18874, 0));
|
|
}
|
|
CHECK(heap_test::heap().blocks.empty());
|
|
MyMesh recreated(disk);
|
|
CHECK(heap_test::heap().blocks.empty());
|
|
recreated.begin();
|
|
CHECK(snapshot(recreated) == std::vector<uint8_t>(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 <helpers/RepeaterPsramStorage.h>\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()
|