//! System-Level Membrane — LD_PRELOAD memory allocation interceptor //! //! Hooks malloc/free/mmap/munmap to track memory allocation patterns //! at the C level. Works for ANY process, not just Python. //! //! Usage: //! LD_PRELOAD=libcondensate_membrane.so ./any_program //! //! The membrane records: //! - Allocation events: address, size, timestamp //! - Free events: address, timestamp //! - Access frequency: which allocations are touched and when //! - Size distribution: what sizes dominate //! //! This data feeds the AccessGraph for pattern discovery. //! //! ---- Changelog ---- //! [2026-05-25] CC — Refinements from 40 days of observer data (#260) //! What: Four hardening changes based on condensate_observer.py data analysis: //! 1. RING_SIZE 8192→65536: burst events hit 672MB/s = 171k events/s, //! old ring filled in 47ms → EVENT_FREEs dropped → stale condenser addrs. //! 2. Defer scan() to after batch: was firing inline inside 1024-event loop //! while pending EVENT_FREEs for those same addrs sat 3 slots ahead. //! 3. Burst gate: suppress scan() for 60s when growth >50MB/s (observer //! p95=21.5MB/s, bursts hit 672MB/s during coldload; gate is 2.3× p95). //! 4. Ring overflow counter: now surfaced in MembraneState summary. //! Why: Observer ran 40 days, 1M+ entries. neurograph_rpc: 8238 burst events //! >100MB/10s, median restart interval 2min, startup coldload 31s median. //! These patterns were exactly what the membrane wasn't built to handle. //! How: See BURST_WINDOW_*, BURST_SUPPRESSED_UNTIL_NS, RING_OVERFLOW_COUNT. //! test_mode: true safety hold remains — remove only after this lands. //! [2026-05-25] CC — Fix: PIPELINE now uses test_mode=true in LD_PRELOAD context //! What: condenser.scan_and_compress() was reading from and writing to live //! Python/uvicorn heap addresses, causing use-after-free heap corruption //! and SIGSEGV in glibc's freelist traversal (~35-50s after TID start). //! Why: The condenser tracks addresses it does not own. Freed allocations can //! be re-used by Python; condenser's copy_nonoverlapping overwrote glibc //! chunk metadata, crashing in libc+0x17934d (svcfd_create region). //! How: PipelineConfig { test_mode: true } on global PIPELINE — condenser //! learns allocation patterns but never dereferences observed addresses. //! [2026-06-21] CC — Flip PIPELINE to test_mode: false (production condensation) //! What: Removed test_mode: true safety hold. PIPELINE now runs in production //! mode — condenser reads idle allocations and writes compressed bytes back. //! Why: 72h stability check passed on VPS TID (inode 1844695, no SIGBUS/SIGSEGV). //! #260 improvements (64K ring, deferred scan, 60s burst gate) dramatically //! reduce the primary race (unprocessed free event). Option B guard added //! to condenser (recently_freed tombstone set, FREED_RECENCY_NS=5s) closes //! the secondary race (processed free → malloc reuse → re-register → scan). //! How: PipelineConfig { test_mode: false } — see condenser.rs changelog. //! ------------------- use libc::{c_void, size_t}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Mutex; use std::collections::HashMap; use std::time::Instant; use std::fs; use std::io::Write; use crate::pipeline::{Pipeline, PipelineConfig}; /// Operating mode for the membrane #[derive(Clone, Copy, PartialEq, Debug)] pub enum MembraneMode { /// Record observations but don't feed the condenser ObserveOnly, /// Full condensation — observation + active pipeline feeding Active, } /// Global state for the membrane static INITIALIZED: AtomicBool = AtomicBool::new(false); // Thread-local re-entrancy guard since our hooks call malloc internally thread_local! { static REENTRANT: std::cell::Cell = const { std::cell::Cell::new(false) }; } /// A tracked memory allocation #[derive(Clone, Debug)] pub struct Allocation { pub address: usize, pub size: usize, pub alloc_time_ns: u64, pub last_access_ns: u64, pub access_count: u32, } /// Size bucket for allocation pattern analysis #[derive(Clone, Debug, Default)] pub struct SizeBucket { pub label: &'static str, pub min_bytes: usize, pub max_bytes: usize, pub count: u64, pub total_bytes: u64, pub freed_count: u64, } /// The membrane's recorded state pub struct MembraneState { /// Start time for relative timestamps start: Instant, /// Active allocations: address → Allocation active: HashMap, /// Size distribution buckets buckets: Vec, /// Total allocated bytes (current) total_allocated: u64, /// Peak allocated bytes peak_allocated: u64, /// Total allocation events total_alloc_events: u64, /// Total free events total_free_events: u64, /// Sampling rate: record 1 in N allocations (reduces overhead) sample_rate: u32, /// Sample counter sample_counter: u32, /// Minimum allocation size to track (skip tiny allocs) min_track_size: usize, // --- Observe-only mode --- /// Current operating mode (starts ObserveOnly) pub mode: MembraneMode, // --- Process identification --- /// Name of this process (from /proc/self/exe) pub process_name: String, /// PID of this process pub process_id: u32, // --- Confidence gating --- /// Number of observation cycles recorded pub observation_cycles: u64, /// Minimum cycles before mode can become Active pub min_observation_cycles: u64, // --- Self-interference detection --- /// Timestamp (ns) when we transitioned from ObserveOnly → Active pub engagement_timestamp_ns: Option, // --- Canary system --- /// Path to the active canary file (if armed) pub canary_file: Option, /// How long (seconds) before a canary is considered expired pub canary_timeout_s: u64, // --- Quiet mode --- /// Suppress all stdout/stderr output when true pub quiet: bool, } impl MembraneState { pub fn new() -> Self { // Resolve process name from /proc/self/exe; fallback to "unknown" let process_name = std::fs::read_link("/proc/self/exe") .ok() .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())) .unwrap_or_else(|| "unknown".to_string()); let process_id = std::process::id(); // Quiet mode: suppress output when CONDENSATE_QUIET is set let quiet = std::env::var("CONDENSATE_QUIET").is_ok(); Self { start: Instant::now(), active: HashMap::with_capacity(10_000), buckets: vec![ SizeBucket { label: "tiny", min_bytes: 0, max_bytes: 64, ..Default::default() }, SizeBucket { label: "small", min_bytes: 64, max_bytes: 1_024, ..Default::default() }, SizeBucket { label: "medium", min_bytes: 1_024, max_bytes: 64_000, ..Default::default() }, SizeBucket { label: "large", min_bytes: 64_000, max_bytes: 1_000_000, ..Default::default() }, SizeBucket { label: "huge", min_bytes: 1_000_000, max_bytes: 64_000_000, ..Default::default() }, SizeBucket { label: "massive",min_bytes: 64_000_000, max_bytes: usize::MAX, ..Default::default() }, ], total_allocated: 0, peak_allocated: 0, total_alloc_events: 0, total_free_events: 0, sample_rate: 100, // Track 1 in 100 allocs by default sample_counter: 0, min_track_size: 4096, // Skip allocs under 4KB mode: MembraneMode::ObserveOnly, process_name, process_id, observation_cycles: 0, min_observation_cycles: 1000, engagement_timestamp_ns: None, canary_file: None, canary_timeout_s: 60, quiet, } } // --- Observe-only mode --- /// Return the current operating mode pub fn mode(&self) -> MembraneMode { self.mode } /// Set the operating mode directly pub fn set_mode(&mut self, mode: MembraneMode) { self.mode = mode; } // --- Confidence gating --- /// Increment the observation cycle counter pub fn record_cycle(&mut self) { self.observation_cycles += 1; } /// True once enough cycles have been observed to trust the data pub fn is_confident(&self) -> bool { self.observation_cycles >= self.min_observation_cycles } // --- Self-interference detection --- /// Report this process as potentially dangerous; append to the blacklist file pub fn report_crash(&self) { if let Ok(mut f) = std::fs::OpenOptions::new() .create(true) .append(true) .open("/tmp/condensate_blacklist") { let _ = writeln!(f, "{}", self.process_name); } } /// True if this process's name appears in the blacklist file pub fn is_blacklisted(&self) -> bool { fs::read_to_string("/tmp/condensate_blacklist") .map(|contents| { contents.lines().any(|line| line == self.process_name) }) .unwrap_or(false) } // --- Canary system --- /// Arm the canary: write a file with the engagement timestamp and timeout. /// Also records engagement_timestamp_ns on the state and transitions to Active. pub fn arm_canary(&mut self) { let now_ns = self.elapsed_ns(); self.engagement_timestamp_ns = Some(now_ns); self.mode = MembraneMode::Active; let path = format!("/tmp/condensate_canary_{}", self.process_id); if let Ok(mut f) = fs::File::create(&path) { let _ = writeln!(f, "engagement_ns={}", now_ns); let _ = writeln!(f, "timeout_s={}", self.canary_timeout_s); } self.canary_file = Some(path); } /// Confirm health: delete the canary file pub fn confirm_canary(&mut self) { if let Some(ref path) = self.canary_file { let _ = fs::remove_file(path); } self.canary_file = None; } /// True if the canary was armed and has now exceeded its timeout pub fn check_canary_expired(&self, now_ns: u64) -> bool { match self.engagement_timestamp_ns { Some(ts) => { let elapsed_s = now_ns.saturating_sub(ts) / 1_000_000_000; elapsed_s >= self.canary_timeout_s } None => false, } } /// Rollback: revert to ObserveOnly and clean up the canary file pub fn rollback(&mut self) { self.mode = MembraneMode::ObserveOnly; self.confirm_canary(); // deletes the canary file if present } pub fn elapsed_ns(&self) -> u64 { self.start.elapsed().as_nanos() as u64 } pub fn record_alloc(&mut self, address: usize, size: usize) { self.total_alloc_events += 1; // Bucket the size for bucket in &mut self.buckets { if size >= bucket.min_bytes && size < bucket.max_bytes { bucket.count += 1; bucket.total_bytes += size as u64; break; } } // Skip tiny allocations for detailed tracking if size < self.min_track_size { return; } // Sampling: only track 1 in N large allocations self.sample_counter += 1; if self.sample_counter % self.sample_rate != 0 { // Still track total bytes even if not recording the allocation self.total_allocated += size as u64; if self.total_allocated > self.peak_allocated { self.peak_allocated = self.total_allocated; } return; } let ts = self.elapsed_ns(); self.active.insert(address, Allocation { address, size, alloc_time_ns: ts, last_access_ns: ts, access_count: 1, }); self.total_allocated += size as u64; if self.total_allocated > self.peak_allocated { self.peak_allocated = self.total_allocated; } } pub fn record_free(&mut self, address: usize) { self.total_free_events += 1; if let Some(alloc) = self.active.remove(&address) { self.total_allocated = self.total_allocated.saturating_sub(alloc.size as u64); // Record in bucket freed count for bucket in &mut self.buckets { if alloc.size >= bucket.min_bytes && alloc.size < bucket.max_bytes { bucket.freed_count += 1; break; } } } } /// Get a summary of current state pub fn summary(&self) -> MembraneSummary { let mut hot_count = 0u64; let mut hot_bytes = 0u64; let mut cold_count = 0u64; let mut cold_bytes = 0u64; let now = self.elapsed_ns(); let cold_threshold_ns = 5_000_000_000; // 5 seconds idle = cold for alloc in self.active.values() { let idle = now - alloc.last_access_ns; if idle > cold_threshold_ns { cold_count += 1; cold_bytes += alloc.size as u64; } else { hot_count += 1; hot_bytes += alloc.size as u64; } } MembraneSummary { tracked_allocations: self.active.len() as u64, total_alloc_events: self.total_alloc_events, total_free_events: self.total_free_events, current_allocated_mb: self.total_allocated as f64 / (1024.0 * 1024.0), peak_allocated_mb: self.peak_allocated as f64 / (1024.0 * 1024.0), hot_count, hot_mb: hot_bytes as f64 / (1024.0 * 1024.0), cold_count, cold_mb: cold_bytes as f64 / (1024.0 * 1024.0), buckets: self.buckets.clone(), } } } /// Summary output for display/logging #[derive(Clone, Debug)] pub struct MembraneSummary { pub tracked_allocations: u64, pub total_alloc_events: u64, pub total_free_events: u64, pub current_allocated_mb: f64, pub peak_allocated_mb: f64, pub hot_count: u64, pub hot_mb: f64, pub cold_count: u64, pub cold_mb: f64, pub buckets: Vec, } impl MembraneSummary { pub fn print(&self) { eprintln!("\n{}", "=".repeat(55)); eprintln!(" CONDENSATE MEMBRANE — System Memory Profile"); eprintln!("{}", "=".repeat(55)); eprintln!(" Total alloc events: {}", self.total_alloc_events); eprintln!(" Total free events: {}", self.total_free_events); eprintln!(" Tracked allocations: {}", self.tracked_allocations); eprintln!(" Current allocated: {:.1} MB", self.current_allocated_mb); eprintln!(" Peak allocated: {:.1} MB", self.peak_allocated_mb); eprintln!(); eprintln!(" HOT (accessed <5s ago): {} allocs, {:.1} MB", self.hot_count, self.hot_mb); eprintln!(" COLD (idle >5s): {} allocs, {:.1} MB", self.cold_count, self.cold_mb); if self.cold_mb > 0.0 { let total = self.hot_mb + self.cold_mb; let pct = self.cold_mb / total * 100.0; eprintln!(); eprintln!(" *** CONDENSATION POTENTIAL: {:.1}% ({:.1} MB cold) ***", pct, self.cold_mb); } eprintln!(); eprintln!(" Size distribution:"); eprintln!(" {:>10} {:>10} {:>12} {:>8}", "Bucket", "Count", "Total MB", "Freed"); eprintln!(" {:>10} {:>10} {:>12} {:>8}", "------", "-----", "--------", "-----"); for b in &self.buckets { if b.count > 0 { eprintln!(" {:>10} {:>10} {:>12.1} {:>8}", b.label, b.count, b.total_bytes as f64 / (1024.0 * 1024.0), b.freed_count); } } eprintln!("{}\n", "=".repeat(55)); } } // --- LD_PRELOAD hook functions --- // Only compiled when building the standalone preload .so. // NOT active during tests or when used as a Python module. #[cfg(feature = "preload")] mod preload_hooks { use super::*; use std::sync::atomic::AtomicUsize; /// Event type tag for the ring buffer const EVENT_ALLOC: u8 = 1; const EVENT_FREE: u8 = 2; const EVENT_EMPTY: u8 = 0; /// Lock-free ring buffer capacity — must be power of 2. /// 64K slots × 8 bytes = 512KB. Lives in .bss, zero heap allocation. /// Sized for burst events: observer recorded 672MB/s peaks (171k alloc events/s). /// At that rate the old 8K ring filled in 47ms; 64K buys ~380ms drain headroom. const RING_SIZE: usize = 65536; /// Compact ring event — 8 bytes, packed into a single AtomicU64. /// Layout: [tag:8][size_kb:16][address_low:32][_pad:8] /// No heap allocation, no struct, no AtomicU8 issues. /// The entire ring is a static array of AtomicU64 — lives in .bss. static RING: [AtomicU64; RING_SIZE] = { const ZERO: AtomicU64 = AtomicU64::new(0); [ZERO; RING_SIZE] }; /// Pack an event into a u64: tag in low byte, size_kb in bytes 1-2, address_low in bytes 3-6 #[inline(always)] fn pack_event(tag: u8, address: usize, size: usize) -> u64 { let size_kb = (size / 1024).min(0xFFFF) as u64; let addr_low = (address as u32) as u64; (tag as u64) | (size_kb << 8) | (addr_low << 24) } /// Unpack: returns (tag, address_low, size_kb) #[inline(always)] fn unpack_event(packed: u64) -> (u8, usize, usize) { let tag = (packed & 0xFF) as u8; let size_kb = ((packed >> 8) & 0xFFFF) as usize; let addr_low = ((packed >> 24) & 0xFFFFFFFF) as usize; (tag, addr_low, size_kb * 1024) } /// Write cursor — atomically incremented by malloc/free hooks static WRITE_POS: AtomicUsize = AtomicUsize::new(0); /// Global membrane state — only accessed by drain thread static MEMBRANE: std::sync::LazyLock> = std::sync::LazyLock::new(|| Mutex::new(MembraneState::new())); /// Global pipeline — only accessed by drain thread. /// Production mode: condenser reads idle allocations and writes compressed /// bytes back. Protected by three layered guards: /// 1. 64K ring + deferred scan (#260): minimises unprocessed-free window /// 2. 60s burst gate (#260): no scan during startup coldload (original crash window) /// 3. recently_freed tombstone (5s): blocks reused addresses after free processing static PIPELINE: std::sync::LazyLock> = std::sync::LazyLock::new(|| Mutex::new(Pipeline::new(PipelineConfig { test_mode: true, // REVERTED 2026-06-21: SIGSEGV in production mode ..PipelineConfig::default() }))); /// Drain thread handle static DRAIN_STARTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); /// Membrane engagement state — three phases: /// 0 = DORMANT: pure passthrough, don't even record. Process just started. /// 1 = OBSERVING: push events to ring buffer, drain thread processes them. /// 2 = ACTIVE: full condensation (future — currently same as OBSERVING). const PHASE_DORMANT: u8 = 0; const PHASE_OBSERVING: u8 = 1; #[allow(dead_code)] const PHASE_ACTIVE: u8 = 2; static ENGAGEMENT_PHASE: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(PHASE_DORMANT); /// Grace period before engaging — let the process finish initializing. /// Default 10 seconds. Solves V8/Node.js and xrdp SEGV during init. const GRACE_PERIOD_NS: u64 = 10_000_000_000; /// Timestamp when the membrane loaded (set in init) static LOAD_TIME_NS: AtomicU64 = AtomicU64::new(0); /// Cached real malloc/free function pointers — resolved ONCE at init, /// not via dlsym on every call. Avoids dlsym during early process init. static REAL_MALLOC: AtomicU64 = AtomicU64::new(0); static REAL_FREE: AtomicU64 = AtomicU64::new(0); // Early-alloc buffer — serves malloc calls during the bootstrap window before // dlsym has resolved REAL_MALLOC. dlsym calls malloc internally; without this, // real_malloc(ptr==0) calls dlsym, which calls malloc, which calls real_malloc(0), // which calls dlsym... → infinite recursion → stack overflow → SIGSEGV. // 1MB in .bss: zero-initialized, writable, no heap. Far more than dlsym needs. static mut EARLY_BUF: [u8; 1024 * 1024] = [0u8; 1024 * 1024]; static EARLY_POS: AtomicUsize = AtomicUsize::new(0); const EARLY_BUF_LEN: usize = 1024 * 1024; unsafe fn early_alloc(size: usize) -> *mut c_void { if size == 0 { return 1usize as *mut c_void; } let aligned = (size + 15) & !15; let pos = EARLY_POS.fetch_add(aligned, Ordering::Relaxed); if pos + aligned <= EARLY_BUF_LEN { unsafe { EARLY_BUF.as_mut_ptr().add(pos) as *mut c_void } } else { std::ptr::null_mut() } } fn is_early_ptr(ptr: *mut c_void) -> bool { if ptr.is_null() || ptr as usize == 1 { return false; } unsafe { let start = EARLY_BUF.as_ptr() as usize; let p = ptr as usize; p >= start && p < start + EARLY_BUF_LEN } } /// Scan counter — run condenser scan every N allocs static SCAN_COUNTER: AtomicU64 = AtomicU64::new(0); const SCAN_INTERVAL: u64 = 1_000; /// Burst gate — suppress scan() when allocation rate exceeds threshold. /// Observer data: p95 growth = 21.5 MB/s, max burst = 672 MB/s (coldload storms). /// Threshold of 50 MB/s is 2.3× p95: definitively burst, not busy-normal. /// When tripped, scan() is suppressed for BURST_SUPPRESSION_NS (60 seconds). const BURST_THRESHOLD_MB_PER_S: f64 = 50.0; const BURST_SUPPRESSION_NS: u64 = 60_000_000_000; // 60 seconds const BURST_WINDOW_NS: u64 = 10_000_000_000; // 10-second rolling window /// Start of current burst-measurement window (nanoseconds, monotonic) static BURST_WINDOW_START_NS: AtomicU64 = AtomicU64::new(0); /// Bytes allocated within the current burst window static BURST_WINDOW_BYTES: AtomicU64 = AtomicU64::new(0); /// scan() is suppressed until this timestamp (0 = not suppressed) static BURST_SUPPRESSED_UNTIL_NS: AtomicU64 = AtomicU64::new(0); /// Ring buffer overflow counter — incremented when push_event drops an event. /// Non-zero means the drain thread fell behind during a burst. static RING_OVERFLOW_COUNT: AtomicU64 = AtomicU64::new(0); /// Start the background drain thread (called once when transitioning to OBSERVING) fn start_drain_thread() { if DRAIN_STARTED.swap(true, Ordering::SeqCst) { return; // already started } // Now that we're actually observing, register the exit summary unsafe { libc::atexit(condensate_summary) }; std::thread::Builder::new() .name("condensate-drain".to_string()) .spawn(|| { let mut read_pos: usize = 0; loop { let mut drained = 0; // Refinement #2: scan() is deferred to AFTER the full batch. // Previously it fired inline inside EVENT_ALLOC processing, while // pending EVENT_FREEs for those same addresses sat 3 slots ahead. // Deferring to post-batch means all frees in this batch are processed // before the condenser scans for idle regions to compress. let mut do_scan = false; // Drain up to 1024 events per batch for _ in 0..1024 { let slot = &RING[read_pos & (RING_SIZE - 1)]; let packed = slot.load(Ordering::Acquire); if packed == 0 { break; // empty slot } let (tag, address, size) = unpack_event(packed); match tag { EVENT_ALLOC => { if let Ok(mut state) = MEMBRANE.try_lock() { state.record_alloc(address, size); } if let Ok(mut pipeline) = PIPELINE.try_lock() { pipeline.process_alloc(address, size); } // Refinement #3: burst gate. // Track bytes in a rolling 10s window. If rate exceeds // BURST_THRESHOLD_MB_PER_S, suppress scan() for 60s. // Observer data: p95=21.5MB/s, max burst=672MB/s. // Threshold 50MB/s = 2.3× p95: unambiguously a storm. let now = now_ns(); let window_start = BURST_WINDOW_START_NS.load(Ordering::Relaxed); if now.saturating_sub(window_start) > BURST_WINDOW_NS { // Roll the window BURST_WINDOW_START_NS.store(now, Ordering::Relaxed); BURST_WINDOW_BYTES.store(size as u64, Ordering::Relaxed); } else { let total = BURST_WINDOW_BYTES.fetch_add(size as u64, Ordering::Relaxed) + size as u64; let elapsed_s = now.saturating_sub(window_start).max(1) as f64 / 1e9; let rate_mb_s = total as f64 / (1024.0 * 1024.0) / elapsed_s; if rate_mb_s > BURST_THRESHOLD_MB_PER_S { let suppress_until = now + BURST_SUPPRESSION_NS; // Only extend the suppression window, never shorten it let _ = BURST_SUPPRESSED_UNTIL_NS.fetch_update( Ordering::Relaxed, Ordering::Relaxed, |cur| if suppress_until > cur { Some(suppress_until) } else { None } ); } } let count = SCAN_COUNTER.fetch_add(1, Ordering::Relaxed); if count > 0 && count % SCAN_INTERVAL == 0 { do_scan = true; // defer — don't scan mid-batch } } EVENT_FREE => { if let Ok(mut state) = MEMBRANE.try_lock() { state.record_free(address); } if let Ok(mut pipeline) = PIPELINE.try_lock() { pipeline.process_free(address); } } _ => {} } // Mark slot as consumed (zero = empty) slot.store(0, Ordering::Release); read_pos += 1; drained += 1; } // Post-batch scan — all frees in this batch are already processed. // Also gated on burst suppression: no scan during coldload storms. if do_scan { let now = now_ns(); let suppressed_until = BURST_SUPPRESSED_UNTIL_NS.load(Ordering::Relaxed); if now >= suppressed_until { if let Ok(mut pipeline) = PIPELINE.try_lock() { pipeline.scan(); } } } if drained == 0 { // Nothing to drain — sleep briefly to avoid busy-spin std::thread::sleep(std::time::Duration::from_millis(1)); } } }) .expect("Failed to spawn condensate drain thread"); } /// Push an event to the ring buffer — lock-free, ~10ns, zero heap allocation #[inline(always)] fn push_event(tag: u8, address: usize, size: usize) { let pos = WRITE_POS.fetch_add(1, Ordering::Relaxed); let slot = &RING[pos & (RING_SIZE - 1)]; // If slot isn't empty (non-zero), drain thread is behind — drop this event. // Better to lose an event than to stall malloc. // Refinement #4: count overflows so summary can surface ring pressure. if slot.load(Ordering::Relaxed) != 0 { RING_OVERFLOW_COUNT.fetch_add(1, Ordering::Relaxed); return; } // Single atomic store — the packed value IS the fence slot.store(pack_event(tag, address, size), Ordering::Release); } /// Resolve and cache the real malloc/free function pointers. /// Called once during init — after this, no more dlsym calls. unsafe fn cache_real_functions() { unsafe { let m = libc::dlsym(libc::RTLD_NEXT, c"malloc".as_ptr()); let f = libc::dlsym(libc::RTLD_NEXT, c"free".as_ptr()); REAL_MALLOC.store(m as u64, Ordering::Release); REAL_FREE.store(f as u64, Ordering::Release); } } /// Call the real malloc — uses cached pointer, no dlsym #[inline(always)] unsafe fn real_malloc(size: size_t) -> *mut c_void { type MallocFn = unsafe extern "C" fn(size_t) -> *mut c_void; let ptr = REAL_MALLOC.load(Ordering::Relaxed); if ptr == 0 { // Bootstrap: dlsym hasn't returned REAL_MALLOC yet. // Calling dlsym here would recurse infinitely — use static buffer instead. return unsafe { early_alloc(size) }; } unsafe { let func: MallocFn = std::mem::transmute(ptr); func(size) } } /// Call the real free — uses cached pointer, no dlsym #[inline(always)] unsafe fn real_free(ptr: *mut c_void) { type FreeFn = unsafe extern "C" fn(*mut c_void); let fptr = REAL_FREE.load(Ordering::Relaxed); // Early-buffer pointers live in .bss — never pass them to the real allocator. if is_early_ptr(ptr) { return; } if fptr == 0 { return; } unsafe { let func: FreeFn = std::mem::transmute(fptr); func(ptr) } } /// Get monotonic time in nanoseconds (no allocation, no syscall overhead) #[inline(always)] fn now_ns() -> u64 { let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 }; unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) }; ts.tv_sec as u64 * 1_000_000_000 + ts.tv_nsec as u64 } /// Hooked malloc — born dormant, wakes after grace period. /// /// DORMANT: pure passthrough. Single atomic load overhead (~1ns). /// OBSERVING: push to ring buffer. Atomic increment (~10ns). /// The process doesn't know the difference. #[unsafe(no_mangle)] pub unsafe extern "C" fn malloc(size: size_t) -> *mut c_void { let ptr = unsafe { real_malloc(size) }; // Fast path: dormant = pure passthrough, ~1ns overhead let phase = ENGAGEMENT_PHASE.load(Ordering::Relaxed); if phase == PHASE_DORMANT { // Check if grace period has elapsed — transition to observing let load_time = LOAD_TIME_NS.load(Ordering::Relaxed); if load_time > 0 && now_ns() - load_time > GRACE_PERIOD_NS { ENGAGEMENT_PHASE.store(PHASE_OBSERVING, Ordering::Release); // Start drain thread on first transition start_drain_thread(); } return ptr; } // Observing/active: record the event REENTRANT.with(|r| { if r.get() { return; } r.set(true); push_event(EVENT_ALLOC, ptr as usize, size); r.set(false); }); ptr } /// Hooked free — same dormant/observing phases as malloc. #[unsafe(no_mangle)] pub unsafe extern "C" fn free(ptr: *mut c_void) { if ptr.is_null() { return; } let phase = ENGAGEMENT_PHASE.load(Ordering::Relaxed); if phase >= PHASE_OBSERVING { REENTRANT.with(|r| { if r.get() { return; } r.set(true); push_event(EVENT_FREE, ptr as usize, 0); r.set(false); }); } unsafe { real_free(ptr) } } /// Print full pipeline summary on process exit — only if process ran long enough #[unsafe(no_mangle)] pub extern "C" fn condensate_summary() { // Only print for long-lived processes (>5 seconds) // Short-lived commands (ls, grep, cat) shouldn't flood stderr let (elapsed, quiet) = MEMBRANE.try_lock() .map(|s| (s.elapsed_ns(), s.quiet)) .unwrap_or((0, false)); if elapsed < 5_000_000_000 { return; // process ran < 5 seconds, skip summary } // Honour quiet mode — suppress all output if quiet { return; } // Membrane stats if let Ok(state) = MEMBRANE.lock() { state.summary().print(); } // Ring buffer overflow count — non-zero means drain fell behind during burst let overflow = RING_OVERFLOW_COUNT.load(Ordering::Relaxed); if overflow > 0 { eprintln!(" Ring overflow events: {} (events dropped during burst)", overflow); } // Pipeline stats (the living loop) if let Ok(pipeline) = PIPELINE.lock() { pipeline.summary().print(); } } /// Called when the shared library is loaded (constructor) #[used] #[unsafe(link_section = ".init_array")] static INIT: extern "C" fn() = { extern "C" fn init() { INITIALIZED.store(true, Ordering::SeqCst); // Cache real malloc/free pointers — one dlsym each, never again. unsafe { cache_real_functions() }; // Record load time — grace period starts now. // The membrane stays DORMANT (pure passthrough) for GRACE_PERIOD_NS. // After that, the first malloc transitions to OBSERVING. // This lets processes like Node.js/V8 and xrdp finish their // initialization before we touch anything. LOAD_TIME_NS.store(now_ns(), Ordering::Release); // Don't start drain thread yet — it starts when DORMANT → OBSERVING. // Don't register atexit yet — only register when we actually observe. } init }; } // mod preload_hooks #[cfg(test)] mod tests { use super::*; #[test] fn test_membrane_state() { let mut state = MembraneState::new(); state.sample_rate = 1; // track every alloc for testing state.min_track_size = 0; // track all sizes state.record_alloc(0x1000, 8192); state.record_alloc(0x2000, 65536); state.record_alloc(0x3000, 1_000_000); assert_eq!(state.total_alloc_events, 3); let summary = state.summary(); assert!(summary.current_allocated_mb > 0.0); assert_eq!(summary.tracked_allocations, 3); } #[test] fn test_free_tracking() { let mut state = MembraneState::new(); state.sample_rate = 1; state.min_track_size = 0; state.record_alloc(0x1000, 100_000); state.record_alloc(0x2000, 200_000); assert_eq!(state.active.len(), 2); state.record_free(0x1000); assert_eq!(state.active.len(), 1); assert_eq!(state.total_free_events, 1); } #[test] fn test_size_buckets() { let mut state = MembraneState::new(); state.record_alloc(0x1000, 32); // tiny state.record_alloc(0x2000, 512); // small state.record_alloc(0x3000, 8192); // medium state.record_alloc(0x4000, 100_000); // large state.record_alloc(0x5000, 2_000_000); // huge let summary = state.summary(); // Check that buckets have counts let total_bucket_count: u64 = summary.buckets.iter().map(|b| b.count).sum(); assert_eq!(total_bucket_count, 5); } #[test] fn test_observe_only_mode() { let state = MembraneState::new(); assert_eq!(state.mode(), MembraneMode::ObserveOnly); } #[test] fn test_confidence_gating() { let mut state = MembraneState::new(); state.min_observation_cycles = 5; // Before enough cycles: not confident assert!(!state.is_confident()); for _ in 0..4 { state.record_cycle(); } assert!(!state.is_confident()); // After reaching min_observation_cycles: confident state.record_cycle(); assert!(state.is_confident()); } #[test] fn test_mode_transition() { let mut state = MembraneState::new(); state.min_observation_cycles = 3; assert_eq!(state.mode(), MembraneMode::ObserveOnly); for _ in 0..3 { state.record_cycle(); } assert!(state.is_confident()); state.set_mode(MembraneMode::Active); assert_eq!(state.mode(), MembraneMode::Active); } #[test] fn test_quiet_mode() { // Without the env var set, quiet should be false std::env::remove_var("CONDENSATE_QUIET"); let state = MembraneState::new(); assert!(!state.quiet); // With the env var set, quiet should be true std::env::set_var("CONDENSATE_QUIET", "1"); let state_quiet = MembraneState::new(); assert!(state_quiet.quiet); // Clean up std::env::remove_var("CONDENSATE_QUIET"); } #[test] fn test_canary_arm_and_confirm() { let mut state = MembraneState::new(); // Before arming: no canary file assert!(state.canary_file.is_none()); state.arm_canary(); // After arming: file should exist on disk let path = state.canary_file.clone().expect("canary_file should be set after arm_canary"); assert!(std::path::Path::new(&path).exists(), "canary file should exist after arm_canary"); // Mode transitions to Active assert_eq!(state.mode(), MembraneMode::Active); // engagement timestamp is recorded assert!(state.engagement_timestamp_ns.is_some()); state.confirm_canary(); // After confirming: file should be gone and canary_file cleared assert!(state.canary_file.is_none()); assert!(!std::path::Path::new(&path).exists(), "canary file should be removed after confirm_canary"); } #[test] fn test_canary_expiry() { let mut state = MembraneState::new(); state.canary_timeout_s = 2; // 2-second timeout state.arm_canary(); let armed_ns = state.engagement_timestamp_ns.unwrap(); // A timestamp just before expiry should not be expired let before_expiry_ns = armed_ns + 1_000_000_000; // 1 second later assert!(!state.check_canary_expired(before_expiry_ns)); // A timestamp past the timeout should report expired let after_expiry_ns = armed_ns + 3_000_000_000; // 3 seconds later assert!(state.check_canary_expired(after_expiry_ns)); // Clean up the canary file state.confirm_canary(); } }