Files
radar_system/data_acq_and_processing/tests/native_regressions.cpp
T
2026-10-05 16:46:28 +03:00

399 lines
18 KiB
C++

#include "capture_file_writer.h"
#include "channel_bundle_support.hpp"
#include "locator/tcp_server.hpp"
#include "processing_live_config.hpp"
#include "runtime_wait.hpp"
#include "shm_ring.hpp"
#include "sweep_orchestrator.hpp"
#include "tty_protocol_writer.h"
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
#include <array>
#include <atomic>
#include <chrono>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <limits>
#include <random>
#include <stdexcept>
#include <thread>
#include <nlohmann/json.hpp>
namespace {
using namespace radar;
using Json = nlohmann::json;
void require(bool condition, const char* message) {
if (!condition) {
throw std::runtime_error(message);
}
}
template <typename Callable>
void expect_error(Callable operation) {
try {
operation();
} catch (const std::bad_alloc&) {
throw; // A malformed count must be rejected before allocating.
} catch (const std::exception&) {
return;
}
throw std::runtime_error("Expected operation to reject malformed input");
}
struct Workspace {
std::filesystem::path path;
Workspace() {
char pattern[] = "/tmp/radar-native-XXXXXX";
const auto* created = ::mkdtemp(pattern);
require(created != nullptr, "mkdtemp failed");
path = created;
}
~Workspace() { std::filesystem::remove_all(path); }
};
struct RingName {
std::string name = "/radar_native_test_" + std::to_string(::getpid());
RingName() { ipc::ShmRing::unlink_ring(name); }
~RingName() { ipc::ShmRing::unlink_ring(name); }
};
void write_u32(std::vector<std::uint8_t>& bytes, std::size_t offset, std::uint32_t value) {
require(offset + sizeof(value) <= bytes.size(), "invalid test offset");
std::memcpy(bytes.data() + offset, &value, sizeof(value));
}
void test_wire_decoders() {
ipc::RawSweepCollection raw{.collection_id = 7, .monotonic_ns = 42,
.capture_start_ns = 21, .capture_end_ns = 40,
.traces = {{.combo = {1, 2}, .frequency_hz = {1, 2},
.s11 = {{1, 2}, {3, 4}}, .s21 = {{5, 6}, {7, 8}},
.capture_start_ns = 25, .capture_end_ns = 39}}};
ipc::ResultCollection results{.collection_id = 7, .processing_duration_ns = 17,
.collection_payloads = {
{.kind = ipc::ResultKind::TraceComplex, .frequency_hz = {1}, .trace = {{2, 3}}},
{.kind = ipc::ResultKind::ScalarF32, .scalar_value = 4},
{.kind = ipc::ResultKind::ImageF32, .image_x_axis = {1, 2},
.image_y_axis = {3}, .image_values = {4, 5}},
{.kind = ipc::ResultKind::TableF32, .table_columns = 2, .table_values = {6, 7}},
{.kind = ipc::ResultKind::ImageF32, .image_y_axis = {1}},
{.kind = ipc::ResultKind::TableF32, .table_columns = 3}},
.blocks = {{.combo = {1, 2}, .payloads = {{.kind = ipc::ResultKind::ScalarF32}}}}};
const auto raw_bytes = ipc::serialize_raw_collection(raw);
const auto pre_bytes = ipc::serialize_preprocessed_collection(raw);
const auto result_bytes = ipc::serialize_result_collection(results);
require(ipc::serialize_raw_collection(ipc::deserialize_raw_collection(raw_bytes)) == raw_bytes,
"raw roundtrip changed data");
require(ipc::serialize_preprocessed_collection(ipc::deserialize_preprocessed_collection(pre_bytes)) == pre_bytes,
"preprocessed roundtrip changed data");
require(ipc::serialize_result_collection(ipc::deserialize_result_collection(result_bytes)) == result_bytes,
"result roundtrip changed data or empty shape");
for (std::size_t size = 0; size < result_bytes.size(); ++size) {
expect_error([&] { (void)ipc::deserialize_result_collection(std::span(result_bytes).first(size)); });
}
for (std::size_t count_offset : {28U, 32U, 39U}) {
auto corrupt = result_bytes;
write_u32(corrupt, count_offset, std::numeric_limits<std::uint32_t>::max());
expect_error([&] { (void)ipc::deserialize_result_collection(corrupt); });
}
for (auto kind : {ipc::ResultKind::ImageF32, ipc::ResultKind::TableF32}) {
ipc::ResultCollection empty{.collection_payloads = {{.kind = kind}}};
auto corrupt = ipc::serialize_result_collection(empty);
write_u32(corrupt, 39, std::numeric_limits<std::uint32_t>::max());
write_u32(corrupt, 43, std::numeric_limits<std::uint32_t>::max());
expect_error([&] { (void)ipc::deserialize_result_collection(corrupt); });
}
// Deterministic random mutations include wire-compatible valid frames as seeds.
std::mt19937 random(0x52414452U);
for (unsigned iteration = 0; iteration < 10'000U; ++iteration) {
auto bytes = iteration % 3U == 0U ? raw_bytes : iteration % 3U == 1U ? pre_bytes : result_bytes;
for (unsigned edit = 0; edit < 1U + random() % 5U; ++edit) {
bytes[random() % bytes.size()] = static_cast<std::uint8_t>(random());
}
if (iteration % 4U == 0U) {
bytes.resize(random() % bytes.size());
}
try {
if (iteration % 3U == 0U) { (void)ipc::deserialize_raw_collection(bytes); }
else if (iteration % 3U == 1U) { (void)ipc::deserialize_preprocessed_collection(bytes); }
else { (void)ipc::deserialize_result_collection(bytes); }
} catch (const std::bad_alloc&) { throw; }
catch (const std::runtime_error&) {}
}
}
void test_configuration(const std::string& seed_path, const Workspace& workspace) {
std::ifstream input(seed_path);
const auto seed = Json::parse(input);
const auto config_path = workspace.path / "config.json";
const auto save = [&](const Json& config) { std::ofstream(config_path) << config; };
save(seed);
const auto config = config::load_run_config(config_path.string());
for (const auto& [pointer, value] : std::array<std::pair<const char*, double>, 8>{{
{"/radar/sweep/start_hz", -1}, {"/radar/sweep/stop_hz", 1e100},
{"/radar/sweep/if_bandwidth_hz", 0}, {"/radar/sweep/points", 0},
{"/radar/sweep/points", 4294967296.0}, {"/run/combos/0/input", 0.0000001},
{"/rings/raw/slot_size_bytes", 65}, {"/gpr/rx_geometry/0/x_m", 1e100}}}) {
auto invalid = seed;
invalid[Json::json_pointer(pointer)] = value;
save(invalid);
expect_error([&] { (void)config::load_run_config(config_path.string()); });
}
const auto live_path = workspace.path / "live.json";
std::ofstream(live_path) << Json{{"bscan_gain", 2}, {"settings_revision", 11}};
processing::ProcessingLiveConfigLoader loader(live_path.string(), config);
require(loader.refresh_if_needed().bscan_gain == 2, "valid live config did not load");
const auto revision = loader.revision();
for (const auto& invalid : {Json{{"bscan_gain", 1e100}},
Json{{"gpr_input_positions", {0.0000001}}}, Json{{"bscan_gain", true}}}) {
std::ofstream(live_path) << invalid;
std::this_thread::sleep_for(std::chrono::milliseconds(110));
const auto current = loader.refresh_if_needed();
require(current.bscan_gain == 2 && loader.revision() == revision,
"invalid live config replaced the last valid settings");
}
const std::array<float, 1> nan_axis{std::numeric_limits<float>::quiet_NaN()};
require(!preprocessing::frequency_axes_match(nan_axis, nan_axis), "NaN frequency axes matched");
preprocessing::ChannelTrace invalid_trace{.frequency_hz = {1},
.samples = {{std::numeric_limits<float>::infinity(), 0}}};
expect_error([&] { preprocessing::validate_channel_trace_layout(invalid_trace, "test", {}); });
}
void test_ring() {
RingName name;
expect_error([&] { (void)ipc::ShmRing::open_or_create(name.name, 2, 65); });
expect_error([&] { (void)ipc::ShmRing::open_or_create(name.name, 0xffffffffU, 0xfffffff8U); });
auto ring = ipc::ShmRing::open_or_create(name.name, 2, 4096);
const auto child = ::fork();
require(child >= 0, "fork failed");
if (child == 0) {
std::array<std::uint64_t, 512> words{};
for (std::uint64_t sequence = 1; sequence <= 20'000U; ++sequence) {
words.fill(sequence);
if (!ring.push({reinterpret_cast<const std::uint8_t*>(words.data()), sizeof(words)})) {
::_exit(2);
}
}
::_exit(0);
}
std::uint64_t previous = 0;
std::vector<std::uint8_t> payload;
int status = 0;
bool finished = false;
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10);
try {
while (true) {
if (ring.pop(payload)) {
require(payload.size() == 4096, "stress payload changed size");
std::uint64_t sequence = 0;
std::memcpy(&sequence, payload.data(), sizeof(sequence));
require(sequence > previous, "stress ring yielded duplicate or older data");
for (std::size_t offset = 0; offset < payload.size(); offset += sizeof(sequence)) {
std::uint64_t word = 0;
std::memcpy(&word, payload.data() + offset, sizeof(word));
require(word == sequence, "stress ring accepted a torn payload");
}
previous = sequence;
} else if (finished) { break; }
finished = finished || ::waitpid(child, &status, WNOHANG) == child;
require(std::chrono::steady_clock::now() < deadline, "ring stress timed out");
}
require(WIFEXITED(status) && WEXITSTATUS(status) == 0 && previous != 0,
"ring stress writer failed");
} catch (...) {
::kill(child, SIGKILL);
if (!finished) { (void)::waitpid(child, &status, 0); }
throw;
}
}
void test_partial_driver_open() {
struct Radar final : drivers::RadarDriver {
unsigned closes = 0;
void open() override {}
void close() override { ++closes; }
auto acquire_sweep() -> drivers::SweepTrace override { return {}; }
} radar;
struct Switch final : drivers::SwitchDriver {
std::atomic<bool>& stop;
bool fail_open = false;
bool fail_close = false;
unsigned closes = 0;
explicit Switch(std::atomic<bool>& stop_flag) : stop(stop_flag) {}
void open() override {
if (fail_open) { stop.store(true); throw std::runtime_error("open failure"); }
}
void close() override { ++closes; if (fail_close) { throw std::runtime_error("close failure"); } }
auto position_count() const -> std::uint32_t override { return 1; }
void switch_to(std::uint32_t) override {}
};
std::atomic<bool> stop{false};
Switch input(stop), output(stop);
input.fail_open = true;
output.fail_close = true;
config::RunConfig config;
config.radar.sweep.points = 1;
config.run_combos = {{0, 0}};
RingName name;
auto ring = ipc::ShmRing::open_or_create(name.name, 2, 256);
acq::SweepOrchestrator(config, radar, input, output, ring).run(stop);
require(radar.closes == 1 && input.closes == 1 && output.closes == 1,
"partial driver startup did not close each driver exactly once");
}
void test_interruptible_wait() {
std::atomic<bool> stop{false};
std::jthread request_stop([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
stop.store(true);
});
const auto start = std::chrono::steady_clock::now();
require(config::wait_or_stop(0xffffffffU, stop), "large wait ignored stop");
require(std::chrono::steady_clock::now() - start < std::chrono::seconds(1),
"large wait delayed shutdown");
}
void test_csv_failures(const Workspace& workspace) {
const auto csv = workspace.path / "capture.csv";
const auto spool = workspace.path / "capture.capture_spool.bin";
CaptureFileWriter writer(csv.string(), (workspace.path / "capture.svg").string(), "", "");
writer.initialize_csv(2, false);
CapturePacket packet{.ch1 = {1, 2}, .ch2 = {3, 4}, .di1 = {}};
auto invalid_packet = packet;
invalid_packet.ch2.pop_back();
expect_error([&] { writer.append_csv_packet(invalid_packet); });
require(std::filesystem::file_size(spool) == 16, "invalid packet partially changed the spool");
writer.append_csv_packet(packet);
std::filesystem::remove(csv);
std::filesystem::create_symlink("/dev/full", csv);
expect_error([&] { writer.write({packet}, 1000, 10); });
require(std::filesystem::file_size(spool) > 16, "failed CSV write deleted the recovery spool");
std::filesystem::remove(csv);
writer.write({packet}, 1000, 10);
require(!std::filesystem::exists(spool), "successful CSV export retained the spool");
std::ifstream csv_input(csv);
const std::string csv_text((std::istreambuf_iterator<char>(csv_input)), {});
require(csv_text.find("1.000000000,3.000000000") != std::string::npos,
"CSV retry lost channel samples");
writer.initialize_csv(2, false);
std::ofstream(spool, std::ios::binary | std::ios::app).put('x');
expect_error([&] { writer.write({}, 1000, 10); });
require(std::filesystem::exists(spool), "truncated packet header deleted the spool");
writer.initialize_csv(2, false);
const std::array<std::uint64_t, 4> corrupt_packet{0, 2, 0xffffffffffffffffULL, 0};
std::ofstream(spool, std::ios::binary | std::ios::app).write(
reinterpret_cast<const char*>(corrupt_packet.data()), sizeof(corrupt_packet));
expect_error([&] { writer.write({}, 1000, 10); });
require(std::filesystem::exists(spool), "oversized packet count deleted the spool");
}
void test_asset_reload_preserves_data(const Workspace& workspace) {
const auto save = [&](const char* name, float sample) {
const auto path = workspace.path / name;
const auto bytes = ipc::serialize_raw_collection({.traces = {
{.frequency_hz = {1}, .s11 = {{sample, 0}}, .s21 = {{sample, 0}}}}});
std::ofstream(path, std::ios::binary).write(
reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
return path.string();
};
const auto open = save("open.bin", 1);
const auto short_standard = save("short.bin", -1);
const auto load = save("load.bin", 0);
const auto nan = save("nan.bin", std::numeric_limits<float>::quiet_NaN());
preprocessing::S21ReferenceBundle s21_reference;
s21_reference.load(open);
expect_error([&] { s21_reference.load(nan); });
require(s21_reference.resolve({}).samples.front().re == 1, "failed S21 reload lost previous reference");
preprocessing::S21CalibrationBundle s21_calibration;
s21_calibration.load(open);
expect_error([&] { s21_calibration.load(nan); });
require(s21_calibration.is_enabled(), "failed S21 reload disabled previous calibration");
preprocessing::S11ReferenceBundle s11_reference;
s11_reference.load(open);
expect_error([&] { s11_reference.load(nan); });
require(s11_reference.resolve({}).samples.front().re == 1, "failed S11 reload lost previous reference");
preprocessing::S11CalibrationBundle s11_calibration;
s11_calibration.load(open, short_standard, load);
expect_error([&] { s11_calibration.load(open, open, load); });
const std::array<float, 1> axis{1};
require(s11_calibration.apply({}, axis, {{0.5, 0}}).front().re == 0.5F,
"failed OSL reload lost previous calibration");
}
void test_locator_packet_errors() {
int sockets[2]{};
require(::socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) == 0, "socketpair failed");
std::atomic<double> speed{0};
std::atomic<std::int64_t> at_ns{0};
locator::ClientSession client(sockets[0], "regression", 2, 1024);
client.start(speed, at_ns);
const auto send_json = [&](const std::string& json) {
std::vector<std::uint8_t> bytes(8 + json.size());
write_u32(bytes, 4, static_cast<std::uint32_t>(json.size()));
std::memcpy(bytes.data() + 8, json.data(), json.size());
const auto sent = ::send(sockets[1], bytes.data(), bytes.size(), MSG_NOSIGNAL);
if (sent != static_cast<ssize_t>(bytes.size())) {
throw std::runtime_error(std::string("send test packet failed: ") + std::strerror(errno));
}
};
send_json("{\"vlc\":1e400}");
send_json("{\"vlc\":1e308}");
std::this_thread::sleep_for(std::chrono::milliseconds(50));
require(at_ns.load() == 0 && speed.load() == 0, "invalid socket speed reached the processor");
send_json("{\"vlc\":3.5}");
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
while (at_ns.load() == 0 && std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
require(speed.load() == 3.5 && at_ns.load() > 0, "locator stopped after oversized JSON number");
client.request_stop();
client.join();
::close(sockets[1]);
require(client.has_exited(), "locator session did not finish both loops");
locator::LocatorServerConfig config{.host = "127.0.0.1", .port = 0};
locator::TcpServer server(config);
for (unsigned iteration = 0; iteration < 30; ++iteration) { server.start(); server.stop(); }
}
void test_unread_tty_shutdown(const Workspace& workspace) {
TtyProtocolWriter writer((workspace.path / "tty").string(), 160'000);
const std::vector<std::uint16_t> words(80'000, 42);
writer.enqueue_encoded_frames(words.data(), words.size() / 4U);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
const auto start = std::chrono::steady_clock::now();
writer.shutdown();
require(std::chrono::steady_clock::now() - start < std::chrono::seconds(1),
"unread tty blocked shutdown");
writer.shutdown();
}
} // namespace
int main(int argc, char** argv) {
try {
require(argc == 2, "Pass the simulator config fixture path");
Workspace workspace;
test_wire_decoders();
test_configuration(argv[1], workspace);
test_asset_reload_preserves_data(workspace);
test_ring();
test_partial_driver_open();
test_interruptible_wait();
test_csv_failures(workspace);
test_locator_packet_errors();
test_unread_tty_shutdown(workspace);
std::cout << "native regressions passed (10,000 decoder mutations; 20,000 concurrent ring frames)\n";
} catch (const std::exception& error) {
std::cerr << "native regression failed: " << error.what() << '\n';
return 1;
}
}