use std::collections::VecDeque; use std::net::IpAddr; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use arc_swap::ArcSwap; use parking_lot::Mutex; use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use super::exchange::HttpTraceExchange; use super::types::{ TraceCarrierDetail, TraceIdentity, TraceLifecycleEvent, TraceLifecycleRecord, TraceRecord, TraceRecordKind, }; use crate::config::{WebDebugConfig, WebLimitsConfig}; // WebSocket message capture is isolated from HTTP exchange storage. mod websocket; const BASE_RECORD_RESERVATION: usize = 1024; struct RingState { records: VecDeque>, } /// One retained record whose lease survives status-page snapshots. pub(crate) struct StoredTraceRecord { /// Immutable trace record. pub(crate) record: TraceRecord, bytes: usize, used_bytes: Arc, } impl Drop for StoredTraceRecord { fn drop(&mut self) { self.used_bytes.fetch_sub(self.bytes, Ordering::AcqRel); } } /// Point-in-time process-owned trace counters and ring bounds. pub(crate) struct TraceStoreStatus { /// Current debug policy. pub(crate) policy: Arc, /// Retained record count. pub(crate) records: usize, /// Configured record capacity. pub(crate) records_capacity: usize, /// Retained plus in-flight byte leases. pub(crate) used_bytes: usize, /// Configured byte capacity. pub(crate) bytes_capacity: usize, /// Records dropped on commit-lock contention. pub(crate) contention_drops: u64, /// Oldest records evicted by ring capacity. pub(crate) evictions: u64, /// Body or metadata captures truncated by policy or byte capacity. pub(crate) byte_truncations: u64, /// Earliest retained record sequence. pub(crate) earliest_seq: Option, /// Latest retained record sequence. pub(crate) latest_seq: Option, } /// Process-owned bounded WEB debug trace store. pub(crate) struct WebTraceStore { policy: ArcSwap, policy_update: Mutex<()>, enabled: AtomicBool, epoch: AtomicU64, records_capacity: usize, bytes_capacity: usize, max_carrier_body_bytes: usize, frame_limits: WebLimitsConfig, used_bytes: Arc, ring: Mutex, next_record_seq: AtomicU64, next_session_id: AtomicU64, contention_drops: AtomicU64, evictions: AtomicU64, byte_truncations: AtomicU64, renders: Arc, } impl WebTraceStore { /// Creates a process-owned store from restart-only capacity and initial policy. pub(crate) fn new(policy: WebDebugConfig, limits: &WebLimitsConfig) -> Arc { Arc::new(Self { enabled: AtomicBool::new(policy.enabled), policy: ArcSwap::from_pointee(policy), policy_update: Mutex::new(()), epoch: AtomicU64::new(1), records_capacity: limits.debug_records_capacity, bytes_capacity: limits.debug_bytes_global, max_carrier_body_bytes: limits.max_body_bytes.max(limits.carrier_batch_bytes), frame_limits: limits.clone(), used_bytes: Arc::new(AtomicUsize::new(0)), ring: Mutex::new(RingState { records: VecDeque::with_capacity(limits.debug_records_capacity), }), next_record_seq: AtomicU64::new(1), next_session_id: AtomicU64::new(1), contention_drops: AtomicU64::new(0), evictions: AtomicU64::new(0), byte_truncations: AtomicU64::new(0), renders: Arc::new(Semaphore::new(2)), }) } /// Applies one hot policy and clears incompatible retained records. pub(crate) fn apply_policy(&self, policy: &WebDebugConfig) { let _policy_update = self.policy_update.lock(); let current = self.policy.load_full(); if current.as_ref() == policy { return; } let capture_changed = current.enabled != policy.enabled || current.capture_lifecycle != policy.capture_lifecycle || current.capture_headers != policy.capture_headers || current.capture_timings != policy.capture_timings || current.capture_frames != policy.capture_frames || current.body_capture != policy.body_capture || current.body_prefix_bytes != policy.body_prefix_bytes || current.decoy_body_prefix_bytes != policy.decoy_body_prefix_bytes; self.policy.store(Arc::new(policy.clone())); self.enabled.store(policy.enabled, Ordering::Release); if capture_changed { self.epoch.fetch_add(1, Ordering::AcqRel); self.ring.lock().records.clear(); } } /// Allocates a process-unique monotonic WEB session trace identifier. pub(crate) fn next_session_id(&self) -> u64 { self.next_session_id.fetch_add(1, Ordering::Relaxed) } /// Starts one HTTP exchange only when debugging is enabled and budgeted. pub(crate) fn begin_http( self: &Arc, request: &hyper::Request, peer_ip: IpAddr, ) -> Option> { if !self.enabled.load(Ordering::Acquire) { return None; } let epoch = self.epoch.load(Ordering::Acquire); let policy = self.policy.load_full(); if !policy.enabled || !self.try_reserve_record(BASE_RECORD_RESERVATION) { return None; } Some(HttpTraceExchange::new( Arc::clone(self), epoch, policy, request, peer_ip, BASE_RECORD_RESERVATION, )) } /// Records one typed lifecycle event without retaining dynamic error strings. pub(crate) fn record_lifecycle( &self, peer_ip: Option, effective_ip: Option, identity: TraceIdentity, event: TraceLifecycleEvent, stream_id: Option, reason: Option<&'static str>, ) { self.record_lifecycle_detail( peer_ip, effective_ip, identity, event, stream_id, reason, None, ); } /// Records one carrier transition with structured, non-secret negotiation fields. #[allow(clippy::too_many_arguments)] pub(crate) fn record_carrier_lifecycle( &self, effective_ip: IpAddr, identity: TraceIdentity, event: TraceLifecycleEvent, client_class: &'static str, carrier: crate::config::WebCarrier, attempt: u8, scores: [i16; 4], reason: Option<&'static str>, ) { self.record_lifecycle_detail( None, Some(effective_ip), identity, event, None, reason, Some(TraceCarrierDetail { client_class, carrier, attempt, scores, }), ); } #[allow(clippy::too_many_arguments)] fn record_lifecycle_detail( &self, peer_ip: Option, effective_ip: Option, identity: TraceIdentity, event: TraceLifecycleEvent, stream_id: Option, reason: Option<&'static str>, carrier: Option, ) { if !self.enabled.load(Ordering::Acquire) { return; } let epoch = self.epoch.load(Ordering::Acquire); let policy = self.policy.load_full(); let identity_bytes = identity .user .as_ref() .map_or(0, String::len) .checked_add(identity.key_fingerprint.as_ref().map_or(0, String::len)); let Some(reservation) = identity_bytes.and_then(|bytes| BASE_RECORD_RESERVATION.checked_add(bytes)) else { self.record_truncation(); return; }; if !policy.enabled || !policy.capture_lifecycle || !self.try_reserve_record(reservation) { return; } let record = TraceRecord { seq: self.next_record_seq(), epoch_millis: epoch_millis(), peer_ip, effective_ip, user_agent: None, identity, kind: TraceRecordKind::Lifecycle(TraceLifecycleRecord { event, stream_id, reason, carrier, }), }; if !self.try_commit(record, reservation, epoch) { self.release(reservation); } } /// Records lifecycle identity from a profile only after enabled-policy checks. pub(crate) fn record_profile_lifecycle( &self, effective_ip: IpAddr, session_id: Option, profile: &crate::config::WebRuntimeProfile, event: TraceLifecycleEvent, stream_id: Option, reason: Option<&'static str>, ) { if !self.enabled.load(Ordering::Acquire) || !self.policy.load().capture_lifecycle { return; } self.record_lifecycle( None, Some(effective_ip), TraceIdentity::from_optional_profile(session_id, profile), event, stream_id, reason, ); } /// Returns records matching the supplied predicate in newest-first order. pub(crate) fn snapshot_matching(&self, mut matches: F) -> Vec> where F: FnMut(&TraceRecord) -> bool, { self.ring .lock() .records .iter() .rev() .filter(|record| matches(&record.record)) .cloned() .collect() } /// Returns current bounds, counters, and retained sequence range. pub(crate) fn status(&self) -> TraceStoreStatus { let ring = self.ring.lock(); TraceStoreStatus { policy: self.policy.load_full(), records: ring.records.len(), records_capacity: self.records_capacity, used_bytes: self.used_bytes.load(Ordering::Acquire), bytes_capacity: self.bytes_capacity, contention_drops: self.contention_drops.load(Ordering::Relaxed), evictions: self.evictions.load(Ordering::Relaxed), byte_truncations: self.byte_truncations.load(Ordering::Relaxed), earliest_seq: ring.records.front().map(|record| record.record.seq), latest_seq: ring.records.back().map(|record| record.record.seq), } } /// Reserves one of two bounded concurrent status-page render slots. pub(crate) fn try_render_permit(&self) -> Option { Arc::clone(&self.renders).try_acquire_owned().ok() } /// Returns the current capture-policy epoch. pub(super) fn policy_epoch(&self) -> u64 { self.epoch.load(Ordering::Acquire) } /// Returns the restart-frozen recognized carrier body ceiling. pub(super) fn max_carrier_body_bytes(&self) -> usize { self.max_carrier_body_bytes } /// Atomically reserves debug bytes or records one truncation. pub(super) fn try_reserve(&self, bytes: usize) -> bool { if self.try_reserve_inner(bytes) { true } else { self.byte_truncations.fetch_add(1, Ordering::Relaxed); false } } fn try_reserve_inner(&self, bytes: usize) -> bool { let mut current = self.used_bytes.load(Ordering::Acquire); loop { let Some(next) = current.checked_add(bytes) else { return false; }; if next > self.bytes_capacity { return false; } match self.used_bytes.compare_exchange_weak( current, next, Ordering::AcqRel, Ordering::Acquire, ) { Ok(_) => return true, Err(actual) => current = actual, } } } fn try_reserve_record(&self, bytes: usize) -> bool { if self.try_reserve_inner(bytes) { return true; } let Some(mut ring) = self.ring.try_lock() else { self.byte_truncations.fetch_add(1, Ordering::Relaxed); return false; }; while let Some(record) = ring.records.pop_front() { self.evictions.fetch_add(1, Ordering::Relaxed); drop(record); if self.try_reserve_inner(bytes) { return true; } } self.byte_truncations.fetch_add(1, Ordering::Relaxed); false } /// Releases an in-flight lease that was not transferred into a record. pub(super) fn release(&self, bytes: usize) { self.used_bytes.fetch_sub(bytes, Ordering::AcqRel); } /// Increments the closed truncation counter. pub(super) fn record_truncation(&self) { self.byte_truncations.fetch_add(1, Ordering::Relaxed); } /// Allocates a process-monotonic record sequence number. pub(super) fn next_record_seq(&self) -> u64 { self.next_record_seq.fetch_add(1, Ordering::Relaxed) } /// Attempts one non-blocking ring commit under the originating policy epoch. pub(super) fn try_commit(&self, record: TraceRecord, bytes: usize, epoch: u64) -> bool { if epoch != self.policy_epoch() { return false; } let Some(mut ring) = self.ring.try_lock() else { self.contention_drops.fetch_add(1, Ordering::Relaxed); return false; }; if epoch != self.policy_epoch() { return false; } while ring.records.len() >= self.records_capacity { ring.records.pop_front(); self.evictions.fetch_add(1, Ordering::Relaxed); } ring.records.push_back(Arc::new(StoredTraceRecord { record, bytes, used_bytes: Arc::clone(&self.used_bytes), })); true } } /// Returns Unix epoch milliseconds with saturation for stored display timestamps. pub(crate) fn epoch_millis() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() .min(u128::from(u64::MAX)) as u64 } #[cfg(test)] mod tests { use super::*; fn store(records_capacity: usize, bytes_capacity: usize) -> Arc { let policy = WebDebugConfig { enabled: true, ..Default::default() }; let limits = WebLimitsConfig { debug_records_capacity: records_capacity, debug_bytes_global: bytes_capacity, ..Default::default() }; WebTraceStore::new(policy, &limits) } #[test] fn ring_evicts_oldest_records_and_snapshot_leases_survive_clear() { let store = store(2, 4 * BASE_RECORD_RESERVATION); for _ in 0..3 { store.record_lifecycle( None, Some("192.0.2.10".parse().unwrap()), TraceIdentity::default(), TraceLifecycleEvent::BridgeIssued, None, None, ); } let snapshot = store.snapshot_matching(|_| true); assert_eq!( snapshot .iter() .map(|record| record.record.seq) .collect::>(), vec![3, 2] ); assert_eq!(store.status().evictions, 1); assert_eq!(store.status().used_bytes, 2 * BASE_RECORD_RESERVATION); let policy = WebDebugConfig::default(); store.apply_policy(&policy); assert_eq!(store.status().records, 0); assert_eq!(store.status().used_bytes, 2 * BASE_RECORD_RESERVATION); drop(snapshot); assert_eq!(store.status().used_bytes, 0); } #[test] fn capture_policy_epoch_rejects_an_inflight_old_policy_record() { let store = store(4, 8 * BASE_RECORD_RESERVATION); let request = hyper::Request::builder().uri("/").body(()).unwrap(); let exchange = store .begin_http(&request, "192.0.2.20".parse().unwrap()) .unwrap(); let changed = WebDebugConfig { enabled: true, capture_headers: false, ..Default::default() }; store.apply_policy(&changed); exchange.commit(); assert_eq!(store.status().records, 0); assert_eq!(store.status().used_bytes, 0); } }