Files
MeshCore/test/test_rak4631_ethernet_runtime.py
T

214 lines
10 KiB
Python

#!/usr/bin/env python3
"""Exercise combined Ethernet on/off, SPI ownership and retained Stream safety."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from test_serial_wifi_sessions import SANITIZER_FLAGS
from test_radio_receive_contract import method
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / "test/fixtures/rak4631_ethernet_runtime"
SCENARIOS = (
"disabled boot and exact command parsing",
"flash conflict or active OTA refuses initialization",
"task allocation failure releases SPI",
"OFF before worker scheduling avoids hardware IO",
"OFF during slow initial DHCP waits for cooperative cleanup",
"OFF wakes DHCP retry promptly",
"missing Ethernet hardware reaps worker before returning SPI",
"online framing, short writes and real remote disconnect",
"slow DHCP renewal gates retained Stream without blocking mesh",
"renewal failure returns to service without restarting worker",
"active or staged OTA refuses OFF",
"remote OFF reply precedes socket cleanup and synchronous task reclamation",
"OTA race retains SPI owner while main-thread release retries",
"optional setting-save failure is transactional",
"repeated hot switches reclaim workers without stale notifications",
"saved-ON boot does not save twice",
"OFF during slow renewal never releases SPI early",
"bounded ring queue retains order and prevents command dispatch under backpressure",
"oversized line discards the whole command prefix",
"embedded NUL discards the whole record",
"missing TCP acknowledgement holds the SPI gate without blocking mesh",
"stuck SEND command faults without vendor RX or accept",
"remote OFF acknowledgement is queued and missing ACK shutdown is bounded",
"fragmented oversized record discard is bounded between loop turns",
"newly stuck RECV command yields without a vendor command spin",
"delayed terminal RECV retains its synchronous response",
"new peer cannot receive previous peer synchronous or retained async output",
"partial record from a replaced peer cannot concatenate with new input",
"concurrent worker replacement cannot steal a record's response owner",
"never-started terminal shutdown leaves storage bus pads untouched",
"terminal DHCP timeout retains live SPI without task deletion or preference changes",
"online terminal shutdown completes owner handoff and is idempotent",
"terminal release refusal remains fail-closed until OTA owner permits handoff",
"terminal renewal timeout retains active worker and SPI until cooperative completion",
"terminal stop deadline survives millis rollover",
)
class Rak4631EthernetRuntimeTests(unittest.TestCase):
def test_fixture_retries_busy_and_short_writes_without_duplicates(self):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
if compiler is None:
self.skipTest("a host C++17 compiler is required")
fixture = (FIXTURE / "test.cpp").read_text(encoding="ascii")
waiter = method(fixture, "static void wait_until(")
writer = method(fixture, "static void write_all(")
harness = r'''
#include <cassert>
#include <chrono>
#include <cstdint>
#include <functional>
#include <string>
#include <thread>
struct Client {
unsigned calls = 0;
std::string output;
size_t write(const uint8_t* data, size_t size) {
const size_t capacity[] = {0, 2, 0, 1, 1, 1};
assert(calls < sizeof(capacity) / sizeof(capacity[0]));
const size_t offered = capacity[calls++];
const size_t accepted = size < offered ? size : offered;
output.append(reinterpret_cast<const char*>(data), accepted);
return accepted;
}
} ethernet_client;
@WAITER@
@WRITER@
int main() {
const uint8_t payload[] = {'a', 'b', 'c', 'd', 'e'};
write_all(payload, sizeof(payload));
assert(ethernet_client.output == "abcde" && "busy/short retries must preserve exact bytes");
assert(ethernet_client.calls == 6);
}
'''
with tempfile.TemporaryDirectory(prefix="meshcore-ethernet-fixture-write-") as directory:
for name, implementation in (
("retry-unsent", writer),
("single-attempt", "static void write_all(const uint8_t* data, size_t size) {\n"
"wait_until([] { return true; });\n"
"assert(ethernet_client.write(data, size) == size);\n}"),
("duplicate-prefix", writer.replace("data + accepted", "data")),
):
with self.subTest(implementation=name):
binary = Path(directory) / name
compiled = subprocess.run(
[compiler, "-std=c++17", "-pthread", "-Wall", "-Wextra", "-Werror",
*SANITIZER_FLAGS, "-x", "c++", "-", "-o", str(binary)],
input=harness.replace("@WAITER@", waiter).replace("@WRITER@", implementation),
capture_output=True, text=True, timeout=60)
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
if name == "retry-unsent":
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
else:
self.assertNotEqual(checked.returncode, 0)
self.assertIn("Assertion", checked.stderr)
def test_unblock_holds_predicate_mutex_before_notifying(self):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
if compiler is None:
self.skipTest("a host C++17 compiler is required")
helper = method((FIXTURE / "test.cpp").read_text(), "static void unblock(")
guard = "std::lock_guard<std::mutex> lock(library_mutex);"
self.assertEqual(helper.count(guard), 1)
harness = r'''
#include <atomic>
#include <cassert>
#include <chrono>
#include <mutex>
#include <thread>
struct TrackedMutex {
std::mutex underlying;
std::atomic<bool> lock_attempted{false};
void lock() {
lock_attempted.store(true);
underlying.lock();
}
void unlock() { underlying.unlock(); }
} library_mutex;
struct Notification {
unsigned count = 0;
void notify_all() { ++count; }
} library_changed;
@UNBLOCK@
int main() {
std::atomic<bool> blocked{true}, completed{false};
// Hold the waiter's mutex across its predicate-to-wait boundary. The
// notifier must attempt this mutex before it can change the predicate.
library_mutex.underlying.lock();
std::thread notifier([&] {
unblock(blocked);
completed.store(true);
});
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3);
while (!library_mutex.lock_attempted.load() && !completed.load()) {
assert(std::chrono::steady_clock::now() < deadline && "notifier did not run");
std::this_thread::yield();
}
assert(library_mutex.lock_attempted.load() && "unblock bypassed predicate mutex");
assert(blocked.load() && "predicate changed before waiter released its mutex");
library_mutex.underlying.unlock();
notifier.join();
assert(!blocked.load() && library_changed.count == 1);
}
'''
with tempfile.TemporaryDirectory(prefix="meshcore-rak4631-unblock-") as directory:
for name, implementation in (
("guarded", helper),
("unguarded", helper.replace(guard, "")),
):
with self.subTest(implementation=name):
binary = Path(directory) / name
# Track only mutex acquisition; execute the actual helper's
# predicate update and notification in both variants.
source = harness.replace("@UNBLOCK@", implementation.replace("std::mutex", "TrackedMutex"))
compiled = subprocess.run(
[compiler, "-std=c++17", "-pthread", "-Wall", "-Wextra", "-Werror",
*SANITIZER_FLAGS, "-x", "c++", "-", "-o", str(binary)],
input=source, capture_output=True, text=True, timeout=60,
)
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
if name == "guarded":
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
else:
self.assertNotEqual(checked.returncode, 0)
self.assertIn("unblock bypassed predicate mutex", checked.stderr)
def test_production_runtime_with_background_dhcp_and_spi_gate(self):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
if compiler is None:
self.skipTest("a host C++17 compiler is required")
with tempfile.TemporaryDirectory(prefix="meshcore-rak4631-ethernet-runtime-") as directory:
binary = Path(directory) / "ethernet-runtime"
compiled = subprocess.run(
[compiler, "-std=c++17", "-pthread", "-Wall", "-Wextra", "-Werror",
*SANITIZER_FLAGS, "-DETHERNET_ENABLED=1", "-DRAK4631_COMBINED_ETHERNET=1",
f"-I{FIXTURE / 'mocks'}", f"-I{ROOT / 'src'}",
str(FIXTURE / "test.cpp"), "-o", str(binary)],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
cases = list(enumerate(SCENARIOS))
cases.extend((7, f"online ownership race repeat {index}") for index in range(32))
for scenario, description in cases:
with self.subTest(scenario=description):
checked = subprocess.run([str(binary), str(scenario)],
capture_output=True, text=True, timeout=10)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn(f"PASS: RAK4631 Ethernet runtime scenario {scenario}", checked.stdout)
if __name__ == "__main__":
unittest.main()