grok-build-upstream-mirror/crates/codegen/xai-grok-shell/tests/test_leader_soak.rs
grokkybara[bot] dd04f397b1 Synced from monorepo
Synced from monorepo

Changes:
- Temporarily disable session share link creation in the TUI
- Do not approve plan on empty Enter from the revise prompt
- Expose chat product Skills via ACP available_commands_update
- Return immediately from a blocking wait on an already-completed ACP task
- Split headless pager module for clearer structure
- Stop git worktree prune from removing user registrations on resume
- Use compaction sampler tokenizer for item token counts
- Opt-in extra root CAs via GROK_EXTRA_CA_BUNDLE
- Cancel all session subagents when the user stops
- Let the session persistence actor exit when its session ends
- Make fullscreen terminal resize much cheaper on long sessions
- Report honestly from kill_task when an ACP task does not exist
- Hide /usage for external-auth deployments
- Forward the history-load trailer’s computer_reason to the client
- Remove ineffective no-op tool reminder
- Declare slash-command screen-mode support in one place
- Keep settings enum picker on the committed value until Enter
- Reap a PTY’s full process tree
- Stream tool calls from headless mode over ACP
- Bridge gateway task lifecycle to ACP for chat session background tasks
- Don’t warn about truncated history on a suppressed replay
- Fit full-replace summarizer input and recover on context-length errors
- Stop dropping agents over an unrecognized frontmatter color
- Add /undo as a slash alias for /rewind
- Harden sleep/wake token-refresh paths against forced re-login
- Add session/list ACP method
- Give each sampling backend its own conversion module
- Treat an unenrolled child process as a lint error
- Suppress the cancelled marker on send-now wake turns
- Stop tearing down Roslyn on every edit, and read C# diagnostics

Source-Revision: 2a28b4a86cfc4a4c133c35b7fc2a6a9964387c39
2026-07-30 19:07:40 +00:00

407 lines
17 KiB
Rust

//! Leader soak: a real `MvpAgent` behind an in-process leader, churned by
//! clients until `LEADER_SOAK_SECS` expires. Each cycle closes its sessions,
//! so the bounds measure what teardown reclaims.
//!
//! ```bash
//! LEADER_SOAK_SECS=1200 cargo test -p xai-grok-shell --features test-support \
//! --test test_leader_soak -- --ignored --nocapture
//! ```
#![cfg(unix)]
#[cfg(feature = "dhat-heap")]
#[global_allocator]
static DHAT_ALLOC: dhat::Alloc = dhat::Alloc;
/// Warmup so the window measures steady state, not first-session cost.
#[cfg(feature = "dhat-heap")]
const HEAP_WARMUP_CYCLES: u64 = 2;
use std::sync::Arc;
use std::time::Duration;
use tempfile::TempDir;
use tokio_util::sync::CancellationToken;
use xai_grok_shell::leader::{
ClientCapabilities, ClientMode, LeaderClient, LeaderServerControlState, LeaderServerMetadata,
run_leader_server,
};
use xai_grok_test_support::resources::ResourceSnapshot;
fn env_u64(key: &str, default: u64) -> u64 {
std::env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
/// `leader.response.send_failed` entries written by THIS process.
fn send_failed_count() -> usize {
let Some(bytes) = xai_grok_telemetry::unified_log::snapshot_log() else {
return 0;
};
String::from_utf8_lossy(&bytes)
.lines()
.filter(|line| {
serde_json::from_str::<serde_json::Value>(line).is_ok_and(|entry| {
entry["msg"] == "leader.response.send_failed" && entry["pid"] == std::process::id()
})
})
.count()
}
/// Send one JSON-RPC request through a `LeaderClient` and await the response
/// with the matching id, skipping interleaved notifications.
async fn rpc(client: &mut LeaderClient, payload: String, id: u64, what: &str) -> serde_json::Value {
client
.send(payload)
.unwrap_or_else(|e| panic!("{what}: send failed: {e}"));
let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
loop {
let remaining = deadline
.checked_duration_since(tokio::time::Instant::now())
.unwrap_or_else(|| panic!("{what}: timed out waiting for response id {id}"));
let msg = tokio::time::timeout(remaining, client.recv())
.await
.unwrap_or_else(|_| panic!("{what}: timed out waiting for response id {id}"))
.unwrap_or_else(|| panic!("{what}: connection closed awaiting response id {id}"));
let json: serde_json::Value = match serde_json::from_str(&msg) {
Ok(v) => v,
Err(_) => continue,
};
if json["id"] == id && (json.get("result").is_some() || json.get("error").is_some()) {
assert!(
json.get("error").is_none(),
"{what}: error response: {json}"
);
return json;
}
}
}
/// Per-registry entry counts. `x.ai/debug/agent` answers under the extension
/// envelope's own `result`, nested inside the JSON-RPC `result`.
async fn registry_counts(client: &mut LeaderClient, id: u64) -> serde_json::Value {
let resp = rpc(
client,
format!(r#"{{"jsonrpc":"2.0","id":{id},"method":"_x.ai/debug/agent","params":{{}}}}"#),
id,
"x.ai/debug/agent",
)
.await;
let counts = resp["result"]["result"]["registries"].clone();
assert!(
counts.is_object(),
"x.ai/debug/agent returned no registries: {resp}"
);
counts
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "leader soak; run with --ignored (LEADER_SOAK_SECS bounds the duration)"]
async fn leader_soak_churning_clients_no_leaks_no_zombies() {
let _ = rustls::crypto::ring::default_provider().install_default();
let server = xai_grok_test_support::MockInferenceServer::start()
.await
.unwrap();
let grok_home = TempDir::new().unwrap();
let workdir = TempDir::new().unwrap();
// SAFETY: single-threaded current-thread runtime; set before any agent
// code reads these process-globals (same pattern as session_load_perf).
unsafe {
std::env::set_var("GROK_HOME", grok_home.path());
std::env::set_var("GROK_CLI_CHAT_PROXY_BASE_URL", server.url());
std::env::set_var("GROK_XAI_API_BASE_URL", server.url());
std::env::set_var("XAI_API_KEY", "test-key-for-ci");
std::env::set_var("GROK_TELEMETRY_ENABLED", "false");
std::env::set_var("GROK_FEEDBACK_ENABLED", "false");
std::env::set_var("GROK_TRACE_UPLOAD", "false");
}
let sock_path = grok_home.path().join("leader-soak.sock");
let soak_secs = env_u64("LEADER_SOAK_SECS", 10);
let max_growth_mb = env_u64("LEADER_SOAK_MAX_RSS_GROWTH_MB", 1024);
let max_thread_growth = env_u64("LEADER_SOAK_MAX_THREAD_GROWTH", 64) as usize;
let send_failed_before = send_failed_count();
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (acp_tx, acp_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let (response_tx, response_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let cancel = CancellationToken::new();
let client_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let control_state = LeaderServerControlState::new(LeaderServerMetadata {
pid: std::process::id(),
socket_path: sock_path.clone(),
lock_path: sock_path.with_extension("lock"),
ws_url_suffix: String::new(),
leader_binary_version: env!("CARGO_PKG_VERSION").to_string(),
});
let cancel_for_server = cancel.clone();
let sock_for_server = sock_path.clone();
let client_count_for_server = client_count.clone();
tokio::task::spawn_local(async move {
let _ = run_leader_server(
sock_for_server,
acp_tx,
response_rx,
cancel_for_server,
true,
client_count_for_server,
Arc::new(std::sync::atomic::AtomicBool::new(false)),
xai_grok_shell::agent::activity::AgentActivity::default(),
tokio::sync::watch::channel(true).1,
tokio::sync::watch::channel(false).0,
tokio::sync::watch::channel(xai_grok_shell::leader::ShutdownReason::Manual).0,
None,
control_state,
)
.await;
});
// Hold a sender for the whole soak: the leader's response channel
// must not close when the agent's output ends.
xai_grok_shell::leader::in_process::spawn_agent(acp_rx, response_tx.clone());
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
while !sock_path.exists() && tokio::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(sock_path.exists(), "leader socket never bound");
let mut bootstrap = LeaderClient::connect(
sock_path.clone(),
"soak-bootstrap",
ClientMode::Stdio,
ClientCapabilities::default(),
)
.await
.expect("bootstrap connect");
rpc(
&mut bootstrap,
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false},"_meta":{"startupHints":{"nonInteractive":true,"skipGitStatus":true,"skipProjectLayout":true},"clientType":"soak","clientVersion":"0.0.0-test"}}}"#.to_string(),
1,
"initialize",
)
.await;
rpc(
&mut bootstrap,
r#"{"jsonrpc":"2.0","id":2,"method":"authenticate","params":{"methodId":"xai.api_key","_meta":{"headless":true}}}"#.to_string(),
2,
"authenticate",
)
.await;
eprintln!(
"[soak] budgets: {soak_secs}s, rss {max_growth_mb} MB, threads {max_thread_growth}"
);
#[cfg(feature = "dhat-heap")]
let mut heap_window: Option<(dhat::Profiler, dhat::HeapStats, u64)> = None;
let rss_before = ResourceSnapshot::capture();
let soak_deadline = tokio::time::Instant::now() + Duration::from_secs(soak_secs);
let workdir_str = workdir.path().to_string_lossy().to_string();
let mut cycles: u64 = 0;
let mut turns: u64 = 0;
let mut baseline: Option<serde_json::Value> = None;
// Each cycle: 10 fresh clients, 2 sessions each, one scripted
// turn per session, then all disconnect.
while tokio::time::Instant::now() < soak_deadline {
cycles += 1;
let mut clients = Vec::new();
for i in 0..10u64 {
let client = LeaderClient::connect(
sock_path.clone(),
"soak-client",
ClientMode::Stdio,
ClientCapabilities::default(),
)
.await
.unwrap_or_else(|e| panic!("cycle {cycles} client {i} connect: {e}"));
clients.push(client);
}
for (i, client) in clients.iter_mut().enumerate() {
for s in 0..2u64 {
let new_id = 100 + s;
let resp = rpc(
client,
format!(
r#"{{"jsonrpc":"2.0","id":{new_id},"method":"session/new","params":{{"cwd":"{workdir_str}","mcpServers":[]}}}}"#
),
new_id,
"session/new",
)
.await;
let sid = resp["result"]["sessionId"]
.as_str()
.unwrap_or_else(|| panic!("no sessionId in {resp}"))
.to_string();
let prompt_id = 200 + s;
rpc(
client,
format!(
r#"{{"jsonrpc":"2.0","id":{prompt_id},"method":"session/prompt","params":{{"sessionId":"{sid}","prompt":[{{"type":"text","text":"soak c{i} s{s} cycle {cycles}"}}]}}}}"#
),
prompt_id,
"session/prompt",
)
.await;
turns += 1;
// Disconnecting leaves sessions resident for a
// reconnect; `_` is the wire form for a custom method.
let close_id = 300 + s;
rpc(
client,
format!(
r#"{{"jsonrpc":"2.0","id":{close_id},"method":"_x.ai/session/close","params":{{"sessionId":"{sid}"}}}}"#
),
close_id,
"x.ai/session/close",
)
.await;
}
}
for client in clients {
client.cancel();
}
let drain_deadline = tokio::time::Instant::now() + Duration::from_secs(30);
while client_count.load(std::sync::atomic::Ordering::Relaxed) > 1 {
assert!(
tokio::time::Instant::now() < drain_deadline,
"cycle {cycles}: roster kept {} zombie clients after churn",
client_count.load(std::sync::atomic::Ordering::Relaxed)
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
// An entry that never drains names itself here, one cycle
// after it leaks, while memory is still within its budget.
let counts = registry_counts(&mut bootstrap, 1000 + cycles).await;
assert_eq!(
counts["sessions"], 0,
"cycle {cycles}: sessions outlived their close: {counts}"
);
match baseline.as_ref() {
None => baseline = Some(counts),
Some(first) => assert_eq!(
&counts, first,
"cycle {cycles}: registry counts left their baseline"
),
}
#[cfg(feature = "dhat-heap")]
if cycles == HEAP_WARMUP_CYCLES {
let profiler = dhat::Profiler::builder()
// The 10-frame default never reaches our own code.
.trim_backtraces(Some(48))
.file_name(
std::env::var("LEADER_SOAK_DHAT_OUT")
.unwrap_or_else(|_| "dhat-leader-soak.json".to_string()),
)
.build();
heap_window = Some((profiler, dhat::HeapStats::get(), cycles));
}
// Linear in cycles is a leak; flattening is the allocator.
if let Some(rss) = ResourceSnapshot::capture().rss {
eprintln!(
"[soak] cycle {cycles}: rss {:.1} MB",
rss as f64 / (1024.0 * 1024.0)
);
}
}
// Retained heap is a leak; retained pages alone are the allocator.
#[cfg(feature = "dhat-heap")]
if let Some((profiler, before, start_cycle)) = heap_window.take() {
let after = dhat::HeapStats::get();
drop(profiler);
let measured = cycles.saturating_sub(start_cycle).max(1);
let net_bytes = after.curr_bytes as i64 - before.curr_bytes as i64;
let net_blocks = after.curr_blocks as i64 - before.curr_blocks as i64;
let per_cycle = net_bytes / measured as i64;
eprintln!(
"[soak] heap over {measured} cycles: {net_bytes} bytes, {net_blocks} blocks \
({:.2} MB per cycle)",
net_bytes as f64 / measured as f64 / (1024.0 * 1024.0)
);
let max_per_cycle = env_u64("LEADER_SOAK_MAX_HEAP_BYTES_PER_CYCLE", 4 << 20) as i64;
assert!(
per_cycle <= max_per_cycle,
"leader retained {per_cycle} heap bytes per cycle (bound {max_per_cycle})"
);
}
eprintln!("[soak] {cycles} cycles, {turns} turns in {soak_secs}s budget");
assert!(cycles > 0, "soak budget too small to complete one cycle");
assert_eq!(
client_count.load(std::sync::atomic::Ordering::Relaxed),
1,
"roster must converge to the bootstrap client after churn"
);
let resp = rpc(
&mut bootstrap,
format!(
r#"{{"jsonrpc":"2.0","id":900,"method":"session/new","params":{{"cwd":"{workdir_str}","mcpServers":[]}}}}"#
),
900,
"post-soak session/new",
)
.await;
assert!(resp["result"]["sessionId"].is_string());
assert_eq!(
send_failed_count(),
send_failed_before,
"leader.response.send_failed must not occur during the soak"
);
let rss_after = ResourceSnapshot::capture();
let growth = rss_after.growth_from(&rss_before);
if let (Some(before), Some(after), Some(growth_bytes)) =
(rss_before.rss, rss_after.rss, growth.rss)
{
let growth_mb = growth_bytes as f64 / (1024.0 * 1024.0);
eprintln!(
"[soak] rss: {:.1} MB -> {:.1} MB (growth {growth_mb:.1} MB)",
before as f64 / (1024.0 * 1024.0),
after as f64 / (1024.0 * 1024.0),
);
assert!(
growth_mb <= max_growth_mb as f64,
"leader RSS grew {growth_mb:.1} MB over the soak (bound {max_growth_mb} MB)"
);
} else {
panic!("memory sample unavailable; the soak cannot bound it");
}
// A missing sample means the probe failed, which would silently
// retire the nightly budget. Threads are Linux-only.
match growth.threads {
Some(thread_growth) => {
eprintln!("[soak] threads: growth {thread_growth}");
assert!(
thread_growth <= max_thread_growth,
"leader threads grew by {thread_growth} over the soak \
(bound {max_thread_growth})"
);
}
None if cfg!(target_os = "linux") => {
panic!("thread growth sample unavailable; the soak cannot bound it")
}
None => {}
}
bootstrap.cancel();
cancel.cancel();
})
.await;
}