Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
405 lines
13 KiB
Rust
405 lines
13 KiB
Rust
//! Integration test for cgroup memory-high OOM handling.
|
||
//!
|
||
//! **Must be run on Linux with cgroupv2** and sufficient permissions to create
|
||
//! child cgroups (typically root, or a user-session cgroup with delegation).
|
||
//!
|
||
//! Run with:
|
||
//! ```bash
|
||
//! # On a Linux machine (as root or with cgroup delegation):
|
||
//! cargo test -p kigi-tools --test cgroup_memory_test -- --ignored --nocapture
|
||
//!
|
||
//! # If you need root:
|
||
//! sudo -E cargo test -p kigi-tools --test cgroup_memory_test -- --ignored --nocapture
|
||
//! ```
|
||
//!
|
||
//! The cgroup-dependent tests (1–5) are `#[ignore]`d by default so they don't
|
||
//! run in CI where cgroup delegation is typically unavailable. Test 6 (no-config)
|
||
//! always runs.
|
||
//!
|
||
//! The tests exercise:
|
||
//! 1. A command that stays under the memory limit → exits normally (exit 0)
|
||
//! 2. A command that exceeds memory.high → killed with exit 137, signal "oom"
|
||
//! 3. The session (backend) survives an OOM and can run another command after
|
||
//! 4. Background tasks are also killed on OOM
|
||
//! 5. A gradual allocator that slowly ramps up past the limit
|
||
|
||
use std::collections::HashMap;
|
||
use std::path::PathBuf;
|
||
use std::time::Duration;
|
||
|
||
use kigi_tools::computer::local::LocalTerminalBackend;
|
||
use kigi_tools::computer::local::cgroup::{CgroupMemoryConfig, PROCESS_OOM_EXIT_CODE};
|
||
use kigi_tools::computer::types::{TerminalBackend, TerminalRunRequest, TerminalRunResult};
|
||
use kigi_tools::notification::types::ToolNotificationHandle;
|
||
|
||
/// Small memory limit for testing: 32 MiB high, 32 MiB headroom (64 MiB hard max).
|
||
fn test_memory_config() -> CgroupMemoryConfig {
|
||
CgroupMemoryConfig {
|
||
memory_high_bytes: 32 * 1024 * 1024,
|
||
headroom_bytes: 32 * 1024 * 1024,
|
||
}
|
||
}
|
||
|
||
fn make_request(command: &str, timeout_secs: u64) -> TerminalRunRequest {
|
||
let output_file = std::env::temp_dir().join(format!(
|
||
"cgroup-test-{}-{}.out",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_nanos()
|
||
));
|
||
|
||
TerminalRunRequest {
|
||
command: command.to_string(),
|
||
working_directory: PathBuf::from("/tmp"),
|
||
env: HashMap::new(),
|
||
timeout: Duration::from_secs(timeout_secs),
|
||
output_byte_limit: 1024 * 1024,
|
||
output_file,
|
||
notification_handle: ToolNotificationHandle::noop(),
|
||
tool_call_id: format!("cgroup-test-{}", uuid::Uuid::now_v7()),
|
||
display_command: None,
|
||
auto_background_on_timeout: false,
|
||
foreground_block_budget: None,
|
||
kind: Default::default(),
|
||
owner_session_id: None,
|
||
}
|
||
}
|
||
|
||
fn is_linux_with_cgroupv2() -> bool {
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
std::path::Path::new("/sys/fs/cgroup/cgroup.controllers").exists()
|
||
}
|
||
#[cfg(not(target_os = "linux"))]
|
||
{
|
||
false
|
||
}
|
||
}
|
||
|
||
fn can_create_cgroups() -> bool {
|
||
if !is_linux_with_cgroupv2() {
|
||
return false;
|
||
}
|
||
// Try reading our own cgroup path — if this works, we can probably create children
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
if let Ok(contents) = std::fs::read_to_string("/proc/self/cgroup") {
|
||
for line in contents.lines() {
|
||
if let Some(path) = line.strip_prefix("0::") {
|
||
let cgroup_dir = std::path::PathBuf::from(format!("/sys/fs/cgroup{}", path));
|
||
let subtree = cgroup_dir.join("cgroup.subtree_control");
|
||
return subtree.exists();
|
||
}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
#[cfg(not(target_os = "linux"))]
|
||
{
|
||
false
|
||
}
|
||
}
|
||
|
||
fn skip_unless_cgroup() {
|
||
if !can_create_cgroups() {
|
||
eprintln!(
|
||
"\n╔══════════════════════════════════════════════════════════════╗\n\
|
||
║ SKIPPED: cgroupv2 not available or insufficient perms. ║\n\
|
||
║ Run on Linux as root or with cgroup delegation. ║\n\
|
||
╚══════════════════════════════════════════════════════════════╝\n"
|
||
);
|
||
}
|
||
}
|
||
|
||
fn print_result(label: &str, result: &TerminalRunResult) {
|
||
let output_preview = if result.combined_output.len() > 200 {
|
||
format!("{}…", &result.combined_output[..200])
|
||
} else {
|
||
result.combined_output.clone()
|
||
};
|
||
eprintln!(
|
||
"\n── {label} ──\n exit_code: {:?}\n signal: {:?}\n timed_out: {}\n truncated: {}\n output: {:?}\n",
|
||
result.exit_code,
|
||
result.signal,
|
||
result.timed_out,
|
||
result.truncated,
|
||
output_preview.trim(),
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Linux cgroupv2 with delegation — run with: cargo test --test cgroup_memory_test -- --ignored --nocapture"]
|
||
async fn test_under_limit_exits_normally() {
|
||
skip_unless_cgroup();
|
||
if !can_create_cgroups() {
|
||
return;
|
||
}
|
||
|
||
eprintln!("\n=== Test: under_limit_exits_normally ===");
|
||
let backend = LocalTerminalBackend::with_memory_limit(test_memory_config());
|
||
// Give actor time to initialize cgroup
|
||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||
|
||
let result = backend
|
||
.run(make_request(
|
||
"echo 'hello from cgroup'; cat /proc/self/cgroup",
|
||
10,
|
||
))
|
||
.await
|
||
.expect("command should succeed");
|
||
|
||
print_result("Under limit", &result);
|
||
|
||
assert_eq!(result.exit_code, Some(0), "Expected exit code 0");
|
||
assert!(
|
||
result.combined_output.contains("hello from cgroup"),
|
||
"Output should contain our echo"
|
||
);
|
||
assert_ne!(
|
||
result.signal.as_deref(),
|
||
Some("oom"),
|
||
"Should NOT be OOM-killed"
|
||
);
|
||
|
||
eprintln!("✅ PASSED: under_limit_exits_normally");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Linux cgroupv2 with delegation"]
|
||
async fn test_over_limit_gets_oom_killed() {
|
||
skip_unless_cgroup();
|
||
if !can_create_cgroups() {
|
||
return;
|
||
}
|
||
|
||
eprintln!("\n=== Test: over_limit_gets_oom_killed ===");
|
||
let backend = LocalTerminalBackend::with_memory_limit(test_memory_config());
|
||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||
|
||
// Allocate 128 MiB in Python — well above the 32 MiB high / 64 MiB max limits.
|
||
let alloc_cmd = r#"python3 -c "
|
||
import sys
|
||
print('Allocating 128 MiB...', flush=True)
|
||
data = bytearray(128 * 1024 * 1024)
|
||
print('Allocation succeeded (should not reach here)', flush=True)
|
||
""#;
|
||
|
||
let result = backend
|
||
.run(make_request(alloc_cmd, 30))
|
||
.await
|
||
.expect("command should return a result (even if killed)");
|
||
|
||
print_result("Over limit", &result);
|
||
|
||
// The process should be killed — either by our monitor (exit 137 + signal "oom")
|
||
// or by the kernel hard OOM killer (exit 137 / signal 9).
|
||
let killed_by_memory = result.exit_code == Some(PROCESS_OOM_EXIT_CODE)
|
||
|| result.signal.as_deref() == Some("oom")
|
||
|| result
|
||
.signal
|
||
.as_ref()
|
||
.is_some_and(|s| s.contains("signal 9"));
|
||
|
||
assert!(
|
||
killed_by_memory,
|
||
"Expected OOM kill (exit 137 or signal 9/oom), got exit_code={:?} signal={:?}",
|
||
result.exit_code, result.signal
|
||
);
|
||
|
||
assert!(
|
||
result.combined_output.contains("Allocating 128 MiB"),
|
||
"Output before OOM should be captured"
|
||
);
|
||
|
||
eprintln!("✅ PASSED: over_limit_gets_oom_killed");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Linux cgroupv2 with delegation"]
|
||
async fn test_session_survives_oom() {
|
||
skip_unless_cgroup();
|
||
if !can_create_cgroups() {
|
||
return;
|
||
}
|
||
|
||
eprintln!("\n=== Test: session_survives_oom ===");
|
||
let backend = LocalTerminalBackend::with_memory_limit(test_memory_config());
|
||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||
|
||
let oom_cmd = r#"python3 -c "data = bytearray(128 * 1024 * 1024)""#;
|
||
let oom_result = backend
|
||
.run(make_request(oom_cmd, 30))
|
||
.await
|
||
.expect("should return result even on OOM");
|
||
|
||
print_result("OOM command", &oom_result);
|
||
|
||
// Small delay so cgroup memory is reclaimed
|
||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||
|
||
let ok_result = backend
|
||
.run(make_request("echo 'alive after OOM'", 10))
|
||
.await
|
||
.expect("post-OOM command should succeed");
|
||
|
||
print_result("After OOM", &ok_result);
|
||
|
||
assert_eq!(
|
||
ok_result.exit_code,
|
||
Some(0),
|
||
"Post-OOM command should exit 0"
|
||
);
|
||
assert!(
|
||
ok_result.combined_output.contains("alive after OOM"),
|
||
"Post-OOM output should contain our echo"
|
||
);
|
||
|
||
eprintln!("✅ PASSED: session_survives_oom");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Linux cgroupv2 with delegation"]
|
||
async fn test_background_task_oom() {
|
||
skip_unless_cgroup();
|
||
if !can_create_cgroups() {
|
||
return;
|
||
}
|
||
|
||
eprintln!("\n=== Test: background_task_oom ===");
|
||
let backend = LocalTerminalBackend::with_memory_limit(test_memory_config());
|
||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||
|
||
let alloc_cmd = r#"python3 -c "
|
||
import time
|
||
print('BG: allocating...', flush=True)
|
||
time.sleep(0.5)
|
||
data = bytearray(128 * 1024 * 1024)
|
||
print('BG: done (should not reach here)', flush=True)
|
||
time.sleep(60)
|
||
""#;
|
||
|
||
let handle = backend
|
||
.run_background(make_request(alloc_cmd, 60))
|
||
.await
|
||
.expect("background spawn should succeed");
|
||
|
||
eprintln!(" Background task_id: {}", handle.task_id);
|
||
|
||
let snapshot = backend
|
||
.wait_for_completion(&handle.task_id, Some(Duration::from_secs(30)))
|
||
.await;
|
||
|
||
if let Some(snap) = &snapshot {
|
||
eprintln!(
|
||
" BG result: completed={} exit_code={:?} signal={:?} output={:?}",
|
||
snap.completed,
|
||
snap.exit_code,
|
||
snap.signal,
|
||
&snap.output[..snap.output.len().min(200)]
|
||
);
|
||
|
||
assert!(snap.completed, "Background task should have completed");
|
||
let killed_by_memory = snap.exit_code == Some(PROCESS_OOM_EXIT_CODE)
|
||
|| snap.signal.as_deref() == Some("oom")
|
||
|| snap.signal.as_ref().is_some_and(|s| s.contains("signal 9"));
|
||
assert!(
|
||
killed_by_memory,
|
||
"Background task should be OOM-killed, got exit_code={:?} signal={:?}",
|
||
snap.exit_code, snap.signal
|
||
);
|
||
} else {
|
||
panic!("Expected a snapshot for the background task");
|
||
}
|
||
|
||
eprintln!("✅ PASSED: background_task_oom");
|
||
}
|
||
|
||
/// Slow ramp past the limit exercises the inotify monitor catching the
|
||
/// memory.high event rather than the kernel's hard memory.max kill.
|
||
#[tokio::test]
|
||
#[ignore = "requires Linux cgroupv2 with delegation"]
|
||
async fn test_gradual_allocation_oom() {
|
||
skip_unless_cgroup();
|
||
if !can_create_cgroups() {
|
||
return;
|
||
}
|
||
|
||
eprintln!("\n=== Test: gradual_allocation_oom ===");
|
||
let backend = LocalTerminalBackend::with_memory_limit(test_memory_config());
|
||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||
|
||
// Allocate in 1 MiB chunks with a small delay — slowly ramps past 32 MiB.
|
||
let gradual_cmd = r#"python3 -c "
|
||
import time, sys
|
||
chunks = []
|
||
for i in range(128):
|
||
chunks.append(bytearray(1024 * 1024)) # 1 MiB per chunk
|
||
print(f'Allocated {i+1} MiB', flush=True)
|
||
time.sleep(0.05)
|
||
print('Finished all allocations (should not reach here)', flush=True)
|
||
""#;
|
||
|
||
let result = backend
|
||
.run(make_request(gradual_cmd, 30))
|
||
.await
|
||
.expect("should return result");
|
||
|
||
print_result("Gradual allocation", &result);
|
||
|
||
let killed_by_memory = result.exit_code == Some(PROCESS_OOM_EXIT_CODE)
|
||
|| result.signal.as_deref() == Some("oom")
|
||
|| result
|
||
.signal
|
||
.as_ref()
|
||
.is_some_and(|s| s.contains("signal 9"));
|
||
|
||
assert!(
|
||
killed_by_memory,
|
||
"Expected OOM kill for gradual allocator, got exit_code={:?} signal={:?}",
|
||
result.exit_code, result.signal
|
||
);
|
||
|
||
assert!(
|
||
result.combined_output.contains("Allocated"),
|
||
"Should see some allocation progress before kill"
|
||
);
|
||
|
||
assert!(
|
||
!result.combined_output.contains("Finished all allocations"),
|
||
"Should have been killed before finishing"
|
||
);
|
||
|
||
eprintln!("✅ PASSED: gradual_allocation_oom");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn test_no_config_no_enforcement() {
|
||
eprintln!("\n=== Test: no_config_no_enforcement ===");
|
||
|
||
let backend = LocalTerminalBackend::new();
|
||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||
|
||
// Allocate 64 MiB — would be killed with a 32 MiB limit, but should succeed here
|
||
let alloc_cmd = r#"python3 -c "
|
||
data = bytearray(64 * 1024 * 1024)
|
||
print('Allocated 64 MiB without limits')
|
||
""#;
|
||
|
||
let result = backend
|
||
.run(make_request(alloc_cmd, 10))
|
||
.await
|
||
.expect("command should succeed without limits");
|
||
|
||
print_result("No enforcement", &result);
|
||
|
||
assert_eq!(result.exit_code, Some(0), "Should exit 0 without limits");
|
||
assert!(
|
||
result.combined_output.contains("Allocated 64 MiB"),
|
||
"Allocation should succeed without limits"
|
||
);
|
||
|
||
eprintln!("✅ PASSED: no_config_no_enforcement");
|
||
}
|