Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@
2. When a project root becomes unbound (no active routes/channels remaining and no pending binds), the subc daemon quiesces it: marks the actor context as subc unbound, invalidates the configure generation, retires search/callgraph/semantic build receivers, cancels queued and pending artifact work, cancels all queued maintenance jobs (returning `"maintenance_cancelled"` answers, except for active `Lsp` drains which are allowed to run/finish), and discards deferred configure maintenance. Transient unbind deliberately keeps the watcher and resident artifacts warm so a host restart can rebind without a full verification scan. Receiver generation/epoch pairs prevent already-dequeued results from committing after teardown or replacement, while per-artifact publication epochs prevent superseded workers from publishing stale disk pointers. When a new route is bound, the root is reactivated, clearing the quiesced and evicted flags.
3. After the idle TTL, and only while the root still has no bound or pending route, evict root-scoped artifact handles (callgraph store, search index, semantic index, borrowed indexes, symbol data, and inspect SQLite caches) via `evict_idle_artifacts`; stop and bounded-join the watcher on a detached reaper thread; and shut down reopenable LSP clients in the background. Subsequent queries trigger asynchronous index reloads. Because edits during watcher downtime go unobserved, advance artifact publication epochs and invalidate the verify memo, forcing `WarmVerifyPlan::Strict` re-verification on a later bind. The process-wide tree-sitter parser cache and shared `aft.db` connection are not per-root resources.
4. If the unbound root directory no longer exists, remove its idle executor actor and drop its LSP, bash watchdog, channels, and registries on a detached teardown thread. Purge detached-session replay and wake state for that root; a missing-directory root cannot be rebound by the plugin. If cleanup of an idle or deleted root is blocked, a detailed reap blocker census (`ReapBlockerCensus`) tracks and exposes the specific blockers (such as active route channels, quiescing status, background bash waits, or pending/queued maintenance tasks) within the subc health report -- `crates/aft/src/subc/health.rs`.
5. Under macOS and Linux, after sweeping idle roots or periodically on transport ticks when reported allocator slack is >= 1 GiB, request memory pressure relief from the OS allocator via `relieve_allocator_pressure` to reclaim unused pages -- `crates/aft/src/memory.rs`.
5. After sweeping idle roots, request forced mimalloc collection via `relieve_allocator_pressure`. Periodically sample mimalloc statistics on the detached `aft-mem-relief` thread and collect when retained committed memory is at least 1 GiB. The SubC transport and stdin ticks only perform a cheap cadence comparison -- `crates/aft/src/memory.rs`.
6. Track process-wide and root-scoped memory usage (including SQLite allocator metrics and OS RSS memory) via memory snapshots returned in status reports -- `crates/aft/src/memory.rs`, `crates/aft/src/commands/status.rs`. Status runtime counts expose live watcher runtimes, live actor roots, and open routes. Key status memory roots by `ProjectRootId` on all platforms to prevent path-casing/verbatim comparison mismatches. To prevent large status payloads from exceeding metrics cache limits, the per-root detail breakdown in status payloads and health check metrics is capped (e.g. at the top 8 roots by attributed bytes), and the remaining entries are rolled up in a compact summarized footprint -- `crates/aft/src/subc/health.rs`, `crates/aft/src/memory.rs`.

**Codebase inspection flow:**
Expand Down Expand Up @@ -362,8 +362,8 @@
**MemoryEstimate / MemorySnapshot:**
- Purpose: Track, attribute, and report process-wide and subsystem-specific memory usage.
- Location: `crates/aft/src/memory.rs`
- Pattern: Diagnostic structures and OS memory allocator hooks.
- Contains: Subsystem memory estimation helpers, SQLite allocator query bindings (`sqlite3_memory_used`), platform-specific resident set size (RSS), and macOS kernel physical footprint (`phys_footprint_bytes` via `proc_pid_rusage RUSAGE_INFO_V4`) queries. Periodic allocator slack scans run on a detached background-priority `aft-mem-relief` thread because allocator inspection can block. Transport and stdin ticks only perform a cheap cadence check.
- Pattern: Diagnostic structures and mimalloc collection hooks.
- Contains: Subsystem memory estimation helpers, SQLite allocator query bindings (`sqlite3_memory_used`), mimalloc committed/requested byte telemetry, platform-specific resident set size (RSS), and macOS kernel physical footprint (`phys_footprint_bytes` via `proc_pid_rusage RUSAGE_INFO_V4`) queries. Periodic allocator slack scans and forced collection run on the detached background-priority `aft-mem-relief` thread. Transport and stdin ticks only perform a cheap cadence check.

**FleetStatusClient:**
- Purpose: Publish AFT's project-scoped status segment to the fleet status-holder plane (`prefrontal-core`).
Expand Down
26 changes: 26 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/aft/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ crossbeam-channel = "0.5"
parking_lot = "0.12"
portable-pty = "0.9"
libc = "0.2"
mimalloc = { version = "0.1.52", features = ["extended"] }
getrandom = "0.3"
tree-sitter = "0.26"
tree-sitter-typescript = "0.23.2"
Expand Down
4 changes: 4 additions & 0 deletions crates/aft/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
// Response::error instead of panicking. Confirmed zero .unwrap()/.expect() in
// production error paths as of v0.6.3 audit.

#[cfg(not(test))]
#[global_allocator]
static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc;

pub mod agent_child_env;
pub mod alert_records;
pub mod alert_state;
Expand Down
200 changes: 56 additions & 144 deletions crates/aft/src/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,6 @@ pub struct AllocatorMemorySnapshot {
}

impl AllocatorMemorySnapshot {
#[cfg(any(target_os = "macos", all(target_os = "linux", target_env = "gnu")))]
fn measured(bytes_in_use: u64, size_allocated: u64) -> Self {
Self {
status: "measured",
Expand All @@ -239,10 +238,6 @@ impl AllocatorMemorySnapshot {
}
}

// Not cfg-gated to the fallback platforms: linux-gnu also uses this at
// RUNTIME when the host glibc predates mallinfo2 (< 2.33), which only
// manifests on release binaries built against an old glibc floor.
#[cfg_attr(target_os = "macos", allow(dead_code))]
fn not_estimated(reason: &'static str) -> Self {
Self {
status: "not_estimated_on_this_platform",
Expand Down Expand Up @@ -622,81 +617,34 @@ fn nonnegative_i64_to_u64(value: i64) -> u64 {
u64::try_from(value).unwrap_or(0)
}

#[cfg(target_os = "macos")]
fn allocator_memory_snapshot() -> AllocatorMemorySnapshot {
let mut statistics = std::mem::MaybeUninit::<libc::malloc_statistics_t>::zeroed();
unsafe {
libc::malloc_zone_statistics(libc::malloc_default_zone(), statistics.as_mut_ptr());
}
let statistics = unsafe { statistics.assume_init() };
AllocatorMemorySnapshot::measured(
usize_to_u64(statistics.size_in_use),
usize_to_u64(statistics.size_allocated),
)
pub const fn allocator_backend_name() -> &'static str {
"mimalloc"
}

#[cfg(all(target_os = "linux", target_env = "gnu"))]
fn allocator_memory_snapshot() -> AllocatorMemorySnapshot {
// mallinfo2 exists only in glibc >= 2.33. Release Linux binaries link
// against an older glibc floor (cross gnu images, kept old so dlopen and
// wide distro compatibility hold), so a link-time reference to the symbol
// fails the release build even though native CI (glibc 2.35) links fine.
// Resolve it at runtime instead and report honestly when it is absent.
use std::sync::OnceLock;
type Mallinfo2Fn = unsafe extern "C" fn() -> libc::mallinfo2;
static MALLINFO2: OnceLock<Option<Mallinfo2Fn>> = OnceLock::new();
let resolved = MALLINFO2.get_or_init(|| {
let symbol = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"mallinfo2".as_ptr()) };
if symbol.is_null() {
None
} else {
// SAFETY: glibc declares mallinfo2 as `struct mallinfo2 (*)(void)`;
// the signature matches Mallinfo2Fn exactly.
Some(unsafe { std::mem::transmute::<*mut libc::c_void, Mallinfo2Fn>(symbol) })
}
});
let Some(mallinfo2) = resolved else {
return AllocatorMemorySnapshot::not_estimated("mallinfo2_requires_glibc_2_33");
let Ok(statistics) = mimalloc::MiMalloc::stats_json() else {
return AllocatorMemorySnapshot::not_estimated("mimalloc_statistics_unavailable");
};
let Ok(statistics) = serde_json::from_slice::<Value>(statistics.to_bytes()) else {
return AllocatorMemorySnapshot::not_estimated("mimalloc_statistics_invalid");
};
let current = |field: &str| {
statistics
.get(field)
.and_then(|value| value.get("current"))
.and_then(Value::as_u64)
};
let Some(bytes_in_use) = current("malloc_requested") else {
return AllocatorMemorySnapshot::not_estimated("mimalloc_statistics_incomplete");
};
let Some(size_allocated) = current("committed") else {
return AllocatorMemorySnapshot::not_estimated("mimalloc_statistics_incomplete");
};
let statistics = unsafe { mallinfo2() };
let mapped_bytes = statistics.hblkhd as u64;
let bytes_in_use = (statistics.uordblks as u64).saturating_add(mapped_bytes);
let size_allocated = (statistics.arena as u64).saturating_add(mapped_bytes);
AllocatorMemorySnapshot::measured(bytes_in_use, size_allocated)
}

#[cfg(all(target_os = "linux", target_env = "gnu"))]
type MallocTrimFn = unsafe extern "C" fn(libc::size_t) -> libc::c_int;

/// Resolve glibc's optional trimming primitive without creating a link-time
/// dependency on a symbol that musl and alternate allocators do not provide.
#[cfg(all(target_os = "linux", target_env = "gnu"))]
fn resolved_malloc_trim() -> Option<MallocTrimFn> {
use std::sync::OnceLock;
static MALLOC_TRIM: OnceLock<Option<MallocTrimFn>> = OnceLock::new();
MALLOC_TRIM
.get_or_init(|| {
let symbol = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"malloc_trim".as_ptr()) };
if symbol.is_null() {
None
} else {
// SAFETY: glibc declares malloc_trim as `int (size_t)`;
// the signature matches MallocTrimFn exactly.
Some(unsafe { std::mem::transmute::<*mut libc::c_void, MallocTrimFn>(symbol) })
}
})
.as_ref()
.copied()
}

#[cfg(not(any(target_os = "macos", all(target_os = "linux", target_env = "gnu"))))]
fn allocator_memory_snapshot() -> AllocatorMemorySnapshot {
AllocatorMemorySnapshot::not_estimated("platform_allocator_statistics_unavailable")
}

#[cfg(target_os = "macos")]
unsafe extern "C" {
fn malloc_zone_pressure_relief(zone: *mut libc::malloc_zone_t, goal: usize) -> usize;
fn mi_collect(force: bool);
}

/// Allocator slack (mapped-but-unused arena bytes) above which opportunistic
Expand All @@ -705,8 +653,7 @@ pub const ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES: u64 = 1024 * 1024 * 1024;

/// Minimum spacing between allocator slack scans.
///
/// Linux `mallinfo2()` walks every glibc arena under allocator locks. Keep that
/// work off the transport thread and do not repeat it on each maintenance tick.
/// Keep allocator statistics and collection off the transport thread.
pub const ALLOCATOR_SLACK_SCAN_MIN_INTERVAL: std::time::Duration =
std::time::Duration::from_secs(300);

Expand Down Expand Up @@ -759,43 +706,25 @@ pub fn spawn_allocator_slack_scan_if_due(
.is_ok()
}

/// Ask the platform allocator to return unused pages after a process-wide idle
/// gate. Callers own that gate because allocator pressure relief can add
/// latency. Linux invokes glibc's optional `malloc_trim(0)` when the symbol is
/// available; non-glibc allocators intentionally remain a no-op.
#[cfg(target_os = "macos")]
/// Ask mimalloc to return all unused pages after a process-wide idle gate.
/// Callers own that gate because forced collection can add latency.
pub fn relieve_allocator_pressure() -> AllocatorPressureRelief {
let rss_before_bytes = process_rss_bytes();
let allocator_before = allocator_memory_snapshot();
let bytes_released = unsafe { malloc_zone_pressure_relief(std::ptr::null_mut(), 0) };
// SAFETY: `mi_collect` is provided by the linked mimalloc global allocator.
unsafe { mi_collect(true) };
let allocator_after = allocator_memory_snapshot();
let rss_after_bytes = process_rss_bytes();
AllocatorPressureRelief {
bytes_released: usize_to_u64(bytes_released),
rss_before_bytes,
rss_after_bytes,
allocator_before,
allocator_after,
}
}

#[cfg(target_os = "linux")]
pub fn relieve_allocator_pressure() -> AllocatorPressureRelief {
let rss_before_bytes = process_rss_bytes();
let allocator_before = allocator_memory_snapshot();
#[cfg(target_env = "gnu")]
if let Some(malloc_trim) = resolved_malloc_trim() {
// SAFETY: resolved_malloc_trim verifies the symbol and its C ABI
// signature before returning the function pointer.
unsafe { malloc_trim(0) };
}
let allocator_after = allocator_memory_snapshot();
let rss_after_bytes = process_rss_bytes();
let bytes_released = allocator_before
let allocator_released = allocator_before
.size_allocated
.zip(allocator_after.size_allocated)
.map(|(before, after)| before.saturating_sub(after))
.unwrap_or(0);
let rss_released = rss_before_bytes
.zip(rss_after_bytes)
.map(|(before, after)| before.saturating_sub(after))
.unwrap_or(0);
let bytes_released = allocator_released.max(rss_released);
AllocatorPressureRelief {
bytes_released,
rss_before_bytes,
Expand Down Expand Up @@ -872,6 +801,20 @@ mod tests {
assert_eq!(signed_difference(5, 8), -3);
}

#[test]
fn allocator_backend_is_mimalloc() {
assert_eq!(allocator_backend_name(), "mimalloc");
}

#[test]
fn allocator_snapshot_uses_mimalloc_statistics() {
let snapshot = allocator_memory_snapshot();
assert_eq!(snapshot.status, "measured");
assert!(snapshot.bytes_in_use.is_some());
assert!(snapshot.size_allocated.is_some());
assert!(snapshot.retained_slack_bytes.is_some());
}

#[test]
fn slack_relief_requires_large_measured_slack() {
let threshold = ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES;
Expand Down Expand Up @@ -923,32 +866,20 @@ mod tests {
.is_some());
}

#[cfg(any(target_os = "macos", all(target_os = "linux", target_env = "gnu")))]
#[test]
fn allocator_snapshot_reports_measured_slack() {
let allocator = allocator_memory_snapshot();
if allocator.status == "measured" {
let in_use = allocator.bytes_in_use.expect("allocator bytes in use");
let allocated = allocator.size_allocated.expect("allocator size allocated");
assert_eq!(
allocator.retained_slack_bytes,
Some(allocated.saturating_sub(in_use))
);
} else {
assert_eq!(allocator.status, "not_estimated_on_this_platform");
assert_eq!(allocator.bytes_in_use, None);
assert_eq!(allocator.size_allocated, None);
assert_eq!(allocator.retained_slack_bytes, None);
assert_eq!(
allocator.not_estimated,
Some("mallinfo2_requires_glibc_2_33")
);
}
let in_use = allocator.bytes_in_use.expect("allocator bytes in use");
let allocated = allocator.size_allocated.expect("allocator size allocated");
assert_eq!(allocator.status, "measured");
assert_eq!(
allocator.retained_slack_bytes,
Some(allocated.saturating_sub(in_use))
);
}

#[cfg(target_os = "linux")]
#[test]
fn linux_allocator_pressure_relief_smoke() {
fn allocator_pressure_relief_smoke() {
let mut allocation = vec![0u8; 32 * 1024 * 1024];
for byte in allocation.iter_mut().step_by(4096) {
*byte = 1;
Expand All @@ -957,30 +888,10 @@ mod tests {
drop(allocation);

let relief = relieve_allocator_pressure();
std::hint::black_box(relief);

#[cfg(target_env = "gnu")]
assert!(
resolved_malloc_trim().is_some(),
"glibc malloc_trim must be available for the Linux relief path"
);
}

#[cfg(not(any(target_os = "macos", all(target_os = "linux", target_env = "gnu"))))]
#[test]
fn allocator_snapshot_is_honest_when_platform_counters_are_unavailable() {
let allocator = allocator_memory_snapshot();
assert_eq!(allocator.status, "not_estimated_on_this_platform");
assert_eq!(allocator.bytes_in_use, None);
assert_eq!(allocator.size_allocated, None);
assert_eq!(allocator.retained_slack_bytes, None);
assert_eq!(
allocator.not_estimated,
Some("platform_allocator_statistics_unavailable")
);
assert_eq!(relief.allocator_before.status, "measured");
assert_eq!(relief.allocator_after.status, "measured");
}

#[cfg(target_os = "macos")]
#[test]
#[ignore = "bounded live RSS experiment; run explicitly after allocator changes"]
fn allocator_pressure_relief_warm_then_idle_measurement() {
Expand Down Expand Up @@ -1012,5 +923,6 @@ mod tests {
);
assert_eq!(relief.allocator_before.status, "measured");
assert_eq!(relief.allocator_after.status, "measured");
assert!(relief.bytes_released > 0);
}
}
3 changes: 1 addition & 2 deletions crates/aft/src/subc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3602,8 +3602,7 @@ where
next_standing_pass_at = tokio::time::Instant::now()
+ standing::STANDING_MAINTENANCE_INTERVAL;
}
// Scan and trim allocator arenas on a detached thread. On glibc,
// mallinfo2() walks every arena under allocator locks, so the
// Sample and collect mimalloc pages on a detached thread. The
// transport thread must only evaluate the scan cadence here.
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
Expand Down
Loading
Loading