125 lines
5.5 KiB
C++
125 lines
5.5 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#pragma once
|
|
|
|
#include <atomic>
|
|
#include <cstdint>
|
|
#include <string>
|
|
|
|
namespace udpscope {
|
|
|
|
/// The largest datagram the client's socket can deliver, and therefore the payload
|
|
/// capacity of one capture slot. Kept equal to the client's own receive buffer: an
|
|
/// over-long datagram is impossible by construction, and `store()` counts the
|
|
/// impossibility rather than trusting it.
|
|
inline constexpr std::uint32_t kMaxDatagramBytes = 65535 + 17;
|
|
|
|
/// One slot's stride: the fixed record header, the largest datagram, and the `seq`
|
|
/// word that is written last. 8-byte aligned.
|
|
inline constexpr std::uint32_t kRecordHeaderBytes = 16; ///< t_arrival f64, len u32, orig_len u32
|
|
inline constexpr std::uint32_t kSlotBytes =
|
|
kRecordHeaderBytes + kMaxDatagramBytes + 8; ///< the trailing 8 are `seq`
|
|
|
|
/// Owns the ring file a packet capture stores raw datagrams into, and stores a
|
|
/// datagram into it. It includes no GTK, no `ScopeModel`, and no client types: its
|
|
/// whole world is bytes, a clock reading and one file.
|
|
///
|
|
/// Everything here runs on the producer thread except `open()`'s caller choice and
|
|
/// the GTK thread's reads of the counters and the error, which are publishable
|
|
/// without a lock: see the field table in the design note
|
|
/// (`docs/superpowers/specs/2026-10-09-udp-packet-capture-design.md`).
|
|
class PacketCapture {
|
|
public:
|
|
PacketCapture() = default;
|
|
~PacketCapture() { close(); }
|
|
|
|
PacketCapture(const PacketCapture&) = delete;
|
|
PacketCapture& operator=(const PacketCapture&) = delete;
|
|
|
|
/// Renames an existing capture to `<path>` minus extension plus `-prev.bin`,
|
|
/// creates and reserves the new file, maps it, writes the header. Returns false
|
|
/// and leaves the object disabled on any failure, with `error()` set.
|
|
bool open(const std::string& path, std::uint32_t slot_count);
|
|
|
|
/// Unmaps and closes. Idempotent; keeps the counters, which read "since open()".
|
|
void close() noexcept;
|
|
|
|
bool enabled() const noexcept { return enabled_.load(std::memory_order_relaxed); }
|
|
|
|
/// One datagram. Called only from the producer thread. Never allocates, never
|
|
/// blocks, never fails: an over-long datagram is truncated to the slot and counted.
|
|
void store(const std::uint8_t* bytes, std::size_t len, double recv_time) noexcept;
|
|
|
|
std::uint64_t stored() const noexcept { return stored_.load(std::memory_order_relaxed); }
|
|
std::uint64_t truncated() const noexcept {
|
|
return truncated_.load(std::memory_order_relaxed);
|
|
}
|
|
std::uint32_t slot_count() const noexcept { return slot_count_; }
|
|
const std::string& path() const noexcept { return path_; }
|
|
|
|
/// The source's IPv4, in network order, and its port: written into the header at
|
|
/// open() so the pcap converter can synthesise a UDP header. Call before open(),
|
|
/// from the thread that owns the decision -- the same write-once discipline as the
|
|
/// path. `local_port` stays zero: the client does not expose its own socket.
|
|
void set_peer(std::uint32_t addr_net_order, std::uint16_t port) noexcept {
|
|
peer_addr_ = addr_net_order;
|
|
peer_port_ = port;
|
|
}
|
|
|
|
/// Wall milliseconds `open()` took, measured rather than asserted: the one cost a
|
|
/// toggle has, and a reading above ~50 ms is a finding about the filesystem.
|
|
double open_ms() const noexcept { return open_ms_.load(std::memory_order_relaxed); }
|
|
|
|
/// Copies the last open() failure out, or writes an empty string. Deliberately not
|
|
/// a `const std::string&`: the producer writes the fixed buffer and *then* publishes
|
|
/// the flag with a release store, and the GTK thread copies only when the flag reads
|
|
/// true -- a `std::string` shared like that is a data race with a reallocating
|
|
/// buffer, the kind that reads correctly for months.
|
|
void error(char* out, std::size_t cap) const noexcept;
|
|
|
|
private:
|
|
void set_error(const char* what) noexcept;
|
|
|
|
std::string path_;
|
|
std::uint32_t slot_count_ = 0;
|
|
int fd_ = -1;
|
|
std::uint8_t* map_ = nullptr; ///< whole file, header first
|
|
std::uint64_t map_bytes_ = 0;
|
|
std::uint64_t seq_ = 0; ///< 1-based ordinal since open(); producer-only
|
|
std::uint32_t cursor_ = 0; ///< next slot index to write; producer-only
|
|
std::uint32_t peer_addr_ = 0; ///< the source's IPv4, network order
|
|
std::uint16_t peer_port_ = 0;
|
|
std::atomic<bool> enabled_{false};
|
|
std::atomic<std::uint64_t> stored_{0};
|
|
std::atomic<std::uint64_t> truncated_{0};
|
|
std::atomic<bool> error_set_{false};
|
|
std::atomic<double> open_ms_{0.0};
|
|
mutable char error_[128] = {0};
|
|
};
|
|
|
|
/// What a capture file says when read back off disk. `stored` counts slots with a
|
|
/// non-zero `seq`; `torn` counts slots with bytes but no `seq` (a capture that died
|
|
/// mid-copy); `empty` counts all-zero slots, which a freshly reserved file is full of.
|
|
struct CaptureReadStats {
|
|
bool header_ok = false;
|
|
std::uint32_t slot_count = 0;
|
|
std::uint32_t slot_bytes = 0;
|
|
std::uint32_t cursor = 0;
|
|
double t0_realtime = 0.0;
|
|
std::uint64_t stored = 0;
|
|
std::uint64_t torn = 0;
|
|
std::uint64_t empty = 0;
|
|
std::uint64_t truncated = 0; ///< slots whose `orig_len` exceeds the payload capacity
|
|
std::uint64_t payload = 0; ///< sum of stored slots' `len`: the bytes the wire delivered
|
|
std::uint64_t first_seq = 0;
|
|
std::uint64_t last_seq = 0;
|
|
std::uint64_t file_bytes = 0;
|
|
};
|
|
|
|
/// Reads a capture file back and validates it. Pure POSIX plus `<cstdio>`; used by the
|
|
/// headless probe, and `tools/capture_to_pcap.py` re-implements the walk in Python.
|
|
bool read_capture(const std::string& path, CaptureReadStats* out);
|
|
|
|
} // namespace udpscope
|