From b3dc1cd29e2fa8d9ab8f34dad724cc06abcdcdcb Mon Sep 17 00:00:00 2001 From: zuub-don Date: Fri, 7 Aug 2026 19:37:12 -0700 Subject: [PATCH] File each lost observation against the packet it belonged to Attributing a hole to a probe narrowed where to look but left every absence unusable: knowing that ip_rcv failed to emit somewhere says nothing about whether a particular packet's missing hop was never reached or merely never observed. The identity of the packet is already in scope wherever a reserve fails, so record the loss itself rather than inferring it later: a table keyed by packet and probe, written only on the failure path. Deliberately a plain hash and not an LRU. A full LRU evicts silently, and a silently evicted entry would make a packet look like it lost nothing, which is exactly the claim this table exists to support. A plain hash refuses the insert and counts the refusal instead. While that count is zero the table is exhaustive, so a packet absent from it provably lost nothing and a function missing from its chain was never reached. explain now rules per packet: complete, lost, or unknown. Reserve failures are the only loss kind a packet can own. A recursion miss never reaches the emit path, and a decode, enrichment or output failure discards a record after the kernel has handed it over, by which point nothing knows which packet it described. Any of those leaves a hole no packet can be cleared of, so clearing any packet requires all of them to be zero. A packet can now appear in the ledger while appearing nowhere in the capture. That is a packet whose every observation was dropped, which previously left no trace at all; replay counts these separately from the packets it actually observed. Carries the review fixes applied to the parent branch through to the new field: skb_loss dedupes revisited keys from bpf_map_get_next_key iteration, and both breakdowns are parameters of into_reliability so neither can be left unset by a future call site. Co-Authored-By: Claude Opus 5 (1M context) --- crates/skbx-cli/src/main.rs | 98 ++++++++++++- crates/skbx-contract/src/lib.rs | 182 +++++++++++++++++++++++- crates/skbx-core/src/replay.rs | 222 +++++++++++++++++++++++++++++- crates/skbx-sensor/bpf/skbx.bpf.c | 97 ++++++++++--- crates/skbx-sensor/src/lib.rs | 2 +- crates/skbx-sensor/src/live.rs | 55 +++++++- crates/skbx-sensor/src/raw.rs | 66 +++++++-- docs/architecture.md | 30 +++- 8 files changed, 709 insertions(+), 43 deletions(-) diff --git a/crates/skbx-cli/src/main.rs b/crates/skbx-cli/src/main.rs index f6996c1..2735a33 100644 --- a/crates/skbx-cli/src/main.rs +++ b/crates/skbx-cli/src/main.rs @@ -5,8 +5,9 @@ use skbx_contract::{ BpfMapOperation, BpfMapOperationKind, BpfProgramAction, BpfProgramKind, BpfProgramPhase, BpfProgramRef, BtfDump, CONTRACT_VERSION, CaptureEnd, CaptureFilters, CaptureLimits, CaptureStart, Describe, Envelope, EventAssociation, FunctionRef, KernelCpuLoss, - KernelProbeLoss, MatchOrigin, MetadataEncoding, MetadataScalar, MetadataValue, PacketMeta, - PacketTuple, PresentedTimestamp, Reliability, StopReason, TimestampMode, TraceEvent, + KernelProbeLoss, KernelSkbLoss, MatchOrigin, MetadataEncoding, MetadataScalar, MetadataValue, + PacketMeta, PacketTuple, PresentedTimestamp, Reliability, StopReason, TimestampMode, + TraceEvent, }; use skbx_core::{ BoundedMap, DEFAULT_BTF_PATH, DropReasonTable, SymbolTable, build_dynamic_probe_plan, @@ -14,7 +15,7 @@ use skbx_core::{ ensure_btf_dump_support, event_handle, explain_with_context, replay, resolve_skb_filter, resolve_skb_metadata, resolve_xdp_filter, resolve_xdp_metadata, }; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::ffi::CString; use std::fs::{self, File, OpenOptions}; use std::io::{BufRead, BufReader, BufWriter, Write}; @@ -572,6 +573,28 @@ fn run(cli: Cli) -> Result { summary.reliability.kernel_unattributed_reserve_failures ); } + let affected = summary + .reliability + .kernel_loss_by_skb + .iter() + .map(|loss| loss.skb.as_str()) + .collect::>() + .len(); + if summary.reliability.loss_is_fully_attributed() { + println!( + "Absence is evidence: every loss was filed against a packet, so any packet not among the {affected} affected provably lost nothing" + ); + if summary.skbs_lost_entirely != 0 { + println!( + "Packets lost entirely: {} (every observation dropped; absent from the {} packets observed above)", + summary.skbs_lost_entirely, summary.distinct_skbs + ); + } + } else if !summary.complete { + println!( + "Absence is not evidence: loss remains that no packet can be cleared of" + ); + } } } Ok(if summary.complete { 0 } else { 3 }) @@ -1140,11 +1163,13 @@ fn capture( // be attributed without being counted, leaving the // breakdown larger than the total it refines. let probes = kernel_loss_by_probe(&sensor, &symbols)?; + let skbs = kernel_loss_by_skb(&sensor, &symbols)?; let current = sensor.stats()?.into_reliability( sensor.recursion_misses()?, sensor.decode_failures(), sensor.enrichment_failures(), probes, + skbs, ); let segment = reliability_delta(¤t, &reliability_checkpoint); reliability_checkpoint = current; @@ -1159,12 +1184,14 @@ fn capture( // Attribution before total, so the breakdown can only ever undercount // the number it refines. See the segment boundary above. let probes = kernel_loss_by_probe(&sensor, &symbols)?; + let skbs = kernel_loss_by_skb(&sensor, &symbols)?; let stats = sensor.stats()?; let reliability = stats.into_reliability( sensor.recursion_misses()?, sensor.decode_failures(), sensor.enrichment_failures(), probes, + skbs, ); let complete = reliability.complete(); let end = CaptureEnd { @@ -1728,9 +1755,50 @@ fn reliability_delta(current: &Reliability, previous: &Reliability) -> Reliabili kernel_unattributed_reserve_failures: current .kernel_unattributed_reserve_failures .saturating_sub(previous.kernel_unattributed_reserve_failures), + kernel_loss_by_skb: kernel_loss_by_skb_delta( + ¤t.kernel_loss_by_skb, + &previous.kernel_loss_by_skb, + ), + kernel_skb_loss_unattributed: current + .kernel_skb_loss_unattributed + .saturating_sub(previous.kernel_skb_loss_unattributed), } } +/// Matched on packet identity and probe together, since one packet can lose +/// observations at several probes and each is its own ledger line. +fn kernel_loss_by_skb_delta( + current: &[KernelSkbLoss], + previous: &[KernelSkbLoss], +) -> Vec { + let site = |loss: &KernelSkbLoss| { + ( + loss.skb.clone(), + loss.function + .as_ref() + .map(|function| function.address.clone()), + loss.program_id, + ) + }; + let mut segment: Vec = current + .iter() + .filter_map(|now| { + let before = previous.iter().find(|earlier| site(earlier) == site(now)); + let reserve_failures = now + .reserve_failures + .saturating_sub(before.map_or(0, |earlier| earlier.reserve_failures)); + (reserve_failures != 0).then(|| KernelSkbLoss { + skb: now.skb.clone(), + function: now.function.clone(), + program_id: now.program_id, + reserve_failures, + }) + }) + .collect(); + segment.sort_by_key(site); + segment +} + /// Resolves each losing probe site to a name. A kernel site carries its /// instruction pointer symbolized against kallsyms; a TC/XDP site carries the /// BPF program id, which needs no lookup. @@ -1752,6 +1820,30 @@ fn kernel_loss_by_probe( .collect()) } +/// Resolves each lost observation to the packet and probe it belonged to. The +/// identity is formatted exactly as events format theirs, so a reader can join +/// this against a replayed chain without reformatting either side. +fn kernel_loss_by_skb( + sensor: &skbx_sensor::LiveSensor, + symbols: &SymbolTable, +) -> Result> { + Ok(sensor + .skb_loss()? + .into_iter() + .map(|loss| KernelSkbLoss { + skb: format!("{:#x}", loss.key.identity), + function: (loss.key.site.function_ip != 0).then(|| FunctionRef { + address: format!("{:#x}", loss.key.site.function_ip), + symbol: symbols + .resolve(loss.key.site.function_ip) + .map(str::to_owned), + }), + program_id: (loss.key.site.program_id != 0).then_some(loss.key.site.program_id), + reserve_failures: loss.reserve_failures, + }) + .collect()) +} + /// Probes are matched on their site, not their position: the list is ordered by /// loss, so a probe can move between checkpoints as other probes overtake it. fn kernel_loss_by_probe_delta( diff --git a/crates/skbx-contract/src/lib.rs b/crates/skbx-contract/src/lib.rs index d708790..3fed5e9 100644 --- a/crates/skbx-contract/src/lib.rs +++ b/crates/skbx-contract/src/lib.rs @@ -678,6 +678,26 @@ pub struct KernelProbeLoss { pub reserve_failures: u64, } +/// A lost observation, named down to the packet it belonged to. +/// +/// This is the attribution that can restore absence as evidence. Given a +/// capture where `kernel_skb_loss_unattributed` is zero, this table is +/// exhaustive: a packet that does not appear in it lost nothing, so a function +/// missing from that packet's chain was genuinely never reached rather than +/// merely unobserved. The kernel table is a plain hash that refuses inserts +/// when full rather than an LRU that evicts silently, which is what makes that +/// negative claim provable instead of merely likely. +/// +/// `skb` is the same identity emitted on every event, so this joins directly +/// against a replayed chain. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct KernelSkbLoss { + pub skb: String, + pub function: Option, + pub program_id: Option, + pub reserve_failures: u64, +} + #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Reliability { pub kernel_reserve_failures: u64, @@ -707,6 +727,58 @@ pub struct Reliability { /// `kernel_loss_by_probe` undercounts by exactly this much. #[serde(default)] pub kernel_unattributed_reserve_failures: u64, + /// Which observation of which packet was lost, ordered by packet. + #[serde(default)] + pub kernel_loss_by_skb: Vec, + /// Losses the kernel could not file against a packet because the per-skb + /// table was full. While this is zero, `kernel_loss_by_skb` is exhaustive + /// and a packet absent from it provably lost nothing; once it is non-zero, + /// no such claim can be made about any packet. + #[serde(default)] + pub kernel_skb_loss_unattributed: u64, +} + +impl Reliability { + /// Whether a packet absent from `kernel_loss_by_skb` can be treated as + /// having lost nothing. + /// + /// This is the difference between "this function was never hit" and "we + /// lost that one". It holds only while every loss was filed against a + /// packet; a single unfiled loss means any packet could be the one missing + /// an observation, so the claim collapses for all of them at once. + pub fn skb_loss_is_exhaustive(&self) -> bool { + self.kernel_skb_loss_unattributed == 0 + } + + /// Whether every lost observation in this capture could be filed against a + /// specific packet. + /// + /// Reserve failures are filed per packet, but the other loss kinds are not: + /// a recursion miss never reaches the emit path, and a decode, enrichment, + /// or output failure discards a record after the kernel has already handed + /// it over, by which point nothing knows which packet it described. Any of + /// those leaves a hole no packet can be cleared of, so all of them must be + /// zero before an absence anywhere in this capture may be read as evidence. + /// + /// Read failures are deliberately not included: they degrade fields within + /// an event that was still emitted, and each one is already visible on the + /// event that carries it. + pub fn loss_is_fully_attributed(&self) -> bool { + self.skb_loss_is_exhaustive() + && self.kernel_recursion_misses == 0 + && self.userspace_decode_failures == 0 + && self.userspace_enrichment_failures == 0 + && self.output_failures == 0 + } + + /// Observations lost for one packet, by identity, across every probe. + pub fn losses_for_skb(&self, skb: &str) -> u64 { + self.kernel_loss_by_skb + .iter() + .filter(|loss| loss.skb == skb) + .map(|loss| loss.reserve_failures) + .sum() + } } impl Reliability { @@ -762,6 +834,13 @@ pub struct TraceSummary { pub complete: bool, pub events: u64, pub distinct_skbs: usize, + /// Packets that lost every observation, so they appear in the footer's + /// per-packet loss ledger but nowhere in the capture itself. Previously + /// invisible: a packet whose whole chain was dropped left no trace at all. + /// Not counted in `distinct_skbs`, which only counts packets that were + /// actually observed. + #[serde(default)] + pub skbs_lost_entirely: usize, pub functions: BTreeMap, pub processes: BTreeMap, #[serde(default)] @@ -1215,7 +1294,25 @@ pub fn json_schema() -> serde_json::Value { "type": "array", "items": {"$ref": "#/$defs/KernelProbeLoss"} }, - "kernel_unattributed_reserve_failures": {"type": "integer", "minimum": 0} + "kernel_unattributed_reserve_failures": {"type": "integer", "minimum": 0}, + "kernel_loss_by_skb": { + "type": "array", + "items": {"$ref": "#/$defs/KernelSkbLoss"} + }, + "kernel_skb_loss_unattributed": {"type": "integer", "minimum": 0} + }, + "additionalProperties": false + }, + "KernelSkbLoss": { + "type": "object", + "required": ["skb", "function", "program_id", "reserve_failures"], + "properties": { + "skb": {"type": "string"}, + "function": { + "oneOf": [{"$ref": "#/$defs/FunctionRef"}, {"type": "null"}] + }, + "program_id": {"type": ["integer", "null"], "minimum": 0}, + "reserve_failures": {"type": "integer", "minimum": 0} }, "additionalProperties": false }, @@ -1359,6 +1456,89 @@ mod tests { assert!(!reliability.complete()); } + fn skb_loss(skb: &str, symbol: &str, reserve_failures: u64) -> KernelSkbLoss { + KernelSkbLoss { + skb: skb.into(), + function: Some(FunctionRef { + address: "0xffffffff81234560".into(), + symbol: Some(symbol.into()), + }), + program_id: None, + reserve_failures, + } + } + + #[test] + fn an_exhaustive_skb_table_clears_the_packets_absent_from_it() { + let reliability = Reliability { + kernel_reserve_failures: 3, + kernel_loss_by_skb: vec![skb_loss("0xaaa", "nf_hook_slow", 3)], + ..Reliability::default() + }; + + assert!(reliability.loss_is_fully_attributed()); + assert_eq!(reliability.losses_for_skb("0xaaa"), 3); + // The packet nobody filed a loss against is the one whose absences can + // be read as evidence. + assert_eq!(reliability.losses_for_skb("0xbbb"), 0); + } + + #[test] + fn one_unfiled_loss_collapses_the_claim_for_every_packet() { + let reliability = Reliability { + kernel_reserve_failures: 4, + kernel_loss_by_skb: vec![skb_loss("0xaaa", "nf_hook_slow", 3)], + kernel_skb_loss_unattributed: 1, + ..Reliability::default() + }; + + assert!(!reliability.skb_loss_is_exhaustive()); + assert!(!reliability.loss_is_fully_attributed()); + // 0xbbb still reports zero filed losses, which is exactly why the + // exhaustiveness flag has to be consulted first. + assert_eq!(reliability.losses_for_skb("0xbbb"), 0); + } + + #[test] + fn loss_that_no_packet_owns_defeats_attribution() { + // Each of these discards a record after the kernel handed it over, or + // before it ever reached the emit path, so no packet can be cleared. + for defeat in [ + Reliability { + kernel_recursion_misses: 1, + ..Reliability::default() + }, + Reliability { + userspace_decode_failures: 1, + ..Reliability::default() + }, + Reliability { + userspace_enrichment_failures: 1, + ..Reliability::default() + }, + Reliability { + output_failures: 1, + ..Reliability::default() + }, + ] { + assert!(defeat.skb_loss_is_exhaustive()); + assert!(!defeat.loss_is_fully_attributed(), "{defeat:?}"); + } + } + + #[test] + fn read_failures_do_not_defeat_attribution() { + // A read failure degrades fields on an event that was still emitted, + // and is already visible on that event. + let reliability = Reliability { + kernel_read_failures: 9, + kernel_filtered_events: 500, + ..Reliability::default() + }; + + assert!(reliability.loss_is_fully_attributed()); + } + #[test] fn per_probe_loss_names_the_leg_and_admits_undercounting() { let reliability = Reliability { diff --git a/crates/skbx-core/src/replay.rs b/crates/skbx-core/src/replay.rs index 31fee9a..e9a9e12 100644 --- a/crates/skbx-core/src/replay.rs +++ b/crates/skbx-core/src/replay.rs @@ -212,6 +212,16 @@ pub fn replay(reader: R) -> Result { None => (false, Reliability::default(), None), }; + // A packet can appear in the loss ledger without appearing in the capture: + // that is precisely the packet whose every observation was dropped. + let skbs_lost_entirely = reliability + .kernel_loss_by_skb + .iter() + .map(|loss| &loss.skb) + .filter(|skb| !skbs.contains(*skb)) + .collect::>() + .len(); + let routes = routes.expect("start and route state are initialized together"); let route_evictions = routes.evictions(); let (route_patterns, route_consensus) = @@ -224,6 +234,7 @@ pub fn replay(reader: R) -> Result { complete, events: observed_events, distinct_skbs: skbs.len(), + skbs_lost_entirely, functions, processes, route_patterns, @@ -459,6 +470,33 @@ pub struct Explanation { pub target: TraceEvent, pub same_skb_evidence: Vec, pub truncated: bool, + /// Whether the evidence below can be read as the whole story for this + /// packet. See [`SkbEvidence`]. + #[serde(default)] + pub evidence: SkbEvidence, +} + +/// Whether a function missing from a packet's evidence was never reached, or +/// merely never observed. +/// +/// This is the question a capture normally cannot answer. It becomes answerable +/// only when every lost observation in the capture was filed against a specific +/// packet, which lets a packet with no filed losses be cleared outright. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "verdict", rename_all = "snake_case")] +pub enum SkbEvidence { + /// Nothing was lost for this packet, and every loss elsewhere in the + /// capture was accounted for. A function absent from the evidence was not + /// reached — this is the one verdict under which absence is evidence. + Complete, + /// This packet lost observations. Its chain has holes, so an absent + /// function may have run unobserved. + Lost { observations_lost: u64 }, + /// The capture holds losses that could not be filed against any packet, so + /// no packet can be cleared — including this one. Also the verdict when no + /// footer was read. + #[default] + Unknown, } pub fn explain(reader: R, handle: &str) -> Result { @@ -505,9 +543,119 @@ pub fn explain(reader: R, handle: &str) -> Result CaptureEnd { + CaptureEnd { + schema: CONTRACT_VERSION.into(), + capture_id: "c".into(), + events: 1, + complete: reliability.complete(), + reliability, + stop_reason: StopReason::Duration, + segment: None, + } + } + + fn loss(skb: &str, count: u64) -> KernelSkbLoss { + KernelSkbLoss { + skb: skb.into(), + function: Some(FunctionRef { + address: "0xffffffff81234560".into(), + symbol: Some("nf_hook_slow".into()), + }), + program_id: None, + reserve_failures: count, + } + } + + #[test] + fn a_packet_with_no_filed_loss_is_cleared_even_when_the_capture_lost_events() { + // The capture as a whole is incomplete, but every hole belongs to a + // known packet, so a different packet's absences remain evidence. + let end = footer(Reliability { + kernel_reserve_failures: 5, + kernel_loss_by_skb: vec![loss("0xaaa", 5)], + ..Reliability::default() + }); + + assert!(!end.complete); + assert_eq!(skb_evidence(Some(&end), "0xbbb"), SkbEvidence::Complete); + assert_eq!( + skb_evidence(Some(&end), "0xaaa"), + SkbEvidence::Lost { + observations_lost: 5 + } + ); + } + + #[test] + fn an_unfiled_loss_makes_every_packet_unknown() { + let end = footer(Reliability { + kernel_reserve_failures: 6, + kernel_loss_by_skb: vec![loss("0xaaa", 5)], + kernel_skb_loss_unattributed: 1, + ..Reliability::default() + }); + + assert_eq!(skb_evidence(Some(&end), "0xbbb"), SkbEvidence::Unknown); + assert_eq!(skb_evidence(Some(&end), "0xaaa"), SkbEvidence::Unknown); + } + + #[test] + fn output_loss_makes_every_packet_unknown() { + // An output failure discards a record the kernel already handed over, + // so no packet owns it and none can be cleared. + let end = footer(Reliability { + output_failures: 1, + ..Reliability::default() + }); + + assert_eq!(skb_evidence(Some(&end), "0xaaa"), SkbEvidence::Unknown); + } + + #[test] + fn a_lossless_capture_clears_every_packet() { + let end = footer(Reliability::default()); + + assert!(end.complete); + assert_eq!(skb_evidence(Some(&end), "0xaaa"), SkbEvidence::Complete); + } + + #[test] + fn a_missing_footer_never_claims_completeness() { + assert_eq!(skb_evidence(None, "0xaaa"), SkbEvidence::Unknown); + } +} + +/// Rules on whether one packet's chain can be read as complete. +/// +/// Deliberately conservative in both directions it can be wrong: no footer, or +/// any loss the capture could not file against a packet, yields `Unknown` +/// rather than a guess. +fn skb_evidence(footer: Option<&CaptureEnd>, skb: &str) -> SkbEvidence { + let Some(footer) = footer else { + return SkbEvidence::Unknown; + }; + if !footer.reliability.loss_is_fully_attributed() { + return SkbEvidence::Unknown; + } + match footer.reliability.losses_for_skb(skb) { + 0 => SkbEvidence::Complete, + observations_lost => SkbEvidence::Lost { observations_lost }, + } +} + /// Explain with a reopenable path, returning bounded same-SKB evidence. pub fn explain_file(path: &std::path::Path, handle: &str) -> Result { let first = std::io::BufReader::new(std::fs::File::open(path)?); @@ -526,6 +674,7 @@ pub fn explain_with_context( let target_skb = event_identity(&explanation.target).to_owned(); let mut evidence = Vec::new(); let mut matching = 0_usize; + let mut footer: Option = None; for (index, line) in second.lines().enumerate() { let line = line?; @@ -537,17 +686,22 @@ pub fn explain_with_context( line: index + 1, source, })?; - if let Envelope::Event(event) = envelope { - if event_identity(&event) == target_skb { - matching += 1; - if evidence.len() < MAX_EXPLAIN_NEIGHBORS { - evidence.push(event); + match envelope { + Envelope::Event(event) => { + if event_identity(&event) == target_skb { + matching += 1; + if evidence.len() < MAX_EXPLAIN_NEIGHBORS { + evidence.push(event); + } } } + Envelope::CaptureEnd(end) => footer = Some(end), + Envelope::CaptureStart(_) => {} } } explanation.truncated = matching > evidence.len(); explanation.same_skb_evidence = evidence; + explanation.evidence = skb_evidence(footer.as_ref(), &target_skb); Ok(explanation) } @@ -706,6 +860,64 @@ mod tests { lines.join("\n") } + /// Replaces the fixture footer with one carrying the given per-packet loss. + fn fixture_with_skb_loss(losses: &[(&str, u64)], unattributed: u64) -> String { + let mut lines: Vec = fixture(true).lines().map(str::to_owned).collect(); + let reliability = Reliability { + kernel_reserve_failures: losses.iter().map(|(_, n)| n).sum::() + unattributed, + kernel_skb_loss_unattributed: unattributed, + kernel_loss_by_skb: losses + .iter() + .map(|(skb, n)| skbx_contract::KernelSkbLoss { + skb: (*skb).into(), + function: Some(FunctionRef { + address: "0x2".into(), + symbol: Some("ip_rcv".into()), + }), + program_id: None, + reserve_failures: *n, + }) + .collect(), + ..Reliability::default() + }; + let last = lines.len() - 1; + lines[last] = serde_json::to_string(&Envelope::CaptureEnd(CaptureEnd { + schema: CONTRACT_VERSION.into(), + capture_id: "c1".into(), + events: 1, + complete: reliability.complete(), + reliability, + stop_reason: StopReason::Duration, + segment: None, + })) + .unwrap(); + lines.join("\n") + } + + #[test] + fn a_packet_that_lost_every_observation_is_counted_but_never_observed() { + // 0x1 is the only packet in the stream. 0x99 appears solely in the + // footer's ledger, which is what a wholly dropped chain looks like: + // before per-packet attribution it left no trace at all. + let summary = replay(Cursor::new(fixture_with_skb_loss( + &[("0x1", 2), ("0x99", 5)], + 0, + ))) + .unwrap(); + + assert_eq!(summary.distinct_skbs, 1); + assert_eq!(summary.skbs_lost_entirely, 1); + assert!(summary.reliability.loss_is_fully_attributed()); + } + + #[test] + fn an_observed_packet_is_never_counted_as_lost_entirely() { + let summary = replay(Cursor::new(fixture_with_skb_loss(&[("0x1", 2)], 0))).unwrap(); + + assert_eq!(summary.distinct_skbs, 1); + assert_eq!(summary.skbs_lost_entirely, 0); + } + #[test] fn replay_is_deterministic() { let input = fixture(true); diff --git a/crates/skbx-sensor/bpf/skbx.bpf.c b/crates/skbx-sensor/bpf/skbx.bpf.c index 3569651..258141b 100644 --- a/crates/skbx-sensor/bpf/skbx.bpf.c +++ b/crates/skbx-sensor/bpf/skbx.bpf.c @@ -296,9 +296,10 @@ struct kernel_stats { __u64 read_failures; __u64 filtered_events; __u64 unattributed_reserve_failures; + __u64 unattributed_skb_losses; }; -_Static_assert(sizeof(struct kernel_stats) == 32, +_Static_assert(sizeof(struct kernel_stats) == 40, "kernel stats ABI changed"); /* Identifies the probe that could not emit. A kernel-function probe is named @@ -316,6 +317,21 @@ _Static_assert(sizeof(struct probe_site_key) == 16, #define MAX_PROBE_SITES 512 +/* Which observation of which packet was lost. The identity is the same value + * stamped into every emitted record, so a loss correlates directly with the + * chain a replay reconstructs for that packet. */ +struct skb_loss_key { + __u64 identity; + __u64 function_ip; + __u32 program_id; + __u32 _pad; +}; + +_Static_assert(sizeof(struct skb_loss_key) == 24, + "skb loss key ABI changed"); + +#define MAX_SKB_LOSSES 4096 + struct metadata_access { __u32 offsets[MAX_METADATA_ACCESS_STEPS]; __u8 dereference_mask; @@ -402,6 +418,19 @@ struct { __type(value, __u64); } probe_site_loss SEC(".maps"); +/* Deliberately a plain hash and not an LRU. A full LRU evicts silently, and a + * silently evicted entry would make a packet look like it lost nothing. A plain + * hash refuses the insert instead, which is counted, so a capture reporting no + * unattributed skb losses proves this table is exhaustive — and that a packet + * absent from it genuinely lost nothing. That proof is the whole point of the + * table; an LRU would destroy it to save a bounded amount of memory. */ +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __uint(max_entries, MAX_SKB_LOSSES); + __type(key, struct skb_loss_key); + __type(value, __u64); +} skb_loss SEC(".maps"); + struct { __uint(type, BPF_MAP_TYPE_STACK_TRACE); __uint(max_entries, 1024); @@ -511,14 +540,46 @@ static __always_inline struct probe_site_key program_probe_site(void) * attribution is added on top. A capture whose probe plan overflowed the map * therefore still reports the right number of holes, with the surplus landing * in unattributed_reserve_failures instead of being silently misfiled. */ +static __always_inline void record_skb_loss( + struct kernel_stats *counters, struct probe_site_key site, __u64 identity) +{ + struct skb_loss_key key; + __u64 *lost; + __u64 first = 1; + + __builtin_memset(&key, 0, sizeof(key)); + key.identity = identity; + key.function_ip = site.function_ip; + key.program_id = site.program_id; + lost = bpf_map_lookup_elem(&skb_loss, &key); + if (lost) { + __sync_fetch_and_add(lost, 1); + return; + } + if (bpf_map_update_elem(&skb_loss, &key, &first, BPF_NOEXIST)) { + /* Either the table is full or another CPU inserted this key since the + * lookup. Looking again separates the two: on the race the entry now + * exists and the count belongs on it, and only a genuinely full table + * leaves nothing to add to. Unlike the per-CPU probe table, this value + * is shared, so a blind BPF_ANY overwrite here would discard the other + * CPU's count rather than join it. */ + lost = bpf_map_lookup_elem(&skb_loss, &key); + if (lost) + __sync_fetch_and_add(lost, 1); + else if (counters) + counters->unattributed_skb_losses++; + } +} + static __always_inline void record_reserve_failure( - struct kernel_stats *counters, struct probe_site_key site) + struct kernel_stats *counters, struct probe_site_key site, __u64 identity) { __u64 *attributed; __u64 first = 1; if (counters) counters->reserve_failures++; + record_skb_loss(counters, site, identity); attributed = bpf_map_lookup_elem(&probe_site_loss, &site); if (attributed) { *attributed += 1; @@ -1661,7 +1722,7 @@ static __always_inline int trace_skb_associated(struct pt_regs *ctx, bpf_map_lookup_elem(&btf_scratch, &key); if (!record) { - record_reserve_failure(counters, kernel_probe_site(ctx)); + record_reserve_failure(counters, kernel_probe_site(ctx), identity); return 0; } __builtin_memset(&record->record, 0, sizeof(record->record)); @@ -1674,7 +1735,7 @@ static __always_inline int trace_skb_associated(struct pt_regs *ctx, } fill_btf_dumps(skb, &record->dumps, counters); if (bpf_ringbuf_output(&events, record, sizeof(*record), 0)) - record_reserve_failure(counters, kernel_probe_site(ctx)); + record_reserve_failure(counters, kernel_probe_site(ctx), identity); return 0; } if (CONFIG.metadata_count) { @@ -1682,7 +1743,7 @@ static __always_inline int trace_skb_associated(struct pt_regs *ctx, bpf_ringbuf_reserve(&events, sizeof(*record), 0); if (!record) { - record_reserve_failure(counters, kernel_probe_site(ctx)); + record_reserve_failure(counters, kernel_probe_site(ctx), identity); return 0; } __builtin_memset(record, 0, sizeof(*record)); @@ -1695,7 +1756,7 @@ static __always_inline int trace_skb_associated(struct pt_regs *ctx, bpf_ringbuf_reserve(&events, sizeof(*event), 0); if (!event) { - record_reserve_failure(counters, kernel_probe_site(ctx)); + record_reserve_failure(counters, kernel_probe_site(ctx), identity); return 0; } fill_trace_event(ctx, skb, association, match, identity, event, @@ -1780,7 +1841,7 @@ int skbx_trace_tc(__u64 *ctx) (struct skbx_program_btf_trace_event *)scratch; if (!record) { - record_reserve_failure(counters, program_probe_site()); + record_reserve_failure(counters, program_probe_site(), identity); return 0; } __builtin_memset(&record->record, 0, sizeof(record->record)); @@ -1793,7 +1854,7 @@ int skbx_trace_tc(__u64 *ctx) } fill_btf_dumps(skb, &record->dumps, counters); if (bpf_ringbuf_output(&events, record, sizeof(*record), 0)) - record_reserve_failure(counters, program_probe_site()); + record_reserve_failure(counters, program_probe_site(), identity); return 0; } if (CONFIG.metadata_count) { @@ -1801,7 +1862,7 @@ int skbx_trace_tc(__u64 *ctx) bpf_ringbuf_reserve(&events, sizeof(*record), 0); if (!record) { - record_reserve_failure(counters, program_probe_site()); + record_reserve_failure(counters, program_probe_site(), identity); return 0; } fill_program_trace_event(ctx, skb, match, identity, @@ -1813,7 +1874,7 @@ int skbx_trace_tc(__u64 *ctx) bpf_ringbuf_reserve(&events, sizeof(*record), 0); if (!record) { - record_reserve_failure(counters, program_probe_site()); + record_reserve_failure(counters, program_probe_site(), identity); return 0; } fill_program_trace_event(ctx, skb, match, identity, record, @@ -1870,7 +1931,7 @@ int skbx_trace_xdp(__u64 *ctx) bpf_ringbuf_reserve(&events, sizeof(*record), 0); if (!record) { - record_reserve_failure(counters, program_probe_site()); + record_reserve_failure(counters, program_probe_site(), identity); return 0; } fill_xdp_program_trace_event(ctx, xdp, match, identity, @@ -1883,7 +1944,7 @@ int skbx_trace_xdp(__u64 *ctx) bpf_ringbuf_reserve(&events, sizeof(*record), 0); if (!record) { - record_reserve_failure(counters, program_probe_site()); + record_reserve_failure(counters, program_probe_site(), identity); return 0; } fill_xdp_program_trace_event(ctx, xdp, match, identity, @@ -1919,7 +1980,7 @@ int skbx_trace_xdp_exit(__u64 *ctx) bpf_ringbuf_reserve(&events, sizeof(*record), 0); if (!record) { - record_reserve_failure(counters, program_probe_site()); + record_reserve_failure(counters, program_probe_site(), identity); return 0; } fill_xdp_program_trace_event(ctx, xdp, match, identity, @@ -1933,7 +1994,7 @@ int skbx_trace_xdp_exit(__u64 *ctx) bpf_ringbuf_reserve(&events, sizeof(*record), 0); if (!record) { - record_reserve_failure(counters, program_probe_site()); + record_reserve_failure(counters, program_probe_site(), identity); return 0; } fill_xdp_program_trace_event(ctx, xdp, match, identity, @@ -2028,7 +2089,7 @@ static __always_inline int trace_map_associated(struct pt_regs *ctx, bpf_map_lookup_elem(&btf_scratch, &key); if (!record) { - record_reserve_failure(counters, kernel_probe_site(ctx)); + record_reserve_failure(counters, kernel_probe_site(ctx), identity); return 0; } __builtin_memset(&record->record, 0, sizeof(record->record)); @@ -2045,7 +2106,7 @@ static __always_inline int trace_map_associated(struct pt_regs *ctx, fill_btf_dumps((struct sk_buff *)*skb_addr, &record->dumps, counters); if (bpf_ringbuf_output(&events, record, sizeof(*record), 0)) - record_reserve_failure(counters, kernel_probe_site(ctx)); + record_reserve_failure(counters, kernel_probe_site(ctx), identity); return 0; } if (CONFIG.metadata_count) { @@ -2053,7 +2114,7 @@ static __always_inline int trace_map_associated(struct pt_regs *ctx, bpf_ringbuf_reserve(&events, sizeof(*record), 0); if (!record) { - record_reserve_failure(counters, kernel_probe_site(ctx)); + record_reserve_failure(counters, kernel_probe_site(ctx), identity); return 0; } __builtin_memset(record, 0, sizeof(*record)); @@ -2067,7 +2128,7 @@ static __always_inline int trace_map_associated(struct pt_regs *ctx, bpf_ringbuf_reserve(&events, sizeof(*record), 0); if (!record) { - record_reserve_failure(counters, kernel_probe_site(ctx)); + record_reserve_failure(counters, kernel_probe_site(ctx), identity); return 0; } __builtin_memset(record, 0, sizeof(*record)); diff --git a/crates/skbx-sensor/src/lib.rs b/crates/skbx-sensor/src/lib.rs index a7c7228..1b3341c 100644 --- a/crates/skbx-sensor/src/lib.rs +++ b/crates/skbx-sensor/src/lib.rs @@ -14,7 +14,7 @@ pub use raw::{ READ_NETNS_FAILED, READ_PROTOCOL_FAILED, READ_TUNNEL_TUPLE_FAILED, READ_TUPLE_FAILED, RawBpfProgram, RawBtfDumps, RawBtfTraceEvent, RawMapMetadataTraceEvent, RawMapTraceEvent, RawMetadata, RawMetadataTraceEvent, RawObservation, RawPacketTuple, RawProgramBtfTraceEvent, - RawProgramMetadataTraceEvent, RawProgramTraceEvent, RawTraceEvent, + RawProgramMetadataTraceEvent, RawProgramTraceEvent, RawTraceEvent, SkbLoss, SkbLossKey, }; #[cfg(feature = "ebpf")] diff --git a/crates/skbx-sensor/src/live.rs b/crates/skbx-sensor/src/live.rs index 8b94c6a..608bcff 100644 --- a/crates/skbx-sensor/src/live.rs +++ b/crates/skbx-sensor/src/live.rs @@ -1,4 +1,6 @@ -use crate::{KernelStats, KernelStatsByCpu, ProbeSiteKey, ProbeSiteLoss, RawObservation}; +use crate::{ + KernelStats, KernelStatsByCpu, ProbeSiteKey, ProbeSiteLoss, RawObservation, SkbLoss, SkbLossKey, +}; use libbpf_rs::btf::{Btf, BtfType, TypeId}; use libbpf_rs::query::{ProgInfoIter, ProgInfoQueryOptions, ProgramInfo}; use libbpf_rs::{ @@ -162,6 +164,7 @@ pub struct LiveSensor { ring_state: Box, telemetry: MapHandle, probe_site_loss: MapHandle, + skb_loss: MapHandle, stack_traces: MapHandle, _links: Vec, _object: Object, @@ -550,6 +553,7 @@ impl LiveSensor { let events = map_handle(&object, "events")?; let telemetry = map_handle(&object, "telemetry")?; let probe_site_loss = map_handle(&object, "probe_site_loss")?; + let skb_loss = map_handle(&object, "skb_loss")?; let stack_traces = map_handle(&object, "stack_traces")?; let mut ring_state = Box::::default(); let ring = create_ring(&events, ring_state.as_mut())?; @@ -559,6 +563,7 @@ impl LiveSensor { ring_state, telemetry, probe_site_loss, + skb_loss, stack_traces, _links: links, _object: object, @@ -665,6 +670,53 @@ impl LiveSensor { Ok(losses) } + /// Which observation of which packet was lost. + /// + /// Ordered by packet, then by the probe that lost it, so the footer reads + /// as a per-packet ledger and stays reproducible across runs. + /// + /// Keyed rather than pushed, for the same reason as `probe_loss`: + /// `bpf_map_get_next_key()` iteration can revisit a key when entries are + /// inserted while it runs. Here a duplicate would be worse than a + /// miscount — the same packet would appear twice in the ledger a reader + /// consults to decide whether that packet lost anything. + pub fn skb_loss(&self) -> Result, LiveError> { + let mut losses = BTreeMap::new(); + for key in self.skb_loss.keys() { + let Some(decoded) = SkbLossKey::from_bytes(&key) else { + return Err(LiveError::Map(format!( + "skb loss key has unexpected size {}", + key.len() + ))); + }; + let Some(value) = self + .skb_loss + .lookup(&key, MapFlags::ANY) + .map_err(|error| LiveError::Map(error.to_string()))? + else { + continue; + }; + let bytes: [u8; 8] = value.as_slice().try_into().map_err(|_| { + LiveError::Map(format!( + "skb loss value has unexpected size {}", + value.len() + )) + })?; + let reserve_failures = u64::from_ne_bytes(bytes); + if reserve_failures != 0 { + losses.insert(decoded, reserve_failures); + } + } + // BTreeMap iteration is already the key order this ledger wants. + Ok(losses + .into_iter() + .map(|(key, reserve_failures)| SkbLoss { + key, + reserve_failures, + }) + .collect()) + } + pub fn decode_failures(&self) -> u64 { self.ring_state.decode_failures } @@ -1031,6 +1083,7 @@ fn kernel_stats_from_bytes(bytes: &[u8]) -> Option { read_failures: u64::from_ne_bytes(bytes[8..16].try_into().ok()?), filtered_events: u64::from_ne_bytes(bytes[16..24].try_into().ok()?), unattributed_reserve_failures: u64::from_ne_bytes(bytes[24..32].try_into().ok()?), + unattributed_skb_losses: u64::from_ne_bytes(bytes[32..40].try_into().ok()?), }) } diff --git a/crates/skbx-sensor/src/raw.rs b/crates/skbx-sensor/src/raw.rs index 4433b38..771bfbc 100644 --- a/crates/skbx-sensor/src/raw.rs +++ b/crates/skbx-sensor/src/raw.rs @@ -1,4 +1,4 @@ -use skbx_contract::{KernelCpuLoss, KernelProbeLoss, Reliability}; +use skbx_contract::{KernelCpuLoss, KernelProbeLoss, KernelSkbLoss, Reliability}; pub const READ_LEN_FAILED: u16 = 1 << 0; pub const READ_PROTOCOL_FAILED: u16 = 1 << 1; @@ -489,6 +489,10 @@ pub struct KernelStats { /// probe-site map was full. Counted here rather than dropped so the /// per-probe breakdown never has to be read as exhaustive. pub unattributed_reserve_failures: u64, + /// Losses the kernel could not file against a packet because the per-skb + /// table was full. While this is zero the per-skb table is exhaustive, and + /// a packet missing from it provably lost nothing. + pub unattributed_skb_losses: u64, } /// Identifies the probe that failed to emit, mirroring `struct probe_site_key` @@ -529,6 +533,40 @@ pub struct ProbeSiteLoss { pub reserve_failures: u64, } +/// Mirrors `struct skb_loss_key` in the BPF object: which observation of which +/// packet was lost. `identity` is the same value stamped into emitted records, +/// so it joins directly against the chain a replay reconstructs. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub struct SkbLossKey { + pub identity: u64, + pub site: ProbeSiteKey, +} + +impl SkbLossKey { + pub const BYTE_LEN: usize = 24; + + pub fn from_bytes(bytes: &[u8]) -> Option { + if bytes.len() != Self::BYTE_LEN { + return None; + } + Some(Self { + identity: u64::from_ne_bytes(bytes[0..8].try_into().ok()?), + site: ProbeSiteKey { + function_ip: u64::from_ne_bytes(bytes[8..16].try_into().ok()?), + program_id: u32::from_ne_bytes(bytes[16..20].try_into().ok()?), + _pad: 0, + }, + }) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct SkbLoss { + pub key: SkbLossKey, + pub reserve_failures: u64, +} + /// Kernel counters as the per-CPU array actually holds them, indexed by CPU id. /// /// The counters live in a `BPF_MAP_TYPE_PERCPU_ARRAY`, so the breakdown is free @@ -561,6 +599,9 @@ impl KernelStatsByCpu { total.unattributed_reserve_failures = total .unattributed_reserve_failures .saturating_add(stats.unattributed_reserve_failures); + total.unattributed_skb_losses = total + .unattributed_skb_losses + .saturating_add(stats.unattributed_skb_losses); total }) } @@ -580,16 +621,17 @@ impl KernelStatsByCpu { .collect() } - /// `loss_by_probe` is a parameter rather than a field the caller patches in - /// afterwards: leaving it to the caller means a future third call site can - /// drop the attribution silently, and a dropped breakdown reads exactly - /// like a capture that lost nothing per probe. + /// The breakdowns are parameters rather than fields the caller patches in + /// afterwards: leaving them to the caller means a future call site can drop + /// attribution silently, and a dropped breakdown reads exactly like a + /// capture that lost nothing. pub fn into_reliability( self, recursion_misses: u64, decode_failures: u64, enrichment_failures: u64, loss_by_probe: Vec, + loss_by_skb: Vec, ) -> Reliability { let total = self.total(); Reliability { @@ -602,9 +644,11 @@ impl KernelStatsByCpu { output_failures: 0, kernel_loss_by_cpu: self.loss_by_cpu(), kernel_unattributed_reserve_failures: total.unattributed_reserve_failures, + kernel_skb_loss_unattributed: total.unattributed_skb_losses, // Resolved by the caller, which owns the symbol table needed to turn // a probe site address into a function name. kernel_loss_by_probe: loss_by_probe, + kernel_loss_by_skb: loss_by_skb, } } } @@ -780,6 +824,7 @@ mod tests { read_failures, filtered_events, unattributed_reserve_failures: 0, + unattributed_skb_losses: 0, }, ) .collect(), @@ -789,7 +834,7 @@ mod tests { #[test] fn reliability_totals_still_match_the_per_cpu_breakdown() { let stats = stats_by_cpu(&[(2, 0, 10), (0, 0, 40), (3, 1, 0)]); - let reliability = stats.into_reliability(0, 0, 0, Vec::new()); + let reliability = stats.into_reliability(0, 0, 0, Vec::new(), Vec::new()); assert_eq!(reliability.kernel_reserve_failures, 5); assert_eq!(reliability.kernel_read_failures, 1); @@ -826,7 +871,7 @@ mod tests { assert!(stats.loss_by_cpu().is_empty()); assert_eq!( stats - .into_reliability(0, 0, 0, Vec::new()) + .into_reliability(0, 0, 0, Vec::new(), Vec::new()) .kernel_filtered_events, 912 ); @@ -847,7 +892,7 @@ mod tests { }, ]); - let reliability = stats.into_reliability(0, 0, 0, Vec::new()); + let reliability = stats.into_reliability(0, 0, 0, Vec::new(), Vec::new()); assert_eq!(reliability.kernel_reserve_failures, 9); assert_eq!(reliability.kernel_unattributed_reserve_failures, 6); @@ -887,7 +932,8 @@ mod tests { reserve_failures: 3, }]; - let reliability = stats_by_cpu(&[(3, 0, 0)]).into_reliability(0, 0, 0, probes.clone()); + let reliability = + stats_by_cpu(&[(3, 0, 0)]).into_reliability(0, 0, 0, probes.clone(), Vec::new()); assert_eq!(reliability.kernel_loss_by_probe, probes); } @@ -895,7 +941,7 @@ mod tests { #[test] fn a_lossless_capture_reports_an_empty_breakdown_not_a_missing_one() { let reliability = - stats_by_cpu(&[(0, 0, 0), (0, 0, 0)]).into_reliability(0, 0, 0, Vec::new()); + stats_by_cpu(&[(0, 0, 0), (0, 0, 0)]).into_reliability(0, 0, 0, Vec::new(), Vec::new()); assert!(reliability.complete()); assert!(reliability.kernel_loss_by_cpu.is_empty()); diff --git a/docs/architecture.md b/docs/architecture.md index 90f51bb..5f3b03d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,10 +54,32 @@ The per-CPU breakdown has no such gap — it comes from the same map read as the totals, so it sums to `kernel_reserve_failures` exactly, in whole captures and in segments alike. -A hole that is attributed to a probe still says only that this probe failed to -emit at least once. It does not identify which packet lost that observation, so -`complete: false` continues to downgrade every absence in the capture — the -attribution narrows where to look, it does not restore the absence as evidence. +`kernel_loss_by_skb` files each hole against the packet it belonged to, keyed +by the same identity stamped on every event, so it joins directly against a +replayed chain. That table is a plain hash rather than an LRU: a full LRU +evicts silently, and a silently evicted entry would make a packet look like it +lost nothing. A plain hash refuses the insert and counts it in +`kernel_skb_loss_unattributed` instead. + +That refusal is what makes the table's negative claim provable. While +`kernel_skb_loss_unattributed` is zero the table is exhaustive, so a packet +absent from it lost nothing, and a function missing from that packet's chain +was never reached rather than merely unobserved. This is the one condition +under which absence is evidence, and it is reported per packet by `explain` as +`complete`, `lost`, or `unknown`. + +Reserve failures are the only loss kind that can be filed against a packet. A +recursion miss never reaches the emit path, and a decode, enrichment or output +failure discards a record after the kernel has handed it over, by which point +nothing knows which packet it described. Any of those leaves a hole no packet +can be cleared of, so all of them must be zero before any absence in the +capture may be read as evidence. Read failures are excluded: they degrade +fields on an event that was still emitted and are already visible on it. + +A packet can appear in the ledger without appearing in the capture — that is +exactly a packet whose every observation was dropped, previously invisible. +Replay counts these as `skbs_lost_entirely`, separate from `distinct_skbs`, +which counts only packets actually observed. ## Pipeline