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
@@ -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);