Files
Kigi-CLI/crates/codegen/kigi-tui/src/memory_release.rs
T
ZacharyZhang-NY d6c20fc13f 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).
2026-07-17 05:31:01 -04:00

223 lines
9.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.<ALL>.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 (~50300 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<fn()> = 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 ~50300 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<usize> = 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"
);
}
}