use std::collections::{BTreeMap, HashMap};
use std::io::{Read as _, Write as _};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use super::DaemonState;
use super::session_claude_liveness::ClaudeLiveness;
use crate::core::session::SessionId;
use crate::core::twin_arming::{
ProcessFacts, STATUS_MAX_ANCESTOR_HOPS, nearest_claude_for_status_in, process_facts,
};
use crate::core::twin_identity::ClaudeProcess;
pub(crate) const SESSION_CLAUDES_FILE: &str = "session-claudes.json";
const FORMAT_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Announcement {
Claude(ClaudeProcess),
Unproven,
}
#[derive(Debug)]
pub struct SessionClaudes {
path: PathBuf,
registry: Mutex<Result<HashMap<SessionId, Announcement>, String>>,
binding: Mutex<HashMap<SessionId, usize>>,
resumes: Mutex<HashMap<SessionId, ResumeGrant>>,
events: Mutex<HashMap<SessionId, Instant>>,
announcers: Mutex<HashMap<SessionId, ClaudeProcess>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResumeGrant {
pub tmux_name: String,
pub pane_id: Option<String>,
pub issued_at: u64,
}
pub(crate) const RESUME_HOLD_SECS: u64 = 5 * crate::daemon::REAP_INTERVAL_SECS;
impl ResumeGrant {
pub(crate) fn holds_reap(&self, now: u64) -> bool {
now.saturating_sub(self.issued_at) < RESUME_HOLD_SECS
}
}
impl SessionClaudes {
pub(crate) fn load(root: &Path) -> Self {
Self::load_as(root.join(SESSION_CLAUDES_FILE), current_uid())
}
pub(crate) fn load_as(path: PathBuf, uid: u32) -> Self {
let registry = read_registry(&path, uid);
if let Err(why) = ®istry {
tracing::error!(
"#8531: the session-claude registry is sealed — no delegation repair is \
granted to an owning session until it is fixed or removed: {why}"
);
}
Self {
path,
registry: Mutex::new(registry),
binding: Mutex::new(HashMap::new()),
resumes: Mutex::new(HashMap::new()),
events: Mutex::new(HashMap::new()),
announcers: Mutex::new(HashMap::new()),
}
}
pub fn get(&self, session: SessionId) -> Option<ClaudeProcess> {
match &*self.registry.lock() {
Ok(map) => match map.get(&session) {
Some(Announcement::Claude(claude)) => Some(*claude),
_ => None,
},
Err(_) => None,
}
}
pub fn is_settled(&self, session: SessionId) -> bool {
match &*self.registry.lock() {
Ok(map) => map.contains_key(&session),
Err(_) => true,
}
}
pub fn sealed(&self) -> Option<String> {
self.registry.lock().as_ref().err().cloned()
}
fn record(
&self,
session: SessionId,
announcement: Announcement,
admit: impl FnOnce() -> Result<(), String>,
) -> Result<Option<Announcement>, String> {
let mut guard = self.registry.lock();
let map = guard
.as_mut()
.map_err(|why| format!("the session-claude registry is sealed: {why}"))?;
if let Some(earlier) = map.get(&session) {
return Ok(Some(*earlier));
}
admit()?;
map.insert(session, announcement);
if let Err(e) = write_registry(&self.path, map) {
if matches!(announcement, Announcement::Claude(_)) {
map.remove(&session);
}
return Err(format!(
"the announcement could not be saved to {}: {e}",
self.path.display()
));
}
Ok(None)
}
pub(crate) fn begin_bind(&self, session: SessionId) -> BindInFlight<'_> {
*self.binding.lock().entry(session).or_insert(0) += 1;
BindInFlight {
claudes: self,
session,
}
}
pub(crate) fn settle_after_event(&self, session: SessionId) -> Result<bool, String> {
let binding = self.binding.lock();
if binding.contains_key(&session) {
return Ok(false);
}
Ok(self
.record(session, Announcement::Unproven, || Ok(()))?
.is_none())
}
pub(crate) fn grant_resume(&self, session: SessionId, grant: ResumeGrant) {
self.resumes.lock().insert(session, grant);
}
pub(crate) fn resume_grant(&self, session: SessionId) -> Option<ResumeGrant> {
self.resumes.lock().get(&session).cloned()
}
pub(super) fn revoke_resume(&self, session: SessionId, used: &ResumeGrant) {
let mut resumes = self.resumes.lock();
if resumes.get(&session) == Some(used) {
resumes.remove(&session);
}
}
pub(super) fn rebind(&self, session: SessionId, claude: ClaudeProcess) -> Result<(), String> {
let mut guard = self.registry.lock();
let map = guard
.as_mut()
.map_err(|why| format!("the session-claude registry is sealed: {why}"))?;
let earlier = map.insert(session, Announcement::Claude(claude));
if let Err(e) = write_registry(&self.path, map) {
match earlier {
Some(announcement) => map.insert(session, announcement),
None => map.remove(&session),
};
return Err(format!(
"the rebinding could not be saved to {}: {e}",
self.path.display()
));
}
Ok(())
}
pub fn peer_claude(&self, pid: u32, seen_at: u64) -> Result<ClaudeProcess, String> {
peer_claude_with(
pid,
seen_at,
process_facts,
crate::core::twin_arming::is_claude,
)
}
}
#[derive(Debug)]
pub(crate) struct BindInFlight<'a> {
claudes: &'a SessionClaudes,
session: SessionId,
}
impl Drop for BindInFlight<'_> {
fn drop(&mut self) {
let mut binding = self.claudes.binding.lock();
if let Some(count) = binding.get_mut(&self.session) {
*count -= 1;
if *count == 0 {
binding.remove(&self.session);
}
}
}
}
pub(crate) fn peer_claude_with(
pid: u32,
seen_at: u64,
facts: impl Fn(u32) -> Result<ProcessFacts, String>,
is_claude: impl Fn(u32) -> Result<bool, String>,
) -> Result<ClaudeProcess, String> {
let started = facts(pid)
.map_err(|e| format!("the calling process (pid {pid}) could not be read: {e}"))?
.start_time;
if started > seen_at {
return Err(format!(
"pid {pid} now names a process started after the request arrived"
));
}
match nearest_claude_for_status_in(pid, facts, is_claude) {
Ok(Some(claude)) => Ok(claude),
Ok(None) => Err(format!(
"no claude session process runs within {STATUS_MAX_ANCESTOR_HOPS} parent hops \
of the calling process (pid {pid})"
)),
Err(e) => Err(format!(
"the calling process's ancestry (pid {pid}) could not be read: {e}"
)),
}
}
impl SessionClaudes {
pub(crate) fn note_event(&self, session: SessionId) {
self.events.lock().insert(session, Instant::now());
}
pub(crate) fn note_announcer(&self, session: SessionId, claude: ClaudeProcess) {
self.announcers.lock().insert(session, claude);
}
pub(crate) fn reap_hold(
&self,
session: SessionId,
probed: ClaudeProcess,
quiet: Duration,
probe: impl Fn(ClaudeProcess) -> ClaudeLiveness,
) -> Option<String> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
if self
.resume_grant(session)
.is_some_and(|g| g.holds_reap(now))
{
return Some("the daemon is resuming it".to_string());
}
if self.get(session) != Some(probed) {
return Some("it was rebound after the probe".to_string());
}
if self
.events
.lock()
.get(&session)
.is_some_and(|at| at.elapsed() < quiet)
{
return Some("a hook event for it arrived within the last reap interval".to_string());
}
let announcer = self.announcers.lock().get(&session).copied();
match announcer.map(&probe) {
Some(ClaudeLiveness::Alive) => Some("a later announcer still runs".to_string()),
Some(ClaudeLiveness::Unknown(why)) => Some(format!("a later announcer: {why}")),
Some(ClaudeLiveness::Gone(_)) | None => None,
}
}
pub(crate) fn forget_events_before(&self, quiet: Duration) {
self.events.lock().retain(|_, at| at.elapsed() < quiet);
}
}
impl DaemonState {
pub(super) fn reap_settled_sessions(
&self,
bound: Vec<(SessionId, ClaudeProcess)>,
probe: impl Fn(ClaudeProcess) -> ClaudeLiveness,
) -> usize {
let quiet = Duration::from_secs(crate::daemon::REAP_INTERVAL_SECS);
self.session_claudes.forget_events_before(quiet);
let mut reaped = 0;
for (id, claude) in bound {
if !probe(claude).proves_gone(id) {
continue;
}
if let Some(why) = self.session_claudes.reap_hold(id, claude, quiet, &probe) {
tracing::info!(session = ?id, "kept a settled session: {why} (#9010)");
continue;
}
self.remove_session(id);
self.session_claudes.announcers.lock().remove(&id);
self.stale_delegations_of_dead_session(id);
reaped += 1;
}
reaped
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredRegistry {
version: u32,
sessions: BTreeMap<String, StoredAnnouncement>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
enum StoredAnnouncement {
Claude { pid: u32, start_time: u64 },
Unproven,
}
pub(crate) fn read_registry(
path: &Path,
uid: u32,
) -> Result<HashMap<SessionId, Announcement>, String> {
let shown = path.display();
let mut file = match open_no_follow(path) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
Err(e) => return Err(format!("{shown} could not be opened: {e}")),
};
let meta = file
.metadata()
.map_err(|e| format!("{shown} could not be inspected: {e}"))?;
if !meta.is_file() {
return Err(format!("{shown} is not a regular file"));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt as _;
if meta.uid() != uid {
return Err(format!(
"{shown} is owned by uid {}, not the daemon's uid {uid}",
meta.uid()
));
}
if meta.mode() & 0o077 != 0 {
return Err(format!(
"{shown} has mode {:o}; only its owner may read or write it",
meta.mode() & 0o777
));
}
}
#[cfg(not(unix))]
let _ = uid;
let mut text = String::new();
file.read_to_string(&mut text)
.map_err(|e| format!("{shown} could not be read: {e}"))?;
let stored: StoredRegistry =
serde_json::from_str(&text).map_err(|e| format!("{shown} does not parse: {e}"))?;
if stored.version != FORMAT_VERSION {
return Err(format!(
"{shown} is format version {}, this daemon reads {FORMAT_VERSION}",
stored.version
));
}
let mut map = HashMap::with_capacity(stored.sessions.len());
for (id, entry) in stored.sessions {
let session = uuid::Uuid::parse_str(&id)
.map(SessionId)
.map_err(|e| format!("{shown} names a malformed session id {id:?}: {e}"))?;
let announcement = match entry {
StoredAnnouncement::Claude { pid, start_time } => {
Announcement::Claude(ClaudeProcess { pid, start_time })
}
StoredAnnouncement::Unproven => Announcement::Unproven,
};
map.insert(session, announcement);
}
Ok(map)
}
fn open_no_follow(path: &Path) -> std::io::Result<std::fs::File> {
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
}
options.open(path)
}
fn write_registry(path: &Path, map: &HashMap<SessionId, Announcement>) -> std::io::Result<()> {
crate::core::home_write_fence::check(path); let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent)?;
let sessions = map
.iter()
.map(|(session, announcement)| {
let entry = match announcement {
Announcement::Claude(c) => StoredAnnouncement::Claude {
pid: c.pid,
start_time: c.start_time,
},
Announcement::Unproven => StoredAnnouncement::Unproven,
};
(session.0.to_string(), entry)
})
.collect();
let stored = StoredRegistry {
version: FORMAT_VERSION,
sessions,
};
let bytes = serde_json::to_vec(&stored).map_err(std::io::Error::other)?;
let mut tmp = tempfile::Builder::new()
.prefix(".session-claudes.")
.suffix(".tmp")
.tempfile_in(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
tmp.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
tmp.write_all(&bytes)?;
tmp.as_file().sync_all()?;
tmp.persist(path).map_err(|e| e.error)?;
#[cfg(unix)]
if let Ok(dir) = std::fs::File::open(parent) {
let _ = dir.sync_all();
}
Ok(())
}
pub(crate) fn current_uid() -> u32 {
#[cfg(unix)]
{
unsafe { libc::geteuid() }
}
#[cfg(not(unix))]
{
0
}
}
impl DaemonState {
pub fn session_claudes(&self) -> &SessionClaudes {
&self.session_claudes
}
pub fn bind_session_claude(
&self,
session: SessionId,
claude: ClaudeProcess,
) -> Result<(), String> {
let admit = || {
if self.delegations.iter().any(|d| d.session == session) {
return Err(
"the session already owns delegation records, so the process that \
announced it first is unknown"
.to_string(),
);
}
Ok(())
};
match self
.session_claudes
.record(session, Announcement::Claude(claude), admit)?
{
None => Ok(()),
Some(Announcement::Claude(_)) => {
Err("the session is already bound to its claude".to_string())
}
Some(Announcement::Unproven) => Err(
"the session was first announced without a kernel-verified claude, so it is \
never bound"
.to_string(),
),
}
}
pub fn settle_unproven_session(&self, session: SessionId) -> Result<(), String> {
self.session_claudes
.record(session, Announcement::Unproven, || Ok(()))
.map(|_| ())
}
}
#[cfg(test)]
#[path = "session_claudes_tests.rs"]
mod session_claudes_tests;