//! Allocator memory-release seam. //! //! Resuming a large session parses the whole `updates.jsonl` and stages the //! replay in memory — a multi-hundred-MB transient for big sessions. The //! allocations are freed once replay completes, but with jemalloc the freed //! pages stay attached to the process (macOS additionally counts `MADV_FREE`d //! pages in RSS until real memory pressure), so an idle, just-resumed TUI //! looks like it is "using" several times its live heap. //! //! The pager library cannot reference jemalloc (the allocator choice belongs //! to the composition-root binary — see `kigi-bin/src/main.rs`), so //! the binary installs a release hook here at startup, mirroring the //! `minimal_hook` IoC seam. The hook purges retained arena pages //! (`arena..purge`); calling it right after heavy transients drop returns //! that memory to the OS instead of letting it linger for the process //! lifetime. //! //! Absent a hook (tests, alternate binaries, non-jemalloc builds) everything //! here is inert. //! //! ## Covered memory cliffs //! //! [`release_retained_memory`] is invoked after every known multi-MB drop: //! //! - session-load replay completion (`dispatch/session/load.rs`) — the parsed //! `updates.jsonl` staging transient (100s of MB for long sessions) //! - reconnect-reload window finalize/abort/supersede //! (`agent_view/session.rs`) — gated on `apply_reload_outcome` reporting a //! heavy drop: the stashed pre-reload `ScrollbackState` (full-replay //! success) or the discarded staging (failure). The common cursor-resolve //! outcome reuses the stash and does NOT purge. //! - subagent transcript replay (`app/subagent.rs`, //! `replay_inherited_updates`) — covers both producers (eager live-spawn //! and resume-deferred first open), gated on a non-empty replay //! - closing an agent tab (`dispatch/session/modal.rs`) — the whole //! `AgentView` incl. scrollback, render caches, and child views //! - video teardown — the pre-extracted frame set (~50–300 MB per video): //! viewer close (`media.rs`, synchronous — input path), inline stop / //! scroll-off / replacement (`media.rs`, `render.rs`, `app_view.rs` — //! [`request_release_after_draw`], the purge runs post-frame-flush) //! - image viewer close (`media.rs`) — the decoded overlay image //! - rewind truncation (`dispatch/rewind.rs`) — the removed transcript tail //! //! Every purge is edge-triggered by a rare lifecycle/user event and gated on //! an actual drop, never per frame; draw/tick-path cliffs defer the purge to //! the post-flush gap so it cannot stall the frame being painted. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Mutex, OnceLock}; static RELEASE_HOOK: OnceLock = OnceLock::new(); /// Install the allocator release hook. Idempotent; first caller wins. /// Called once by the composition-root binary before the app runs. pub fn install_release_hook(hook: fn()) { let _ = RELEASE_HOOK.set(hook); } /// Ask the allocator to return freed-but-retained pages to the OS. /// /// Cheap enough to call after any known memory cliff (see the module docs for /// the covered sites). No-op when no hook is installed. /// /// Call this directly from dispatch/input paths (the stall lands between /// interactions). From draw/tick paths use [`request_release_after_draw`] /// instead: when a purge has real work to do (a ~50–300 MB frame set just /// dropped) the synchronous madvise would land inside the very frame the /// user is waiting for. pub fn release_retained_memory() { release_retained_memory_with("unattributed"); } /// [`release_retained_memory`] with memory-cliff attribution: `reason` tags /// the purge event in the memory trace (`memory_trace` module) so per-site /// purge frequency, duration, and released footprint are analyzable offline. /// Use a short stable kebab-case tag (e.g. `"session-load-replay"`). pub fn release_retained_memory_with(reason: &'static str) { let hook = RELEASE_HOOK.get(); // Skip gauge sampling entirely when tracing is off (`KIGI_MEMTRACE=0` // or no sink): a disabled trace must add zero syscalls to purges. let trace = crate::memory_trace::is_active(); let before = if trace { // Same gauge precedence as the trace's threshold logic: physical // footprint where available (macOS), else RSS (Linux) — so purge // deltas are computable on every platform. let mem = crate::memory_trace::sample_process_memory(); mem.footprint_bytes.or(mem.rss_bytes) } else { None }; let started = std::time::Instant::now(); if let Some(hook) = hook { hook(); } if trace { crate::memory_trace::record_purge(reason, hook.is_some(), before, started.elapsed()); } } /// Deferred-release request flag, drained post-frame-flush. /// /// `AtomicBool` rather than a thread-local: requesters (draw/tick code) and /// the drainer (`AppView::draw` tail) are both main-thread today, but the /// flag must not silently drop a request if that ever changes. static RELEASE_AFTER_DRAW: AtomicBool = AtomicBool::new(false); /// Memory-cliff tag for the pending deferred request. Coalescing requests /// keep the LAST writer's reason — precise enough for trace attribution /// (coalesced requests within one frame are the same user gesture). static DEFER_REASON: Mutex<&'static str> = Mutex::new("post-draw"); /// Request [`release_retained_memory`] to run right after the current frame /// flushes (drained by [`run_deferred_release`] at the end of /// `AppView::draw`). For memory cliffs hit *inside* the draw/tick path — /// e.g. inline video stopped because it scrolled off screen. pub fn request_release_after_draw() { request_release_after_draw_with("post-draw"); } /// [`request_release_after_draw`] with memory-cliff attribution for the /// trace (see [`release_retained_memory_with`]). pub fn request_release_after_draw_with(reason: &'static str) { if let Ok(mut r) = DEFER_REASON.lock() { *r = reason; } RELEASE_AFTER_DRAW.store(true, Ordering::Relaxed); } /// Drain a pending [`request_release_after_draw`], if any. Called once at /// the end of `AppView::draw`, after the terminal buffer flush, so the purge /// cost lands in the idle gap between frames instead of inside one. pub fn run_deferred_release() { if RELEASE_AFTER_DRAW.swap(false, Ordering::Relaxed) { let reason = DEFER_REASON.lock().map(|r| *r).unwrap_or("post-draw"); release_retained_memory_with(reason); } } /// Test support: a counting release hook with a **per-thread** counter. /// /// The real `RELEASE_HOOK` is a process-global `OnceLock`, but dispatch/view /// code always calls [`release_retained_memory`] on the calling thread, so a /// thread-local count lets parallel `cargo test` threads assert both positive /// ("this path released") and negative ("this path must not release") deltas /// without cross-test interference. #[cfg(test)] pub(crate) mod test_support { use std::cell::Cell; thread_local! { static CALLS: Cell = const { Cell::new(0) }; } fn counting_hook() { CALLS.with(|c| c.set(c.get() + 1)); } /// Install the counting hook (idempotent; first caller wins, and every /// test installs the same `fn`, so ordering does not matter). pub(crate) fn install_counting_hook() { super::install_release_hook(counting_hook); } /// Number of releases observed **on this thread**. pub(crate) fn calls() -> usize { CALLS.with(|c| c.get()) } } #[cfg(test)] mod tests { use super::*; #[test] fn release_invokes_installed_hook_per_call() { // The hook slot is a global OnceLock shared with sibling tests, but // the counter is thread-local, so deltas on THIS thread are exact. test_support::install_counting_hook(); let before = test_support::calls(); release_retained_memory(); release_retained_memory(); assert_eq!( test_support::calls(), before + 2, "each release must invoke the installed hook exactly once" ); } /// The deferred request coalesces into exactly one release at the next /// drain, and a drain without a request is inert. Serialized: the request /// flag is process-wide. #[test] #[serial_test::serial(MEMORY_RELEASE_DEFER)] fn deferred_request_coalesces_and_drains_once() { test_support::install_counting_hook(); run_deferred_release(); // drain any stale request let before = test_support::calls(); run_deferred_release(); assert_eq!( test_support::calls(), before, "drain without request is inert" ); request_release_after_draw(); request_release_after_draw(); // coalesces assert_eq!( test_support::calls(), before, "requesting must not purge synchronously" ); run_deferred_release(); assert_eq!(test_support::calls(), before + 1, "one drain, one purge"); run_deferred_release(); assert_eq!( test_support::calls(), before + 1, "flag cleared by the drain" ); } }