use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use serde_json::{Value, json};
use super::record::{ManagedSessionId, ManagedSessionState};
use super::tests::make_manager;
use crate::daemon::search_rpc;
use crate::test_support::isolated_daemon_home;
use crate::uds_mock::{self, MockFuture, MockUdsDaemon, RpcError};
const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
struct CountingDaemon {
calls: Arc<AtomicUsize>,
daemon: MockUdsDaemon,
}
impl CountingDaemon {
async fn start() -> Self {
let calls = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&calls);
let socket = search_rpc::search_socket().expect("derive the trusty-search socket");
let daemon = uds_mock::spawn_at(socket, move |_method, _params| {
counter.fetch_add(1, Ordering::SeqCst);
Box::pin(
async move { Ok(json!({ "ok": true, "removed": true, "data_deleted": true })) },
)
})
.await;
Self { calls, daemon }
}
async fn self_check(&self) {
search_rpc::call_at(
self.daemon.socket(),
search_rpc::METHOD_HEALTH,
json!({}),
PROBE_TIMEOUT,
)
.await
.expect("the stand-in daemon must answer — the fixture's premise");
self.calls.store(0, Ordering::SeqCst);
}
fn seen(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
#[serial_test::serial]
#[tokio::test]
async fn decommission_issues_no_request_to_a_live_daemon_under_test() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let managed_root = crate::test_support::hermetic_temp_dir();
let workspace_path = managed_root.path().join("owner").join("repo").join("full");
std::fs::create_dir_all(&workspace_path).expect("create the fixture workspace");
let record = mgr
.create_with_id(
ManagedSessionId::new(),
"task".into(),
Some(workspace_path.clone()),
None,
Some(workspace_path.clone()),
None,
None,
crate::runtime::RuntimeKind::default(),
false,
true, )
.await
.expect("create");
let (_home, _override) = isolated_daemon_home(false);
let daemon = CountingDaemon::start().await;
daemon.self_check().await;
let outcome = mgr
.decommission_with_root(&record.id, managed_root.path(), None)
.await;
let (tombstone, _removed) = outcome.expect("full decommission");
assert_eq!(
tombstone.state,
ManagedSessionState::Decommissioned,
"the guard must not change what decommission does to the record"
);
assert_eq!(
daemon.seen(),
0,
"a test process must reach the trusty-search daemon zero times — this delete \
carries the delete_data opt-in and destroys a real index named `full` (#4743)"
);
}
#[serial_test::serial]
#[tokio::test]
async fn sweep_makes_no_request_under_a_test_harness() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let (_home, _override) = isolated_daemon_home(false);
let daemon = CountingDaemon::start().await;
daemon.self_check().await;
let swept = mgr.sweep_orphaned_search_indexes(false).await;
assert_eq!(
swept.expect("sweep must not error").len(),
0,
"a refused sweep reports nothing removed"
);
assert_eq!(
daemon.seen(),
0,
"the orphan sweep must not even LIST indexes from a test process (#4743)"
);
}
#[serial_test::serial]
#[tokio::test]
async fn sweep_orphaned_search_indexes_noop_when_daemon_unreachable() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let (_home, _override) = isolated_daemon_home(false);
let swept = mgr
.sweep_orphaned_search_indexes(true)
.await
.expect("an unreachable daemon is a no-op, never an error");
assert!(
swept.is_empty(),
"nothing is collectable when nothing answered: {swept:?}"
);
}
const ORPHAN_ID: &str = "gone-session";
fn one_index_daemon(
root: PathBuf,
status: Option<Value>,
) -> impl Fn(&str, Value) -> MockFuture + Send + Sync {
move |method, _params| {
let answer = if method == search_rpc::METHOD_INDEXES_LIST {
Ok(json!({
"indexes": [{ "id": ORPHAN_ID, "root_path": root.to_string_lossy() }]
}))
} else if method == search_rpc::METHOD_INDEX_STATUS {
match &status {
Some(body) => Ok(body.clone()),
None => Err(RpcError::new(
crate::daemon::error::CODE_UNAVAILABLE,
"index status unavailable",
)),
}
} else {
Err(RpcError::new(
-32601,
format!("unexpected method: {method}"),
))
};
Box::pin(async move { answer })
}
}
async fn dry_sweep_against(
mgr: &super::manager::SessionManager,
root: PathBuf,
status: Option<Value>,
) -> Vec<String> {
let (_home, _override) = isolated_daemon_home(false);
let socket = search_rpc::search_socket().expect("derive the trusty-search socket");
let _daemon = uds_mock::spawn_at(socket, one_index_daemon(root, status)).await;
mgr.sweep_orphaned_search_indexes(true)
.await
.expect("a dry-run sweep against an answering daemon must not error")
}
#[serial_test::serial]
#[tokio::test]
async fn sweep_skips_a_candidate_whose_status_probe_failed() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let vanished_root = PathBuf::from("/nonexistent/owner/repo/.worktrees/gone-session");
let skipped = dry_sweep_against(&mgr, vanished_root.clone(), None).await;
assert!(
skipped.is_empty(),
"an index whose chunk count could not be read must not be collectable: {skipped:?}"
);
let collected = dry_sweep_against(&mgr, vanished_root, Some(json!({ "chunk_count": 0 }))).await;
assert_eq!(
collected,
vec![ORPHAN_ID.to_string()],
"fixture premise: the same candidate IS collectable once the daemon answers"
);
}