返回 CodeWhale
resource_admission.rs
根目录 / crates / tui / src / tools / resource_admission.rs
1 //! Cross-process admission for expensive local commands.
2 //!
3 //! Fleet and Workflow workers execute in separate Codewhale processes, so an
4 //! in-process semaphore cannot protect the host. Heavy shell commands instead
5 //! take one of a small number of filesystem-backed permits under
6 //! `CODEWHALE_HOME`. The default of two permits is deliberately conservative
7 //! for the 36 GiB laptop class from #4864.
8
9 use std::fs::{File, OpenOptions};
10 use std::io;
11 use std::path::{Path, PathBuf};
12 use std::time::{Duration, Instant};
13
14 use anyhow::{Context, Result, anyhow};
15 use fd_lock::{RwLock, RwLockWriteGuard};
16 use tokio_util::sync::CancellationToken;
17
18 pub(crate) const DEFAULT_HEAVY_COMMAND_LIMIT: usize = 2;
19 const MAX_HEAVY_COMMAND_LIMIT: usize = 16;
20 const ADMISSION_POLL_INTERVAL: Duration = Duration::from_millis(50);
21
22 /// When the host free-RAM fraction drops to/below these thresholds the
23 /// effective heavy-command admission limit tightens so a saturated host stops
24 /// admitting new link graphs (#4864 req 7). Values are deliberately generous
25 /// because the measurement is advisory, not authoritative.
26 const CONSTRAINED_FREE_FRACTION: f64 = 0.30;
27 const CRITICAL_FREE_FRACTION: f64 = 0.15;
28
29 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
30 pub(crate) enum CommandExpense {
31 Normal,
32 Heavy,
33 }
34
35 /// Measured host memory pressure used to tighten heavy-command admission.
36 ///
37 /// `Unknown` means "could not be measured"; admission then fails open (uses the
38 /// configured limit) rather than risk blocking on an unmeasurable host. This
39 /// keeps the gate safe on any CI runner where the probe is unavailable.
40 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
41 pub(crate) enum MemoryPressure {
42 Unknown,
43 Nominal,
44 Constrained,
45 Critical,
46 }
47
48 /// Pluggable memory probe so the admission policy is unit-testable without
49 /// having to drive the host into real memory pressure.
50 pub(crate) trait MemoryProbe: Send + Sync {
51 /// Free-RAM fraction in `0.0..=1.0`, or `None` when it cannot be measured.
52 fn free_fraction(&self) -> Option<f64>;
53 }
54
55 struct HostMemoryProbe;
56
57 impl MemoryProbe for HostMemoryProbe {
58 fn free_fraction(&self) -> Option<f64> {
59 host_memory_free_fraction()
60 }
61 }
62
63 fn classify_memory_pressure(free_fraction: Option<f64>) -> MemoryPressure {
64 match free_fraction {
65 None => MemoryPressure::Unknown,
66 Some(fraction) if fraction <= CRITICAL_FREE_FRACTION => MemoryPressure::Critical,
67 Some(fraction) if fraction <= CONSTRAINED_FREE_FRACTION => MemoryPressure::Constrained,
68 Some(_) => MemoryPressure::Nominal,
69 }
70 }
71
72 /// Effective admission limit after applying host memory pressure. `Critical`
73 /// yields zero so queued heavy commands wait for the host to recover instead of
74 /// snowballing; `Constrained` halves the budget (never below one).
75 fn effective_admission_limit(configured: usize, pressure: MemoryPressure) -> usize {
76 match pressure {
77 MemoryPressure::Critical => 0,
78 MemoryPressure::Constrained => configured.div_ceil(2).max(1),
79 MemoryPressure::Nominal | MemoryPressure::Unknown => configured,
80 }
81 }
82
83 #[derive(Debug)]
84 struct HeavyPermitSlot {
85 _guard: RwLockWriteGuard<'static, File>,
86 // The guard borrows the lock. Keeping the boxed lock here makes that
87 // allocation outlive the guard; field drop order is guard, then lock.
88 _lock: Box<RwLock<File>>,
89 }
90
91 /// A held cross-process heavy-command permit.
92 #[derive(Debug)]
93 pub(crate) struct HeavyCommandPermit {
94 _slot: HeavyPermitSlot,
95 queued_for: Duration,
96 limit: usize,
97 memory_pressure: MemoryPressure,
98 }
99
100 impl HeavyCommandPermit {
101 pub(crate) fn queued_for(&self) -> Duration {
102 self.queued_for
103 }
104
105 pub(crate) fn limit(&self) -> usize {
106 self.limit
107 }
108
109 pub(crate) fn memory_pressure(&self) -> MemoryPressure {
110 self.memory_pressure
111 }
112 }
113
114 pub(crate) fn infer_command_expense(command: &str) -> CommandExpense {
115 let heavy = command
116 .split(['\n', '\r', ';', '|', '&'])
117 .any(segment_is_heavy);
118
119 if heavy {
120 CommandExpense::Heavy
121 } else {
122 CommandExpense::Normal
123 }
124 }
125
126 fn segment_is_heavy(segment: &str) -> bool {
127 let tokens: Vec<String> = segment
128 .split_whitespace()
129 .map(|token| token.trim_matches(['"', '\'']).to_string())
130 .collect();
131 let Some(index) = tokens
132 .iter()
133 .position(|token| !token.contains('=') && token != "env")
134 else {
135 return false;
136 };
137 let executable = Path::new(&tokens[index])
138 .file_stem()
139 .and_then(|name| name.to_str())
140 .unwrap_or_default()
141 .to_ascii_lowercase();
142 if !matches!(executable.as_str(), "cargo" | "rustc") {
143 return false;
144 }
145 if executable == "rustc" {
146 return true;
147 }
148 tokens[index + 1..]
149 .iter()
150 .map(|arg| arg.trim().to_ascii_lowercase())
151 .find(|arg| !arg.is_empty() && !arg.starts_with('-') && !arg.contains('='))
152 .is_some_and(|subcommand| {
153 matches!(
154 subcommand.as_str(),
155 "build" | "test" | "check" | "clippy" | "rustc"
156 )
157 })
158 }
159
160 pub(crate) async fn acquire_heavy_command_permit(
161 command: &str,
162 cancel: Option<&CancellationToken>,
163 ) -> Result<Option<HeavyCommandPermit>> {
164 if infer_command_expense(command) == CommandExpense::Normal {
165 return Ok(None);
166 }
167
168 let limit = configured_heavy_command_limit();
169 let root = admission_root();
170 let probe = HostMemoryProbe;
171 acquire_heavy_command_permit_at(&root, limit, cancel, &probe)
172 .await
173 .map(Some)
174 }
175
176 async fn acquire_heavy_command_permit_at(
177 root: &Path,
178 limit: usize,
179 cancel: Option<&CancellationToken>,
180 probe: &dyn MemoryProbe,
181 ) -> Result<HeavyCommandPermit> {
182 tokio::fs::create_dir_all(root)
183 .await
184 .with_context(|| format!("creating resource admission directory {}", root.display()))?;
185 let started = Instant::now();
186
187 loop {
188 if cancel.is_some_and(|token| token.is_cancelled()) {
189 return Err(anyhow!(
190 "heavy command canceled while queued for resource admission"
191 ));
192 }
193 // Re-measure each iteration: under memory pressure the effective limit
194 // tightens so a saturated host stops admitting new heavy link graphs
195 // (#4864 req 7). Critical pressure yields zero slots, so the command
196 // waits for recovery instead of snowballing.
197 let pressure = classify_memory_pressure(probe.free_fraction());
198 let effective = effective_admission_limit(limit, pressure);
199 for slot in 0..effective {
200 let path = root.join(format!("heavy-{slot}.lock"));
201 match try_lock_slot(&path) {
202 Ok(Some(slot)) => {
203 return Ok(HeavyCommandPermit {
204 _slot: slot,
205 queued_for: started.elapsed(),
206 limit,
207 memory_pressure: pressure,
208 });
209 }
210 Ok(None) => {}
211 Err(error) => {
212 return Err(error).with_context(|| {
213 format!("acquiring heavy command permit {}", path.display())
214 });
215 }
216 }
217 }
218 tokio::time::sleep(ADMISSION_POLL_INTERVAL).await;
219 }
220 }
221
222 fn try_lock_slot(path: &Path) -> io::Result<Option<HeavyPermitSlot>> {
223 let file = OpenOptions::new()
224 .create(true)
225 .read(true)
226 .write(true)
227 .truncate(false)
228 .open(path)?;
229 let lock = Box::new(RwLock::new(file));
230 let lock_ptr = Box::into_raw(lock);
231 // SAFETY: `lock_ptr` remains allocated in `HeavyPermitSlot::_lock` for the
232 // lifetime of `_guard`, and the guard is dropped before that box.
233 let guard = match unsafe { (&mut *lock_ptr).try_write() } {
234 Ok(guard) => guard,
235 Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
236 // SAFETY: no guard was created, so reclaim the allocation now.
237 unsafe { drop(Box::from_raw(lock_ptr)) };
238 return Ok(None);
239 }
240 Err(error) => {
241 // SAFETY: no guard was created, so reclaim the allocation now.
242 unsafe { drop(Box::from_raw(lock_ptr)) };
243 return Err(error);
244 }
245 };
246 // SAFETY: the allocation is owned exactly once by this box and is stable on
247 // the heap even if `HeavyPermitSlot` moves.
248 let lock = unsafe { Box::from_raw(lock_ptr) };
249 // SAFETY: the boxed lock remains alive until after `_guard` is dropped.
250 let guard = unsafe {
251 std::mem::transmute::<RwLockWriteGuard<'_, File>, RwLockWriteGuard<'static, File>>(guard)
252 };
253 Ok(Some(HeavyPermitSlot {
254 _guard: guard,
255 _lock: lock,
256 }))
257 }
258
259 fn configured_heavy_command_limit() -> usize {
260 std::env::var("CODEWHALE_HEAVY_COMMAND_LIMIT")
261 .ok()
262 .and_then(|value| value.trim().parse::<usize>().ok())
263 .filter(|limit| *limit > 0)
264 .unwrap_or(DEFAULT_HEAVY_COMMAND_LIMIT)
265 .min(MAX_HEAVY_COMMAND_LIMIT)
266 }
267
268 fn admission_root() -> PathBuf {
269 if let Some(home) = codewhale_paths::codewhale_home_override().ok().flatten() {
270 return home.join("resource-admission");
271 }
272 if let Some(home) = codewhale_paths::user_home() {
273 return home.join(".codewhale").join("resource-admission");
274 }
275 std::env::temp_dir().join("codewhale-resource-admission")
276 }
277
278 /// Host free-RAM fraction in `0.0..=1.0`, or `None` when it cannot be measured.
279 ///
280 /// Each implementation shells out to a standard, always-present tool so no new
281 /// crate or build-feature dependency is introduced, and every error path returns
282 /// `None` so admission fails open (never blocks on an unmeasurable host). This
283 /// keeps the gate safe on any CI runner, while protecting the macOS dogfood host
284 /// and Linux/Windows machines where the tool exists.
285 #[cfg(target_os = "linux")]
286 fn host_memory_free_fraction() -> Option<f64> {
287 let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
288 let total = parse_meminfo_kb(&meminfo, "MemTotal:")?;
289 let available = parse_meminfo_kb(&meminfo, "MemAvailable:")?;
290 (total > 0).then(|| (available as f64 / total as f64).clamp(0.0, 1.0))
291 }
292
293 #[cfg(target_os = "linux")]
294 fn parse_meminfo_kb(meminfo: &str, prefix: &str) -> Option<u64> {
295 meminfo
296 .lines()
297 .find(|line| line.starts_with(prefix))
298 .and_then(|line| line.split_whitespace().nth(1))
299 .and_then(|value| value.parse::<u64>().ok())
300 }
301
302 #[cfg(target_os = "macos")]
303 fn host_memory_free_fraction() -> Option<f64> {
304 let total = run_capture("/usr/sbin/sysctl", &["-n", "hw.memsize"])
305 .and_then(|bytes| bytes.trim().parse::<u64>().ok())?;
306 let page_size = run_capture("/usr/bin/pagesize", &[])?
307 .trim()
308 .parse::<u64>()
309 .ok()?;
310 let stats = run_capture("/usr/bin/vm_stat", &[])?;
311 let free_pages = memory_pages_from_vm_stat(&stats, &["Pages free:", "Pages inactive:"])?;
312 let free_bytes = free_pages.checked_mul(page_size)?;
313 (total > 0).then(|| (free_bytes as f64 / total as f64).clamp(0.0, 1.0))
314 }
315
316 #[cfg(target_os = "macos")]
317 fn memory_pages_from_vm_stat(vm_stat: &str, prefixes: &[&str]) -> Option<u64> {
318 let mut total = 0u64;
319 for prefix in prefixes {
320 let pages = vm_stat
321 .lines()
322 .find(|line| line.trim_start().starts_with(prefix))
323 .and_then(|line| {
324 line.split('.')
325 .nth(1)
326 .and_then(|rest| rest.trim().parse::<u64>().ok())
327 })?;
328 total = total.checked_add(pages)?;
329 }
330 Some(total)
331 }
332
333 #[cfg(windows)]
334 fn host_memory_free_fraction() -> Option<f64> {
335 // `wmic` is deprecated but present on every supported Windows runner and
336 // avoids adding a GlobalMemoryStatusEx build dependency. Fail open on error.
337 let out = run_capture(
338 "C:\\Windows\\System32\\wbem\\wmic.exe",
339 &[
340 "OS",
341 "get",
342 "FreePhysicalMemory,TotalVisibleMemorySize",
343 "/value",
344 ],
345 )?;
346 let free_kb = wmic_value(&out, "FreePhysicalMemory=")?;
347 let total_kb = wmic_value(&out, "TotalVisibleMemorySize=")?;
348 (total_kb > 0).then(|| (free_kb as f64 / total_kb as f64).clamp(0.0, 1.0))
349 }
350
351 #[cfg(windows)]
352 fn wmic_value(output: &str, key: &str) -> Option<u64> {
353 output
354 .lines()
355 .find_map(|line| line.trim().strip_prefix(key))
356 .and_then(|value| value.trim().parse::<u64>().ok())
357 }
358
359 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
360 fn host_memory_free_fraction() -> Option<f64> {
361 None
362 }
363
364 #[cfg(any(target_os = "macos", target_os = "windows"))]
365 fn run_capture(program: &str, args: &[&str]) -> Option<String> {
366 let output = std::process::Command::new(program)
367 .args(args)
368 .output()
369 .ok()?;
370 if !output.status.success() {
371 return None;
372 }
373 String::from_utf8(output.stdout).ok()
374 }
375
376 #[cfg(test)]
377 mod tests {
378 use std::sync::Arc;
379 use std::sync::atomic::{AtomicUsize, Ordering};
380
381 use super::*;
382
383 /// Deterministic probe returning a fixed fraction so admission behavior is
384 /// independent of the host running the test suite.
385 struct StaticMemoryProbe(Option<f64>);
386 impl MemoryProbe for StaticMemoryProbe {
387 fn free_fraction(&self) -> Option<f64> {
388 self.0
389 }
390 }
391
392 const NOMINAL_PROBE: StaticMemoryProbe = StaticMemoryProbe(Some(0.9));
393
394 #[test]
395 fn whitespace_codewhale_home_uses_shared_user_home_for_admission_state() {
396 let _lock = crate::test_support::lock_test_env();
397 let tmp = tempfile::tempdir().expect("temporary root");
398 let home = tmp.path().join("home");
399 let userprofile = tmp.path().join("userprofile");
400 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
401 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &userprofile);
402 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", " \t ");
403
404 assert_eq!(
405 admission_root(),
406 home.join(".codewhale").join("resource-admission")
407 );
408 }
409
410 #[test]
411 fn memory_pressure_classification_and_effective_limit() {
412 // Unknown (unmeasurable) fails open to the configured limit.
413 assert_eq!(classify_memory_pressure(None), MemoryPressure::Unknown);
414 assert_eq!(effective_admission_limit(2, MemoryPressure::Unknown), 2);
415 assert_eq!(classify_memory_pressure(Some(0.9)), MemoryPressure::Nominal);
416 assert_eq!(
417 classify_memory_pressure(Some(0.30)),
418 MemoryPressure::Constrained
419 );
420 assert_eq!(
421 classify_memory_pressure(Some(0.15)),
422 MemoryPressure::Critical
423 );
424 assert_eq!(
425 classify_memory_pressure(Some(0.0)),
426 MemoryPressure::Critical
427 );
428 assert_eq!(effective_admission_limit(4, MemoryPressure::Nominal), 4);
429 assert_eq!(effective_admission_limit(4, MemoryPressure::Constrained), 2);
430 assert_eq!(effective_admission_limit(1, MemoryPressure::Constrained), 1);
431 assert_eq!(effective_admission_limit(4, MemoryPressure::Critical), 0);
432 }
433
434 #[test]
435 fn infers_only_expensive_rust_compilation_commands() {
436 for command in [
437 "cargo test -p codewhale-tui shell::tests",
438 "env CARGO_BUILD_JOBS=2 cargo build --workspace",
439 "cargo check",
440 "cargo clippy --all-targets",
441 "/usr/bin/rustc src/main.rs",
442 "printf ok && cargo rustc -- --emit=metadata",
443 ] {
444 assert_eq!(
445 infer_command_expense(command),
446 CommandExpense::Heavy,
447 "{command}"
448 );
449 }
450 for command in [
451 "cargo fmt --check",
452 "cargo metadata",
453 "git status",
454 "echo cargo test",
455 ] {
456 assert_eq!(
457 infer_command_expense(command),
458 CommandExpense::Normal,
459 "{command}"
460 );
461 }
462 }
463
464 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
465 async fn cross_task_heavy_admission_never_exceeds_limit() {
466 let temp = tempfile::tempdir().expect("tempdir");
467 let active = Arc::new(AtomicUsize::new(0));
468 let peak = Arc::new(AtomicUsize::new(0));
469 let mut tasks = Vec::new();
470
471 for _ in 0..12 {
472 let root = temp.path().to_path_buf();
473 let active = Arc::clone(&active);
474 let peak = Arc::clone(&peak);
475 tasks.push(tokio::spawn(async move {
476 let _permit = acquire_heavy_command_permit_at(&root, 2, None, &NOMINAL_PROBE)
477 .await
478 .expect("permit");
479 let current = active.fetch_add(1, Ordering::SeqCst) + 1;
480 peak.fetch_max(current, Ordering::SeqCst);
481 tokio::time::sleep(Duration::from_millis(30)).await;
482 active.fetch_sub(1, Ordering::SeqCst);
483 }));
484 }
485 for task in tasks {
486 task.await.expect("admission task");
487 }
488
489 assert_eq!(active.load(Ordering::SeqCst), 0);
490 assert_eq!(peak.load(Ordering::SeqCst), 2);
491 }
492
493 #[tokio::test]
494 async fn queued_admission_observes_cancellation() {
495 let temp = tempfile::tempdir().expect("tempdir");
496 let _held = acquire_heavy_command_permit_at(temp.path(), 1, None, &NOMINAL_PROBE)
497 .await
498 .expect("initial permit");
499 let cancel = CancellationToken::new();
500 let wait_cancel = cancel.clone();
501 let root = temp.path().to_path_buf();
502 let waiter = tokio::spawn(async move {
503 acquire_heavy_command_permit_at(&root, 1, Some(&wait_cancel), &NOMINAL_PROBE).await
504 });
505
506 tokio::time::sleep(Duration::from_millis(75)).await;
507 cancel.cancel();
508 let error = tokio::time::timeout(Duration::from_secs(1), waiter)
509 .await
510 .expect("bounded cancellation")
511 .expect("waiter task")
512 .expect_err("queued command must cancel");
513 assert!(error.to_string().contains("canceled while queued"));
514 }
515
516 #[tokio::test]
517 async fn critical_memory_pressure_queues_without_admitting() {
518 let temp = tempfile::tempdir().expect("tempdir");
519 // Critical pressure -> zero effective slots -> the command cannot be
520 // admitted and must observe cancellation rather than spin forever.
521 let critical = StaticMemoryProbe(Some(0.05));
522 let cancel = CancellationToken::new();
523 let wait_cancel = cancel.clone();
524 let root = temp.path().to_path_buf();
525 let waiter = tokio::spawn(async move {
526 acquire_heavy_command_permit_at(&root, 2, Some(&wait_cancel), &critical).await
527 });
528 tokio::time::sleep(Duration::from_millis(120)).await;
529 cancel.cancel();
530 let error = tokio::time::timeout(Duration::from_secs(2), waiter)
531 .await
532 .expect("bounded cancellation")
533 .expect("waiter task")
534 .expect_err("critical-pressure command must not be admitted");
535 assert!(error.to_string().contains("canceled while queued"));
536 }
537
538 #[test]
539 fn host_memory_probe_is_fail_safe() {
540 // On any supported host the probe either measures a plausible fraction
541 // or admits it cannot; it must never panic or return an out-of-range.
542 if let Some(fraction) = host_memory_free_fraction() {
543 assert!(
544 (0.0..=1.0).contains(&fraction),
545 "measured free fraction out of range: {fraction}"
546 );
547 }
548 }
549
550 /// Opt-in real cargo acceptance (#4864 req 8): drive an actual heavy-class
551 /// cargo invocation through the admission path end to end. `cargo check
552 /// --version` classifies as heavy (subcommand `check`) but exits instantly,
553 /// so this is fast. Gated behind an env var so CI never runs a real cargo.
554 #[cfg(unix)]
555 #[tokio::test]
556 async fn real_cargo_command_is_admitted_and_released() {
557 if std::env::var_os("CODEWHALE_RESOURCE_ADMISSION_RUST_ACCEPTANCE").is_none() {
558 tracing::info!(
559 "skipping opt-in real-rust admission acceptance; \
560 set CODEWHALE_RESOURCE_ADMISSION_RUST_ACCEPTANCE=1 to run"
561 );
562 return;
563 }
564 let temp = tempfile::tempdir().expect("tempdir");
565 let permit = acquire_heavy_command_permit_at(temp.path(), 2, None, &NOMINAL_PROBE)
566 .await
567 .expect("heavy permit for real cargo");
568 assert_eq!(permit.limit(), 2);
569 assert_eq!(permit.memory_pressure(), MemoryPressure::Nominal);
570 let cargo = std::process::Command::new("cargo")
571 .arg("check")
572 .arg("--version")
573 .output()
574 .expect("run cargo");
575 assert!(cargo.status.success(), "cargo check --version failed");
576 drop(permit);
577 let _again = acquire_heavy_command_permit_at(temp.path(), 2, None, &NOMINAL_PROBE)
578 .await
579 .expect("re-acquire after release");
580 }
581 }
582
582 lines RUST