some fixes

This commit is contained in:
Ayzen
2026-06-05 14:40:10 +03:00
parent 22942d9dc9
commit bbea744459
35 changed files with 1797 additions and 297 deletions
@@ -5,6 +5,7 @@
#include <chrono>
#include <cmath>
#include <complex>
#include <cstdint>
#include <exception>
#include <random>
#include <string>
@@ -21,7 +22,26 @@ namespace detail = radar::drivers::librevna::detail;
namespace {
constexpr std::uint32_t kNativeAcquireMaxAttempts = 3U;
constexpr auto kNativeSweepResponseTimeout = std::chrono::milliseconds(1500);
// Derive the budget the whole multi-point sweep is allowed to take before the
// first datapoint must arrive (#7): a fixed setup cost plus the expected dwell
// (points / IFBW) with a generous margin, clamped to the overall hard cap. The
// per-gap stall timeout then governs progress once datapoints start flowing.
[[nodiscard]] auto native_initial_sweep_budget(const config::RadarSweepSettings& sweep)
-> std::chrono::milliseconds {
const float points = std::max(1.0F, static_cast<float>(sweep.points));
const float if_bw = std::max(1.0F, sweep.if_bandwidth_hz);
const double dwell_ms =
(static_cast<double>(points) / static_cast<double>(if_bw)) * 1000.0 *
static_cast<double>(detail::kNativeSweepDwellMargin);
const double total_ms = static_cast<double>(detail::kNativeSweepSetupBudgetMs) + dwell_ms;
const auto budget = std::chrono::milliseconds(static_cast<std::int64_t>(total_ms));
return std::clamp<std::chrono::milliseconds>(
budget,
std::chrono::milliseconds(detail::kNativeSweepPerGapTimeoutMs),
std::chrono::milliseconds(detail::kNativeSweepHardCapMs)
);
}
// One synthetic GPR reflector. `range_m` is its physical depth, `reflection`
// is the dimensionless complex reflection coefficient (|Γ| ≤ 1).
@@ -78,12 +98,24 @@ constexpr auto kMockMinimumSweepDuration = std::chrono::microseconds(50);
}
[[nodiscard]] auto is_retryable_native_acquire_error(std::string_view message) -> bool {
constexpr std::array<std::string_view, 5> kRetryableSubstrings = {
// A stop request must not be retried: drain to the caller immediately (#26).
if (message.find("aborted by stop request") != std::string_view::npos) {
return false;
}
constexpr std::array<std::string_view, 8> kRetryableSubstrings = {
"Timeout waiting for expected LibreVNA packet type",
"Timeout waiting for LibreVNA ACK",
"LibreVNA returned NACK",
"Failed to read LibreVNA USB bulk packet",
"Failed to write LibreVNA USB bulk packet",
// #31: a transient USB error often triggers a brief device re-enumeration,
// so the device may be momentarily absent or its handle invalidated when
// we reconnect. Treat these as retryable; the reconnect path absorbs the
// re-enumeration with a short bounded discovery backoff.
"No compatible LibreVNA USB device found",
"LibreVNA native handle is not open",
"LibreVNA USB handle is not open",
};
return std::any_of(
@@ -153,10 +185,28 @@ auto LibreVnaMinimalDriver::acquire_sweep() -> SweepTrace {
if (!is_retryable_native_acquire_error(last_message) || attempt == kNativeAcquireMaxAttempts) {
break;
}
if (detail::native_stop_requested()) {
break; // #26: do not waste reconnect attempts while shutting down.
}
// Recover from transient USB/protocol stalls by reconnecting the device.
close_native();
open_native();
// #31: recover from transient USB/protocol stalls without
// destroying the libusb_context. Release only the interface
// and handle, then reopen against the surviving context so
// we ride out a brief device re-enumeration instead of
// re-initialising libusb from scratch on every retry.
rx_buffer_.clear();
packet_queue_.clear();
if (usb_handle_ != nullptr && interface_claimed_) {
libusb_release_interface(usb_handle_, detail::kUsbInterface);
interface_claimed_ = false;
}
if (usb_handle_ != nullptr) {
libusb_close(usb_handle_);
usb_handle_ = nullptr;
}
protocol_version_ = 0;
device_num_ports_ = 0;
open_native(); // Reuses the surviving usb_context_ and retries discovery.
}
}
@@ -282,12 +332,32 @@ auto LibreVnaMinimalDriver::acquire_native() -> SweepTrace {
std::vector<std::uint8_t> received(settings_.sweep.points, 0U);
std::uint32_t received_count = 0;
const auto deadline = std::chrono::steady_clock::now() + kNativeSweepResponseTimeout;
// #7: the per-datapoint deadline is rebased every time a fresh point lands
// (or the first point must arrive within the size-derived budget), so a
// large sweep is not killed by one fixed ~1.5s timeout. An independent hard
// cap bounds the whole sweep against a device that streams forever.
const auto now = std::chrono::steady_clock::now();
const auto hard_cap_deadline = now + std::chrono::milliseconds(detail::kNativeSweepHardCapMs);
auto gap_deadline = std::min(now + native_initial_sweep_budget(settings_.sweep), hard_cap_deadline);
while (received_count < settings_.sweep.points) {
// #26: honour a stop request observed during the (chunked) USB wait so
// SIGTERM aborts the sweep promptly instead of after the full budget.
if (detail::native_stop_requested()) {
throw std::runtime_error("Native sweep aborted by stop request");
}
if (std::chrono::steady_clock::now() >= hard_cap_deadline) {
throw std::runtime_error(
"Native sweep exceeded hard cap (" + std::to_string(received_count) + "/" +
std::to_string(settings_.sweep.points) + " points received)"
);
}
const auto wait_deadline = std::min(gap_deadline, hard_cap_deadline);
NativePacket packet{};
try {
packet = wait_for_packet(detail::kPacketVnaDatapoint, deadline);
packet = wait_for_packet(detail::kPacketVnaDatapoint, wait_deadline);
} catch (const std::exception& exception) {
throw std::runtime_error(
"Timeout while collecting VNADatapoints (" + std::to_string(received_count) + "/" +
@@ -311,6 +381,10 @@ auto LibreVnaMinimalDriver::acquire_native() -> SweepTrace {
trace.frequency_hz[datapoint.point_number] = datapoint.frequency_hz;
trace.s11[datapoint.point_number] = datapoint.s11;
trace.s21[datapoint.point_number] = datapoint.s21;
// Extend the per-gap stall timeout now that progress was made.
gap_deadline = std::chrono::steady_clock::now() +
std::chrono::milliseconds(detail::kNativeSweepPerGapTimeoutMs);
}
return trace;
@@ -3,10 +3,12 @@
#include <algorithm>
#include <array>
#include <cerrno>
#include <chrono>
#include <cstring>
#include <fcntl.h>
#include <stdexcept>
#include <string>
#include <thread>
#include <utility>
#include <vector>
@@ -104,18 +106,40 @@ namespace {
} // namespace
void LibreVnaMinimalDriver::open_native() {
if (usb_context_ != nullptr || usb_handle_ != nullptr) {
if (usb_handle_ != nullptr) {
throw std::runtime_error("LibreVNA native state is already initialized");
}
const auto init_status = libusb_init(&usb_context_);
if (init_status != LIBUSB_SUCCESS) {
usb_context_ = nullptr;
throw std::runtime_error(libusb_error_message("Failed to initialize libusb", init_status));
// #26: observe SIGINT/SIGTERM (chaining to main()'s handler) so the blocking
// USB loops can bail out promptly during shutdown. Safe to call repeatedly.
detail::install_native_stop_observer();
// #31: keep the libusb_context alive across reconnect retries. Only create a
// fresh context the first time; reconnect paths reuse the surviving context
// and merely reopen the handle, avoiding a full libusb teardown/re-init.
if (usb_context_ == nullptr) {
const auto init_status = libusb_init(&usb_context_);
if (init_status != LIBUSB_SUCCESS) {
usb_context_ = nullptr;
throw std::runtime_error(libusb_error_message("Failed to initialize libusb", init_status));
}
}
try {
auto* selected_handle = find_matching_device_handle(usb_context_, settings_.serial);
// #31: retry discovery with a short bounded backoff so a brief device
// re-enumeration (common right after a transient USB error) is absorbed
// instead of surfacing as a hard "device not found".
libusb_device_handle* selected_handle = nullptr;
for (int attempt = 0; attempt < detail::kNativeDiscoveryRetryAttempts; ++attempt) {
if (detail::native_stop_requested()) {
break; // #26: abandon discovery promptly during shutdown.
}
selected_handle = find_matching_device_handle(usb_context_, settings_.serial);
if (selected_handle != nullptr) {
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(detail::kNativeDiscoveryRetryDelayMs));
}
if (selected_handle == nullptr) {
const auto serial_hint = settings_.serial.empty() ? std::string() : " for serial '" + settings_.serial + "'";
@@ -215,6 +239,12 @@ void LibreVnaMinimalDriver::send_packet_no_payload(std::uint8_t packet_type, boo
void LibreVnaMinimalDriver::wait_for_ack(std::chrono::steady_clock::time_point deadline) {
while (std::chrono::steady_clock::now() < deadline) {
// #26: bail out promptly on SIGTERM/SIGINT rather than blocking until the
// ACK deadline expires.
if (detail::native_stop_requested()) {
throw std::runtime_error("LibreVNA ACK wait aborted by stop request");
}
std::optional<NativePacket> ack_or_nack{};
for (auto iter = packet_queue_.begin(); iter != packet_queue_.end(); ++iter) {
if (iter->packet_type != detail::kPacketAck && iter->packet_type != detail::kPacketNack) {
@@ -246,6 +276,11 @@ auto LibreVnaMinimalDriver::wait_for_packet(
if (auto packet = pop_packet(expected_type); packet.has_value()) {
return *packet;
}
// #26: re-check the stop flag between short USB polls so a shutdown is
// honoured within a few hundred ms even on a long sweep deadline.
if (detail::native_stop_requested()) {
throw std::runtime_error("LibreVNA packet wait aborted by stop request");
}
pump_usb(deadline);
}
@@ -1,6 +1,8 @@
#pragma once
#include <array>
#include <csignal>
#include <signal.h> // POSIX sigaction/NSIG for the chaining stop observer (#26).
#include <cstddef>
#include <cstdint>
#include <cstring>
@@ -51,6 +53,21 @@ constexpr std::size_t kUsbReadChunkBytes = 16 * 1024;
constexpr int kUsbReadPollMinTimeoutMs = 1;
constexpr int kUsbReadPollMaxTimeoutMs = 50;
// Native sweep timing budget (#7). The native deadline is no longer a single
// fixed value for the whole sweep: instead it is derived from the sweep size
// (fixed setup cost + per-point dwell ≈ points/IFBW) and is extended every
// time a fresh datapoint arrives via a per-gap stall timeout, while an overall
// hard cap bounds a wedged device.
constexpr int kNativeSweepSetupBudgetMs = 500;
constexpr float kNativeSweepDwellMargin = 3.0F;
constexpr int kNativeSweepPerGapTimeoutMs = 1500;
constexpr int kNativeSweepHardCapMs = 60'000;
// Reconnect backoff (#31). After a transient error the device may re-enumerate;
// retry discovery a few times with a short bounded delay before giving up.
constexpr int kNativeDiscoveryRetryAttempts = 10;
constexpr int kNativeDiscoveryRetryDelayMs = 150;
[[nodiscard]] inline auto read_u16_le(std::span<const std::uint8_t> data, std::size_t offset) -> std::uint16_t {
if ((offset + sizeof(std::uint16_t)) > data.size()) {
throw std::runtime_error("Failed to decode uint16 from payload");
@@ -108,5 +125,73 @@ inline void write_u32_le(std::span<std::uint8_t> data, std::size_t offset, std::
data[offset + 3] = static_cast<std::uint8_t>((value >> 24U) & 0xFFU);
}
// Stop responsiveness (#26). The blocking USB loops keep their per-call
// timeouts short and re-poll this flag so a SIGTERM/SIGINT delivered mid-sweep
// is honoured within a few hundred ms instead of only after the whole sweep
// budget has elapsed.
//
// `main()` installs its own SIGINT/SIGTERM handlers before any driver is
// opened, so we chain to (and preserve) whatever handler is already installed
// rather than clobbering it. All state lives in single inline-static slots so
// every translation unit observes the same flag and chain table.
[[nodiscard]] inline auto native_stop_flag() -> volatile std::sig_atomic_t& {
static volatile std::sig_atomic_t flag = 0;
return flag;
}
[[nodiscard]] inline auto native_chained_handlers() -> std::array<struct sigaction, NSIG>& {
static std::array<struct sigaction, NSIG> handlers{};
return handlers;
}
inline void native_stop_signal_handler(int signal_number) {
native_stop_flag() = 1;
// Chain to the previously installed disposition (e.g. main()'s handler) so
// process-level stop semantics are unchanged.
if (signal_number < 0 || signal_number >= NSIG) {
return;
}
const auto& prior = native_chained_handlers()[static_cast<std::size_t>(signal_number)];
if ((prior.sa_flags & SA_SIGINFO) == 0 && prior.sa_handler != nullptr &&
prior.sa_handler != SIG_DFL && prior.sa_handler != SIG_IGN &&
prior.sa_handler != native_stop_signal_handler) {
prior.sa_handler(signal_number);
}
}
// Lazily install the chaining stop observer for one signal. Must be called
// after main() has installed its handlers (i.e. from open_native()).
inline void install_native_stop_observer_for(int signal_number) {
if (signal_number < 0 || signal_number >= NSIG) {
return;
}
struct sigaction current{};
if (sigaction(signal_number, nullptr, &current) != 0) {
return;
}
if (current.sa_handler == native_stop_signal_handler) {
return; // Already installed; do not chain to ourselves.
}
native_chained_handlers()[static_cast<std::size_t>(signal_number)] = current;
struct sigaction action{};
action.sa_handler = native_stop_signal_handler;
sigemptyset(&action.sa_mask);
action.sa_flags = current.sa_flags & ~SA_SIGINFO; // Keep flags such as SA_RESTART.
sigaction(signal_number, &action, nullptr);
}
inline void install_native_stop_observer() {
install_native_stop_observer_for(SIGINT);
install_native_stop_observer_for(SIGTERM);
}
[[nodiscard]] inline auto native_stop_requested() -> bool {
return native_stop_flag() != 0;
}
} // namespace radar::drivers::librevna::detail
@@ -14,6 +14,10 @@
namespace radar::drivers {
namespace {
// A transient ioctl failure (e.g. EINTR/EAGAIN/EBUSY under load) should not be
// immediately fatal; retry a small number of times before giving up.
constexpr int kSwitchIoctlRetries = 3;
// Position mapping for H7992 control pins:
// position -> (A, B)
constexpr std::array<std::array<std::uint8_t, 2>, 4> kPositionToAB = {
@@ -44,6 +48,15 @@ void validate_open_settings(const H7992MinimalDriverSettings& settings) {
H7992MinimalDriver::H7992MinimalDriver(H7992MinimalDriverSettings settings) : settings_(std::move(settings)) {}
H7992MinimalDriver::~H7992MinimalDriver() {
// Safety net: if the owner never called close() (e.g. on crash/unwind),
// still drive the RF path to a known-safe state and release the GPIO lines.
try {
close();
} catch (...) {
}
}
void H7992MinimalDriver::open() {
if (is_open_) {
return;
@@ -134,6 +147,15 @@ void H7992MinimalDriver::open_native() {
}
void H7992MinimalDriver::close_native() {
// Leave the RF path in its known-safe default position before releasing the
// lines, so shutdown/crash never strands the switch in an arbitrary state.
if (line_fd_ >= 0 && settings_.default_position < settings_.positions) {
const auto [pin_a_state, pin_b_state] = kPositionToAB[settings_.default_position];
auto values = make_line_values(pin_a_state, pin_b_state);
// Best-effort: do not throw out of the teardown path.
::ioctl(line_fd_, GPIO_V2_LINE_SET_VALUES_IOCTL, &values);
}
if (line_fd_ >= 0) {
::close(line_fd_);
line_fd_ = -1;
@@ -152,9 +174,16 @@ void H7992MinimalDriver::switch_native(std::uint32_t position) {
const auto [pin_a_state, pin_b_state] = kPositionToAB[position];
auto values = make_line_values(pin_a_state, pin_b_state);
if (::ioctl(line_fd_, GPIO_V2_LINE_SET_VALUES_IOCTL, &values) != 0) {
throw std::runtime_error("Failed to switch GPIO state for " + settings_.name + ": " + std::strerror(errno));
// Retry transient ioctl failures so a momentary hiccup is recoverable
// instead of aborting the whole sweep; only the final attempt is fatal.
int last_errno = 0;
for (int attempt = 0; attempt < kSwitchIoctlRetries; ++attempt) {
if (::ioctl(line_fd_, GPIO_V2_LINE_SET_VALUES_IOCTL, &values) == 0) {
return;
}
last_errno = errno;
}
throw std::runtime_error("Failed to switch GPIO state for " + settings_.name + ": " + std::strerror(last_errno));
}
} // namespace radar::drivers
@@ -30,6 +30,7 @@ struct H7992MinimalDriverSettings {
class H7992MinimalDriver final : public SwitchDriver {
public:
explicit H7992MinimalDriver(H7992MinimalDriverSettings settings);
~H7992MinimalDriver() override; // Leaves RF path safe even if close() was skipped.
void open() override;
void close() override;
@@ -13,6 +13,23 @@
namespace radar::drivers {
namespace {
// A transient ioctl failure (e.g. EINTR/EAGAIN/EBUSY under load) should not be
// immediately fatal; retry a small number of times before giving up.
constexpr int kSwitchIoctlRetries = 3;
// Compute the GPIO line values for a given logical position, honouring inverted
// control logic. Shared by switch_native() and the teardown default-drive path.
[[nodiscard]] auto make_line_values(const HMC349AMinimalDriverSettings& settings, std::uint32_t position)
-> gpio_v2_line_values {
const std::uint8_t requested_state = static_cast<std::uint8_t>(position & 0x01U);
const std::uint8_t control_state = settings.invert_logic ? (requested_state ^ 0x01U) : requested_state;
gpio_v2_line_values values{};
values.mask = (1ULL << 0U);
values.bits = static_cast<std::uint64_t>(control_state);
return values;
}
void validate_open_settings(const HMC349AMinimalDriverSettings& settings) {
if (settings.positions == 0U || settings.positions > 2U) {
throw std::runtime_error("HMC349A switch positions must be in range [1, 2] for " + settings.name);
@@ -27,6 +44,15 @@ void validate_open_settings(const HMC349AMinimalDriverSettings& settings) {
HMC349AMinimalDriver::HMC349AMinimalDriver(HMC349AMinimalDriverSettings settings)
: settings_(std::move(settings)) {}
HMC349AMinimalDriver::~HMC349AMinimalDriver() {
// Safety net: if the owner never called close() (e.g. on crash/unwind),
// still drive the RF path to a known-safe state and release the GPIO lines.
try {
close();
} catch (...) {
}
}
void HMC349AMinimalDriver::open() {
if (is_open_) {
return;
@@ -117,6 +143,14 @@ void HMC349AMinimalDriver::open_native() {
}
void HMC349AMinimalDriver::close_native() {
// Leave the RF path in its known-safe default position before releasing the
// line, so shutdown/crash never strands the switch in an arbitrary state.
if (line_fd_ >= 0 && settings_.default_position < settings_.positions) {
auto values = make_line_values(settings_, settings_.default_position);
// Best-effort: do not throw out of the teardown path.
::ioctl(line_fd_, GPIO_V2_LINE_SET_VALUES_IOCTL, &values);
}
if (line_fd_ >= 0) {
::close(line_fd_);
line_fd_ = -1;
@@ -132,16 +166,18 @@ void HMC349AMinimalDriver::switch_native(std::uint32_t position) {
throw std::runtime_error("Native GPIO line fd is not open for " + settings_.name);
}
const std::uint8_t requested_state = static_cast<std::uint8_t>(position & 0x01U);
const std::uint8_t control_state = settings_.invert_logic ? (requested_state ^ 0x01U) : requested_state;
auto values = make_line_values(settings_, position);
gpio_v2_line_values values{};
values.mask = (1ULL << 0U);
values.bits = static_cast<std::uint64_t>(control_state);
if (::ioctl(line_fd_, GPIO_V2_LINE_SET_VALUES_IOCTL, &values) != 0) {
throw std::runtime_error("Failed to switch GPIO state for " + settings_.name + ": " + std::strerror(errno));
// Retry transient ioctl failures so a momentary hiccup is recoverable
// instead of aborting the whole sweep; only the final attempt is fatal.
int last_errno = 0;
for (int attempt = 0; attempt < kSwitchIoctlRetries; ++attempt) {
if (::ioctl(line_fd_, GPIO_V2_LINE_SET_VALUES_IOCTL, &values) == 0) {
return;
}
last_errno = errno;
}
throw std::runtime_error("Failed to switch GPIO state for " + settings_.name + ": " + std::strerror(last_errno));
}
} // namespace radar::drivers
@@ -30,6 +30,7 @@ struct HMC349AMinimalDriverSettings {
class HMC349AMinimalDriver final : public SwitchDriver {
public:
explicit HMC349AMinimalDriver(HMC349AMinimalDriverSettings settings);
~HMC349AMinimalDriver() override; // Leaves RF path safe even if close() was skipped.
void open() override;
void close() override;
@@ -1,7 +1,10 @@
#include "sweep_orchestrator.hpp"
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <exception>
#include <iostream>
#include <stdexcept>
#include <thread>
#include <vector>
@@ -9,6 +12,16 @@
namespace radar::acq {
namespace {
// Wait-for-device retry tuning (mirror of python matrix_raw_producer._open_radar_with_retry):
// a device that is absent at boot or disappears mid-run must never kill the orchestrator,
// only make it wait. Backoff is capped so a long absence does not busy-spin, and every wait
// is interruptible by stop_requested for a prompt clean exit.
constexpr std::uint32_t kOpenRetryMinMs = 1'000U;
constexpr std::uint32_t kOpenRetryMaxMs = 10'000U;
// Throttle open-failure logging during a long wait so a permanently absent device does not
// flood the process log: log the first failure, then every Nth attempt.
constexpr std::uint64_t kOpenRetryLogEvery = 30U;
[[nodiscard]] inline auto should_stop(const std::atomic<bool>& stop_requested) -> bool {
return stop_requested.load(std::memory_order_relaxed);
}
@@ -20,6 +33,21 @@ void sleep_if_needed_ms(std::uint32_t delay_ms) {
std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
}
// Interruptible sleep: returns true if a stop was requested while waiting.
[[nodiscard]] auto interruptible_sleep_ms(std::uint32_t delay_ms, const std::atomic<bool>& stop_requested) -> bool {
constexpr std::uint32_t kPollMs = 50U;
std::uint32_t waited = 0U;
while (waited < delay_ms) {
if (should_stop(stop_requested)) {
return true;
}
const std::uint32_t chunk = std::min(kPollMs, delay_ms - waited);
std::this_thread::sleep_for(std::chrono::milliseconds(chunk));
waited += chunk;
}
return should_stop(stop_requested);
}
void validate_sweep(const drivers::SweepTrace& sweep) {
if (sweep.frequency_hz.size() != sweep.s11.size() || sweep.frequency_hz.size() != sweep.s21.size()) {
throw std::runtime_error("Radar driver returned inconsistent sweep vectors");
@@ -64,6 +92,41 @@ class DriverLifecycleGuard {
active_ = true;
}
// Open all devices, retrying forever with capped exponential backoff until success or
// stop. Used for both the initial open and every in-loop reopen, so a device that is
// absent at boot or disappears mid-run never kills the orchestrator — it just waits.
// Returns false only if a stop was requested before any device became available.
[[nodiscard]] auto open_all_with_retry(const std::atomic<bool>& stop_requested) -> bool {
// Drop any partial state from a previous open before retrying.
close_all();
std::uint64_t attempt = 0;
std::uint32_t delay_ms = kOpenRetryMinMs;
while (!should_stop(stop_requested)) {
try {
open_all();
if (attempt > 0) {
std::cerr << "sweep_orchestrator: devices opened after " << (attempt + 1) << " attempt(s)\n";
}
return true;
} catch (const std::exception& exc) {
// Best-effort: drop any partial open before the next attempt.
close_all();
++attempt;
if (attempt == 1 || attempt % kOpenRetryLogEvery == 0) {
std::cerr << "sweep_orchestrator: devices not available (attempt " << attempt
<< "); retrying up to every " << (kOpenRetryMaxMs / 1'000U)
<< "s until present: " << exc.what() << '\n';
}
if (interruptible_sleep_ms(delay_ms, stop_requested)) {
return false;
}
delay_ms = std::min(delay_ms * 2U, kOpenRetryMaxMs);
}
}
return false;
}
void close_all() {
if (!active_) {
return;
@@ -81,14 +144,47 @@ class DriverLifecycleGuard {
bool active_ = false;
};
void publish_collection(ipc::ShmRing& raw_ring, ipc::ShmRing* raw_tap_ring, const ipc::RawSweepCollection& collection) {
// Worst-case serialized size of a collection given the configured combo count and sweep
// point count, using the trace wire format (see ipc::write_trace_collection/write_trace_block):
// collection header: magic(4) + collection_id(8) + monotonic_ns(8) + trace_count(4)
// + capture_start_ns(8) + capture_end_ns(8) = 40 bytes
// per trace block: input_pos(4) + output_pos(4) + point_count(4) = 12 bytes
// + per point: frequency(4) + s11(8) + s21(8) = 20 bytes
[[nodiscard]] auto worst_case_serialized_bytes(std::size_t combo_count, std::uint32_t sweep_points) -> std::size_t {
constexpr std::size_t kCollectionHeaderBytes = 40U;
constexpr std::size_t kTraceHeaderBytes = 12U;
constexpr std::size_t kBytesPerPoint = 20U;
const std::size_t per_trace = kTraceHeaderBytes + (static_cast<std::size_t>(sweep_points) * kBytesPerPoint);
return kCollectionHeaderBytes + (combo_count * per_trace);
}
// Publish a collection on the primary raw ring; tap pushes are strictly best-effort.
// Never throws: an oversize payload is logged-and-dropped (the slot-size budget is also
// validated up front at startup, so this guards only against unexpected runtime growth).
// Returns true if the primary push succeeded.
[[nodiscard]] auto publish_collection(
ipc::ShmRing& raw_ring,
ipc::ShmRing* raw_tap_ring,
const ipc::RawSweepCollection& collection,
std::uint64_t& oversize_drop_count
) -> bool {
const auto serialized_collection = ipc::serialize_raw_collection(collection);
if (!raw_ring.push(serialized_collection)) {
throw std::runtime_error("Raw ring slot is too small for serialized collection");
// Log-and-drop an oversize payload rather than aborting the long-running loop.
// Throttle so a persistently oversize payload cannot flood the log.
if (oversize_drop_count % 100 == 0) {
std::cerr << "sweep_orchestrator: dropped oversize raw payload (" << serialized_collection.size()
<< " bytes > slot " << raw_ring.slot_size_bytes() << "; drop count="
<< (oversize_drop_count + 1) << ")\n";
}
++oversize_drop_count;
return false;
}
if (raw_tap_ring != nullptr && !raw_tap_ring->push(serialized_collection)) {
throw std::runtime_error("Raw tap ring slot is too small for serialized collection");
// Tap ring is for GUI/debug observers only: a tap failure must never abort the primary path.
if (raw_tap_ring != nullptr) {
(void)raw_tap_ring->push(serialized_collection);
}
return true;
}
} // namespace
@@ -109,25 +205,59 @@ SweepOrchestrator::SweepOrchestrator(
raw_tap_ring_(raw_tap_ring) {}
void SweepOrchestrator::run(const std::atomic<bool>& stop_requested) {
// Fail fast on a config error: a slot that is too small for the worst-case payload can
// never carry a full collection, so report it clearly at startup instead of dropping
// every collection at runtime (fix #27).
const auto worst_case_bytes = worst_case_serialized_bytes(config_.run_combos.size(), config_.radar.sweep.points);
if (worst_case_bytes > raw_ring_.slot_size_bytes()) {
throw std::runtime_error(
"Raw ring slot_size_bytes (" + std::to_string(raw_ring_.slot_size_bytes())
+ ") is too small for the worst-case serialized collection (" + std::to_string(worst_case_bytes)
+ " bytes for " + std::to_string(config_.run_combos.size()) + " combos x "
+ std::to_string(config_.radar.sweep.points) + " points)"
);
}
DriverLifecycleGuard lifecycle_guard(radar_driver_, input_switch_driver_, output_switch_driver_);
// Open devices once and keep them active for the whole acquisition loop.
lifecycle_guard.open_all();
// Wait for the devices to become available before starting (fix #8/#9): an absent device
// makes the orchestrator wait, not exit.
if (!lifecycle_guard.open_all_with_retry(stop_requested)) {
return; // stop requested before any device became available
}
std::uint64_t collection_id = 0;
std::uint64_t oversize_drop_count = 0;
while (!should_stop(stop_requested)) {
auto raw_collection = acquire_one_collection(++collection_id, stop_requested);
if (raw_collection.traces.empty()) {
try {
auto raw_collection = acquire_one_collection(++collection_id, stop_requested);
if (raw_collection.traces.empty()) {
if (!config_.runtime.continuous) {
break;
}
sleep_if_needed_ms(config_.runtime.idle_sleep_ms);
continue;
}
(void)publish_collection(raw_ring_, raw_tap_ring_, raw_collection, oversize_drop_count);
if (!config_.runtime.continuous) {
break;
}
sleep_if_needed_ms(config_.runtime.idle_sleep_ms);
continue;
}
publish_collection(raw_ring_, raw_tap_ring_, raw_collection);
if (!config_.runtime.continuous) {
break;
} catch (const std::exception& exc) {
// Treat any in-loop device error (timeout/NACK/transient USB or socket glitch/
// device-not-found-after-glitch) as recoverable: close, wait for the device to
// come back, and continue. This mirrors the python producer's reconnect-forever
// policy so a long-running headless appliance survives transient hardware hiccups
// instead of exiting. Genuinely fatal config errors are caught at startup above
// (and in main()), reserving non-zero exit for those.
std::cerr << "sweep_orchestrator: acquisition failed; reopening and waiting for the device: "
<< exc.what() << '\n';
if (!config_.runtime.continuous) {
throw; // single-run mode has no recovery path; surface the failure
}
if (!lifecycle_guard.open_all_with_retry(stop_requested)) {
break; // stop requested while waiting to reopen
}
}
}