M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,746 @@
|
||||
//! 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
|
||||
//! `KIGI_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
|
||||
//! (`KIGI_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
|
||||
//!
|
||||
//! `$KIGI_SHARE_DIR/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; `kigi-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};
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
// ─── Process memory sampling ──────────────────────────────────────────────
|
||||
|
||||
/// Cross-platform process memory gauges. Fields are `None` where the
|
||||
/// platform offers no cheap equivalent.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct ProcessMem {
|
||||
pub footprint_bytes: Option<u64>,
|
||||
pub rss_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
/// Sample this process's memory. Sub-microsecond syscall on macOS/Linux.
|
||||
pub fn sample_process_memory() -> ProcessMem {
|
||||
imp::sample()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use super::ProcessMem;
|
||||
|
||||
// Hand-rolled `task_vm_info` prefix through `phys_footprint` (the kernel
|
||||
// accepts any count ≤ the current struct revision; passing the prefix
|
||||
// count returns exactly these fields). Layout per XNU osfmk/mach/task_info.h.
|
||||
#[repr(C)]
|
||||
#[derive(Default)]
|
||||
struct TaskVmInfoPrefix {
|
||||
virtual_size: u64,
|
||||
region_count: i32,
|
||||
page_size: i32,
|
||||
resident_size: u64,
|
||||
resident_size_peak: u64,
|
||||
device: u64,
|
||||
device_peak: u64,
|
||||
internal: u64,
|
||||
internal_peak: u64,
|
||||
external: u64,
|
||||
external_peak: u64,
|
||||
reusable: u64,
|
||||
reusable_peak: u64,
|
||||
purgeable_volatile_pmap: u64,
|
||||
purgeable_volatile_resident: u64,
|
||||
purgeable_volatile_virtual: u64,
|
||||
compressed: u64,
|
||||
compressed_peak: u64,
|
||||
compressed_lifetime: u64,
|
||||
phys_footprint: u64,
|
||||
}
|
||||
|
||||
const TASK_VM_INFO: u32 = 22;
|
||||
// mach natural_t (u32) units.
|
||||
const PREFIX_COUNT: u32 = (size_of::<TaskVmInfoPrefix>() / size_of::<u32>()) as u32;
|
||||
|
||||
unsafe extern "C" {
|
||||
// libSystem: the calling task's control port and task_info(2).
|
||||
static mach_task_self_: u32;
|
||||
fn task_info(task: u32, flavor: u32, info: *mut u8, count: *mut u32) -> i32;
|
||||
}
|
||||
|
||||
pub(super) fn sample() -> ProcessMem {
|
||||
let mut info = TaskVmInfoPrefix::default();
|
||||
let mut count = PREFIX_COUNT;
|
||||
// SAFETY: `info` is a properly sized/aligned out-buffer and `count`
|
||||
// tells the kernel its length in natural_t units; TASK_VM_INFO on
|
||||
// the caller's own task port cannot fault.
|
||||
let kr = unsafe {
|
||||
task_info(
|
||||
mach_task_self_,
|
||||
TASK_VM_INFO,
|
||||
(&raw mut info).cast::<u8>(),
|
||||
&raw mut count,
|
||||
)
|
||||
};
|
||||
if kr != 0 {
|
||||
return ProcessMem::default();
|
||||
}
|
||||
ProcessMem {
|
||||
footprint_bytes: Some(info.phys_footprint),
|
||||
rss_bytes: Some(info.resident_size),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod imp {
|
||||
use super::ProcessMem;
|
||||
|
||||
pub(super) fn sample() -> ProcessMem {
|
||||
// /proc/self/statm field 2 = resident pages.
|
||||
let Ok(statm) = std::fs::read_to_string("/proc/self/statm") else {
|
||||
return ProcessMem::default();
|
||||
};
|
||||
let rss_pages: u64 = statm
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|f| f.parse().ok())
|
||||
.unwrap_or(0);
|
||||
// Kernel page size is not always 4 KiB (aarch64 kernels commonly use
|
||||
// 16K/64K pages); ask once.
|
||||
// SAFETY: sysconf(_SC_PAGESIZE) has no preconditions.
|
||||
static PAGE_SIZE: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
|
||||
let page = *PAGE_SIZE.get_or_init(|| {
|
||||
let sz = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
|
||||
if sz > 0 { sz as u64 } else { 4096 }
|
||||
});
|
||||
ProcessMem {
|
||||
footprint_bytes: None,
|
||||
rss_bytes: Some(rss_pages * page),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
mod imp {
|
||||
use super::ProcessMem;
|
||||
|
||||
pub(super) fn sample() -> ProcessMem {
|
||||
ProcessMem::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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 `KIGI_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 `KIGI_MEMTRACE=0|false|off`.
|
||||
fn enabled_by_env() -> bool {
|
||||
!matches!(
|
||||
std::env::var("KIGI_MEMTRACE").ok().as_deref(),
|
||||
Some("0") | Some("false") | Some("off")
|
||||
)
|
||||
}
|
||||
|
||||
fn interval_from_env() -> Duration {
|
||||
let secs = std::env::var("KIGI_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("KIGI_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. `$KIGI_SHARE_DIR/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 `KIGI_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]
|
||||
fn process_memory_sampling_returns_gauges() {
|
||||
let mem = sample_process_memory();
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
assert!(mem.footprint_bytes.unwrap_or(0) > 0, "footprint on macOS");
|
||||
assert!(mem.rss_bytes.unwrap_or(0) > 0, "rss on macOS");
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
assert!(mem.rss_bytes.unwrap_or(0) > 0, "rss on linux");
|
||||
let _ = mem;
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user