Skip to content

Commit 06bcc88

Browse files
authored
fix(server): don't disable/degrade embeddings when macOS misreports 0 MB available (#13)
The pre-model-load RAM gate read sysinfo's available_memory(), which returns 0 on some macOS versions (reclaimable memory parked in inactive/speculative/ purgeable pages isn't counted as free), so embedding-dependent tools were silently disabled on healthy Macs. - evaluate_memory_gate(): a 0 reading is a detection failure -> proceed with the load; a genuine low-but-nonzero reading still falls back to graph-only. - Applied consistently across all three sites: per-workspace MemoryManager gate, socket-engine shared-model gate, and embed-loop RAM backpressure (0 no longer triggers false pressure). - CODEGRAPH_SKIP_MEMORY_CHECK=1 escape hatch (MCP + --run-tool); documented in both READMEs. - Unit tests cover load/skip/zero-proceed/bypass/boundary. Closes #13.
1 parent 17540f9 commit 06bcc88

5 files changed

Lines changed: 202 additions & 23 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,21 @@ with no setup. For the CLI/MCP server it needs a local model directory
100100
- Distill one from any sentence-transformer (Apache-2.0 Jina-Code by default) in
101101
~30 s on CPU: `python scripts/distill_static_model.py`.
102102

103+
#### `CODEGRAPH_SKIP_MEMORY_CHECK` — force the embedding model past the RAM gate
104+
105+
Before loading the ONNX model, the server checks available memory and, if under
106+
~1.5 GB, skips the model to avoid an OOM-kill (running graph-only instead).
107+
Set `CODEGRAPH_SKIP_MEMORY_CHECK=1` (also accepts `true`/`yes`) to bypass that
108+
check and always load the model.
109+
110+
Use it if embeddings are disabled even though the machine has plenty of free
111+
RAM.
112+
A reading of `0 MB available` is treated as a detection failure and the model
113+
loads anyway (macOS parks reclaimable memory in inactive/speculative pages that
114+
some memory readers do not count as free), so this override is mainly for other
115+
cases where the reported figure is low but wrong.
116+
It works in both MCP and one-shot `--run-tool` modes.
117+
103118
#### `--profile` — narrow the MCP tool surface
104119

105120
The full 32-tool surface is convenient but inflates the agent's prompt-context cost. A profile exposes only the slice you need (also settable via the `CODEGRAPH_TOOL_PROFILE` env var):

crates/codegraph-server/src/ai_query/engine.rs

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ fn available_memory_mb() -> u64 {
7676
sys.available_memory() / (1024 * 1024)
7777
}
7878

79+
/// Whether an available-memory reading counts as real pressure for the embed
80+
/// loop. A 0 MB reading is a detection failure, not genuine pressure — some
81+
/// macOS `sysinfo` versions report 0 available because reclaimable memory is
82+
/// not counted as free (issue #13) — so it must not ratchet the batch size
83+
/// down or force checkpoints.
84+
fn embed_memory_pressured(avail_mb: u64) -> bool {
85+
avail_mb > 0 && avail_mb < EMBED_LOW_MEM_MB
86+
}
87+
7988
/// Split an identifier into camelCase/snake_case words (deduped, lowercased),
8089
/// reusing the BM25 tokenizer: `getUserById` -> "get user by id".
8190
fn split_identifier_words(name: &str) -> String {
@@ -392,13 +401,13 @@ impl QueryEngine {
392401
pos = end;
393402
chunks_done += 1;
394403

395-
if chunks_done % 10 == 0 && pos < total {
404+
if chunks_done.is_multiple_of(10) && pos < total {
396405
tracing::info!("[QueryEngine] Embedded {}/{} symbols", pos, total);
397406
}
398407

399408
// RAM backpressure: shed batch size under memory pressure.
400-
let pressured = chunks_done % EMBED_MEM_CHECK_CHUNKS == 0
401-
&& available_memory_mb() < EMBED_LOW_MEM_MB;
409+
let pressured = chunks_done.is_multiple_of(EMBED_MEM_CHECK_CHUNKS)
410+
&& embed_memory_pressured(available_memory_mb());
402411
if pressured && chunk_size > 4 {
403412
chunk_size = (chunk_size / 2).max(4);
404413
tracing::warn!(
@@ -546,8 +555,8 @@ impl QueryEngine {
546555
pos = end;
547556
chunks_done += 1;
548557

549-
let pressured = chunks_done % EMBED_MEM_CHECK_CHUNKS == 0
550-
&& available_memory_mb() < EMBED_LOW_MEM_MB;
558+
let pressured = chunks_done.is_multiple_of(EMBED_MEM_CHECK_CHUNKS)
559+
&& embed_memory_pressured(available_memory_mb());
551560
if pressured && chunk_size > 4 {
552561
chunk_size = (chunk_size / 2).max(4);
553562
tracing::warn!(
@@ -2632,6 +2641,24 @@ mod tests {
26322641
(engine, graph)
26332642
}
26342643

2644+
#[test]
2645+
fn embed_memory_pressured_low_reading_is_pressure() {
2646+
assert!(embed_memory_pressured(1));
2647+
assert!(embed_memory_pressured(EMBED_LOW_MEM_MB - 1));
2648+
}
2649+
2650+
#[test]
2651+
fn embed_memory_pressured_zero_reading_is_detection_failure_not_pressure() {
2652+
assert!(!embed_memory_pressured(0));
2653+
}
2654+
2655+
#[test]
2656+
fn embed_memory_pressured_at_or_above_floor_is_not_pressure() {
2657+
assert!(!embed_memory_pressured(EMBED_LOW_MEM_MB));
2658+
assert!(!embed_memory_pressured(EMBED_LOW_MEM_MB + 1));
2659+
assert!(!embed_memory_pressured(64 * 1024));
2660+
}
2661+
26352662
#[tokio::test]
26362663
async fn test_engine_creation() {
26372664
let (engine, _) = create_test_engine().await;
@@ -3964,7 +3991,10 @@ mod tests {
39643991

39653992
#[test]
39663993
fn split_identifier_words_handles_camel_and_snake() {
3967-
assert_eq!(split_identifier_words("authenticate_user"), "authenticate user");
3994+
assert_eq!(
3995+
split_identifier_words("authenticate_user"),
3996+
"authenticate user"
3997+
);
39683998
assert_eq!(split_identifier_words("getUserById"), "get user by id");
39693999
// The existing tokenizer keeps acronym+word runs joined (HTML|Parser is
39704000
// NOT split) and drops 1-char tokens — known limitations worth revisiting
@@ -3986,11 +4016,13 @@ mod tests {
39864016
// Enabled: split name words are front-loaded for the static embedder.
39874017
let with_split =
39884018
QueryEngine::build_embed_text(&node, 0, "getUserById", false, true, &graph);
3989-
assert!(with_split.starts_with("get user by id"), "got: {with_split}");
4019+
assert!(
4020+
with_split.starts_with("get user by id"),
4021+
"got: {with_split}"
4022+
);
39904023

39914024
// Disabled (default): original transformer-path text is unchanged.
3992-
let without =
3993-
QueryEngine::build_embed_text(&node, 0, "getUserById", false, false, &graph);
4025+
let without = QueryEngine::build_embed_text(&node, 0, "getUserById", false, false, &graph);
39944026
assert!(without.starts_with("getUserById"), "got: {without}");
39954027
assert!(!without.contains("get user by id"));
39964028
}

crates/codegraph-server/src/mcp/engine.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,24 @@ mod imp {
219219
let shared_engine = {
220220
let mut sys = sysinfo::System::new();
221221
sys.refresh_memory();
222-
if sys.available_memory() < 1_500_000_000 {
223-
tracing::warn!("Engine: <1.5 GB free — running graph-only (no shared model)");
222+
let avail = sys.available_memory();
223+
let gate = crate::memory::evaluate_memory_gate(
224+
avail,
225+
crate::memory::MODEL_MIN_FREE_BYTES,
226+
crate::memory::memory_check_bypassed(),
227+
);
228+
if let crate::memory::MemoryGate::Skip(avail) = gate {
229+
tracing::warn!(
230+
"Engine: only {} MB available — skipping shared embedding model to avoid OOM; running graph-only. Set CODEGRAPH_SKIP_MEMORY_CHECK=1 to override.",
231+
avail / 1_000_000
232+
);
224233
None
225234
} else {
235+
if gate == crate::memory::MemoryGate::ProceedDetectionFailed {
236+
tracing::warn!(
237+
"Engine: available memory read as 0 MB — treating as a detection failure (common on macOS, where reclaimable memory is not counted as free) and proceeding with the shared model load. Set CODEGRAPH_SKIP_MEMORY_CHECK=1 to always bypass this check."
238+
);
239+
}
226240
match VectorEngine::from_backend(model_cache_dir(), &cfg.embedding_model) {
227241
Ok(e) => {
228242
tracing::info!("Engine: shared embedding model loaded");

crates/codegraph-server/src/memory.rs

Lines changed: 125 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,62 @@ fn project_data_dir(workspace_path: &Path) -> Result<PathBuf, MemoryError> {
169169
.join(slug))
170170
}
171171

172+
/// Minimum available memory to load the ONNX embedding model + runtime
173+
/// without risking an OOM-kill (a native crash Rust can't catch).
174+
pub(crate) const MODEL_MIN_FREE_BYTES: u64 = 1_500_000_000; // ~1.5 GB
175+
176+
/// Decision of the pre-model-load RAM gate. Separated from the live sysinfo
177+
/// reading so the policy is unit-testable.
178+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179+
pub(crate) enum MemoryGate {
180+
/// Enough available memory (or the check was bypassed) — load the model.
181+
Load,
182+
/// Genuinely constrained — skip the model and run graph-only. Carries the
183+
/// detected byte count for the log/error message.
184+
Skip(u64),
185+
/// The available-memory reading was 0, which is a detection failure rather
186+
/// than a real state: a running process always holds memory, and on macOS
187+
/// reclaimable memory is parked in inactive/speculative/purgeable pages
188+
/// that some `sysinfo` versions report as 0 available (issue #13). Proceed
189+
/// with the load instead of hard-disabling on a number we don't trust.
190+
ProceedDetectionFailed,
191+
}
192+
193+
/// Decide whether to load the embedding model from the available-memory
194+
/// reading, the minimum threshold, and whether the user forced a bypass.
195+
///
196+
/// Pure: no I/O, no env reads — the caller supplies the inputs. This is the
197+
/// gate policy; keeping it separate lets tests cover the 0-MB detection-failure
198+
/// path that can't be reproduced by driving `sysinfo` live.
199+
pub(crate) fn evaluate_memory_gate(
200+
available_bytes: u64,
201+
min_free_bytes: u64,
202+
bypass: bool,
203+
) -> MemoryGate {
204+
if bypass {
205+
return MemoryGate::Load;
206+
}
207+
if available_bytes == 0 {
208+
return MemoryGate::ProceedDetectionFailed;
209+
}
210+
if available_bytes < min_free_bytes {
211+
return MemoryGate::Skip(available_bytes);
212+
}
213+
MemoryGate::Load
214+
}
215+
216+
/// Whether the user forced the RAM gate off via `CODEGRAPH_SKIP_MEMORY_CHECK`
217+
/// (`1`/`true`/`yes`, case-insensitive). The escape hatch works in both MCP and
218+
/// one-shot `--run-tool` modes since it is read at model-load time.
219+
pub(crate) fn memory_check_bypassed() -> bool {
220+
std::env::var("CODEGRAPH_SKIP_MEMORY_CHECK")
221+
.map(|v| {
222+
let v = v.trim();
223+
v == "1" || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("yes")
224+
})
225+
.unwrap_or(false)
226+
}
227+
172228
/// Memory manager for the LSP server
173229
///
174230
/// Opens the database on-demand for each operation and closes it immediately after.
@@ -192,7 +248,10 @@ pub struct MemoryManager {
192248
impl MemoryManager {
193249
/// Create a new MemoryManager
194250
pub fn new(extension_path: Option<PathBuf>) -> Self {
195-
Self::with_model(extension_path, codegraph_memory::EmbeddingBackend::default())
251+
Self::with_model(
252+
extension_path,
253+
codegraph_memory::EmbeddingBackend::default(),
254+
)
196255
}
197256

198257
/// Create a new MemoryManager with a specific embedding backend
@@ -284,21 +343,30 @@ impl MemoryManager {
284343
let mut sys = sysinfo::System::new();
285344
sys.refresh_memory();
286345
let avail = sys.available_memory();
287-
const MIN_FREE_BYTES: u64 = 1_500_000_000; // ~1.5 GB for model + ort runtime
288346
tracing::info!(
289347
"[MemoryManager::initialize] available memory: {} MB",
290348
avail / 1_000_000
291349
);
292-
if avail < MIN_FREE_BYTES {
293-
crate::crash_phase::mark("onnx_skipped_lowmem");
294-
tracing::warn!(
295-
"[MemoryManager::initialize] only {} MB free — skipping embedding model to avoid OOM; semantic search disabled (graph-only)",
296-
avail / 1_000_000
297-
);
298-
return Err(MemoryError::Other(format!(
299-
"insufficient memory ({} MB free) to load embedding model; running graph-only",
300-
avail / 1_000_000
301-
)));
350+
match evaluate_memory_gate(avail, MODEL_MIN_FREE_BYTES, memory_check_bypassed()) {
351+
MemoryGate::Skip(avail) => {
352+
crate::crash_phase::mark("onnx_skipped_lowmem");
353+
tracing::warn!(
354+
"[MemoryManager::initialize] only {} MB available — skipping embedding model to avoid OOM; semantic search disabled (graph-only). Set CODEGRAPH_SKIP_MEMORY_CHECK=1 to override.",
355+
avail / 1_000_000
356+
);
357+
return Err(MemoryError::Other(format!(
358+
"insufficient memory ({} MB available) to load embedding model; running graph-only (set CODEGRAPH_SKIP_MEMORY_CHECK=1 to override)",
359+
avail / 1_000_000
360+
)));
361+
}
362+
MemoryGate::ProceedDetectionFailed => {
363+
// 0 MB is a detection failure (see MemoryGate) — don't
364+
// disable embeddings on a Mac that actually has RAM.
365+
tracing::warn!(
366+
"[MemoryManager::initialize] available memory read as 0 MB — treating as a detection failure (common on macOS, where reclaimable memory is not counted as free) and proceeding with the embedding model load. Set CODEGRAPH_SKIP_MEMORY_CHECK=1 to always bypass this check."
367+
);
368+
}
369+
MemoryGate::Load => {}
302370
}
303371
}
304372

@@ -612,6 +680,51 @@ pub use codegraph_memory::{
612680
mod tests {
613681
use super::*;
614682

683+
const MIN: u64 = MODEL_MIN_FREE_BYTES;
684+
685+
#[test]
686+
fn gate_loads_when_ample_memory() {
687+
assert_eq!(
688+
evaluate_memory_gate(8_000_000_000, MIN, false),
689+
MemoryGate::Load
690+
);
691+
}
692+
693+
#[test]
694+
fn gate_loads_exactly_at_threshold() {
695+
// `< min` is the skip condition, so exactly `min` must load.
696+
assert_eq!(evaluate_memory_gate(MIN, MIN, false), MemoryGate::Load);
697+
}
698+
699+
#[test]
700+
fn gate_skips_when_genuinely_low() {
701+
assert_eq!(
702+
evaluate_memory_gate(500_000_000, MIN, false),
703+
MemoryGate::Skip(500_000_000)
704+
);
705+
}
706+
707+
#[test]
708+
fn gate_proceeds_on_zero_reading_as_detection_failure() {
709+
// The issue-13 case: sysinfo reports 0 available on a healthy Mac.
710+
// 0 is never a real state, so proceed rather than disable embeddings.
711+
assert_eq!(
712+
evaluate_memory_gate(0, MIN, false),
713+
MemoryGate::ProceedDetectionFailed
714+
);
715+
}
716+
717+
#[test]
718+
fn gate_bypass_forces_load_regardless_of_reading() {
719+
// CODEGRAPH_SKIP_MEMORY_CHECK=1 overrides even a genuine low reading.
720+
assert_eq!(evaluate_memory_gate(0, MIN, true), MemoryGate::Load);
721+
assert_eq!(evaluate_memory_gate(1, MIN, true), MemoryGate::Load);
722+
assert_eq!(
723+
evaluate_memory_gate(500_000_000, MIN, true),
724+
MemoryGate::Load
725+
);
726+
}
727+
615728
#[test]
616729
fn generation_zero_maps_to_historical_path() {
617730
let dir = Path::new("/home/u/.codegraph");

mcp-package/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ Pass flags after `--`:
5454
| `--graph-only` | off | Skip embeddings — graph + structural tools only. No ONNX model load, 10-50× faster indexing. For CI / one-shot graph queries. |
5555
| `--run-tool <name>` || One-shot: index, run a single tool, print result, exit. No MCP handshake. Pair with `--tool-args '<json>'`. |
5656

57+
Before loading the ONNX embedding model, the server checks available memory and runs graph-only if under ~1.5 GB.
58+
If embeddings are disabled even though the machine has plenty of free RAM, set `CODEGRAPH_SKIP_MEMORY_CHECK=1` (also accepts `true`/`yes`) to bypass the check.
59+
A reading of `0 MB available` is treated as a detection failure and the model loads anyway (common on macOS).
60+
Works in both MCP and one-shot `--run-tool` modes.
61+
5762
### Agent rules (recommended)
5863

5964
Pre-configured rule files that teach your AI agent to use CodeGraph tools before falling back to grep / multi-file reads:

0 commit comments

Comments
 (0)