grok-build-upstream-mirror/crates/codegen/xai-grok-test-support/src/leader.rs
grokkybara[bot] a5727c5960 Synced from monorepo
Changes:
- Non-blocking coding-data sharing upsell banner
- Consolidate remediation in Doctor
- Auto mode defers fail-closed gate asks to the classifier
- Coalesce marketplace list fetches
- Allow removing a marketplace source by name
- Contain hung git marketplace sources (timeouts, non-blocking refresh, unbrick modal)
- Label failed workspace RPCs with error_kind
- Drop redundant explicit tonic/prost deps from xai-grok-shell
- Report real exit codes for completed background shells
- Narrow the date-rollover reminder to date-bearing templates
- Wire toolOverrides through the session and agent
- Security: Bash(git:*) allowlist matches whole command chain by prefix
- Split prompt-trigger telemetry and record classifier provenance
- Raise connectors-manager timeout to 60s
- Auto classifier honors recorded approvals for repeat actions
- Apply doctor fixes in the TUI
- Auto-mode classifier timeouts prompt instead of silently denying
- Scope subagent completion drains to the owning session
- Add the toolOverrides wire types
- Set client_identifier=grok-agent-sdk
- Accept both spellings of the workspace-teleport kill switch
- Persist one-shot occurrence journal
- Stop turns that poll the exact same tool call 16x in a row
- Copy compaction checkpoint files when forking sessions
- Auto-focus permission prompt from scrollback
- Esc cancels the running turn in non-vim and minimal modes
- List Ctrl+Z undo and redo in keyboard shortcuts
- Out-of-process macOS mic capture
- Show active auth mode on session-info
- Install the npm binary under $GROK_HOME
- Remove hover/click dead zones between dashboard items
- Route startup warnings to doctor
- Document [feedback.user] author identity config
- Extend bang command timeout
- Close combine-queued edit-hold race
- Integrate relocation recovery
- Expose privacy notice rollout flag
- Break harness discovery ref cycle so connections can idle-evict
- Shift/Alt+Enter inserts newline when editing a queued prompt
- Gate project Claude permissions on folder trust
- Echo response.create.event_id on response.created
- Toast when session creation fails from disk full
- Add shared test process lifecycle
- Enable dynamic workflows by default
- Add relocation transaction state machine
- Add shared test sandbox
- Surface auth failures on model-switch compact
- Persist durable scheduler expiry
- Confirm before removing extensions-modal items
- Re-run compact and prompt after login when compact hit expired auth
- Recap sends hosted tools under backend search
2026-07-22 19:22:27 +01:00

920 lines
31 KiB
Rust

//! Leader-mode (`grok agent --leader stdio`) test harness.
//!
//! The fixture owns only subprocess handles it created: one initial persistent
//! leader and each returned stdio client. Lock-file PIDs are observations only;
//! detached replacement generations are never adopted or signaled.
use std::io::{self, ErrorKind};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex, Weak};
use std::time::Duration;
use agent_client_protocol::{self as acp, Agent as _};
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use xai_acp_lib::LineBufferedRead;
use crate::env::grok_binary;
use crate::mock_server::MockInferenceServer;
use crate::process::{TestOutput, TestProcess, TestProcessConfig, TestProcessTree, TestStdin};
use crate::sandbox::TestSandbox;
/// Env var naming the binary that elects/hosts the leader in a two-binary
/// (version-skew) test. Falls back to [`grok_binary`]'s resolution.
pub const LEADER_BINARY_ENV: &str = "GROK_BINARY_LEADER";
/// Env var naming the binary for the second (usually newer) client in a
/// two-binary test. Falls back to [`grok_binary`]'s resolution.
pub const CLIENT_BINARY_ENV: &str = "GROK_BINARY_CLIENT";
fn role_binary(env_key: &str) -> PathBuf {
if let Ok(path) = std::env::var(env_key) {
let path = PathBuf::from(path);
assert!(
path.exists(),
"{env_key} does not exist: {}",
path.display()
);
return path;
}
grok_binary()
}
/// Binary for the leader-electing side of a version-skew test.
pub fn leader_binary() -> PathBuf {
role_binary(LEADER_BINARY_ENV)
}
/// Binary for the client side of a version-skew test.
pub fn client_binary() -> PathBuf {
role_binary(CLIENT_BINARY_ENV)
}
/// Capture for notifications and reconnect signals.
#[derive(Default)]
pub struct Capture {
chunks: std::sync::Mutex<Vec<String>>,
notification_count: AtomicU32,
reconnected_count: AtomicU32,
}
struct LeaderAcpClient {
capture: Arc<Capture>,
}
#[async_trait::async_trait(?Send)]
impl acp::Client for LeaderAcpClient {
async fn request_permission(
&self,
args: acp::RequestPermissionRequest,
) -> acp::Result<acp::RequestPermissionResponse> {
let outcome = args
.options
.iter()
.find(|option| option.kind == acp::PermissionOptionKind::AllowOnce)
.or(args.options.first())
.map(|option| {
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(
option.option_id.clone(),
))
})
.unwrap_or(acp::RequestPermissionOutcome::Cancelled);
Ok(acp::RequestPermissionResponse::new(outcome))
}
async fn session_notification(&self, args: acp::SessionNotification) -> acp::Result<()> {
self.capture
.notification_count
.fetch_add(1, Ordering::SeqCst);
if let acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { content, .. }) =
args.update
&& let acp::ContentBlock::Text(text) = content
{
self.capture.chunks.lock().unwrap().push(text.text);
}
Ok(())
}
async fn ext_notification(&self, args: acp::ExtNotification) -> acp::Result<()> {
if &*args.method == "x.ai/leader_reconnected" {
self.capture
.reconnected_count
.fetch_add(1, Ordering::SeqCst);
}
Ok(())
}
}
/// Owns the concrete initial persistent leader shared by a test's clients.
///
/// Production-created replacement generations are outside this fixture's
/// ownership. [`Self::wait_for_new_leader`] may observe one for assertions but
/// never turns its lock-file PID into signal authority.
pub struct LeaderFixture {
inner: Arc<Mutex<LeaderFixtureState>>,
}
struct LeaderFixtureState {
binary: PathBuf,
socket: PathBuf,
lock: PathBuf,
active_clients: usize,
leader: Option<PersistentLeader>,
}
struct PersistentLeader {
child: std::process::Child,
tree: TestProcessTree,
pid: u32,
}
struct FixtureClientRegistration {
fixture: Weak<Mutex<LeaderFixtureState>>,
}
impl FixtureClientRegistration {
fn new(fixture: &Arc<Mutex<LeaderFixtureState>>) -> Self {
fixture
.lock()
.unwrap_or_else(|error| error.into_inner())
.active_clients += 1;
Self {
fixture: Arc::downgrade(fixture),
}
}
}
impl Drop for FixtureClientRegistration {
fn drop(&mut self) {
if let Some(fixture) = self.fixture.upgrade() {
let mut fixture = fixture.lock().unwrap_or_else(|error| error.into_inner());
fixture.active_clients = fixture.active_clients.saturating_sub(1);
}
}
}
/// A `grok agent --leader stdio` client subprocess speaking ACP over pipes.
pub struct LeaderStdioClient {
pub conn: acp::ClientSideConnection,
process: TestProcess,
capture: Arc<Capture>,
registration: Option<FixtureClientRegistration>,
}
impl LeaderFixture {
/// Start one concrete persistent leader under the shared sandbox.
pub async fn start(
server: &MockInferenceServer,
cwd: &Path,
sandbox: &TestSandbox,
) -> io::Result<Self> {
Self::start_with_binary(&grok_binary(), server, cwd, sandbox).await
}
pub async fn start_with_binary(
binary: &Path,
server: &MockInferenceServer,
cwd: &Path,
sandbox: &TestSandbox,
) -> io::Result<Self> {
Self::start_with_binary_timeout(binary, server, cwd, sandbox, Duration::from_secs(30)).await
}
async fn start_with_binary_timeout(
binary: &Path,
server: &MockInferenceServer,
cwd: &Path,
sandbox: &TestSandbox,
readiness_timeout: Duration,
) -> io::Result<Self> {
let socket = sandbox.grok_home().join("leader.sock");
let lock = sandbox.grok_home().join("leader.lock");
let mut cmd = std::process::Command::new(binary);
cmd.args([
"agent",
"leader",
"--no-exit-on-disconnect",
"--relay-on-demand",
"--no-auto-update",
])
.current_dir(cwd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null());
sandbox.apply_to_std_command(&mut cmd);
cmd.envs(xai_tty_utils::pager_env())
.env("GROK_CLI_CHAT_PROXY_BASE_URL", server.url())
.env("GROK_XAI_API_BASE_URL", server.url())
.env("GROK_MODELS_BASE_URL", server.url())
.env("GROK_FEEDBACK_BASE_URL", server.url())
.env("GROK_TRACE_UPLOAD_URL", server.url())
.env("XAI_API_KEY", "test-key-for-ci")
.env("GROK_LEADER_SOCKET", &socket)
.env("RUST_LOG", "xai_grok_shell=debug");
let log_path = sandbox.grok_home().join("leader.log");
match std::fs::File::create(&log_path) {
Ok(log) => {
cmd.stderr(log);
}
Err(_) => {
cmd.stderr(std::process::Stdio::null());
}
}
xai_tty_utils::detach_std_command(&mut cmd);
#[allow(clippy::disallowed_methods)]
let mut child = cmd.spawn()?;
let pid = child.id();
let tree = match TestProcessTree::try_attach(pid, "persistent grok test leader") {
Ok(tree) => tree,
Err(error) => {
let _ = child.kill();
let _ = wait_std_child_bounded(&mut child, Duration::from_secs(1));
return Err(error);
}
};
let fixture = Self {
inner: Arc::new(Mutex::new(LeaderFixtureState {
binary: binary.to_path_buf(),
socket,
lock,
active_clients: 0,
leader: Some(PersistentLeader { child, tree, pid }),
})),
};
fixture.finish_start(readiness_timeout).await
}
async fn finish_start(self, timeout: Duration) -> io::Result<Self> {
if let Err(error) = self.wait_ready(timeout).await {
let cleanup = self.close().await;
return Err(match cleanup {
Ok(()) => error,
Err(cleanup) => io::Error::new(
error.kind(),
format!("{error}; readiness cleanup also failed: {cleanup}"),
),
});
}
Ok(self)
}
pub async fn spawn_client(
&self,
server: &MockInferenceServer,
cwd: &Path,
sandbox: &TestSandbox,
) -> io::Result<LeaderStdioClient> {
let binary = self
.inner
.lock()
.unwrap_or_else(|error| error.into_inner())
.binary
.clone();
self.spawn_client_with_binary(&binary, server, cwd, sandbox)
.await
}
pub async fn spawn_client_with_binary(
&self,
binary: &Path,
server: &MockInferenceServer,
cwd: &Path,
sandbox: &TestSandbox,
) -> io::Result<LeaderStdioClient> {
let socket = self
.inner
.lock()
.unwrap_or_else(|error| error.into_inner())
.socket
.clone();
let registration = FixtureClientRegistration::new(&self.inner);
LeaderStdioClient::spawn_with_binary_and_socket(
binary,
server,
cwd,
sandbox,
socket,
registration,
)
.await
}
/// PID of the concrete initial leader while the fixture still owns it.
pub fn leader_pid(&self) -> Option<u32> {
self.inner
.lock()
.unwrap_or_else(|error| error.into_inner())
.leader
.as_ref()
.map(|leader| leader.pid)
}
/// Observe a replacement PID from the lock file without adopting it.
pub async fn wait_for_new_leader(&self, old_pid: u32, timeout: Duration) -> io::Result<u32> {
let lock = self
.inner
.lock()
.unwrap_or_else(|error| error.into_inner())
.lock
.clone();
let deadline = tokio::time::Instant::now() + timeout;
loop {
if let Some(pid) = read_pid_path(&lock)
&& pid != old_pid
&& pid_alive(pid)
{
return Ok(pid);
}
if tokio::time::Instant::now() >= deadline {
return Err(io::Error::new(
ErrorKind::TimedOut,
format!("no replacement leader appeared after pid {old_pid}"),
));
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
/// Hard-kill only the concrete initial leader spawned by this fixture.
pub fn kill_current_concrete_leader(&self) -> io::Result<u32> {
let mut state = self.inner.lock().unwrap_or_else(|error| error.into_inner());
let leader = state
.leader
.as_mut()
.ok_or_else(|| io::Error::other("leader fixture no longer owns an initial leader"))?;
let tree_result = leader.tree.kill();
let child_result = leader.child.kill();
if let Err(error) = tree_result
&& !is_missing_process_error(&error)
{
return Err(error);
}
if let Err(error) = child_result
&& !is_missing_process_error(&error)
{
return Err(error);
}
Ok(leader.pid)
}
/// Reap the concrete initial leader after a crash test killed it.
pub async fn reap_exited_concrete_leaders(&self) -> io::Result<()> {
let state = self.inner.clone();
tokio::task::spawn_blocking(move || {
let mut state = state.lock().unwrap_or_else(|error| error.into_inner());
let Some(leader) = state.leader.as_mut() else {
return Ok(());
};
reap_exited_persistent_leader(leader, Duration::from_secs(2))?;
state.leader = None;
Ok(())
})
.await
.map_err(|error| io::Error::other(format!("leader reap task: {error}")))?
}
/// On failed test cleanup, hard-kill the concrete initial leader and leak
/// its already-signaled owner so unwind cannot run blocking Drop cleanup.
/// Lock-file and detached replacement PIDs are never consulted or signaled.
pub fn contain_failed_cleanup_for_unwind(&self) {
let leader = self
.inner
.lock()
.unwrap_or_else(|error| error.into_inner())
.leader
.take();
if let Some(mut leader) = leader {
let _ = leader.tree.kill();
let _ = leader.child.kill();
std::mem::forget(leader);
}
}
/// Close the directly-owned clients first, then shut down the concrete
/// initial leader. Detached replacements are intentionally untouched.
pub async fn close(&self) -> io::Result<()> {
let state = self.inner.clone();
tokio::task::spawn_blocking(move || {
let mut state = state.lock().unwrap_or_else(|error| error.into_inner());
if state.active_clients != 0 {
return Err(io::Error::other(format!(
"cannot close leader fixture while {} directly-owned client(s) remain; close/drop clients first",
state.active_clients
)));
}
let Some(leader) = state.leader.as_mut() else {
return Ok(());
};
shutdown_persistent_leader(leader)?;
state.leader = None;
Ok(())
})
.await
.map_err(|error| io::Error::other(format!("leader cleanup task: {error}")))?
}
async fn wait_ready(&self, timeout: Duration) -> io::Result<()> {
let (socket, pid) = {
let state = self.inner.lock().unwrap_or_else(|error| error.into_inner());
let leader = state
.leader
.as_ref()
.expect("leader fixture missing concrete owner");
(state.socket.clone(), leader.pid)
};
let deadline = tokio::time::Instant::now() + timeout;
loop {
if socket.exists() && pid_alive(pid) {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
return Err(io::Error::new(
ErrorKind::TimedOut,
format!(
"persistent leader pid {pid} did not become ready at {}",
socket.display()
),
));
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
}
impl Drop for LeaderFixture {
fn drop(&mut self) {
let leader = self
.inner
.lock()
.unwrap_or_else(|error| error.into_inner())
.leader
.take();
if let Some(mut leader) = leader {
let _ = shutdown_persistent_leader(&mut leader);
}
}
}
fn shutdown_persistent_leader(leader: &mut PersistentLeader) -> io::Result<()> {
const GRACE: Duration = Duration::from_secs(2);
const HARD_WAIT: Duration = Duration::from_secs(2);
if crate::process::process_has_exited_without_reap(leader.pid, "persistent leader")? {
return reap_exited_persistent_leader(leader, HARD_WAIT);
}
if let Err(error) = leader.tree.terminate()
&& !is_missing_process_error(&error)
{
return Err(error);
}
if !wait_std_child_exit_without_reap(&leader.child, GRACE)? {
if let Err(error) = leader.tree.kill()
&& !is_missing_process_error(&error)
{
return Err(error);
}
if let Err(error) = leader.child.kill()
&& !is_missing_process_error(&error)
{
return Err(error);
}
if !wait_std_child_exit_without_reap(&leader.child, HARD_WAIT)? {
return Err(io::Error::new(
ErrorKind::TimedOut,
format!("persistent leader pid {} did not exit", leader.pid),
));
}
}
reap_exited_persistent_leader(leader, HARD_WAIT)
}
fn reap_exited_persistent_leader(
leader: &mut PersistentLeader,
timeout: Duration,
) -> io::Result<()> {
if !wait_std_child_exit_without_reap(&leader.child, timeout)? {
return Err(io::Error::new(
ErrorKind::TimedOut,
format!("persistent leader pid {} did not exit", leader.pid),
));
}
// macOS may report EPERM when the group contains only the unreaped zombie
// leader. The direct child is already known exited; attempt descendant
// cleanup while its PGID is reserved, then revoke before consuming status.
// Focused tests separately prove a live descendant is removed.
let _ = leader.tree.kill();
leader.tree.release();
leader.child.wait().map(|_| ())
}
fn wait_std_child_exit_without_reap(
child: &std::process::Child,
timeout: Duration,
) -> io::Result<bool> {
let deadline = std::time::Instant::now() + timeout;
loop {
if crate::process::process_has_exited_without_reap(child.id(), "persistent leader")? {
return Ok(true);
}
if std::time::Instant::now() >= deadline {
return Ok(false);
}
std::thread::sleep(Duration::from_millis(10));
}
}
fn is_missing_process_error(error: &io::Error) -> bool {
matches!(error.raw_os_error(), Some(code) if code == libc::ESRCH || code == libc::ECHILD)
}
fn read_pid_path(path: &Path) -> Option<u32> {
std::fs::read_to_string(path).ok()?.trim().parse().ok()
}
fn wait_std_child_bounded(
child: &mut std::process::Child,
timeout: Duration,
) -> io::Result<Option<std::process::ExitStatus>> {
let deadline = std::time::Instant::now() + timeout;
loop {
if let Some(status) = child.try_wait()? {
return Ok(Some(status));
}
if std::time::Instant::now() >= deadline {
return Ok(None);
}
std::thread::sleep(Duration::from_millis(10));
}
}
impl LeaderStdioClient {
async fn spawn_with_binary_and_socket(
binary: &Path,
server: &MockInferenceServer,
cwd: &Path,
sandbox: &TestSandbox,
leader_socket: PathBuf,
registration: FixtureClientRegistration,
) -> io::Result<Self> {
let mut cmd = tokio::process::Command::new(binary);
cmd.args(["agent", "--leader", "stdio"]).current_dir(cwd);
let mut process = TestProcess::spawn(
cmd,
sandbox,
TestProcessConfig::new()
.label("grok leader stdio client")
.stdin(TestStdin::Piped)
.stdout(TestOutput::Piped)
.env("GROK_CLI_CHAT_PROXY_BASE_URL", server.url())
.env("GROK_XAI_API_BASE_URL", server.url())
.env("GROK_MODELS_BASE_URL", server.url())
.env("GROK_FEEDBACK_BASE_URL", server.url())
.env("GROK_TRACE_UPLOAD_URL", server.url())
.env("XAI_API_KEY", "test-key-for-ci")
.env("GROK_LEADER_SOCKET", leader_socket)
.env("RUST_LOG", "xai_grok_shell=debug"),
)
.map_err(|error| {
io::Error::new(
error.kind(),
format!(
"failed to spawn leader stdio client at {}: {error}\n{}",
binary.display(),
sandbox.diagnostic_summary(),
),
)
})?;
let outgoing = process
.take_stdin()
.ok_or_else(|| io::Error::other("leader stdio client stdin pipe missing"))?
.compat_write();
let incoming = process
.take_stdout()
.ok_or_else(|| io::Error::other("leader stdio client stdout pipe missing"))?
.compat();
let capture = Arc::new(Capture::default());
let client = LeaderAcpClient {
capture: capture.clone(),
};
let incoming = LineBufferedRead::spawn_local(incoming);
let (conn, handle_io) =
acp::ClientSideConnection::new(client, outgoing, incoming, |future| {
tokio::task::spawn_local(future);
});
tokio::task::spawn_local(handle_io);
Ok(Self {
conn,
process,
capture,
registration: Some(registration),
})
}
pub fn child_pid(&self) -> Option<u32> {
self.process.pid()
}
pub fn stderr_text(&self) -> String {
self.process.stderr_tail().text
}
pub fn process_diagnostics(&self) -> String {
self.process.diagnostic_summary()
}
pub fn start_terminate(&mut self) -> io::Result<()> {
self.process.start_terminate()
}
pub fn start_kill(&mut self) {
self.process.start_kill();
}
/// Request a nonblocking hard kill while retaining concrete process and
/// fixture-registration ownership for unwind containment.
pub fn contain_failed_cleanup_for_unwind(&mut self) {
self.process.start_kill();
}
pub async fn close(&mut self) -> io::Result<std::process::ExitStatus> {
let status = self.process.close().await?;
self.registration.take();
Ok(status)
}
pub async fn kill_and_close(&mut self) -> io::Result<std::process::ExitStatus> {
let status = self.process.kill().await?;
self.registration.take();
Ok(status)
}
pub fn captured_text(&self) -> String {
self.capture.chunks.lock().unwrap().join("")
}
pub async fn initialize(&self) -> acp::InitializeResponse {
let init = tokio::time::timeout(
Duration::from_secs(60),
self.conn.initialize(
acp::InitializeRequest::new(acp::ProtocolVersion::V1)
.client_capabilities(
acp::ClientCapabilities::new()
.fs(acp::FileSystemCapabilities::new())
.terminal(false),
)
.meta(
serde_json::json!({
"startupHints": {
"nonInteractive": true,
"skipGitStatus": true,
"skipProjectLayout": true
},
"clientType": "test-client",
"clientVersion": "0.0.0-test"
})
.as_object()
.cloned(),
),
),
)
.await
.unwrap_or_else(|_| panic!("initialize timed out\nstderr:\n{}", self.stderr_text()))
.expect("initialize failed");
let api_key_method = init
.auth_methods
.iter()
.find(|method| &*method.id().0 == "xai.api_key")
.expect("xai.api_key auth method");
self.conn
.authenticate(
acp::AuthenticateRequest::new(api_key_method.id().clone())
.meta(serde_json::json!({"headless": true}).as_object().cloned()),
)
.await
.expect("authenticate failed");
init
}
pub async fn create_session(&self, cwd: &Path) -> acp::SessionId {
self.create_session_inner(cwd, None).await
}
pub async fn create_session_with_model(&self, cwd: &Path, model_id: &str) -> acp::SessionId {
self.create_session_inner(
cwd,
serde_json::json!({ "modelId": model_id })
.as_object()
.cloned(),
)
.await
}
async fn create_session_inner(&self, cwd: &Path, meta: Option<acp::Meta>) -> acp::SessionId {
tokio::time::timeout(
Duration::from_secs(30),
self.conn.new_session(
acp::NewSessionRequest::new(cwd.to_path_buf())
.mcp_servers(vec![])
.meta(meta),
),
)
.await
.unwrap_or_else(|_| panic!("session/new timed out\nstderr:\n{}", self.stderr_text()))
.expect("session/new failed")
.session_id
}
pub async fn prompt(
&self,
session_id: &acp::SessionId,
text: &str,
) -> acp::Result<acp::PromptResponse> {
tokio::time::timeout(
Duration::from_secs(30),
self.conn.prompt(acp::PromptRequest::new(
session_id.clone(),
vec![acp::ContentBlock::Text(acp::TextContent::new(
text.to_string(),
))],
)),
)
.await
.unwrap_or_else(|_| panic!("prompt timed out\nstderr:\n{}", self.stderr_text()))
}
pub fn reconnected_count(&self) -> u32 {
self.capture.reconnected_count.load(Ordering::SeqCst)
}
pub fn notification_count(&self) -> u32 {
self.capture.notification_count.load(Ordering::SeqCst)
}
}
pub fn leader_lock_path(home: &Path) -> PathBuf {
home.join(".grok").join("leader.lock")
}
pub fn read_leader_pid(home: &Path) -> Option<u32> {
std::fs::read_to_string(leader_lock_path(home))
.ok()?
.trim()
.parse()
.ok()
}
pub fn pid_alive(pid: u32) -> bool {
// SAFETY: signal 0 performs an existence/permission check only.
let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
result == 0 || io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
/// Wait until the leader lock file contains a live PID.
pub async fn wait_for_live_leader(home: &Path, timeout: Duration) -> Option<u32> {
let deadline = tokio::time::Instant::now() + timeout;
while tokio::time::Instant::now() < deadline {
if let Some(pid) = read_leader_pid(home)
&& pid_alive(pid)
{
return Some(pid);
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
None
}
/// Wait for evidence that the bridge finished its reconnect replay.
pub async fn wait_for_replay_notifications(
client: &LeaderStdioClient,
baseline: u32,
timeout: Duration,
) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
while tokio::time::Instant::now() < deadline {
if client.reconnected_count() > 0 || client.notification_count() > baseline {
return true;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
false
}
pub fn leader_log(home: &Path) -> String {
std::fs::read_to_string(home.join(".grok").join("leader.log")).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_leader(script: &str) -> PersistentLeader {
let mut cmd = std::process::Command::new("/bin/sh");
cmd.args(["-c", script])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.envs(xai_tty_utils::pager_env());
xai_tty_utils::detach_std_command(&mut cmd);
let child = cmd.spawn().expect("spawn fake persistent leader");
let pid = child.id();
let tree = TestProcessTree::try_attach(pid, "fake persistent leader")
.expect("attach fake persistent leader");
PersistentLeader { child, tree, pid }
}
fn fixture(root: &Path, leader: PersistentLeader) -> LeaderFixture {
LeaderFixture {
inner: Arc::new(Mutex::new(LeaderFixtureState {
binary: PathBuf::from("fixture"),
socket: root.join("leader.sock"),
lock: root.join("leader.lock"),
active_clients: 0,
leader: Some(leader),
})),
}
}
#[tokio::test]
async fn close_terminates_and_reaps_directly_owned_leader() {
let temp = tempfile::tempdir().expect("tempdir");
let leader = fake_leader("trap 'exit 0' TERM; while :; do sleep 1; done");
let pid = leader.pid;
let fixture = fixture(temp.path(), leader);
fixture.close().await.expect("close fixture");
assert!(!pid_alive(pid));
assert!(fixture.inner.lock().unwrap().leader.is_none());
}
#[tokio::test]
async fn active_direct_client_registration_blocks_fixture_close() {
let temp = tempfile::tempdir().expect("tempdir");
let fixture = fixture(
temp.path(),
fake_leader("trap 'exit 0' TERM; while :; do sleep 1; done"),
);
let registration = FixtureClientRegistration::new(&fixture.inner);
let error = fixture
.close()
.await
.expect_err("active client must block close");
assert!(error.to_string().contains("close/drop clients first"));
drop(registration);
fixture.close().await.expect("close after client drop");
}
#[test]
fn lock_file_replacement_pid_is_never_adopted_or_signaled() {
let temp = tempfile::tempdir().expect("tempdir");
let initial = fake_leader("trap 'exit 0' TERM; while :; do sleep 1; done");
let initial_pid = initial.pid;
let mut replacement = fake_leader("trap 'exit 0' TERM; while :; do sleep 1; done");
let replacement_pid = replacement.pid;
std::fs::write(temp.path().join("leader.lock"), replacement_pid.to_string())
.expect("replacement lock");
let fixture = fixture(temp.path(), initial);
drop(fixture);
assert!(!pid_alive(initial_pid));
assert!(
pid_alive(replacement_pid),
"observed replacement must remain untouched"
);
shutdown_persistent_leader(&mut replacement).expect("clean replacement test owner");
}
#[tokio::test]
async fn close_hard_kills_term_ignoring_descendant() {
let temp = tempfile::tempdir().expect("tempdir");
let pid_file = temp.path().join("descendant.pid");
let script = format!(
"trap 'exit 0' TERM; sh -c 'trap \"\" TERM; echo $$ > {}; while :; do sleep 1; done' & while :; do sleep 1; done",
pid_file.display()
);
let fixture = fixture(temp.path(), fake_leader(&script));
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while !pid_file.exists() && tokio::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(10)).await;
}
let descendant: u32 = std::fs::read_to_string(&pid_file)
.expect("descendant pid")
.trim()
.parse()
.expect("parse descendant pid");
fixture.close().await.expect("close fixture");
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while pid_alive(descendant) && tokio::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(!pid_alive(descendant), "descendant {descendant} leaked");
}
}