diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9083d67f..de242b79 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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:** @@ -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`). diff --git a/Cargo.lock b/Cargo.lock index a3a370fa..c8c44a6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,6 +48,7 @@ dependencies = [ "log", "lsp-types", "memchr", + "mimalloc", "ndarray", "notify", "ort", @@ -683,6 +684,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "cty" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35" + [[package]] name = "darling" version = "0.20.11" @@ -1819,6 +1826,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", + "cty", +] + [[package]] name = "libredox" version = "0.1.15" @@ -1940,6 +1957,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + [[package]] name = "minimal-lexical" version = "0.2.1" diff --git a/crates/aft/Cargo.toml b/crates/aft/Cargo.toml index 7374698c..cf5e629b 100644 --- a/crates/aft/Cargo.toml +++ b/crates/aft/Cargo.toml @@ -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" diff --git a/crates/aft/src/lib.rs b/crates/aft/src/lib.rs index 66c6003a..53218521 100644 --- a/crates/aft/src/lib.rs +++ b/crates/aft/src/lib.rs @@ -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; diff --git a/crates/aft/src/memory.rs b/crates/aft/src/memory.rs index 227be74c..246a9e77 100644 --- a/crates/aft/src/memory.rs +++ b/crates/aft/src/memory.rs @@ -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", @@ -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", @@ -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::::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> = 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::(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 { - use std::sync::OnceLock; - static MALLOC_TRIM: OnceLock> = 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 @@ -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); @@ -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, @@ -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; @@ -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; @@ -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() { @@ -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); } } diff --git a/crates/aft/src/subc/mod.rs b/crates/aft/src/subc/mod.rs index 034abd6f..46484839 100644 --- a/crates/aft/src/subc/mod.rs +++ b/crates/aft/src/subc/mod.rs @@ -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"))] { diff --git a/crates/aft/src/test_allocations.rs b/crates/aft/src/test_allocations.rs index 9b203516..11abd924 100644 --- a/crates/aft/src/test_allocations.rs +++ b/crates/aft/src/test_allocations.rs @@ -1,7 +1,8 @@ -use std::alloc::{GlobalAlloc, Layout, System}; +use mimalloc::MiMalloc; +use std::alloc::{GlobalAlloc, Layout}; use std::cell::Cell; -struct CountingAllocator; +struct CountingAllocator(MiMalloc); thread_local! { static COUNTING: Cell = const { Cell::new(false) }; @@ -11,21 +12,21 @@ thread_local! { unsafe impl GlobalAlloc for CountingAllocator { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { record_allocation(); - unsafe { System.alloc(layout) } + unsafe { self.0.alloc(layout) } } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { - unsafe { System.dealloc(ptr, layout) } + unsafe { self.0.dealloc(ptr, layout) } } unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { record_allocation(); - unsafe { System.realloc(ptr, layout, new_size) } + unsafe { self.0.realloc(ptr, layout, new_size) } } } #[global_allocator] -static GLOBAL: CountingAllocator = CountingAllocator; +static GLOBAL: CountingAllocator = CountingAllocator(MiMalloc); fn record_allocation() { if COUNTING.try_with(Cell::get).unwrap_or(false) {