some fixes
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <iostream>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
@@ -63,6 +64,10 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
|
||||
std::uint64_t last_replayed_revision = live_config_loader_.revision();
|
||||
std::uint64_t last_applied_history_command_seq = 0;
|
||||
std::uint64_t error_count = 0;
|
||||
// Fix #55: track the socket-fed speed used for the last reprocess so a change
|
||||
// arriving without a live-config revision bump still triggers a reprocess of
|
||||
// the current result (gated below by reprocess_current_result).
|
||||
std::optional<double> last_reprocessed_socket_speed = std::nullopt;
|
||||
|
||||
while (!stop_requested.load(std::memory_order_relaxed)) {
|
||||
try {
|
||||
@@ -71,7 +76,17 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
|
||||
const auto live_revision = live_config_loader_.revision();
|
||||
auto& processor = resolve_processor(live_config);
|
||||
|
||||
if (live_revision != last_replayed_revision) {
|
||||
// The effective config folds in latest_socket_speed(); a change there is
|
||||
// a reprocess trigger on its own, even when the live revision is unchanged.
|
||||
// Mirror the gating in resolve_effective_live_config(): only consider the
|
||||
// socket speed when it is actually applied to the effective config.
|
||||
const std::optional<double> current_socket_speed =
|
||||
(live_config_raw.ignore_socket_speed || locator_server_ == nullptr)
|
||||
? std::nullopt
|
||||
: locator_server_->latest_socket_speed();
|
||||
const bool socket_speed_dirty = current_socket_speed != last_reprocessed_socket_speed;
|
||||
|
||||
if (live_revision != last_replayed_revision || socket_speed_dirty) {
|
||||
if (live_config.history_command_seq > last_applied_history_command_seq) {
|
||||
if (live_config.history_command == HistoryCommand::RemoveLast) {
|
||||
if (!preprocessed_history.empty()) {
|
||||
@@ -108,6 +123,9 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
|
||||
publish_locator(replay_result, live_config);
|
||||
}
|
||||
last_replayed_revision = live_revision;
|
||||
// Fix #55: record the speed we just reprocessed with so an
|
||||
// unchanged socket value does not retrigger every iteration.
|
||||
last_reprocessed_socket_speed = current_socket_speed;
|
||||
}
|
||||
|
||||
if (preprocessed_ring_.pop(bytes)) {
|
||||
|
||||
@@ -64,8 +64,9 @@ class ClientSession {
|
||||
auto operator=(ClientSession&&) -> ClientSession& = delete;
|
||||
|
||||
// Start writer/reader threads. The shared `vlc` slot is notified whenever
|
||||
// an inbound packet carrying a finite `vlc` field arrives.
|
||||
void start(std::atomic<double>& shared_vlc_slot);
|
||||
// an inbound packet carrying a finite `vlc` field arrives; the companion
|
||||
// timestamp slot records when, so the server can expire stale speeds.
|
||||
void start(std::atomic<double>& shared_vlc_slot, std::atomic<std::int64_t>& shared_vlc_at_ns);
|
||||
|
||||
// Enqueue one outbound packet. Disconnects this session if the queue is
|
||||
// already full or the writer has stopped.
|
||||
@@ -85,7 +86,7 @@ class ClientSession {
|
||||
|
||||
private:
|
||||
void writer_loop();
|
||||
void reader_loop(std::atomic<double>& shared_vlc_slot);
|
||||
void reader_loop(std::atomic<double>& shared_vlc_slot, std::atomic<std::int64_t>& shared_vlc_at_ns);
|
||||
|
||||
int socket_fd_;
|
||||
std::string peer_name_;
|
||||
@@ -93,6 +94,9 @@ class ClientSession {
|
||||
std::uint32_t max_payload_bytes_;
|
||||
std::atomic<bool> stop_requested_{false};
|
||||
std::atomic<bool> exited_{false};
|
||||
// Counts how many of the two loops (reader, writer) have finished. The
|
||||
// second one to finish flips `exited_`, so a writer-only death is reapable.
|
||||
std::atomic<unsigned int> loops_finished_{0};
|
||||
std::thread writer_thread_{};
|
||||
std::thread reader_thread_{};
|
||||
};
|
||||
@@ -144,6 +148,10 @@ class TcpServer {
|
||||
void enroll_client(std::unique_ptr<ClientSession> session);
|
||||
void broadcast_packet(const std::vector<std::uint8_t>& packet);
|
||||
void reap_finished_clients();
|
||||
// Best-effort reap from the publish path: acquires clients_mutex_ via try-lock
|
||||
// (skips if contended) and joins exited sessions outside the lock. Keeps
|
||||
// clients_ bounded even when no new connections arrive to trigger a reap.
|
||||
void try_reap_finished_clients();
|
||||
void cache_latest_packet(std::vector<std::uint8_t> packet);
|
||||
[[nodiscard]] auto latest_packet_copy() const -> std::optional<std::vector<std::uint8_t>>;
|
||||
|
||||
@@ -160,6 +168,9 @@ class TcpServer {
|
||||
|
||||
// Sentinel of "no value yet" is NaN. Lock-free read from data_processor.
|
||||
std::atomic<double> latest_socket_speed_{};
|
||||
// Monotonic (steady_clock) nanoseconds at which latest_socket_speed_ was
|
||||
// last written; used to expire stale socket-fed speeds. 0 means "never set".
|
||||
std::atomic<std::int64_t> latest_socket_speed_at_ns_{0};
|
||||
};
|
||||
|
||||
} // namespace radar::locator
|
||||
|
||||
@@ -95,6 +95,17 @@ constexpr std::uint32_t kMinTableColumns = 3U; // [x_m, z_m, score, ...]
|
||||
return stream.str();
|
||||
}
|
||||
|
||||
// Wall-clock epoch milliseconds at packet-build time. Unlike the human-readable
|
||||
// `tim` field (which has no date and rolls over at midnight), this is a
|
||||
// monotonically increasing generation stamp consumers can use to compute the
|
||||
// true age of a snapshot and detect a stalled pipeline.
|
||||
[[nodiscard]] auto epoch_millis_now() -> std::int64_t {
|
||||
using Clock = std::chrono::system_clock;
|
||||
return std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
Clock::now().time_since_epoch()
|
||||
).count();
|
||||
}
|
||||
|
||||
void append_u32_little_endian(std::vector<std::uint8_t>& buffer, std::uint32_t value) {
|
||||
buffer.push_back(static_cast<std::uint8_t>(value & 0xFFU));
|
||||
buffer.push_back(static_cast<std::uint8_t>((value >> 8U) & 0xFFU));
|
||||
@@ -181,9 +192,15 @@ auto build_payload_json(
|
||||
});
|
||||
}
|
||||
|
||||
// Fix #48: stamp every packet with a numeric build-time generation
|
||||
// (`gen`, epoch ms). A cached snapshot re-sent verbatim to a new client
|
||||
// keeps its original `gen`, so a consumer can compute the snapshot's true
|
||||
// age and flag a stalled pipeline (which `tim` alone cannot express, having
|
||||
// no date and rolling over at midnight).
|
||||
const Json root{
|
||||
{"ver", protocol_version},
|
||||
{"tim", format_timestamp_now()},
|
||||
{"gen", epoch_millis_now()},
|
||||
{"sts", status},
|
||||
{"obs", std::move(obs_array)},
|
||||
};
|
||||
|
||||
@@ -33,6 +33,14 @@ using Json = nlohmann::json;
|
||||
|
||||
constexpr std::size_t kPacketHeaderSize = 8U; // device_id u32 LE + payload_len u32 LE.
|
||||
|
||||
// Per-recv()/send() socket timeout. Bounds how long a wedged peer can stall a
|
||||
// reader/writer loop and lets the reader re-check the stop flag periodically.
|
||||
constexpr long kSocketTimeoutSeconds = 5;
|
||||
|
||||
// A socket-fed `vlc` speed older than this is considered stale and ignored, so
|
||||
// a client that disconnects (or stops sending) cannot pin a fixed speed forever.
|
||||
constexpr std::chrono::seconds kSocketSpeedStaleAfter{5};
|
||||
|
||||
// Best-effort full-write helper: loops over write() until everything is sent
|
||||
// or an error occurs. Returns false on socket error or peer disconnect.
|
||||
[[nodiscard]] auto write_all(int socket_fd, const std::uint8_t* data, std::size_t size) -> bool {
|
||||
@@ -48,6 +56,9 @@ constexpr std::size_t kPacketHeaderSize = 8U; // device_id u32 LE + payload_len
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
// EAGAIN/EWOULDBLOCK here is a SO_SNDTIMEO send timeout: treat it as
|
||||
// fatal so a stalled peer tears the session down rather than wedging
|
||||
// the writer thread indefinitely.
|
||||
return false;
|
||||
}
|
||||
if (chunk == 0) {
|
||||
@@ -58,15 +69,25 @@ constexpr std::size_t kPacketHeaderSize = 8U; // device_id u32 LE + payload_len
|
||||
return true;
|
||||
}
|
||||
|
||||
// Best-effort exact-read helper. Returns false if the peer closed the socket
|
||||
// or an unrecoverable error occurred before all bytes were read.
|
||||
[[nodiscard]] auto read_exact(int socket_fd, std::uint8_t* data, std::size_t size) -> bool {
|
||||
// Best-effort exact-read helper. Returns false if the peer closed the socket,
|
||||
// an unrecoverable error occurred, or stop was requested before all bytes were
|
||||
// read. A SO_RCVTIMEO timeout (EAGAIN/EWOULDBLOCK) is not fatal: we re-check the
|
||||
// stop flag and retry so a wedged peer cannot keep the reader thread alive.
|
||||
[[nodiscard]] auto read_exact(
|
||||
int socket_fd,
|
||||
std::uint8_t* data,
|
||||
std::size_t size,
|
||||
const std::atomic<bool>& stop_requested
|
||||
) -> bool {
|
||||
std::size_t consumed = 0;
|
||||
while (consumed < size) {
|
||||
if (stop_requested.load(std::memory_order_acquire)) {
|
||||
return false;
|
||||
}
|
||||
const auto chunk = ::recv(socket_fd, data + consumed, size - consumed, 0);
|
||||
if (chunk < 0) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
continue; // Interrupted or recv timeout: re-check stop and retry.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -107,6 +128,14 @@ void apply_socket_keepalive(int socket_fd) {
|
||||
int yes = 1;
|
||||
(void)::setsockopt(socket_fd, IPPROTO_TCP, TCP_NODELAY, &yes, sizeof(yes));
|
||||
(void)::setsockopt(socket_fd, SOL_SOCKET, SO_KEEPALIVE, &yes, sizeof(yes));
|
||||
// Bound blocking recv()/send() so a wedged peer cannot stall a reader/writer
|
||||
// thread forever. recv() timeouts are retried (stop-aware); a send() timeout
|
||||
// is treated as a fatal write failure that tears the session down.
|
||||
timeval timeout{};
|
||||
timeout.tv_sec = kSocketTimeoutSeconds;
|
||||
timeout.tv_usec = 0;
|
||||
(void)::setsockopt(socket_fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
||||
(void)::setsockopt(socket_fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
|
||||
}
|
||||
|
||||
void shutdown_and_close(int& socket_fd) {
|
||||
@@ -192,9 +221,14 @@ ClientSession::~ClientSession() {
|
||||
shutdown_and_close(socket_fd_);
|
||||
}
|
||||
|
||||
void ClientSession::start(std::atomic<double>& shared_vlc_slot) {
|
||||
void ClientSession::start(
|
||||
std::atomic<double>& shared_vlc_slot,
|
||||
std::atomic<std::int64_t>& shared_vlc_at_ns
|
||||
) {
|
||||
writer_thread_ = std::thread([this]() { writer_loop(); });
|
||||
reader_thread_ = std::thread([this, &shared_vlc_slot]() { reader_loop(shared_vlc_slot); });
|
||||
reader_thread_ = std::thread([this, &shared_vlc_slot, &shared_vlc_at_ns]() {
|
||||
reader_loop(shared_vlc_slot, shared_vlc_at_ns);
|
||||
});
|
||||
}
|
||||
|
||||
void ClientSession::enqueue(std::vector<std::uint8_t> packet) {
|
||||
@@ -245,15 +279,24 @@ void ClientSession::writer_loop() {
|
||||
}
|
||||
}
|
||||
request_stop();
|
||||
// Exited flag is set once both threads finish; reader_loop sets it.
|
||||
// Mark exited only once BOTH loops have finished; otherwise a writer-only
|
||||
// death (e.g. a send timeout while the reader still blocks on recv) would
|
||||
// never be reaped. request_stop() above shuts the socket so the reader's
|
||||
// recv() returns promptly.
|
||||
if (loops_finished_.fetch_add(1, std::memory_order_acq_rel) + 1U == 2U) {
|
||||
exited_.store(true, std::memory_order_release);
|
||||
}
|
||||
}
|
||||
|
||||
void ClientSession::reader_loop(std::atomic<double>& shared_vlc_slot) {
|
||||
void ClientSession::reader_loop(
|
||||
std::atomic<double>& shared_vlc_slot,
|
||||
std::atomic<std::int64_t>& shared_vlc_at_ns
|
||||
) {
|
||||
std::array<std::uint8_t, kPacketHeaderSize> header_buffer{};
|
||||
std::vector<std::uint8_t> payload_buffer;
|
||||
|
||||
while (!stop_requested_.load(std::memory_order_acquire)) {
|
||||
if (!read_exact(socket_fd_, header_buffer.data(), header_buffer.size())) {
|
||||
if (!read_exact(socket_fd_, header_buffer.data(), header_buffer.size(), stop_requested_)) {
|
||||
break;
|
||||
}
|
||||
const auto payload_len = decode_u32_little_endian(header_buffer.data() + 4U);
|
||||
@@ -266,7 +309,8 @@ void ClientSession::reader_loop(std::atomic<double>& shared_vlc_slot) {
|
||||
}
|
||||
|
||||
payload_buffer.assign(payload_len, std::uint8_t{0});
|
||||
if (payload_len > 0U && !read_exact(socket_fd_, payload_buffer.data(), payload_len)) {
|
||||
if (payload_len > 0U
|
||||
&& !read_exact(socket_fd_, payload_buffer.data(), payload_len, stop_requested_)) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -277,7 +321,13 @@ void ClientSession::reader_loop(std::atomic<double>& shared_vlc_slot) {
|
||||
if (found != json.end() && found->is_number()) {
|
||||
const double value = found->get<double>();
|
||||
if (std::isfinite(value)) {
|
||||
// Stamp the value first, then the time, so a reader that
|
||||
// observes a fresh timestamp also observes the matching value.
|
||||
shared_vlc_slot.store(value, std::memory_order_release);
|
||||
const auto now_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()
|
||||
).count();
|
||||
shared_vlc_at_ns.store(now_ns, std::memory_order_release);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -288,7 +338,11 @@ void ClientSession::reader_loop(std::atomic<double>& shared_vlc_slot) {
|
||||
}
|
||||
}
|
||||
request_stop();
|
||||
exited_.store(true, std::memory_order_release);
|
||||
// Mark exited only once BOTH loops have finished (see writer_loop), so a
|
||||
// reader-only death still waits for the writer before this session is reaped.
|
||||
if (loops_finished_.fetch_add(1, std::memory_order_acq_rel) + 1U == 2U) {
|
||||
exited_.store(true, std::memory_order_release);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- TcpServer ------------------------------------------------------------
|
||||
@@ -409,11 +463,28 @@ void TcpServer::publish(const ipc::ResultCollection& collection, const FilterPar
|
||||
auto packet = encode_packet(payload_json, config_.device_id);
|
||||
cache_latest_packet(packet);
|
||||
broadcast_packet(packet);
|
||||
// Reap exited sessions on the publish path too, so clients_ stays bounded
|
||||
// even when no new connections arrive to trigger an accept-time reap. Uses a
|
||||
// try-lock and joins outside the mutex, so publish() never blocks on it.
|
||||
try_reap_finished_clients();
|
||||
}
|
||||
|
||||
auto TcpServer::latest_socket_speed() const -> std::optional<double> {
|
||||
// Read the timestamp first, then the value, mirroring the writer's order so a
|
||||
// value seen here is at least as fresh as its timestamp.
|
||||
const std::int64_t at_ns = latest_socket_speed_at_ns_.load(std::memory_order_acquire);
|
||||
const double value = latest_socket_speed_.load(std::memory_order_acquire);
|
||||
if (std::isnan(value)) {
|
||||
if (at_ns == 0 || std::isnan(value)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
// Expire stale socket-fed speeds: a client that stopped sending (or
|
||||
// disconnected) must not pin a fixed speed indefinitely.
|
||||
const auto now_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()
|
||||
).count();
|
||||
const auto stale_after_ns =
|
||||
std::chrono::duration_cast<std::chrono::nanoseconds>(kSocketSpeedStaleAfter).count();
|
||||
if (now_ns - at_ns > stale_after_ns) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return value;
|
||||
@@ -457,7 +528,7 @@ void TcpServer::acceptor_loop() {
|
||||
if (snapshot.has_value()) {
|
||||
session->enqueue(*snapshot);
|
||||
}
|
||||
session->start(latest_socket_speed_);
|
||||
session->start(latest_socket_speed_, latest_socket_speed_at_ns_);
|
||||
enroll_client(std::move(session));
|
||||
}
|
||||
}
|
||||
@@ -496,6 +567,35 @@ void TcpServer::reap_finished_clients() {
|
||||
}
|
||||
}
|
||||
|
||||
void TcpServer::try_reap_finished_clients() {
|
||||
std::vector<std::unique_ptr<ClientSession>> to_join;
|
||||
{
|
||||
// try_to_lock: if the acceptor already holds the mutex (it reaps too),
|
||||
// skip this round rather than block the publish path.
|
||||
std::unique_lock<std::mutex> guard(clients_mutex_, std::try_to_lock);
|
||||
if (!guard.owns_lock()) {
|
||||
return;
|
||||
}
|
||||
auto first_dead = std::partition(
|
||||
clients_.begin(),
|
||||
clients_.end(),
|
||||
[](const std::unique_ptr<ClientSession>& session) {
|
||||
return !session->has_exited();
|
||||
}
|
||||
);
|
||||
for (auto it = first_dead; it != clients_.end(); ++it) {
|
||||
to_join.push_back(std::move(*it));
|
||||
}
|
||||
clients_.erase(first_dead, clients_.end());
|
||||
}
|
||||
// Join outside the lock: a finished session's threads are done, so join()
|
||||
// returns promptly without holding clients_mutex_.
|
||||
for (auto& session : to_join) {
|
||||
session->request_stop();
|
||||
session->join();
|
||||
}
|
||||
}
|
||||
|
||||
void TcpServer::cache_latest_packet(std::vector<std::uint8_t> packet) {
|
||||
std::lock_guard<std::mutex> guard(latest_packet_mutex_);
|
||||
latest_packet_ = std::move(packet);
|
||||
|
||||
Reference in New Issue
Block a user