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
407 lines
17 KiB
Rust
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;
|
|
}
|