grok-build-upstream-mirror/crates/codegen/xai-grok-pager/src/memory_trace.rs
grokkybara[bot] 780d1388ff Synced from monorepo
Synced from monorepo

Changes:
- grok-shell: send an expired external-provider credential to the sign-in flow, not a 401 loop
- pager: clickable ▲ jumps to the top of the response being read
- grok-shell: keep a large task log from making the completion message too long
- Plan viewer scrollbar: widen grab zone to the border column; fix striped thumb in Terminal.app
- pager: poll the tmux probe teardown grace instead of sleeping it
- security: vendor-compat MCP kill switch is now actually enforced when reported as on
- grok-shell: restore session eviction when a leader client disconnects
- Bump rust-toolchain to 1.93.0
- workspace: lexical-normalize permission path patterns before glob matching
- pager: reject garbage Enter in the /resume picker
- pager: show Mermaid affordances in plan mode preview
- pager: drop manage-account link from /session-info
- workspace: auto-approve read-only git queries; defer write floor to auto classifier
- Add free-form pattern editor to the "Always allow" command prompt
- grok-shell: fix /btw caching
- pager: Tab walks answers in the ask_user_question card
- External-provider auth refresh: single 7s attempt instead of 3×5s
- pager: don't resurrect finished background tasks as Running when completion arrives first
- pager: report tmux truecolor clamping in Doctor
- Fix plan viewer scrollbar click+drag hijacked by comment gutter
- pager/shell: stop double Recap after the same last turn
- sampler: preserve x-should-retry through stream collection
- pager: clear plan-mode indicator immediately when the user approves a plan
- pager: tmux does not re-read its config on reattach

Source-Revision: 64c4de99cc822b25ce9c54ab5a4f372093d0885d
2026-08-03 08:17:57 +00:00

616 lines
23 KiB
Rust

//! Process memory tracing: durable JSONL evidence for memory investigations.
//!
//! The pager's footprint problems (see `memory_release`) historically had to
//! be diagnosed live with `vmmap` on whatever process happened to still be
//! running — and vmmap mislabels the jemalloc heap as "CoreMedia Capture
//! Data" (jemalloc tags its mmaps `VM_MAKE_TAG(101)` = `VM_MEMORY_CM_REGWARP`),
//! so post-hoc analysis was blind. This module records what actually
//! happened, when, attributed to which code path:
//!
//! - **Samples**: footprint/RSS + allocator gauges every
//! `GROK_MEMTRACE_INTERVAL_SECS` (default 30s) from a detached thread.
//! - **Purges**: every `memory_release` invocation, tagged with the memory
//! cliff that triggered it (`reason`), with before/after footprint and
//! duration — so over- or under-purging is visible per call site.
//! - **Thresholds**: when the physical footprint crosses a bucket
//! (`GROK_MEMTRACE_THRESHOLD_MB`, default 1 GiB, then doubling), a full
//! allocator stats dump (jemalloc `malloc_stats_print`) is written next to
//! the trace and the *threshold hook* fires — the seam the GCS trace-upload
//! pipeline plugs into (see below). Buckets re-arm once the footprint
//! halves, so a long-lived process can evidence repeated growth cycles.
//!
//! ## Files
//!
//! `$GROK_HOME/memtrace/<start-ts>-<pid>.jsonl` (+ `.1` after 4 MiB
//! rotation) and `<stem>-jemalloc-<seq>.txt` threshold dumps. Files are
//! created lazily on the first event so short-lived CLI invocations leave no
//! debris. Traces contain **process memory numbers only** — no user content —
//! so they are safe to ship for analysis.
//!
//! ## Seams (composition-root pattern, mirrors `memory_release`)
//!
//! The lib cannot depend on jemalloc; `xai-grok-pager-bin` installs:
//! - [`install_allocator_stats_provider`] — cheap mallctl gauge reads
//! - [`install_allocator_dump_provider`] — full `malloc_stats_print` text
//! - [`install_threshold_hook`] — `(trace_path, crossed_bytes)`; the
//! GCS upload pipeline (WIP) attaches here to ship the trace + dump when a
//! process gets big enough to care about. Absent a hook, crossing is still
//! recorded locally and surfaced via `tracing::warn!`.
//!
//! Everything is inert until [`start`] runs (tests use scoped sinks).
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock, RwLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub use xai_tty_utils::{ProcessResources, sample_process_memory};
/// Allocator gauges sampled from jemalloc (`stats.*` mallctls). All bytes.
#[derive(Clone, Copy, Debug, serde::Serialize)]
pub struct AllocatorStats {
/// Bytes in live application allocations.
pub allocated: u64,
/// Bytes in active pages (allocated + internal fragmentation).
pub active: u64,
/// Bytes in physically resident data pages mapped by the allocator.
pub resident: u64,
/// Bytes in active extents mapped by the allocator.
pub mapped: u64,
/// Bytes in virtual memory mappings retained rather than returned.
pub retained: u64,
/// Allocator metadata bytes.
pub metadata: u64,
}
/// One JSONL trace line. `kind` discriminates which optional fields apply.
#[derive(serde::Serialize)]
struct TraceEvent<'a> {
/// Unix millis.
ts_ms: u64,
/// "start" | "sample" | "purge" | "threshold".
kind: &'a str,
/// macOS `phys_footprint` (resident dirty + compressed + swapped); the
/// number that matches Activity Monitor "Memory" and `vmmap`'s
/// "Physical footprint". `None` where unavailable (Linux).
#[serde(skip_serializing_if = "Option::is_none")]
footprint_bytes: Option<u64>,
/// Resident set size.
#[serde(skip_serializing_if = "Option::is_none")]
rss_bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
alloc: Option<AllocatorStats>,
/// Purge: which memory cliff triggered it (call-site tag).
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<&'a str>,
/// Purge: whether an allocator release hook was installed (a purge
/// without a hook is a no-op — evidence the binary is misconfigured).
#[serde(skip_serializing_if = "Option::is_none")]
hook_installed: Option<bool>,
/// Purge: the memory gauge before the purge — physical footprint where
/// available (macOS), else RSS (Linux). Pair with the event's
/// `footprint_bytes`/`rss_bytes` (same precedence, sampled after) for
/// the released delta.
#[serde(skip_serializing_if = "Option::is_none")]
gauge_before_bytes: Option<u64>,
/// Purge duration in microseconds.
#[serde(skip_serializing_if = "Option::is_none")]
purge_us: Option<u64>,
/// Threshold: the bucket that fired.
#[serde(skip_serializing_if = "Option::is_none")]
threshold_bytes: Option<u64>,
/// Threshold: relative path of the allocator dump written next to the
/// trace, if a dump provider is installed.
#[serde(skip_serializing_if = "Option::is_none")]
dump_file: Option<&'a str>,
/// Start: pid + binary version, for joining traces to sessions/hosts.
#[serde(skip_serializing_if = "Option::is_none")]
pid: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<&'a str>,
}
// ─── Seams (installed by the composition-root binary) ────────────────────
static STATS_PROVIDER: OnceLock<fn() -> Option<AllocatorStats>> = OnceLock::new();
static DUMP_PROVIDER: OnceLock<fn() -> String> = OnceLock::new();
static THRESHOLD_HOOK: OnceLock<fn(&Path, u64)> = OnceLock::new();
/// Install the allocator gauge provider (jemalloc `stats.*` reads).
/// Idempotent; first caller wins.
pub fn install_allocator_stats_provider(provider: fn() -> Option<AllocatorStats>) {
let _ = STATS_PROVIDER.set(provider);
}
/// Install the full allocator dump provider (`malloc_stats_print` text),
/// invoked only on threshold crossings. Idempotent; first caller wins.
pub fn install_allocator_dump_provider(provider: fn() -> String) {
let _ = DUMP_PROVIDER.set(provider);
}
/// Install the threshold hook: `(jsonl_trace_path, crossed_threshold_bytes)`.
/// This is the attachment point for the GCS trace-upload pipeline: it fires
/// at most once per bucket per growth cycle (buckets re-arm after the
/// footprint halves). Idempotent; first caller wins.
pub fn install_threshold_hook(hook: fn(&Path, u64)) {
let _ = THRESHOLD_HOOK.set(hook);
}
// ─── Threshold state (pure; unit-tested) ──────────────────────────────────
/// Exactly-once-per-growth-cycle threshold buckets. A bucket fires when the
/// footprint reaches it while armed, then stays disarmed until the footprint
/// drops below half the bucket (hysteresis, so purge/regrow cycles near a
/// boundary can't spam).
struct Thresholds {
buckets: Vec<u64>,
armed: Vec<bool>,
}
impl Thresholds {
fn new(first_bytes: u64, count: usize) -> Self {
let mut buckets = Vec::with_capacity(count);
let mut b = first_bytes.max(64 << 20); // floor: 64 MiB
for _ in 0..count {
buckets.push(b);
b = b.saturating_mul(2);
}
let armed = vec![true; buckets.len()];
Self { buckets, armed }
}
/// Feed a footprint observation; returns the buckets that fire on it.
fn observe(&mut self, footprint: u64) -> Vec<u64> {
let mut fired = Vec::new();
for (i, &bucket) in self.buckets.iter().enumerate() {
if self.armed[i] && footprint >= bucket {
self.armed[i] = false;
fired.push(bucket);
} else if !self.armed[i] && footprint < bucket / 2 {
self.armed[i] = true;
}
}
fired
}
}
// ─── Sink ──────────────────────────────────────────────────────────────────
const ROTATE_BYTES_DEFAULT: u64 = 4 << 20; // 4 MiB, then one .1 rotation.
static DUMP_SEQ: AtomicU64 = AtomicU64::new(0);
struct Sink {
/// Lazily-created JSONL file; `None` until the first event.
file: Mutex<Option<std::fs::File>>,
path: PathBuf,
bytes_written: AtomicU64,
rotate_bytes: u64,
thresholds: Mutex<Thresholds>,
}
/// Process-global sink. `RwLock` (not `OnceLock`) so tests can install
/// scoped sinks; production installs exactly once via [`start`].
static SINK: RwLock<Option<std::sync::Arc<Sink>>> = RwLock::new(None);
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
impl Sink {
fn new(path: PathBuf, rotate_bytes: u64, first_threshold: u64) -> Self {
Self {
file: Mutex::new(None),
path,
bytes_written: AtomicU64::new(0),
rotate_bytes,
thresholds: Mutex::new(Thresholds::new(first_threshold, 6)),
}
}
fn write_line(&self, line: &str) {
let mut guard = match self.file.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if guard.is_none() {
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
{
Ok(f) => *guard = Some(f),
Err(_) => return, // Tracing must never break the pager.
}
}
if let Some(f) = guard.as_mut()
&& f.write_all(line.as_bytes()).is_ok()
{
let _ = f.write_all(b"\n");
let total = self
.bytes_written
.fetch_add(line.len() as u64 + 1, Ordering::Relaxed);
if total > self.rotate_bytes {
// Rotate: current → .1 (replacing any previous .1), and
// reopen the live file eagerly so a reader between
// events never observes a missing trace.
let mut rotated = self.path.clone().into_os_string();
rotated.push(".1");
let _ = std::fs::rename(&self.path, PathBuf::from(rotated));
self.bytes_written.store(0, Ordering::Relaxed);
*guard = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.ok();
}
}
}
fn emit(&self, event: &TraceEvent<'_>) {
if let Ok(line) = serde_json::to_string(event) {
self.write_line(&line);
}
}
/// Record gauges + run threshold logic. Shared by samples and purges.
fn record(&self, kind: &str, purge: Option<PurgeInfo<'_>>) {
let mem = sample_process_memory();
let alloc = STATS_PROVIDER.get().and_then(|p| p());
let (reason, hook_installed, before, purge_us) = match purge {
Some(p) => (
Some(p.reason),
Some(p.hook_installed),
p.gauge_before,
Some(p.duration.as_micros() as u64),
),
None => (None, None, None, None),
};
self.emit(&TraceEvent {
ts_ms: now_ms(),
kind,
footprint_bytes: mem.footprint_bytes,
rss_bytes: mem.rss_bytes,
alloc,
reason,
hook_installed,
gauge_before_bytes: before,
purge_us,
threshold_bytes: None,
dump_file: None,
pid: None,
version: None,
});
// Threshold gauge: physical footprint where available (macOS);
// otherwise RSS (Linux) — without the fallback, Linux thresholds
// would never fire.
if let Some(gauge) = mem.footprint_bytes.or(mem.rss_bytes) {
let fired = match self.thresholds.lock() {
Ok(mut t) => t.observe(gauge),
Err(p) => p.into_inner().observe(gauge),
};
for bucket in fired {
self.fire_threshold(bucket, gauge);
}
}
}
fn fire_threshold(&self, bucket: u64, footprint: u64) {
// Full allocator dump next to the trace (rare: once per bucket per
// growth cycle), so the upload has arena-level detail to analyze.
let dump_rel = DUMP_PROVIDER.get().map(|dump| {
let seq = DUMP_SEQ.fetch_add(1, Ordering::Relaxed);
let stem = self
.path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "memtrace".to_owned());
let name = format!("{stem}-jemalloc-{seq}.txt");
let dump_path = self.path.with_file_name(&name);
let _ = std::fs::write(&dump_path, dump());
name
});
self.emit(&TraceEvent {
ts_ms: now_ms(),
kind: "threshold",
footprint_bytes: Some(footprint),
rss_bytes: None,
alloc: None,
reason: None,
hook_installed: None,
gauge_before_bytes: None,
purge_us: None,
threshold_bytes: Some(bucket),
dump_file: dump_rel.as_deref(),
pid: None,
version: None,
});
tracing::warn!(
target: "memtrace",
footprint_bytes = footprint,
threshold_bytes = bucket,
trace = %self.path.display(),
"process footprint crossed memory threshold"
);
if let Some(hook) = THRESHOLD_HOOK.get() {
hook(&self.path, bucket);
}
}
}
struct PurgeInfo<'a> {
reason: &'a str,
hook_installed: bool,
gauge_before: Option<u64>,
duration: Duration,
}
fn with_sink(f: impl FnOnce(&Sink)) {
let guard = match SINK.read() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(sink) = guard.as_ref() {
f(sink);
}
}
/// Whether a trace sink is installed ([`start`] ran and `GROK_MEMTRACE` is
/// not disabled, or a test sink is scoped in). Lets callers skip gauge
/// sampling entirely when tracing is off.
pub(crate) fn is_active() -> bool {
match SINK.read() {
Ok(g) => g.is_some(),
Err(p) => p.into_inner().is_some(),
}
}
/// Record a completed purge, attributed to its memory cliff. Called by
/// `memory_release`; no-op until [`start`].
pub(crate) fn record_purge(
reason: &'static str,
hook_installed: bool,
gauge_before: Option<u64>,
duration: Duration,
) {
with_sink(|s| {
s.record(
"purge",
Some(PurgeInfo {
reason,
hook_installed,
gauge_before,
duration,
}),
);
});
}
// ─── Startup ───────────────────────────────────────────────────────────────
/// Env: disable with `GROK_MEMTRACE=0|false|off`.
fn enabled_by_env() -> bool {
!matches!(
std::env::var("GROK_MEMTRACE").ok().as_deref(),
Some("0") | Some("false") | Some("off")
)
}
fn interval_from_env() -> Duration {
let secs = std::env::var("GROK_MEMTRACE_INTERVAL_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(30)
.max(5);
Duration::from_secs(secs)
}
fn first_threshold_from_env() -> u64 {
std::env::var("GROK_MEMTRACE_THRESHOLD_MB")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(1024)
.saturating_mul(1 << 20)
}
/// Start memory tracing: install the process-global sink under
/// `dir` (e.g. `$GROK_HOME/memtrace/`) and spawn the detached sampler
/// thread. Call once from the composition-root binary, AFTER the
/// short-lived-child intercepts (mermaid render worker) so helper processes
/// don't trace. Inert when `GROK_MEMTRACE=0`.
///
/// The trace file is created lazily on the first event; the first sample is
/// taken after one full interval, so short-lived CLI invocations
/// (`grok --version`, `grok trace …`) leave no files behind.
pub fn start(dir: PathBuf) {
if !enabled_by_env() {
return;
}
{
let mut guard = match SINK.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if guard.is_some() {
return; // Already started.
}
let start_ts = now_ms() / 1000;
let path = dir.join(format!("{start_ts}-{}.jsonl", std::process::id()));
*guard = Some(std::sync::Arc::new(Sink::new(
path,
ROTATE_BYTES_DEFAULT,
first_threshold_from_env(),
)));
}
let interval = interval_from_env();
// Detached sampler; the thread holds no locks across sleeps and dies
// with the process. Named for `sample`/Instruments visibility.
let _ = std::thread::Builder::new()
.name("grok-memtrace".into())
.spawn(move || {
let mut wrote_start = false;
loop {
std::thread::sleep(interval);
with_sink(|s| {
if !wrote_start {
wrote_start = true;
s.emit(&TraceEvent {
ts_ms: now_ms(),
kind: "start",
footprint_bytes: None,
rss_bytes: None,
alloc: None,
reason: None,
hook_installed: None,
gauge_before_bytes: None,
purge_us: None,
threshold_bytes: None,
dump_file: None,
pid: Some(std::process::id()),
version: Some(env!("CARGO_PKG_VERSION")),
});
}
s.record("sample", None);
});
}
});
}
// ─── Test support ──────────────────────────────────────────────────────────
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
/// Install a scoped sink writing to `path` (tiny rotation cap, high
/// thresholds). Returns a guard restoring the previous sink on drop.
/// Tests using this must serialize on the `MEMTRACE_SINK` serial key —
/// the sink is process-global.
pub(crate) struct SinkGuard(Option<std::sync::Arc<Sink>>);
impl Drop for SinkGuard {
fn drop(&mut self) {
let mut guard = match SINK.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
*guard = self.0.take();
}
}
pub(crate) fn install_test_sink(path: PathBuf, rotate_bytes: u64) -> SinkGuard {
let mut guard = match SINK.write() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
let prev = guard.take();
*guard = Some(std::sync::Arc::new(Sink::new(
path,
rotate_bytes,
u64::MAX >> 1, // never fire thresholds unless a test asks
)));
SinkGuard(prev)
}
pub(crate) fn record_sample_for_tests() {
with_sink(|s| s.record("sample", None));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thresholds_fire_once_and_rearm_after_halving() {
let mut t = Thresholds::new(1 << 30, 3); // 1 GiB, 2 GiB, 4 GiB
assert!(t.observe(512 << 20).is_empty(), "below first bucket");
assert_eq!(t.observe(1 << 30), vec![1 << 30], "first crossing fires");
assert!(
t.observe(1200 << 20).is_empty(),
"staying above must not re-fire"
);
assert_eq!(
t.observe(5 << 30),
vec![2 << 30, 4 << 30],
"one observation can cross several buckets"
);
// Drop below half of 1 GiB → re-arms only that bucket.
assert!(t.observe(400 << 20).is_empty());
assert_eq!(
t.observe(1 << 30),
vec![1 << 30],
"re-armed bucket fires again on the next growth cycle"
);
}
#[test]
fn thresholds_floor_prevents_degenerate_buckets() {
let mut t = Thresholds::new(0, 2);
assert!(
t.observe(1 << 20).is_empty(),
"buckets are floored at 64 MiB, tiny footprints never fire"
);
assert_eq!(t.observe(64 << 20), vec![64 << 20]);
}
#[test]
#[serial_test::serial(MEMTRACE_SINK)]
fn sample_events_are_valid_jsonl_and_rotate() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.jsonl");
let _guard = test_support::install_test_sink(path.clone(), 256);
// Enough samples to exceed the 256-byte cap at least twice; the
// post-rotation file is created lazily by the NEXT write, so the
// final sample guarantees both files exist.
for _ in 0..16 {
test_support::record_sample_for_tests();
}
// Rotation happened at the tiny cap: a `.1` exists and both files
// hold only valid JSON lines with the expected shape.
let rotated = dir.path().join("t.jsonl.1");
assert!(rotated.exists(), "rotation must produce a .1 file");
assert!(path.exists(), "rotation reopens the live file eagerly");
for p in [&path, &rotated] {
let body = std::fs::read_to_string(p).unwrap();
for line in body.lines() {
let v: serde_json::Value = serde_json::from_str(line).expect("valid JSON line");
assert_eq!(v["kind"], "sample");
assert!(v["ts_ms"].as_u64().unwrap() > 0);
}
}
}
#[test]
#[serial_test::serial(MEMTRACE_SINK)]
fn purge_events_carry_cliff_attribution() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("p.jsonl");
let _guard = test_support::install_test_sink(path.clone(), 1 << 20);
crate::memory_release::release_retained_memory_with("unit-test-cliff");
let body = std::fs::read_to_string(&path).unwrap();
let purge_line = body
.lines()
.map(|l| serde_json::from_str::<serde_json::Value>(l).unwrap())
.find(|v| v["kind"] == "purge" && v["reason"] == "unit-test-cliff")
.expect("a purge event tagged with the calling cliff");
assert!(purge_line["purge_us"].as_u64().is_some());
assert!(purge_line["hook_installed"].as_bool().is_some());
// The before-gauge must exist on every supported platform (footprint
// on macOS, RSS fallback on Linux) or purge deltas are uncomputable.
#[cfg(any(target_os = "macos", target_os = "linux"))]
assert!(
purge_line["gauge_before_bytes"].as_u64().unwrap_or(0) > 0,
"purge events must carry a before-gauge for delta analysis"
);
}
}