From 504808892df72b2565dffea18c6e22d583e3bcd3 Mon Sep 17 00:00:00 2001 From: Petros Gigis Date: Thu, 30 Apr 2026 12:47:21 +0100 Subject: [PATCH 1/8] introduce new configuration examples and remove mutex from packet processing --- CMakeLists.txt | 4 + docs/dev/active-passive-overview.md | 3 + docs/run/configuration-examples.md | 4 +- examples/configs/config_default.yaml | 2 +- examples/configs/config_passive.yaml | 22 +- examples/configs/platform/egress_raw_nic.yaml | 13 +- .../configs/platform/egress_raw_socket.yaml | 13 + include/openpenny/app/cli/cli_helpers.h | 16 ++ .../openpenny/app/core/ActiveTestPipeline.h | 5 + .../openpenny/app/core/DropCollectorBinding.h | 15 +- .../app/core/OpenpennyPipelineDriver.h | 64 ++++- include/openpenny/app/core/PerThreadStats.h | 16 +- include/openpenny/egress/PacketSink.h | 7 +- include/openpenny/egress/RawNicSink.h | 13 +- include/openpenny/egress/RawSocketSink.h | 9 + include/openpenny/net/Packet.h | 19 +- .../openpenny/penny/flow/engine/FlowEngine.h | 31 +-- .../openpenny/penny/flow/state/PacketDropId.h | 35 +++ .../penny/flow/timer/ThreadFlowEventTimer.h | 19 +- src/app/cli/cli_helpers.cpp | 13 +- src/app/cli/penny_cli.cpp | 72 ++++- src/app/core/AggregatesController.cpp | 253 ++++++++++++------ src/app/core/DropCollectorBinding.cpp | 100 ++++--- src/app/core/OpenpennyPipelineDriver.cpp | 19 +- src/app/core/PerThreadStats.cpp | 67 ++++- src/app/core/active/ActiveTestPipeline.cpp | 30 ++- src/egress/RawNicSink.cpp | 73 +++-- src/egress/RawSocketSink.cpp | 37 ++- .../af_packet/AfPacketMirrorReader.cpp | 72 ++++- src/ingress/af_xdp/XdpReader.cpp | 5 + src/ingress/dpdk/DpdkReader.cpp | 4 + src/penny/flow/engine/FlowEngine.cpp | 40 +-- src/penny/flow/timer/ThreadFlowEventTimer.cpp | 16 +- .../unit/flow/test_aggregate_drop_budget.cpp | 38 +++ .../unit/flow/test_drop_snapshot_updates.cpp | 148 ++++++++-- tests/unit/flow/test_drop_timer.cpp | 10 +- tests/unit/flow/test_gap_management.cpp | 4 +- 37 files changed, 1008 insertions(+), 303 deletions(-) create mode 100644 examples/configs/platform/egress_raw_socket.yaml create mode 100644 include/openpenny/penny/flow/state/PacketDropId.h create mode 100644 tests/unit/flow/test_aggregate_drop_budget.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 507b2df..f715c38 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -166,6 +166,10 @@ add_executable(test_flow_thresholds tests/unit/flow/test_flow_thresholds.cpp) target_link_libraries(test_flow_thresholds PRIVATE openpenny) add_test(NAME flow_thresholds COMMAND test_flow_thresholds) +add_executable(test_aggregate_drop_budget tests/unit/flow/test_aggregate_drop_budget.cpp) +target_link_libraries(test_aggregate_drop_budget PRIVATE openpenny) +add_test(NAME aggregate_drop_budget COMMAND test_aggregate_drop_budget) + add_executable(test_cli_options tests/unit/cli/test_cli_options.cpp) target_link_libraries(test_cli_options PRIVATE openpenny) add_test(NAME cli_options COMMAND test_cli_options) diff --git a/docs/dev/active-passive-overview.md b/docs/dev/active-passive-overview.md index baea3e0..f267b93 100644 --- a/docs/dev/active-passive-overview.md +++ b/docs/dev/active-passive-overview.md @@ -42,6 +42,8 @@ NIC -> {AF_XDP redirect | AF_PACKET copy | DPDK} -> PacketSource -> PacketParser - Aggregated counters can short-circuit decisions once enough drops have outcomes. - Egress: - `cfg.egress.kind = tun` by default in examples; switch to `raw_socket` / `raw_nic` / `none` as needed. + - `raw_socket` is the routed L3 path: the kernel picks the next hop and rewrites the Ethernet header. + - `raw_nic` is the L2 replay path: it sends the original Ethernet frame unchanged and is only correct when that captured L2 header is still valid on the target segment. ## Passive mode - Observes flows without inducing drops. @@ -57,6 +59,7 @@ NIC -> {AF_XDP redirect | AF_PACKET copy | DPDK} -> PacketSource -> PacketParser - Override with `ingress_mode: redirect` to force AF_XDP in passive mode (e.g. to measure the fast path); the captured stream must then be reinjected through a TUN egress to reach the application. - Egress: - Typically `cfg.egress.kind = none` in copy mode (the kernel has already delivered the packet). Enable `tun` / `raw_socket` / `raw_nic` only if the captured stream should be mirrored elsewhere or if `ingress_mode: redirect` is in use. + - For redirected traffic that must still reach a local app or a routed downstream host, prefer `tun` or `raw_socket`; `raw_nic` does not perform route / ARP resolution. ## CLI vs gRPC - CLI: `openpenny_cli --mode active|passive ...` uses on-disk config, optionally overridden by flags. diff --git a/docs/run/configuration-examples.md b/docs/run/configuration-examples.md index e5a370c..0e169be 100644 --- a/docs/run/configuration-examples.md +++ b/docs/run/configuration-examples.md @@ -118,8 +118,8 @@ egress: | ------------ | ------------------------------------------------------------------------------------- | | `none` | Drop matched packets after counting them. Good for measurement-only runs. | | `tun` | Reinject packets into a TUN device (`device:`). The `tun.*` block applies here only. | -| `raw_socket` | Send via `IPPROTO_RAW`. The kernel routes the packet. | -| `raw_nic` | Send via `AF_PACKET` bound to `device`, bypassing routing. | +| `raw_socket` | Send via `IPPROTO_RAW`. The kernel routes the packet and resolves the next-hop MAC. | +| `raw_nic` | Replay the original Ethernet frame via `AF_PACKET` bound to `device`, bypassing routing and neighbour resolution. | `tun.rp_filter_loose: true` is usually needed: redirected packets keep their original source IP, and the return route lives on the physical NIC, diff --git a/examples/configs/config_default.yaml b/examples/configs/config_default.yaml index a812413..fd1e3db 100644 --- a/examples/configs/config_default.yaml +++ b/examples/configs/config_default.yaml @@ -30,7 +30,7 @@ includes: platform: platform/af_xdp.yaml # Where matched packets go after Penny is done with them. - traffic_forwarding: platform/forwarding_tun.yaml + egress: platform/egress_raw_socket.yaml # gRPC daemon listen address (only used by `pennyd`, ignored by the CLI). grpc: platform/grpc.yaml diff --git a/examples/configs/config_passive.yaml b/examples/configs/config_passive.yaml index 6b50ce1..20cc0cd 100644 --- a/examples/configs/config_passive.yaml +++ b/examples/configs/config_passive.yaml @@ -3,14 +3,24 @@ log: level: info # Passive observation example. The pipeline mirrors traffic without -# touching the data path: AF_PACKET copies frames out of the kernel, -# the runtime policy runs in passive mode, and egress is disabled so -# nothing is reinjected. Includes are resolved relative to this file -# and the combined config is schema-validated before OpenPenny starts. +# touching the data path: AF_PACKET copies frames out of the kernel +# while the kernel keeps delivering the originals to the real +# application. Matched packets are then forwarded out of OpenPenny via +# the egress sink so a downstream collector / NIC can receive them. If +# you do not want any re-emission, swap `egress` back to +# `platform/egress_none.yaml` (which uses the no-op NullSink). +# +# Includes are resolved relative to this file and the combined config +# is schema-validated before OpenPenny starts. includes: traffic_policy: policies/traffic_default.yaml runtime_policy: policies/runtime_passive.yaml platform: platform/af_xdp.yaml - traffic_forwarding: platform/forwarding_disabled.yaml - egress: platform/egress_none.yaml + # Forward matched packets via an IPPROTO_RAW socket so the kernel + # routing table picks the egress hop. Replace with + # `platform/egress_raw_nic.yaml` to replay the original Ethernet + # frame out a specific NIC, or `platform/egress_tun.yaml` to hand + # them to a local TUN consumer. `platform/egress_none.yaml` keeps the + # historical "observe only, do not forward" behaviour. + egress: platform/egress_raw_socket.yaml grpc: platform/grpc.yaml diff --git a/examples/configs/platform/egress_raw_nic.yaml b/examples/configs/platform/egress_raw_nic.yaml index 79c0f8c..7fb3c0a 100644 --- a/examples/configs/platform/egress_raw_nic.yaml +++ b/examples/configs/platform/egress_raw_nic.yaml @@ -1,7 +1,12 @@ -# Example egress configuration: send matched packets out a specific NIC -# via AF_PACKET, bypassing the local routing table. Use this when you -# want forwarded traffic to leave a named physical port regardless of -# what /proc/net/route says. +# Example egress configuration: replay matched packets out a specific NIC +# via AF_PACKET, bypassing the local routing table. +# +# Important: raw_nic sends the ORIGINAL Ethernet frame. It does not +# reinject packets into the local host stack, and it does not rewrite +# destination/source MAC addresses for a different next hop. Use this only +# when the captured L2 header is still valid on the target egress segment. +# For redirected traffic that must still reach a local application, prefer +# `egress.kind: tun`. egress: kind: raw_nic device: ens5f0np0 diff --git a/examples/configs/platform/egress_raw_socket.yaml b/examples/configs/platform/egress_raw_socket.yaml new file mode 100644 index 0000000..cff146a --- /dev/null +++ b/examples/configs/platform/egress_raw_socket.yaml @@ -0,0 +1,13 @@ +# Example egress configuration: send matched packets back through the +# kernel routing stack via an IPPROTO_RAW socket. +# +# Use this when the destination should still be reached through normal +# L3 routing and neighbour resolution (for example, when this host acts +# as a router and must rewrite the next-hop Ethernet header). +# +# Unlike `raw_nic`, this path does not replay the original Ethernet +# frame. The kernel chooses the next hop from the routing table and +# resolves the neighbour MAC as needed. +egress: + kind: raw_socket + device: ens5f0np0 diff --git a/include/openpenny/app/cli/cli_helpers.h b/include/openpenny/app/cli/cli_helpers.h index ac3c97e..7e4e971 100644 --- a/include/openpenny/app/cli/cli_helpers.h +++ b/include/openpenny/app/cli/cli_helpers.h @@ -37,6 +37,22 @@ struct CliOptions { unsigned queue_value = 0; unsigned queue_count = 1; unsigned queue_probe_ms = 250; + + // ------------------------------------------------------------------ + // "Was this set on the command line?" flags. + // + // Several of the fields above carry a sensible default (e.g. mode = + // Active, queue_count = 1, source = "xdp"). Without these flags we + // cannot distinguish "operator typed --mode active" from "operator + // typed nothing and we left the default", which means a YAML file + // saying `mode: passive` would be silently overwritten by the + // baked-in CLI default. Any code path that copies a CLI value into + // the loaded Config should branch on the matching `*_set` flag and + // leave the YAML value alone when it is false. + // ------------------------------------------------------------------ + bool mode_set = false; ///< true if --mode was supplied. + bool source_set = false; ///< true if --source was supplied. + bool queue_count_set = false; ///< true if --queues was supplied. }; std::string to_lower(std::string value); diff --git a/include/openpenny/app/core/ActiveTestPipeline.h b/include/openpenny/app/core/ActiveTestPipeline.h index 02f2adc..ae736f6 100644 --- a/include/openpenny/app/core/ActiveTestPipeline.h +++ b/include/openpenny/app/core/ActiveTestPipeline.h @@ -202,6 +202,11 @@ class ActiveTestPipelineRunner : public IPipelineStrategy { */ DropCollectorPtr drop_collector_; + /** + * Collector shard assigned to this worker thread. + */ + std::size_t drop_collector_shard_index_{0}; + /** * Friendly name for this worker thread. */ diff --git a/include/openpenny/app/core/DropCollectorBinding.h b/include/openpenny/app/core/DropCollectorBinding.h index 2f6c319..2e26416 100644 --- a/include/openpenny/app/core/DropCollectorBinding.h +++ b/include/openpenny/app/core/DropCollectorBinding.h @@ -28,30 +28,31 @@ class DropCollectorBinding { void bind(penny::FlowEngine* flow, DropCollectorPtr collector, - const std::string& thread_name); + const std::string& thread_name, + std::size_t shard_index); void unbind(penny::FlowEngine* flow); void upsert(DropCollectorPtr collector, const std::string& thread_name, + std::size_t shard_index, const FlowKey& key, - const std::string& packet_id, - const penny::PacketDropSnapshot& snap, - const openpenny::app::AggregatedCounters& agg); + penny::PacketDropId packet_id, + const penny::PacketDropSnapshot& snap); private: struct BindingContext { DropCollectorPtr collector; std::string thread_name; + std::size_t shard_index{0}; }; DropCollectorBinding() = default; BindingContext lookup(penny::FlowEngine* flow) const; void upsert_locked(const BindingContext& binding, const FlowKey& key, - const std::string& packet_id, - const penny::PacketDropSnapshot& snap, - const openpenny::app::AggregatedCounters& agg); + penny::PacketDropId packet_id, + const penny::PacketDropSnapshot& snap); mutable std::mutex mtx_; std::once_flag hook_once_; diff --git a/include/openpenny/app/core/OpenpennyPipelineDriver.h b/include/openpenny/app/core/OpenpennyPipelineDriver.h index c0d0438..e8bca0b 100644 --- a/include/openpenny/app/core/OpenpennyPipelineDriver.h +++ b/include/openpenny/app/core/OpenpennyPipelineDriver.h @@ -6,15 +6,21 @@ #include "openpenny/agg/Stats.h" #include "openpenny/egress/PacketSink.h" #include "openpenny/penny/flow/state/PennySnapshot.h" +#include "openpenny/penny/flow/state/PacketDropId.h" #include "openpenny/app/core/PerThreadStats.h" #include "openpenny/net/TrafficMatch.h" #include +#include #include #include #include #include +#include +#include +#include #include +#include #include #include @@ -68,21 +74,69 @@ struct PipelineOptions { struct DropSnapshotRecord { FlowKey key{}; - std::string packet_id; + penny::PacketDropId packet_id{0}; penny::PacketDropSnapshot snapshot{}; - openpenny::app::AggregatedCounters counters{}; + openpenny::app::AggregatedCounters counters{}; // Decorated when exporting/evaluating aggregates. std::string thread_name; }; /** * @brief Shared drop snapshot collector across all active pipeline threads. * - * Threads append/update records here; ordering/sorting happens in the driver. + * Threads append/update records in their own shard; ordering/sorting happens + * in the driver. */ struct DropCollector { - std::mutex mtx; + static constexpr std::size_t kMaxShards = 128; + using TimestampRep = std::chrono::steady_clock::duration::rep; + static constexpr TimestampRep kNoSnapshotTimestamp = + std::numeric_limits::min(); + + struct SnapshotKey { + FlowKey key{}; + penny::PacketDropId packet_id{0}; + + bool operator==(const SnapshotKey& other) const noexcept { + return key == other.key && packet_id == other.packet_id; + } + }; + + struct SnapshotKeyHash { + std::size_t operator()(const SnapshotKey& value) const noexcept { + std::size_t h = FlowKeyHash{}(value.key); + const auto hs = std::hash{}(value.packet_id); + return h ^ (hs + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2)); + } + }; + + struct alignas(64) Shard { + mutable std::mutex mtx; + std::vector snapshots; + std::unordered_map snapshot_index; + std::atomic snapshot_count{0}; + std::atomic pending_snapshot_count{0}; + std::atomic latest_snapshot_index{0}; + std::atomic latest_snapshot_timestamp{kNoSnapshotTimestamp}; + }; + + explicit DropCollector(std::size_t requested_shards = 1) + : shard_count(std::max(1, std::min(requested_shards, kMaxShards))) {} + std::atomic accepting{true}; - std::vector snapshots; + std::size_t shard_count{1}; + std::array shards{}; + + std::size_t clamp_shard_index(std::size_t idx) const noexcept { + return idx < shard_count ? idx : shard_count - 1; + } + + Shard& shard_for(std::size_t idx) noexcept { + return shards[clamp_shard_index(idx)]; + } + + const Shard& shard_for(std::size_t idx) const noexcept { + return shards[clamp_shard_index(idx)]; + } }; using DropCollectorPtr = std::shared_ptr; diff --git a/include/openpenny/app/core/PerThreadStats.h b/include/openpenny/app/core/PerThreadStats.h index f71928e..d897da4 100644 --- a/include/openpenny/app/core/PerThreadStats.h +++ b/include/openpenny/app/core/PerThreadStats.h @@ -7,6 +7,7 @@ #include #include "openpenny/agg/Stats.h" // for FlowKey +#include "openpenny/penny/flow/state/PacketDropId.h" namespace openpenny::app { @@ -35,7 +36,7 @@ struct alignas(64) PerThreadStats { struct DropSnapshotInfo { FlowKey key{}; - std::string packet_id; + penny::PacketDropId packet_id{0}; std::uint64_t timestamp_ns{0}; std::uint64_t duplicates{0}; std::uint64_t data_packets{0}; @@ -71,9 +72,22 @@ struct AggregatedCounters { void init_thread_counters(std::size_t count); void set_thread_counter_index(std::size_t idx); +std::size_t current_thread_counter_index() noexcept; PerThreadStats& current_thread_counters(); const std::vector& thread_counters(); AggregatedCounters aggregate_counters(); std::uint64_t aggregate_active_flows(); +/** + * Best-effort aggregate drop-budget reservation across worker slots. + * + * Sums the per-worker atomic drop counters and, if the total is still below + * @p max_total_drops, increments the current worker's slot and returns true. + * This path is intentionally lock-free and may overshoot slightly under races. + */ +bool try_reserve_aggregate_drop(std::uint64_t max_total_drops) noexcept; + +/// Return the current summed value of the per-worker aggregate drop budget. +std::uint64_t aggregate_drop_budget_drops() noexcept; + } // namespace openpenny::app diff --git a/include/openpenny/egress/PacketSink.h b/include/openpenny/egress/PacketSink.h index 05e022f..45d56e8 100644 --- a/include/openpenny/egress/PacketSink.h +++ b/include/openpenny/egress/PacketSink.h @@ -46,7 +46,7 @@ enum class EgressKind { None, ///< Drop matched packets; only increment counters. Tun, ///< Write layer-3 bytes into a TUN device (IFF_TUN, IFF_NO_PI). RawSocket, ///< Write layer-3 bytes into an IPPROTO_RAW socket. - RawNic, ///< Write layer-3 bytes out an AF_PACKET raw socket on a NIC. + RawNic, ///< Replay the original layer-2 frame out an AF_PACKET raw socket on a NIC. }; /** @@ -81,8 +81,9 @@ struct EgressConfig { /// interface. Set false only if you manage rp_filter externally. bool tun_rp_filter_loose = true; - /// RawNic-only: if true, the socket is bound to `device` via SO_BINDTODEVICE - /// and the caller is responsible for ensuring the NIC can TX layer-3 frames. + /// RawNic-only: if true, the socket is bound to `device` via SO_BINDTODEVICE. + /// RawNic replays the original Ethernet frame and therefore bypasses + /// route / ARP / neighbour resolution. bool raw_nic_bind_device = true; /** diff --git a/include/openpenny/egress/RawNicSink.h b/include/openpenny/egress/RawNicSink.h index c181f3e..e75bd23 100644 --- a/include/openpenny/egress/RawNicSink.h +++ b/include/openpenny/egress/RawNicSink.h @@ -3,14 +3,15 @@ #pragma once /** * @file RawNicSink.h - * @brief PacketSink implementation that emits layer-3 packets out a - * specific NIC via an AF_PACKET/SOCK_DGRAM socket. + * @brief PacketSink implementation that replays original Ethernet frames + * out a specific NIC via AF_PACKET/SOCK_RAW. * * Unlike RawSocketSink (IPPROTO_RAW, which consults the routing table), - * this sink writes frames straight to a named interface using - * AF_PACKET, making it appropriate for mirroring / reinjection - * scenarios where the operator wants the traffic to leave a physical - * port without being re-routed by the local host. + * this sink transmits a captured layer-2 frame straight to a named + * interface. It does not ARP / route / rewrite next-hop MAC addresses, + * and it does not deliver packets into the local host stack. Use it only + * when replaying the original Ethernet frame is actually valid for the + * target egress segment. */ #include "openpenny/egress/PacketSink.h" diff --git a/include/openpenny/egress/RawSocketSink.h b/include/openpenny/egress/RawSocketSink.h index 4cbe531..1b9754f 100644 --- a/include/openpenny/egress/RawSocketSink.h +++ b/include/openpenny/egress/RawSocketSink.h @@ -14,6 +14,8 @@ #include "openpenny/egress/PacketSink.h" +#include + namespace openpenny::egress { class RawSocketSink : public PacketSink { @@ -30,6 +32,13 @@ class RawSocketSink : public PacketSink { private: EgressConfig cfg_{}; int fd_ = -1; + /// Latched once we have logged the first EMSGSIZE failure. The kernel + /// returns EMSGSIZE for any IP datagram larger than the egress + /// interface MTU (raw sockets cannot fragment), and on a busy + /// passive tap that would otherwise emit a WARN per oversized + /// packet. We log a single, actionable hint and silently count the + /// rest in stats_.errors. + std::atomic oversized_logged_{false}; }; } // namespace openpenny::egress diff --git a/include/openpenny/net/Packet.h b/include/openpenny/net/Packet.h index e894c4c..218e5e1 100644 --- a/include/openpenny/net/Packet.h +++ b/include/openpenny/net/Packet.h @@ -4,6 +4,7 @@ #include "openpenny/agg/Stats.h" // for FlowKey #include "openpenny/dataplane/Session.h" +#include "openpenny/penny/flow/state/PacketDropId.h" #include #include @@ -112,13 +113,25 @@ struct PacketView { const uint8_t* layer3_ptr{nullptr}; ///< Pointer into the source buffer (non-owning). uint32_t layer3_length{0}; ///< Length of the Layer 3 (IP) packet parsed. + /// Pointer to the start of the Layer 2 (Ethernet) header in the source + /// buffer, when the source surfaces it. Non-owning; valid only for the + /// lifetime of the packet handler call. Set by the AF_XDP and AF_PACKET + /// readers; egress sinks that need to forward the original frame + /// (e.g. RawNicSink) read it from here. + const uint8_t* layer2_ptr{nullptr}; + uint32_t layer2_length{0}; ///< Bytes from layer2_ptr to end of frame. + /** * @brief Build a logical identifier for snapshot bookkeeping. * - * Format: "-". No timestamp is included to ensure determinism. + * Encodes the packet's sequence number and payload length into a compact + * fixed-size identifier. No timestamp is included to ensure determinism. */ - std::string packet_id() const noexcept { - return std::to_string(tcp.seq) + "-" + std::to_string(payload_bytes); + penny::PacketDropId packet_id() const noexcept { + return penny::make_packet_drop_id( + tcp.seq, + static_cast(payload_bytes) + ); } }; diff --git a/include/openpenny/penny/flow/engine/FlowEngine.h b/include/openpenny/penny/flow/engine/FlowEngine.h index 71f106d..5d59cff 100644 --- a/include/openpenny/penny/flow/engine/FlowEngine.h +++ b/include/openpenny/penny/flow/engine/FlowEngine.h @@ -6,6 +6,7 @@ #include "openpenny/config/Config.h" #include "openpenny/penny/flow/timer/ThreadFlowEventTimer.h" #include "openpenny/penny/flow/state/PennyStats.h" +#include "openpenny/penny/flow/state/PacketDropId.h" #include "openpenny/penny/flow/state/PennySnapshot.h" #include @@ -41,7 +42,7 @@ namespace openpenny::penny { class FlowEngine { public: using DropSnapshotSink = std::function; /// High-level decision / outcome for this flow. @@ -178,7 +179,7 @@ class FlowEngine { } /// All recorded packet-drop snapshots, in observation order. - const std::vector>& + const std::vector>& drop_snapshots() const noexcept { return flow_drop_snapshots_; } @@ -231,7 +232,7 @@ class FlowEngine { * The drop is associated with @p packet_id so that future retransmissions * filling this interval can be matched back to the original decision. */ - void register_gap(uint32_t start, uint32_t end, const std::string& packet_id); + void register_gap(uint32_t start, uint32_t end, PacketDropId packet_id); /** * @brief True if [start, end) lies strictly within gap space (no new coverage). @@ -252,9 +253,9 @@ class FlowEngine { * @param partially_filled Optional out-parameter set to true if at least one * gap is only partially repaired. */ - std::vector fill_gaps(uint32_t start, - uint32_t end, - bool* partially_filled = nullptr); + std::vector fill_gaps(uint32_t start, + uint32_t end, + bool* partially_filled = nullptr); /** * @brief Mark the given gap packet_ids as fully repaired. @@ -262,7 +263,7 @@ class FlowEngine { * Typically called after fill_gaps() when a gap is confirmed to be * completely covered by retransmissions. */ - void register_filled_gaps(const std::vector& packet_ids); + void register_filled_gaps(const std::vector& packet_ids); /** * @brief Track that we observed a duplicate packet at @p seq for snapshot @@ -318,18 +319,18 @@ class FlowEngine { */ bool drop_packet(uint32_t start, uint32_t end, - const std::string& packet_id, + PacketDropId packet_id, const FlowKey& key, const std::chrono::steady_clock::time_point& now); /// Mark the snapshot associated with @p packet_id as retransmitted. - void mark_snapshot_retransmitted(const std::string& packet_id); + void mark_snapshot_retransmitted(PacketDropId packet_id); /// Mark the snapshot associated with @p packet_id as expired (no repair observed in time). - void mark_snapshot_expired(const std::string& packet_id); + void mark_snapshot_expired(PacketDropId packet_id); /// Mark the snapshot associated with @p packet_id as invalid (e.g., misclassified or cancelled). - void mark_snapshot_invalid(const std::string& packet_id); + void mark_snapshot_invalid(PacketDropId packet_id); /// Mark all pending snapshots as expired (used on shutdown/cleanup). void expire_all_pending_snapshots(); @@ -354,7 +355,7 @@ class FlowEngine { */ struct GapRecord { icl::interval::type range; ///< Dropped byte range. - std::string packet_id; ///< Snapshot identifier. + PacketDropId packet_id{0}; ///< Snapshot identifier. bool completed{false}; ///< True if the gap is fully repaired. }; @@ -380,7 +381,7 @@ class FlowEngine { std::optional flow_first_data_time_{}; /// Mapping from snapshot packet_id to its index in flow_drop_snapshots_. - std::unordered_map flow_snapshot_index_by_id_; + std::unordered_map flow_snapshot_index_by_id_; DropSnapshotSink drop_sink_{}; /** @@ -396,10 +397,10 @@ class FlowEngine { // --------------------------------------------------------------------- /// Tracks whether a given packet_id was actually dropped. - std::unordered_map flow_dropped_packets_; + std::unordered_map flow_dropped_packets_; /// All packet-drop snapshots along with their logical packet_id. - std::vector> flow_drop_snapshots_; + std::vector> flow_drop_snapshots_; /// Bytes we have observed as covered in the sequence space. icl::interval_set flow_covered_; diff --git a/include/openpenny/penny/flow/state/PacketDropId.h b/include/openpenny/penny/flow/state/PacketDropId.h new file mode 100644 index 0000000..36d3c67 --- /dev/null +++ b/include/openpenny/penny/flow/state/PacketDropId.h @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: BSD-2-Clause + +#pragma once + +#include +#include + +namespace openpenny::penny { + +// Packet drop IDs must remain exact because they are used as hash keys and +// compared across threads/timer callbacks. Packing seq and payload length into +// one integer keeps the hot path allocation-free without introducing the +// rounding/equality hazards of a floating-point encoding. +using PacketDropId = std::uint64_t; + +constexpr PacketDropId make_packet_drop_id(std::uint32_t seq, + std::uint32_t payload_bytes) noexcept { + return (static_cast(seq) << 32) | + static_cast(payload_bytes); +} + +constexpr std::uint32_t packet_drop_id_seq(PacketDropId id) noexcept { + return static_cast(id >> 32); +} + +constexpr std::uint32_t packet_drop_id_payload_bytes(PacketDropId id) noexcept { + return static_cast(id & 0xffffffffULL); +} + +inline std::string format_packet_drop_id(PacketDropId id) { + return std::to_string(packet_drop_id_seq(id)) + "-" + + std::to_string(packet_drop_id_payload_bytes(id)); +} + +} // namespace openpenny::penny diff --git a/include/openpenny/penny/flow/timer/ThreadFlowEventTimer.h b/include/openpenny/penny/flow/timer/ThreadFlowEventTimer.h index aa44bb3..516cbb4 100644 --- a/include/openpenny/penny/flow/timer/ThreadFlowEventTimer.h +++ b/include/openpenny/penny/flow/timer/ThreadFlowEventTimer.h @@ -3,6 +3,7 @@ #pragma once #include "openpenny/agg/Stats.h" // for FlowKey +#include "openpenny/penny/flow/state/PacketDropId.h" #include #include @@ -84,7 +85,7 @@ class ThreadFlowEventTimerManager { * @param snapshot_index Index of the snapshot inside FlowEngine::flow_drop_snapshots_. */ void register_drop(const ::openpenny::FlowKey& key, - const std::string& packet_id, + PacketDropId packet_id, std::chrono::steady_clock::time_point ts, std::shared_ptr flow_alive, FlowEngine* flow, @@ -96,7 +97,7 @@ class ThreadFlowEventTimerManager { * The timer thread will later convert this into a callback that updates * the relevant snapshot in the owning FlowEngine. */ - void enqueue_retransmitted(const std::string& packet_id, FlowEngine* flow); + void enqueue_retransmitted(PacketDropId packet_id, FlowEngine* flow); /** * @brief Queue an asynchronous "duplicate" event from the packet path. @@ -132,7 +133,7 @@ class ThreadFlowEventTimerManager { * are drained. */ static void set_snapshot_hook(std::function hook); private: @@ -147,7 +148,7 @@ class ThreadFlowEventTimerManager { std::uint64_t token{0}; ///< Unique token for cancellation / tracking. std::chrono::steady_clock::time_point deadline{}; ///< Expiry time. ::openpenny::FlowKey key{}; ///< Flow key for logging / debugging. - std::string packet_id{}; ///< Snapshot identifier within the flow. + PacketDropId packet_id{0}; ///< Snapshot identifier within the flow. std::weak_ptr flow_alive; ///< Liveness flag to avoid calling dead flows. FlowEngine* flow{nullptr}; ///< Non-owning pointer to the FlowEngine. std::size_t snapshot_index{0}; ///< Index into the flow's snapshot vector. @@ -170,7 +171,7 @@ class ThreadFlowEventTimerManager { */ struct PacketKey { FlowEngine* flow{nullptr}; - std::string packet_id{}; + PacketDropId packet_id{0}; bool operator==(const PacketKey& other) const noexcept { return flow == other.flow && packet_id == other.packet_id; @@ -180,7 +181,7 @@ class ThreadFlowEventTimerManager { struct PacketKeyHash { std::size_t operator()(const PacketKey& k) const noexcept { std::size_t h1 = std::hash{}(k.flow); - std::size_t h2 = std::hash{}(k.packet_id); + std::size_t h2 = std::hash{}(k.packet_id); return h1 ^ (h2 + 0x9e3779b97f4a7c15ULL + (h1 << 6) + (h1 >> 2)); } }; @@ -198,7 +199,7 @@ class ThreadFlowEventTimerManager { }; Kind kind{Kind::Retransmit}; - std::string packet_id{}; ///< For retransmit events. + PacketDropId packet_id{0}; ///< For retransmit events. FlowEngine* flow{nullptr}; ///< Target flow; not owned. std::uint32_t seq{0}; ///< For duplicate events. std::uint32_t payload{0}; ///< Payload size for duplicate events. @@ -217,7 +218,7 @@ class ThreadFlowEventTimerManager { }; Kind kind{Kind::Expire}; - std::string packet_id{}; ///< For Expire / Retransmit callbacks. + PacketDropId packet_id{0}; ///< For Expire / Retransmit callbacks. FlowEngine* flow{nullptr}; ///< Target flow; not owned. std::uint32_t seq{0}; ///< For Duplicate callbacks. }; @@ -297,7 +298,7 @@ class ThreadFlowEventTimerManager { */ std::atomic pending_callbacks_{0}; - static std::function snapshot_hook_; + static std::function snapshot_hook_; }; } // namespace openpenny::penny diff --git a/src/app/cli/cli_helpers.cpp b/src/app/cli/cli_helpers.cpp index 28b6d00..d066f00 100644 --- a/src/app/cli/cli_helpers.cpp +++ b/src/app/cli/cli_helpers.cpp @@ -75,6 +75,7 @@ CliOptions parse_args(int argc, char** argv) { std::cerr << "Invalid --source value: " << opts.source << " (use xdp or dpdk)\n"; std::exit(1); } + opts.source_set = true; } else if ((arg == "--prefix" || arg == "-p") && i + 1 < argc) { std::string spec = argv[++i]; if (!parse_cidr(spec, opts.prefix_ip, opts.prefix_cidr, opts.prefix_host, opts.mask_host, opts.mask_bits)) { @@ -119,6 +120,7 @@ CliOptions parse_args(int argc, char** argv) { std::exit(1); } opts.queue_count = static_cast(n); + opts.queue_count_set = true; } else if (arg == "--iface" && i + 1 < argc) { opts.iface = argv[++i]; } else if (arg == "--xdp-mode" && i + 1 < argc) { @@ -135,6 +137,7 @@ CliOptions parse_args(int argc, char** argv) { std::cerr << "Invalid --mode value: " << m << " (use active|passive)\n"; std::exit(1); } + opts.mode_set = true; } else if (arg == "--stats-sock" && i + 1 < argc) { opts.stats_socket_path = argv[++i]; } else if ((arg == "--tun" || arg == "--tun-name") && i + 1 < argc) { @@ -143,16 +146,18 @@ CliOptions parse_args(int argc, char** argv) { } else if (arg == "--help" || arg == "-h") { std::cout << "Usage: openpenny_cli [options]\n" << " -c, --config Configuration file (default examples/configs/config_default.yaml)\n" - << " --source Packet source backend (default xdp)\n" - << " --mode Pipeline mode (default active)\n" + << " --source Packet source backend; falls back to the YAML platform if omitted\n" + << " --mode Pipeline mode; falls back to runtime_policy.mode in the YAML if omitted\n" << " --stats-sock Unix datagram socket path for live stats (optional)\n" << " -p, --prefix Legacy runtime prefix metadata (traffic_match controls capture)\n" - << " --iface Ensure XDP program is attached to interface\n" + << " --iface Override the YAML interface (otherwise platform.interface wins)\n" << " --xdp-mode Attachment mode (default auto)\n" << " -q, --queue AF_XDP queue id, or auto-probe the active RX queue\n" << " --queue-probe-ms Probe time per queue when using --queue auto\n" << " --tun Forward matching packets to the named TUN device\n" - << " -Q, --queues Number of AF_XDP or DPDK queues/threads to poll\n" + << " -Q, --queues Override platform.queue_count from the YAML (otherwise YAML wins)\n" + << "\nWhen a flag is omitted, the corresponding value from --config wins; the\n" + << "CLI is for ad-hoc overrides on top of the YAML, not for replacing it.\n" << "\nPolling continues until Penny heuristics finish or you press Ctrl+C.\n"; std::exit(0); } diff --git a/src/app/cli/penny_cli.cpp b/src/app/cli/penny_cli.cpp index b955da4..5aa4a66 100644 --- a/src/app/cli/penny_cli.cpp +++ b/src/app/cli/penny_cli.cpp @@ -498,11 +498,9 @@ int main(int argc, char** argv) { } // Child process from here onward. The egress sink (if any) is built - // from the declarative cfg->egress once the config is loaded; its fd - // lifecycle is owned by the PacketSink, so the old local `tun_fd` - // bookkeeping is gone. + // from the declarative cfg->egress once the config is loaded; - // Load configuration file. + // Load configuration file. auto cfg = openpenny::Config::from_file(cli_opts.config_path); if (!cfg) { std::cerr @@ -511,7 +509,27 @@ int main(int argc, char** argv) { return 1; } - // Determine source backend. + // Determine source backend. The CLI default is "xdp", but when the + // operator did not type --source we'd rather honour whatever the YAML + // already set in cfg->input.backend. Re-derive `cli_opts.source` from + // the loaded config so every later branch (queue auto-probe, XDP + // attach, etc.) sees the same answer the pipeline driver will use. + if (!cli_opts.source_set) { + switch (cfg->input.backend) { + case openpenny::PacketInputBackend::XdpAfXdp: + cli_opts.source = "xdp"; + break; + case openpenny::PacketInputBackend::Dpdk: + cli_opts.source = "dpdk"; + break; + case openpenny::PacketInputBackend::AfPacketMirror: + // AF_PACKET mirror is configured via the YAML platform + // file directly; the legacy --source flag is XDP/DPDK + // only, so map it to "xdp" for the helper checks below. + cli_opts.source = "xdp"; + break; + } + } const bool use_xdp = openpenny::cli::to_lower(cli_opts.source) == "xdp"; const bool use_dpdk = openpenny::cli::to_lower(cli_opts.source) == "dpdk"; @@ -540,8 +558,19 @@ int main(int argc, char** argv) { cfg->queue = cli_opts.queue_value; } - // Number of queues to process in this run. - cfg->queue_count = std::max(1u, cli_opts.queue_count); + // Number of queues to process in this run. Only let the CLI value + // win when the operator actually typed `--queues N`; otherwise keep + // whatever the YAML already loaded into cfg->queue_count so a + // config that says `queue_count: 4` does not get silently demoted + // to the CLI default of 1. Either way, mirror the resolved value + // back onto cli_opts so later code (init_thread_counters, the run + // banner) reads the same number the driver will use. + if (cli_opts.queue_count_set) { + cfg->queue_count = std::max(1u, cli_opts.queue_count); + } else { + cfg->queue_count = std::max(1u, cfg->queue_count); + } + cli_opts.queue_count = cfg->queue_count; // For AF_XDP, sanity-check the requested queue range against the NIC's // actual RX queue count. Two failure modes are surfaced explicitly here @@ -639,15 +668,19 @@ int main(int argc, char** argv) { cfg->xdp_runtime.pin_settings_path = cli_opts.pin_settings_path; } - // Prepare per-thread counters. - openpenny::app::init_thread_counters(std::max(1u, cli_opts.queue_count)); + // TODO: update code here + + // Prepare per-thread counters. cfg->queue_count is the resolved + // value (CLI > YAML > 1) computed above, so use it directly rather + // than re-reading the now-mirrored cli_opts.queue_count. + openpenny::app::init_thread_counters(std::max(1u, cfg->queue_count)); openpenny::app::set_thread_counter_index(0); // Small helper thread for periodically checking aggregate counters. // Currently this looks like a hook point for future reporting/export. std::atomic agg_stop{false}; std::atomic agg_drop_threshold{12}; - + std::cout << "agg_drop_threshold " << agg_drop_threshold << std::endl; std::thread agg_thread([&agg_stop, &agg_drop_threshold] { uint64_t last_agg_drops = 0; @@ -667,6 +700,8 @@ int main(int argc, char** argv) { } }); + + // Apply source-specific setup to the config. if (use_xdp) { openpenny::cli::configure_xdp_source(*cfg, cli_opts); @@ -752,7 +787,22 @@ int main(int argc, char** argv) { return g_stop_requested != 0; }; - pipeline_opts.mode = cli_opts.mode; + // Pipeline mode resolution. The CLI default is Active, but the YAML + // can carry `runtime_policy.mode: passive` (mirrored onto cfg->mode + // by the loader). When the operator did not pass --mode, take the + // mode from the loaded config so a passive YAML actually starts in + // passive. --mode on the CLI still overrides everything for ad-hoc + // overrides. + if (cli_opts.mode_set) { + pipeline_opts.mode = cli_opts.mode; + } else if (openpenny::cli::to_lower(cfg->mode) == "passive") { + pipeline_opts.mode = openpenny::PipelineOptions::Mode::Passive; + } else { + pipeline_opts.mode = cli_opts.mode; // baseline default: Active. + } + // Mirror the resolved mode back onto cli_opts so later helpers (run + // banner, summary printout) see the same value. + cli_opts.mode = pipeline_opts.mode; pipeline_opts.stats_socket_path = cli_opts.stats_socket_path; pipeline_opts.queue_count = std::max(1u, cli_opts.queue_count); diff --git a/src/app/core/AggregatesController.cpp b/src/app/core/AggregatesController.cpp index 5f06a5c..c5cf349 100644 --- a/src/app/core/AggregatesController.cpp +++ b/src/app/core/AggregatesController.cpp @@ -9,6 +9,139 @@ #include namespace openpenny { +namespace { + +DropCollector::TimestampRep snapshot_timestamp( + const penny::PacketDropSnapshot& snap) noexcept { + return snap.timestamp.time_since_epoch().count(); +} + +void decorate_snapshot_record(DropSnapshotRecord& record, + const openpenny::app::AggregatedCounters& agg) { + record.counters = agg; + record.snapshot.stats.overwrite_from_aggregates(agg); +} + +void set_runtime_eval_counters(RuntimeStatus& runtime, + const penny::PennyStats& stats) { + runtime.aggregate_eval_counters.data_packets = stats.droppable_packets(); + runtime.aggregate_eval_counters.duplicate_packets = stats.duplicate_packets(); + runtime.aggregate_eval_counters.retransmitted_packets = stats.retransmitted_packets(); + runtime.aggregate_eval_counters.non_retransmitted_packets = stats.non_retransmitted_packets(); +} + +void set_runtime_eval_counters(RuntimeStatus& runtime, + const openpenny::app::AggregatedCounters& agg) { + runtime.aggregate_eval_counters.data_packets = agg.droppable_packets; + runtime.aggregate_eval_counters.duplicate_packets = agg.duplicate_packets; + runtime.aggregate_eval_counters.retransmitted_packets = agg.retransmitted_packets; + runtime.aggregate_eval_counters.non_retransmitted_packets = agg.non_retransmitted_packets; +} + +void store_aggregate_snapshot_once( + std::optional& snapshot_slot, + std::mutex& snapshot_mtx, + const openpenny::app::AggregatedCounters& agg) { + std::lock_guard lk(snapshot_mtx); + if (!snapshot_slot) snapshot_slot = agg; +} + +std::vector collect_all_drop_snapshots( + const DropCollector& collector, + const openpenny::app::AggregatedCounters& agg) { + std::vector out; + std::size_t total = 0; + for (std::size_t shard_index = 0; shard_index < collector.shard_count; ++shard_index) { + const auto& shard = collector.shard_for(shard_index); + total += shard.snapshot_count.load(std::memory_order_relaxed); + } + out.reserve(total); + for (std::size_t shard_index = 0; shard_index < collector.shard_count; ++shard_index) { + const auto& shard = collector.shard_for(shard_index); + std::lock_guard lock(shard.mtx); + out.insert(out.end(), shard.snapshots.begin(), shard.snapshots.end()); + } + for (auto& record : out) { + decorate_snapshot_record(record, agg); + } + return out; +} + +std::optional collect_latest_drop_snapshot( + const DropCollector& collector, + const openpenny::app::AggregatedCounters& agg) { + std::size_t best_shard_index = 0; + auto best_timestamp = DropCollector::kNoSnapshotTimestamp; + for (std::size_t shard_index = 0; shard_index < collector.shard_count; ++shard_index) { + const auto& shard = collector.shard_for(shard_index); + if (shard.snapshot_count.load(std::memory_order_relaxed) == 0) { + continue; + } + const auto latest_timestamp = + shard.latest_snapshot_timestamp.load(std::memory_order_relaxed); + if (latest_timestamp >= best_timestamp) { + best_timestamp = latest_timestamp; + best_shard_index = shard_index; + } + } + if (best_timestamp == DropCollector::kNoSnapshotTimestamp) { + return std::nullopt; + } + + const auto& best_shard = collector.shard_for(best_shard_index); + { + std::lock_guard lock(best_shard.mtx); + const auto latest_index = + best_shard.latest_snapshot_index.load(std::memory_order_relaxed); + if (latest_index < best_shard.snapshots.size()) { + auto record = best_shard.snapshots[latest_index]; + if (snapshot_timestamp(record.snapshot) == best_timestamp) { + decorate_snapshot_record(record, agg); + return record; + } + } + } + + std::optional latest; + for (std::size_t shard_index = 0; shard_index < collector.shard_count; ++shard_index) { + const auto& shard = collector.shard_for(shard_index); + std::lock_guard lock(shard.mtx); + auto it = std::max_element( + shard.snapshots.begin(), + shard.snapshots.end(), + [](const DropSnapshotRecord& a, const DropSnapshotRecord& b) { + return a.snapshot.timestamp < b.snapshot.timestamp; + }); + if (it == shard.snapshots.end()) { + continue; + } + if (!latest || latest->snapshot.timestamp < it->snapshot.timestamp) { + latest = *it; + } + } + if (latest) { + decorate_snapshot_record(*latest, agg); + } + return latest; +} + +struct CollectorSnapshotSummary { + std::size_t snapshot_count{0}; + std::size_t pending_snapshot_count{0}; +}; + +CollectorSnapshotSummary summarize_collector_snapshots(const DropCollector& collector) { + CollectorSnapshotSummary summary; + for (std::size_t shard_index = 0; shard_index < collector.shard_count; ++shard_index) { + const auto& shard = collector.shard_for(shard_index); + summary.snapshot_count += shard.snapshot_count.load(std::memory_order_relaxed); + summary.pending_snapshot_count += + shard.pending_snapshot_count.load(std::memory_order_relaxed); + } + return summary; +} + +} // namespace AggregatesController::AggregatesController(const Config& cfg, const PipelineOptions& opts, @@ -81,8 +214,8 @@ std::optional AggregatesController::aggregat void AggregatesController::populate_drop_snapshots(PipelineSummary& summary) const { if (!collector_) return; - std::lock_guard lock(collector_->mtx); - auto snaps = collector_->snapshots; + const auto agg = openpenny::app::aggregate_counters(); + auto snaps = collect_all_drop_snapshots(*collector_, agg); std::sort( snaps.begin(), snaps.end(), @@ -119,10 +252,7 @@ void AggregatesController::evaluate_pending_if_needed(const Config& cfg, runtime.aggregates_status = RuntimeStatus::AggregatesStatus::DUPLICATES_EXCEEDED; } runtime.has_aggregate_eval = true; - runtime.aggregate_eval_counters.data_packets = stats.droppable_packets(); - runtime.aggregate_eval_counters.duplicate_packets = stats.duplicate_packets(); - runtime.aggregate_eval_counters.retransmitted_packets = stats.retransmitted_packets(); - runtime.aggregate_eval_counters.non_retransmitted_packets = stats.non_retransmitted_packets(); + set_runtime_eval_counters(runtime, stats); collector_completed_.store(true, std::memory_order_relaxed); } @@ -170,15 +300,9 @@ void AggregatesController::collector_loop() { static_cast(agg.flows_finished)); runtime.aggregates_status = RuntimeStatus::AggregatesStatus::CLOSED_LOOP; runtime.has_aggregate_eval = true; - runtime.aggregate_eval_counters.data_packets = agg.droppable_packets; - runtime.aggregate_eval_counters.duplicate_packets = agg.duplicate_packets; - runtime.aggregate_eval_counters.retransmitted_packets = agg.retransmitted_packets; - runtime.aggregate_eval_counters.non_retransmitted_packets = agg.non_retransmitted_packets; + set_runtime_eval_counters(runtime, agg); collector_completed_.store(true, std::memory_order_relaxed); - { - std::lock_guard lk(aggregates_snapshot_mtx_); - if (!aggregates_snapshot_) aggregates_snapshot_ = openpenny::app::aggregate_counters(); - } + store_aggregate_snapshot_once(aggregates_snapshot_, aggregates_snapshot_mtx_, agg); stop_flag_.store(true, std::memory_order_relaxed); break; } @@ -190,14 +314,10 @@ void AggregatesController::collector_loop() { std::size_t pending_snapshot_count = 0; std::uint64_t pending_rtx_count = 0; { - std::lock_guard lock(collector_->mtx); - snapshot_count = collector_->snapshots.size(); - for (const auto& rec : collector_->snapshots) { - if (rec.snapshot.state == penny::SnapshotState::Pending) { - pending = true; - ++pending_snapshot_count; - } - } + const auto collector_summary = summarize_collector_snapshots(*collector_); + snapshot_count = collector_summary.snapshot_count; + pending_snapshot_count = collector_summary.pending_snapshot_count; + pending = pending_snapshot_count > 0; pending_rtx_count = openpenny::app::aggregate_counters().pending_retransmissions; pending_rtx = pending_rtx_count > 0; ready = snapshot_count >= required_drops_ && !pending && !pending_rtx; @@ -235,8 +355,8 @@ void AggregatesController::collector_loop() { ready_logged = true; } collector_->accepting.store(false, std::memory_order_relaxed); + const auto agg_now = openpenny::app::aggregate_counters(); if (cfg_.active.max_duplicate_fraction > 0.0) { - auto agg_now = openpenny::app::aggregate_counters(); if (agg_now.data_packets > 0) { const double agg_dup_ratio = static_cast(agg_now.duplicate_packets) / static_cast(agg_now.data_packets); @@ -244,15 +364,12 @@ void AggregatesController::collector_loop() { runtime.aggregates_status = RuntimeStatus::AggregatesStatus::DUPLICATES_EXCEEDED; runtime.aggregates_active = false; runtime.has_aggregate_eval = true; - runtime.aggregate_eval_counters.data_packets = agg_now.droppable_packets; - runtime.aggregate_eval_counters.duplicate_packets = agg_now.duplicate_packets; - runtime.aggregate_eval_counters.retransmitted_packets = agg_now.retransmitted_packets; - runtime.aggregate_eval_counters.non_retransmitted_packets = agg_now.non_retransmitted_packets; + set_runtime_eval_counters(runtime, agg_now); collector_completed_.store(true, std::memory_order_relaxed); - { - std::lock_guard lk(aggregates_snapshot_mtx_); - if (!aggregates_snapshot_) aggregates_snapshot_ = agg_now; - } + store_aggregate_snapshot_once( + aggregates_snapshot_, + aggregates_snapshot_mtx_, + agg_now); stop_flag_.store(true, std::memory_order_relaxed); break; } @@ -260,26 +377,13 @@ void AggregatesController::collector_loop() { } if (!aggregate_eval_done) { aggregate_eval_done = true; - std::optional latest_snapshot; - { - std::lock_guard lock(collector_->mtx); - auto it = std::max_element( - collector_->snapshots.begin(), - collector_->snapshots.end(), - [](const DropSnapshotRecord& a, const DropSnapshotRecord& b) { - return a.snapshot.timestamp < b.snapshot.timestamp; - }); - if (it != collector_->snapshots.end()) { - latest_snapshot = *it; - } - } + auto latest_snapshot = collect_latest_drop_snapshot(*collector_, agg_now); if (latest_snapshot) { - if (openpenny::app::aggregate_counters().pending_retransmissions > 0) { + if (agg_now.pending_retransmissions > 0) { continue; } auto stats = latest_snapshot->snapshot.stats; - stats.overwrite_from_aggregates(openpenny::app::aggregate_counters()); const auto miss_prob = std::clamp( cfg_.active.retransmission_miss_probability, 0.0, @@ -294,6 +398,7 @@ void AggregatesController::collector_loop() { stats, miss_prob, cfg_.active.max_duplicate_fraction); + const auto packet_id_text = penny::format_packet_drop_id(latest_snapshot->packet_id); const auto denom = eval.p_closed + eval.p_not_closed; TCPLOG_INFO( @@ -311,36 +416,30 @@ void AggregatesController::collector_loop() { denom, eval.closed_weight, penny::flow_decision_to_string(eval.decision), - latest_snapshot->packet_id.c_str(), + packet_id_text.c_str(), latest_snapshot->thread_name.c_str()); if (dup_threshold_hit) { runtime.aggregates_status = RuntimeStatus::AggregatesStatus::DUPLICATES_EXCEEDED; runtime.aggregates_active = false; runtime.has_aggregate_eval = true; - runtime.aggregate_eval_counters.data_packets = stats.droppable_packets(); - runtime.aggregate_eval_counters.duplicate_packets = stats.duplicate_packets(); - runtime.aggregate_eval_counters.retransmitted_packets = stats.retransmitted_packets(); - runtime.aggregate_eval_counters.non_retransmitted_packets = stats.non_retransmitted_packets(); + set_runtime_eval_counters(runtime, stats); collector_completed_.store(true, std::memory_order_relaxed); - { - std::lock_guard lk(aggregates_snapshot_mtx_); - if (!aggregates_snapshot_) aggregates_snapshot_ = openpenny::app::aggregate_counters(); - } + store_aggregate_snapshot_once( + aggregates_snapshot_, + aggregates_snapshot_mtx_, + agg_now); break; } if (eval.decision == penny::FlowEngine::FlowDecision::FINISHED_CLOSED_LOOP) { runtime.aggregates_status = RuntimeStatus::AggregatesStatus::CLOSED_LOOP; - { - std::lock_guard lk(aggregates_snapshot_mtx_); - if (!aggregates_snapshot_) aggregates_snapshot_ = openpenny::app::aggregate_counters(); - } + store_aggregate_snapshot_once( + aggregates_snapshot_, + aggregates_snapshot_mtx_, + agg_now); runtime.has_aggregate_eval = true; - runtime.aggregate_eval_counters.data_packets = stats.droppable_packets(); - runtime.aggregate_eval_counters.duplicate_packets = stats.duplicate_packets(); - runtime.aggregate_eval_counters.retransmitted_packets = stats.retransmitted_packets(); - runtime.aggregate_eval_counters.non_retransmitted_packets = stats.non_retransmitted_packets(); + set_runtime_eval_counters(runtime, stats); collector_completed_.store(true, std::memory_order_relaxed); stop_flag_.store(true, std::memory_order_relaxed); break; @@ -348,10 +447,7 @@ void AggregatesController::collector_loop() { runtime.aggregates_status = RuntimeStatus::AggregatesStatus::NON_CLOSED_LOOP; } - runtime.aggregate_eval_counters.data_packets = stats.droppable_packets(); - runtime.aggregate_eval_counters.duplicate_packets = stats.duplicate_packets(); - runtime.aggregate_eval_counters.retransmitted_packets = stats.retransmitted_packets(); - runtime.aggregate_eval_counters.non_retransmitted_packets = stats.non_retransmitted_packets(); + set_runtime_eval_counters(runtime, stats); runtime.has_aggregate_eval = true; if (cfg_.active.aggregates_enabled && @@ -371,10 +467,10 @@ void AggregatesController::collector_loop() { static_cast(closed_loop_required), closed_loop_required == 1 ? "" : "s"); } else { - { - std::lock_guard lk(aggregates_snapshot_mtx_); - if (!aggregates_snapshot_) aggregates_snapshot_ = openpenny::app::aggregate_counters(); - } + store_aggregate_snapshot_once( + aggregates_snapshot_, + aggregates_snapshot_mtx_, + agg_now); collector_completed_.store(true, std::memory_order_relaxed); stop_flag_.store(true, std::memory_order_relaxed); break; @@ -405,10 +501,7 @@ void AggregatesController::individual_limit_loop() { static_cast(agg.flows_not_closed_loop), static_cast(agg.flows_rst), static_cast(agg.flows_duplicates_exceeded)); - { - std::lock_guard lk(aggregates_snapshot_mtx_); - if (!aggregates_snapshot_) aggregates_snapshot_ = agg; - } + store_aggregate_snapshot_once(aggregates_snapshot_, aggregates_snapshot_mtx_, agg); stop_flag_.store(true, std::memory_order_relaxed); individual_stop_hit_.store(true, std::memory_order_relaxed); break; @@ -440,10 +533,7 @@ void AggregatesController::min_closed_loop_loop() { static_cast(agg.flows_not_closed_loop), static_cast(agg.flows_rst), static_cast(agg.flows_duplicates_exceeded)); - { - std::lock_guard lk(aggregates_snapshot_mtx_); - if (!aggregates_snapshot_) aggregates_snapshot_ = agg; - } + store_aggregate_snapshot_once(aggregates_snapshot_, aggregates_snapshot_mtx_, agg); // If the aggregate eval has not produced a verdict yet, mark // it CLOSED_LOOP since we have collected enough closed-loop // evidence on its own. @@ -451,10 +541,7 @@ void AggregatesController::min_closed_loop_loop() { if (runtime.aggregates_status == RuntimeStatus::AggregatesStatus::PENDING) { runtime.aggregates_status = RuntimeStatus::AggregatesStatus::CLOSED_LOOP; runtime.has_aggregate_eval = true; - runtime.aggregate_eval_counters.data_packets = agg.droppable_packets; - runtime.aggregate_eval_counters.duplicate_packets = agg.duplicate_packets; - runtime.aggregate_eval_counters.retransmitted_packets = agg.retransmitted_packets; - runtime.aggregate_eval_counters.non_retransmitted_packets = agg.non_retransmitted_packets; + set_runtime_eval_counters(runtime, agg); } collector_completed_.store(true, std::memory_order_relaxed); closed_loop_stop_hit_.store(true, std::memory_order_relaxed); diff --git a/src/app/core/DropCollectorBinding.cpp b/src/app/core/DropCollectorBinding.cpp index 2c0fca8..1494cda 100644 --- a/src/app/core/DropCollectorBinding.cpp +++ b/src/app/core/DropCollectorBinding.cpp @@ -9,6 +9,18 @@ #include namespace openpenny::app { +namespace { + +DropCollector::TimestampRep snapshot_timestamp( + const penny::PacketDropSnapshot& snap) noexcept { + return snap.timestamp.time_since_epoch().count(); +} + +bool is_pending_snapshot(const penny::PacketDropSnapshot& snap) noexcept { + return snap.state == penny::SnapshotState::Pending; +} + +} // namespace DropCollectorBinding& DropCollectorBinding::instance() { static DropCollectorBinding inst; @@ -19,25 +31,24 @@ void DropCollectorBinding::ensure_snapshot_hook() { std::call_once(hook_once_, []() { penny::ThreadFlowEventTimerManager::set_snapshot_hook( [](penny::FlowEngine* flow, - const std::string& packet_id, + penny::PacketDropId packet_id, penny::ThreadFlowEventTimerManager::SnapshotEventKind /*kind*/) { auto& self = DropCollectorBinding::instance(); const auto binding = self.lookup(flow); if (!binding.collector) return; + if (!binding.collector->accepting.load(std::memory_order_relaxed)) return; - const auto agg = openpenny::app::aggregate_counters(); const auto& snaps = flow->drop_snapshots(); const auto key = flow->flow_key(); + auto& shard = binding.collector->shard_for(binding.shard_index); - std::lock_guard lock(binding.collector->mtx); + std::lock_guard lock(shard.mtx); if (!binding.collector->accepting.load(std::memory_order_relaxed)) return; // Mirror any updated packet drop snapshots from the FlowEngine into // the shared collector so aggregate decisions see fresh stats. for (const auto& pair : snaps) { - if (!packet_id.empty() && pair.first != packet_id) continue; - auto snap = pair.second; - snap.stats.overwrite_from_aggregates(agg); - self.upsert_locked(binding, key, pair.first, snap, agg); + if (packet_id != 0 && pair.first != packet_id) continue; + self.upsert_locked(binding, key, pair.first, pair.second); } }); }); @@ -45,10 +56,15 @@ void DropCollectorBinding::ensure_snapshot_hook() { void DropCollectorBinding::bind(penny::FlowEngine* flow, DropCollectorPtr collector, - const std::string& thread_name) { + const std::string& thread_name, + std::size_t shard_index) { if (!flow || !collector) return; std::lock_guard lock(mtx_); - bindings_[flow] = BindingContext{std::move(collector), thread_name}; + bindings_[flow] = BindingContext{ + std::move(collector), + thread_name, + shard_index + }; } void DropCollectorBinding::unbind(penny::FlowEngine* flow) { @@ -59,14 +75,16 @@ void DropCollectorBinding::unbind(penny::FlowEngine* flow) { void DropCollectorBinding::upsert(DropCollectorPtr collector, const std::string& thread_name, + std::size_t shard_index, const FlowKey& key, - const std::string& packet_id, - const penny::PacketDropSnapshot& snap, - const openpenny::app::AggregatedCounters& agg) { + penny::PacketDropId packet_id, + const penny::PacketDropSnapshot& snap) { if (!collector) return; - std::lock_guard lock(collector->mtx); if (!collector->accepting.load(std::memory_order_relaxed)) return; - upsert_locked(BindingContext{collector, thread_name}, key, packet_id, snap, agg); + auto& shard = collector->shard_for(shard_index); + std::lock_guard lock(shard.mtx); + if (!collector->accepting.load(std::memory_order_relaxed)) return; + upsert_locked(BindingContext{collector, thread_name, shard_index}, key, packet_id, snap); } DropCollectorBinding::BindingContext DropCollectorBinding::lookup(penny::FlowEngine* flow) const { @@ -80,25 +98,45 @@ DropCollectorBinding::BindingContext DropCollectorBinding::lookup(penny::FlowEng void DropCollectorBinding::upsert_locked(const BindingContext& binding, const FlowKey& key, - const std::string& packet_id, - const penny::PacketDropSnapshot& snap, - const openpenny::app::AggregatedCounters& agg) { + penny::PacketDropId packet_id, + const penny::PacketDropSnapshot& snap) { if (!binding.collector) return; - auto& snapshots = binding.collector->snapshots; - auto it = std::find_if( - snapshots.begin(), - snapshots.end(), - [&](const DropSnapshotRecord& rec) { - return rec.packet_id == packet_id && - rec.thread_name == binding.thread_name && - rec.key == key; - }); - - if (it != snapshots.end()) { - it->snapshot = snap; - it->counters = agg; + auto& shard = binding.collector->shard_for(binding.shard_index); + auto& snapshots = shard.snapshots; + DropCollector::SnapshotKey snapshot_key{key, packet_id}; + + auto index_it = shard.snapshot_index.find(snapshot_key); + if (index_it != shard.snapshot_index.end()) { + auto& rec = snapshots[index_it->second]; + auto pending_count = shard.pending_snapshot_count.load(std::memory_order_relaxed); + const bool was_pending = is_pending_snapshot(rec.snapshot); + const bool now_pending = is_pending_snapshot(snap); + rec.snapshot = snap; + if (was_pending != now_pending) { + if (now_pending) { + ++pending_count; + } else if (pending_count > 0) { + --pending_count; + } + shard.pending_snapshot_count.store(pending_count, std::memory_order_relaxed); + } } else { - snapshots.push_back(DropSnapshotRecord{key, packet_id, snap, agg, binding.thread_name}); + const auto idx = snapshots.size(); + snapshots.push_back(DropSnapshotRecord{key, packet_id, snap, {}, binding.thread_name}); + shard.snapshot_index.emplace(std::move(snapshot_key), idx); + shard.snapshot_count.store(snapshots.size(), std::memory_order_relaxed); + if (is_pending_snapshot(snap)) { + const auto pending_count = + shard.pending_snapshot_count.load(std::memory_order_relaxed); + shard.pending_snapshot_count.store(pending_count + 1, std::memory_order_relaxed); + } + const auto ts = snapshot_timestamp(snap); + const auto latest_ts = + shard.latest_snapshot_timestamp.load(std::memory_order_relaxed); + if (ts >= latest_ts) { + shard.latest_snapshot_index.store(idx, std::memory_order_relaxed); + shard.latest_snapshot_timestamp.store(ts, std::memory_order_relaxed); + } } } diff --git a/src/app/core/OpenpennyPipelineDriver.cpp b/src/app/core/OpenpennyPipelineDriver.cpp index 037db9e..0667b01 100644 --- a/src/app/core/OpenpennyPipelineDriver.cpp +++ b/src/app/core/OpenpennyPipelineDriver.cpp @@ -180,6 +180,23 @@ PipelineSummary drive_pipeline(const Config& cfg_in, const PipelineOptions& opts if (opts_local.sink) { TCPLOG_INFO("[openpenny] egress sink: %s", opts_local.sink->describe().c_str()); + if (cfg.egress.kind == egress::EgressKind::RawNic) { + TCPLOG_WARN("[openpenny] raw_nic replays the original Ethernet " + "frame out '%s'. It does not reinject packets into " + "the local host stack, and it does not resolve or " + "rewrite L2 next-hop MAC addresses. Use egress.kind=tun " + "for local application delivery, or raw_socket if the " + "kernel routing table should choose the egress hop.", + cfg.egress.device.c_str()); + if (!cfg.ifname.empty() && cfg.egress.device == cfg.ifname) { + TCPLOG_WARN("[openpenny] raw_nic egress matches the ingress " + "interface '%s'. Redirected packets destined for " + "this host will not be delivered locally on this " + "path; they are transmitted back out the NIC with " + "their original Ethernet header.", + cfg.ifname.c_str()); + } + } } // Traffic match policy applies process-wide (every worker uses the @@ -247,7 +264,7 @@ PipelineSummary drive_pipeline(const Config& cfg_in, const PipelineOptions& opts std::vector> results(qcount); // Shared drop snapshot collector across worker threads. - auto drop_collector = std::make_shared(); + auto drop_collector = std::make_shared(qcount); AggregatesController aggregates_controller(cfg, opts_local, drop_collector, stop_flag, user_should_stop); aggregates_controller.start(); aggregates_controller.start_individual_limit(); diff --git a/src/app/core/PerThreadStats.cpp b/src/app/core/PerThreadStats.cpp index 3018fb5..fa5b934 100644 --- a/src/app/core/PerThreadStats.cpp +++ b/src/app/core/PerThreadStats.cpp @@ -29,6 +29,16 @@ static thread_local std::size_t g_counter_index = 0; // Static array holding counter objects, one per queue or thread. static PerThreadStats g_counters[kMaxCounters]{}; +// Dedicated per-worker aggregate-drop budget counters. +// +// Unlike the broader PerThreadStats struct, this storage is explicitly atomic +// because it participates in the active-mode drop admission path. +struct alignas(64) AggregateDropBudgetCounter { + std::atomic drops{0}; +}; + +static AggregateDropBudgetCounter g_drop_budget_counters[kMaxCounters]{}; + // Defines how many counter slots are currently active. Atomic to allow // safe updates when initialising systems with multiple queues. static std::atomic g_counters_size{1}; @@ -49,7 +59,11 @@ static std::atomic g_counters_size{1}; */ void init_thread_counters(std::size_t count) { const auto clamped = std::min(count, kMaxCounters); - + + for (auto& counter : g_drop_budget_counters) { + counter.drops.store(0, std::memory_order_relaxed); + } + // Ensure at least one counter slot is active. g_counters_size.store( clamped == 0 ? 1 : clamped, @@ -74,6 +88,14 @@ void set_thread_counter_index(std::size_t idx) { } } +std::size_t current_thread_counter_index() noexcept { + const auto size = g_counters_size.load(std::memory_order_relaxed); + if (g_counter_index >= size) { + g_counter_index = size - 1; + } + return g_counter_index; +} + /** * @brief Retrieve the PerThreadStats instance bound to the current thread. * @@ -83,14 +105,7 @@ void set_thread_counter_index(std::size_t idx) { * @return PerThreadStats reference for this thread's counters. */ PerThreadStats& current_thread_counters() { - const auto size = g_counters_size.load(std::memory_order_relaxed); - - if (g_counter_index >= size) { - // Thread index is stale, correct it to the final valid active counter. - g_counter_index = size - 1; - } - - return g_counters[g_counter_index]; + return g_counters[current_thread_counter_index()]; } // ----------------------------------------------------------------------------- @@ -171,4 +186,38 @@ std::uint64_t aggregate_active_flows() { return total; } +bool try_reserve_aggregate_drop(std::uint64_t max_total_drops) noexcept { + if (max_total_drops == 0) { + return true; + } + + const auto size = g_counters_size.load(std::memory_order_relaxed); + if (g_counter_index >= size) { + g_counter_index = size - 1; + } + + std::uint64_t total = 0; + for (std::size_t i = 0; i < size; ++i) { + total += g_drop_budget_counters[i].drops.load(std::memory_order_relaxed); + } + if (total >= max_total_drops) { + return false; + } + + g_drop_budget_counters[g_counter_index].drops.fetch_add( + 1, + std::memory_order_relaxed + ); + return true; +} + +std::uint64_t aggregate_drop_budget_drops() noexcept { + std::uint64_t total = 0; + const auto size = g_counters_size.load(std::memory_order_relaxed); + for (std::size_t i = 0; i < size; ++i) { + total += g_drop_budget_counters[i].drops.load(std::memory_order_relaxed); + } + return total; +} + } // namespace openpenny::app diff --git a/src/app/core/active/ActiveTestPipeline.cpp b/src/app/core/active/ActiveTestPipeline.cpp index 9e78419..b0f7348 100644 --- a/src/app/core/active/ActiveTestPipeline.cpp +++ b/src/app/core/active/ActiveTestPipeline.cpp @@ -45,6 +45,7 @@ ActiveTestPipelineRunner::ActiveTestPipelineRunner( matcher_{std::move(matcher)}, // Take ownership of the FlowMatcher used to classify relevant packets/flows. flow_manager_{cfg.active}, // Manage active monitored flows and aggregate per-flow stats. drop_collector_{std::move(drop_collector)}, // Shared drop snapshot collector across threads. + drop_collector_shard_index_{app::current_thread_counter_index()}, // Bind collector writes to the current worker shard. thread_name_{std::move(thread_name)}, // Friendly identifier for this worker thread. source_{std::move(source)}, // Take ownership of the packet source interface used to receive network packets. last_stats_log_{std::chrono::steady_clock::now()}, // Record current time to pace the first periodic stats log. @@ -54,18 +55,18 @@ ActiveTestPipelineRunner::ActiveTestPipelineRunner( if (drop_collector_) { app::DropCollectorBinding::instance().ensure_snapshot_hook(); flow_manager_.set_drop_sink( - [collector = drop_collector_, name = thread_name_](const FlowKey& key, - const std::string& packet_id, - penny::PacketDropSnapshot snapshot) { - const auto agg = openpenny::app::aggregate_counters(); - snapshot.stats.overwrite_from_aggregates(agg); + [collector = drop_collector_, + name = thread_name_, + shard_index = drop_collector_shard_index_](const FlowKey& key, + penny::PacketDropId packet_id, + penny::PacketDropSnapshot snapshot) { app::DropCollectorBinding::instance().upsert( collector, name, + shard_index, key, packet_id, - snapshot, - agg); + snapshot); }); } } @@ -225,9 +226,10 @@ void ActiveTestPipelineRunner::sweep_expired_snapshots(const std::chrono::steady if (pair.second.state != penny::SnapshotState::Pending) continue; if (now - pair.second.timestamp >= retransmission_timeout) { if (TCPLOG_ENABLED(INFO)) { + const auto packet_id_text = penny::format_packet_drop_id(pair.first); TCPLOG_INFO("[packet_expired] flow=%s packet_id=%s", flow_debug_details(entry.flow.flow_key()).c_str(), - pair.first.c_str()); + packet_id_text.c_str()); } entry.flow.mark_snapshot_expired(pair.first); } @@ -341,7 +343,11 @@ penny::FlowEngineEntry* ActiveTestPipelineRunner::admit_or_forward_flow( if (inserted) { if (drop_collector_) { if (auto* entry = flow_manager_.find(packet.flow)) { - app::DropCollectorBinding::instance().bind(&entry->flow, drop_collector_, thread_name_); + app::DropCollectorBinding::instance().bind( + &entry->flow, + drop_collector_, + thread_name_, + drop_collector_shard_index_); } } if (TCPLOG_ENABLED(INFO)) { @@ -493,9 +499,9 @@ void ActiveTestPipelineRunner::handle_data_packet(penny::FlowEngineEntry& entry, // If its in-sequence it can not be a duplicate or a retransmission entry.flow.record_droppable_packet(); - const bool dropped = entry.flow.drop_packet(start_seq, end_seq, packet.packet_id(), packet.flow, now); + const auto packet_id = packet.packet_id(); + const bool dropped = entry.flow.drop_packet(start_seq, end_seq, packet_id, packet.flow, now); if (dropped) { - entry.flow.register_gap(start_seq, end_seq, packet.packet_id()); return; } // We forward the packet. @@ -539,7 +545,7 @@ void ActiveTestPipelineRunner::handle_data_packet(penny::FlowEngineEntry& entry, const bool touches_gap = interval_mark.touches_gap; bool gap_partially_filled = false; - std::vector filled_gaps; + std::vector filled_gaps; bool fills_only_gap_space = false; // First, we check whether the packet touches the byte ranges affected by our packet drops. if (!touches_gap){ diff --git a/src/egress/RawNicSink.cpp b/src/egress/RawNicSink.cpp index 5aed4fe..963c4ae 100644 --- a/src/egress/RawNicSink.cpp +++ b/src/egress/RawNicSink.cpp @@ -1,17 +1,13 @@ // SPDX-License-Identifier: BSD-2-Clause /** * @file RawNicSink.cpp - * @brief PacketSink that emits IP datagrams out a specific NIC using - * an AF_PACKET/SOCK_DGRAM socket with a SOCK_DGRAM layer-3 view. + * @brief PacketSink that replays original Ethernet frames out a specific + * NIC using AF_PACKET/SOCK_RAW. * - * AF_PACKET + SOCK_DGRAM lets us address the egress interface by - * ifindex via sendto(2), bypassing the local routing table. That - * matches operator intent when a named NIC is the desired egress - * point -- IPPROTO_RAW (RawSocketSink) cannot make that guarantee - * because routing still decides the output interface there. - * - * We write starting from the layer-3 header (IP); the kernel builds - * the appropriate layer-2 encap from the socket's bind / sockaddr_ll. + * Unlike RawSocketSink (IPPROTO_RAW), this sink does not ask the kernel + * to route or ARP-resolve the packet. It transmits the captured layer-2 + * frame as-is on the selected NIC, so it is only correct when the + * original Ethernet header is still valid on that egress segment. */ #include "openpenny/egress/RawNicSink.h" @@ -43,9 +39,17 @@ bool RawNicSink::open() { return false; } - fd_ = ::socket(AF_PACKET, SOCK_DGRAM | SOCK_NONBLOCK, htons(ETH_P_IP)); + // SOCK_RAW (not SOCK_DGRAM): we want to forward the original frame + // including its Ethernet header. SOCK_DGRAM strips L2 on RX and + // synthesises one on TX from sockaddr_ll, but with no destination + // MAC available the kernel sends the frame with an all-zero dst + // MAC — which the next hop drops, so traffic never reaches its + // destination. SOCK_RAW preserves the original L2 verbatim. + // + // ETH_P_ALL on the protocol so we can write any frame type. + fd_ = ::socket(AF_PACKET, SOCK_RAW | SOCK_NONBLOCK, htons(ETH_P_ALL)); if (fd_ < 0) { - TCPLOG_ERROR("RawNicSink: socket(AF_PACKET, SOCK_DGRAM) failed: %s (need CAP_NET_RAW)", + TCPLOG_ERROR("RawNicSink: socket(AF_PACKET, SOCK_RAW) failed: %s (need CAP_NET_RAW)", std::strerror(errno)); return false; } @@ -68,7 +72,7 @@ bool RawNicSink::open() { // so the kernel drops incoming frames targeted at other ifaces. sockaddr_ll addr{}; addr.sll_family = AF_PACKET; - addr.sll_protocol = htons(ETH_P_IP); + addr.sll_protocol = htons(ETH_P_ALL); addr.sll_ifindex = if_index_; if (::bind(fd_, reinterpret_cast(&addr), sizeof(addr)) != 0) { const int saved = errno; @@ -105,22 +109,47 @@ void RawNicSink::close() noexcept { } bool RawNicSink::write(const net::PacketView& packet) { - if (fd_ < 0 || !packet.layer3_ptr || packet.layer3_length == 0) { + if (fd_ < 0) { + return false; + } + + // Preferred path: the source surfaced the original L2 frame, so we + // can replay it verbatim through SOCK_RAW. The kernel doesn't add + // any header — what we write goes on the wire as-is. This preserves + // the original Ethernet src/dst MAC addresses, which is what makes + // the next-hop switch / NIC accept the frame. + const std::uint8_t* buf = nullptr; + std::uint32_t len = 0; + if (packet.layer2_ptr && packet.layer2_length > 0) { + buf = packet.layer2_ptr; + len = packet.layer2_length; + } else if (packet.layer3_ptr && packet.layer3_length > 0) { + // Fallback: source has no L2 view (older readers, gRPC paths). + // Sending bare L3 via SOCK_RAW would require us to fabricate an + // Ethernet header, and we don't know the destination MAC. Warn + // and drop to make this fail loudly rather than silently. + static std::atomic warned{false}; + if (!warned.exchange(true, std::memory_order_relaxed)) { + TCPLOG_WARN("RawNicSink: source did not surface a layer-2 " + "frame; cannot forward via raw_nic without the " + "original Ethernet header. Use a different " + "egress kind (tun, raw_socket) or use a source " + "that populates packet.layer2_ptr.%s", ""); + } + stats_.errors.fetch_add(1, std::memory_order_relaxed); + return false; + } else { return false; } sockaddr_ll dst{}; dst.sll_family = AF_PACKET; - dst.sll_protocol = htons(ETH_P_IP); + dst.sll_protocol = htons(ETH_P_ALL); dst.sll_ifindex = if_index_; - dst.sll_halen = ETH_ALEN; // Kernel will use interface default if unknown. - // No sll_addr means "use the device's configured destination"; for - // point-to-point NICs that's always correct. Operators wanting to - // explicitly set a next-hop MAC should prefer a router upstream. const ssize_t written = ::sendto(fd_, - packet.layer3_ptr, - static_cast(packet.layer3_length), + buf, + static_cast(len), 0, reinterpret_cast(&dst), sizeof(dst)); @@ -131,7 +160,7 @@ bool RawNicSink::write(const net::PacketView& packet) { const int err = errno; if (err != EAGAIN && err != EWOULDBLOCK) { TCPLOG_WARN("RawNicSink::write (%u bytes) failed on fd=%d (device='%s'): %s", - static_cast(packet.layer3_length), fd_, + static_cast(len), fd_, cfg_.device.c_str(), std::strerror(err)); stats_.errors.fetch_add(1, std::memory_order_relaxed); } diff --git a/src/egress/RawSocketSink.cpp b/src/egress/RawSocketSink.cpp index 7d36108..59c7707 100644 --- a/src/egress/RawSocketSink.cpp +++ b/src/egress/RawSocketSink.cpp @@ -91,12 +91,41 @@ bool RawSocketSink::write(const net::PacketView& packet) { return true; } const int err = errno; - if (err != EAGAIN && err != EWOULDBLOCK) { - TCPLOG_WARN("RawSocketSink::write (%u bytes) failed on fd=%d: %s", - static_cast(packet.layer3_length), fd_, - std::strerror(err)); + if (err == EAGAIN || err == EWOULDBLOCK) { + // Transient back-pressure on a non-blocking raw socket; the + // packet is dropped and no error is recorded (the same policy + // the active path uses). + return false; + } + if (err == EMSGSIZE) { + // The IP datagram is larger than the egress interface MTU. + // IPPROTO_RAW with IP_HDRINCL cannot fragment for us, so this + // is a hard "won't fit" result. Common on passive taps that + // capture jumbo frames and try to forward them out a 1500-MTU + // NIC. Log a single actionable hint, silently count the rest. stats_.errors.fetch_add(1, std::memory_order_relaxed); + bool expected = false; + if (oversized_logged_.compare_exchange_strong( + expected, true, std::memory_order_relaxed)) { + TCPLOG_WARN( + "RawSocketSink: dropped %u-byte IP datagram on fd=%d " + "(EMSGSIZE) — packet exceeds the egress interface MTU " + "and a raw socket cannot fragment. Either raise the " + "egress MTU (e.g. `ip link set %s mtu %u`) or switch " + "egress.kind to `tun`/`raw_nic` on a path that supports " + "the packet size. Further oversized drops will be " + "counted silently.", + static_cast(packet.layer3_length), + fd_, + cfg_.device.empty() ? "" : cfg_.device.c_str(), + static_cast(packet.layer3_length)); + } + return false; } + TCPLOG_WARN("RawSocketSink::write (%u bytes) failed on fd=%d: %s", + static_cast(packet.layer3_length), fd_, + std::strerror(err)); + stats_.errors.fetch_add(1, std::memory_order_relaxed); return false; } diff --git a/src/ingress/af_packet/AfPacketMirrorReader.cpp b/src/ingress/af_packet/AfPacketMirrorReader.cpp index c6a6522..da4feb4 100644 --- a/src/ingress/af_packet/AfPacketMirrorReader.cpp +++ b/src/ingress/af_packet/AfPacketMirrorReader.cpp @@ -30,6 +30,13 @@ #include #include +#ifdef OPENPENNY_WITH_LIBBPF +extern "C" { +#include +#include +} +#endif + namespace openpenny::ingress::af_packet { namespace { @@ -131,19 +138,52 @@ bool AfPacketMirrorReader::open(const std::string& ifname, unsigned /*queue*/) { ifname_ = ifname; TCPLOG_INFO("AfPacketMirrorReader: tapping '%s' (ifindex=%u, fd=%d, frame=%zu)", ifname.c_str(), idx, fd_, frame_buf_.size()); - // AF_PACKET runs AFTER the XDP hook in the kernel: if a previous run - // (or any other tool) left an XDP program attached on this interface, - // it sees packets first and we only see whatever it returns XDP_PASS - // for. With a stale "redirect" program from a prior active run, the - // matched traffic gets redirected away from us and our [afpkt_counters] - // line stays at rx=0. Surface this once at open so the operator has - // somewhere to start when nothing arrives. + + // Detect a leftover XDP program. AF_PACKET runs *after* the XDP + // hook in the kernel pipeline, so any program already attached + // (e.g. from a prior active-mode run that didn't clean up) sees + // packets first and we only see what it returns XDP_PASS for. + // With a stale "redirect" program from a prior active run, the + // matched traffic gets redirected away from us and our + // [afpkt_counters] line stays at rx=0 indefinitely. + // + // When libbpf is available, query the netdev directly so the + // warning is precise (it fires only when there really is a + // program attached). Otherwise fall back to a generic hint. +#ifdef OPENPENNY_WITH_LIBBPF + { + __u32 prog_id = 0; + const int qrc = bpf_xdp_query_id(static_cast(idx), 0, &prog_id); + if (qrc == 0 && prog_id != 0) { + TCPLOG_WARN( + "AfPacketMirrorReader: an XDP program (id=%u) is " + "attached on '%s' RIGHT NOW. AF_PACKET runs after XDP, " + "so any traffic that program redirects or drops will " + "never reach this passive tap. To detach it and try " + "again:\n" + " sudo ip link set dev %s xdp off\n" + " sudo ip link set dev %s xdpgeneric off\n" + " sudo ip link set dev %s xdpdrv off\n" + " sudo rm -rf /sys/fs/bpf/openpenny*", + static_cast(prog_id), + ifname.c_str(), + ifname.c_str(), ifname.c_str(), ifname.c_str()); + } else { + TCPLOG_INFO( + "AfPacketMirrorReader: no XDP program currently attached " + "on '%s' — AF_PACKET tap should see all traffic the " + "kernel delivers to this interface.", + ifname.c_str()); + } + } +#else TCPLOG_INFO("AfPacketMirrorReader: AF_PACKET runs after the XDP hook. " "If rx stays at 0, check for a leftover XDP program with " "`ip -d link show %s` and clean up via " - "`sudo python3 scripts/xdp_attach.py --iface %s --mode drv --detach && " + "`sudo ip link set dev %s xdp off && " "sudo rm -rf /sys/fs/bpf/openpenny*`", ifname.c_str(), ifname.c_str()); +#endif return true; } @@ -170,7 +210,16 @@ bool AfPacketMirrorReader::poll(const net::PacketHandler& handler, std::size_t b } } - const std::size_t cap = std::min(budget, opts_.batch); + // Honour the IPipelineStrategy contract: a budget of 0 means "use the + // source's own default". PassiveTestPipelineRunner doesn't override + // poll_budget(), so it always passes 0 here; treating that literally as + // std::min(0, opts_.batch) collapsed cap to 0 and the recvfrom loop + // below never ran -- the AF_PACKET tap drained zero packets per poll, + // so nothing reached the strategy (no stats bumped, nothing forwarded + // through opts_.sink). Fall back to opts_.batch when no explicit + // budget is supplied; otherwise clamp the caller's budget to it. + const std::size_t cap = budget == 0 ? opts_.batch + : std::min(budget, opts_.batch); std::size_t drained = 0; while (drained < cap) { @@ -210,6 +259,11 @@ bool AfPacketMirrorReader::poll(const net::PacketHandler& handler, std::size_t b continue; } view.timestamp_ns = now_nanos(); + // Surface the original L2 frame (Ethernet header included) so + // sinks like RawNicSink can forward via SOCK_RAW without losing + // the original src/dst MAC addresses. + view.layer2_ptr = frame_buf_.data(); + view.layer2_length = static_cast(n); if (!opts_.match_config.empty() && !net::traffic_matches_packet(opts_.match_config, view)) { diff --git a/src/ingress/af_xdp/XdpReader.cpp b/src/ingress/af_xdp/XdpReader.cpp index dd81bc3..cc7c379 100644 --- a/src/ingress/af_xdp/XdpReader.cpp +++ b/src/ingress/af_xdp/XdpReader.cpp @@ -1547,6 +1547,11 @@ bool XdpReader::poll(const net::PacketHandler& handler, std::size_t budget) { net::PacketView packet{}; if (net::PacketParser::decode(pkt, len, packet)) { packet.timestamp_ns = now_ns(); + // Publish the L2 frame so egress sinks that need to + // forward via the NIC (e.g. RawNicSink with SOCK_RAW) + // can replay the original Ethernet header. + packet.layer2_ptr = pkt; + packet.layer2_length = len; handler(packet); } else { ++rs.decode_failures; diff --git a/src/ingress/dpdk/DpdkReader.cpp b/src/ingress/dpdk/DpdkReader.cpp index 900772d..4ff45f6 100644 --- a/src/ingress/dpdk/DpdkReader.cpp +++ b/src/ingress/dpdk/DpdkReader.cpp @@ -167,6 +167,10 @@ bool DpdkReader::poll(const net::PacketHandler& handler, std::size_t budget) { continue; } packet.timestamp_ns = now_ns(); + // Surface the original L2 frame for L2-level egress paths + // (RawNicSink with SOCK_RAW needs the Ethernet header). + packet.layer2_ptr = data; + packet.layer2_length = len; handler(packet); } rte_pktmbuf_free(mbuf); diff --git a/src/penny/flow/engine/FlowEngine.cpp b/src/penny/flow/engine/FlowEngine.cpp index cccd934..df645b5 100644 --- a/src/penny/flow/engine/FlowEngine.cpp +++ b/src/penny/flow/engine/FlowEngine.cpp @@ -132,7 +132,7 @@ void FlowEngine::update_highest_sequence(uint32_t seq) { // When we deliberately drop a packet range we record both the interval and the originating packet id, // using the packet id as the unique drop identifier so filled gaps can find the matching drop snapshot // and adjust counters once that specific drop is observed repaired. -void FlowEngine::register_gap(uint32_t start, uint32_t end, const std::string& packet_id) { +void FlowEngine::register_gap(uint32_t start, uint32_t end, PacketDropId packet_id) { if (end <= start) end = start + 1; auto seg = icl::interval::right_open(start, end); flow_gaps_.add(seg); @@ -150,8 +150,8 @@ bool FlowEngine::fills_only_gap_space(uint32_t start, uint32_t end) const { // output: vector of packet ids whose entire dropped range has been recovered by this retransmission. // purpose: remove repaired regions from flow_gaps_ while pinpointing which previously dropped packets // have now been fully observed on the wire. -std::vector FlowEngine::fill_gaps(uint32_t start, uint32_t end, bool* partially_filled) { - std::vector filled_ids; +std::vector FlowEngine::fill_gaps(uint32_t start, uint32_t end, bool* partially_filled) { + std::vector filled_ids; bool partial = false; if (end <= start) end = start + 1; auto seg = icl::interval::right_open(start, end); @@ -204,7 +204,7 @@ std::vector FlowEngine::fill_gaps(uint32_t start, uint32_t end, boo return filled_ids; } -void FlowEngine::register_filled_gaps(const std::vector& packet_ids) { +void FlowEngine::register_filled_gaps(const std::vector& packet_ids) { if (packet_ids.empty()) return; for (const auto& id : packet_ids) { ThreadFlowEventTimerManager::instance().enqueue_retransmitted(id, this); @@ -245,7 +245,7 @@ void FlowEngine::evaluate_snapshot_duplicate_threshold() { bool FlowEngine::drop_packet(uint32_t start, uint32_t end, - const std::string& packet_id, + PacketDropId packet_id, const FlowKey& key, const std::chrono::steady_clock::time_point& now) { @@ -264,17 +264,6 @@ bool FlowEngine::drop_packet(uint32_t start, std::max(0, flow_cfg_.max_drops_aggregates) ); - if (max_drops_in_aggregates > 0) { - const auto& runtime = openpenny::current_runtime_setup(); - if (runtime.aggregates_active) { - auto agg = openpenny::app::aggregate_counters(); - if (agg.dropped_packets >= max_drops_in_aggregates) { - // Global drop budget has been exhausted. - return false; - } - } - } - // Decide whether to drop the packet, using the configured drop probability. double r = flow_random_dist_(flow_random_engine_); // Draw a uniform random number in [0, 1). bool should_drop = (r < flow_cfg_.drop_probability); // Check if it falls within the drop probability range. @@ -283,6 +272,17 @@ bool FlowEngine::drop_packet(uint32_t start, return false; } + if (max_drops_in_aggregates > 0) { + const auto& runtime = openpenny::current_runtime_setup(); + if (runtime.aggregates_active && + !openpenny::app::try_reserve_aggregate_drop(max_drops_in_aggregates)) { + // Best-effort global drop budget has been exhausted. The atomic + // per-worker counters may still allow a small overshoot under + // heavy concurrency, which is acceptable for this path. + return false; + } + } + // Record that this flow has one more retransmission outstanding and one more packet dropped. flow_stats_.inc_pending_retransmission(); flow_stats_.record_drop(); @@ -349,7 +349,7 @@ bool FlowEngine::drop_packet(uint32_t start, * consistent, and * - remove the packet → snapshot index mapping. */ -void FlowEngine::mark_snapshot_retransmitted(const std::string& packet_id) { +void FlowEngine::mark_snapshot_retransmitted(PacketDropId packet_id) { // Look up the snapshot index by the packet ID. auto index_it = flow_snapshot_index_by_id_.find(packet_id); if (index_it == flow_snapshot_index_by_id_.end()) { @@ -417,7 +417,7 @@ void FlowEngine::mark_snapshot_retransmitted(const std::string& packet_id) { * - update flow-wide and snapshot-local statistics, and * - propagate the outcome to any snapshots recorded after this one. */ -void FlowEngine::mark_snapshot_expired(const std::string& packet_id) { +void FlowEngine::mark_snapshot_expired(PacketDropId packet_id) { // Look up the index of the snapshot associated with this packet ID. auto index_it = flow_snapshot_index_by_id_.find(packet_id); @@ -497,7 +497,7 @@ void FlowEngine::mark_snapshot_expired(const std::string& packet_id) { * - remove the packet → snapshot index mapping, and * - ensure later snapshots no longer consider this packet as pending. */ -void FlowEngine::mark_snapshot_invalid(const std::string& packet_id) { +void FlowEngine::mark_snapshot_invalid(PacketDropId packet_id) { // Look up the index of the snapshot tracked for this packet. auto index_it = flow_snapshot_index_by_id_.find(packet_id); if (index_it == flow_snapshot_index_by_id_.end()) { @@ -548,7 +548,7 @@ void FlowEngine::mark_snapshot_invalid(const std::string& packet_id) { } void FlowEngine::expire_all_pending_snapshots() { - std::vector pending_ids; + std::vector pending_ids; pending_ids.reserve(flow_drop_snapshots_.size()); for (const auto& pair : flow_drop_snapshots_) { if (pair.second.state == SnapshotState::Pending) { diff --git a/src/penny/flow/timer/ThreadFlowEventTimer.cpp b/src/penny/flow/timer/ThreadFlowEventTimer.cpp index ed4350d..7fef6d7 100644 --- a/src/penny/flow/timer/ThreadFlowEventTimer.cpp +++ b/src/penny/flow/timer/ThreadFlowEventTimer.cpp @@ -32,7 +32,7 @@ ThreadFlowEventTimerManager& ThreadFlowEventTimerManager::instance() { return mgr; } -std::function +std::function ThreadFlowEventTimerManager::snapshot_hook_{}; ThreadFlowEventTimerManager::~ThreadFlowEventTimerManager() { @@ -87,7 +87,7 @@ void ThreadFlowEventTimerManager::stop() { // ----------------------------------------------------------------------------- void ThreadFlowEventTimerManager::register_drop(const ::openpenny::FlowKey& key, - const std::string& packet_id, + PacketDropId packet_id, std::chrono::steady_clock::time_point ts, std::shared_ptr flow_alive, FlowEngine* flow, @@ -115,7 +115,7 @@ void ThreadFlowEventTimerManager::register_drop(const ::openpenny::FlowKey& key, wake_locked(); // Wake timer thread to re-evaluate scheduling. } -void ThreadFlowEventTimerManager::enqueue_retransmitted(const std::string& packet_id, FlowEngine* flow) { +void ThreadFlowEventTimerManager::enqueue_retransmitted(PacketDropId packet_id, FlowEngine* flow) { std::lock_guard lock(mutex_); if (!flow) return; @@ -194,7 +194,7 @@ void ThreadFlowEventTimerManager::run_callbacks(std::deque& pending) { else if (cb.kind == Callback::Kind::Duplicate) { cb.flow->register_duplicate_snapshot(cb.seq); cb.flow->evaluate_snapshot_duplicate_threshold(); - if (snapshot_hook_) snapshot_hook_(cb.flow, {}, SnapshotEventKind::Duplicate); + if (snapshot_hook_) snapshot_hook_(cb.flow, 0, SnapshotEventKind::Duplicate); } cb.flow->evaluate_if_ready(); // Re-check whether the flow now satisfies its scheduling thresholds. @@ -246,9 +246,10 @@ void ThreadFlowEventTimerManager::timer_loop() { // Ensure we only schedule snapshot mutation if the flow is still alive. if (auto alive = entry.flow_alive.lock(); alive && *alive && entry.flow) { if (TCPLOG_ENABLED(INFO)) { + const auto packet_id_text = format_packet_drop_id(entry.packet_id); TCPLOG_INFO("[packet_expired] flow=%s packet_id=%s token=%" PRIu64, flow_debug_details(entry.flow->flow_key()).c_str(), - entry.packet_id.c_str(), + packet_id_text.c_str(), entry.token ); } @@ -291,9 +292,10 @@ void ThreadFlowEventTimerManager::timer_loop() { cancelled_.insert(token); if (TCPLOG_ENABLED(INFO)) { + const auto packet_id_text = format_packet_drop_id(ev.packet_id); TCPLOG_INFO("[packet_retransmitted] flow=%s packet_id=%s seq=%" PRIu32, flow_debug_details(ev.flow->flow_key()).c_str(), - ev.packet_id.c_str(), + packet_id_text.c_str(), ev.seq ); } @@ -370,7 +372,7 @@ void ThreadFlowEventTimerManager::drain_callbacks() { } void ThreadFlowEventTimerManager::set_snapshot_hook(std::function hook) { snapshot_hook_ = std::move(hook); } diff --git a/tests/unit/flow/test_aggregate_drop_budget.cpp b/tests/unit/flow/test_aggregate_drop_budget.cpp new file mode 100644 index 0000000..244f429 --- /dev/null +++ b/tests/unit/flow/test_aggregate_drop_budget.cpp @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: BSD-2-Clause + +#include "openpenny/app/core/PerThreadStats.h" + +#include +#include + +int main() { + using namespace openpenny::app; + + init_thread_counters(3); + assert(aggregate_drop_budget_drops() == 0); + + set_thread_counter_index(0); + assert(try_reserve_aggregate_drop(2)); + assert(aggregate_drop_budget_drops() == 1); + + set_thread_counter_index(1); + assert(try_reserve_aggregate_drop(2)); + assert(aggregate_drop_budget_drops() == 2); + + set_thread_counter_index(2); + assert(!try_reserve_aggregate_drop(2)); + assert(aggregate_drop_budget_drops() == 2); + + // Initialisation should reset the dedicated aggregate-drop budget so a + // fresh pipeline run does not inherit drops from the previous one. + init_thread_counters(2); + set_thread_counter_index(0); + assert(aggregate_drop_budget_drops() == 0); + assert(try_reserve_aggregate_drop(1)); + + set_thread_counter_index(1); + assert(!try_reserve_aggregate_drop(1)); + assert(aggregate_drop_budget_drops() == 1); + + return 0; +} diff --git a/tests/unit/flow/test_drop_snapshot_updates.cpp b/tests/unit/flow/test_drop_snapshot_updates.cpp index a01f7a6..c27d27c 100644 --- a/tests/unit/flow/test_drop_snapshot_updates.cpp +++ b/tests/unit/flow/test_drop_snapshot_updates.cpp @@ -1,4 +1,30 @@ // SPDX-License-Identifier: BSD-2-Clause +// +// Tests how `FlowEngine` snapshots evolve as drops, retransmissions and +// duplicates are recorded. Each call to `drop_packet()` captures a +// `PacketDropSnapshot` whose `.stats` field is a frozen copy of the +// flow's stats at that instant. Later events MUST update both the +// flow-wide counters AND the matching snapshot, *and propagate forward* +// to every newer snapshot — older snapshots stay untouched. +// +// Coverage: +// 1. Two consecutive drops correctly bump the flow's pending count and +// append a snapshot per drop, with each snapshot's frozen stats +// reflecting the count at that moment. +// 2. `register_filled_gaps()` (a successful retransmission of an +// earlier drop) decrements pending on the matching snapshot and on +// every later snapshot, but does not touch older snapshots. +// 3. `register_duplicate_snapshot()` increments duplicate_packets only +// on snapshots whose covered seq range is *at or after* the +// duplicate's sequence number — i.e. duplicates seen "after" a +// snapshot affect that snapshot, but duplicates with smaller seq +// affect every snapshot up the chain. +// +// Synchronization caveat: +// `FlowEngine::register_filled_gaps()` enqueues a Retransmit event on +// the global `ThreadFlowEventTimerManager`; the actual mutation happens +// on the timer thread. The test polls until the mutation is observed +// so we don't race against the background thread. #include "openpenny/config/Config.h" #include "openpenny/penny/flow/engine/FlowEngine.h" @@ -6,60 +32,140 @@ #include #include +#include #include - using namespace std::chrono; +using namespace std::chrono; - int main() { - openpenny::Config cfg; - cfg.active.drop_probability = 1.0; // Ensure drop_packet always drops. +namespace { - openpenny::penny::FlowEngine flow(cfg.active); - auto now = steady_clock::time_point{}; +// Wait up to `timeout` for `predicate()` to become true. Used to +// synchronise the test thread with the FlowEngine timer thread, which +// processes Retransmit events asynchronously. +template +bool wait_for(Predicate predicate, milliseconds timeout = milliseconds{2000}) { + const auto deadline = steady_clock::now() + timeout; + while (steady_clock::now() < deadline) { + if (predicate()) return true; + std::this_thread::sleep_for(milliseconds{5}); + } + return predicate(); +} + +} // namespace + +int main() { + // ---------------------------------------------------------------- + // Setup + // ---------------------------------------------------------------- + openpenny::Config cfg; + // drop_probability=1.0 makes drop_packet() always drop, so the test + // is deterministic regardless of the random number generator state. + cfg.active.drop_probability = 1.0; + // Long retransmission timeout. With `now = steady_clock::now()`, the + // deadline = `now + 60s` lies far in the future so the timer-manager + // background thread's expiry path never runs during this test — + // only the explicit `register_filled_gaps()` events do. (The test's + // assertions break if `mark_snapshot_expired` runs concurrently and + // decrements pending on entries we haven't filled yet.) + cfg.active.rtt_timeout_factor = 60.0; + + openpenny::penny::FlowEngine flow(cfg.active); + + // Real wall-clock timestamp so timer deadlines land in the future. + auto now = steady_clock::now(); openpenny::FlowKey key{}; - // First drop. + // ---------------------------------------------------------------- + // Phase 1: two drops in increasing-seq order + // ---------------------------------------------------------------- + // First drop: seq 1000-1100. record_data() advertises that the seq + // was observed (so drop_packet has a flow position to attach to). + const auto drop1_id = openpenny::penny::make_packet_drop_id(1000, 100); + const auto drop2_id = openpenny::penny::make_packet_drop_id(2000, 100); flow.record_data(1000, now); - bool dropped1 = flow.drop_packet(1000, 1100, "drop1", key, now); + bool dropped1 = flow.drop_packet(1000, 1100, drop1_id, key, now); assert(dropped1); assert(flow.pending_retransmissions() == 1); assert(flow.drop_snapshots().size() == 1); - // Second drop with higher sequence. + // Second drop: seq 2000-2100. Pending count climbs to 2, and the + // snapshot vector grows to two entries. flow.record_data(2000, now); - bool dropped2 = flow.drop_packet(2000, 2100, "drop2", key, now); + bool dropped2 = flow.drop_packet(2000, 2100, drop2_id, key, now); assert(dropped2); assert(flow.pending_retransmissions() == 2); assert(flow.drop_snapshots().size() == 2); + // ---------------------------------------------------------------- + // Phase 1 verification: each snapshot's frozen stats + // ---------------------------------------------------------------- + // Insertion order: FlowEngine appends each new drop with + // emplace_back, so the OLDEST drop sits at front() and the NEWEST + // at back(). const auto& snaps_before_fill = flow.drop_snapshots(); - // Latest drop is at the front; oldest at the back. - auto& snap_drop1_before = snaps_before_fill.back().second; - auto& snap_drop2_before = snaps_before_fill.front().second; + auto& snap_drop1_before = snaps_before_fill.front().second; // drop1, oldest + auto& snap_drop2_before = snaps_before_fill.back().second; // drop2, newest + + // drop1's snapshot was taken right after the FIRST drop, so it sees + // pending=1 frozen in time. drop2's snapshot was taken after the + // SECOND drop, so it sees pending=2. assert(snap_drop1_before.stats.pending_retransmissions() == 1); assert(snap_drop2_before.stats.pending_retransmissions() == 2); + // No retransmissions yet — neither snapshot has been "filled". assert(snap_drop1_before.stats.retransmitted_packets() == 0); assert(snap_drop2_before.stats.retransmitted_packets() == 0); - // Mark the first drop as filled; it should decrement pending counts and bump retransmissions - // for the matching snapshot and all newer ones. - flow.register_filled_gaps(std::vector{"drop1"}); + // ---------------------------------------------------------------- + // Phase 2: drop1 is retransmitted (gap filled by a later packet) + // ---------------------------------------------------------------- + // register_filled_gaps() queues a Retransmit event on the timer + // manager. The timer thread picks it up and calls + // mark_snapshot_retransmitted on this thread's FlowEngine, which: + // - decrements flow_stats_.pending_retransmissions by 1, + // - increments flow_stats_.retransmitted_packets by 1, + // - flips drop1's snapshot to Retransmitted state, + // - propagates the change to drop2's snapshot (the only later one + // still Pending), bumping its retransmitted_packets and + // decrementing its frozen pending count. + flow.register_filled_gaps(std::vector{drop1_id}); + + // Wait for the timer thread to process the event before asserting. + // Without this, the assertions race against the background thread. + assert(wait_for([&] { return flow.retransmitted_packets() == 1; })); + + // Phase 2 verification: flow-wide counters assert(flow.pending_retransmissions() == 1); assert(flow.retransmitted_packets() == 1); + + // Phase 2 verification: per-snapshot frozen stats const auto& snaps_after_fill = flow.drop_snapshots(); - auto& snap_drop1_after = snaps_after_fill.back().second; - auto& snap_drop2_after = snaps_after_fill.front().second; + auto& snap_drop1_after = snaps_after_fill.front().second; // drop1, oldest + auto& snap_drop2_after = snaps_after_fill.back().second; // drop2, newest + // drop1 itself: pending dropped to 0, retransmitted bumped to 1. assert(snap_drop1_after.stats.pending_retransmissions() == 0); - assert(snap_drop2_after.stats.pending_retransmissions() == 1); assert(snap_drop1_after.stats.retransmitted_packets() == 1); + // drop2 (later snapshot): forward propagation also drops its frozen + // pending count by 1 (2 -> 1) and bumps its retransmitted (0 -> 1). + assert(snap_drop2_after.stats.pending_retransmissions() == 1); assert(snap_drop2_after.stats.retransmitted_packets() == 1); - // Record a duplicate with seq between the two drops: only the newer snapshot should count it. + // ---------------------------------------------------------------- + // Phase 3: duplicate observations and the seq-coverage rule + // ---------------------------------------------------------------- + // Duplicate at seq 1950 — falls between drop1 (1000) and drop2 + // (2000). Only snapshots whose covered range INCLUDES 1950 should + // be incremented. drop1's snapshot's coverage ends at 1100 < 1950, + // so drop1 is NOT touched. drop2's coverage extends past 1950 + // (its highest seq seen at the time of the snapshot was 2000), so + // drop2 IS incremented. flow.register_duplicate_snapshot(1950); assert(snap_drop1_after.stats.duplicate_packets() == 0); assert(snap_drop2_after.stats.duplicate_packets() == 1); - // Record a duplicate earlier than both: both snapshots should count it. + // Duplicate at seq 900 — earlier than drop1 itself. Both snapshots + // cover that seq (drop1: 1000 >= 900; drop2: 2000 >= 900), so both + // increment. flow.register_duplicate_snapshot(900); assert(snap_drop1_after.stats.duplicate_packets() == 1); assert(snap_drop2_after.stats.duplicate_packets() == 2); diff --git a/tests/unit/flow/test_drop_timer.cpp b/tests/unit/flow/test_drop_timer.cpp index 612701e..df9f6b6 100644 --- a/tests/unit/flow/test_drop_timer.cpp +++ b/tests/unit/flow/test_drop_timer.cpp @@ -27,15 +27,16 @@ int main() { openpenny::penny::FlowEngine flow(cfg.active); openpenny::FlowKey key{}; const auto now = std::chrono::steady_clock::now(); + const auto packet_id = openpenny::penny::make_packet_drop_id(1000, 100); flow.record_data(1000, now); - bool dropped = flow.drop_packet(1000, 1100, "expire-me", key, now); + bool dropped = flow.drop_packet(1000, 1100, packet_id, key, now); assert(dropped); assert(flow.pending_retransmissions() == 1); // Wait past the expiration deadline, then enqueue a retransmit event. sleep_for_ms(80); - openpenny::penny::ThreadFlowEventTimerManager::instance().enqueue_retransmitted("expire-me", &flow); + openpenny::penny::ThreadFlowEventTimerManager::instance().enqueue_retransmitted(packet_id, &flow); sleep_for_ms(80); openpenny::penny::ThreadFlowEventTimerManager::instance().drain_callbacks(); @@ -61,15 +62,16 @@ int main() { openpenny::penny::FlowEngine flow(cfg.active); openpenny::FlowKey key{}; const auto now = std::chrono::steady_clock::now(); + const auto packet_id = openpenny::penny::make_packet_drop_id(2000, 100); flow.record_data(2000, now); - bool dropped = flow.drop_packet(2000, 2100, "retransmit-me", key, now); + bool dropped = flow.drop_packet(2000, 2100, packet_id, key, now); assert(dropped); assert(flow.pending_retransmissions() == 1); // Enqueue retransmit well before deadline so event path runs first. sleep_for_ms(20); - openpenny::penny::ThreadFlowEventTimerManager::instance().enqueue_retransmitted("retransmit-me", &flow); + openpenny::penny::ThreadFlowEventTimerManager::instance().enqueue_retransmitted(packet_id, &flow); sleep_for_ms(150); openpenny::penny::ThreadFlowEventTimerManager::instance().drain_callbacks(); diff --git a/tests/unit/flow/test_gap_management.cpp b/tests/unit/flow/test_gap_management.cpp index e355469..cafa2e9 100644 --- a/tests/unit/flow/test_gap_management.cpp +++ b/tests/unit/flow/test_gap_management.cpp @@ -6,8 +6,6 @@ #include #include -#include - using namespace std::chrono; using openpenny::penny::FlowTrackingState; namespace net = openpenny::net; @@ -36,7 +34,7 @@ int main() { // Register a gap representing a dropped packet. auto& entry = track(table, flow, true, 1000, now); - std::string gap_id = "pkt-1000-1100"; + const auto gap_id = openpenny::penny::make_packet_drop_id(1000, 100); entry.flow.register_gap(1000, 1100, gap_id); // First retransmission partially fills the gap. From 40f66f34621103fa46751abdac6e0adb3e00e700 Mon Sep 17 00:00:00 2001 From: Petros Gigis Date: Thu, 30 Apr 2026 12:54:44 +0100 Subject: [PATCH 2/8] update .gitignore --- .DS_Store | Bin 6148 -> 0 bytes .gitignore | 1 + docs/.DS_Store | Bin 6148 -> 0 bytes 3 files changed, 1 insertion(+) delete mode 100644 .DS_Store delete mode 100644 docs/.DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index fd51af936cc89e7ab0cd4c137a7d0f500d02a39b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK!AiqG5S?wS-BN@e6nYGJEm&Ta7C@-2x!e zHBt-O)Jsr~W6`yk8N?M7VNwxIs<17FFzM)bY@BN`GicI5*ycmnHw)XL2>o_^zN5oI zxCVJ-2AF|W28w1{qx1jl_x^u1iD%3JGq6z%h*Hn%b+IISwyqUNXRU*Jhe|?mnZacW j8u}>4SUQU9s9MnPkb&r0%nYIjg)ah{1|FD!KV{$(!8M7% diff --git a/.gitignore b/.gitignore index 8830672..5d6492f 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ # Editor and OS files .DS_Store +.DS_STORE *.swp diff --git a/docs/.DS_Store b/docs/.DS_Store deleted file mode 100644 index 5008ddfcf53c02e82d7eee2e57c38e5672ef89f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 Date: Thu, 30 Apr 2026 18:35:01 +0100 Subject: [PATCH 3/8] new traffic generator scripts --- tools/traffic_generator/Makefile | 91 ++++ tools/traffic_generator/README.md | 309 +++++++++++--- .../__pycache__/client.cpython-310.pyc | Bin 0 -> 1983 bytes .../__pycache__/mixed_traffic.cpython-310.pyc | Bin 0 -> 4264 bytes .../__pycache__/preflight.cpython-310.pyc | Bin 0 -> 13657 bytes .../__pycache__/server.cpython-310.pyc | Bin 0 -> 1783 bytes .../spoofed_client.cpython-310.pyc | Bin 0 -> 15221 bytes .../spoofed_client.cpython-314.pyc | Bin 0 -> 27576 bytes tools/traffic_generator/preflight.py | 387 ++++++++++++++++++ tools/traffic_generator/run.sh | 229 +++++++++++ tools/traffic_generator/spoofed_client.py | 200 ++++++++- 11 files changed, 1131 insertions(+), 85 deletions(-) create mode 100644 tools/traffic_generator/Makefile create mode 100644 tools/traffic_generator/__pycache__/client.cpython-310.pyc create mode 100644 tools/traffic_generator/__pycache__/mixed_traffic.cpython-310.pyc create mode 100644 tools/traffic_generator/__pycache__/preflight.cpython-310.pyc create mode 100644 tools/traffic_generator/__pycache__/server.cpython-310.pyc create mode 100644 tools/traffic_generator/__pycache__/spoofed_client.cpython-310.pyc create mode 100644 tools/traffic_generator/__pycache__/spoofed_client.cpython-314.pyc create mode 100755 tools/traffic_generator/preflight.py create mode 100755 tools/traffic_generator/run.sh diff --git a/tools/traffic_generator/Makefile b/tools/traffic_generator/Makefile new file mode 100644 index 0000000..ab9bb9a --- /dev/null +++ b/tools/traffic_generator/Makefile @@ -0,0 +1,91 @@ +# Makefile shortcuts for the traffic generator. +# +# All targets that need to send raw packets re-exec themselves under sudo +# via run.sh. Override any variable on the command line, e.g. +# +# make spoof IFACE=ens5f1np1 DEST_IP=10.0.0.5 DEST_PORT=9000 SRC_IP=198.51.100.10 +# +# or in your shell: +# +# IFACE=ens5f0np0 DEST_IP=192.168.43.3 DEST_PORT=5201 SRC_IP=198.51.100.10 make spoof + +SHELL := /bin/bash +HERE := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) + +# --- Required parameters (override on the command line) --- +IFACE ?= +DEST_IP ?= +DEST_PORT ?= 9000 +SRC_IP ?= +DST_MAC ?= ff:ff:ff:ff:ff:ff + +# --- Tunable defaults (forwarded to run.sh) --- +FLOWS ?= 1 +COUNT ?= 20 +PAYLOAD ?= 64 +PORT ?= 9000 +HOST ?= 127.0.0.1 + +EXPORT_VARS := \ + DEFAULT_FLOWS=$(FLOWS) \ + DEFAULT_COUNT=$(COUNT) \ + DEFAULT_PAYLOAD=$(PAYLOAD) + +.PHONY: help install server client doctor spoof spoof-l2 mixed clean + +help: + @echo "Common targets:" + @echo " make install - create .venv, install scapy" + @echo " make server PORT=9000 - run the listener on PORT" + @echo " make client HOST=127.0.0.1 PORT=9000 - run the simple client" + @echo " make doctor IFACE=... DEST_IP=... SRC_IP=... - run preflight only" + @echo " make spoof IFACE=... DEST_IP=... DEST_PORT=... SRC_IP=..." + @echo " Routed mode (kernel decides next hop)." + @echo " make spoof-l2 IFACE=... DST_MAC=... DEST_IP=... DEST_PORT=... SRC_IP=..." + @echo " L2 mode (you set the destination MAC)." + @echo " make mixed IFACE=... IPERF_SERVER=... DEST_IP=... SRC_IP=..." + @echo " iperf3 + spoofed flows together." + @echo + @echo "Tuning vars: FLOWS=$(FLOWS) COUNT=$(COUNT) PAYLOAD=$(PAYLOAD)" + +install: + @$(HERE)run.sh install + +server: + @$(EXPORT_VARS) $(HERE)run.sh server $(PORT) + +client: + @$(EXPORT_VARS) $(HERE)run.sh client $(HOST) $(PORT) + +# Required-arg guard. +define need +$(if $($1),,$(error $1 is required, e.g. make $@ $1=...)) +endef + +doctor: + $(call need,IFACE) + $(call need,DEST_IP) + $(call need,SRC_IP) + @$(EXPORT_VARS) $(HERE)run.sh doctor $(IFACE) $(DEST_IP) $(SRC_IP) + +spoof: + $(call need,IFACE) + $(call need,DEST_IP) + $(call need,SRC_IP) + @$(EXPORT_VARS) $(HERE)run.sh spoof $(IFACE) $(DEST_IP) $(DEST_PORT) $(SRC_IP) + +spoof-l2: + $(call need,IFACE) + $(call need,DEST_IP) + $(call need,SRC_IP) + @$(EXPORT_VARS) $(HERE)run.sh spoof-l2 $(IFACE) $(DST_MAC) $(DEST_IP) $(DEST_PORT) $(SRC_IP) + +mixed: + $(call need,IFACE) + $(call need,IPERF_SERVER) + $(call need,DEST_IP) + $(call need,SRC_IP) + @$(EXPORT_VARS) $(HERE)run.sh mixed $(IFACE) $(IPERF_SERVER) $(DEST_IP) $(SRC_IP) + +clean: + rm -rf $(HERE).venv $(HERE)__pycache__ diff --git a/tools/traffic_generator/README.md b/tools/traffic_generator/README.md index 3e670ae..064868a 100644 --- a/tools/traffic_generator/README.md +++ b/tools/traffic_generator/README.md @@ -1,105 +1,288 @@ -# TCP Traffic Generator +# Traffic Generator -Two simple Python scripts plus an optional spoofed sender help generate TCP traffic for lab testing: +Lab tooling for poking at openpenny: a tiny TCP echo pair, a Scapy-based +spoofed-flow injector, and a mixed (iperf3 + spoofed) driver. Scripts can be +called directly, but the `run.sh` wrapper and the `Makefile` cover the common +cases without having to remember every flag. -- `server.py`: listens on a given host/port and prints any bytes received. -- `client.py`: connects to the server and sends a message every second. -- `spoofed_client.py`: crafts flows with Scapy and injects them at L2 (useful for controlled sequence numbers, duplicates, and multi-flow bursts). -- `mixed_traffic.py`: launches iperf3 client traffic alongside spoofed flows for mixed workloads. +## Files at a glance -## Usage +| File | What it does | +| --- | --- | +| `server.py` | Listens on host/port and prints received bytes. | +| `client.py` | Connects to a server and sends a line every second. | +| `spoofed_client.py` | Crafts TCP flows (SYN → DATA → FIN) with Scapy. Supports L2 (`sendp()`) and L3-routed (`IP_HDRINCL`) modes, with pacing, jitter, duplication, and now a `--preflight` diagnostic mode. | +| `mixed_traffic.py` | Runs iperf3 plus spoofed flows in parallel. | +| `preflight.py` | Read-only diagnostics: rp_filter, route lookup, ARP, firewall rules, common foot-guns. Importable or runnable on its own. | +| `run.sh` | Friendly wrapper with subcommands and a single source of truth for the venv + sudo. | +| `Makefile` | `make spoof IFACE=… DEST_IP=… SRC_IP=…` style shortcuts. | -Start the server (listen on all interfaces, port 9000): +## Install ```bash -python3 server.py --host 0.0.0.0 --port 9000 +./run.sh install # creates ./.venv and installs scapy +# or +make install ``` -Start the client in another terminal (connects to 127.0.0.1:9000 and sends a line every second): +Anything that needs raw sockets (`spoofed_client.py`, `preflight.py`) re-execs +itself under `sudo` with the venv's Python so Scapy stays importable. + +## Quick start ```bash -python3 client.py --host 127.0.0.1 --port 9000 --message "hello openpenny" +# Terminal 1: catch payloads +./run.sh server 9000 + +# Terminal 2: simple TCP client (no spoofing) +./run.sh client 127.0.0.1 9000 + +# Terminal 3: 1 spoofed flow, routed via the kernel, with built-in preflight +./run.sh spoof ens5f0np0 192.0.2.10 9000 198.51.100.10 ``` -Use `Ctrl+C` in either terminal to stop. +Extra flags after the positional arguments are forwarded to the underlying +Python script: -## Spoofed flows (Scapy) +```bash +./run.sh spoof ens5f0np0 192.168.43.3 5201 198.51.100.10 \ + --flows 10 --count 150 --payload-size 64 --debug +``` -Requirements: `pip install scapy` +## Make targets -Install dependencies in a virtualenv (optional): ```bash -python3 -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt # if you add one; otherwise pip install scapy +make doctor IFACE=ens5f0np0 DEST_IP=192.168.43.3 SRC_IP=198.51.100.10 +make spoof IFACE=ens5f0np0 DEST_IP=192.168.43.3 DEST_PORT=5201 SRC_IP=198.51.100.10 \ + FLOWS=10 COUNT=150 PAYLOAD=64 +make spoof-l2 IFACE=ens5f0np0 DST_MAC=aa:bb:cc:dd:ee:ff \ + DEST_IP=192.0.2.10 DEST_PORT=9000 SRC_IP=198.51.100.10 +make mixed IFACE=ens5f0np0 IPERF_SERVER=192.0.2.20 \ + DEST_IP=198.51.100.20 SRC_IP=198.51.100.10 ``` -Generate 3 spoofed flows with 20 data packets each, broadcast MAC, no pacing: +## Calling the Python scripts directly + +The wrapper just invokes these — the underlying CLI hasn't changed. + +L2 inject (default, fastest path): + ```bash sudo python3 spoofed_client.py \ - --iface ens5f0np0 \ - --dst-mac ff:ff:ff:ff:ff:ff \ - --dest-ip 192.0.2.10 --dest-port 9000 \ - --src-ip 198.51.100.10 \ - --flows 3 --count 20 --payload-size 64 + --iface ens5f0np0 --dst-mac ff:ff:ff:ff:ff:ff \ + --dest-ip 192.0.2.10 --dest-port 9000 \ + --src-ip 198.51.100.10 \ + --flows 3 --count 20 --payload-size 64 --preflight ``` -Add duplication and pacing jitter: +Routed mode (kernel decides the next hop, applies its own filters): + ```bash sudo python3 spoofed_client.py \ - --iface ens5f0np0 \ - --dst-mac ff:ff:ff:ff:ff:ff \ - --dest-ip 192.0.2.10 --dest-port 9000 \ - --src-ip 198.51.100.10 \ - --flows 2 --count 10 --payload-size 48 \ - --interval 0.02 --interval-jitter 0.01 \ - --duplication-prob 0.1 \ - --debug + --iface ens5f0np0 --routed \ + --dest-ip 192.168.43.3 --dest-port 5201 \ + --src-ip 198.51.100.10 \ + --flows 10 --count 150 --payload-size 64 --preflight ``` -## Using with openpenny CLI (example) +Diagnose-only (no traffic generated): -Run the server to capture payloads: ```bash -python3 server.py --host 0.0.0.0 --port 9000 +sudo python3 preflight.py --iface ens5f0np0 \ + --dest-ip 192.168.43.3 --src-ip 198.51.100.10 --routed +# or: +sudo python3 spoofed_client.py ... --preflight-only ``` -Run openpenny in active mode (adjust iface/queue/prefix): +## Mixed traffic (iperf + spoofed) + ```bash -sudo ./build/openpenny_cli \ - --config examples/configs/config_default.yaml \ - --mode active \ - --prefix 198.51.100.0 \ - --mask-bits 24 \ - --iface ens5f0np0 \ - --queue 0 \ - --tun xdp-tun +python3 mixed_traffic.py \ + --iface ens5f0np0 --dst-mac ff:ff:ff:ff:ff:ff \ + --iperf-server 192.0.2.20 --iperf-port 5201 \ + --iperf-parallel 4 --iperf-duration 30 \ + --spoof-dest-ip 198.51.100.20 --spoof-dest-port 9000 \ + --spoof-src-ip 198.51.100.10 \ + --spoof-flows 2 --spoof-count 15 --spoof-payload 64 \ + --spoof-interval 0.02 --spoof-jitter 0.01 --spoof-dup-prob 0.1 ``` -Generate spoofed traffic toward the server: +## Troubleshooting: spoofed traffic never arrives + +If a command like + ```bash sudo python3 spoofed_client.py \ - --iface ens5f0np0 \ - --dst-mac ff:ff:ff:ff:ff:ff \ - --dest-ip 198.51.100.20 --dest-port 9000 \ - --src-ip 198.51.100.10 \ - --flows 2 --count 15 --payload-size 64 + --iface ens5f0np0 --routed \ + --dest-ip 192.168.43.3 --dest-port 5201 \ + --src-ip 198.51.100.10 \ + --flows 10 --count 150 --payload-size 64 ``` -Watch openpenny logs for drops/duplicates/retransmissions and server output for received payloads. Adjust `--prefix/--mask-bits` to filter to your spoofed subnet. +returns success on the sender but the receiver application sees nothing, +the packet is being dropped somewhere along the path. Run -## Mixed traffic (iperf + spoofed) +```bash +sudo python3 preflight.py --iface ens5f0np0 \ + --dest-ip 192.168.43.3 --src-ip 198.51.100.10 --routed +``` + +then walk through the checklist below. The first three causes account for +the vast majority of "spoofed traffic disappears" reports. + +### 1. Reverse-path filter on the receiver (most common) + +Linux's `rp_filter` drops incoming packets whose source IP is not reachable +through the arrival interface. With `--src-ip 198.51.100.10` (TEST-NET-2) the +receiver has no return route, so strict mode silently drops every packet on +arrival. Confirm on the **receiver**: -Run iperf3 client plus spoofed flows in parallel: ```bash -python3 mixed_traffic.py \ - --iperf-server 192.0.2.20 --iperf-port 5201 --iperf-parallel 4 --iperf-duration 30 \ - --iface ens5f0np0 --dst-mac ff:ff:ff:ff:ff:ff \ - --spoof-dest-ip 198.51.100.20 --spoof-dest-port 9000 \ - --spoof-src-ip 198.51.100.10 --spoof-flows 2 --spoof-count 15 --spoof-payload 64 \ - --spoof-interval 0.02 --spoof-jitter 0.01 --spoof-dup-prob 0.1 +sysctl net.ipv4.conf.all.rp_filter \ + net.ipv4.conf.default.rp_filter \ + net.ipv4.conf..rp_filter +``` + +Fix one of two ways: + +```bash +# (a) loose mode: accept the packet if any interface has a route back +sudo sysctl -w net.ipv4.conf.all.rp_filter=2 +sudo sysctl -w net.ipv4.conf..rp_filter=2 + +# (b) install a return route and keep strict mode +sudo ip route add 198.51.100.0/24 dev +``` + +To prove the packet is at least making it onto the wire, run on the receiver: + +```bash +sudo tcpdump -ni "src host 198.51.100.10 and dst host 192.168.43.3" +``` + +If `tcpdump` shows the packets but the application doesn't, you're hitting +rp_filter (or a netfilter rule — see below). + +### 2. Reverse-path filter / OUTPUT chain on the sender + +The sender can drop its own egress. Same check on the sending host: + +```bash +sysctl net.ipv4.conf.all.rp_filter net.ipv4.conf..rp_filter +sudo nft list ruleset # or: sudo iptables -S OUTPUT ``` -Requirements: -- iperf3 installed on the client host -- Scapy (`pip install -r tools/traffic_generator/requirements.txt`) +If the OUTPUT chain has DROP/REJECT rules that match `198.51.100.0/24` (e.g. +firewalld's "drop bogons" zone), the packet never leaves the host. Counter +to verify: + +```bash +sudo iptables -nvL OUTPUT # watch the pkts/bytes columns increment +``` + +### 3. BCP38 / source-address validation on the upstream router + +Most production gateways drop packets whose source isn't in the prefix the +sender belongs to. There's nothing you can do from the sender — either use +a source IP that's actually allocated to the lab, or run sender and receiver +on the same L2 segment so the gateway is bypassed. + +### 4. ARP/neighbour resolution dropped the first packet + +With `--routed`, the kernel triggers ARP for the next hop on the first +packet. While ARP resolves the kernel queues 1–2 packets and discards the +rest. If your flow has `--count 150` but the first ~5 packets disappear, +this is why. Pre-warm the neighbour: + +```bash +ping -c 1 -I +``` + +`preflight.py` warns when it can't find a `REACHABLE`/`STALE` entry for the +next hop. + +### 5. Receiver listens but the segment is invalid + +Even if the packet reaches the application's interface, the kernel only +hands it to a TCP socket if: + +- the destination MAC is unicast to that NIC (broadcast TCP frames are + ignored — see `--dst-mac` warnings below), and +- the TCP checksum is valid, and +- a socket is actually `LISTEN`ing on `dest_port`, and +- the segment isn't blocked by `nftables INPUT` (e.g. `conntrack invalid`). + +A quick `ss -ltn 'sport = :5201'` on the receiver tells you whether anything +is listening, and `nstat -az TcpExt | grep -i invalid` reveals +checksum/conntrack drops. + +### 6. L2-mode foot-gun: broadcast destination MAC + +`--dst-mac ff:ff:ff:ff:ff:ff` (the script's default) is fine for forcing +packets onto a wire so an XDP program can intercept them, but a vanilla +Linux receiver will drop them — TCP only accepts unicast frames. If you +want the destination's TCP stack to actually see the packet, set the real +MAC: + +```bash +ip neigh show # find its MAC +./run.sh spoof-l2 ens5f0np0 aa:bb:cc:dd:ee:ff 192.0.2.10 9000 198.51.100.10 +``` + +### 7. NIC features eating the packet + +A few NIC quirks worth knowing: + +- LRO/GRO can coalesce DUP-ACKs and identical SEQ frames; if the + duplication test feels broken, try `ethtool -K gro off lro off`. +- Some drivers (mlx5, ena) refuse to transmit Ethernet frames whose source + MAC is not the device's own. Leave the source MAC unset (Scapy fills in + the device MAC) or set it explicitly to the device MAC. +- Hardware TSO won't kick in for raw socket traffic, but a few drivers + reject odd-length frames; use a payload size ≥ 14 to be safe. + +### Confirming what actually leaves the host + +Whatever the suspicion, the cheapest signal is a packet capture on both +ends: + +```bash +# Sender +sudo tcpdump -ni "host 198.51.100.10" -w /tmp/sender.pcap +# Receiver +sudo tcpdump -ni "host 198.51.100.10" -w /tmp/receiver.pcap +``` + +If the sender pcap shows the flow but the receiver pcap doesn't, blame the +network (BCP38, switch ACLs, MAC filtering). If both pcaps show the flow +but the application is silent, blame the receiver's stack (rp_filter, +firewall, no listener, invalid checksum). + +## Using with openpenny CLI + +```bash +# Receiver: capture payloads +python3 server.py --host 0.0.0.0 --port 9000 + +# openpenny in active mode on the receiver +sudo ./build/openpenny_cli \ + --config examples/configs/config_default.yaml \ + --mode active \ + --prefix 198.51.100.0 --mask-bits 24 \ + --iface ens5f0np0 --queue 0 --tun xdp-tun + +# Sender: spoofed traffic toward the receiver +./run.sh spoof ens5f0np0 198.51.100.20 9000 198.51.100.10 --flows 2 --count 15 +``` + +Watch openpenny logs for drops/duplicates/retransmissions and the server +output for received payloads. Adjust `--prefix`/`--mask-bits` to filter to +your spoofed subnet. + +## Requirements + +- Python 3.8+ +- `scapy` (installed via `./run.sh install` or `pip install -r requirements.txt`) +- `iperf3` for `mixed_traffic.py` +- Root / `CAP_NET_RAW` for anything that opens raw sockets +- Linux iproute2 (`ip`, `nft`/`iptables`) for the preflight checks diff --git a/tools/traffic_generator/__pycache__/client.cpython-310.pyc b/tools/traffic_generator/__pycache__/client.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cfc50354327c92fe0f2561b74bd135bb888caa13 GIT binary patch literal 1983 zcmaJBO>Y}TbY^$GyIwnvlSZXdt1>DDVU>i03nBtaiBhSmNFy9n>Z)3{p0T~@dUrcJ zZsJvp5az%o;sOV#DkVow960e;=E?!JLI}Z$a)9^NPDmxxnVmN~?`z(?uViM%BQPF* z{Y&_zL&(o~I5|u>yay}e061w8PPq}$7AyZI62&flP4;?e9w@}qAI*G8Pm@}bCWc@{EueXPb0y$);3u?3Pr4#>II5r zBIX$jSYKpW&=pKP5ON6Eb`tX?&-)0Gs~&@q4plFSr@~9=kS#9ul1#CcH{M)YhPeWW zG?9wEwYadbK8k40N$OeJ85V5v+Vgj{fjI$tmJ1TlZE zdEOvaVZ_2%2`LAu0^yxd_1Kz{(WSK|?=g6nY&58L+EJ2)aaY??sDX^zNH`i4er#B4 zu(FQvXMF{WQV8X38>XB;?t^pH|=lf;)SqTJzCN=A@7`HQN| zQJy{|+(13Bn|tO%^2k=+c7&3#>Z>F}M0gg&x zoALj)J;%DagZaG@-g5sF(0OY310^^?V-@eZ1#dj@vY95?AuA;NaR+EPZ^7bNFSU*Q z{Ow8n?yyb}Mo{38l+(_Ezfb9EZmcp0yVU{mW31);^s&s8b0XIL?pqu`UEcZ=*}Q!A zm=Drit+Eq>Mcz+t1pK`BAE!#v6gr7S6^(@=bD;j9WoZkGO&WmLc9vi%Y3IsS|Jug- zmaa5!tX=n;TOY1p`2f1nr1fd%Msr=tMCwv1F`vzBn1QU~(9X0o?Vj2eaXaBcoBd!{ zyGPZg9kelsA}H>#FLWu3gh=(=buqk~1d?AnYKl-)7M6x~rmaI;Xq`62Zd)?2R=$J) zi)8}l;7?oqAdJt#U-mu#n^x&*!=N>o7pX_NlX(J=6HB z>bgLwKQR^-ewDAGR@e7YukSZzwB!3cY5Tq`14W)ea2~M%mc$52fKjDK&yo<5RP6Mz^x9n1!V7F0o K-E$tb{`nW^a1!+Z literal 0 HcmV?d00001 diff --git a/tools/traffic_generator/__pycache__/mixed_traffic.cpython-310.pyc b/tools/traffic_generator/__pycache__/mixed_traffic.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d07d828cae779df4a6e8487c1198787a6550dbc2 GIT binary patch literal 4264 zcmZu!NpBm;74D6TniNIJ@-p@;Wqa(H9+Rfzcn}+dL1xCrz=0!Z;si*P1VpQ=C^d)d zW~z&})u1j>Zh-$2sJZ-!Z?SuQ??81p$0XzE|B%wd`rCwpaCDEpPi2EibzU zzTLn6JNU;r!}u2s${)@C6ea!(DsJo;+~n5S+%a`)?O3|CcWkuw*qKyzDyG35QRNj; z<5iRzN?ll^`q1S|-2IcYv&0+19Jx8w;Z42_Y6D|XhZb+~6^xr0*Dz}HRg9KJi?4la z$*;wVSUxh2%s=8B&y3DF@6VQDxKF)V=p_1D|;z zXYn+OhJv%GYTh9ZpAPutBz3D-x=eoa&y*=^JWo&w&_M`9~NfvyNi^3w-?FK{7 z7fgil{b4_x_CevX>P|eL4TlfU{xSN%LFekmLOvIgZ9LfQ^?RGWP2$XErjbb6igxGC%yX{y^THuk(kn~mi3_D@1J3erT-S+#wlO+DEAg} z{Ah;fD+!)@2jj@&?9;QS2||S{d1Kb^^*0yXqd-4)DjV#H zP)JWj@~+Hwo4_{Wo_eXE?=E|G#xOi7SP+hcuW$xWv8S7C76;*;K8$!7#EJ&`POyrF z<@DSW_=&OAF;i#TQ~PN(({<{QHq!bu*JfJhGn^%e!n7)dn#s_2h%x^vXvy=wLWzHd zio9%Ya9bI3^T^~5S_^Fjt$k$fEgd?_4yi9yxOdb%hGAzp~*m zOIv9*bC0Cn?Pe!Ws@*OfFmci5M>XilCyB2&u|$F;!CJ3%QER#T@>fP>y33&ULbLfC--9CM7Q{8)AvyH z3;z=xWhlJt2=VNSacI)ZsfvDUy!)uiZPYdHpswT9D`=PS{#CRtujz3Es9k21_*QB*`6akOd=T=FR=SLUjmO4MWNQJkx zCRewT)psoLO)Za0ZtxefMIZ?_bEt-ym&e3yUX6b1~WNi&?zDfTCL=N#8!LdQ;dtmo2*D3RP{YR;gM; z)v@J8H1Ho^22&-GhcnaH_Ak%VT!TzrA3^ZjAjF@cYMG8j2Je(gJ?~TdZgKuoz3iIn z_{lpE;G--)au0Go?Gm>zQpQN9`B&zl0VB1z%^hCh)mOHejcdy0bxP{5oSf1C1?jv* zX@RnWB!0SP6;jqpQqJ*pex6@gNWF;pOQreuAi?36`IW^k?_>NyX~l>^nMI~55azXL+K z@FwvoW0AQ|J|xBu^v(=5_3oS^ zsg9)BoUES3P=Hj))koMU2z8KQKmX#ZcT<1Ik{e*oZo9I;9eMa;W&S7I@3K>D(|(pk zOdTe#m6?e0E*pHfPrLROTSK*(oIJu_f0hG`IGRbo#vj~#xnR-(>4aP|xp=CYJOE@t zGg&Ow0qcZK%wz;;5%PS|!1>?qWqxm_-b z)Sq3ah=6V_er(L8vhYie=jEgTc7t)C&O*R~dGSD59Cl-XKLH3o6UmLoA*B$eXBg1SboN791-S8if@#{E zfNbhO5^#`Mq^&RH-i#pRHVtLk^f(`2R?|`&&yiN3MNg%!W}vwk{!iHq>$BKS07nOS z`8j$|QR0tLHO;oYX40>1x6L-SdX&*sJn|b6eh4_0iI?JGVy-!k@29LML-aehPXM zbmt7cBEQVnSI*F@aw}h7Ez`MuSeu)pW$rvT<*%uSv6YYcyvA+(x5tSBdJRFgvX$1P zn2zx;7ri20Fp{w(4}r&1v(;(oH_0GhQ^%!27DrPz8Az(!sWb6{aESfmo2aVh1*>j0?IsYxI`BZLSS~>E8j54eZ|J-L zR>V6PblPccFyN6t7{~{h$zTaxHmyzZ&&xR2)lf|1EJ=VP!z>z7FKu}f5yR7jez5H2 z3;7}D0NmVX2eA^9$1ej_I{w1u)B0zVh|k92b5fs%@s3?@*MVD_CbKWJ+t*r`>sMUU H`QQHmVA9Kn literal 0 HcmV?d00001 diff --git a/tools/traffic_generator/__pycache__/preflight.cpython-310.pyc b/tools/traffic_generator/__pycache__/preflight.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3345aab0a41cfb919532a3386ba3b7c8ae33455c GIT binary patch literal 13657 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 literal 0 HcmV?d00001 diff --git a/tools/traffic_generator/__pycache__/server.cpython-310.pyc b/tools/traffic_generator/__pycache__/server.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3bf48ec7e4b2c5a5fbebd6a2c3b1240f6077d8f6 GIT binary patch literal 1783 zcmZWpUvC^W5VyU)-Mzg_o2F??p$`@k5{KFlKp+tzMAQaU6_To#3N)_Ik!Mi(S4ol!dfKot45|@7IWQ|Y9wyl=$G1v)s=c6S(VC&k5^Y$qiDMi zr?=#pHc)7Rx(2zGD)%!9QaI&zoyv8Qq$yPBL|K4H%c6kPWGIf_CBkF_xE{^D1ax~O z@(kt5-Yfp1p+br$0F@SMSQJ^YD~5&1a)D0-rG}LOxf1<>c$3WzwLzMf8ilN{zzkK9 zx`WdX5hFg%n>h7HxEQimhvqpwBong7q$jxx!*h?$=)ia~lua2u=ZjYR zI}VTzaW*BLx(Ko3XhNs7-nEd2JJXg7?a&uQdd}Vp@-sg|{`iZglV6yUN9@o$m=Qxhg&mw$}=-~nCx$(fAf5?eh9(_OKpw*VM`hc9(l75#Q5Y+tftfqAN z*WUcYrSkal!x{YF%!BTV18eMru5FL6zC1fqeLYnUgmStWSwsWZ{u9bG%mN`ZJr@UQ z0|ix{q~OAirB*6Ez7oCE(EXt??nYT26rD?Tq%kqE16b~IQ8B#6`nq%#VT1M0}sGzPE9|yRm-j);-HzMOc59*tSvk z4eQSujOEzb_N}K!8t*c)92(m=P?-ViOGrxzw$aOnx^FMsfzjhKQS$a_$7_@^hIB-)QHCqW zDqwUOckG1mYbWgQcFS=V|71(cf2kE`EsSGlFOEBH8^p0J(>PYHNYoX#yy;{jbjiRo zlV^`-$Tl~V0cdPEP(!cUPW04VfoE}yn2xYf4>q1<=FAIR<*ND_;U|Mq4s-a_rG#F_ Vg?nK$M3=FEy>mhM20q}Qe*y1y=l1{r literal 0 HcmV?d00001 diff --git a/tools/traffic_generator/__pycache__/spoofed_client.cpython-310.pyc b/tools/traffic_generator/__pycache__/spoofed_client.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d048351f49b89fbb91b6ee0bc05fe3fde38eaff8 GIT binary patch literal 15221 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 c= None: + self.fatal.append(msg) + + def add_warn(self, msg: str) -> None: + self.warnings.append(msg) + + def add_info(self, msg: str) -> None: + self.info.append(msg) + + def print(self) -> None: + if self.info: + print(f"{BLD}Diagnostics{RST}") + for m in self.info: + print(f" {DIM}info{RST} {m}") + if self.warnings: + print(f"{BLD}Warnings{RST}") + for m in self.warnings: + print(f" {YEL}warn{RST} {m}") + if self.fatal: + print(f"{BLD}Errors{RST}") + for m in self.fatal: + print(f" {RED}error{RST} {m}") + if not (self.info or self.warnings or self.fatal): + print(f"{GRN}preflight: nothing to report{RST}") + + +def _read_proc(path: str) -> Optional[str]: + try: + with open(path, "r") as f: + return f.read().strip() + except OSError: + return None + + +def _run(cmd: List[str], timeout: float = 2.0) -> Optional[str]: + """Run a command and return its stdout, or None on failure.""" + try: + out = subprocess.run( + cmd, + check=False, + capture_output=True, + text=True, + timeout=timeout, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + if out.returncode != 0: + return None + return out.stdout.strip() + + +def _check_interface(iface: str, report: PreflightReport) -> None: + """Verify that the interface exists and is up.""" + try: + socket.if_nametoindex(iface) + except OSError: + names = ", ".join(n for _, n in socket.if_nameindex()) + report.add_fatal( + f"interface {iface!r} not found" + + (f"; available: {names}" if names else "") + ) + return + + operstate = _read_proc(f"/sys/class/net/{iface}/operstate") + carrier = _read_proc(f"/sys/class/net/{iface}/carrier") + if operstate and operstate != "up": + report.add_warn( + f"{iface}: operstate={operstate} (interface is not up; " + f"run `sudo ip link set {iface} up`)" + ) + if carrier == "0": + report.add_warn(f"{iface}: no carrier (cable unplugged or peer down)") + else: + report.add_info( + f"{iface}: operstate={operstate or '?'}, carrier={carrier or '?'}" + ) + + +def _check_rp_filter(iface: str, report: PreflightReport) -> None: + """Check Reverse Path Filtering on the egress interface and globally. + + Linux applies the maximum of conf..rp_filter and conf.all.rp_filter. + Strict mode (1) drops packets whose source IP is not reachable through + the same interface — which is exactly what spoofed traffic looks like. + """ + iface_rp = _read_proc(f"/proc/sys/net/ipv4/conf/{iface}/rp_filter") + all_rp = _read_proc("/proc/sys/net/ipv4/conf/all/rp_filter") + default_rp = _read_proc("/proc/sys/net/ipv4/conf/default/rp_filter") + + def label(v: Optional[str]) -> str: + if v == "0": + return "off" + if v == "1": + return "strict" + if v == "2": + return "loose" + return v or "?" + + report.add_info( + f"rp_filter: {iface}={label(iface_rp)}, " + f"all={label(all_rp)}, default={label(default_rp)}" + ) + + effective = max(int(iface_rp or 0), int(all_rp or 0)) + if effective == 1: + report.add_warn( + "rp_filter is strict on the sender. The kernel may drop locally " + "generated packets whose source IP is not reachable through the " + "egress interface. To experiment, switch to loose mode:\n" + f" sudo sysctl -w net.ipv4.conf.all.rp_filter=2\n" + f" sudo sysctl -w net.ipv4.conf.{iface}.rp_filter=2" + ) + elif effective == 2: + report.add_info( + "rp_filter is loose on the sender (acceptable for spoofed lab traffic)." + ) + + +def _check_local_source_ip(src_ip: Optional[str], iface: str, report: PreflightReport) -> None: + """Warn if --src-ip is actually a local address (not really spoofed).""" + if not src_ip: + return + addrs = _run(["ip", "-4", "-o", "addr", "show", "dev", iface]) or "" + iface_ips = re.findall(r"inet\s+([\d.]+)/", addrs) + if src_ip in iface_ips: + report.add_info( + f"--src-ip {src_ip} is already configured on {iface}; " + "this is not technically spoofing." + ) + return + + # Check whether src_ip is bound on any interface. + all_addrs = _run(["ip", "-4", "-o", "addr"]) or "" + if re.search(rf"\binet\s+{re.escape(src_ip)}/", all_addrs): + report.add_warn( + f"--src-ip {src_ip} is configured on a different interface than " + f"--iface {iface}. The kernel may rewrite or drop the packet." + ) + + +def _check_destination_route(dest_ip: str, src_ip: Optional[str], iface: str, + report: PreflightReport) -> None: + """Use `ip route get` to see how the kernel will route the destination.""" + cmd = ["ip", "route", "get", dest_ip] + if src_ip: + cmd += ["from", src_ip] + out = _run(cmd) or "" + if not out: + report.add_warn( + f"`ip route get {dest_ip}` returned no result; the destination " + "may not be routable from this host." + ) + return + + first_line = out.splitlines()[0] + report.add_info(f"route: {first_line}") + + m = re.search(r"\bdev\s+(\S+)", first_line) + if m and m.group(1) != iface: + report.add_warn( + f"kernel would normally egress {dest_ip} via {m.group(1)}, " + f"but you asked for --iface {iface}. With --routed and " + "SO_BINDTODEVICE, the packet will leave from {iface} regardless, " + "but the next hop on that link must be reachable from this host." + ) + + +def _check_neighbour(dest_ip: str, iface: str, report: PreflightReport) -> None: + """Confirm that a neighbour entry exists for the next hop.""" + # Find the gateway / next hop for the destination. + route = _run(["ip", "route", "get", dest_ip]) or "" + m = re.search(r"\bvia\s+(\S+)", route) + next_hop = m.group(1) if m else dest_ip + + neigh = _run(["ip", "neigh", "show", next_hop, "dev", iface]) or "" + if not neigh or "FAILED" in neigh.upper(): + report.add_warn( + f"no neighbour entry for next-hop {next_hop} on {iface} " + "(ARP/ND will be triggered on the first packet, which may be lost). " + f"Pre-warm with: ping -c 1 -I {iface} {next_hop}" + ) + else: + report.add_info(f"neighbour: {neigh}") + + +def _check_firewall(report: PreflightReport) -> None: + """Look for nftables/iptables rules that may drop locally generated traffic.""" + if shutil.which("nft"): + out = _run(["nft", "list", "ruleset"]) or "" + if re.search(r"chain\s+output", out, re.IGNORECASE) and \ + re.search(r"\b(drop|reject)\b", out): + report.add_warn( + "nftables OUTPUT chain contains drop/reject rules. " + "Confirm they don't match your spoofed source/destination." + ) + if shutil.which("iptables"): + out = _run(["iptables", "-S", "OUTPUT"]) or "" + non_default = [ + line for line in out.splitlines() + if line and not line.startswith("-P ") and "ACCEPT" not in line + ] + if non_default: + report.add_warn( + "iptables OUTPUT has non-default rules; review with " + "`sudo iptables -S OUTPUT`." + ) + + +def _check_l2_broadcast(dst_mac: str, routed: bool, report: PreflightReport) -> None: + """Broadcast MAC + TCP is a common foot-gun for the L2 mode.""" + if routed: + return + if dst_mac.lower() in ("ff:ff:ff:ff:ff:ff", "ff-ff-ff-ff-ff-ff"): + report.add_warn( + "--dst-mac is broadcast. Most receivers ignore TCP frames whose " + "destination MAC is the broadcast address (the kernel never hands " + "them to a TCP socket). Use the receiver's actual MAC, e.g.\n" + " ip neigh show " + ) + + +def _check_receiver_hint(src_ip: Optional[str], report: PreflightReport) -> None: + """A reminder about the most common cause: rp_filter on the receiver.""" + if not src_ip: + return + try: + addr = ipaddress.ip_address(src_ip) + except ValueError: + return + test_nets = ( + ipaddress.ip_network("192.0.2.0/24"), + ipaddress.ip_network("198.51.100.0/24"), + ipaddress.ip_network("203.0.113.0/24"), + ) + if any(addr in n for n in test_nets): + report.add_info( + f"--src-ip {src_ip} is in a TEST-NET range; the receiver almost " + "certainly has no return route for it. If the receiver runs Linux " + "with rp_filter=1 (the default on many distros) the packet will " + "be silently dropped on arrival. On the *receiver* run:\n" + " sudo sysctl -w net.ipv4.conf.all.rp_filter=2\n" + " sudo sysctl -w net.ipv4.conf..rp_filter=2\n" + " # or temporarily install a return route:\n" + f" sudo ip route add {addr.exploded}/32 dev " + ) + + +def _check_ip_forward(report: PreflightReport) -> None: + val = _read_proc("/proc/sys/net/ipv4/ip_forward") + if val is not None: + report.add_info(f"ip_forward={val}") + + +def _check_root(report: PreflightReport) -> None: + if hasattr(os, "geteuid") and os.geteuid() != 0: + report.add_warn( + "preflight is running as a non-root user. Some checks " + "(neighbour state, firewall) may be incomplete. Re-run with sudo " + "for full visibility." + ) + + +def run_preflight( + iface: str, + dest_ip: str, + src_ip: Optional[str] = None, + routed: bool = False, + dst_mac: str = "ff:ff:ff:ff:ff:ff", + quiet: bool = False, +) -> PreflightReport: + """Run all diagnostics and return a populated report.""" + report = PreflightReport() + _check_root(report) + _check_interface(iface, report) + if report.fatal: + # No point continuing if the interface doesn't exist. + if not quiet: + report.print() + return report + + _check_rp_filter(iface, report) + _check_ip_forward(report) + _check_local_source_ip(src_ip, iface, report) + _check_destination_route(dest_ip, src_ip, iface, report) + _check_neighbour(dest_ip, iface, report) + _check_firewall(report) + _check_l2_broadcast(dst_mac, routed, report) + _check_receiver_hint(src_ip, report) + + if not quiet: + report.print() + return report + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Pre-flight checks for spoofed TCP traffic.", + ) + parser.add_argument("--iface", required=True, help="Egress interface") + parser.add_argument("--dest-ip", required=True, help="Destination IPv4") + parser.add_argument("--src-ip", default=None, help="Spoofed source IPv4") + parser.add_argument("--routed", action="store_true", + help="Use kernel routing (no Ethernet frame crafting).") + parser.add_argument("--dst-mac", default="ff:ff:ff:ff:ff:ff", + help="Destination MAC for L2 mode (ignored with --routed).") + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + report = run_preflight( + iface=args.iface, + dest_ip=args.dest_ip, + src_ip=args.src_ip, + routed=args.routed, + dst_mac=args.dst_mac, + ) + return 1 if report.fatal else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/traffic_generator/run.sh b/tools/traffic_generator/run.sh new file mode 100755 index 0000000..4d0091a --- /dev/null +++ b/tools/traffic_generator/run.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: BSD-2-Clause +# +# Friendly wrapper around the traffic_generator scripts. +# Handles sudo, virtualenv activation, and provides short subcommands. +# +# Quick examples: +# ./run.sh install # set up .venv with scapy +# ./run.sh server 9000 # run server.py on :9000 +# ./run.sh client 192.0.2.1 9000 # run client.py against host:port +# ./run.sh doctor ens5f0np0 192.168.43.3 198.51.100.10 +# # run preflight.py only, no traffic +# ./run.sh spoof ens5f0np0 192.168.43.3 5201 198.51.100.10 +# # routed-mode spoof with sane defaults +# ./run.sh spoof-l2 ens5f0np0 aa:bb:cc:dd:ee:ff 192.0.2.10 9000 198.51.100.10 +# # L2 inject with a unicast MAC +# ./run.sh mixed ens5f0np0 192.0.2.20 198.51.100.20 198.51.100.10 +# # iperf3 + spoofed flows in parallel +# +# Any extra flags after the positional args are forwarded to the underlying +# Python script unchanged, e.g. +# ./run.sh spoof ens5f0np0 192.168.43.3 5201 198.51.100.10 \ +# --flows 10 --count 150 --payload-size 64 --debug + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VENV="${HERE}/.venv" +PY_BIN="${PYTHON:-python3}" + +# Default lab-friendly knobs (override with env vars). +DEFAULT_FLOWS="${DEFAULT_FLOWS:-1}" +DEFAULT_COUNT="${DEFAULT_COUNT:-20}" +DEFAULT_PAYLOAD="${DEFAULT_PAYLOAD:-64}" +DEFAULT_IPERF_PORT="${DEFAULT_IPERF_PORT:-5201}" +DEFAULT_IPERF_PARALLEL="${DEFAULT_IPERF_PARALLEL:-4}" +DEFAULT_IPERF_DURATION="${DEFAULT_IPERF_DURATION:-30}" + +usage() { + cat <<'EOF' +Friendly wrapper around the traffic_generator scripts. +Handles sudo, virtualenv activation, and provides short subcommands. + +Quick examples: + ./run.sh install # set up .venv with scapy + ./run.sh server 9000 # run server.py on :9000 + ./run.sh client 192.0.2.1 9000 # run client.py against host:port + ./run.sh doctor ens5f0np0 192.168.43.3 198.51.100.10 + # run preflight.py only, no traffic + ./run.sh spoof ens5f0np0 192.168.43.3 5201 198.51.100.10 + # routed-mode spoof with sane defaults + ./run.sh spoof-l2 ens5f0np0 aa:bb:cc:dd:ee:ff 192.0.2.10 9000 198.51.100.10 + # L2 inject with a unicast MAC + ./run.sh mixed ens5f0np0 192.0.2.20 198.51.100.20 198.51.100.10 + # iperf3 + spoofed flows in parallel + +Any extra flags after the positional args are forwarded to the underlying +Python script unchanged, e.g. + ./run.sh spoof ens5f0np0 192.168.43.3 5201 198.51.100.10 \ + --flows 10 --count 150 --payload-size 64 --debug +EOF + cat < [extra...] Run server.py. + client [extra...] Run client.py. + doctor [extra...] + Run preflight diagnostics only. + spoof [extra...] + Routed mode (kernel picks the route). + spoof-l2 [extra...] + L2 mode (raw Ethernet, you set dst MAC). + mixed [extra...] + iperf3 + spoofed flows together. + help Print this help. + +Environment overrides: + DEFAULT_FLOWS=$DEFAULT_FLOWS DEFAULT_COUNT=$DEFAULT_COUNT + DEFAULT_PAYLOAD=$DEFAULT_PAYLOAD DEFAULT_IPERF_PORT=$DEFAULT_IPERF_PORT + DEFAULT_IPERF_PARALLEL=$DEFAULT_IPERF_PARALLEL DEFAULT_IPERF_DURATION=$DEFAULT_IPERF_DURATION + PYTHON=python3.11 ./run.sh ... +EOF +} + +ensure_venv() { + if [[ -x "${VENV}/bin/python" ]]; then + return + fi + echo "[run.sh] creating virtualenv at ${VENV}" + "${PY_BIN}" -m venv "${VENV}" + # shellcheck disable=SC1091 + source "${VENV}/bin/activate" + pip install --upgrade pip >/dev/null + pip install -r "${HERE}/requirements.txt" + deactivate +} + +# Build the python invocation. Use sudo only when raw sockets are needed. +# We point sudo at the venv's python so scapy is importable. +py_in_venv() { + local need_sudo="$1"; shift + local py="${VENV}/bin/python" + if [[ ! -x "${py}" ]]; then + py="${PY_BIN}" + fi + if [[ "${need_sudo}" == "1" && "${EUID}" -ne 0 ]]; then + # Preserve PYTHONPATH so the venv's site-packages stays visible. + sudo -E "${py}" "$@" + else + "${py}" "$@" + fi +} + +cmd_install() { + ensure_venv + echo "[run.sh] virtualenv ready: ${VENV}" + echo "[run.sh] activate with: source ${VENV}/bin/activate" +} + +cmd_server() { + local port="${1:?port required}" + shift + py_in_venv 0 "${HERE}/server.py" --host 0.0.0.0 --port "${port}" "$@" +} + +cmd_client() { + local host="${1:?host required}" + local port="${2:?port required}" + shift 2 + py_in_venv 0 "${HERE}/client.py" --host "${host}" --port "${port}" "$@" +} + +cmd_doctor() { + local iface="${1:?iface required}" + local dest_ip="${2:?dest-ip required}" + local src_ip="${3:?src-ip required}" + shift 3 + py_in_venv 1 "${HERE}/preflight.py" \ + --iface "${iface}" \ + --dest-ip "${dest_ip}" \ + --src-ip "${src_ip}" \ + "$@" +} + +cmd_spoof() { + local iface="${1:?iface required}" + local dest_ip="${2:?dest-ip required}" + local dest_port="${3:?dest-port required}" + local src_ip="${4:?src-ip required}" + shift 4 + ensure_venv + py_in_venv 1 "${HERE}/spoofed_client.py" \ + --iface "${iface}" \ + --routed \ + --dest-ip "${dest_ip}" \ + --dest-port "${dest_port}" \ + --src-ip "${src_ip}" \ + --flows "${DEFAULT_FLOWS}" \ + --count "${DEFAULT_COUNT}" \ + --payload-size "${DEFAULT_PAYLOAD}" \ + --preflight \ + "$@" +} + +cmd_spoof_l2() { + local iface="${1:?iface required}" + local dst_mac="${2:?dst-mac required}" + local dest_ip="${3:?dest-ip required}" + local dest_port="${4:?dest-port required}" + local src_ip="${5:?src-ip required}" + shift 5 + ensure_venv + py_in_venv 1 "${HERE}/spoofed_client.py" \ + --iface "${iface}" \ + --dst-mac "${dst_mac}" \ + --dest-ip "${dest_ip}" \ + --dest-port "${dest_port}" \ + --src-ip "${src_ip}" \ + --flows "${DEFAULT_FLOWS}" \ + --count "${DEFAULT_COUNT}" \ + --payload-size "${DEFAULT_PAYLOAD}" \ + --preflight \ + "$@" +} + +cmd_mixed() { + local iface="${1:?iface required}" + local iperf_server="${2:?iperf-server required}" + local spoof_dest_ip="${3:?spoof-dest-ip required}" + local spoof_src_ip="${4:?spoof-src-ip required}" + shift 4 + ensure_venv + py_in_venv 1 "${HERE}/mixed_traffic.py" \ + --iface "${iface}" \ + --iperf-server "${iperf_server}" \ + --iperf-port "${DEFAULT_IPERF_PORT}" \ + --iperf-parallel "${DEFAULT_IPERF_PARALLEL}" \ + --iperf-duration "${DEFAULT_IPERF_DURATION}" \ + --spoof-dest-ip "${spoof_dest_ip}" \ + --spoof-dest-port "${DEFAULT_IPERF_PORT}" \ + --spoof-src-ip "${spoof_src_ip}" \ + --spoof-flows "${DEFAULT_FLOWS}" \ + --spoof-count "${DEFAULT_COUNT}" \ + --spoof-payload "${DEFAULT_PAYLOAD}" \ + "$@" +} + +main() { + local sub="${1:-help}" + shift || true + case "${sub}" in + install) cmd_install ;; + server) cmd_server "$@" ;; + client) cmd_client "$@" ;; + doctor) cmd_doctor "$@" ;; + spoof) cmd_spoof "$@" ;; + spoof-l2) cmd_spoof_l2 "$@" ;; + mixed) cmd_mixed "$@" ;; + help|-h|--help) usage ;; + *) + echo "[run.sh] unknown subcommand: ${sub}" >&2 + usage + exit 2 + ;; + esac +} + +main "$@" diff --git a/tools/traffic_generator/spoofed_client.py b/tools/traffic_generator/spoofed_client.py index 08abd69..f1d3875 100644 --- a/tools/traffic_generator/spoofed_client.py +++ b/tools/traffic_generator/spoofed_client.py @@ -21,11 +21,17 @@ import asyncio import os import random +import socket import sys from dataclasses import dataclass from typing import List, Optional -from scapy.all import IP, TCP, Raw, Ether, sendp # type: ignore +try: + from scapy.all import IP, TCP, Raw, Ether, sendp # type: ignore + _SCAPY_IMPORT_ERROR = None +except ModuleNotFoundError as exc: + IP = TCP = Raw = Ether = sendp = None # type: ignore + _SCAPY_IMPORT_ERROR = exc @dataclass @@ -139,6 +145,15 @@ def parse_args() -> argparse.Namespace: default="ff:ff:ff:ff:ff:ff", help="Destination MAC address for the Ethernet header (default: broadcast).", ) + parser.add_argument( + "--routed", + action="store_true", + help=( + "Send at layer 3 through the kernel routing table instead of " + "injecting raw Ethernet frames. In this mode --dst-mac is ignored " + "and the host resolves the next hop normally." + ), + ) # Random duplication / randomness controls. parser.add_argument( @@ -164,6 +179,24 @@ def parse_args() -> argparse.Namespace: help="Print a one-line summary for each crafted packet.", ) + # Preflight diagnostics. + parser.add_argument( + "--preflight", + action="store_true", + help=( + "Run pre-send diagnostics (rp_filter, route lookup, ARP, firewall, " + "common foot-guns) before sending any traffic." + ), + ) + parser.add_argument( + "--preflight-only", + action="store_true", + help=( + "Run pre-send diagnostics and exit without sending anything. " + "Useful when investigating why spoofed traffic is not delivered." + ), + ) + return parser.parse_args() @@ -207,6 +240,56 @@ def clamp_args(args: argparse.Namespace) -> None: args.duplication_prob = min(max(args.duplication_prob, 0.0), 1.0) +def validate_runtime(args: argparse.Namespace) -> bool: + """Verify that the generator can actually send packets on this host.""" + if _SCAPY_IMPORT_ERROR is not None: + print( + "[spoofed_client] scapy is not installed for this Python interpreter. " + "Install it with `python3 -m pip install scapy` or run the script " + "from the virtualenv that has scapy available.", + file=sys.stderr, + ) + return False + + try: + socket.if_nametoindex(args.iface) + except OSError: + known_ifaces = ", ".join(name for _, name in socket.if_nameindex()) + print( + f"[spoofed_client] interface '{args.iface}' does not exist on this host." + + (f" Available interfaces: {known_ifaces}" if known_ifaces else ""), + file=sys.stderr, + ) + return False + + return True + + +def open_routed_socket(iface: str) -> socket.socket: + """Open a raw IPv4 socket that still uses the kernel routing table.""" + raw_sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW) + raw_sock.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1) + + if hasattr(socket, "SO_BINDTODEVICE"): + raw_sock.setsockopt( + socket.SOL_SOCKET, + socket.SO_BINDTODEVICE, + iface.encode() + b"\0", + ) + + return raw_sock + + +def send_routed_packets(packets: List, iface: str) -> None: + """Send fully formed IP packets through the kernel routing path.""" + with open_routed_socket(iface) as raw_sock: + for pkt in packets: + ip_pkt = pkt.getlayer(IP) + if ip_pkt is None: + raise ValueError("routed send expects packets with an IP layer") + raw_sock.sendto(bytes(ip_pkt), (ip_pkt.dst, 0)) + + def build_flow_params_list(args: argparse.Namespace, rng: random.Random) -> List[FlowParams]: """Derive per-flow parameters from command-line arguments and cache per-flow layers.""" payload = b"X" * args.payload_size @@ -251,7 +334,7 @@ def build_flow_params_list(args: argparse.Namespace, rng: random.Random) -> List return flow_params -def build_syn_packet(flow: FlowParams, eth_layer: Ether): +def build_syn_packet(flow: FlowParams, eth_layer: Optional[Ether]): """Create the SYN packet for a flow.""" ip_layer = flow.ip_layer or IP(dst=flow.dest_ip, src=flow.src_ip) # fallback if needed @@ -262,13 +345,13 @@ def build_syn_packet(flow: FlowParams, eth_layer: Ether): seq=flow.initial_seq, window=64240, ) - syn_pkt = eth_layer / ip_layer / syn_tcp + syn_pkt = (eth_layer / ip_layer / syn_tcp) if eth_layer is not None else (ip_layer / syn_tcp) return syn_pkt def build_data_packets( flow: FlowParams, - eth_layer: Ether, + eth_layer: Optional[Ether], rng: random.Random, duplication_prob: float, ): @@ -298,13 +381,21 @@ def build_data_packets( tcp_layer = base_tcp.copy() tcp_layer.seq = seq - pkt = eth_layer / ip_layer / tcp_layer / payload_layer + pkt = ( + eth_layer / ip_layer / tcp_layer / payload_layer + if eth_layer is not None + else ip_layer / tcp_layer / payload_layer + ) packets.append(pkt) # Optionally enqueue a duplicate with the same SEQ and payload. if duplication_prob > 0.0 and rng.random() < duplication_prob: dup_tcp = tcp_layer # same header, same seq - dup_pkt = eth_layer / ip_layer / dup_tcp / payload_layer + dup_pkt = ( + eth_layer / ip_layer / dup_tcp / payload_layer + if eth_layer is not None + else ip_layer / dup_tcp / payload_layer + ) packets.append(dup_pkt) # Advance the sequence position by the number of payload bytes @@ -314,7 +405,7 @@ def build_data_packets( return packets, seq -def build_fin_packet(flow: FlowParams, eth_layer: Ether, final_seq: int): +def build_fin_packet(flow: FlowParams, eth_layer: Optional[Ether], final_seq: int): """Create the FIN packet for a flow. The FIN consumes one additional sequence number. @@ -329,13 +420,13 @@ def build_fin_packet(flow: FlowParams, eth_layer: Ether, final_seq: int): ack=flow.initial_ack, window=64240, ) - fin_pkt = eth_layer / ip_layer / fin_tcp + fin_pkt = (eth_layer / ip_layer / fin_tcp) if eth_layer is not None else (ip_layer / fin_tcp) return fin_pkt def build_full_flow_packets( flow: FlowParams, - eth_layer: Ether, + eth_layer: Optional[Ether], rng: random.Random, duplication_prob: float, ): @@ -366,8 +457,9 @@ def build_full_flow_packets( async def send_flow( flow: FlowParams, - eth_layer: Ether, + eth_layer: Optional[Ether], iface: str, + routed: bool, base_interval: float, interval_jitter: float, duplication_prob: float, @@ -405,10 +497,14 @@ async def send_flow( f"(flags={flags}, seq={seq})" ) - # Single blocking sendp() in a background thread. - await asyncio.to_thread(sendp, packets, iface=iface, verbose=0, inter=0) + if routed: + await asyncio.to_thread(send_routed_packets, packets, iface) + else: + # Single blocking sendp() in a background thread. + await asyncio.to_thread(sendp, packets, iface=iface, verbose=0, inter=0) if debug: - print(f"[DEBUG][flow={flow.flow_id}] Spoofed TCP flow completed (burst mode)") + mode = "routed" if routed else "burst mode" + print(f"[DEBUG][flow={flow.flow_id}] Spoofed TCP flow completed ({mode})") return # --- SLOW PATH: per-packet paced behaviour --- @@ -420,7 +516,10 @@ async def send_flow( f"[DEBUG][flow={flow.flow_id}] SYN: {syn_pkt.summary()} " f"(seq={flow.initial_seq})" ) - await asyncio.to_thread(sendp, syn_pkt, iface=iface, verbose=0) + if routed: + await asyncio.to_thread(send_routed_packets, [syn_pkt], iface) + else: + await asyncio.to_thread(sendp, syn_pkt, iface=iface, verbose=0) # 2. DATA (+ optional duplicates) data_packets, final_seq_before_fin = build_data_packets( @@ -437,7 +536,10 @@ async def send_flow( f"(seq={seq}, len={len(flow.payload)})" ) - await asyncio.to_thread(sendp, pkt, iface=iface, verbose=0) + if routed: + await asyncio.to_thread(send_routed_packets, [pkt], iface) + else: + await asyncio.to_thread(sendp, pkt, iface=iface, verbose=0) # Compute per-packet pacing with jitter. if base_interval > 0.0 or interval_jitter > 0.0: @@ -456,16 +558,21 @@ async def send_flow( f"[DEBUG][flow={flow.flow_id}] FIN: {fin_pkt.summary()} " f"(seq={fin_seq})" ) - await asyncio.to_thread(sendp, fin_pkt, iface=iface, verbose=0) + if routed: + await asyncio.to_thread(send_routed_packets, [fin_pkt], iface) + else: + await asyncio.to_thread(sendp, fin_pkt, iface=iface, verbose=0) if debug: - print(f"[DEBUG][flow={flow.flow_id}] Spoofed TCP flow completed (paced mode)") + mode = "routed paced mode" if routed else "paced mode" + print(f"[DEBUG][flow={flow.flow_id}] Spoofed TCP flow completed ({mode})") async def run_all_flows( flows: List[FlowParams], - eth_layer: Ether, + eth_layer: Optional[Ether], iface: str, + routed: bool, base_interval: float, interval_jitter: float, duplication_prob: float, @@ -483,6 +590,7 @@ async def run_all_flows( flow=flow, eth_layer=eth_layer, iface=iface, + routed=routed, base_interval=base_interval, interval_jitter=interval_jitter, duplication_prob=duplication_prob, @@ -496,7 +604,8 @@ async def run_all_flows( if debug: print( f"[DEBUG] Starting {len(tasks)} flow task(s) on {iface} " - f"(base_interval={base_interval}, jitter={interval_jitter}, " + f"(mode={'routed' if routed else 'l2'}, " + f"base_interval={base_interval}, jitter={interval_jitter}, " f"flow_start_interval={flow_start_interval})" ) @@ -504,20 +613,53 @@ async def run_all_flows( def main() -> int: + args = parse_args() + clamp_args(args) + # Raw Ethernet injection requires root privileges. if os.geteuid() != 0: print("[spoofed_client] must run as root to send raw packets", file=sys.stderr) return 1 - args = parse_args() - clamp_args(args) + if not validate_runtime(args): + return 1 + + # Optional preflight diagnostics. Imported lazily so the rest of the + # script keeps working in environments where preflight.py is missing. + if args.preflight or args.preflight_only: + try: + from preflight import run_preflight # type: ignore + except ModuleNotFoundError: + print( + "[spoofed_client] preflight.py not found alongside this script; " + "skipping diagnostics.", + file=sys.stderr, + ) + else: + print("[spoofed_client] running preflight diagnostics...") + report = run_preflight( + iface=args.iface, + dest_ip=args.dest_ip, + src_ip=args.src_ip, + routed=args.routed, + dst_mac=args.dst_mac, + ) + if report.fatal: + print( + "[spoofed_client] preflight reported fatal issues; aborting.", + file=sys.stderr, + ) + return 1 + if args.preflight_only: + print("[spoofed_client] preflight-only mode; not sending traffic.") + return 0 # Seeded RNG for reproducible behaviour when desired. rng = random.Random(args.seed) # Shared Ethernet header template. If you need per-flow MAC customisation, # move this into build_flow_params_list() or send_flow(). - eth_layer = Ether(dst=args.dst_mac) + eth_layer = None if args.routed else Ether(dst=args.dst_mac) flows = build_flow_params_list(args, rng) @@ -529,6 +671,17 @@ def main() -> int: f"payload_size={len(f.payload)}, initial_seq={f.initial_seq}, " f"initial_ack={f.initial_ack}" ) + if args.routed and args.dst_mac != "ff:ff:ff:ff:ff:ff": + print("[DEBUG] --routed ignores --dst-mac; kernel routing decides the next hop MAC") + else: + print( + f"[spoofed_client] sending {len(flows)} flow(s) on {args.iface} " + f"(mode={'routed' if args.routed else 'l2'}): " + f"{args.src_ip or ''} -> {args.dest_ip}:{args.dest_port}, " + f"data_packets={args.count}, payload_size={args.payload_size}" + ) + if args.routed and args.dst_mac != "ff:ff:ff:ff:ff:ff": + print("[spoofed_client] note: --routed ignores --dst-mac") # Run all flows concurrently using asyncio. asyncio.run( @@ -536,6 +689,7 @@ def main() -> int: flows=flows, eth_layer=eth_layer, iface=args.iface, + routed=args.routed, base_interval=args.interval, interval_jitter=args.interval_jitter, duplication_prob=args.duplication_prob, @@ -544,6 +698,8 @@ def main() -> int: debug=args.debug, ) ) + if not args.debug: + print("[spoofed_client] completed") return 0 From 8dd7ed4218cff2e895756d53ed7f76898e0fb1d6 Mon Sep 17 00:00:00 2001 From: Petros Gigis Date: Thu, 30 Apr 2026 18:42:29 +0100 Subject: [PATCH 4/8] update .gitignore --- .gitignore | 28 ++++++++++++++++++ .../__pycache__/xdp_attach.cpython-314.pyc | Bin 8054 -> 0 bytes .../__pycache__/client.cpython-310.pyc | Bin 1983 -> 0 bytes .../__pycache__/mixed_traffic.cpython-310.pyc | Bin 4264 -> 0 bytes .../__pycache__/preflight.cpython-310.pyc | Bin 13657 -> 0 bytes .../__pycache__/server.cpython-310.pyc | Bin 1783 -> 0 bytes .../spoofed_client.cpython-310.pyc | Bin 15221 -> 0 bytes .../spoofed_client.cpython-314.pyc | Bin 27576 -> 0 bytes 8 files changed, 28 insertions(+) delete mode 100644 scripts/__pycache__/xdp_attach.cpython-314.pyc delete mode 100644 tools/traffic_generator/__pycache__/client.cpython-310.pyc delete mode 100644 tools/traffic_generator/__pycache__/mixed_traffic.cpython-310.pyc delete mode 100644 tools/traffic_generator/__pycache__/preflight.cpython-310.pyc delete mode 100644 tools/traffic_generator/__pycache__/server.cpython-310.pyc delete mode 100644 tools/traffic_generator/__pycache__/spoofed_client.cpython-310.pyc delete mode 100644 tools/traffic_generator/__pycache__/spoofed_client.cpython-314.pyc diff --git a/.gitignore b/.gitignore index 5d6492f..3d12e7f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,31 @@ .DS_Store .DS_STORE *.swp +*.swo +*~ + +# Python bytecode and caches +__pycache__/ +*.py[cod] +*$py.class + +# Python virtual environments and packaging artefacts +.venv/ +venv/ +env/ +*.egg-info/ +.eggs/ +dist/ + +# Python tooling caches +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.tox/ +.coverage +.coverage.* +htmlcov/ + +# Local environment files +.env +.env.local diff --git a/scripts/__pycache__/xdp_attach.cpython-314.pyc b/scripts/__pycache__/xdp_attach.cpython-314.pyc deleted file mode 100644 index f06e75865409f1443d6dfc67df9b94f820f6de4f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8054 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 diff --git a/tools/traffic_generator/__pycache__/client.cpython-310.pyc b/tools/traffic_generator/__pycache__/client.cpython-310.pyc deleted file mode 100644 index cfc50354327c92fe0f2561b74bd135bb888caa13..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1983 zcmaJBO>Y}TbY^$GyIwnvlSZXdt1>DDVU>i03nBtaiBhSmNFy9n>Z)3{p0T~@dUrcJ zZsJvp5az%o;sOV#DkVow960e;=E?!JLI}Z$a)9^NPDmxxnVmN~?`z(?uViM%BQPF* z{Y&_zL&(o~I5|u>yay}e061w8PPq}$7AyZI62&flP4;?e9w@}qAI*G8Pm@}bCWc@{EueXPb0y$);3u?3Pr4#>II5r zBIX$jSYKpW&=pKP5ON6Eb`tX?&-)0Gs~&@q4plFSr@~9=kS#9ul1#CcH{M)YhPeWW zG?9wEwYadbK8k40N$OeJ85V5v+Vgj{fjI$tmJ1TlZE zdEOvaVZ_2%2`LAu0^yxd_1Kz{(WSK|?=g6nY&58L+EJ2)aaY??sDX^zNH`i4er#B4 zu(FQvXMF{WQV8X38>XB;?t^pH|=lf;)SqTJzCN=A@7`HQN| zQJy{|+(13Bn|tO%^2k=+c7&3#>Z>F}M0gg&x zoALj)J;%DagZaG@-g5sF(0OY310^^?V-@eZ1#dj@vY95?AuA;NaR+EPZ^7bNFSU*Q z{Ow8n?yyb}Mo{38l+(_Ezfb9EZmcp0yVU{mW31);^s&s8b0XIL?pqu`UEcZ=*}Q!A zm=Drit+Eq>Mcz+t1pK`BAE!#v6gr7S6^(@=bD;j9WoZkGO&WmLc9vi%Y3IsS|Jug- zmaa5!tX=n;TOY1p`2f1nr1fd%Msr=tMCwv1F`vzBn1QU~(9X0o?Vj2eaXaBcoBd!{ zyGPZg9kelsA}H>#FLWu3gh=(=buqk~1d?AnYKl-)7M6x~rmaI;Xq`62Zd)?2R=$J) zi)8}l;7?oqAdJt#U-mu#n^x&*!=N>o7pX_NlX(J=6HB z>bgLwKQR^-ewDAGR@e7YukSZzwB!3cY5Tq`14W)ea2~M%mc$52fKjDK&yo<5RP6Mz^x9n1!V7F0o K-E$tb{`nW^a1!+Z diff --git a/tools/traffic_generator/__pycache__/mixed_traffic.cpython-310.pyc b/tools/traffic_generator/__pycache__/mixed_traffic.cpython-310.pyc deleted file mode 100644 index d07d828cae779df4a6e8487c1198787a6550dbc2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4264 zcmZu!NpBm;74D6TniNIJ@-p@;Wqa(H9+Rfzcn}+dL1xCrz=0!Z;si*P1VpQ=C^d)d zW~z&})u1j>Zh-$2sJZ-!Z?SuQ??81p$0XzE|B%wd`rCwpaCDEpPi2EibzU zzTLn6JNU;r!}u2s${)@C6ea!(DsJo;+~n5S+%a`)?O3|CcWkuw*qKyzDyG35QRNj; z<5iRzN?ll^`q1S|-2IcYv&0+19Jx8w;Z42_Y6D|XhZb+~6^xr0*Dz}HRg9KJi?4la z$*;wVSUxh2%s=8B&y3DF@6VQDxKF)V=p_1D|;z zXYn+OhJv%GYTh9ZpAPutBz3D-x=eoa&y*=^JWo&w&_M`9~NfvyNi^3w-?FK{7 z7fgil{b4_x_CevX>P|eL4TlfU{xSN%LFekmLOvIgZ9LfQ^?RGWP2$XErjbb6igxGC%yX{y^THuk(kn~mi3_D@1J3erT-S+#wlO+DEAg} z{Ah;fD+!)@2jj@&?9;QS2||S{d1Kb^^*0yXqd-4)DjV#H zP)JWj@~+Hwo4_{Wo_eXE?=E|G#xOi7SP+hcuW$xWv8S7C76;*;K8$!7#EJ&`POyrF z<@DSW_=&OAF;i#TQ~PN(({<{QHq!bu*JfJhGn^%e!n7)dn#s_2h%x^vXvy=wLWzHd zio9%Ya9bI3^T^~5S_^Fjt$k$fEgd?_4yi9yxOdb%hGAzp~*m zOIv9*bC0Cn?Pe!Ws@*OfFmci5M>XilCyB2&u|$F;!CJ3%QER#T@>fP>y33&ULbLfC--9CM7Q{8)AvyH z3;z=xWhlJt2=VNSacI)ZsfvDUy!)uiZPYdHpswT9D`=PS{#CRtujz3Es9k21_*QB*`6akOd=T=FR=SLUjmO4MWNQJkx zCRewT)psoLO)Za0ZtxefMIZ?_bEt-ym&e3yUX6b1~WNi&?zDfTCL=N#8!LdQ;dtmo2*D3RP{YR;gM; z)v@J8H1Ho^22&-GhcnaH_Ak%VT!TzrA3^ZjAjF@cYMG8j2Je(gJ?~TdZgKuoz3iIn z_{lpE;G--)au0Go?Gm>zQpQN9`B&zl0VB1z%^hCh)mOHejcdy0bxP{5oSf1C1?jv* zX@RnWB!0SP6;jqpQqJ*pex6@gNWF;pOQreuAi?36`IW^k?_>NyX~l>^nMI~55azXL+K z@FwvoW0AQ|J|xBu^v(=5_3oS^ zsg9)BoUES3P=Hj))koMU2z8KQKmX#ZcT<1Ik{e*oZo9I;9eMa;W&S7I@3K>D(|(pk zOdTe#m6?e0E*pHfPrLROTSK*(oIJu_f0hG`IGRbo#vj~#xnR-(>4aP|xp=CYJOE@t zGg&Ow0qcZK%wz;;5%PS|!1>?qWqxm_-b z)Sq3ah=6V_er(L8vhYie=jEgTc7t)C&O*R~dGSD59Cl-XKLH3o6UmLoA*B$eXBg1SboN791-S8if@#{E zfNbhO5^#`Mq^&RH-i#pRHVtLk^f(`2R?|`&&yiN3MNg%!W}vwk{!iHq>$BKS07nOS z`8j$|QR0tLHO;oYX40>1x6L-SdX&*sJn|b6eh4_0iI?JGVy-!k@29LML-aehPXM zbmt7cBEQVnSI*F@aw}h7Ez`MuSeu)pW$rvT<*%uSv6YYcyvA+(x5tSBdJRFgvX$1P zn2zx;7ri20Fp{w(4}r&1v(;(oH_0GhQ^%!27DrPz8Az(!sWb6{aESfmo2aVh1*>j0?IsYxI`BZLSS~>E8j54eZ|J-L zR>V6PblPccFyN6t7{~{h$zTaxHmyzZ&&xR2)lf|1EJ=VP!z>z7FKu}f5yR7jez5H2 z3;7}D0NmVX2eA^9$1ej_I{w1u)B0zVh|k92b5fs%@s3?@*MVD_CbKWJ+t*r`>sMUU H`QQHmVA9Kn diff --git a/tools/traffic_generator/__pycache__/preflight.cpython-310.pyc b/tools/traffic_generator/__pycache__/preflight.cpython-310.pyc deleted file mode 100644 index 3345aab0a41cfb919532a3386ba3b7c8ae33455c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13657 zcmcIr+ix7#dEdG0ZMeMZVp+E1V>^zy7P%B9*|B9wwl0*-M5YvxwqaVvYPe^X9BOxG zb!L_nC#xc@j5aB1v}uFh92HW5qIG%!`cR-K`X?0q1LmO*?L*)`6b1YgrH%UgzPap@ zmYuwGsX24z+|PHu`}yRJj#@eV==hBbeu93Qpjge`ZRLS%IA<1a#21nzk%E{@>_CRR*`#Fep|jMk0AG)yd-Dj zEOK9$Z_9ak47um!k~}U?AomT_z9e5p?giw&DPKWux@?qREuSpEDlf|`@{~M{d8_g} z@^$$JYG0IBynWe1+BS=Mq~H|u*n)o{eh`75GpH*PqA zFT7^pbVSSZZitq9!=a&eUQmu$bKK6l@SRQ|nr_DtUWckI6i7#jX?l03t71+ldt3M( z4JMQm^i)Uqy+*_F{pyU^L1D+)LI=Csb!@x>9z{6~$K9d{P~LJIcHnxQnpnX$+Srz8 zD(7a8rdE#admUfc$~kFSLd@61RT|HCX1jK|gJV_&_n7;NdXXo4921Q2Ik0a`T3@fZX*9My4Jb4^z{+jgs!?k)j+ytAPp zTs|3b`JI(3@2m(<#ap{_d3oh3$`+U26xg2&AWl2A^_u831KO>RqjS4SR`pOYgTBHB zNaJZ`JkGN;9{|;vZFvm@J39}&PqR+ zZQBj8raW6VY(D_<(FoE3k#u+hv9x$zxF8!$98X4M&{TFC%M3Pf5I{1RT*5(`p&bu}rZ$sRLJ4b5!|nm)_O9XWvsi;av`pI6%Yfk3D{~zO zw7mu#1=jmstH*o8z%)k3xfO^F9K?q0^BRe=8eY5Gastr&G!D%V;-w^r1Ww89tgjkz z?JKpH=41@}yd#LQ6ZK{k+}qJ)zF8wNqO4odF=G_31s@Y&D=e$uie+6 ztK^n*pK5@)s-Pw+knrlrPXTVyY^!vH@ayrn$#NQo5!`E_dKIb3W21?W4 z`*_}Cv@vsmu~SV;36Zt|xTQ!_RWmX@Fr>(Y!X=|TVB&V8;^k#V7y$PurjNAd(5{(L zSaoU-ndLRE(Y^^mulPCu;Z1xO~RR;#LjS`Z}@@4x>$839#6V*({@`u<T2#W!UvmA*tfPR`lzcUvDU269X z9F6f&4sFc);I(%k_HN^-?&ZN_bD!ukf7j^iYFZZh;LUe+7}Ukbc&zuq59z>yf-IvY zI3Drr+eY?1!rx}!P%C{yR&b;%7`Mo6NBhLZ$2dQJ7Y09gy(uSP@UCTTChzJjf-wuq zN@>6Drkq0GeSNd*QSNR&ZB^YL9(mVMZgVHr;z?Z|SZ&TN@C>M>z#3??y>2bkXT&bb zCH$qwC$U8A;nzX89~Q6aw+STryP(LMI?*4CV?M~L5$Uy~kyeki&fb$PpCZHiR#%+~ zG1lkAxb*KG%!SX@858T-(rTyJpz_bu20g5-R)iMo`AtH;aP&1{Zvm}IuRbXhNztWn zd=6Ul94myd9fG;gngd&a9RQs=EX_5@gmqM;_qt)_q{ymHhqGdOFb|+YR&cNT8mX<~ zy5Ey9d%AE2bZ)@>4@8(p<@IW$9Sx6=)PZ`;1B>YftNFc7x7Ax;hr$d!y6eF9liqfx z`apXLO?*X_q$;jpPEIUoz|O0A#cIbQ z^~sZP@S}3JwWyd(xA#yc_5#0${yyG0Pb@O^tB5P)R{`sT6;J9#;#!HF6hnkX z6t&*+)?h2`z*rRcU2;3UTbbogblA3Ux$RyXPA2g0PP2B3+1Qy{=1Pg}EvmvegJQCZ zg;*Z=Sj6;v^_Ti|7?C04cDr+UC|ZmUY#G|XzklyXWDPeqh%q>~U|qC!Pyjzb>UKyy zY2+O9**g}mD?Btuf`ZA92+!?qotUHj5ea5n3!ne8R@mxLv+9>MOAMbu(@5J=^FXjz zdm^=E7b7D%MmqK~F|rwCNCe~H3sMIX011(=uz1Zu)aq(V7O1>?Ko)@tr6qL*eITux z%}ARkaYi1lNIMqg0RZ1o-$8ZE{xr3k=3`1@{UQ(cimtJ*Y}lO!T-;#{g0GVd z{GHT63dWWCLci!Hd^Ae2OJ*-cU@{MQ1P%-xp#zn~-*y9F7xZ(6ANvYVGBy%&GoJ+m z2il3*ZFpXS8sSlm>0}o%oIduebqXs30y=%{GeU*%;w~7)>qsa%aVJjOaMs3tKz1dk z$d5xwUCGrNDe`>eyU`EanAtNa+OWFqTaf|5!rZ!A9AeZZCi}YT!jx{c=xwD|z3Qs# z)Vu67o8-=eUyWq&5L%_YJKM?cq28|{nJAWkVNjFGQ?N_Gx$&3}59bN%piY^E|0z8z z6)9grx!R<83bEIvK@vax6_Cjc(;wrbpY!4CSSC&mHAzA5RroOqAQ`No>waQ}dJ3_{ytRzYbkF*ZX{^uI{A4UH>`kQhLX`WOxv>k`S zmA|j6OBkh)j55KaOb$k&HC3m=b7UzBw>j`^0sq0jU@ifz?HIvD>SCQH@eZy?b;;r4 zKqKAlKG0`R;PE1p+QPZ9;cZ8TbhcQ*2q)n753cz~ra!nQYabq|&OIn+uC(wl(VX2P zi(-d)ySonc8ahBngwMSO0zozY(cFR4*dUK;wg8Bg8W4*7OR7ythY}A-SiZKF%=$o^ z3tyrkh#&@o+qnAxrYO1`7$`At-*x~EMl%u4e1Y!a(N=B%>j26LxwA-ArbA+#Y&il|aAVQfhzgDm>%oc9x}a{*VuzFvf*q8Q7{%Q#vKXPxrF&8NV8R2L z1Jz@%He7)Jh!Oo~k(ki<4@2o71fC#dHYbe3%tlEKVI=-8nngwOccNp1w9E8I7@XN* ze}fOUzl?rv-2{$mY7DA2-s}1|bGJ>{rqC?9`(z$kNZx;@Z$J|%Fzyx;&4Co~JXx%= z{CEyJhQZhkcS@|F2PTY#k$wRbQ0y1g)qW9pTe+|GbyoRlyip!+EUh6dCv!qh+%2Zc zI~f!7R42m=@4#Pv9hxWmLdk1=op`73IB+s=^N}T9_-z-LB5^A^$RQtpjgczKb7f>; zjL0PKZH&-kyG9q`;`PLm1l~fgg(n<-M#9})W3u2NJcfIk#$~0KUBZmyU*?|419!Eaz; zbiJ2h_8%nIAF1z})u-jl^>d3$7gjD`Sa^5w{6c*3k=z0>s9KJ_<>00RUhMBOn%>4< z*KH+n>4RGznveFJ-7&C$fF8sqK)VMAKW#nJtM=?YAs{9X7}2=S3((Q=WA{sdt*b30 zQGOlJ=~hdsPer?_D7Ka_QZtLMGU_yhdXZZ05eJ778X7wFI)nXR(b)e6l8Ql!JE`xa z)Dsm|bg1+2@0i+tR_GxX9;!QNx=Rd@B5&bgMgB*~kRne4fJg|$QkG`I_@SUP<2Th~ zeG_B>OSS`*A8eg^6lJNOhj7r=;i>F?9lfBSt64Dh0$JM-6>osa6Y=CpNHCKh<#dcn zVr5@Wfc!`ljQ2B=qcRZ6H_sDCRBap@U0Mw;Z3LL2+JO$jCU1H#oE~dT8|pSTrhbhQ zVi<&}z`I?T3cHG~fmD|o-9!=^P z{ZND>*-B1|r4$J>bW^`h3(xo9xmS#ujE-o=*q5>I7#xIWQHgd@$1c=IwDCP?muex| zq@SUYPZsKaqXNg_A)uJ4jcIV@{DPj+!~F&I161!)(G3)ktAQjG zhkP08EcYB??pp!pO`hs#xOj;o0QfMI`(cF@vg%og_;hJ z?JHt5>rfQwmIA_c5`c!=_P*v%!uZ#=GzD+k5A~a{ysCO=%w7>u;q3YI3s+WfrDefI zqXIGyHsG!O7Dgi1Hu&8hoU=}m!<%k-&lF+q_~bh;z-5cSUzBuypz~&)C6E-y3k(da zLI3dWbeCzIKzxzh!PO~Wuf=eL56Ibaoo%MM%omiP!R&I<_d4u>64av&Tv4~8JR8xh zM5&DYB5U!@rOQ_r&Yy)>`bn%?eGdsBOpY7HjD7?$1@&b1y*nRIn0&gWpQ9J^b_--S z0j>^5jjn^YTjm5*fBb>VCyM^h%!2wuw1<`iB?&+Jg>EjJ1e=~0%&x@kclS^~aPjmh7R1eW~(%te)*)ipXLNJxFsViJzEy zKSt3mCmkI!$veC?|9i-!Cf>kL{sZ!pTkI!4kX=sTz~Sr8LB_)?&2JzAzs>tv)3}Y> z$=}K~H8K!CD)n{pq~Foh8E}I<3`X*gm%g9dq&vv+9y87g&z%JX+vMs*xJ(vM!SPQC z4si?#{Jm-5QR>}Dp;%iAD-`l@w1#Ky*okm_{?#nRfeXft9X*cf`FW&h@CANGa5=u) z@qJtSKFDAz_ejTg%&ycWbo*3elPxA)zEOJ{r8DH0Swbd!Avv#fv31;521C3lb(1TF z*7jZWBWn-AOu)8<<(1i`g%!k>b=I9&BTt8c(HVotQ6vmt)W)0)u;kz~rgbJdH$Y@b za~GzPi`!59l*E1qpglbw2l}M!5EP8-CWsQca`gNv4>PP(Ls9W@Khzpy3zLpX}%Jg#OdWkn#@yJjA!m^)xjF836U46!YXt3X-4hfvUz*_|zkuw%93n zJycd1jtugrA>v=b=&}B(Gi!?s#J%m6d6UT=2VGyK4HDO)4dDXfq54aF?Aioc3{kM8;Euduh+1EW1sI*gZ)Zavb+g(>n>Rzcf%uDt)&@GMm= zJa(1t!U3t_0N|k;aa(k~Zm)$4K)NKStKwR9lw%nf0DO7H0Tgjq#3r`QIHky~#1=8z zEUZ5%5~7O823FHslxNe4$(4mPi)(T;J@P_zoLGqCVKW~lw4oA3J#$=vx#6_BA>6H`!a-LGCoUk92C^<* z**X!HQeSMiZ#jwl&f=T5PGB@R30bpOe-GGD^F#}V^??xBdZ2nvc$8GRMDe3)qIUR; z#RAkj#2`~3HeH%E5K>Nc)f&bv#37#Xu(ZTD^piYkyWb<}cMWHCn2w-4AB_)G2Oq$Bk~s|`XtGSf*! z6dq0(WIvQR4-<;Ks=E3oR4WI`F8m^MkN5)1hoi^@Iq(FqSHdMca0_k1_rD3+-^Blh zK#U6{2ZG8D(sdh0?VC&Kb@ihthn#|O>`5tJ0b%`@6IunefWApve~J#r(|;x$T6Q8G!%JQCD1 zN0^`jR@Tc|Jx6$zVzEwbe@RO#W-B}r^Oj$sPvS%5K3}~K^GjC8yo>uYEBli#+ehN= z=QTZQpXO35R4!J>hW5$Fy=O;*c?NSc^W?cFX#oVoG&b(8YbKcR%J@ZYE0RZ8AKf^gU!AG|1yAclL^pgK~3qC&7k-Ze$sRFPOz z3ta4y_}z($PG`$iUWdJgQ6Y{mR@2n(1xl)vknyZY8B@$1rz<6E(t5(0whkhlvL3TWt;1G9uf%~_l?Rrv#!3}(DE}YnUQVq5 diff --git a/tools/traffic_generator/__pycache__/server.cpython-310.pyc b/tools/traffic_generator/__pycache__/server.cpython-310.pyc deleted file mode 100644 index 3bf48ec7e4b2c5a5fbebd6a2c3b1240f6077d8f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1783 zcmZWpUvC^W5VyU)-Mzg_o2F??p$`@k5{KFlKp+tzMAQaU6_To#3N)_Ik!Mi(S4ol!dfKot45|@7IWQ|Y9wyl=$G1v)s=c6S(VC&k5^Y$qiDMi zr?=#pHc)7Rx(2zGD)%!9QaI&zoyv8Qq$yPBL|K4H%c6kPWGIf_CBkF_xE{^D1ax~O z@(kt5-Yfp1p+br$0F@SMSQJ^YD~5&1a)D0-rG}LOxf1<>c$3WzwLzMf8ilN{zzkK9 zx`WdX5hFg%n>h7HxEQimhvqpwBong7q$jxx!*h?$=)ia~lua2u=ZjYR zI}VTzaW*BLx(Ko3XhNs7-nEd2JJXg7?a&uQdd}Vp@-sg|{`iZglV6yUN9@o$m=Qxhg&mw$}=-~nCx$(fAf5?eh9(_OKpw*VM`hc9(l75#Q5Y+tftfqAN z*WUcYrSkal!x{YF%!BTV18eMru5FL6zC1fqeLYnUgmStWSwsWZ{u9bG%mN`ZJr@UQ z0|ix{q~OAirB*6Ez7oCE(EXt??nYT26rD?Tq%kqE16b~IQ8B#6`nq%#VT1M0}sGzPE9|yRm-j);-HzMOc59*tSvk z4eQSujOEzb_N}K!8t*c)92(m=P?-ViOGrxzw$aOnx^FMsfzjhKQS$a_$7_@^hIB-)QHCqW zDqwUOckG1mYbWgQcFS=V|71(cf2kE`EsSGlFOEBH8^p0J(>PYHNYoX#yy;{jbjiRo zlV^`-$Tl~V0cdPEP(!cUPW04VfoE}yn2xYf4>q1<=FAIR<*ND_;U|Mq4s-a_rG#F_ Vg?nK$M3=FEy>mhM20q}Qe*y1y=l1{r diff --git a/tools/traffic_generator/__pycache__/spoofed_client.cpython-310.pyc b/tools/traffic_generator/__pycache__/spoofed_client.cpython-310.pyc deleted file mode 100644 index d048351f49b89fbb91b6ee0bc05fe3fde38eaff8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15221 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 c= Date: Thu, 30 Apr 2026 20:08:18 +0100 Subject: [PATCH 5/8] update flow lookups using fixed size hashmaps --- CMakeLists.txt | 4 ++ include/openpenny/agg/FlowKey.h | 64 +++++++++++++++++++ include/openpenny/agg/Stats.h | 29 +-------- .../app/core/OpenpennyPipelineDriver.h | 2 +- .../openpenny/app/core/PassiveTestPipeline.h | 12 ++-- include/openpenny/app/core/PerThreadStats.h | 2 +- include/openpenny/net/Packet.h | 6 +- .../openpenny/penny/flow/engine/FlowEngine.h | 6 +- .../penny/flow/manager/ThreadFlowManager.h | 9 +-- .../penny/flow/timer/ThreadFlowEventTimer.h | 2 +- src/app/core/passive/PassiveTestPipeline.cpp | 16 ++++- src/app/core/utils/FlowDebug.cpp | 22 ++++++- src/net/PacketParser.cpp | 1 + src/net/TrafficMatch.cpp | 14 ++-- src/penny/flow/manager/ThreadFlowManager.cpp | 12 +++- tests/unit/flow/test_gap_management.cpp | 2 +- .../flow/test_initial_flow_monitoring.cpp | 6 +- tests/unit/net/test_packet_parser.cpp | 1 + tests/unit/net/test_traffic_match.cpp | 7 ++ 19 files changed, 161 insertions(+), 56 deletions(-) create mode 100644 include/openpenny/agg/FlowKey.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f715c38..4bc6f1a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -178,6 +178,10 @@ add_executable(test_traffic_match tests/unit/net/test_traffic_match.cpp) target_link_libraries(test_traffic_match PRIVATE openpenny) add_test(NAME traffic_match COMMAND test_traffic_match) +add_executable(test_packet_parser tests/unit/net/test_packet_parser.cpp) +target_link_libraries(test_packet_parser PRIVATE openpenny) +add_test(NAME packet_parser COMMAND test_packet_parser) + add_executable(test_control_planner tests/unit/control/test_control_planner.cpp) target_link_libraries(test_control_planner PRIVATE openpenny) add_test(NAME control_planner COMMAND test_control_planner) diff --git a/include/openpenny/agg/FlowKey.h b/include/openpenny/agg/FlowKey.h new file mode 100644 index 0000000..4d3e295 --- /dev/null +++ b/include/openpenny/agg/FlowKey.h @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: BSD-2-Clause + +#pragma once + +#include +#include +#include +#include + +namespace openpenny { + +struct FlowKey { + /** + * @brief Protocol-aware flow tuple in host byte order. + * + * Encodes IPv4 source/destination, L4 ports, and the IPv4 protocol + * number so TCP/UDP traffic with the same addresses/ports do not + * alias to the same key. + */ + std::uint32_t src{0}; + std::uint32_t dst{0}; + std::uint16_t sport{0}; + std::uint16_t dport{0}; + std::uint8_t ip_proto{0}; + + bool operator==(const FlowKey& o) const noexcept { + return src == o.src && + dst == o.dst && + sport == o.sport && + dport == o.dport && + ip_proto == o.ip_proto; + } +}; + +struct FlowKeyHash { + /** + * @brief Mix all FlowKey fields into a single hash using 64-bit avalanching. + */ + std::size_t operator()(const FlowKey& k) const noexcept { + const std::uint64_t addr_pair = + (static_cast(k.src) << 32) | k.dst; + const std::uint64_t ports_proto = + (static_cast(k.sport) << 24) | + (static_cast(k.dport) << 8) | + static_cast(k.ip_proto); + + std::uint64_t v = + addr_pair ^ (ports_proto + 0x9e3779b97f4a7c15ULL + + (addr_pair << 6) + (addr_pair >> 2)); + v ^= (v >> 33); + v *= 0xff51afd7ed558ccdULL; + v ^= (v >> 33); + v *= 0xc4ceb9fe1a85ec53ULL; + v ^= (v >> 33); + return static_cast(v); + } +}; + +template +using FlowMap = std::unordered_map; + +using FlowSet = std::unordered_set; + +} // namespace openpenny diff --git a/include/openpenny/agg/Stats.h b/include/openpenny/agg/Stats.h index e735282..7fe61a8 100644 --- a/include/openpenny/agg/Stats.h +++ b/include/openpenny/agg/Stats.h @@ -5,41 +5,18 @@ * @file Stats.h * @brief Per-flow and aggregated statistics with a striped hash table. */ +#include "openpenny/agg/FlowKey.h" + #include #include #include #include -#include #include #include #include namespace openpenny { -struct FlowKey { - /** - * @brief Tuple identifying a TCP/UDP flow in host byte order. - */ - uint32_t src; uint32_t dst; uint16_t sport; uint16_t dport; - bool operator==(const FlowKey& o) const noexcept { - return src==o.src && dst==o.dst && sport==o.sport && dport==o.dport; - } -}; - -struct FlowKeyHash { - /** - * @brief Mix all FlowKey fields into a single hash using 64-bit avalanching. - */ - size_t operator()(const FlowKey& k) const noexcept { - uint64_t v = (static_cast(k.src) << 32) ^ k.dst; - v ^= (static_cast(k.sport) << 16) ^ k.dport; - v ^= (v >> 33); v *= 0xff51afd7ed558ccdULL; - v ^= (v >> 33); v *= 0xc4ceb9fe1a85ec53ULL; - v ^= (v >> 33); - return static_cast(v); - } -}; - /** * @brief Per-flow counters that mirror the BPF-side stats exposed to users. */ @@ -91,7 +68,7 @@ class FlowTable { private: struct Shard { mutable std::shared_mutex mutex; - std::unordered_map map; + FlowMap map; }; std::vector shards_; FlowKeyHash hash_; diff --git a/include/openpenny/app/core/OpenpennyPipelineDriver.h b/include/openpenny/app/core/OpenpennyPipelineDriver.h index e8bca0b..cb55a6b 100644 --- a/include/openpenny/app/core/OpenpennyPipelineDriver.h +++ b/include/openpenny/app/core/OpenpennyPipelineDriver.h @@ -3,7 +3,7 @@ #pragma once #include "openpenny/config/Config.h" -#include "openpenny/agg/Stats.h" +#include "openpenny/agg/FlowKey.h" #include "openpenny/egress/PacketSink.h" #include "openpenny/penny/flow/state/PennySnapshot.h" #include "openpenny/penny/flow/state/PacketDropId.h" diff --git a/include/openpenny/app/core/PassiveTestPipeline.h b/include/openpenny/app/core/PassiveTestPipeline.h index 64d2df7..26167d4 100644 --- a/include/openpenny/app/core/PassiveTestPipeline.h +++ b/include/openpenny/app/core/PassiveTestPipeline.h @@ -2,7 +2,7 @@ #pragma once -#include "openpenny/agg/Stats.h" +#include "openpenny/agg/FlowKey.h" #include "openpenny/app/core/OpenpennyPipelineDriver.h" #include "openpenny/app/core/PipelineRunner.h" #include "openpenny/config/Config.h" @@ -13,8 +13,6 @@ #include #include #include -#include -#include #include namespace openpenny { @@ -75,17 +73,19 @@ class PassiveTestPipelineRunner : public IPipelineStrategy { void finalize(ModeResult& result) override; private: + void reserve_for_config(); + const Config& cfg_; const PipelineOptions& opts_; FlowMatcher matcher_; net::PacketSourcePtr source_; - std::unordered_map flows_; + FlowMap flows_; std::chrono::steady_clock::time_point start_time_{std::chrono::steady_clock::now()}; std::size_t flows_seen_{0}; std::size_t flows_finished_{0}; std::vector finished_flows_; - std::unordered_map finished_index_; - std::unordered_set finished_keys_; + FlowMap finished_index_; + FlowSet finished_keys_; bool stop_grace_active_{false}; std::chrono::steady_clock::time_point stop_grace_start_{}; bool stop_requested_{false}; diff --git a/include/openpenny/app/core/PerThreadStats.h b/include/openpenny/app/core/PerThreadStats.h index d897da4..b3fe671 100644 --- a/include/openpenny/app/core/PerThreadStats.h +++ b/include/openpenny/app/core/PerThreadStats.h @@ -6,7 +6,7 @@ #include #include -#include "openpenny/agg/Stats.h" // for FlowKey +#include "openpenny/agg/FlowKey.h" #include "openpenny/penny/flow/state/PacketDropId.h" namespace openpenny::app { diff --git a/include/openpenny/net/Packet.h b/include/openpenny/net/Packet.h index 218e5e1..35e7503 100644 --- a/include/openpenny/net/Packet.h +++ b/include/openpenny/net/Packet.h @@ -2,7 +2,7 @@ #pragma once -#include "openpenny/agg/Stats.h" // for FlowKey +#include "openpenny/agg/FlowKey.h" #include "openpenny/dataplane/Session.h" #include "openpenny/penny/flow/state/PacketDropId.h" @@ -104,9 +104,9 @@ struct TcpHeaderView { * All pointers into the packet buffer are valid only during the handler call. */ struct PacketView { - FlowKey flow{}; ///< Flow identifier (5-tuple or 4-tuple depending on source). + FlowKey flow{}; ///< Protocol-aware flow identifier (IPv4 src/dst, L4 ports, IP proto). TcpHeaderView tcp{}; ///< Minimal parsed TCP header subset. - uint8_t ip_proto{0}; ///< IPv4 protocol number (TCP=6, UDP=17, etc.). + uint8_t ip_proto{0}; ///< IPv4 protocol number (TCP=6, UDP=17, etc.); mirrors flow.ip_proto. uint64_t payload_bytes{0}; ///< L4 payload length (0 for pure ACKs or empty payloads). uint64_t timestamp_ns{0}; ///< Packet capture timestamp in nanoseconds. diff --git a/include/openpenny/penny/flow/engine/FlowEngine.h b/include/openpenny/penny/flow/engine/FlowEngine.h index 5d59cff..3b41c3b 100644 --- a/include/openpenny/penny/flow/engine/FlowEngine.h +++ b/include/openpenny/penny/flow/engine/FlowEngine.h @@ -152,10 +152,10 @@ class FlowEngine { // Flow identity // --------------------------------------------------------------------- - /// Attach the 5-tuple (or equivalent) key to this flow. + /// Attach the protocol-aware flow key to this flow. void set_flow_key(const FlowKey& key) noexcept { flow_key_ = key; } - /// Return the flow key (5-tuple) associated with this FlowEngine. + /// Return the protocol-aware flow key associated with this FlowEngine. FlowKey flow_key() const noexcept { return flow_key_; } // --------------------------------------------------------------------- @@ -432,7 +432,7 @@ class FlowEngine { // Flow identity // --------------------------------------------------------------------- - FlowKey flow_key_{}; ///< 5-tuple (or equivalent) identifying this flow. + FlowKey flow_key_{}; ///< Protocol-aware tuple identifying this flow. }; } // namespace openpenny::penny diff --git a/include/openpenny/penny/flow/manager/ThreadFlowManager.h b/include/openpenny/penny/flow/manager/ThreadFlowManager.h index 488f368..b098c5f 100644 --- a/include/openpenny/penny/flow/manager/ThreadFlowManager.h +++ b/include/openpenny/penny/flow/manager/ThreadFlowManager.h @@ -2,14 +2,13 @@ #pragma once +#include "openpenny/agg/FlowKey.h" #include "openpenny/penny/flow/engine/FlowEngine.h" #include "openpenny/penny/flow/state/PennyStats.h" #include "openpenny/net/Packet.h" #include "openpenny/app/core/PerThreadStats.h" #include -#include -#include #include #include #include @@ -254,6 +253,8 @@ class ThreadFlowManager { } private: + void reserve_for_config(const Config::ActiveConfig& cfg); + /** * @brief Count how many flows are currently considered "active". * @@ -279,10 +280,10 @@ class ThreadFlowManager { PennyStats stats_{}; /// Map from flow key to the corresponding FlowEngineEntry for active or tracked flows. - std::unordered_map table_active_flows_; + FlowMap table_active_flows_; /// Set of flow keys that have already been fully processed / completed. - std::unordered_set table_completed_flows_; + FlowSet table_completed_flows_; FlowEngine::DropSnapshotSink drop_sink_{}; }; diff --git a/include/openpenny/penny/flow/timer/ThreadFlowEventTimer.h b/include/openpenny/penny/flow/timer/ThreadFlowEventTimer.h index 516cbb4..6198c5f 100644 --- a/include/openpenny/penny/flow/timer/ThreadFlowEventTimer.h +++ b/include/openpenny/penny/flow/timer/ThreadFlowEventTimer.h @@ -2,7 +2,7 @@ #pragma once -#include "openpenny/agg/Stats.h" // for FlowKey +#include "openpenny/agg/FlowKey.h" #include "openpenny/penny/flow/state/PacketDropId.h" #include diff --git a/src/app/core/passive/PassiveTestPipeline.cpp b/src/app/core/passive/PassiveTestPipeline.cpp index 4843516..982a052 100644 --- a/src/app/core/passive/PassiveTestPipeline.cpp +++ b/src/app/core/passive/PassiveTestPipeline.cpp @@ -31,7 +31,21 @@ PassiveTestPipelineRunner::PassiveTestPipelineRunner(const Config& cfg, : cfg_(cfg), opts_(opts), matcher_(std::move(matcher)), - source_(std::move(source)) {} + source_(std::move(source)) { + reserve_for_config(); +} + +void PassiveTestPipelineRunner::reserve_for_config() { + if (cfg_.passive.max_parallel_flows > 0) { + flows_.reserve(cfg_.passive.max_parallel_flows); + } + + if (cfg_.passive.min_number_of_flows_to_finish > 0) { + finished_flows_.reserve(cfg_.passive.min_number_of_flows_to_finish); + finished_index_.reserve(cfg_.passive.min_number_of_flows_to_finish); + finished_keys_.reserve(cfg_.passive.min_number_of_flows_to_finish); + } +} std::optional PassiveTestPipelineRunner::run() { PipelineRunner runner(cfg_, diff --git a/src/app/core/utils/FlowDebug.cpp b/src/app/core/utils/FlowDebug.cpp index ccc4628..4f8686a 100644 --- a/src/app/core/utils/FlowDebug.cpp +++ b/src/app/core/utils/FlowDebug.cpp @@ -6,6 +6,21 @@ namespace openpenny { +namespace { + +std::string proto_label(std::uint8_t proto) { + switch (proto) { + case 6: + return "tcp"; + case 17: + return "udp"; + default: + return std::to_string(static_cast(proto)); + } +} + +} // namespace + std::string to_ipv4_string(uint32_t host_order_ip) { std::ostringstream out; out << ((host_order_ip >> 24) & 0xff) << '.' @@ -18,9 +33,14 @@ std::string to_ipv4_string(uint32_t host_order_ip) { std::string flow_debug_details(const FlowKey& flow) { const auto src_ip = to_ipv4_string(flow.src); const auto dst_ip = to_ipv4_string(flow.dst); + const bool have_proto = flow.ip_proto != 0; std::string tag; - tag.reserve(src_ip.size() + dst_ip.size() + 16); + tag.reserve(src_ip.size() + dst_ip.size() + (have_proto ? 24 : 16)); tag.push_back('{'); + if (have_proto) { + tag.append(proto_label(flow.ip_proto)); + tag.push_back('-'); + } tag.append(src_ip); tag.push_back('-'); tag.append(dst_ip); diff --git a/src/net/PacketParser.cpp b/src/net/PacketParser.cpp index e889a16..41239ea 100644 --- a/src/net/PacketParser.cpp +++ b/src/net/PacketParser.cpp @@ -170,6 +170,7 @@ bool PacketParser::decode(const uint8_t* frame, std::size_t length, PacketView& view.flow.dst = dst; view.flow.sport = sport; view.flow.dport = dport; + view.flow.ip_proto = proto; view.ip_proto = proto; diff --git a/src/net/TrafficMatch.cpp b/src/net/TrafficMatch.cpp index 8c25bf8..a03858c 100644 --- a/src/net/TrafficMatch.cpp +++ b/src/net/TrafficMatch.cpp @@ -39,9 +39,7 @@ bool ip_matches(std::uint32_t value, const TrafficIpPrefix& prefix) { return (value & prefix.mask_host) == (prefix.prefix_host & prefix.mask_host); } -bool rule_matches_flow(const TrafficMatchRule& rule, const FlowKey& key) { - if (!rule.enabled) return false; - +bool rule_matches_endpoints(const TrafficMatchRule& rule, const FlowKey& key) { if (rule.src_ip && !ip_matches(key.src, *rule.src_ip)) return false; if (rule.dst_ip && !ip_matches(key.dst, *rule.dst_ip)) return false; @@ -51,8 +49,16 @@ bool rule_matches_flow(const TrafficMatchRule& rule, const FlowKey& key) { return true; } +bool rule_matches_flow(const TrafficMatchRule& rule, const FlowKey& key) { + if (!rule.enabled) return false; + if (!rule_matches_endpoints(rule, key)) return false; + if (rule.ip_proto && key.ip_proto != *rule.ip_proto) return false; + return true; +} + bool rule_matches_packet(const TrafficMatchRule& rule, const PacketView& packet) { - if (!rule_matches_flow(rule, packet.flow)) return false; + if (!rule.enabled) return false; + if (!rule_matches_endpoints(rule, packet.flow)) return false; if (rule.ip_proto && packet.ip_proto != *rule.ip_proto) return false; return true; } diff --git a/src/penny/flow/manager/ThreadFlowManager.cpp b/src/penny/flow/manager/ThreadFlowManager.cpp index c5703a2..a91c69b 100644 --- a/src/penny/flow/manager/ThreadFlowManager.cpp +++ b/src/penny/flow/manager/ThreadFlowManager.cpp @@ -9,16 +9,26 @@ namespace openpenny::penny { ThreadFlowManager::ThreadFlowManager() = default; -ThreadFlowManager::ThreadFlowManager(const Config::ActiveConfig& cfg) : table_cfg_(cfg) {} +ThreadFlowManager::ThreadFlowManager(const Config::ActiveConfig& cfg) : table_cfg_(cfg) { + reserve_for_config(cfg); +} void ThreadFlowManager::configure(const Config::ActiveConfig& cfg) { table_cfg_ = cfg; + reserve_for_config(cfg); for (auto& [_, entry] : table_active_flows_) { entry.flow.configure(table_cfg_); entry.flow.set_drop_sink(drop_sink_); } } +void ThreadFlowManager::reserve_for_config(const Config::ActiveConfig& cfg) { + if (cfg.max_tracked_flows == 0) return; + + table_active_flows_.reserve(cfg.max_tracked_flows); + table_completed_flows_.reserve(cfg.max_tracked_flows); +} + void ThreadFlowManager::set_drop_sink(FlowEngine::DropSnapshotSink sink) { drop_sink_ = std::move(sink); for (auto& [_, entry] : table_active_flows_) { diff --git a/tests/unit/flow/test_gap_management.cpp b/tests/unit/flow/test_gap_management.cpp index cafa2e9..2a9d31a 100644 --- a/tests/unit/flow/test_gap_management.cpp +++ b/tests/unit/flow/test_gap_management.cpp @@ -29,7 +29,7 @@ int main() { cfg.active.rtt_timeout_factor = 3.0; openpenny::penny::ThreadFlowManager table(cfg.active); - openpenny::FlowKey flow{10, 20, 1111, 2222}; + openpenny::FlowKey flow{10, 20, 1111, 2222, 6}; auto now = steady_clock::time_point{}; // Register a gap representing a dropped packet. diff --git a/tests/unit/flow/test_initial_flow_monitoring.cpp b/tests/unit/flow/test_initial_flow_monitoring.cpp index 7cbbe0a..346d964 100644 --- a/tests/unit/flow/test_initial_flow_monitoring.cpp +++ b/tests/unit/flow/test_initial_flow_monitoring.cpp @@ -24,7 +24,7 @@ namespace net = openpenny::net; auto now = steady_clock::time_point{}; // Case 1: Flow starts with SYN. - openpenny::FlowKey flow_syn{1, 2, 1000, 2000}; + openpenny::FlowKey flow_syn{1, 2, 1000, 2000, 6}; net::PacketView syn_pkt{}; syn_pkt.flow = flow_syn; syn_pkt.tcp.seq = 100; @@ -47,7 +47,7 @@ namespace net = openpenny::net; auto& syn_entry_data = *syn_entry_data_ptr; // Case 2: Flow starts with data (no SYN yet). - openpenny::FlowKey flow_data{3, 4, 3000, 4000}; + openpenny::FlowKey flow_data{3, 4, 3000, 4000, 6}; auto t0 = steady_clock::time_point{}; net::PacketView data_pkt0{}; data_pkt0.flow = flow_data; @@ -81,7 +81,7 @@ namespace net = openpenny::net; assert(data_entry3.flow.highest_sequence() == 60); // Case 3: Flow receives SYN after data-first start. - openpenny::FlowKey flow_data_then_syn{5, 6, 1234, 4321}; + openpenny::FlowKey flow_data_then_syn{5, 6, 1234, 4321, 6}; auto td0 = steady_clock::time_point{}; net::PacketView first_data_pkt{}; first_data_pkt.flow = flow_data_then_syn; diff --git a/tests/unit/net/test_packet_parser.cpp b/tests/unit/net/test_packet_parser.cpp index a74be2f..343186d 100644 --- a/tests/unit/net/test_packet_parser.cpp +++ b/tests/unit/net/test_packet_parser.cpp @@ -73,6 +73,7 @@ void assert_decodes(const std::vector& frame) { assert(packet.flow.dst == 0xc0a82902u); assert(packet.flow.sport == 40000); assert(packet.flow.dport == 5201); + assert(packet.flow.ip_proto == 6); assert(packet.ip_proto == 6); } diff --git a/tests/unit/net/test_traffic_match.cpp b/tests/unit/net/test_traffic_match.cpp index f8b52dd..4ed40c4 100644 --- a/tests/unit/net/test_traffic_match.cpp +++ b/tests/unit/net/test_traffic_match.cpp @@ -17,6 +17,7 @@ int main() { matching.dst = 0xc0000201u; matching.sport = 12345; matching.dport = 443; + matching.ip_proto = 6; openpenny::FlowKey non_matching = matching; non_matching.src = 0x0a020203u; @@ -57,12 +58,18 @@ int main() { cfg.rules.clear(); cfg.rules.push_back(tcp_https); + assert(openpenny::net::traffic_matches_flow(cfg, matching)); + auto wrong_proto = matching; + wrong_proto.ip_proto = 17; + assert(!openpenny::net::traffic_matches_flow(cfg, wrong_proto)); + openpenny::net::PacketView packet{}; packet.flow = matching; packet.ip_proto = 6; assert(openpenny::net::traffic_matches_packet(cfg, packet)); packet.ip_proto = 17; + packet.flow.ip_proto = 17; assert(!openpenny::net::traffic_matches_packet(cfg, packet)); cfg.default_action = openpenny::net::TrafficRuleAction::RedirectToUserspace; From 250cc02ec1b08ed4aba5a70420319242898c6831 Mon Sep 17 00:00:00 2001 From: Petros Gigis Date: Mon, 4 May 2026 00:11:15 +0100 Subject: [PATCH 6/8] update aggregate pipeline and detect egress backpressure --- CMakeLists.txt | 20 + .../openpenny/app/core/ActiveTestPipeline.h | 15 +- .../openpenny/app/core/DropCollectorBinding.h | 50 +-- .../app/core/OpenpennyPipelineDriver.h | 7 + include/openpenny/app/core/RuntimeSetup.h | 9 + include/openpenny/egress/PacketSink.h | 6 +- include/openpenny/egress/RawNicSink.h | 10 + include/openpenny/egress/RawSocketSink.h | 8 + include/openpenny/egress/TunSink.h | 2 + .../openpenny/penny/flow/engine/FlowEngine.h | 17 + .../penny/flow/manager/ThreadFlowManager.h | 17 +- .../penny/flow/timer/ThreadFlowEventTimer.h | 98 ++--- src/app/cli/penny_cli.cpp | 79 +++- src/app/core/AggregatesController.cpp | 348 +++++++++++------- src/app/core/DropCollectorBinding.cpp | 140 ++++--- src/app/core/OpenpennyPipelineDriver.cpp | 39 +- src/app/core/PerThreadStats.cpp | 3 + src/app/core/RuntimeSetup.cpp | 44 +++ src/app/core/active/ActiveTestPipeline.cpp | 231 ++++++++---- src/app/worker/penny_worker.cpp | 40 +- src/egress/RawNicSink.cpp | 141 +++++-- src/egress/RawSocketSink.cpp | 99 ++++- src/egress/TunSink.cpp | 17 +- src/grpc/PennyService.cpp | 11 +- src/ingress/af_xdp/XdpReader.cpp | 59 ++- src/penny/flow/engine/FlowEngine.cpp | 111 +++++- src/penny/flow/manager/ThreadFlowManager.cpp | 66 ++-- src/penny/flow/timer/ThreadFlowEventTimer.cpp | 254 +++++-------- .../test_aggregate_duplicate_fallback.cpp | 113 ++++++ .../test_aggregate_freeze_at_drop_limit.cpp | 133 +++++++ .../test_aggregate_pending_resolution.cpp | 140 +++++++ .../unit/flow/test_drop_snapshot_updates.cpp | 27 +- tests/unit/flow/test_drop_timer.cpp | 35 ++ .../flow/test_flow_evaluation_phase_gate.cpp | 50 +++ .../test_terminal_snapshot_resolution.cpp | 122 ++++++ 35 files changed, 1880 insertions(+), 681 deletions(-) create mode 100644 tests/unit/flow/test_aggregate_duplicate_fallback.cpp create mode 100644 tests/unit/flow/test_aggregate_freeze_at_drop_limit.cpp create mode 100644 tests/unit/flow/test_aggregate_pending_resolution.cpp create mode 100644 tests/unit/flow/test_flow_evaluation_phase_gate.cpp create mode 100644 tests/unit/flow/test_terminal_snapshot_resolution.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 4bc6f1a..e72f62a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -170,6 +170,26 @@ add_executable(test_aggregate_drop_budget tests/unit/flow/test_aggregate_drop_bu target_link_libraries(test_aggregate_drop_budget PRIVATE openpenny) add_test(NAME aggregate_drop_budget COMMAND test_aggregate_drop_budget) +add_executable(test_terminal_snapshot_resolution tests/unit/flow/test_terminal_snapshot_resolution.cpp) +target_link_libraries(test_terminal_snapshot_resolution PRIVATE openpenny) +add_test(NAME terminal_snapshot_resolution COMMAND test_terminal_snapshot_resolution) + +add_executable(test_aggregate_pending_resolution tests/unit/flow/test_aggregate_pending_resolution.cpp) +target_link_libraries(test_aggregate_pending_resolution PRIVATE openpenny) +add_test(NAME aggregate_pending_resolution COMMAND test_aggregate_pending_resolution) + +add_executable(test_aggregate_freeze_at_drop_limit tests/unit/flow/test_aggregate_freeze_at_drop_limit.cpp) +target_link_libraries(test_aggregate_freeze_at_drop_limit PRIVATE openpenny) +add_test(NAME aggregate_freeze_at_drop_limit COMMAND test_aggregate_freeze_at_drop_limit) + +add_executable(test_aggregate_duplicate_fallback tests/unit/flow/test_aggregate_duplicate_fallback.cpp) +target_link_libraries(test_aggregate_duplicate_fallback PRIVATE openpenny) +add_test(NAME aggregate_duplicate_fallback COMMAND test_aggregate_duplicate_fallback) + +add_executable(test_flow_evaluation_phase_gate tests/unit/flow/test_flow_evaluation_phase_gate.cpp) +target_link_libraries(test_flow_evaluation_phase_gate PRIVATE openpenny) +add_test(NAME flow_evaluation_phase_gate COMMAND test_flow_evaluation_phase_gate) + add_executable(test_cli_options tests/unit/cli/test_cli_options.cpp) target_link_libraries(test_cli_options PRIVATE openpenny) add_test(NAME cli_options COMMAND test_cli_options) diff --git a/include/openpenny/app/core/ActiveTestPipeline.h b/include/openpenny/app/core/ActiveTestPipeline.h index ae736f6..266fb37 100644 --- a/include/openpenny/app/core/ActiveTestPipeline.h +++ b/include/openpenny/app/core/ActiveTestPipeline.h @@ -168,8 +168,17 @@ class ActiveTestPipelineRunner : public IPipelineStrategy { /** Expire idle flows based on configured timeout. */ void expire_idle_flows(const std::chrono::steady_clock::time_point& now); - /** Sweep pending snapshots and expire those past timeout. */ - void sweep_expired_snapshots(const std::chrono::steady_clock::time_point& now); + /** Return true once the aggregate phase has completed and per-flow tests may run. */ + bool individual_flow_evaluation_enabled() const; + + /** Evaluate already-tracked flows once per-flow testing becomes active. */ + void evaluate_individual_flows_if_enabled(); + + /** Complete terminal flows once all pending drop snapshots are resolved. */ + void complete_resolved_terminal_flows(); + + /** Complete a flow and preserve a printable closed-loop summary if applicable. */ + void complete_flow_with_summary(const FlowKey& key, const char* reason); // ------------------------------------------------------------------------- // Member state @@ -232,6 +241,8 @@ class ActiveTestPipelineRunner : public IPipelineStrategy { */ std::size_t total_pkts_forwarded_{0}; std::size_t total_forward_errors_{0}; + std::vector closed_loop_flow_summaries_; + std::vector duplicate_exceeded_flow_summaries_; /** * Last time we logged global stats (prevents log flooding). diff --git a/include/openpenny/app/core/DropCollectorBinding.h b/include/openpenny/app/core/DropCollectorBinding.h index 2e26416..a20dea6 100644 --- a/include/openpenny/app/core/DropCollectorBinding.h +++ b/include/openpenny/app/core/DropCollectorBinding.h @@ -5,34 +5,24 @@ #include "openpenny/app/core/OpenpennyPipelineDriver.h" #include "openpenny/agg/Stats.h" -#include #include -#include - -namespace openpenny::penny { -class FlowEngine; -} +#include +#include namespace openpenny::app { /** - * @brief Maintains FlowEngine -> DropCollector bindings and installs the - * snapshot hook so drop events are mirrored into the shared collector. + * @brief Mirrors per-flow drop snapshots into the shared collector. + * + * New drops are inserted one at a time via upsert(). Snapshot state changes + * that affect a suffix of the per-flow snapshot vector (duplicate/rtx/expire) + * are mirrored via refresh_from() so the collector can rescan the already + * contiguous, append-only snapshot storage directly. */ class DropCollectorBinding { public: static DropCollectorBinding& instance(); - // Ensure the global timer snapshot hook is installed exactly once. - void ensure_snapshot_hook(); - - void bind(penny::FlowEngine* flow, - DropCollectorPtr collector, - const std::string& thread_name, - std::size_t shard_index); - - void unbind(penny::FlowEngine* flow); - void upsert(DropCollectorPtr collector, const std::string& thread_name, std::size_t shard_index, @@ -40,23 +30,23 @@ class DropCollectorBinding { penny::PacketDropId packet_id, const penny::PacketDropSnapshot& snap); -private: - struct BindingContext { - DropCollectorPtr collector; - std::string thread_name; - std::size_t shard_index{0}; - }; + void refresh_from( + DropCollectorPtr collector, + const std::string& thread_name, + std::size_t shard_index, + const FlowKey& key, + const std::vector>& snapshots, + std::size_t start_index); +private: DropCollectorBinding() = default; - BindingContext lookup(penny::FlowEngine* flow) const; - void upsert_locked(const BindingContext& binding, + + void upsert_locked(DropCollector& collector, + DropCollector::Shard& shard, + const std::string& thread_name, const FlowKey& key, penny::PacketDropId packet_id, const penny::PacketDropSnapshot& snap); - - mutable std::mutex mtx_; - std::once_flag hook_once_; - std::unordered_map bindings_; }; } // namespace openpenny::app diff --git a/include/openpenny/app/core/OpenpennyPipelineDriver.h b/include/openpenny/app/core/OpenpennyPipelineDriver.h index cb55a6b..17e7d27 100644 --- a/include/openpenny/app/core/OpenpennyPipelineDriver.h +++ b/include/openpenny/app/core/OpenpennyPipelineDriver.h @@ -124,6 +124,10 @@ struct DropCollector { std::atomic accepting{true}; std::size_t shard_count{1}; + std::size_t snapshot_limit{0}; + std::atomic accepted_snapshot_count{0}; + mutable std::mutex frozen_aggregate_counters_mtx; + std::optional frozen_aggregate_counters; std::array shards{}; std::size_t clamp_shard_index(std::size_t idx) const noexcept { @@ -160,10 +164,13 @@ struct ModeResult { std::size_t flows_tracked_data = 0; bool penny_completed = false; // True when Penny heuristics triggered shutdown. bool aggregates_penny_completed = false; // Flag representing aggregate Penny status. + bool closed_loop_stop_hit = false; // True when the configured min_closed_loop_flows threshold was observed. // Passive-mode gap summary. std::size_t passive_flows_with_open_gaps = 0; std::size_t passive_open_gaps = 0; std::vector passive_gap_summaries; + std::vector closed_loop_flow_summaries; + std::vector duplicate_exceeded_flow_summaries; std::size_t passive_flows_rst = 0; std::size_t passive_flows_syn_only = 0; std::size_t passive_flows_finished = 0; diff --git a/include/openpenny/app/core/RuntimeSetup.h b/include/openpenny/app/core/RuntimeSetup.h index dc99db9..0b624d4 100644 --- a/include/openpenny/app/core/RuntimeSetup.h +++ b/include/openpenny/app/core/RuntimeSetup.h @@ -18,4 +18,13 @@ const RuntimeSetupSnapshot& current_runtime_setup(); // Mutable view for helpers that need to update status fields. RuntimeSetupSnapshot& runtime_setup_mutable(); +bool current_aggregates_active() noexcept; +void set_current_aggregates_active(bool value) noexcept; + +RuntimeStatus::AggregatesStatus current_aggregates_status() noexcept; +void set_current_aggregates_status(RuntimeStatus::AggregatesStatus status) noexcept; + +bool current_has_aggregate_eval() noexcept; +void set_current_has_aggregate_eval(bool value) noexcept; + } // namespace openpenny diff --git a/include/openpenny/egress/PacketSink.h b/include/openpenny/egress/PacketSink.h index 45d56e8..6f68534 100644 --- a/include/openpenny/egress/PacketSink.h +++ b/include/openpenny/egress/PacketSink.h @@ -142,9 +142,9 @@ class PacketSink { * @brief Emit a parsed packet. Must be thread-safe. * * Returns true on a successful write, false on any error. Transient - * EAGAIN/EWOULDBLOCK are counted as errors==0 (pipeline drops the - * packet) because the pipeline is not responsible for reliable - * delivery -- it's a passive mirror. + * EAGAIN/EWOULDBLOCK still mean the packet was dropped; sinks may count + * those in stats_.errors as backpressure-induced loss so operators can + * distinguish real reinjection congestion from intentional Penny drops. */ virtual bool write(const net::PacketView& packet) = 0; diff --git a/include/openpenny/egress/RawNicSink.h b/include/openpenny/egress/RawNicSink.h index e75bd23..9d5c1dc 100644 --- a/include/openpenny/egress/RawNicSink.h +++ b/include/openpenny/egress/RawNicSink.h @@ -16,6 +16,10 @@ #include "openpenny/egress/PacketSink.h" +#include +#include +#include + namespace openpenny::egress { class RawNicSink : public PacketSink { @@ -30,9 +34,15 @@ class RawNicSink : public PacketSink { EgressKind kind() const noexcept override { return EgressKind::RawNic; } private: + int open_socket_fd(bool resolve_ifindex, bool log_failures); + int thread_fd(); + EgressConfig cfg_{}; int fd_ = -1; int if_index_ = -1; ///< Cached ifindex for sendto(2). + std::mutex fds_mtx_; + std::vector additional_fds_; + std::atomic backpressure_logged_{false}; }; } // namespace openpenny::egress diff --git a/include/openpenny/egress/RawSocketSink.h b/include/openpenny/egress/RawSocketSink.h index 1b9754f..e8427a7 100644 --- a/include/openpenny/egress/RawSocketSink.h +++ b/include/openpenny/egress/RawSocketSink.h @@ -15,6 +15,8 @@ #include "openpenny/egress/PacketSink.h" #include +#include +#include namespace openpenny::egress { @@ -30,8 +32,14 @@ class RawSocketSink : public PacketSink { EgressKind kind() const noexcept override { return EgressKind::RawSocket; } private: + int open_socket_fd(bool log_failures); + int thread_fd(); + EgressConfig cfg_{}; int fd_ = -1; + std::mutex fds_mtx_; + std::vector additional_fds_; + std::atomic backpressure_logged_{false}; /// Latched once we have logged the first EMSGSIZE failure. The kernel /// returns EMSGSIZE for any IP datagram larger than the egress /// interface MTU (raw sockets cannot fragment), and on a busy diff --git a/include/openpenny/egress/TunSink.h b/include/openpenny/egress/TunSink.h index e261cd3..e194966 100644 --- a/include/openpenny/egress/TunSink.h +++ b/include/openpenny/egress/TunSink.h @@ -15,6 +15,7 @@ #include "openpenny/egress/PacketSink.h" +#include #include #include @@ -54,6 +55,7 @@ class TunSink : public PacketSink { /// the `thread_local` cache are lock-free after the first call. std::mutex fds_mtx_; std::vector additional_fds_; + std::atomic backpressure_logged_{false}; }; } // namespace openpenny::egress diff --git a/include/openpenny/penny/flow/engine/FlowEngine.h b/include/openpenny/penny/flow/engine/FlowEngine.h index 3b41c3b..f328c0a 100644 --- a/include/openpenny/penny/flow/engine/FlowEngine.h +++ b/include/openpenny/penny/flow/engine/FlowEngine.h @@ -44,6 +44,10 @@ class FlowEngine { using DropSnapshotSink = std::function; + using SnapshotRefreshSink = std::function>&, + std::size_t start_index)>; /// High-level decision / outcome for this flow. enum class FlowDecision { @@ -187,6 +191,9 @@ class FlowEngine { /// Install a sink to receive drop snapshots as they are created. void set_drop_sink(DropSnapshotSink sink); + /// Install a sink to mirror in-place snapshot updates from a given suffix onward. + void set_snapshot_refresh_sink(SnapshotRefreshSink sink); + // --------------------------------------------------------------------- // Sequence interval classification // --------------------------------------------------------------------- @@ -335,6 +342,9 @@ class FlowEngine { /// Mark all pending snapshots as expired (used on shutdown/cleanup). void expire_all_pending_snapshots(); + /// Resolve pending snapshots at teardown using the configured timeout. + void resolve_pending_snapshots(const std::chrono::steady_clock::time_point& now); + private: /** * @brief Compute the final classification decision for this flow based on @@ -342,6 +352,12 @@ class FlowEngine { */ FlowDecision evaluate() const; + /// Mirror snapshot updates affecting [start_index, end) to any external collector. + void publish_snapshot_refresh(std::size_t start_index); + + /// Publish a single-snapshot update when no bulk refresh sink is installed. + void publish_single_snapshot_update(PacketDropId packet_id, std::size_t snapshot_index); + // --------------------------------------------------------------------- // Internal gap bookkeeping structures // --------------------------------------------------------------------- @@ -383,6 +399,7 @@ class FlowEngine { /// Mapping from snapshot packet_id to its index in flow_drop_snapshots_. std::unordered_map flow_snapshot_index_by_id_; DropSnapshotSink drop_sink_{}; + SnapshotRefreshSink snapshot_refresh_sink_{}; /** * @brief Shared liveness flag observed by timer entries. diff --git a/include/openpenny/penny/flow/manager/ThreadFlowManager.h b/include/openpenny/penny/flow/manager/ThreadFlowManager.h index b098c5f..27a22ec 100644 --- a/include/openpenny/penny/flow/manager/ThreadFlowManager.h +++ b/include/openpenny/penny/flow/manager/ThreadFlowManager.h @@ -124,18 +124,20 @@ class ThreadFlowManager { * @param is_syn True if the first packet carried a SYN flag. * @param ts Timestamp of the first packet (for data timing). * - * @return true if a new flow entry was inserted, false if the flow already existed - * or had been monitored before. + * @return pointer to the new flow entry when inserted, nullptr otherwise. */ - bool add_new_flow(const FlowKey& key, - uint32_t seq, - uint32_t payload_bytes, - bool is_syn, - const std::chrono::steady_clock::time_point& ts); + FlowEngineEntry* add_new_flow(const FlowKey& key, + uint32_t seq, + uint32_t payload_bytes, + bool is_syn, + const std::chrono::steady_clock::time_point& ts); /// Install a sink that receives drop snapshots from all managed FlowEngines. void set_drop_sink(FlowEngine::DropSnapshotSink sink); + /// Install a sink that mirrors in-place snapshot updates from managed FlowEngines. + void set_snapshot_refresh_sink(FlowEngine::SnapshotRefreshSink sink); + /** * @brief Update or create the FlowEngine entry corresponding to a packet. * @@ -286,6 +288,7 @@ class ThreadFlowManager { FlowSet table_completed_flows_; FlowEngine::DropSnapshotSink drop_sink_{}; + FlowEngine::SnapshotRefreshSink snapshot_refresh_sink_{}; }; } // namespace openpenny::penny diff --git a/include/openpenny/penny/flow/timer/ThreadFlowEventTimer.h b/include/openpenny/penny/flow/timer/ThreadFlowEventTimer.h index 6198c5f..53a2340 100644 --- a/include/openpenny/penny/flow/timer/ThreadFlowEventTimer.h +++ b/include/openpenny/penny/flow/timer/ThreadFlowEventTimer.h @@ -7,14 +7,13 @@ #include #include -#include #include #include +#include #include #include #include #include -#include #include #include #include @@ -30,34 +29,34 @@ class FlowEngine; * * High-level design * ----------------- - * - A single background thread runs timer_loop(). - * - Packet-processing threads never mutate FlowEngine snapshots directly. Instead, they: + * - Each worker thread owns a thread-local manager instance. + * - Packet-processing code never mutates FlowEngine snapshots directly from nested + * helper paths. Instead, it: * * register drops (with deadlines), * * enqueue retransmission / duplicate events. - * - The timer thread: + * - The worker periodically calls drain_callbacks(), which: * * pops expired entries from a min-heap, * * consumes queued events, * * turns them into callbacks, - * * and executes those callbacks itself (without holding the manager mutex). + * * and executes those callbacks on the same worker thread. * * As a result: - * - All snapshot mutations are single-threaded (in the timer thread). - * - The packet path stays lightweight and avoids locking around FlowEngine state. + * - All snapshot mutations stay on the queue worker that owns the flow. + * - We avoid one extra timer thread and the associated context switching per queue. */ class ThreadFlowEventTimerManager { public: /** * @brief Access the thread-local timer manager instance. * - * Each packet-processing thread gets its own manager (and timer thread), - * so queues are isolated. + * Each packet-processing thread gets its own manager, so queues are isolated. */ static ThreadFlowEventTimerManager& instance(); ~ThreadFlowEventTimerManager(); /** - * @brief Start the timer thread with a given drop timeout. + * @brief Initialise the per-thread timer state with a given drop timeout. * * @param timeout_sec Timeout in seconds after which an un-repaired drop snapshot * is considered expired. @@ -65,7 +64,7 @@ class ThreadFlowEventTimerManager { void start(double timeout_sec); /** - * @brief Stop the timer thread and flush internal state. + * @brief Stop and flush internal state. * * Safe to call multiple times; subsequent calls after the first have no effect. */ @@ -94,8 +93,8 @@ class ThreadFlowEventTimerManager { /** * @brief Queue an asynchronous "retransmitted" event from the packet path. * - * The timer thread will later convert this into a callback that updates - * the relevant snapshot in the owning FlowEngine. + * The owning worker thread will later convert this into a callback that + * updates the relevant snapshot in the owning FlowEngine. */ void enqueue_retransmitted(PacketDropId packet_id, FlowEngine* flow); @@ -116,26 +115,10 @@ class ThreadFlowEventTimerManager { void purge_flow(FlowEngine* flow); /** - * @brief Optional manual draining of callbacks. - * - * Historically used when callbacks were executed from the packet-processing - * thread; kept for compatibility. In the current design, the timer thread - * is responsible for draining and executing callbacks via run_callbacks(). + * @brief Drain due expirations and queued events on the current worker thread. */ void drain_callbacks(); - enum class SnapshotEventKind { Expire, Retransmit, Duplicate }; - - /** - * @brief Install a hook invoked after a snapshot event is applied. - * - * The hook runs in the packet-processing thread context when callbacks - * are drained. - */ - static void set_snapshot_hook(std::function hook); - private: // --------------------------------------------------------------------- // Internal helper types @@ -187,7 +170,7 @@ class ThreadFlowEventTimerManager { }; /** - * @brief Event generated by the packet path and consumed by the timer thread. + * @brief Event generated by the packet path and consumed by drain_callbacks(). * * These events are cheap to enqueue in the packet-processing context and * later turned into callbacks against FlowEngine. @@ -206,7 +189,7 @@ class ThreadFlowEventTimerManager { }; /** - * @brief Callback to be executed against FlowEngine by the timer thread. + * @brief Callback to be executed against FlowEngine on the worker thread. * * This is the only place where snapshots and FlowEngine state are mutated. */ @@ -227,25 +210,25 @@ class ThreadFlowEventTimerManager { ThreadFlowEventTimerManager(const ThreadFlowEventTimerManager&) = delete; ThreadFlowEventTimerManager& operator=(const ThreadFlowEventTimerManager&) = delete; - // Main thread loop: waits for timers or events, then processes them. - void timer_loop(); - - // Notify the timer thread that new timers/events are available (mutex_ held). - void wake_locked(); - // Run and clear the callbacks in @p pending, without holding mutex_. void run_callbacks(std::deque& pending); + // Collect all due expirations and queued events into @p pending (mutex_ held). + void collect_ready_callbacks(std::deque& pending, + const std::chrono::steady_clock::time_point& now); + + // Discard cancelled heap entries and refresh the lock-free earliest-deadline hint (mutex_ held). + void refresh_next_deadline_locked(); + // --------------------------------------------------------------------- // Synchronisation / thread state // --------------------------------------------------------------------- std::mutex mutex_; - std::condition_variable cv_; - std::thread thread_; + using DeadlineRep = std::chrono::steady_clock::duration::rep; + static constexpr DeadlineRep kNoDeadline = std::numeric_limits::max(); - bool running_{false}; ///< True once the timer thread has been started. - bool stop_flag_{false}; ///< Set to request shutdown of the timer thread. + bool running_{false}; ///< True once start() has initialised this worker-local manager. double timeout_sec_{0.0}; std::uint64_t next_token_{1}; @@ -260,7 +243,7 @@ class ThreadFlowEventTimerManager { std::unordered_map by_id_; /// Record of flow+packet_id pairs already handled as retransmitted. - std::vector retransmit_seen_; + std::unordered_set retransmit_seen_; /// Map from FlowEngine* to active timer tokens (for bulk purge_flow()). std::unordered_multimap by_flow_; @@ -272,33 +255,20 @@ class ThreadFlowEventTimerManager { // Asynchronous events and callbacks // --------------------------------------------------------------------- - /// Events queued by the packet-processing path for the timer thread. + /// Events queued by the packet-processing path for drain_callbacks(). std::deque events_; /** - * @brief Pending callbacks to execute against FlowEngine. + * @brief Lock-free fast-path size of `events_`. * - * These are built while holding mutex_, but always executed by the timer - * thread via run_callbacks() without the lock, avoiding lock contention - * during snapshot updates. + * This lets drain_callbacks() skip taking mutex_ when there are no queued + * retransmit/duplicate events and no drop deadline has elapsed yet. */ - std::deque callbacks_; + std::atomic queued_event_count_{0}; - /** - * @brief Lock-free fast-path size of `callbacks_`. - * - * Every per-packet poll iteration on every worker calls - * `drain_callbacks()`. With many AF_XDP queue workers in busy-poll - * mode that adds up to millions of mutex acquires per second on - * `mutex_` even when no callbacks are pending. This counter lets - * `drain_callbacks()` skip the lock entirely on the common - * "nothing to drain" path. It is incremented under `mutex_` whenever - * we push to `callbacks_`, and reset to 0 inside `drain_callbacks()` - * after we swap the deque out. - */ - std::atomic pending_callbacks_{0}; + /// Lock-free hint for the earliest outstanding drop deadline. + std::atomic next_deadline_{kNoDeadline}; - static std::function snapshot_hook_; }; } // namespace openpenny::penny diff --git a/src/app/cli/penny_cli.cpp b/src/app/cli/penny_cli.cpp index 5aa4a66..08d270a 100644 --- a/src/app/cli/penny_cli.cpp +++ b/src/app/cli/penny_cli.cpp @@ -862,12 +862,8 @@ int main(int argc, char** argv) { // // End state: Passive pipeline completed (flows=42) if (result.active) { - const auto agg_snapshot = - (result.active->aggregates_snapshot - ? *result.active->aggregates_snapshot - : openpenny::app::aggregate_counters()); - const auto agg_live = openpenny::app::aggregate_counters(); + const auto& agg_snapshot = agg_live; const auto runtime = openpenny::current_runtime_setup(); const bool is_passive = @@ -892,6 +888,15 @@ int main(int argc, char** argv) { result.aggregates_enabled && runtime.aggregates_status != openpenny::RuntimeStatus::AggregatesStatus::PENDING; + const std::uint64_t closed_loop_flows_observed = std::max( + agg_snapshot.flows_closed_loop, + agg_live.flows_closed_loop); + const std::uint64_t closed_loop_flows_found = std::max( + closed_loop_flows_observed, + result.active->closed_loop_flow_summaries.size()); + const std::uint64_t duplicate_exceeded_flows_found = std::max( + agg_snapshot.flows_duplicates_exceeded, + result.active->duplicate_exceeded_flow_summaries.size()); // --- Run --------------------------------------------------------- print_section(std::cout, "Run"); @@ -1020,40 +1025,84 @@ int main(int argc, char** argv) { agg_snapshot.flows_duplicates_exceeded); } - // --- Per-flow detail (passive only, if any) ---------------------- + // --- Per-flow detail --------------------------------------------- if (is_passive && !result.active->passive_gap_summaries.empty()) { print_section(std::cout, "Per-flow detail"); for (const auto& g : result.active->passive_gap_summaries) { std::cout << " " << g << "\n"; } } + if (!is_passive && !result.active->closed_loop_flow_summaries.empty()) { + print_section(std::cout, "Closed-loop flows"); + for (const auto& s : result.active->closed_loop_flow_summaries) { + std::cout << " " << s << "\n"; + } + } + if (!is_passive && !result.active->duplicate_exceeded_flow_summaries.empty()) { + print_section(std::cout, "Duplicate-exceeded flows"); + for (const auto& s : result.active->duplicate_exceeded_flow_summaries) { + std::cout << " " << s << "\n"; + } + } // --- End state --------------------------------------------------- - std::ostringstream end_state; + std::ostringstream end_state_primary; + std::ostringstream end_state_closed_loop_suffix; + std::ostringstream end_state_duplicate_suffix; const char* end_color = ""; + const char* closed_loop_suffix_color = ""; + const char* duplicate_suffix_color = ""; if (!is_passive && agg_done) { - end_state << "Aggregates completed (" << agg_status_str << ")"; + end_state_primary << "Aggregates completed (" << agg_status_str << ")"; + if (closed_loop_flows_found > 0) { + end_state_closed_loop_suffix << ", found " << fmt_count(closed_loop_flows_found) + << " closed-loop flow" + << (closed_loop_flows_found == 1 ? "" : "s"); + closed_loop_suffix_color = kAnsiBlue; + } + if (duplicate_exceeded_flows_found > 0) { + end_state_duplicate_suffix << ", found " + << fmt_count(duplicate_exceeded_flows_found) + << " duplicate-exceeded flow" + << (duplicate_exceeded_flows_found == 1 ? "" : "s"); + duplicate_suffix_color = kAnsiYellow; + } end_color = color_for_agg_status(agg_status_str); } else if (result.active->penny_completed) { if (is_passive) { - end_state << "Passive pipeline completed (flows=" - << result.active->passive_flows_finished << ")"; + end_state_primary << "Passive pipeline completed (flows=" + << result.active->passive_flows_finished << ")"; end_color = kAnsiGreen; } else { - end_state << "Penny heuristics completed"; + end_state_primary << "Penny heuristics completed"; + if (closed_loop_flows_found > 0) { + end_state_closed_loop_suffix << ", found " << fmt_count(closed_loop_flows_found) + << " closed-loop flow" + << (closed_loop_flows_found == 1 ? "" : "s"); + closed_loop_suffix_color = kAnsiBlue; + } + if (duplicate_exceeded_flows_found > 0) { + end_state_duplicate_suffix << ", found " + << fmt_count(duplicate_exceeded_flows_found) + << " duplicate-exceeded flow" + << (duplicate_exceeded_flows_found == 1 ? "" : "s"); + duplicate_suffix_color = kAnsiYellow; + } end_color = kAnsiGreen; } } else if (g_stop_requested != 0) { - end_state << "Stopped via signal (Ctrl+C)"; + end_state_primary << "Stopped via signal (Ctrl+C)"; end_color = kAnsiYellow; } else { - end_state << "Reader/pipeline error (see logs)"; + end_state_primary << "Reader/pipeline error (see logs)"; end_color = kAnsiRed; } std::cout << "\n" << ansi(kAnsiBold) << "End state:" << ansi(kAnsiReset) << " " - << ansi(end_color) << end_state.str() << ansi(kAnsiReset) + << ansi(end_color) << end_state_primary.str() << ansi(kAnsiReset) + << ansi(closed_loop_suffix_color) << end_state_closed_loop_suffix.str() << ansi(kAnsiReset) + << ansi(duplicate_suffix_color) << end_state_duplicate_suffix.str() << ansi(kAnsiReset) << "\n"; } else { // No active result usually means no packets were processed or the @@ -1071,4 +1120,4 @@ int main(int argc, char** argv) { // of the forwarding fd is needed here any more. run_detach_command(); return 0; -} \ No newline at end of file +} diff --git a/src/app/core/AggregatesController.cpp b/src/app/core/AggregatesController.cpp index c5cf349..7b3108d 100644 --- a/src/app/core/AggregatesController.cpp +++ b/src/app/core/AggregatesController.cpp @@ -16,10 +16,8 @@ DropCollector::TimestampRep snapshot_timestamp( return snap.timestamp.time_since_epoch().count(); } -void decorate_snapshot_record(DropSnapshotRecord& record, - const openpenny::app::AggregatedCounters& agg) { - record.counters = agg; - record.snapshot.stats.overwrite_from_aggregates(agg); +bool is_pending_snapshot(const penny::PacketDropSnapshot& snap) noexcept { + return snap.state == penny::SnapshotState::Pending; } void set_runtime_eval_counters(RuntimeStatus& runtime, @@ -46,9 +44,21 @@ void store_aggregate_snapshot_once( if (!snapshot_slot) snapshot_slot = agg; } -std::vector collect_all_drop_snapshots( - const DropCollector& collector, +std::optional collect_frozen_aggregate_counters( + const DropCollector& collector) { + std::lock_guard lock(collector.frozen_aggregate_counters_mtx); + return collector.frozen_aggregate_counters; +} + +penny::PennyStats make_eval_stats_from_aggregates( const openpenny::app::AggregatedCounters& agg) { + penny::PennyStats stats; + stats.overwrite_from_aggregates(agg); + return stats; +} + +std::vector collect_all_drop_snapshots( + const DropCollector& collector) { std::vector out; std::size_t total = 0; for (std::size_t shard_index = 0; shard_index < collector.shard_count; ++shard_index) { @@ -61,15 +71,11 @@ std::vector collect_all_drop_snapshots( std::lock_guard lock(shard.mtx); out.insert(out.end(), shard.snapshots.begin(), shard.snapshots.end()); } - for (auto& record : out) { - decorate_snapshot_record(record, agg); - } return out; } std::optional collect_latest_drop_snapshot( - const DropCollector& collector, - const openpenny::app::AggregatedCounters& agg) { + const DropCollector& collector) { std::size_t best_shard_index = 0; auto best_timestamp = DropCollector::kNoSnapshotTimestamp; for (std::size_t shard_index = 0; shard_index < collector.shard_count; ++shard_index) { @@ -96,7 +102,6 @@ std::optional collect_latest_drop_snapshot( if (latest_index < best_shard.snapshots.size()) { auto record = best_shard.snapshots[latest_index]; if (snapshot_timestamp(record.snapshot) == best_timestamp) { - decorate_snapshot_record(record, agg); return record; } } @@ -119,9 +124,6 @@ std::optional collect_latest_drop_snapshot( latest = *it; } } - if (latest) { - decorate_snapshot_record(*latest, agg); - } return latest; } @@ -141,6 +143,29 @@ CollectorSnapshotSummary summarize_collector_snapshots(const DropCollector& coll return summary; } +CollectorSnapshotSummary summarize_drop_snapshots( + const std::vector& snapshots) { + CollectorSnapshotSummary summary; + summary.snapshot_count = snapshots.size(); + summary.pending_snapshot_count = static_cast(std::count_if( + snapshots.begin(), + snapshots.end(), + [](const DropSnapshotRecord& record) { + return is_pending_snapshot(record.snapshot); + })); + return summary; +} + +bool aggregates_ready_for_evaluation(std::size_t required_drops, + std::size_t snapshot_count, + std::size_t pending_snapshot_count, + std::uint64_t pending_rtx_count) noexcept { + return required_drops > 0 && + snapshot_count >= required_drops && + pending_snapshot_count == 0 && + pending_rtx_count == 0; +} + } // namespace AggregatesController::AggregatesController(const Config& cfg, @@ -159,7 +184,11 @@ AggregatesController::AggregatesController(const Config& cfg, individual_limit_enabled_{opts.mode == PipelineOptions::Mode::Active && cfg.active.stop_after_individual_flows > 0}, min_closed_loop_enabled_{opts.mode == PipelineOptions::Mode::Active && - cfg.active.min_closed_loop_flows > 0} {} + cfg.active.min_closed_loop_flows > 0} { + if (collector_enabled_ && collector_) { + collector_->snapshot_limit = required_drops_; + } +} void AggregatesController::start() { if (collector_enabled_) { @@ -214,8 +243,7 @@ std::optional AggregatesController::aggregat void AggregatesController::populate_drop_snapshots(PipelineSummary& summary) const { if (!collector_) return; - const auto agg = openpenny::app::aggregate_counters(); - auto snaps = collect_all_drop_snapshots(*collector_, agg); + auto snaps = collect_all_drop_snapshots(*collector_); std::sort( snaps.begin(), snaps.end(), @@ -228,14 +256,33 @@ void AggregatesController::populate_drop_snapshots(PipelineSummary& summary) con void AggregatesController::evaluate_pending_if_needed(const Config& cfg, PipelineSummary& summary) { auto& runtime = runtime_setup_mutable(); + const auto snapshot_summary = summarize_drop_snapshots(summary.drop_snapshots); + const auto agg = openpenny::app::aggregate_counters(); + const auto frozen_agg = + collector_ ? collect_frozen_aggregate_counters(*collector_) : std::nullopt; + const auto pending_rtx_count = + frozen_agg ? frozen_agg->pending_retransmissions : agg.pending_retransmissions; + const bool ready = aggregates_ready_for_evaluation( + required_drops_, + snapshot_summary.snapshot_count, + snapshot_summary.pending_snapshot_count, + pending_rtx_count); if (!cfg.active.aggregates_enabled || - runtime.aggregates_status != RuntimeStatus::AggregatesStatus::PENDING || - !aggregates_ready_.load(std::memory_order_relaxed) || + current_aggregates_status() != RuntimeStatus::AggregatesStatus::PENDING || + !ready || summary.drop_snapshots.empty()) { return; } + aggregates_ready_.store(true, std::memory_order_relaxed); + if (frozen_agg) { + store_aggregate_snapshot_once(aggregates_snapshot_, aggregates_snapshot_mtx_, *frozen_agg); + } else { + store_aggregate_snapshot_once(aggregates_snapshot_, aggregates_snapshot_mtx_, agg); + } const auto& latest = summary.drop_snapshots.front(); - const auto& stats = latest.snapshot.stats; + const auto stats = frozen_agg + ? make_eval_stats_from_aggregates(*frozen_agg) + : latest.snapshot.stats; const auto miss_prob = std::clamp( cfg.active.retransmission_miss_probability, 0.0, @@ -245,14 +292,20 @@ void AggregatesController::evaluate_pending_if_needed(const Config& cfg, miss_prob, cfg.active.max_duplicate_fraction); if (eval.decision == penny::FlowEngine::FlowDecision::FINISHED_CLOSED_LOOP) { - runtime.aggregates_status = RuntimeStatus::AggregatesStatus::CLOSED_LOOP; + set_current_aggregates_status(RuntimeStatus::AggregatesStatus::CLOSED_LOOP); } else if (eval.decision == penny::FlowEngine::FlowDecision::FINISHED_NOT_CLOSED_LOOP) { - runtime.aggregates_status = RuntimeStatus::AggregatesStatus::NON_CLOSED_LOOP; + set_current_aggregates_status(RuntimeStatus::AggregatesStatus::NON_CLOSED_LOOP); + } else if (eval.decision == penny::FlowEngine::FlowDecision::FINISHED_DUPLICATE_EXCEEDED) { + set_current_aggregates_status(RuntimeStatus::AggregatesStatus::DUPLICATES_EXCEEDED); } else { - runtime.aggregates_status = RuntimeStatus::AggregatesStatus::DUPLICATES_EXCEEDED; + set_current_aggregates_status(RuntimeStatus::AggregatesStatus::NON_CLOSED_LOOP); + } + set_current_has_aggregate_eval(true); + if (frozen_agg) { + set_runtime_eval_counters(runtime, *frozen_agg); + } else { + set_runtime_eval_counters(runtime, stats); } - runtime.has_aggregate_eval = true; - set_runtime_eval_counters(runtime, stats); collector_completed_.store(true, std::memory_order_relaxed); } @@ -266,16 +319,15 @@ void AggregatesController::collector_loop() { // 2. Evaluate the aggregate stats once. // - bidirectional / closed-loop -> stop the pipeline and // report CLOSED_LOOP. - // - duplicates exceeded -> stop and report - // DUPLICATES_EXCEEDED. - // - anything else (NON_CLOSED_LOOP or no verdict yet) - // -> fall through to step 3. + // - anything else + // (NON_CLOSED_LOOP or DUPLICATES_EXCEEDED) + // -> freeze the aggregate verdict, then switch to the + // separate per-flow phase. // 3. Watch the per-flow CLOSED_LOOP termination tally and stop as // soon as it reaches `min_closed_loop_flows` (defaulting to 2 - // when the operator did not configure it). This is the - // "look for the min flows" path and gives the run a chance - // to upgrade to CLOSED_LOOP via per-flow evidence even when - // the one-shot aggregate eval did not. + // when the operator did not configure it). This is a separate + // per-flow stop condition; it does NOT rewrite the aggregate + // verdict from step 2. auto& runtime = runtime_setup_mutable(); bool aggregate_eval_done = false; bool wait_for_closed_loops = false; @@ -288,39 +340,87 @@ void AggregatesController::collector_loop() { cfg_.active.min_closed_loop_flows > 0 ? cfg_.active.min_closed_loop_flows : static_cast(2); + auto finalize_aggregate_verdict = + [&](RuntimeStatus::AggregatesStatus status, + const std::optional& frozen_agg, + const openpenny::app::AggregatedCounters& agg_now, + const std::optional& stats) { + set_current_aggregates_status(status); + set_current_aggregates_active(false); + set_current_has_aggregate_eval(true); + if (frozen_agg) { + set_runtime_eval_counters(runtime, *frozen_agg); + store_aggregate_snapshot_once( + aggregates_snapshot_, + aggregates_snapshot_mtx_, + *frozen_agg); + } else if (stats) { + set_runtime_eval_counters(runtime, *stats); + store_aggregate_snapshot_once( + aggregates_snapshot_, + aggregates_snapshot_mtx_, + agg_now); + } else { + set_runtime_eval_counters(runtime, agg_now); + store_aggregate_snapshot_once( + aggregates_snapshot_, + aggregates_snapshot_mtx_, + agg_now); + } + }; + auto switch_to_individual_flow_phase = + [&](RuntimeStatus::AggregatesStatus status, + const char* verdict_text, + const std::optional& frozen_agg, + const openpenny::app::AggregatedCounters& agg_now, + const std::optional& stats) { + finalize_aggregate_verdict(status, frozen_agg, agg_now, stats); + aggregate_eval_done = true; + wait_for_closed_loops = true; + TCPLOG_INFO( + "[agg_phase] action=switch_to_individual agg_status=%s drops=%zu " + "next=individual wait_closed_loop_flows=%llu", + verdict_text, + required_drops_, + static_cast(closed_loop_required)); + }; while (!stop_flag_.load(std::memory_order_relaxed)) { if (user_should_stop_ && user_should_stop_()) break; if (wait_for_closed_loops) { auto agg = openpenny::app::aggregate_counters(); if (agg.flows_closed_loop >= closed_loop_required) { TCPLOG_INFO( - "[aggregates_closed_loop] flows_closed_loop=%llu flows_not_closed_loop=%llu flows_finished=%llu", + "[closed_loop_threshold] flows_closed_loop=%llu flows_not_closed_loop=%llu flows_finished=%llu " + "aggregate_status=%d", static_cast(agg.flows_closed_loop), static_cast(agg.flows_not_closed_loop), - static_cast(agg.flows_finished)); - runtime.aggregates_status = RuntimeStatus::AggregatesStatus::CLOSED_LOOP; - runtime.has_aggregate_eval = true; - set_runtime_eval_counters(runtime, agg); + static_cast(agg.flows_finished), + static_cast(current_aggregates_status())); collector_completed_.store(true, std::memory_order_relaxed); - store_aggregate_snapshot_once(aggregates_snapshot_, aggregates_snapshot_mtx_, agg); + closed_loop_stop_hit_.store(true, std::memory_order_relaxed); stop_flag_.store(true, std::memory_order_relaxed); break; } + std::this_thread::sleep_for(25ms); + continue; } bool ready = false; - bool pending = false; - bool pending_rtx = false; std::size_t snapshot_count = 0; std::size_t pending_snapshot_count = 0; std::uint64_t pending_rtx_count = 0; { const auto collector_summary = summarize_collector_snapshots(*collector_); + const auto frozen_agg = collect_frozen_aggregate_counters(*collector_); snapshot_count = collector_summary.snapshot_count; pending_snapshot_count = collector_summary.pending_snapshot_count; - pending = pending_snapshot_count > 0; - pending_rtx_count = openpenny::app::aggregate_counters().pending_retransmissions; - pending_rtx = pending_rtx_count > 0; - ready = snapshot_count >= required_drops_ && !pending && !pending_rtx; + pending_rtx_count = frozen_agg + ? frozen_agg->pending_retransmissions + : openpenny::app::aggregate_counters().pending_retransmissions; + ready = aggregates_ready_for_evaluation( + required_drops_, + snapshot_count, + pending_snapshot_count, + pending_rtx_count); } // Periodic gate diagnostic: when snapshot_count has reached the // required threshold but ready stays false, this line tells the @@ -336,9 +436,8 @@ void AggregatesController::collector_loop() { g_last_gate_log_ns.compare_exchange_strong( last, next, std::memory_order_acq_rel)) { TCPLOG_INFO( - "[aggregates_gate] snapshots=%zu/%zu pending_snapshots=%zu " - "pending_rtx=%llu (waiting for both to reach 0 before " - "evaluating)", + "[agg_wait] drops=%zu/%zu pending_snapshots=%zu pending_rtx=%llu " + "state=waiting", snapshot_count, required_drops_, pending_snapshot_count, @@ -349,41 +448,49 @@ void AggregatesController::collector_loop() { aggregates_ready_.store(true, std::memory_order_relaxed); if (!ready_logged) { TCPLOG_INFO( - "Aggregates have %zu drops ready (required=%zu)", + "[agg_ready] drops=%zu required=%zu", snapshot_count, required_drops_); ready_logged = true; } collector_->accepting.store(false, std::memory_order_relaxed); const auto agg_now = openpenny::app::aggregate_counters(); + const auto frozen_agg = collect_frozen_aggregate_counters(*collector_); + const auto eval_stats = frozen_agg + ? make_eval_stats_from_aggregates(*frozen_agg) + : penny::PennyStats{}; if (cfg_.active.max_duplicate_fraction > 0.0) { - if (agg_now.data_packets > 0) { - const double agg_dup_ratio = static_cast(agg_now.duplicate_packets) / - static_cast(agg_now.data_packets); + const auto dup_data_packets = + frozen_agg ? eval_stats.data_packets() : agg_now.data_packets; + const auto dup_packets = + frozen_agg ? eval_stats.duplicate_packets() : agg_now.duplicate_packets; + if (dup_data_packets > 0) { + const double agg_dup_ratio = static_cast(dup_packets) / + static_cast(dup_data_packets); if (agg_dup_ratio > cfg_.active.max_duplicate_fraction) { - runtime.aggregates_status = RuntimeStatus::AggregatesStatus::DUPLICATES_EXCEEDED; - runtime.aggregates_active = false; - runtime.has_aggregate_eval = true; - set_runtime_eval_counters(runtime, agg_now); - collector_completed_.store(true, std::memory_order_relaxed); - store_aggregate_snapshot_once( - aggregates_snapshot_, - aggregates_snapshot_mtx_, - agg_now); - stop_flag_.store(true, std::memory_order_relaxed); - break; + switch_to_individual_flow_phase( + RuntimeStatus::AggregatesStatus::DUPLICATES_EXCEEDED, + "duplicates_exceeded", + frozen_agg, + agg_now, + std::nullopt); } } } if (!aggregate_eval_done) { aggregate_eval_done = true; - auto latest_snapshot = collect_latest_drop_snapshot(*collector_, agg_now); + auto latest_snapshot = collect_latest_drop_snapshot(*collector_); if (latest_snapshot) { - if (agg_now.pending_retransmissions > 0) { + const auto pending_window_rtx = frozen_agg + ? frozen_agg->pending_retransmissions + : agg_now.pending_retransmissions; + if (pending_window_rtx > 0) { continue; } - auto stats = latest_snapshot->snapshot.stats; + const auto stats = frozen_agg + ? make_eval_stats_from_aggregates(*frozen_agg) + : latest_snapshot->snapshot.stats; const auto miss_prob = std::clamp( cfg_.active.retransmission_miss_probability, 0.0, @@ -399,12 +506,26 @@ void AggregatesController::collector_loop() { miss_prob, cfg_.active.max_duplicate_fraction); const auto packet_id_text = penny::format_packet_drop_id(latest_snapshot->packet_id); - - const auto denom = eval.p_closed + eval.p_not_closed; + const auto* eval_verdict_text = [&]() -> const char* { + switch (eval.decision) { + case penny::FlowEngine::FlowDecision::FINISHED_CLOSED_LOOP: + return "closed_loop"; + case penny::FlowEngine::FlowDecision::FINISHED_NOT_CLOSED_LOOP: + return "not_closed_loop"; + case penny::FlowEngine::FlowDecision::FINISHED_DUPLICATE_EXCEEDED: + return "duplicates_exceeded"; + case penny::FlowEngine::FlowDecision::FINISHED_NO_DECISION: + return "no_decision"; + case penny::FlowEngine::FlowDecision::PENDING: + default: + return "pending"; + } + }(); TCPLOG_INFO( - "[agg_eval] data_pkts=%llu dup_pkts=%llu rtx_pkts=%llu non_rtx_pkts=%llu " - "dup_ratio=%.6f miss_prob=%.6f p_closed=%.6f p_not_closed=%.6f denom=%.6f closed_weight=%.6f decision=%s " + "[agg_eval] verdict=%s data=%llu dup=%llu rtx=%llu non_rtx=%llu " + "dup_ratio=%.6f miss_prob=%.6f p_closed=%.6f p_not_closed=%.6f closed_weight=%.6f " "packet_id=%s thread=%s", + eval_verdict_text, static_cast(stats.data_packets()), static_cast(stats.duplicate_packets()), static_cast(stats.retransmitted_packets()), @@ -413,70 +534,38 @@ void AggregatesController::collector_loop() { miss_prob, eval.p_closed, eval.p_not_closed, - denom, eval.closed_weight, - penny::flow_decision_to_string(eval.decision), packet_id_text.c_str(), latest_snapshot->thread_name.c_str()); if (dup_threshold_hit) { - runtime.aggregates_status = RuntimeStatus::AggregatesStatus::DUPLICATES_EXCEEDED; - runtime.aggregates_active = false; - runtime.has_aggregate_eval = true; - set_runtime_eval_counters(runtime, stats); - collector_completed_.store(true, std::memory_order_relaxed); - store_aggregate_snapshot_once( - aggregates_snapshot_, - aggregates_snapshot_mtx_, - agg_now); - break; + switch_to_individual_flow_phase( + RuntimeStatus::AggregatesStatus::DUPLICATES_EXCEEDED, + "duplicates_exceeded", + frozen_agg, + agg_now, + stats); + continue; } if (eval.decision == penny::FlowEngine::FlowDecision::FINISHED_CLOSED_LOOP) { - runtime.aggregates_status = RuntimeStatus::AggregatesStatus::CLOSED_LOOP; - store_aggregate_snapshot_once( - aggregates_snapshot_, - aggregates_snapshot_mtx_, - agg_now); - runtime.has_aggregate_eval = true; - set_runtime_eval_counters(runtime, stats); - collector_completed_.store(true, std::memory_order_relaxed); - stop_flag_.store(true, std::memory_order_relaxed); - break; - } else if (eval.decision == penny::FlowEngine::FlowDecision::FINISHED_NOT_CLOSED_LOOP) { - runtime.aggregates_status = RuntimeStatus::AggregatesStatus::NON_CLOSED_LOOP; - } - - set_runtime_eval_counters(runtime, stats); - runtime.has_aggregate_eval = true; - - if (cfg_.active.aggregates_enabled && - eval.decision != penny::FlowEngine::FlowDecision::FINISHED_CLOSED_LOOP) { - // Aggregate eval at `required_drops_` drops did not - // produce a bidirectional verdict; switch to - // step 3 of the contract and wait for - // closed_loop_required per-flow CLOSED_LOOP - // terminations before declaring the run done. - runtime.aggregates_active = false; - wait_for_closed_loops = true; - TCPLOG_INFO( - "[agg_eval_fallback] aggregate verdict %s after %zu drops; " - "waiting for %llu closed-loop flow%s before finishing", - penny::flow_decision_to_string(eval.decision), - required_drops_, - static_cast(closed_loop_required), - closed_loop_required == 1 ? "" : "s"); - } else { - store_aggregate_snapshot_once( - aggregates_snapshot_, - aggregates_snapshot_mtx_, - agg_now); + finalize_aggregate_verdict( + RuntimeStatus::AggregatesStatus::CLOSED_LOOP, + frozen_agg, + agg_now, + stats); collector_completed_.store(true, std::memory_order_relaxed); stop_flag_.store(true, std::memory_order_relaxed); break; } + switch_to_individual_flow_phase( + RuntimeStatus::AggregatesStatus::NON_CLOSED_LOOP, + "not_closed_loop", + frozen_agg, + agg_now, + stats); } else { - runtime.aggregates_status = RuntimeStatus::AggregatesStatus::PENDING; + set_current_aggregates_status(RuntimeStatus::AggregatesStatus::PENDING); } } } @@ -488,7 +577,7 @@ void AggregatesController::individual_limit_loop() { using namespace std::chrono_literals; while (!stop_flag_.load(std::memory_order_relaxed)) { if (collector_enabled_ && - runtime_setup_mutable().aggregates_status == RuntimeStatus::AggregatesStatus::PENDING) { + current_aggregates_status() == RuntimeStatus::AggregatesStatus::PENDING) { std::this_thread::sleep_for(100ms); continue; } @@ -518,7 +607,7 @@ void AggregatesController::min_closed_loop_loop() { // and the aggregate eval (if enabled) is not still pending. while (!stop_flag_.load(std::memory_order_relaxed)) { if (collector_enabled_ && - runtime_setup_mutable().aggregates_status == RuntimeStatus::AggregatesStatus::PENDING) { + current_aggregates_status() == RuntimeStatus::AggregatesStatus::PENDING) { std::this_thread::sleep_for(100ms); continue; } @@ -533,14 +622,11 @@ void AggregatesController::min_closed_loop_loop() { static_cast(agg.flows_not_closed_loop), static_cast(agg.flows_rst), static_cast(agg.flows_duplicates_exceeded)); - store_aggregate_snapshot_once(aggregates_snapshot_, aggregates_snapshot_mtx_, agg); - // If the aggregate eval has not produced a verdict yet, mark - // it CLOSED_LOOP since we have collected enough closed-loop - // evidence on its own. auto& runtime = runtime_setup_mutable(); - if (runtime.aggregates_status == RuntimeStatus::AggregatesStatus::PENDING) { - runtime.aggregates_status = RuntimeStatus::AggregatesStatus::CLOSED_LOOP; - runtime.has_aggregate_eval = true; + if (current_aggregates_status() == RuntimeStatus::AggregatesStatus::PENDING) { + store_aggregate_snapshot_once(aggregates_snapshot_, aggregates_snapshot_mtx_, agg); + set_current_aggregates_status(RuntimeStatus::AggregatesStatus::CLOSED_LOOP); + set_current_has_aggregate_eval(true); set_runtime_eval_counters(runtime, agg); } collector_completed_.store(true, std::memory_order_relaxed); diff --git a/src/app/core/DropCollectorBinding.cpp b/src/app/core/DropCollectorBinding.cpp index 1494cda..7ed9e14 100644 --- a/src/app/core/DropCollectorBinding.cpp +++ b/src/app/core/DropCollectorBinding.cpp @@ -2,11 +2,9 @@ #include "openpenny/app/core/DropCollectorBinding.h" -#include "openpenny/penny/flow/engine/FlowEngine.h" -#include "openpenny/penny/flow/timer/ThreadFlowEventTimer.h" +#include "openpenny/app/core/PerThreadStats.h" #include -#include namespace openpenny::app { namespace { @@ -20,57 +18,62 @@ bool is_pending_snapshot(const penny::PacketDropSnapshot& snap) noexcept { return snap.state == penny::SnapshotState::Pending; } -} // namespace - -DropCollectorBinding& DropCollectorBinding::instance() { - static DropCollectorBinding inst; - return inst; +bool try_reserve_snapshot_slot(DropCollector& collector) noexcept { + if (collector.snapshot_limit == 0) { + return true; + } + auto reserved = collector.accepted_snapshot_count.load(std::memory_order_relaxed); + while (reserved < collector.snapshot_limit) { + if (collector.accepted_snapshot_count.compare_exchange_weak( + reserved, + reserved + 1, + std::memory_order_acq_rel, + std::memory_order_relaxed)) { + return true; + } + } + return false; } -void DropCollectorBinding::ensure_snapshot_hook() { - std::call_once(hook_once_, []() { - penny::ThreadFlowEventTimerManager::set_snapshot_hook( - [](penny::FlowEngine* flow, - penny::PacketDropId packet_id, - penny::ThreadFlowEventTimerManager::SnapshotEventKind /*kind*/) { - auto& self = DropCollectorBinding::instance(); - const auto binding = self.lookup(flow); - if (!binding.collector) return; - if (!binding.collector->accepting.load(std::memory_order_relaxed)) return; - - const auto& snaps = flow->drop_snapshots(); - const auto key = flow->flow_key(); - auto& shard = binding.collector->shard_for(binding.shard_index); - - std::lock_guard lock(shard.mtx); - if (!binding.collector->accepting.load(std::memory_order_relaxed)) return; - // Mirror any updated packet drop snapshots from the FlowEngine into - // the shared collector so aggregate decisions see fresh stats. - for (const auto& pair : snaps) { - if (packet_id != 0 && pair.first != packet_id) continue; - self.upsert_locked(binding, key, pair.first, pair.second); - } - }); - }); +void maybe_freeze_aggregate_window(DropCollector& collector, + const openpenny::app::AggregatedCounters& agg) { + if (collector.snapshot_limit == 0 || + collector.accepted_snapshot_count.load(std::memory_order_relaxed) < collector.snapshot_limit) { + return; + } + std::lock_guard lock(collector.frozen_aggregate_counters_mtx); + if (!collector.frozen_aggregate_counters) { + collector.frozen_aggregate_counters = agg; + } } -void DropCollectorBinding::bind(penny::FlowEngine* flow, - DropCollectorPtr collector, - const std::string& thread_name, - std::size_t shard_index) { - if (!flow || !collector) return; - std::lock_guard lock(mtx_); - bindings_[flow] = BindingContext{ - std::move(collector), - thread_name, - shard_index - }; +void apply_frozen_aggregate_transition(DropCollector& collector, + const penny::PacketDropSnapshot& before, + const penny::PacketDropSnapshot& after) { + if (before.state == after.state) { + return; + } + std::lock_guard lock(collector.frozen_aggregate_counters_mtx); + if (!collector.frozen_aggregate_counters) { + return; + } + auto& agg = *collector.frozen_aggregate_counters; + if (before.state == penny::SnapshotState::Pending && + agg.pending_retransmissions > 0) { + --agg.pending_retransmissions; + } + if (after.state == penny::SnapshotState::Retransmitted) { + ++agg.retransmitted_packets; + } else if (after.state == penny::SnapshotState::Expired) { + ++agg.non_retransmitted_packets; + } } -void DropCollectorBinding::unbind(penny::FlowEngine* flow) { - if (!flow) return; - std::lock_guard lock(mtx_); - bindings_.erase(flow); +} // namespace + +DropCollectorBinding& DropCollectorBinding::instance() { + static DropCollectorBinding inst; + return inst; } void DropCollectorBinding::upsert(DropCollectorPtr collector, @@ -84,30 +87,43 @@ void DropCollectorBinding::upsert(DropCollectorPtr collector, auto& shard = collector->shard_for(shard_index); std::lock_guard lock(shard.mtx); if (!collector->accepting.load(std::memory_order_relaxed)) return; - upsert_locked(BindingContext{collector, thread_name, shard_index}, key, packet_id, snap); + upsert_locked(*collector, shard, thread_name, key, packet_id, snap); } -DropCollectorBinding::BindingContext DropCollectorBinding::lookup(penny::FlowEngine* flow) const { - std::lock_guard lock(mtx_); - auto it = bindings_.find(flow); - if (it != bindings_.end()) { - return it->second; +void DropCollectorBinding::refresh_from( + DropCollectorPtr collector, + const std::string& thread_name, + std::size_t shard_index, + const FlowKey& key, + const std::vector>& snapshots, + std::size_t start_index) { + if (!collector) return; + if (!collector->accepting.load(std::memory_order_relaxed)) return; + if (start_index >= snapshots.size()) return; + + auto& shard = collector->shard_for(shard_index); + std::lock_guard lock(shard.mtx); + if (!collector->accepting.load(std::memory_order_relaxed)) return; + + for (std::size_t i = start_index; i < snapshots.size(); ++i) { + const auto& pair = snapshots[i]; + upsert_locked(*collector, shard, thread_name, key, pair.first, pair.second); } - return {}; } -void DropCollectorBinding::upsert_locked(const BindingContext& binding, +void DropCollectorBinding::upsert_locked(DropCollector& collector, + DropCollector::Shard& shard, + const std::string& thread_name, const FlowKey& key, penny::PacketDropId packet_id, const penny::PacketDropSnapshot& snap) { - if (!binding.collector) return; - auto& shard = binding.collector->shard_for(binding.shard_index); auto& snapshots = shard.snapshots; DropCollector::SnapshotKey snapshot_key{key, packet_id}; auto index_it = shard.snapshot_index.find(snapshot_key); if (index_it != shard.snapshot_index.end()) { auto& rec = snapshots[index_it->second]; + const auto previous_snapshot = rec.snapshot; auto pending_count = shard.pending_snapshot_count.load(std::memory_order_relaxed); const bool was_pending = is_pending_snapshot(rec.snapshot); const bool now_pending = is_pending_snapshot(snap); @@ -120,9 +136,14 @@ void DropCollectorBinding::upsert_locked(const BindingContext& binding, } shard.pending_snapshot_count.store(pending_count, std::memory_order_relaxed); } + apply_frozen_aggregate_transition(collector, previous_snapshot, snap); } else { + if (!try_reserve_snapshot_slot(collector)) { + return; + } + const auto agg_now = openpenny::app::aggregate_counters(); const auto idx = snapshots.size(); - snapshots.push_back(DropSnapshotRecord{key, packet_id, snap, {}, binding.thread_name}); + snapshots.push_back(DropSnapshotRecord{key, packet_id, snap, agg_now, thread_name}); shard.snapshot_index.emplace(std::move(snapshot_key), idx); shard.snapshot_count.store(snapshots.size(), std::memory_order_relaxed); if (is_pending_snapshot(snap)) { @@ -137,6 +158,7 @@ void DropCollectorBinding::upsert_locked(const BindingContext& binding, shard.latest_snapshot_index.store(idx, std::memory_order_relaxed); shard.latest_snapshot_timestamp.store(ts, std::memory_order_relaxed); } + maybe_freeze_aggregate_window(collector, agg_now); } } diff --git a/src/app/core/OpenpennyPipelineDriver.cpp b/src/app/core/OpenpennyPipelineDriver.cpp index 0667b01..7768f69 100644 --- a/src/app/core/OpenpennyPipelineDriver.cpp +++ b/src/app/core/OpenpennyPipelineDriver.cpp @@ -205,6 +205,9 @@ PipelineSummary drive_pipeline(const Config& cfg_in, const PipelineOptions& opts TCPLOG_INFO("[openpenny] traffic match: %s", net::describe_traffic_match(opts_local.traffic_match).c_str()); + // Number of queues to process traffic. + const unsigned qcount = std::max(1u, opts_local.queue_count); + // Capture the runtime setup at worker start so observers can inspect it. set_runtime_setup(cfg, opts_local, @@ -219,8 +222,6 @@ PipelineSummary drive_pipeline(const Config& cfg_in, const PipelineOptions& opts auto matcher = [&](const FlowKey& key) { return net::traffic_matches_flow(opts_local.traffic_match, key); }; - // Number of queues to process traffic. - const unsigned qcount = std::max(1u, opts_local.queue_count); // ------------------------------------------------------------------ // One-line startup summary at INFO. With many queues the per-worker @@ -338,6 +339,7 @@ PipelineSummary drive_pipeline(const Config& cfg_in, const PipelineOptions& opts aggregates_controller.join(); const auto agg_counters_now = openpenny::app::aggregate_counters(); bool individual_stop_hit = aggregates_controller.individual_stop_hit(); + bool closed_loop_stop_hit = aggregates_controller.closed_loop_stop_hit(); if (!individual_stop_hit && cfg.active.stop_after_individual_flows > 0 && opts_local.mode == PipelineOptions::Mode::Active && @@ -346,12 +348,18 @@ PipelineSummary drive_pipeline(const Config& cfg_in, const PipelineOptions& opts } if (individual_stop_hit && cfg.active.aggregates_enabled && - runtime_setup_mutable().aggregates_status == RuntimeStatus::AggregatesStatus::PENDING && + current_aggregates_status() == RuntimeStatus::AggregatesStatus::PENDING && aggregates_controller.aggregates_ready()) { - runtime_setup_mutable().aggregates_status = RuntimeStatus::AggregatesStatus::DUPLICATES_EXCEEDED; + set_current_aggregates_status(RuntimeStatus::AggregatesStatus::NON_CLOSED_LOOP); } aggregates_controller.populate_drop_snapshots(summary); aggregates_controller.evaluate_pending_if_needed(cfg, summary); + if (!closed_loop_stop_hit && + opts_local.mode == PipelineOptions::Mode::Active && + cfg.active.min_closed_loop_flows > 0 && + agg_counters_now.flows_closed_loop >= cfg.active.min_closed_loop_flows) { + closed_loop_stop_hit = true; + } // Fold per-thread results into a single aggregated ModeResult. ModeResult aggregate{}; @@ -385,12 +393,26 @@ PipelineSummary drive_pipeline(const Config& cfg_in, const PipelineOptions& opts r->passive_gap_summaries.begin(), r->passive_gap_summaries.end()); } + if (!r->closed_loop_flow_summaries.empty()) { + aggregate.closed_loop_flow_summaries.insert( + aggregate.closed_loop_flow_summaries.end(), + r->closed_loop_flow_summaries.begin(), + r->closed_loop_flow_summaries.end()); + } + if (!r->duplicate_exceeded_flow_summaries.empty()) { + aggregate.duplicate_exceeded_flow_summaries.insert( + aggregate.duplicate_exceeded_flow_summaries.end(), + r->duplicate_exceeded_flow_summaries.begin(), + r->duplicate_exceeded_flow_summaries.end()); + } // Completion flags are combined with logical OR. aggregate.penny_completed = aggregate.penny_completed || r->penny_completed; aggregate.aggregates_penny_completed = aggregate.aggregates_penny_completed || r->aggregates_penny_completed; + aggregate.closed_loop_stop_hit = + aggregate.closed_loop_stop_hit || r->closed_loop_stop_hit; } // Use aggregated counters to avoid undercounting packets processed. aggregate.packets_processed = std::max( @@ -398,16 +420,23 @@ PipelineSummary drive_pipeline(const Config& cfg_in, const PipelineOptions& opts static_cast(agg_counters_now.packets)); if (aggregates_controller.collector_completed()) { const bool agg_done_status = - runtime_setup_mutable().aggregates_status != RuntimeStatus::AggregatesStatus::PENDING; + current_aggregates_status() != RuntimeStatus::AggregatesStatus::PENDING; aggregate.aggregates_penny_completed = agg_done_status; aggregate.penny_completed = agg_done_status; } if (individual_stop_hit) { aggregate.penny_completed = true; } + if (closed_loop_stop_hit) { + aggregate.closed_loop_stop_hit = true; + } if (auto snapshot = aggregates_controller.aggregates_snapshot()) { aggregate.aggregates_snapshot = snapshot; } + std::sort(aggregate.closed_loop_flow_summaries.begin(), + aggregate.closed_loop_flow_summaries.end()); + std::sort(aggregate.duplicate_exceeded_flow_summaries.begin(), + aggregate.duplicate_exceeded_flow_summaries.end()); // Only populate the summary if at least one worker reported results. if (any) { diff --git a/src/app/core/PerThreadStats.cpp b/src/app/core/PerThreadStats.cpp index fa5b934..9c1ee35 100644 --- a/src/app/core/PerThreadStats.cpp +++ b/src/app/core/PerThreadStats.cpp @@ -60,6 +60,9 @@ static std::atomic g_counters_size{1}; void init_thread_counters(std::size_t count) { const auto clamped = std::min(count, kMaxCounters); + for (auto& counter : g_counters) { + counter = {}; + } for (auto& counter : g_drop_budget_counters) { counter.drops.store(0, std::memory_order_relaxed); } diff --git a/src/app/core/RuntimeSetup.cpp b/src/app/core/RuntimeSetup.cpp index 0093c82..6a8095e 100644 --- a/src/app/core/RuntimeSetup.cpp +++ b/src/app/core/RuntimeSetup.cpp @@ -2,9 +2,15 @@ #include "openpenny/app/core/RuntimeSetup.h" +#include + namespace openpenny { namespace { RuntimeSetupSnapshot g_runtime_setup; +std::atomic g_aggregates_active{true}; +std::atomic g_aggregates_status{ + static_cast(RuntimeStatus::AggregatesStatus::PENDING)}; +std::atomic g_has_aggregate_eval{false}; } void set_runtime_setup(const Config& cfg, @@ -15,6 +21,16 @@ void set_runtime_setup(const Config& cfg, g_runtime_setup.options = opts; g_runtime_setup.use_xdp = use_xdp; g_runtime_setup.use_dpdk = use_dpdk; + g_runtime_setup.aggregates_active = true; + g_runtime_setup.testing_finished = false; + g_runtime_setup.aggregates_status = RuntimeStatus::AggregatesStatus::PENDING; + g_runtime_setup.aggregate_eval_counters = {}; + g_runtime_setup.has_aggregate_eval = false; + g_aggregates_active.store(true, std::memory_order_release); + g_aggregates_status.store( + static_cast(RuntimeStatus::AggregatesStatus::PENDING), + std::memory_order_release); + g_has_aggregate_eval.store(false, std::memory_order_release); } const RuntimeSetupSnapshot& current_runtime_setup() { @@ -25,4 +41,32 @@ RuntimeSetupSnapshot& runtime_setup_mutable() { return g_runtime_setup; } +bool current_aggregates_active() noexcept { + return g_aggregates_active.load(std::memory_order_acquire); +} + +void set_current_aggregates_active(bool value) noexcept { + g_runtime_setup.aggregates_active = value; + g_aggregates_active.store(value, std::memory_order_release); +} + +RuntimeStatus::AggregatesStatus current_aggregates_status() noexcept { + return static_cast( + g_aggregates_status.load(std::memory_order_acquire)); +} + +void set_current_aggregates_status(RuntimeStatus::AggregatesStatus status) noexcept { + g_runtime_setup.aggregates_status = status; + g_aggregates_status.store(static_cast(status), std::memory_order_release); +} + +bool current_has_aggregate_eval() noexcept { + return g_has_aggregate_eval.load(std::memory_order_acquire); +} + +void set_current_has_aggregate_eval(bool value) noexcept { + g_runtime_setup.has_aggregate_eval = value; + g_has_aggregate_eval.store(value, std::memory_order_release); +} + } // namespace openpenny diff --git a/src/app/core/active/ActiveTestPipeline.cpp b/src/app/core/active/ActiveTestPipeline.cpp index b0f7348..f24cbcd 100644 --- a/src/app/core/active/ActiveTestPipeline.cpp +++ b/src/app/core/active/ActiveTestPipeline.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +31,20 @@ namespace openpenny { namespace { thread_local ActiveTestPipelineRunner* tls_runner = nullptr; + +std::string format_closed_loop_flow_summary(const FlowKey& key, + const penny::FlowEngine& flow) { + std::ostringstream summary; + summary << flow_debug_details(key) + << " data=" << flow.data_packets() + << " dropped=" << flow.dropped_packets() + << " rtx=" << flow.retransmitted_packets() + << " non_rtx=" << flow.non_retransmitted_packets() + << " dup=" << flow.duplicate_packets() + << " in_order=" << flow.in_order_packets() + << " out_of_order=" << flow.out_of_order_packets(); + return summary.str(); +} } // namespace // Constructs an active OpenPenny traffic processing pipeline runner. @@ -53,7 +68,6 @@ ActiveTestPipelineRunner::ActiveTestPipelineRunner( std::chrono::duration(cfg.active.flow_idle_timeout_seconds))} // Idle expiry window. { if (drop_collector_) { - app::DropCollectorBinding::instance().ensure_snapshot_hook(); flow_manager_.set_drop_sink( [collector = drop_collector_, name = thread_name_, @@ -68,6 +82,21 @@ ActiveTestPipelineRunner::ActiveTestPipelineRunner( packet_id, snapshot); }); + flow_manager_.set_snapshot_refresh_sink( + [collector = drop_collector_, + name = thread_name_, + shard_index = drop_collector_shard_index_]( + const FlowKey& key, + const std::vector>& snapshots, + std::size_t start_index) { + app::DropCollectorBinding::instance().refresh_from( + collector, + name, + shard_index, + key, + snapshots, + start_index); + }); } } @@ -178,7 +207,8 @@ void ActiveTestPipelineRunner::after_poll( if (idle_timeout_.count() > 0) { expire_idle_flows(now); } - sweep_expired_snapshots(now); + evaluate_individual_flows_if_enabled(); + complete_resolved_terminal_flows(); // Mirrors the post-loop drain in the legacy run() so deferred // expirations aren't stranded between iterations. penny::ThreadFlowEventTimerManager::instance().drain_callbacks(); @@ -187,17 +217,23 @@ void ActiveTestPipelineRunner::after_poll( void ActiveTestPipelineRunner::on_closing() { // Flush any callbacks that arrived after the final poll iteration. penny::ThreadFlowEventTimerManager::instance().drain_callbacks(); - sweep_expired_snapshots(std::chrono::steady_clock::now()); + evaluate_individual_flows_if_enabled(); + complete_resolved_terminal_flows(); } void ActiveTestPipelineRunner::finalize(ModeResult& result) { - // Expire any pending snapshots on remaining flows to ensure expirations are logged/applied. + // Resolve any pending snapshots on remaining flows without bypassing the + // configured retransmission timeout at shutdown. flow_manager_.for_each_flow([](const FlowKey&, penny::FlowEngineEntry& entry) { - entry.flow.expire_all_pending_snapshots(); + entry.flow.resolve_pending_snapshots(std::chrono::steady_clock::now()); }); + evaluate_individual_flows_if_enabled(); + complete_resolved_terminal_flows(); result.packets_forwarded = total_pkts_forwarded_; result.forward_errors = total_forward_errors_; + result.closed_loop_flow_summaries = closed_loop_flow_summaries_; + result.duplicate_exceeded_flow_summaries = duplicate_exceeded_flow_summaries_; } // --------------------------------------------------------------------------- @@ -209,32 +245,105 @@ void ActiveTestPipelineRunner::expire_idle_flows(const std::chrono::steady_clock if (idle_timeout_.count() <= 0) return; auto expired = flow_manager_.collect_idle_flows(now, idle_timeout_); for (const auto& key : expired) { - if (auto* entry = flow_manager_.find(key)) { - app::DropCollectorBinding::instance().unbind(&entry->flow); - } - flow_manager_.complete_flow(key, "idle_timeout"); + complete_flow_with_summary(key, "idle_timeout"); } } -void ActiveTestPipelineRunner::sweep_expired_snapshots(const std::chrono::steady_clock::time_point& now) { - // Expire packet drop snapshots using the configured retransmission timeout (seconds). - const auto retransmission_timeout = std::chrono::duration(cfg_.active.rtt_timeout_factor); - if (retransmission_timeout.count() <= 0.0) return; - flow_manager_.for_each_flow([&](const FlowKey&, penny::FlowEngineEntry& entry) { - const auto& snaps = entry.flow.drop_snapshots(); - for (const auto& pair : snaps) { - if (pair.second.state != penny::SnapshotState::Pending) continue; - if (now - pair.second.timestamp >= retransmission_timeout) { - if (TCPLOG_ENABLED(INFO)) { - const auto packet_id_text = penny::format_packet_drop_id(pair.first); - TCPLOG_INFO("[packet_expired] flow=%s packet_id=%s", - flow_debug_details(entry.flow.flow_key()).c_str(), - packet_id_text.c_str()); +bool ActiveTestPipelineRunner::individual_flow_evaluation_enabled() const { + const bool aggregate_phase_configured = + cfg_.active.aggregates_enabled && + cfg_.active.max_drops_aggregates > 0; + if (!aggregate_phase_configured) { + return true; + } + const auto status = current_aggregates_status(); + return status == RuntimeStatus::AggregatesStatus::NON_CLOSED_LOOP || + status == RuntimeStatus::AggregatesStatus::DUPLICATES_EXCEEDED; +} + +void ActiveTestPipelineRunner::evaluate_individual_flows_if_enabled() { + if (!individual_flow_evaluation_enabled()) { + return; + } + + flow_manager_.for_each_flow([&](const FlowKey& key, penny::FlowEngineEntry& entry) { + const bool immutable_terminal_state = + entry.state == penny::FlowTrackingState::INTERRUPTED_RST || + entry.state == penny::FlowTrackingState::INTERRUPTED_DUPLICATE_EXCEEDED || + entry.state == penny::FlowTrackingState::INTERRUPTED_OUT_OF_ORDER_EXCEEDED || + entry.state == penny::FlowTrackingState::FINISHED; + + if (!immutable_terminal_state) { + if (flow_out_of_order_threshold_exceeded(entry.flow)) { + entry.state = penny::FlowTrackingState::INTERRUPTED_OUT_OF_ORDER_EXCEEDED; + if (TCPLOG_ENABLED(DEBUG)) { + const auto flow_tag = flow_debug_details(key); + TCPLOG_DEBUG("Out-of-order threshold exceeded %s", flow_tag.c_str()); } - entry.flow.mark_snapshot_expired(pair.first); + return; } + if (flow_duplicate_threshold_exceeded(entry.flow)) { + entry.state = penny::FlowTrackingState::INTERRUPTED_DUPLICATE_EXCEEDED; + if (TCPLOG_ENABLED(DEBUG)) { + const auto flow_tag = flow_debug_details(key); + TCPLOG_DEBUG("Duplicate threshold exceeded %s", flow_tag.c_str()); + } + return; + } + } + + if (entry.flow.final_decision() == penny::FlowEngine::FlowDecision::PENDING) { + entry.flow.evaluate_if_ready(); + } + + if (entry.state != penny::FlowTrackingState::CONNECTION_CLOSED_FIN && + !immutable_terminal_state && + entry.flow.final_decision() != penny::FlowEngine::FlowDecision::PENDING) { + entry.state = penny::FlowTrackingState::FINISHED; + } + }); +} + +void ActiveTestPipelineRunner::complete_resolved_terminal_flows() { + std::vector completed_keys; + const bool individual_eval_enabled = individual_flow_evaluation_enabled(); + flow_manager_.for_each_flow([&](const FlowKey& key, penny::FlowEngineEntry& entry) { + const bool terminal_state = + entry.state == penny::FlowTrackingState::INTERRUPTED_RST || + entry.state == penny::FlowTrackingState::INTERRUPTED_DUPLICATE_EXCEEDED || + entry.state == penny::FlowTrackingState::INTERRUPTED_OUT_OF_ORDER_EXCEEDED || + entry.state == penny::FlowTrackingState::CONNECTION_CLOSED_FIN || + entry.state == penny::FlowTrackingState::FINISHED; + if (!terminal_state) return; + if (!individual_eval_enabled && + entry.flow.final_decision() == penny::FlowEngine::FlowDecision::PENDING) { + return; } + if (entry.flow.pending_retransmissions() != 0) return; + completed_keys.push_back(key); }); + + for (const auto& key : completed_keys) { + complete_flow_with_summary(key, "terminal_state"); + } +} + +void ActiveTestPipelineRunner::complete_flow_with_summary(const FlowKey& key, const char* reason) { + auto* existing = flow_manager_.find(key); + if (!existing) { + return; + } + existing->flow.resolve_pending_snapshots(std::chrono::steady_clock::now()); + const auto final_decision = existing->flow.final_decision(); + const auto summary = format_closed_loop_flow_summary(key, existing->flow); + if (final_decision == penny::FlowEngine::FlowDecision::FINISHED_CLOSED_LOOP) { + closed_loop_flow_summaries_.push_back(summary); + } + if (existing->state == penny::FlowTrackingState::INTERRUPTED_DUPLICATE_EXCEEDED || + final_decision == penny::FlowEngine::FlowDecision::FINISHED_DUPLICATE_EXCEEDED) { + duplicate_exceeded_flow_summaries_.push_back(summary); + } + flow_manager_.complete_flow(key, reason); } void ActiveTestPipelineRunner::handle_packet(const net::PacketView& packet, @@ -276,45 +385,37 @@ void ActiveTestPipelineRunner::handle_packet(const net::PacketView& packet, penny::FlowEngineEntry* ActiveTestPipelineRunner::admit_or_forward_flow( const net::PacketView& packet, const std::chrono::steady_clock::time_point& now) { + auto* flow_entry = flow_manager_.find(packet.flow); // Skip flows we've already monitored in the past. - if (flow_manager_.was_completed(packet.flow)) { + if (!flow_entry && flow_manager_.was_completed(packet.flow)) { forward_packet(packet); return nullptr; } - const auto monitor_state = flow_manager_.flow_state(packet.flow); - if (monitor_state == penny::FlowTrackingState::NOT_ACTIONABLE && - flow_manager_.is_flow_monitoring_capacity_full()) { + if (!flow_entry && flow_manager_.is_flow_monitoring_capacity_full()) { // Flow is not tracked, and there are no spare monitoring slots. forward_packet(packet); return nullptr; } - if (monitor_state == penny::FlowTrackingState::INTERRUPTED_RST || - monitor_state == penny::FlowTrackingState::INTERRUPTED_DUPLICATE_EXCEEDED || - monitor_state == penny::FlowTrackingState::INTERRUPTED_OUT_OF_ORDER_EXCEEDED || - monitor_state == penny::FlowTrackingState::CONNECTION_CLOSED_FIN || - monitor_state == penny::FlowTrackingState::FINISHED) { - // Mark flow as complete and free the monitoring slot. - if (auto* existing = flow_manager_.find(packet.flow)) { - app::DropCollectorBinding::instance().unbind(&existing->flow); + if (flow_entry && + (flow_entry->state == penny::FlowTrackingState::INTERRUPTED_RST || + flow_entry->state == penny::FlowTrackingState::INTERRUPTED_DUPLICATE_EXCEEDED || + flow_entry->state == penny::FlowTrackingState::INTERRUPTED_OUT_OF_ORDER_EXCEEDED || + flow_entry->state == penny::FlowTrackingState::CONNECTION_CLOSED_FIN || + flow_entry->state == penny::FlowTrackingState::FINISHED)) { + // Terminal flows with unresolved drops stay resident until the + // retransmission gap is filled or the timeout expires. + if (flow_entry->flow.pending_retransmissions() == 0) { + complete_flow_with_summary(packet.flow, "terminal_state"); } - flow_manager_.complete_flow(packet.flow, "terminal_state"); forward_packet(packet); return nullptr; } - // Check whether the packet belongs to one of the flows currently being monitored. - auto* flow_entry = flow_manager_.find(packet.flow); - if (flow_entry) { const auto penny_flow_decision = flow_entry->flow.final_decision(); - if (penny_flow_decision != penny::FlowEngine::FlowDecision::PENDING){ - // From Penny perspective the test for the flow is done. - - - } const bool terminal_state = flow_entry->state == penny::FlowTrackingState::INTERRUPTED_RST || flow_entry->state == penny::FlowTrackingState::INTERRUPTED_DUPLICATE_EXCEEDED || @@ -324,35 +425,25 @@ penny::FlowEngineEntry* ActiveTestPipelineRunner::admit_or_forward_flow( if (!terminal_state && penny_flow_decision != penny::FlowEngine::FlowDecision::PENDING) { flow_entry->state = penny::FlowTrackingState::FINISHED; - app::DropCollectorBinding::instance().unbind(&flow_entry->flow); - flow_manager_.complete_flow(packet.flow, "penny_decision"); + complete_flow_with_summary(packet.flow, "penny_decision"); forward_packet(packet); return nullptr; } } - if (!flow_entry && !flow_manager_.is_flow_monitoring_capacity_full()) { + if (!flow_entry) { try { const bool is_syn = packet.tcp.flags_view().syn; - const bool inserted = flow_manager_.add_new_flow( + flow_entry = flow_manager_.add_new_flow( packet.flow, packet.tcp.seq, static_cast(packet.payload_bytes), is_syn, now); - if (inserted) { - if (drop_collector_) { - if (auto* entry = flow_manager_.find(packet.flow)) { - app::DropCollectorBinding::instance().bind( - &entry->flow, - drop_collector_, - thread_name_, - drop_collector_shard_index_); - } - } + if (flow_entry) { if (TCPLOG_ENABLED(INFO)) { const auto flow_tag = flow_debug_details(packet.flow); - TCPLOG_INFO("[monitor_start] %s flow=%s seq=%" PRIu32 " payload_bytes=%zu", + TCPLOG_INFO("[flow_track] action=start trigger=%s flow=%s seq=%" PRIu32 " payload=%zu", is_syn ? "syn" : "data", flow_tag.c_str(), packet.tcp.seq, @@ -426,7 +517,7 @@ bool ActiveTestPipelineRunner::promote_pending_flow( return false; } -// Fast-path check for RST that marks outstanding drop snapshots as expired. +// Fast-path check for RST that marks outstanding drop snapshots as invalid. void ActiveTestPipelineRunner::handle_rst(penny::FlowEngineEntry& entry, const net::PacketView& packet) { if ((packet.tcp.flags & 0x04) == 0) return; // RST bit not set. @@ -450,7 +541,8 @@ void ActiveTestPipelineRunner::handle_rst(penny::FlowEngineEntry& entry, const n entry.state = penny::FlowTrackingState::INTERRUPTED_RST; } -// Fast-path check for FIN that marks outstanding drop snapshots as expired. +// Fast-path check for FIN. A clean close means any still-missing dropped +// payload was not retransmitted before teardown, so we resolve it immediately. void ActiveTestPipelineRunner::handle_fin(penny::FlowEngineEntry& entry, const net::PacketView& packet) { if ((packet.tcp.flags & 0x01) == 0) return; // FIN bit not set. @@ -460,13 +552,12 @@ void ActiveTestPipelineRunner::handle_fin(penny::FlowEngineEntry& entry, const n for (const auto& snap_pair : snapshots) { const auto& snapshot = snap_pair.second; - // Skip snapshots already decided. if (snapshot.state != penny::SnapshotState::Pending || snapshot.stats.pending_retransmissions() == 0) { continue; } - flow.mark_snapshot_invalid(snap_pair.first); // Treat pending gaps as invalid on close. + flow.mark_snapshot_expired(snap_pair.first); if (flow.pending_retransmissions() == 0) break; } penny::ThreadFlowEventTimerManager::instance().purge_flow(&flow); @@ -525,7 +616,9 @@ void ActiveTestPipelineRunner::handle_data_packet(penny::FlowEngineEntry& entry, end_seq, entry.flow.highest_sequence()); } - const bool ooo_exceeded = flow_out_of_order_threshold_exceeded(entry.flow); + const bool ooo_exceeded = + individual_flow_evaluation_enabled() && + flow_out_of_order_threshold_exceeded(entry.flow); if (ooo_exceeded) { entry.state = penny::FlowTrackingState::INTERRUPTED_OUT_OF_ORDER_EXCEEDED; if (TCPLOG_ENABLED(DEBUG)) { @@ -553,7 +646,9 @@ void ActiveTestPipelineRunner::handle_data_packet(penny::FlowEngineEntry& entry, penny::ThreadFlowEventTimerManager::instance().enqueue_duplicate(&entry.flow, start_seq, packet.payload_bytes); // Logging handled in timer callback. - const bool dup_exceeded = flow_duplicate_threshold_exceeded(entry.flow); + const bool dup_exceeded = + individual_flow_evaluation_enabled() && + flow_duplicate_threshold_exceeded(entry.flow); if (dup_exceeded) { entry.state = penny::FlowTrackingState::INTERRUPTED_DUPLICATE_EXCEEDED; if (TCPLOG_ENABLED(DEBUG)) { @@ -576,7 +671,9 @@ void ActiveTestPipelineRunner::handle_data_packet(penny::FlowEngineEntry& entry, penny::ThreadFlowEventTimerManager::instance().enqueue_duplicate(&entry.flow, start_seq, packet.payload_bytes); // Logging handled in timer callback. - const bool dup_exceeded = flow_duplicate_threshold_exceeded(entry.flow); + const bool dup_exceeded = + individual_flow_evaluation_enabled() && + flow_duplicate_threshold_exceeded(entry.flow); if (dup_exceeded) { entry.state = penny::FlowTrackingState::INTERRUPTED_DUPLICATE_EXCEEDED; if (TCPLOG_ENABLED(DEBUG)) { diff --git a/src/app/worker/penny_worker.cpp b/src/app/worker/penny_worker.cpp index 85aa22c..e82edf4 100644 --- a/src/app/worker/penny_worker.cpp +++ b/src/app/worker/penny_worker.cpp @@ -6,6 +6,7 @@ #include "openpenny/egress/PacketSink.h" #include "openpenny/log/Log.h" +#include #include #include #include @@ -297,9 +298,7 @@ int main(int argc, char** argv) { const uint64_t aggregates_snapshots = aggregates_enabled ? summary.drop_snapshots.size() : 0; openpenny::app::AggregatedCounters agg_snapshot{}; if (is_active_mode) { - agg_snapshot = res.aggregates_snapshot - ? *res.aggregates_snapshot - : openpenny::app::aggregate_counters(); + agg_snapshot = openpenny::app::aggregate_counters(); } std::cout << "aggregates_status=" << aggregates_status_str << "\n"; std::cout << "aggregates_decision_complete=" << (aggregates_done ? 1 : 0) << "\n"; @@ -315,6 +314,12 @@ int main(int argc, char** argv) { std::cout << "aggregate_flows_not_closed_loop=" << agg_snapshot.flows_not_closed_loop << "\n"; std::cout << "aggregate_flows_rst=" << agg_snapshot.flows_rst << "\n"; std::cout << "aggregate_flows_duplicates_exceeded=" << agg_snapshot.flows_duplicates_exceeded << "\n"; + const uint64_t closed_loop_flows_found = std::max( + agg_snapshot.flows_closed_loop, + res.closed_loop_flow_summaries.size()); + const uint64_t duplicate_exceeded_flows_found = std::max( + agg_snapshot.flows_duplicates_exceeded, + res.duplicate_exceeded_flow_summaries.size()); // Emit JSON summary similar to CLI output. nlohmann::json j; j["test_id"] = args.test_id; @@ -357,6 +362,35 @@ int main(int argc, char** argv) { {"rst", agg_snapshot.flows_rst}, {"duplicates_exceeded", agg_snapshot.flows_duplicates_exceeded} }; + j["closed_loop_flows_found"] = closed_loop_flows_found; + j["duplicate_exceeded_flows_found"] = duplicate_exceeded_flows_found; + j["closed_loop_flows"] = nlohmann::json::array(); + for (const auto& line : res.closed_loop_flow_summaries) { + j["closed_loop_flows"].push_back(line); + } + j["duplicate_exceeded_flows"] = nlohmann::json::array(); + for (const auto& line : res.duplicate_exceeded_flow_summaries) { + j["duplicate_exceeded_flows"].push_back(line); + } + std::string end_state; + if (aggregates_done) { + end_state = "Aggregates completed (" + aggregates_status_str + ")"; + } else if (res.penny_completed) { + end_state = is_active_mode + ? "Penny heuristics completed" + : "Passive pipeline completed (flows=" + std::to_string(res.passive_flows_finished) + ")"; + } else { + end_state = "Reader/pipeline error"; + } + if (closed_loop_flows_found > 0) { + end_state += ", found " + std::to_string(closed_loop_flows_found) + " closed-loop flow"; + if (closed_loop_flows_found != 1) end_state += "s"; + } + if (duplicate_exceeded_flows_found > 0) { + end_state += ", found " + std::to_string(duplicate_exceeded_flows_found) + " duplicate-exceeded flow"; + if (duplicate_exceeded_flows_found != 1) end_state += "s"; + } + j["end_state"] = end_state; // Aggregate snapshot counters, if available. if (res.passive_flows_finished > 0 || !res.passive_gap_summaries.empty()) { nlohmann::json passive; diff --git a/src/egress/RawNicSink.cpp b/src/egress/RawNicSink.cpp index 963c4ae..291b935 100644 --- a/src/egress/RawNicSink.cpp +++ b/src/egress/RawNicSink.cpp @@ -33,10 +33,12 @@ RawNicSink::~RawNicSink() { close(); } -bool RawNicSink::open() { +int RawNicSink::open_socket_fd(bool resolve_ifindex, bool log_failures) { if (cfg_.device.empty()) { - TCPLOG_ERROR("RawNicSink: device name is required%s", ""); - return false; + if (log_failures) { + TCPLOG_ERROR("RawNicSink: device name is required%s", ""); + } + return -1; } // SOCK_RAW (not SOCK_DGRAM): we want to forward the original frame @@ -47,60 +49,85 @@ bool RawNicSink::open() { // destination. SOCK_RAW preserves the original L2 verbatim. // // ETH_P_ALL on the protocol so we can write any frame type. - fd_ = ::socket(AF_PACKET, SOCK_RAW | SOCK_NONBLOCK, htons(ETH_P_ALL)); - if (fd_ < 0) { - TCPLOG_ERROR("RawNicSink: socket(AF_PACKET, SOCK_RAW) failed: %s (need CAP_NET_RAW)", - std::strerror(errno)); - return false; + int fd = ::socket(AF_PACKET, SOCK_RAW | SOCK_NONBLOCK, htons(ETH_P_ALL)); + if (fd < 0) { + if (log_failures) { + TCPLOG_ERROR("RawNicSink: socket(AF_PACKET, SOCK_RAW) failed: %s (need CAP_NET_RAW)", + std::strerror(errno)); + } + return -1; } - // Resolve ifindex once so the hot path doesn't need another syscall. - ifreq ifr{}; - std::strncpy(ifr.ifr_name, cfg_.device.c_str(), IFNAMSIZ - 1); - if (::ioctl(fd_, SIOCGIFINDEX, &ifr) != 0) { - const int saved = errno; - TCPLOG_ERROR("RawNicSink: SIOCGIFINDEX('%s') failed: %s", - cfg_.device.c_str(), std::strerror(saved)); - ::close(fd_); - fd_ = -1; - errno = saved; - return false; + if (resolve_ifindex || if_index_ <= 0) { + ifreq ifr{}; + std::strncpy(ifr.ifr_name, cfg_.device.c_str(), IFNAMSIZ - 1); + if (::ioctl(fd, SIOCGIFINDEX, &ifr) != 0) { + const int saved = errno; + if (log_failures) { + TCPLOG_ERROR("RawNicSink: SIOCGIFINDEX('%s') failed: %s", + cfg_.device.c_str(), std::strerror(saved)); + } + ::close(fd); + errno = saved; + return -1; + } + if_index_ = ifr.ifr_ifindex; } - if_index_ = ifr.ifr_ifindex; - // Bind to the interface so sendto(2) without a sockaddr works too, and - // so the kernel drops incoming frames targeted at other ifaces. sockaddr_ll addr{}; addr.sll_family = AF_PACKET; addr.sll_protocol = htons(ETH_P_ALL); addr.sll_ifindex = if_index_; - if (::bind(fd_, reinterpret_cast(&addr), sizeof(addr)) != 0) { + if (::bind(fd, reinterpret_cast(&addr), sizeof(addr)) != 0) { const int saved = errno; - TCPLOG_ERROR("RawNicSink: bind to '%s' (ifindex=%d) failed: %s", - cfg_.device.c_str(), if_index_, std::strerror(saved)); - ::close(fd_); - fd_ = -1; - if_index_ = -1; + if (log_failures) { + TCPLOG_ERROR("RawNicSink: bind to '%s' (ifindex=%d) failed: %s", + cfg_.device.c_str(), if_index_, std::strerror(saved)); + } + ::close(fd); errno = saved; - return false; + return -1; } if (cfg_.raw_nic_bind_device) { - // Redundant with the bind() above on modern kernels, but harmless, - // and it mirrors the IPPROTO_RAW path for consistency. - if (::setsockopt(fd_, SOL_SOCKET, SO_BINDTODEVICE, + if (::setsockopt(fd, SOL_SOCKET, SO_BINDTODEVICE, cfg_.device.c_str(), cfg_.device.size()) != 0) { TCPLOG_WARN("RawNicSink: SO_BINDTODEVICE('%s') failed: %s", cfg_.device.c_str(), std::strerror(errno)); } } + int sndbuf = 16 * 1024 * 1024; + if (::setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &sndbuf, sizeof(sndbuf)) != 0) { + TCPLOG_WARN("RawNicSink: SO_SNDBUF(%d) failed: %s", + sndbuf, std::strerror(errno)); + } + + return fd; +} + +bool RawNicSink::open() { + fd_ = open_socket_fd(true, true); + if (fd_ < 0) { + return false; + } + TCPLOG_INFO("RawNicSink: opened (fd=%d, device='%s', ifindex=%d)", fd_, cfg_.device.c_str(), if_index_); return true; } void RawNicSink::close() noexcept { + std::vector to_close; + { + std::lock_guard lock(fds_mtx_); + to_close.swap(additional_fds_); + } + for (int fd : to_close) { + if (fd >= 0) { + ::close(fd); + } + } if (fd_ >= 0) { ::close(fd_); fd_ = -1; @@ -108,8 +135,37 @@ void RawNicSink::close() noexcept { if_index_ = -1; } +int RawNicSink::thread_fd() { + thread_local int t_fd = -1; + thread_local const RawNicSink* t_owner = nullptr; + if (t_owner == this && t_fd >= 0) { + return t_fd; + } + if (fd_ < 0 || if_index_ <= 0) { + t_owner = this; + t_fd = -1; + return t_fd; + } + + int fd = open_socket_fd(false, false); + if (fd < 0) { + t_owner = this; + t_fd = fd_; + return t_fd; + } + + { + std::lock_guard lock(fds_mtx_); + additional_fds_.push_back(fd); + } + t_owner = this; + t_fd = fd; + return t_fd; +} + bool RawNicSink::write(const net::PacketView& packet) { - if (fd_ < 0) { + const int fd = thread_fd(); + if (fd < 0) { return false; } @@ -147,7 +203,7 @@ bool RawNicSink::write(const net::PacketView& packet) { dst.sll_protocol = htons(ETH_P_ALL); dst.sll_ifindex = if_index_; - const ssize_t written = ::sendto(fd_, + const ssize_t written = ::sendto(fd, buf, static_cast(len), 0, @@ -158,12 +214,21 @@ bool RawNicSink::write(const net::PacketView& packet) { return true; } const int err = errno; - if (err != EAGAIN && err != EWOULDBLOCK) { - TCPLOG_WARN("RawNicSink::write (%u bytes) failed on fd=%d (device='%s'): %s", - static_cast(len), fd_, - cfg_.device.c_str(), std::strerror(err)); + if (err == EAGAIN || err == EWOULDBLOCK) { stats_.errors.fetch_add(1, std::memory_order_relaxed); + if (!backpressure_logged_.exchange(true, std::memory_order_relaxed)) { + TCPLOG_WARN( + "RawNicSink: TX backpressure on fd=%d (EAGAIN/EWOULDBLOCK); " + "dropping packets. This can induce real TCP retransmissions at " + "high rates because OpenPenny does not keep a copy-backed TX queue.", + fd); + } + return false; } + TCPLOG_WARN("RawNicSink::write (%u bytes) failed on fd=%d (device='%s'): %s", + static_cast(len), fd, + cfg_.device.c_str(), std::strerror(err)); + stats_.errors.fetch_add(1, std::memory_order_relaxed); return false; } diff --git a/src/egress/RawSocketSink.cpp b/src/egress/RawSocketSink.cpp index 59c7707..2d516d0 100644 --- a/src/egress/RawSocketSink.cpp +++ b/src/egress/RawSocketSink.cpp @@ -28,30 +28,42 @@ RawSocketSink::~RawSocketSink() { close(); } -bool RawSocketSink::open() { - fd_ = ::socket(AF_INET, SOCK_RAW | SOCK_NONBLOCK, IPPROTO_RAW); - if (fd_ < 0) { - TCPLOG_ERROR("RawSocketSink: socket(AF_INET, SOCK_RAW, IPPROTO_RAW) failed: %s", - std::strerror(errno)); - return false; +int RawSocketSink::open_socket_fd(bool log_failures) { + int fd = ::socket(AF_INET, SOCK_RAW | SOCK_NONBLOCK, IPPROTO_RAW); + if (fd < 0) { + if (log_failures) { + TCPLOG_ERROR("RawSocketSink: socket(AF_INET, SOCK_RAW, IPPROTO_RAW) failed: %s", + std::strerror(errno)); + } + return -1; } if (!cfg_.device.empty()) { - if (::setsockopt(fd_, SOL_SOCKET, SO_BINDTODEVICE, + if (::setsockopt(fd, SOL_SOCKET, SO_BINDTODEVICE, cfg_.device.c_str(), cfg_.device.size()) != 0) { const int saved = errno; TCPLOG_WARN("RawSocketSink: SO_BINDTODEVICE('%s') failed: %s", cfg_.device.c_str(), std::strerror(saved)); - // SO_BINDTODEVICE requires CAP_NET_RAW; treat as non-fatal so - // the sink still works when the operator just hasn't named a - // preferred egress device. } } - // IPPROTO_RAW already implies IP_HDRINCL, but set it explicitly so the - // behaviour is obvious to reviewers tracing packet construction. int one = 1; - (void)::setsockopt(fd_, IPPROTO_IP, IP_HDRINCL, &one, sizeof(one)); + (void)::setsockopt(fd, IPPROTO_IP, IP_HDRINCL, &one, sizeof(one)); + + int sndbuf = 16 * 1024 * 1024; + if (::setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &sndbuf, sizeof(sndbuf)) != 0) { + TCPLOG_WARN("RawSocketSink: SO_SNDBUF(%d) failed: %s", + sndbuf, std::strerror(errno)); + } + + return fd; +} + +bool RawSocketSink::open() { + fd_ = open_socket_fd(true); + if (fd_ < 0) { + return false; + } TCPLOG_INFO("RawSocketSink: opened (fd=%d, device='%s')", fd_, cfg_.device.c_str()); @@ -59,18 +71,60 @@ bool RawSocketSink::open() { } void RawSocketSink::close() noexcept { + std::vector to_close; + { + std::lock_guard lock(fds_mtx_); + to_close.swap(additional_fds_); + } + for (int fd : to_close) { + if (fd >= 0) { + ::close(fd); + } + } if (fd_ >= 0) { ::close(fd_); fd_ = -1; } } +int RawSocketSink::thread_fd() { + thread_local int t_fd = -1; + thread_local const RawSocketSink* t_owner = nullptr; + if (t_owner == this && t_fd >= 0) { + return t_fd; + } + if (fd_ < 0) { + t_owner = this; + t_fd = -1; + return t_fd; + } + + int fd = open_socket_fd(false); + if (fd < 0) { + t_owner = this; + t_fd = fd_; + return t_fd; + } + + { + std::lock_guard lock(fds_mtx_); + additional_fds_.push_back(fd); + } + t_owner = this; + t_fd = fd; + return t_fd; +} + bool RawSocketSink::write(const net::PacketView& packet) { - if (fd_ < 0 || !packet.layer3_ptr || packet.layer3_length < 20) { + if (!packet.layer3_ptr || packet.layer3_length < 20) { // IPv4 header is at least 20 bytes; anything shorter isn't a // routable datagram and the kernel would reject it anyway. return false; } + const int fd = thread_fd(); + if (fd < 0) { + return false; + } sockaddr_in dst{}; dst.sin_family = AF_INET; @@ -80,7 +134,7 @@ bool RawSocketSink::write(const net::PacketView& packet) { std::memcpy(&dst.sin_addr.s_addr, packet.layer3_ptr + 16, sizeof(dst.sin_addr.s_addr)); - const ssize_t written = ::sendto(fd_, + const ssize_t written = ::sendto(fd, packet.layer3_ptr, static_cast(packet.layer3_length), 0, @@ -92,9 +146,14 @@ bool RawSocketSink::write(const net::PacketView& packet) { } const int err = errno; if (err == EAGAIN || err == EWOULDBLOCK) { - // Transient back-pressure on a non-blocking raw socket; the - // packet is dropped and no error is recorded (the same policy - // the active path uses). + stats_.errors.fetch_add(1, std::memory_order_relaxed); + if (!backpressure_logged_.exchange(true, std::memory_order_relaxed)) { + TCPLOG_WARN( + "RawSocketSink: TX backpressure on fd=%d (EAGAIN/EWOULDBLOCK); " + "dropping packets. This can induce real TCP retransmissions at " + "high rates because OpenPenny does not keep a copy-backed TX queue.", + fd); + } return false; } if (err == EMSGSIZE) { @@ -116,14 +175,14 @@ bool RawSocketSink::write(const net::PacketView& packet) { "the packet size. Further oversized drops will be " "counted silently.", static_cast(packet.layer3_length), - fd_, + fd, cfg_.device.empty() ? "" : cfg_.device.c_str(), static_cast(packet.layer3_length)); } return false; } TCPLOG_WARN("RawSocketSink::write (%u bytes) failed on fd=%d: %s", - static_cast(packet.layer3_length), fd_, + static_cast(packet.layer3_length), fd, std::strerror(err)); stats_.errors.fetch_add(1, std::memory_order_relaxed); return false; diff --git a/src/egress/TunSink.cpp b/src/egress/TunSink.cpp index bac3cbf..996fd15 100644 --- a/src/egress/TunSink.cpp +++ b/src/egress/TunSink.cpp @@ -294,12 +294,21 @@ bool TunSink::write(const net::PacketView& packet) { return true; } const int err = errno; - if (err != EAGAIN && err != EWOULDBLOCK) { - TCPLOG_WARN("TunSink::write (%u bytes) failed on fd=%d: %s", - static_cast(packet.layer3_length), fd, - std::strerror(err)); + if (err == EAGAIN || err == EWOULDBLOCK) { stats_.errors.fetch_add(1, std::memory_order_relaxed); + if (!backpressure_logged_.exchange(true, std::memory_order_relaxed)) { + TCPLOG_WARN( + "TunSink: TX backpressure on fd=%d (EAGAIN/EWOULDBLOCK); " + "dropping packets. This can induce real TCP retransmissions at " + "high rates because OpenPenny does not keep a copy-backed TX queue.", + fd); + } + return false; } + TCPLOG_WARN("TunSink::write (%u bytes) failed on fd=%d: %s", + static_cast(packet.layer3_length), fd, + std::strerror(err)); + stats_.errors.fetch_add(1, std::memory_order_relaxed); return false; } diff --git a/src/grpc/PennyService.cpp b/src/grpc/PennyService.cpp index 46ac615..08e524a 100644 --- a/src/grpc/PennyService.cpp +++ b/src/grpc/PennyService.cpp @@ -1041,8 +1041,15 @@ ::grpc::Status PennyServiceImpl::StartTest(::grpc::ServerContext*, ? (aggregates_decision_complete ? "completed" : "running") : "n/a"; - // Build a JSON summary akin to the CLI output. - nlohmann::json summary; + // Build a JSON summary akin to the CLI output, preserving any + // worker-emitted detail sections that do not have dedicated proto fields. + nlohmann::json summary = nlohmann::json::object(); + if (!response->json_summary().empty()) { + auto parsed = nlohmann::json::parse(response->json_summary(), nullptr, false); + if (parsed.is_object()) { + summary = std::move(parsed); + } + } summary["test_id"] = response->test_id(); summary["status"] = response->status(); summary["packets"] = { diff --git a/src/ingress/af_xdp/XdpReader.cpp b/src/ingress/af_xdp/XdpReader.cpp index cc7c379..1bce4ff 100644 --- a/src/ingress/af_xdp/XdpReader.cpp +++ b/src/ingress/af_xdp/XdpReader.cpp @@ -53,7 +53,9 @@ static uint64_t now_ns() { struct SharedAttachState { std::mutex mutex; - unsigned refs{0}; + unsigned refs{0}; ///< Workers currently opening or opened on this shared attach state. + unsigned ready_workers{0}; ///< Workers that finished AF_XDP socket bring-up and published xsks_map. + bool shared_resources_ready{false}; ///< Shared BPF maps / program are prepared for sibling workers. bool rss_checked{false}; ///< Only the first-opening worker runs the RSS coverage check. #ifdef OPENPENNY_WITH_LIBBPF bool attached{false}; @@ -511,10 +513,11 @@ bool XdpReader::open(const std::string& ifname, unsigned queue) { return false; } - // Serialise the per-interface attach / map-pin dance across worker - // threads so two queue workers on the same NIC can't race when creating - // or pinning the shared BPF objects. - std::lock_guard shared_lock(impl.shared_attach->mutex); + // Serialise only the shared BPF attach / map-pin phase across queue + // workers. The expensive per-queue socket bring-up runs after this and is + // intentionally parallel. + std::unique_lock shared_lock(impl.shared_attach->mutex); + bool shared_ref_acquired = false; if (impl.tuning.verbose) { TCPLOG_INFO("Attempting AF_XDP reader on %s queue %u", ifname.c_str(), queue); @@ -559,6 +562,37 @@ bool XdpReader::open(const std::string& ifname, unsigned queue) { rs.ready = false; }; + auto release_shared_startup_ref = [&]() { + if (!shared_ref_acquired || !impl.shared_attach) { + return; + } + bool release_state = false; + { + std::lock_guard lock(impl.shared_attach->mutex); + if (impl.shared_attach->refs > 0) { + --impl.shared_attach->refs; + } + if (impl.shared_attach->refs == 0) { + if (impl.shared_attach->attached && impl.tuning.detach_on_close) { + bpf_xdp_detach(impl.shared_attach->ifindex, + impl.shared_attach->xdp_flags, + nullptr); + } + impl.shared_attach->attached = false; + impl.shared_attach->ifindex = 0; + impl.shared_attach->xdp_flags = 0; + impl.shared_attach->ready_workers = 0; + impl.shared_attach->shared_resources_ready = false; + impl.shared_attach->rss_checked = false; + release_state = true; + } + } + shared_ref_acquired = false; + if (release_state) { + release_shared_attach_state(impl.shared_attach_key, impl.shared_attach); + } + }; + // Populate rs.*_fd from a freshly loaded bpf_object. auto open_maps_from_object = [&](bpf_object* obj) -> bool { if (!obj) return false; @@ -928,12 +962,12 @@ bool XdpReader::open(const std::string& ifname, unsigned queue) { // c) Otherwise, load the object fresh and (optionally) pin the maps // so sibling workers can find them. - const bool shared_reader_already_open = impl.shared_attach->refs > 0; + const bool shared_resources_ready = impl.shared_attach->shared_resources_ready; bool pins_ok = false; - if (shared_reader_already_open && open_maps_from_pins()) { - pins_ok = true; - rs.pinned_maps = true; + bool need_open_maps_from_pins_after_unlock = false; + if (shared_resources_ready) { rs.xdp_flags = impl.shared_attach->xdp_flags; + need_open_maps_from_pins_after_unlock = true; } else if (impl.tuning.reuse_pins && open_maps_from_pins()) { bool stale_pins = false; bpf_map_info conf_info{}; @@ -988,11 +1022,6 @@ bool XdpReader::open(const std::string& ifname, unsigned queue) { pins_ok = true; rs.pinned_maps = true; } - } else if (shared_reader_already_open) { - TCPLOG_ERROR("Pinned AF_XDP maps are not available for shared queue startup on %s.", - ifname.c_str()); - cleanup(); - return false; } if (!pins_ok) { @@ -1037,7 +1066,7 @@ bool XdpReader::open(const std::string& ifname, unsigned queue) { // match rules are deferred to the LAST worker (see Step 6 below) so // every queue's xsks_map[N] entry is in place before redirects begin. const bool should_publish_pass_defaults = - impl.tuning.update_conf_map && !shared_reader_already_open; + impl.tuning.update_conf_map && !shared_resources_ready; if (should_publish_pass_defaults && !xdp::program_xdp_pass_defaults( xdp::XdpRuleMapFds{rs.conf_fd, rs.settings_fd}, diff --git a/src/penny/flow/engine/FlowEngine.cpp b/src/penny/flow/engine/FlowEngine.cpp index df645b5..c579495 100644 --- a/src/penny/flow/engine/FlowEngine.cpp +++ b/src/penny/flow/engine/FlowEngine.cpp @@ -4,6 +4,7 @@ #include "openpenny/penny/flow/engine/FlowEvaluation.h" #include "openpenny/app/core/OpenpennyPipelineDriver.h" #include "openpenny/app/core/PerThreadStats.h" +#include "openpenny/app/core/RuntimeSetup.h" #include "openpenny/log/Log.h" #include "openpenny/app/core/utils/FlowDebug.h" @@ -33,6 +34,35 @@ void FlowEngine::set_drop_sink(DropSnapshotSink sink) { drop_sink_ = std::move(sink); } +void FlowEngine::set_snapshot_refresh_sink(SnapshotRefreshSink sink) { + snapshot_refresh_sink_ = std::move(sink); +} + +void FlowEngine::publish_snapshot_refresh(std::size_t start_index) { + if (!snapshot_refresh_sink_) { + return; + } + if (start_index >= flow_drop_snapshots_.size()) { + return; + } + snapshot_refresh_sink_(flow_key_, flow_drop_snapshots_, start_index); +} + +void FlowEngine::publish_single_snapshot_update(PacketDropId packet_id, + std::size_t snapshot_index) { + if (snapshot_refresh_sink_) { + publish_snapshot_refresh(snapshot_index); + return; + } + if (!drop_sink_) { + return; + } + if (snapshot_index >= flow_drop_snapshots_.size()) { + return; + } + drop_sink_(flow_key_, packet_id, flow_drop_snapshots_[snapshot_index].second); +} + void FlowEngine::reset() { ThreadFlowEventTimerManager::instance().purge_flow(this); flow_drops_enforced_ = 0; @@ -215,15 +245,20 @@ void FlowEngine::register_duplicate_snapshot(uint32_t seq) { // Snanpshots are ordered by insertion; once we find the first snapshot whose coverage // includes this seq (highest_seq >= seq), all later snapshots should reflect the duplicate. bool update = false; + std::size_t first_updated_index = flow_drop_snapshots_.size(); for (size_t i = 0; i < flow_drop_snapshots_.size(); ++i) { auto& snap = flow_drop_snapshots_[i].second; if (!update && snap.stats.highest_seq() >= seq) { update = true; + first_updated_index = i; } if (update) { snap.stats.record_duplicate_packet(); } } + if (update) { + publish_snapshot_refresh(first_updated_index); + } } void FlowEngine::evaluate_snapshot_duplicate_threshold() { @@ -273,8 +308,7 @@ bool FlowEngine::drop_packet(uint32_t start, } if (max_drops_in_aggregates > 0) { - const auto& runtime = openpenny::current_runtime_setup(); - if (runtime.aggregates_active && + if (openpenny::current_aggregates_active() && !openpenny::app::try_reserve_aggregate_drop(max_drops_in_aggregates)) { // Best-effort global drop budget has been exhausted. The atomic // per-worker counters may still allow a small overshoot under @@ -319,7 +353,8 @@ bool FlowEngine::drop_packet(uint32_t start, const size_t snapshot_index = flow_drop_snapshots_.size() - 1; flow_snapshot_index_by_id_[packet_id] = snapshot_index; - // Timer thread will later emit a callback (via ThreadFlowEventTimerManager) that we apply on this thread. + // The owning worker thread will later drain this scheduled timeout/event + // via ThreadFlowEventTimerManager and apply the callback inline. ThreadFlowEventTimerManager::instance().register_drop(key, packet_id, snap.timestamp, flow_alive_flag_, this, snapshot_index); // Register the gap in the SEQ space. @@ -328,7 +363,7 @@ bool FlowEngine::drop_packet(uint32_t start, if (TCPLOG_ENABLED(INFO)) { const auto flow_tag = ::openpenny::flow_debug_details(key); TCPLOG_INFO( - "[drop] flow=%s seq_range=%" PRIu32 "-%" PRIu32 " (len=%" PRIu32 ")", + "[drop_event] action=drop flow=%s start_seq=%" PRIu32 " end_seq=%" PRIu32 " len=%" PRIu32, flow_tag.c_str(), start, end, @@ -406,6 +441,8 @@ void FlowEngine::mark_snapshot_retransmitted(PacketDropId packet_id) { // Remove the packet → snapshot mapping; the snapshot is resolved. flow_snapshot_index_by_id_.erase(index_it); + + publish_single_snapshot_update(packet_id, idx); } /** @@ -481,9 +518,7 @@ void FlowEngine::mark_snapshot_expired(PacketDropId packet_id) { // We no longer need to look up this snapshot by packet ID. flow_snapshot_index_by_id_.erase(index_it); - if (drop_sink_) { - drop_sink_(flow_key_, packet_id, snapshot); - } + publish_single_snapshot_update(packet_id, idx); } /** @@ -531,6 +566,8 @@ void FlowEngine::mark_snapshot_invalid(PacketDropId packet_id) { // Adjust flow-wide pending retransmission statistics. flow_stats_.dec_pending_retransmission(); + auto& counters = openpenny::app::current_thread_counters(); + if (counters.pending_retransmissions > 0) counters.pending_retransmissions--; // Ensure snapshots recorded after this one remain statistically consistent. // They may still include this packet as pending, so remove that dependency. @@ -545,6 +582,8 @@ void FlowEngine::mark_snapshot_invalid(PacketDropId packet_id) { // Remove the packet → snapshot index mapping; this snapshot is now resolved. flow_snapshot_index_by_id_.erase(index_it); + + publish_single_snapshot_update(packet_id, idx); } void FlowEngine::expire_all_pending_snapshots() { @@ -560,6 +599,33 @@ void FlowEngine::expire_all_pending_snapshots() { } } +void FlowEngine::resolve_pending_snapshots(const std::chrono::steady_clock::time_point& now) { + std::vector expired_ids; + std::vector invalid_ids; + expired_ids.reserve(flow_drop_snapshots_.size()); + invalid_ids.reserve(flow_drop_snapshots_.size()); + + const bool timeout_enabled = flow_cfg_.rtt_timeout_factor > 0.0; + const auto timeout = std::chrono::duration_cast( + std::chrono::duration(flow_cfg_.rtt_timeout_factor)); + + for (const auto& pair : flow_drop_snapshots_) { + if (pair.second.state != SnapshotState::Pending) continue; + if (timeout_enabled && now - pair.second.timestamp >= timeout) { + expired_ids.push_back(pair.first); + } else { + invalid_ids.push_back(pair.first); + } + } + + for (const auto& id : expired_ids) { + mark_snapshot_expired(id); + } + for (const auto& id : invalid_ids) { + mark_snapshot_invalid(id); + } +} + FlowEngine::FlowDecision FlowEngine::evaluate() const { const auto eval = evaluate_flow_decision( @@ -575,11 +641,27 @@ FlowEngine::FlowDecision FlowEngine::evaluate() const { const double miss_prob = std::clamp(flow_cfg_.retransmission_miss_probability, 0.0, 1.0); const auto flow_tag = flow_debug_details(flow_key_); + const auto* verdict_text = [&]() -> const char* { + switch (eval.decision) { + case FlowDecision::FINISHED_CLOSED_LOOP: + return "closed_loop"; + case FlowDecision::FINISHED_NOT_CLOSED_LOOP: + return "not_closed_loop"; + case FlowDecision::FINISHED_DUPLICATE_EXCEEDED: + return "duplicates_exceeded"; + case FlowDecision::FINISHED_NO_DECISION: + return "no_decision"; + case FlowDecision::PENDING: + default: + return "pending"; + } + }(); TCPLOG_INFO( - "[flow_eval] flow=%s data_pkts=%llu dup_pkts=%llu rtx_pkts=%llu non_rtx_pkts=%llu " - "dup_ratio=%.6f miss_prob=%.6f p_closed=%.6f p_not_closed=%.6f denom=%.6f closed_weight=%.6f", + "[flow_eval] flow=%s verdict=%s data=%llu dup=%llu rtx=%llu non_rtx=%llu " + "dup_ratio=%.6f miss_prob=%.6f p_closed=%.6f p_not_closed=%.6f closed_weight=%.6f", flow_tag.c_str(), + verdict_text, static_cast(data_pkts), static_cast(dup_pkts), static_cast(retransmitted), @@ -588,7 +670,6 @@ FlowEngine::FlowDecision FlowEngine::evaluate() const { miss_prob, eval.p_closed, eval.p_not_closed, - eval.p_closed + eval.p_not_closed, eval.closed_weight); } @@ -611,6 +692,16 @@ void FlowEngine::evaluate_if_ready() { return; // Decision already made; keep it. } + const bool aggregate_phase_configured = + flow_cfg_.aggregates_enabled && + flow_cfg_.max_drops_aggregates > 0; + const auto aggregates_status = openpenny::current_aggregates_status(); + if (aggregate_phase_configured && + aggregates_status != RuntimeStatus::AggregatesStatus::NON_CLOSED_LOOP && + aggregates_status != RuntimeStatus::AggregatesStatus::DUPLICATES_EXCEEDED) { + return; + } + // Do not evaluate if we have not observed any data packets; the classifier // requires data-bearing evidence. if (flow_stats_.data_packets() == 0) { diff --git a/src/penny/flow/manager/ThreadFlowManager.cpp b/src/penny/flow/manager/ThreadFlowManager.cpp index a91c69b..417e8f8 100644 --- a/src/penny/flow/manager/ThreadFlowManager.cpp +++ b/src/penny/flow/manager/ThreadFlowManager.cpp @@ -19,6 +19,7 @@ void ThreadFlowManager::configure(const Config::ActiveConfig& cfg) { for (auto& [_, entry] : table_active_flows_) { entry.flow.configure(table_cfg_); entry.flow.set_drop_sink(drop_sink_); + entry.flow.set_snapshot_refresh_sink(snapshot_refresh_sink_); } } @@ -36,28 +37,35 @@ void ThreadFlowManager::set_drop_sink(FlowEngine::DropSnapshotSink sink) { } } -bool ThreadFlowManager::add_new_flow(const FlowKey& key, - uint32_t seq, - uint32_t payload_bytes, - bool is_syn, - const std::chrono::steady_clock::time_point& ts) { - +void ThreadFlowManager::set_snapshot_refresh_sink(FlowEngine::SnapshotRefreshSink sink) { + snapshot_refresh_sink_ = std::move(sink); + for (auto& [_, entry] : table_active_flows_) { + entry.flow.set_snapshot_refresh_sink(snapshot_refresh_sink_); + } +} + +FlowEngineEntry* ThreadFlowManager::add_new_flow(const FlowKey& key, + uint32_t seq, + uint32_t payload_bytes, + bool is_syn, + const std::chrono::steady_clock::time_point& ts) { // Ignore ACK packets with no payload when deciding whether to start monitoring a new flow. if (payload_bytes == 0 && !is_syn) { - return false; + return nullptr; } // try_emplace: insert a new entry if the key is absent, otherwise return the existing one without extra copies. auto [it, inserted] = table_active_flows_.try_emplace(key); auto& entry = it->second; if (!inserted) { - return false; + return nullptr; } auto& counters = openpenny::app::current_thread_counters(); counters.flows_monitored++; counters.active_flows++; entry.flow.configure(table_cfg_); // apply current config for counters/thresholds entry.flow.set_drop_sink(drop_sink_); + entry.flow.set_snapshot_refresh_sink(snapshot_refresh_sink_); entry.flow.set_flow_key(key); // stash identifiers once entry.last_seen = ts; entry.first_seen = ts; @@ -71,7 +79,7 @@ bool ThreadFlowManager::add_new_flow(const FlowKey& key, (void)end_seq; // end_seq retained for potential future use } entry.flow.record_packet(); // count the first packet - return true; + return &entry; } void ThreadFlowManager::track_packet(const ::openpenny::net::PacketView& packet, @@ -81,20 +89,20 @@ void ThreadFlowManager::track_packet(const ::openpenny::net::PacketView& packet, const auto now = ts; auto it = table_active_flows_.find(packet.flow); + FlowEngineEntry* new_entry = nullptr; if (it == table_active_flows_.end()) { if (max_flows != 0 && active_flow_count(max_flows) >= max_flows) { return; } - add_new_flow(packet.flow, - packet.tcp.seq, - static_cast(packet.payload_bytes), - is_syn, - now); - it = table_active_flows_.find(packet.flow); + new_entry = add_new_flow(packet.flow, + packet.tcp.seq, + static_cast(packet.payload_bytes), + is_syn, + now); + if (!new_entry) return; } - if (it == table_active_flows_.end()) return; - auto& entry = it->second; + auto& entry = (it != table_active_flows_.end()) ? it->second : *new_entry; auto& flow = entry.flow; entry.last_seen = now; // Flow starts in PENDING_SEEN_DATA when we first see payload without SYN. @@ -166,16 +174,16 @@ bool ThreadFlowManager::complete_flow(const FlowKey& key, const char* reason) { const auto* test_status_text = [] (FlowEngine::FlowDecision status) -> const char* { switch (status) { case FlowEngine::FlowDecision::FINISHED_CLOSED_LOOP: - return "FINISHED_CLOSED_LOOP"; + return "closed_loop"; case FlowEngine::FlowDecision::FINISHED_NOT_CLOSED_LOOP: - return "FINISHED_NOT_CLOSED_LOOP"; + return "not_closed_loop"; case FlowEngine::FlowDecision::FINISHED_DUPLICATE_EXCEEDED: - return "FINISHED_DUPLICATE_EXCEEDED"; + return "duplicates_exceeded"; case FlowEngine::FlowDecision::FINISHED_NO_DECISION: - return "FINISHED_NO_DECISION"; + return "no_decision"; case FlowEngine::FlowDecision::PENDING: default: - return "PENDING"; + return "pending"; } }(flow.final_decision()); @@ -183,9 +191,9 @@ bool ThreadFlowManager::complete_flow(const FlowKey& key, const char* reason) { const auto flow_tag = flow_debug_details(key); TCPLOG_INFO( - "[flow_complete] reason=%s tcp_state=%s test_status=%s flow=%s " - "data_pkts=%llu dup_pkts=%llu in_order_pkts=%llu out_of_order_pkts=%llu " - "rtx_pkts=%llu non_rtx_pkts=%llu pending_rtx_pkts=%llu", + "[flow_result] stage=complete reason=%s tcp_state=%s verdict=%s flow=%s " + "data=%llu dup=%llu in_order=%llu out_of_order=%llu " + "rtx=%llu non_rtx=%llu pending_rtx=%llu", reason ? reason : "completed", tcp_state_text, test_status_text, @@ -199,8 +207,8 @@ bool ThreadFlowManager::complete_flow(const FlowKey& key, const char* reason) { static_cast(flow.pending_retransmissions())); } - // Expire any remaining pending snapshots before tearing down the flow. - entry.flow.expire_all_pending_snapshots(); + // Resolve any remaining pending snapshots before tearing down the flow. + entry.flow.resolve_pending_snapshots(std::chrono::steady_clock::now()); table_completed_flows_.insert(it->first); table_active_flows_.erase(it); @@ -224,7 +232,9 @@ bool ThreadFlowManager::complete_flow(const FlowKey& key, const char* reason) { counters.flows_not_closed_loop++; break; case FlowEngine::FlowDecision::FINISHED_DUPLICATE_EXCEEDED: - counters.flows_duplicates_exceeded++; + if (entry.state != FlowTrackingState::INTERRUPTED_DUPLICATE_EXCEEDED) { + counters.flows_duplicates_exceeded++; + } break; default: break; diff --git a/src/penny/flow/timer/ThreadFlowEventTimer.cpp b/src/penny/flow/timer/ThreadFlowEventTimer.cpp index 7fef6d7..c02408b 100644 --- a/src/penny/flow/timer/ThreadFlowEventTimer.cpp +++ b/src/penny/flow/timer/ThreadFlowEventTimer.cpp @@ -8,8 +8,8 @@ * Design principles: * 1. Expirations are prioritised to ensure snapshots age out promptly. * 2. Flow mutation never happens while holding internal locks. - * 3. All callbacks execute in the timer thread itself to avoid - * cross-thread data races. + * 3. All callbacks execute on the owning worker thread when it drains + * this manager, avoiding per-queue helper-thread context switches. * 4. Cancelled events are garbage collected lazily using a token heap. */ @@ -32,11 +32,8 @@ ThreadFlowEventTimerManager& ThreadFlowEventTimerManager::instance() { return mgr; } -std::function - ThreadFlowEventTimerManager::snapshot_hook_{}; - ThreadFlowEventTimerManager::~ThreadFlowEventTimerManager() { - stop(); // Ensure the timer thread is terminated cleanly. + stop(); // Ensure the worker-local timer state is flushed cleanly. } // ----------------------------------------------------------------------------- @@ -46,40 +43,25 @@ ThreadFlowEventTimerManager::~ThreadFlowEventTimerManager() { void ThreadFlowEventTimerManager::start(double timeout_sec) { std::lock_guard lock(mutex_); timeout_sec_ = timeout_sec; - - if (running_) return; // Prevent multiple timer threads from starting. - - stop_flag_ = false; + if (running_) return; running_ = true; - thread_ = std::thread(&ThreadFlowEventTimerManager::timer_loop, this); // Spawn background timer loop. + next_deadline_.store(kNoDeadline, std::memory_order_release); + queued_event_count_.store(0, std::memory_order_release); } void ThreadFlowEventTimerManager::stop() { - { - std::lock_guard lock(mutex_); - if (!running_) return; // No action needed if thread is not running. - stop_flag_ = true; - } - - cv_.notify_all(); // Wake sleeping thread so it can terminate. - - if (thread_.joinable()) { - thread_.join(); // Wait for graceful thread shutdown. - } - - // Reset all internal state after stopping. - { - std::lock_guard lock(mutex_); - running_ = false; - heap_ = {}; - by_id_.clear(); - by_flow_.clear(); - cancelled_.clear(); - retransmit_seen_.clear(); - events_.clear(); - callbacks_.clear(); - next_token_ = 1; - } + std::lock_guard lock(mutex_); + if (!running_) return; + running_ = false; + heap_ = {}; + by_id_.clear(); + by_flow_.clear(); + cancelled_.clear(); + retransmit_seen_.clear(); + events_.clear(); + queued_event_count_.store(0, std::memory_order_release); + next_deadline_.store(kNoDeadline, std::memory_order_release); + next_token_ = 1; } // ----------------------------------------------------------------------------- @@ -111,8 +93,11 @@ void ThreadFlowEventTimerManager::register_drop(const ::openpenny::FlowKey& key, heap_.push(e); // Add to min-heap ordered by nearest expiry first. by_id_[PacketKey{flow, packet_id}] = e; // Register lookup by (flow, packet_id). by_flow_.emplace(flow, e.token); // Track token association to flow. - - wake_locked(); // Wake timer thread to re-evaluate scheduling. + const auto deadline = e.deadline.time_since_epoch().count(); + const auto current = next_deadline_.load(std::memory_order_relaxed); + if (deadline < current) { + next_deadline_.store(deadline, std::memory_order_release); + } } void ThreadFlowEventTimerManager::enqueue_retransmitted(PacketDropId packet_id, FlowEngine* flow) { @@ -121,8 +106,7 @@ void ThreadFlowEventTimerManager::enqueue_retransmitted(PacketDropId packet_id, // Queue retransmission event for later servicing. events_.push_back(Event{Event::Kind::Retransmit, packet_id, flow, 0}); - - wake_locked(); // Wake timer loop. + queued_event_count_.store(events_.size(), std::memory_order_release); } void ThreadFlowEventTimerManager::enqueue_duplicate(FlowEngine* flow, std::uint32_t seq, std::uint32_t payload) { @@ -131,8 +115,7 @@ void ThreadFlowEventTimerManager::enqueue_duplicate(FlowEngine* flow, std::uint3 // Queue duplicate detection event for later servicing. events_.push_back(Event{Event::Kind::Duplicate, {}, flow, seq, payload}); - - wake_locked(); // Wake timer loop. + queued_event_count_.store(events_.size(), std::memory_order_release); } // ----------------------------------------------------------------------------- @@ -150,28 +133,19 @@ void ThreadFlowEventTimerManager::purge_flow(FlowEngine* flow) { } by_flow_.erase(flow); // Remove all tokens referencing flow. - retransmit_seen_.erase( - std::remove_if(retransmit_seen_.begin(), - retransmit_seen_.end(), - [flow](const PacketKey& k) { return k.flow == flow; }), - retransmit_seen_.end() - ); - - // Remove pending callbacks that reference the purged flow. - callbacks_.erase( - std::remove_if(callbacks_.begin(), callbacks_.end(), - [flow](const Callback& cb) { return cb.flow == flow; }), - callbacks_.end() - ); - // Resync the lock-free counter with the post-erase deque size so the - // drain_callbacks() fast path doesn't keep firing on stale entries. - pending_callbacks_.store(callbacks_.size(), std::memory_order_release); - - wake_locked(); // Wake timer loop to apply purge. -} - -void ThreadFlowEventTimerManager::wake_locked() { - cv_.notify_all(); // Wake timer thread (called while holding mutex_). + for (auto it = retransmit_seen_.begin(); it != retransmit_seen_.end();) { + if (it->flow == flow) { + it = retransmit_seen_.erase(it); + } else { + ++it; + } + } + events_.erase( + std::remove_if(events_.begin(), events_.end(), + [flow](const Event& ev) { return ev.flow == flow; }), + events_.end()); + queued_event_count_.store(events_.size(), std::memory_order_release); + refresh_next_deadline_locked(); } // ----------------------------------------------------------------------------- @@ -185,56 +159,52 @@ void ThreadFlowEventTimerManager::run_callbacks(std::deque& pending) { // Dispatch callback by type (snapshot mutation). if (cb.kind == Callback::Kind::Expire) { cb.flow->mark_snapshot_expired(cb.packet_id); - if (snapshot_hook_) snapshot_hook_(cb.flow, cb.packet_id, SnapshotEventKind::Expire); } else if (cb.kind == Callback::Kind::Retransmit) { cb.flow->mark_snapshot_retransmitted(cb.packet_id); - if (snapshot_hook_) snapshot_hook_(cb.flow, cb.packet_id, SnapshotEventKind::Retransmit); } else if (cb.kind == Callback::Kind::Duplicate) { cb.flow->register_duplicate_snapshot(cb.seq); cb.flow->evaluate_snapshot_duplicate_threshold(); - if (snapshot_hook_) snapshot_hook_(cb.flow, 0, SnapshotEventKind::Duplicate); } cb.flow->evaluate_if_ready(); // Re-check whether the flow now satisfies its scheduling thresholds. } } -// ----------------------------------------------------------------------------- -// Timer loop (long running background scheduling thread) -// ----------------------------------------------------------------------------- +void ThreadFlowEventTimerManager::refresh_next_deadline_locked() { + while (!heap_.empty() && cancelled_.count(heap_.top().token)) { + cancelled_.erase(heap_.top().token); + heap_.pop(); + } -void ThreadFlowEventTimerManager::timer_loop() { - std::unique_lock lock(mutex_); + if (heap_.empty()) { + next_deadline_.store(kNoDeadline, std::memory_order_release); + } else { + next_deadline_.store( + heap_.top().deadline.time_since_epoch().count(), + std::memory_order_release); + } +} +void ThreadFlowEventTimerManager::collect_ready_callbacks( + std::deque& pending, + const std::chrono::steady_clock::time_point& now) { while (true) { - if (stop_flag_) break; // Stop signal received. - - const auto now = std::chrono::steady_clock::now(); - - // Remove stale cancelled entries at the top of the heap. - while (!heap_.empty() && cancelled_.count(heap_.top().token)) { - cancelled_.erase(heap_.top().token); - heap_.pop(); - } - + refresh_next_deadline_locked(); bool processed_item = false; - // 1) Process the next expiry if it is due. if (!heap_.empty() && now >= heap_.top().deadline) { auto entry = heap_.top(); heap_.pop(); - // Remove entry from lookup maps if not already invalidated. auto id_it = by_id_.find(PacketKey{entry.flow, entry.packet_id}); if (id_it != by_id_.end() && id_it->second.token == entry.token) { by_id_.erase(id_it); } - // Remove only the token that matches this entry for the given flow. auto range = by_flow_.equal_range(entry.flow); - for (auto it = range.first; it != range.second; ) { + for (auto it = range.first; it != range.second;) { if (it->second == entry.token) { it = by_flow_.erase(it); break; @@ -243,47 +213,34 @@ void ThreadFlowEventTimerManager::timer_loop() { } } - // Ensure we only schedule snapshot mutation if the flow is still alive. if (auto alive = entry.flow_alive.lock(); alive && *alive && entry.flow) { if (TCPLOG_ENABLED(INFO)) { const auto packet_id_text = format_packet_drop_id(entry.packet_id); TCPLOG_INFO("[packet_expired] flow=%s packet_id=%s token=%" PRIu64, - flow_debug_details(entry.flow->flow_key()).c_str(), - packet_id_text.c_str(), - entry.token - ); + flow_debug_details(entry.flow->flow_key()).c_str(), + packet_id_text.c_str(), + entry.token); } - - // Schedule expiration callback for lock-free handling. - callbacks_.push_back(Callback{ - Callback::Kind::Expire, entry.packet_id, entry.flow, 0 - }); - pending_callbacks_.fetch_add(1, std::memory_order_release); + pending.push_back( + Callback{Callback::Kind::Expire, entry.packet_id, entry.flow, 0}); } processed_item = true; - } - - // 2) If no expiration was ready, service one queued event. - else if (!events_.empty()) { + } else if (!events_.empty()) { auto ev = events_.front(); events_.pop_front(); + queued_event_count_.store(events_.size(), std::memory_order_release); if (ev.kind == Event::Kind::Retransmit && ev.flow) { auto it = by_id_.find(PacketKey{ev.flow, ev.packet_id}); if (it != by_id_.end()) { const auto token = it->second.token; - - // Skip duplicate retransmit handling for the same flow/packet_id. const PacketKey key{ev.flow, ev.packet_id}; - if (std::find(retransmit_seen_.begin(), retransmit_seen_.end(), key) != retransmit_seen_.end()) { + const auto [_, inserted] = retransmit_seen_.insert(key); + if (!inserted) { processed_item = true; continue; } - retransmit_seen_.push_back(key); - - // If we've already cancelled this token (due to an earlier - // retransmit event), skip duplicate handling/logging. if (cancelled_.find(token) != cancelled_.end()) { processed_item = true; continue; @@ -293,88 +250,55 @@ void ThreadFlowEventTimerManager::timer_loop() { if (TCPLOG_ENABLED(INFO)) { const auto packet_id_text = format_packet_drop_id(ev.packet_id); - TCPLOG_INFO("[packet_retransmitted] flow=%s packet_id=%s seq=%" PRIu32, + TCPLOG_INFO( + "[drop_event] action=retransmitted flow=%s packet_id=%s seq=%" PRIu32, flow_debug_details(ev.flow->flow_key()).c_str(), packet_id_text.c_str(), - ev.seq - ); + ev.seq); } - callbacks_.push_back(Callback{ - Callback::Kind::Retransmit, ev.packet_id, it->second.flow, 0 - }); - pending_callbacks_.fetch_add(1, std::memory_order_release); + pending.push_back( + Callback{Callback::Kind::Retransmit, ev.packet_id, it->second.flow, 0}); } - } - else if (ev.kind == Event::Kind::Duplicate && ev.flow) { + } else if (ev.kind == Event::Kind::Duplicate && ev.flow) { if (TCPLOG_ENABLED(DEBUG)) { TCPLOG_DEBUG("[duplicate_detected] flow=%s seq=%" PRIu32 " payload=%u", - flow_debug_details(ev.flow->flow_key()).c_str(), - ev.seq, - ev.payload); + flow_debug_details(ev.flow->flow_key()).c_str(), + ev.seq, + ev.payload); } - - callbacks_.push_back(Callback{ - Callback::Kind::Duplicate, {}, ev.flow, ev.seq - }); - pending_callbacks_.fetch_add(1, std::memory_order_release); + pending.push_back(Callback{Callback::Kind::Duplicate, {}, ev.flow, ev.seq}); } processed_item = true; } - // 2.5) Run callbacks immediately if any were produced. - if (processed_item && !callbacks_.empty()) { - std::deque pending; - pending.swap(callbacks_); // Extract callbacks without copying. - - lock.unlock(); - run_callbacks(pending); // Execute snapshot mutations in lock-free mode. - lock.lock(); - - continue; // Re-evaluate loop state after callback execution. - } - - if (processed_item) continue; - - // 3) No action needed right now: sleep until the next expiry or event wake. - if (!heap_.empty() && timeout_sec_ > 0.0) { - cv_.wait_until(lock, heap_.top().deadline, [&] { - return stop_flag_ || !events_.empty(); - }); - } else { - cv_.wait(lock, [&] { - return stop_flag_ || !events_.empty() || - (!heap_.empty() && timeout_sec_ > 0.0); - }); + if (!processed_item) { + refresh_next_deadline_locked(); + return; } } } void ThreadFlowEventTimerManager::drain_callbacks() { - // Lock-free fast path. drain_callbacks() is called from every worker's - // before_poll() — i.e. potentially millions of times per second across - // busy-polling AF_XDP workers. Acquiring mutex_ on every call serialises - // the hot path on a single global lock; with many workers this becomes - // the dominant bottleneck. Skip the lock entirely when no callbacks - // are queued, which is the overwhelming common case. - if (pending_callbacks_.load(std::memory_order_acquire) == 0) { - return; + const auto now = std::chrono::steady_clock::now(); + if (queued_event_count_.load(std::memory_order_acquire) == 0) { + const auto next_deadline = next_deadline_.load(std::memory_order_acquire); + if (next_deadline == kNoDeadline || + now.time_since_epoch().count() < next_deadline) { + return; + } } std::deque pending; { std::lock_guard lock(mutex_); - pending.swap(callbacks_); - pending_callbacks_.store(0, std::memory_order_release); + if (!running_) { + return; + } + collect_ready_callbacks(pending, now); } run_callbacks(pending); } -void ThreadFlowEventTimerManager::set_snapshot_hook(std::function hook) { - snapshot_hook_ = std::move(hook); -} - } // namespace openpenny::penny diff --git a/tests/unit/flow/test_aggregate_duplicate_fallback.cpp b/tests/unit/flow/test_aggregate_duplicate_fallback.cpp new file mode 100644 index 0000000..2449e60 --- /dev/null +++ b/tests/unit/flow/test_aggregate_duplicate_fallback.cpp @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: BSD-2-Clause + +#include "openpenny/app/core/AggregatesController.h" +#include "openpenny/app/core/DropCollectorBinding.h" +#include "openpenny/app/core/PerThreadStats.h" +#include "openpenny/app/core/RuntimeSetup.h" +#include "openpenny/config/Config.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +openpenny::FlowKey make_key() { + openpenny::FlowKey key{}; + key.src = 0x0a000011; + key.dst = 0x0a000012; + key.sport = 2222; + key.dport = 5201; + key.ip_proto = 6; + return key; +} + +openpenny::penny::PacketDropSnapshot make_duplicate_exceeded_snapshot() { + openpenny::penny::PacketDropSnapshot snap{}; + snap.timestamp = std::chrono::steady_clock::now(); + snap.state = openpenny::penny::SnapshotState::Expired; + for (int i = 0; i < 10; ++i) { + snap.stats.record_data_packet(); + snap.stats.record_droppable_packet(); + } + for (int i = 0; i < 2; ++i) { + snap.stats.record_duplicate_packet(); + } + snap.stats.record_drop(); + snap.stats.inc_non_retransmitted(); + return snap; +} + +} // namespace + +int main() { + openpenny::app::init_thread_counters(1); + openpenny::app::set_thread_counter_index(0); + + openpenny::Config cfg; + cfg.active.aggregates_enabled = true; + cfg.active.max_drops_aggregates = 1; + cfg.active.max_duplicate_fraction = 0.1; + cfg.active.retransmission_miss_probability = 0.0; + cfg.active.min_closed_loop_flows = 0; + + openpenny::PipelineOptions opts{}; + opts.mode = openpenny::PipelineOptions::Mode::Active; + + openpenny::set_runtime_setup(cfg, opts, false, false); + auto& runtime = openpenny::runtime_setup_mutable(); + runtime.aggregates_status = openpenny::RuntimeStatus::AggregatesStatus::PENDING; + runtime.aggregate_eval_counters = {}; + runtime.has_aggregate_eval = false; + runtime.aggregates_active = true; + + std::atomic stop_flag{false}; + auto collector = std::make_shared(1); + openpenny::AggregatesController controller( + cfg, + opts, + collector, + stop_flag, + std::function{}); + controller.start(); + + auto& counters = openpenny::app::current_thread_counters(); + counters.droppable_packets = 10; + counters.data_packets = 10; + counters.duplicate_packets = 2; + counters.dropped_packets = 1; + counters.non_retransmitted_packets = 1; + counters.pending_retransmissions = 0; + + openpenny::app::DropCollectorBinding::instance().upsert( + collector, + "worker-0", + 0, + make_key(), + openpenny::penny::make_packet_drop_id(2000, 100), + make_duplicate_exceeded_snapshot()); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(1); + while (runtime.aggregates_status == openpenny::RuntimeStatus::AggregatesStatus::PENDING && + std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + assert(runtime.aggregates_status == + openpenny::RuntimeStatus::AggregatesStatus::DUPLICATES_EXCEEDED); + assert(runtime.has_aggregate_eval); + assert(runtime.aggregate_eval_counters.data_packets == 10); + assert(runtime.aggregate_eval_counters.duplicate_packets == 2); + assert(!runtime.aggregates_active); + assert(!collector->accepting.load(std::memory_order_relaxed)); + assert(!controller.collector_completed()); + assert(!stop_flag.load(std::memory_order_relaxed)); + + stop_flag.store(true, std::memory_order_relaxed); + controller.join(); + return 0; +} diff --git a/tests/unit/flow/test_aggregate_freeze_at_drop_limit.cpp b/tests/unit/flow/test_aggregate_freeze_at_drop_limit.cpp new file mode 100644 index 0000000..f3e5e37 --- /dev/null +++ b/tests/unit/flow/test_aggregate_freeze_at_drop_limit.cpp @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: BSD-2-Clause + +#include "openpenny/app/core/AggregatesController.h" +#include "openpenny/app/core/DropCollectorBinding.h" +#include "openpenny/app/core/PerThreadStats.h" +#include "openpenny/app/core/RuntimeSetup.h" +#include "openpenny/config/Config.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +openpenny::FlowKey make_key(std::uint16_t sport) { + openpenny::FlowKey key{}; + key.src = 0x0a000001; + key.dst = 0x0a000002; + key.sport = sport; + key.dport = 5201; + key.ip_proto = 6; + return key; +} + +openpenny::penny::PacketDropSnapshot make_pending_snapshot() { + openpenny::penny::PacketDropSnapshot snap{}; + snap.timestamp = std::chrono::steady_clock::now(); + snap.state = openpenny::penny::SnapshotState::Pending; + snap.stats.record_data_packet(); + snap.stats.record_droppable_packet(); + snap.stats.record_drop(); + snap.stats.inc_pending_retransmission(); + return snap; +} + +} // namespace + +int main() { + openpenny::app::init_thread_counters(1); + openpenny::app::set_thread_counter_index(0); + + openpenny::Config cfg; + cfg.active.aggregates_enabled = true; + cfg.active.max_drops_aggregates = 1; + cfg.active.max_duplicate_fraction = 1.0; + cfg.active.retransmission_miss_probability = 0.0; + + openpenny::PipelineOptions opts{}; + opts.mode = openpenny::PipelineOptions::Mode::Active; + + openpenny::set_runtime_setup(cfg, opts, false, false); + auto& runtime = openpenny::runtime_setup_mutable(); + runtime.aggregates_status = openpenny::RuntimeStatus::AggregatesStatus::PENDING; + runtime.aggregate_eval_counters = {}; + runtime.has_aggregate_eval = false; + runtime.aggregates_active = true; + + std::atomic stop_flag{false}; + auto collector = std::make_shared(1); + openpenny::AggregatesController controller( + cfg, + opts, + collector, + stop_flag, + std::function{}); + + auto& counters = openpenny::app::current_thread_counters(); + counters.droppable_packets = 10; + counters.data_packets = 10; + counters.dropped_packets = 1; + counters.pending_retransmissions = 1; + + const auto first_key = make_key(40001); + const auto first_id = openpenny::penny::make_packet_drop_id(1000, 100); + auto first_snapshot = make_pending_snapshot(); + openpenny::app::DropCollectorBinding::instance().upsert( + collector, + "worker-0", + 0, + first_key, + first_id, + first_snapshot); + + counters.droppable_packets = 100; + counters.data_packets = 100; + counters.dropped_packets = 2; + counters.pending_retransmissions = 2; + + const auto second_key = make_key(40002); + const auto second_id = openpenny::penny::make_packet_drop_id(2000, 100); + auto second_snapshot = make_pending_snapshot(); + openpenny::app::DropCollectorBinding::instance().upsert( + collector, + "worker-0", + 0, + second_key, + second_id, + second_snapshot); + + assert(collector->accepted_snapshot_count.load(std::memory_order_relaxed) == 1); + + counters.pending_retransmissions = 0; + counters.non_retransmitted_packets = 50; + first_snapshot.state = openpenny::penny::SnapshotState::Expired; + first_snapshot.stats.dec_pending_retransmission(); + first_snapshot.stats.inc_non_retransmitted(); + openpenny::app::DropCollectorBinding::instance().upsert( + collector, + "worker-0", + 0, + first_key, + first_id, + first_snapshot); + + openpenny::PipelineSummary summary; + controller.populate_drop_snapshots(summary); + assert(summary.drop_snapshots.size() == 1); + + controller.evaluate_pending_if_needed(cfg, summary); + + assert(runtime.aggregates_status == + openpenny::RuntimeStatus::AggregatesStatus::NON_CLOSED_LOOP); + assert(runtime.has_aggregate_eval); + assert(runtime.aggregate_eval_counters.data_packets == 10); + assert(runtime.aggregate_eval_counters.duplicate_packets == 0); + assert(runtime.aggregate_eval_counters.retransmitted_packets == 0); + assert(runtime.aggregate_eval_counters.non_retransmitted_packets == 1); + + return 0; +} diff --git a/tests/unit/flow/test_aggregate_pending_resolution.cpp b/tests/unit/flow/test_aggregate_pending_resolution.cpp new file mode 100644 index 0000000..a7362f4 --- /dev/null +++ b/tests/unit/flow/test_aggregate_pending_resolution.cpp @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: BSD-2-Clause + +#include "openpenny/app/core/AggregatesController.h" +#include "openpenny/app/core/PerThreadStats.h" +#include "openpenny/app/core/RuntimeSetup.h" +#include "openpenny/config/Config.h" + +#include +#include +#include + +namespace { + +openpenny::DropSnapshotRecord make_expired_snapshot_record() { + openpenny::DropSnapshotRecord record{}; + record.key.src = 0x0a000001; + record.key.dst = 0x0a000002; + record.key.sport = 1111; + record.key.dport = 5201; + record.key.ip_proto = 6; + record.packet_id = openpenny::penny::make_packet_drop_id(1000, 100); + record.snapshot.timestamp = std::chrono::steady_clock::now(); + record.snapshot.state = openpenny::penny::SnapshotState::Expired; + for (int i = 0; i < 5; ++i) { + record.snapshot.stats.record_data_packet(); + record.snapshot.stats.record_droppable_packet(); + } + record.snapshot.stats.record_drop(); + record.snapshot.stats.inc_non_retransmitted(); + return record; +} + +openpenny::DropSnapshotRecord make_invalid_snapshot_record() { + auto record = make_expired_snapshot_record(); + record.snapshot.state = openpenny::penny::SnapshotState::Invalid; + record.snapshot.stats = {}; + for (int i = 0; i < 5; ++i) { + record.snapshot.stats.record_data_packet(); + record.snapshot.stats.record_droppable_packet(); + } + record.snapshot.stats.record_drop(); + return record; +} + +openpenny::DropSnapshotRecord make_duplicate_exceeded_snapshot_record() { + openpenny::DropSnapshotRecord record{}; + record.key.src = 0x0a000011; + record.key.dst = 0x0a000012; + record.key.sport = 2222; + record.key.dport = 5201; + record.key.ip_proto = 6; + record.packet_id = openpenny::penny::make_packet_drop_id(2000, 100); + record.snapshot.timestamp = std::chrono::steady_clock::now(); + record.snapshot.state = openpenny::penny::SnapshotState::Expired; + for (int i = 0; i < 10; ++i) { + record.snapshot.stats.record_data_packet(); + record.snapshot.stats.record_droppable_packet(); + } + for (int i = 0; i < 2; ++i) { + record.snapshot.stats.record_duplicate_packet(); + } + record.snapshot.stats.record_drop(); + record.snapshot.stats.inc_non_retransmitted(); + return record; +} + +} // namespace + +int main() { + openpenny::app::init_thread_counters(1); + openpenny::app::set_thread_counter_index(0); + + openpenny::Config cfg; + cfg.active.aggregates_enabled = true; + cfg.active.max_drops_aggregates = 1; + cfg.active.max_duplicate_fraction = 1.0; + cfg.active.retransmission_miss_probability = 0.0; + + openpenny::PipelineOptions opts{}; + opts.mode = openpenny::PipelineOptions::Mode::Active; + + openpenny::set_runtime_setup(cfg, opts, false, false); + auto& runtime = openpenny::runtime_setup_mutable(); + runtime.aggregates_status = openpenny::RuntimeStatus::AggregatesStatus::PENDING; + runtime.aggregate_eval_counters = {}; + runtime.has_aggregate_eval = false; + runtime.aggregates_active = true; + + std::atomic stop_flag{false}; + auto collector = std::make_shared(1); + openpenny::AggregatesController controller( + cfg, + opts, + collector, + stop_flag, + std::function{}); + + openpenny::PipelineSummary summary; + summary.drop_snapshots.push_back(make_expired_snapshot_record()); + + controller.evaluate_pending_if_needed(cfg, summary); + + assert(runtime.aggregates_status == + openpenny::RuntimeStatus::AggregatesStatus::NON_CLOSED_LOOP); + assert(runtime.has_aggregate_eval); + assert(controller.aggregates_ready()); + assert(controller.collector_completed()); + + runtime.aggregates_status = openpenny::RuntimeStatus::AggregatesStatus::PENDING; + runtime.aggregate_eval_counters = {}; + runtime.has_aggregate_eval = false; + runtime.aggregates_active = true; + + openpenny::PipelineSummary invalid_summary; + invalid_summary.drop_snapshots.push_back(make_invalid_snapshot_record()); + + controller.evaluate_pending_if_needed(cfg, invalid_summary); + + assert(runtime.aggregates_status == + openpenny::RuntimeStatus::AggregatesStatus::NON_CLOSED_LOOP); + assert(runtime.has_aggregate_eval); + + runtime.aggregates_status = openpenny::RuntimeStatus::AggregatesStatus::PENDING; + runtime.aggregate_eval_counters = {}; + runtime.has_aggregate_eval = false; + runtime.aggregates_active = true; + + openpenny::PipelineSummary duplicate_summary; + duplicate_summary.drop_snapshots.push_back(make_duplicate_exceeded_snapshot_record()); + + controller.evaluate_pending_if_needed(cfg, duplicate_summary); + + assert(runtime.aggregates_status == + openpenny::RuntimeStatus::AggregatesStatus::DUPLICATES_EXCEEDED); + assert(runtime.has_aggregate_eval); + assert(runtime.aggregate_eval_counters.data_packets == 10); + assert(runtime.aggregate_eval_counters.duplicate_packets == 2); + + return 0; +} diff --git a/tests/unit/flow/test_drop_snapshot_updates.cpp b/tests/unit/flow/test_drop_snapshot_updates.cpp index c27d27c..0e7131d 100644 --- a/tests/unit/flow/test_drop_snapshot_updates.cpp +++ b/tests/unit/flow/test_drop_snapshot_updates.cpp @@ -22,9 +22,10 @@ // // Synchronization caveat: // `FlowEngine::register_filled_gaps()` enqueues a Retransmit event on -// the global `ThreadFlowEventTimerManager`; the actual mutation happens -// on the timer thread. The test polls until the mutation is observed -// so we don't race against the background thread. +// the thread-local `ThreadFlowEventTimerManager`; the actual mutation +// is applied when the worker drains callbacks. The test polls and +// drives that drain explicitly so the assertions observe the updated +// snapshots deterministically. #include "openpenny/config/Config.h" #include "openpenny/penny/flow/engine/FlowEngine.h" @@ -40,15 +41,16 @@ using namespace std::chrono; namespace { // Wait up to `timeout` for `predicate()` to become true. Used to -// synchronise the test thread with the FlowEngine timer thread, which -// processes Retransmit events asynchronously. +// synchronise the test thread with the cooperative timer manager. template bool wait_for(Predicate predicate, milliseconds timeout = milliseconds{2000}) { const auto deadline = steady_clock::now() + timeout; while (steady_clock::now() < deadline) { + openpenny::penny::ThreadFlowEventTimerManager::instance().drain_callbacks(); if (predicate()) return true; std::this_thread::sleep_for(milliseconds{5}); } + openpenny::penny::ThreadFlowEventTimerManager::instance().drain_callbacks(); return predicate(); } @@ -63,11 +65,11 @@ int main() { // is deterministic regardless of the random number generator state. cfg.active.drop_probability = 1.0; // Long retransmission timeout. With `now = steady_clock::now()`, the - // deadline = `now + 60s` lies far in the future so the timer-manager - // background thread's expiry path never runs during this test — - // only the explicit `register_filled_gaps()` events do. (The test's - // assertions break if `mark_snapshot_expired` runs concurrently and - // decrements pending on entries we haven't filled yet.) + // deadline = `now + 60s` lies far in the future so the cooperative + // expiry path never runs during this test — only the explicit + // `register_filled_gaps()` events do. (The test's assertions break + // if `mark_snapshot_expired` also runs and decrements pending on + // entries we haven't filled yet.) cfg.active.rtt_timeout_factor = 60.0; openpenny::penny::FlowEngine flow(cfg.active); @@ -120,7 +122,7 @@ int main() { // Phase 2: drop1 is retransmitted (gap filled by a later packet) // ---------------------------------------------------------------- // register_filled_gaps() queues a Retransmit event on the timer - // manager. The timer thread picks it up and calls + // manager. drain_callbacks() then applies // mark_snapshot_retransmitted on this thread's FlowEngine, which: // - decrements flow_stats_.pending_retransmissions by 1, // - increments flow_stats_.retransmitted_packets by 1, @@ -130,8 +132,7 @@ int main() { // decrementing its frozen pending count. flow.register_filled_gaps(std::vector{drop1_id}); - // Wait for the timer thread to process the event before asserting. - // Without this, the assertions race against the background thread. + // Drain the queued retransmit event before asserting. assert(wait_for([&] { return flow.retransmitted_packets() == 1; })); // Phase 2 verification: flow-wide counters diff --git a/tests/unit/flow/test_drop_timer.cpp b/tests/unit/flow/test_drop_timer.cpp index df9f6b6..e7b98b0 100644 --- a/tests/unit/flow/test_drop_timer.cpp +++ b/tests/unit/flow/test_drop_timer.cpp @@ -4,6 +4,7 @@ #include "openpenny/penny/flow/timer/ThreadFlowEventTimer.h" #include "openpenny/penny/flow/state/PennySnapshot.h" #include "openpenny/penny/flow/engine/FlowEngine.h" +#include "openpenny/app/core/PerThreadStats.h" #include "openpenny/net/Packet.h" #include @@ -84,6 +85,40 @@ int main() { assert(flow.non_retransmitted_packets() == 0); } + // Timer callbacks must publish into the same per-thread counter shard as the + // worker that owns the flow; otherwise multi-queue aggregate pending_rtx can + // stay stuck forever. + openpenny::penny::ThreadFlowEventTimerManager::instance().stop(); + openpenny::app::init_thread_counters(2); + openpenny::app::set_thread_counter_index(1); + { + openpenny::Config cfg; + cfg.active.drop_probability = 1.0; + cfg.active.rtt_timeout_factor = 0.05; + + openpenny::penny::FlowEngine flow(cfg.active); + openpenny::FlowKey key{}; + const auto now = std::chrono::steady_clock::now(); + const auto packet_id = openpenny::penny::make_packet_drop_id(3000, 100); + + flow.record_data(3000, now); + const bool dropped = flow.drop_packet(3000, 3100, packet_id, key, now); + assert(dropped); + assert(openpenny::app::aggregate_counters().pending_retransmissions == 1); + + sleep_for_ms(80); + openpenny::penny::ThreadFlowEventTimerManager::instance().drain_callbacks(); + + const auto counters = openpenny::app::thread_counters(); + assert(counters.size() >= 2); + assert(counters[0].pending_retransmissions == 0); + assert(counters[0].non_retransmitted_packets == 0); + assert(counters[1].pending_retransmissions == 0); + assert(counters[1].non_retransmitted_packets == 1); + assert(openpenny::app::aggregate_counters().pending_retransmissions == 0); + assert(openpenny::app::aggregate_counters().non_retransmitted_packets == 1); + } + // Clean shutdown for other tests. openpenny::penny::ThreadFlowEventTimerManager::instance().stop(); return 0; diff --git a/tests/unit/flow/test_flow_evaluation_phase_gate.cpp b/tests/unit/flow/test_flow_evaluation_phase_gate.cpp new file mode 100644 index 0000000..5e40595 --- /dev/null +++ b/tests/unit/flow/test_flow_evaluation_phase_gate.cpp @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: BSD-2-Clause + +#include "openpenny/app/core/PerThreadStats.h" +#include "openpenny/app/core/RuntimeSetup.h" +#include "openpenny/config/Config.h" +#include "openpenny/penny/flow/engine/FlowEngine.h" + +#include + +int main() { + openpenny::app::init_thread_counters(1); + openpenny::app::set_thread_counter_index(0); + + openpenny::Config cfg; + cfg.active.aggregates_enabled = true; + cfg.active.max_drops_aggregates = 1; + cfg.active.max_duplicate_fraction = 0.5; + + openpenny::PipelineOptions opts{}; + opts.mode = openpenny::PipelineOptions::Mode::Active; + + openpenny::set_runtime_setup(cfg, opts, false, false); + auto& runtime = openpenny::runtime_setup_mutable(); + runtime.aggregates_status = openpenny::RuntimeStatus::AggregatesStatus::PENDING; + runtime.aggregates_active = true; + + openpenny::penny::FlowEngine flow(cfg.active); + flow.record_data_packet(); + flow.record_duplicate_packet(); + + flow.evaluate_if_ready(); + assert(flow.final_decision() == + openpenny::penny::FlowEngine::FlowDecision::PENDING); + + runtime.aggregates_status = openpenny::RuntimeStatus::AggregatesStatus::CLOSED_LOOP; + runtime.aggregates_active = false; + + flow.evaluate_if_ready(); + assert(flow.final_decision() == + openpenny::penny::FlowEngine::FlowDecision::PENDING); + + runtime.aggregates_status = openpenny::RuntimeStatus::AggregatesStatus::NON_CLOSED_LOOP; + runtime.aggregates_active = false; + + flow.evaluate_if_ready(); + assert(flow.final_decision() == + openpenny::penny::FlowEngine::FlowDecision::FINISHED_DUPLICATE_EXCEEDED); + + return 0; +} diff --git a/tests/unit/flow/test_terminal_snapshot_resolution.cpp b/tests/unit/flow/test_terminal_snapshot_resolution.cpp new file mode 100644 index 0000000..3a70eff --- /dev/null +++ b/tests/unit/flow/test_terminal_snapshot_resolution.cpp @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: BSD-2-Clause + +#include "openpenny/config/Config.h" +#include "openpenny/app/core/PerThreadStats.h" +#include "openpenny/penny/flow/engine/FlowEngine.h" +#include "openpenny/penny/flow/timer/ThreadFlowEventTimer.h" + +#include +#include +#include +#include + +namespace { + +openpenny::FlowKey make_flow_key(std::uint16_t sport) { + openpenny::FlowKey key{}; + key.src = 0x0a000001; + key.dst = 0x0a000002; + key.sport = sport; + key.dport = 5201; + key.ip_proto = 6; + return key; +} + +} // namespace + +int main() { + using Clock = std::chrono::steady_clock; + + openpenny::penny::ThreadFlowEventTimerManager::instance().stop(); + openpenny::app::init_thread_counters(1); + openpenny::app::set_thread_counter_index(0); + + openpenny::Config cfg; + cfg.active.drop_probability = 1.0; + cfg.active.rtt_timeout_factor = 60.0; + + { + openpenny::penny::FlowEngine flow(cfg.active); + std::vector observed_states; + flow.set_flow_key(make_flow_key(1111)); + flow.set_drop_sink([&observed_states](const openpenny::FlowKey&, + openpenny::penny::PacketDropId, + const openpenny::penny::PacketDropSnapshot& snapshot) { + observed_states.push_back(snapshot.state); + }); + const auto drop_time = Clock::now(); + const auto key = make_flow_key(1111); + const auto packet_id = openpenny::penny::make_packet_drop_id(1000, 100); + + flow.record_data(1000, drop_time); + assert(flow.drop_packet(1000, 1100, packet_id, key, drop_time)); + assert(openpenny::app::aggregate_counters().pending_retransmissions == 1); + + // Generic teardown before the timeout should NOT mark the drop expired. + flow.resolve_pending_snapshots(drop_time + std::chrono::seconds(1)); + + assert(flow.pending_retransmissions() == 0); + assert(flow.non_retransmitted_packets() == 0); + assert(openpenny::app::aggregate_counters().pending_retransmissions == 0); + assert(flow.drop_snapshots().size() == 1); + assert(flow.drop_snapshots().front().second.state == + openpenny::penny::SnapshotState::Invalid); + assert(observed_states.size() == 2); + assert(observed_states.front() == openpenny::penny::SnapshotState::Pending); + assert(observed_states.back() == openpenny::penny::SnapshotState::Invalid); + } + + { + openpenny::penny::FlowEngine flow(cfg.active); + flow.set_flow_key(make_flow_key(1112)); + const auto drop_time = Clock::now(); + const auto key = make_flow_key(1112); + const auto packet_id = openpenny::penny::make_packet_drop_id(1500, 100); + + flow.record_data(1500, drop_time); + assert(flow.drop_packet(1500, 1600, packet_id, key, drop_time)); + + // FIN semantics are immediate: outstanding drops become non-retransmitted. + flow.mark_snapshot_expired(packet_id); + + assert(flow.pending_retransmissions() == 0); + assert(flow.non_retransmitted_packets() == 1); + assert(flow.drop_snapshots().size() == 1); + assert(flow.drop_snapshots().front().second.state == + openpenny::penny::SnapshotState::Expired); + } + + { + openpenny::penny::FlowEngine flow(cfg.active); + std::vector observed_states; + flow.set_flow_key(make_flow_key(1113)); + flow.set_drop_sink([&observed_states](const openpenny::FlowKey&, + openpenny::penny::PacketDropId, + const openpenny::penny::PacketDropSnapshot& snapshot) { + observed_states.push_back(snapshot.state); + }); + const auto drop_time = Clock::now(); + const auto key = make_flow_key(1113); + const auto packet_id = openpenny::penny::make_packet_drop_id(2000, 100); + + flow.record_data(2000, drop_time); + assert(flow.drop_packet(2000, 2100, packet_id, key, drop_time)); + assert(openpenny::app::aggregate_counters().pending_retransmissions == 1); + + // Once the timeout has elapsed, teardown should promote to Expired. + flow.resolve_pending_snapshots(drop_time + std::chrono::seconds(61)); + + assert(flow.pending_retransmissions() == 0); + assert(flow.non_retransmitted_packets() == 1); + assert(openpenny::app::aggregate_counters().pending_retransmissions == 0); + assert(flow.drop_snapshots().size() == 1); + assert(flow.drop_snapshots().front().second.state == + openpenny::penny::SnapshotState::Expired); + assert(observed_states.size() == 2); + assert(observed_states.front() == openpenny::penny::SnapshotState::Pending); + assert(observed_states.back() == openpenny::penny::SnapshotState::Expired); + } + + openpenny::penny::ThreadFlowEventTimerManager::instance().stop(); + return 0; +} From efb1dbc92d23fb0aa9878bbc6348933e3bba36d7 Mon Sep 17 00:00:00 2001 From: Petros Gigis Date: Mon, 4 May 2026 09:04:06 +0100 Subject: [PATCH 7/8] restore serialization in queue binding --- src/app/core/active/ActiveTestPipeline.cpp | 3 +- src/ingress/af_xdp/XdpReader.cpp | 82 ++++++---------------- 2 files changed, 25 insertions(+), 60 deletions(-) diff --git a/src/app/core/active/ActiveTestPipeline.cpp b/src/app/core/active/ActiveTestPipeline.cpp index f24cbcd..a36158b 100644 --- a/src/app/core/active/ActiveTestPipeline.cpp +++ b/src/app/core/active/ActiveTestPipeline.cpp @@ -21,6 +21,7 @@ #include "openpenny/app/core/PipelineRunner.h" #include "openpenny/app/core/PerThreadStats.h" #include "openpenny/app/core/DropCollectorBinding.h" +#include "openpenny/app/core/RuntimeSetup.h" #include "openpenny/log/Log.h" #include "openpenny/penny/flow/engine/FlowEngine.h" #include "openpenny/penny/flow/timer/ThreadFlowEventTimer.h" @@ -256,7 +257,7 @@ bool ActiveTestPipelineRunner::individual_flow_evaluation_enabled() const { if (!aggregate_phase_configured) { return true; } - const auto status = current_aggregates_status(); + const auto status = openpenny::current_aggregates_status(); return status == RuntimeStatus::AggregatesStatus::NON_CLOSED_LOOP || status == RuntimeStatus::AggregatesStatus::DUPLICATES_EXCEEDED; } diff --git a/src/ingress/af_xdp/XdpReader.cpp b/src/ingress/af_xdp/XdpReader.cpp index 1bce4ff..0ed8dd0 100644 --- a/src/ingress/af_xdp/XdpReader.cpp +++ b/src/ingress/af_xdp/XdpReader.cpp @@ -54,8 +54,6 @@ static uint64_t now_ns() { struct SharedAttachState { std::mutex mutex; unsigned refs{0}; ///< Workers currently opening or opened on this shared attach state. - unsigned ready_workers{0}; ///< Workers that finished AF_XDP socket bring-up and published xsks_map. - bool shared_resources_ready{false}; ///< Shared BPF maps / program are prepared for sibling workers. bool rss_checked{false}; ///< Only the first-opening worker runs the RSS coverage check. #ifdef OPENPENNY_WITH_LIBBPF bool attached{false}; @@ -513,11 +511,10 @@ bool XdpReader::open(const std::string& ifname, unsigned queue) { return false; } - // Serialise only the shared BPF attach / map-pin phase across queue - // workers. The expensive per-queue socket bring-up runs after this and is - // intentionally parallel. + // Serialize queue-worker bring-up against the shared attach state so + // xsks_map publication and live-rule activation happen in a well-defined + // order across every queue. std::unique_lock shared_lock(impl.shared_attach->mutex); - bool shared_ref_acquired = false; if (impl.tuning.verbose) { TCPLOG_INFO("Attempting AF_XDP reader on %s queue %u", ifname.c_str(), queue); @@ -562,37 +559,6 @@ bool XdpReader::open(const std::string& ifname, unsigned queue) { rs.ready = false; }; - auto release_shared_startup_ref = [&]() { - if (!shared_ref_acquired || !impl.shared_attach) { - return; - } - bool release_state = false; - { - std::lock_guard lock(impl.shared_attach->mutex); - if (impl.shared_attach->refs > 0) { - --impl.shared_attach->refs; - } - if (impl.shared_attach->refs == 0) { - if (impl.shared_attach->attached && impl.tuning.detach_on_close) { - bpf_xdp_detach(impl.shared_attach->ifindex, - impl.shared_attach->xdp_flags, - nullptr); - } - impl.shared_attach->attached = false; - impl.shared_attach->ifindex = 0; - impl.shared_attach->xdp_flags = 0; - impl.shared_attach->ready_workers = 0; - impl.shared_attach->shared_resources_ready = false; - impl.shared_attach->rss_checked = false; - release_state = true; - } - } - shared_ref_acquired = false; - if (release_state) { - release_shared_attach_state(impl.shared_attach_key, impl.shared_attach); - } - }; - // Populate rs.*_fd from a freshly loaded bpf_object. auto open_maps_from_object = [&](bpf_object* obj) -> bool { if (!obj) return false; @@ -962,12 +928,19 @@ bool XdpReader::open(const std::string& ifname, unsigned queue) { // c) Otherwise, load the object fresh and (optionally) pin the maps // so sibling workers can find them. - const bool shared_resources_ready = impl.shared_attach->shared_resources_ready; + const bool shared_reader_already_open = impl.shared_attach->refs > 0; bool pins_ok = false; - bool need_open_maps_from_pins_after_unlock = false; - if (shared_resources_ready) { - rs.xdp_flags = impl.shared_attach->xdp_flags; - need_open_maps_from_pins_after_unlock = true; + if (shared_reader_already_open) { + if (!open_maps_from_pins()) { + TCPLOG_ERROR("Shared AF_XDP maps are unavailable for %s queue %u; " + "ensure bpffs pins remain accessible while using " + "multiple queues.", + ifname.c_str(), queue); + cleanup(); + return false; + } + rs.pinned_maps = true; + pins_ok = true; } else if (impl.tuning.reuse_pins && open_maps_from_pins()) { bool stale_pins = false; bpf_map_info conf_info{}; @@ -1066,7 +1039,7 @@ bool XdpReader::open(const std::string& ifname, unsigned queue) { // match rules are deferred to the LAST worker (see Step 6 below) so // every queue's xsks_map[N] entry is in place before redirects begin. const bool should_publish_pass_defaults = - impl.tuning.update_conf_map && !shared_resources_ready; + impl.tuning.update_conf_map && !shared_reader_already_open; if (should_publish_pass_defaults && !xdp::program_xdp_pass_defaults( xdp::XdpRuleMapFds{rs.conf_fd, rs.settings_fd}, @@ -1133,27 +1106,18 @@ bool XdpReader::open(const std::string& ifname, unsigned queue) { // Real match rules are deferred to the last worker. // - // Why: worker setup is serialised through shared_attach->mutex and - // takes ~80-100 ms per worker (UMEM alloc + bind + fill-ring prime). - // With queue_count=63 that's a 5+ second startup window. If worker 0 - // publishes the real rules during ITS open(), the BPF program starts - // redirecting matched packets immediately — but only xsks_map[0] is - // populated, so packets to queues 1..62 hit xsk_miss until each later - // worker registers. We saw this in the wild: after a 9k-packet burst, - // 2946 xsk_hit (queue 0) and 6213 xsk_miss (the rest). + // Why: worker setup is serialized through shared_attach->mutex and can + // take noticeable time per queue (UMEM alloc + bind + fill-ring prime). + // If worker 0 publishes the real rules during its own open(), the BPF + // program starts redirecting matched packets immediately while later + // queues still have no xsks_map entry yet. // // Fix: every worker publishes pass-only-defaults during worker 0's // open (so the program never blackholes), then the LAST worker swaps // to the real rules once every queue has registered its socket. // - // "Last worker" check: we bump refs BEFORE the check so refs reflects - // the total number of workers that have completed setup, including - // this one. With queue_count=N, the worker that observes refs == N - // after its own increment is the last and owns the rule swap. - // - // Transfer ownership of the attach from this reader to the shared - // state first so the program stays attached if this worker closes - // early, then bump refs and -- if we're last -- publish real rules. + // "Last worker" check: refs is bumped after this worker finishes setup, + // so the worker that observes refs == queue_count owns the real-rule swap. if (rs.attached) { impl.shared_attach->attached = true; impl.shared_attach->ifindex = rs.ifindex; From 1adc9b90c8dabd9f94122264cde45e007a12c75d Mon Sep 17 00:00:00 2001 From: Petros Gigis Date: Mon, 4 May 2026 09:11:15 +0100 Subject: [PATCH 8/8] add duplicates exceeded test --- .../test_aggregate_pending_resolution.cpp | 22 +++++++++++-------- .../flow/test_flow_evaluation_phase_gate.cpp | 16 ++++++++------ 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/tests/unit/flow/test_aggregate_pending_resolution.cpp b/tests/unit/flow/test_aggregate_pending_resolution.cpp index a7362f4..e676e7e 100644 --- a/tests/unit/flow/test_aggregate_pending_resolution.cpp +++ b/tests/unit/flow/test_aggregate_pending_resolution.cpp @@ -81,10 +81,11 @@ int main() { openpenny::set_runtime_setup(cfg, opts, false, false); auto& runtime = openpenny::runtime_setup_mutable(); - runtime.aggregates_status = openpenny::RuntimeStatus::AggregatesStatus::PENDING; + openpenny::set_current_aggregates_status( + openpenny::RuntimeStatus::AggregatesStatus::PENDING); runtime.aggregate_eval_counters = {}; - runtime.has_aggregate_eval = false; - runtime.aggregates_active = true; + openpenny::set_current_has_aggregate_eval(false); + openpenny::set_current_aggregates_active(true); std::atomic stop_flag{false}; auto collector = std::make_shared(1); @@ -106,10 +107,11 @@ int main() { assert(controller.aggregates_ready()); assert(controller.collector_completed()); - runtime.aggregates_status = openpenny::RuntimeStatus::AggregatesStatus::PENDING; + openpenny::set_current_aggregates_status( + openpenny::RuntimeStatus::AggregatesStatus::PENDING); runtime.aggregate_eval_counters = {}; - runtime.has_aggregate_eval = false; - runtime.aggregates_active = true; + openpenny::set_current_has_aggregate_eval(false); + openpenny::set_current_aggregates_active(true); openpenny::PipelineSummary invalid_summary; invalid_summary.drop_snapshots.push_back(make_invalid_snapshot_record()); @@ -120,10 +122,12 @@ int main() { openpenny::RuntimeStatus::AggregatesStatus::NON_CLOSED_LOOP); assert(runtime.has_aggregate_eval); - runtime.aggregates_status = openpenny::RuntimeStatus::AggregatesStatus::PENDING; + openpenny::set_current_aggregates_status( + openpenny::RuntimeStatus::AggregatesStatus::PENDING); runtime.aggregate_eval_counters = {}; - runtime.has_aggregate_eval = false; - runtime.aggregates_active = true; + openpenny::set_current_has_aggregate_eval(false); + openpenny::set_current_aggregates_active(true); + cfg.active.max_duplicate_fraction = 0.1; openpenny::PipelineSummary duplicate_summary; duplicate_summary.drop_snapshots.push_back(make_duplicate_exceeded_snapshot_record()); diff --git a/tests/unit/flow/test_flow_evaluation_phase_gate.cpp b/tests/unit/flow/test_flow_evaluation_phase_gate.cpp index 5e40595..8259dd3 100644 --- a/tests/unit/flow/test_flow_evaluation_phase_gate.cpp +++ b/tests/unit/flow/test_flow_evaluation_phase_gate.cpp @@ -20,9 +20,9 @@ int main() { opts.mode = openpenny::PipelineOptions::Mode::Active; openpenny::set_runtime_setup(cfg, opts, false, false); - auto& runtime = openpenny::runtime_setup_mutable(); - runtime.aggregates_status = openpenny::RuntimeStatus::AggregatesStatus::PENDING; - runtime.aggregates_active = true; + openpenny::set_current_aggregates_status( + openpenny::RuntimeStatus::AggregatesStatus::PENDING); + openpenny::set_current_aggregates_active(true); openpenny::penny::FlowEngine flow(cfg.active); flow.record_data_packet(); @@ -32,15 +32,17 @@ int main() { assert(flow.final_decision() == openpenny::penny::FlowEngine::FlowDecision::PENDING); - runtime.aggregates_status = openpenny::RuntimeStatus::AggregatesStatus::CLOSED_LOOP; - runtime.aggregates_active = false; + openpenny::set_current_aggregates_status( + openpenny::RuntimeStatus::AggregatesStatus::CLOSED_LOOP); + openpenny::set_current_aggregates_active(false); flow.evaluate_if_ready(); assert(flow.final_decision() == openpenny::penny::FlowEngine::FlowDecision::PENDING); - runtime.aggregates_status = openpenny::RuntimeStatus::AggregatesStatus::NON_CLOSED_LOOP; - runtime.aggregates_active = false; + openpenny::set_current_aggregates_status( + openpenny::RuntimeStatus::AggregatesStatus::NON_CLOSED_LOOP); + openpenny::set_current_aggregates_active(false); flow.evaluate_if_ready(); assert(flow.final_decision() ==