use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use crate::daemon::state::DaemonState;
use crate::session_manager::{
ManagedError, ManagedSessionId, ManagedSessionState, ManagedTmuxDriver, SessionManager,
};
#[tokio::test]
async fn a_second_operator_resume_is_refused_while_one_is_in_flight() {
let dir = tempfile::tempdir().expect("tempdir");
let state = Arc::new(DaemonState::with_root_isolated_managed(dir.path().to_path_buf()).await);
let mgr = state.session_manager().await;
let ws = dir.path().join("ws");
std::fs::create_dir_all(&ws).expect("workspace dir");
let record = mgr
.create(
"claim-span".into(),
Some(ws.clone()),
None,
None,
None,
None,
)
.await
.expect("create");
let id = record.id;
mgr.set_workspace(&id, ws, ManagedSessionState::Active)
.await
.expect("set Active");
mgr.stop(&id).await.expect("stop");
let before = mgr.get(&id).await.expect("record");
let claim = mgr.begin_resume(&id).expect("the first claim is granted");
let refused = super::lifecycle::resume_managed(&state, &id).await;
let err = refused.expect_err("a second operator resume must be refused");
assert!(
matches!(err, super::ResumeManagedError::AlreadyResuming(ref s) if s == &id.to_string()),
"a refused claim must read as 'already resuming', not as a launch failure: {err}"
);
let after = mgr.get(&id).await.expect("record");
assert_eq!(
after.state, before.state,
"a refused resume must change no state"
);
assert_eq!(
after.task, before.task,
"and append no error note to the record the other path is working on"
);
drop(claim);
let regranted = mgr
.begin_resume(&id)
.expect("a released claim must leave the session claimable again");
drop(regranted);
}
#[derive(Debug)]
struct Observation {
call: &'static str,
claim_held: bool,
persisted_active: bool,
}
struct ClaimRecordingDriver {
manager: Arc<OnceLock<Arc<SessionManager>>>,
session: Arc<OnceLock<ManagedSessionId>>,
store_path: PathBuf,
seen: Arc<Mutex<Vec<Observation>>>,
}
impl ClaimRecordingDriver {
fn persisted_active(&self, id: &ManagedSessionId) -> bool {
let Ok(raw) = std::fs::read_to_string(&self.store_path) else {
return false;
};
let Ok(doc) = serde_json::from_str::<serde_json::Value>(&raw) else {
return false;
};
doc.get("sessions")
.and_then(|s| s.get(id.to_string()))
.and_then(|r| r.get("state"))
.and_then(serde_json::Value::as_str)
== Some("active")
}
fn observe(&self, call: &'static str) {
let (Some(mgr), Some(id)) = (self.manager.get(), self.session.get()) else {
return;
};
let obs = Observation {
call,
claim_held: mgr.is_resume_in_flight(id),
persisted_active: self.persisted_active(id),
};
if let Ok(mut seen) = self.seen.lock() {
seen.push(obs);
}
}
}
impl ManagedTmuxDriver for ClaimRecordingDriver {
fn create_session(&self, _name: &str, _workdir: &str) -> Result<(), ManagedError> {
self.observe("create_session");
Ok(())
}
fn kill_session(&self, _name: &str) -> Result<(), ManagedError> {
self.observe("kill_session");
Ok(())
}
fn send_line(&self, _name: &str, _text: &str) -> Result<(), ManagedError> {
self.observe("send_line");
Ok(())
}
fn send_keys_literal(&self, _name: &str, _text: &str) -> Result<(), ManagedError> {
self.observe("send_keys_literal");
Ok(())
}
fn send_interrupt(&self, _name: &str) -> Result<(), ManagedError> {
self.observe("send_interrupt");
Ok(())
}
fn capture(&self, _name: &str, _lines: usize) -> Result<String, ManagedError> {
self.observe("capture");
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
self.observe("list_sessions");
Ok(Vec::new())
}
fn get_pane_id(&self, name: &str) -> Option<String> {
Some(crate::test_support::self_describing_pane(name))
}
fn pane_identity(
&self,
pane_id: &str,
) -> Result<crate::session_manager::pane_identity::PaneIdentity, ManagedError> {
Ok(crate::test_support::self_describing_identity(pane_id))
}
}
#[cfg(unix)]
#[serial_test::serial]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_claim_is_still_held_when_the_route_types_into_the_pane() {
let _claude = crate::test_support::fake_claude_on_path();
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path().join("framework");
let seen: Arc<Mutex<Vec<Observation>>> = Arc::new(Mutex::new(Vec::new()));
let manager_slot: Arc<OnceLock<Arc<SessionManager>>> = Arc::new(OnceLock::new());
let session_slot: Arc<OnceLock<ManagedSessionId>> = Arc::new(OnceLock::new());
let driver = Arc::new(ClaimRecordingDriver {
manager: Arc::clone(&manager_slot),
session: Arc::clone(&session_slot),
store_path: root.join("session-manager").join("sessions.json"),
seen: Arc::clone(&seen),
});
let state =
Arc::new(DaemonState::with_root_isolated_managed_and_driver(root.clone(), driver).await);
let mgr = state.session_manager().await;
let _ = manager_slot.set(Arc::clone(&mgr));
let ws = dir.path().join("ws");
std::fs::create_dir_all(&ws).expect("workspace dir");
let record = mgr
.create(
"claim-span-driver".into(),
Some(ws.clone()),
None,
None,
None,
None,
)
.await
.expect("create");
let id = record.id;
mgr.set_workspace(&id, ws, ManagedSessionState::Active)
.await
.expect("set Active");
mgr.stop(&id).await.expect("stop");
let _ = session_slot.set(id);
let _ = tokio::time::timeout(
Duration::from_secs(60),
super::lifecycle::resume_managed(&state, &id),
)
.await
.expect("resume_managed must finish well inside the budget, never hang");
let seen = seen.lock().expect("observations");
let every_call: Vec<&str> = seen.iter().map(|o| o.call).collect();
let post_transition: Vec<&Observation> = seen.iter().filter(|o| o.persisted_active).collect();
assert!(
!post_transition.is_empty(),
"the route made no driver call after the record read `Active`, so this test \
can observe nothing about the claim's span — fix the fixture, do not \
weaken the assertion. Calls seen: {every_call:?}"
);
let post_calls: Vec<&str> = post_transition.iter().map(|o| o.call).collect();
assert!(
post_transition.iter().all(|o| o.claim_held),
"#8233 P0: `resume_managed` must still hold the resume claim at every \
driver call it makes after the record reads `Active`. Unclaimed calls \
let the reaper stop a half-resumed session and the supervisor launch it \
a second time. Post-transition calls: {post_calls:?}; full observations: \
{post_transition:?}"
);
}