use std::path::Path;
use anyhow::Context as _;
pub use crate::platform::relaunch::RelaunchSentinel;
pub fn commit_and_stop(
sid: &str,
target_profile: &str,
handoff: &str,
cwd: &str,
born: i64,
_owner_dir: &Path,
) -> anyhow::Result<()> {
use crate::paths;
let current_hop = read_sidecar_hop(sid);
let next_hop = current_hop + 1;
merge_sidecar_hop(sid, next_hop)?;
let actual_born = if born != 0 {
born
} else {
read_pid_born(sid).unwrap_or(0)
};
let sentinel = RelaunchSentinel {
session_id: sid.to_string(),
target_profile: target_profile.to_string(),
cwd: cwd.to_string(),
handoff: handoff.to_string(),
hop: next_hop,
born: actual_born,
};
crate::platform::relaunch::write_relaunch(&paths::relaunch(sid), &sentinel)?;
let switched_path = paths::switched(sid);
if !switched_path.exists() {
let epoch = now_epoch();
let _ = write_noclobber(&switched_path, &format!("{epoch}"));
}
let epoch = now_epoch();
std::fs::write(paths::last_switch(), format!("{epoch}"))
.context("failed to write .last-switch")?;
stop_managed_process(sid)?;
Ok(())
}
fn read_sidecar_hop(sid: &str) -> i64 {
crate::sidecar::read_sidecar(&crate::paths::sidecar(sid))
.map(|s| s.hop_int())
.unwrap_or(0)
}
fn merge_sidecar_hop(sid: &str, next_hop: i64) -> anyhow::Result<()> {
use crate::paths;
let path = paths::sidecar(sid);
let mut val: serde_json::Value = match std::fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content).unwrap_or(serde_json::json!({})),
Err(_) => serde_json::json!({}),
};
if !val.is_object() {
val = serde_json::json!({});
}
val["hop"] = serde_json::Value::String(next_hop.to_string());
let tmp = path.with_extension("json.tmp");
let json = serde_json::to_string(&val).context("failed to serialize sidecar")?;
std::fs::write(&tmp, &json).context("failed to write sidecar tmp")?;
std::fs::rename(&tmp, &path).context("failed to rename sidecar into place")?;
Ok(())
}
fn read_pid_born(sid: &str) -> Option<i64> {
use crate::paths;
let content = std::fs::read_to_string(paths::pid_file(sid)).ok()?;
let mut parts = content.split_whitespace();
let _pid: u32 = parts.next()?.parse().ok()?;
let born: i64 = parts.next()?.parse().ok()?;
Some(born)
}
fn read_pid(sid: &str) -> Option<u32> {
use crate::paths;
let content = std::fs::read_to_string(paths::pid_file(sid)).ok()?;
let pid: u32 = content.split_whitespace().next()?.parse().ok()?;
Some(pid)
}
fn stop_managed_process(sid: &str) -> anyhow::Result<()> {
let Some(pid) = read_pid(sid) else {
return Ok(());
};
if !is_live_claude_or_node(pid) {
return Ok(());
}
platform_stop(pid, sid)
}
pub fn check_is_live_claude_or_node(pid: u32) -> bool {
is_live_claude_or_node(pid)
}
fn is_live_claude_or_node(pid: u32) -> bool {
use crate::platform::proc_check::ProcCheck;
crate::platform::proc_check::SysinfoProcCheck::is_live_claude_or_node(pid)
}
#[cfg(unix)]
fn platform_stop(pid: u32, _sid: &str) -> anyhow::Result<()> {
use nix::sys::signal::{kill, Signal};
use nix::unistd::Pid;
kill(Pid::from_raw(pid as i32), Signal::SIGTERM)
.with_context(|| format!("failed to SIGTERM pid {pid}"))?;
Ok(())
}
#[cfg(windows)]
fn platform_stop(pid: u32, sid: &str) -> anyhow::Result<()> {
use crate::paths;
let stop_path = paths::stop_flag(sid);
std::fs::write(&stop_path, b"")
.with_context(|| format!("failed to write stop flag for pid {pid}"))?;
Ok(())
}
fn now_epoch() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
fn write_noclobber(path: &Path, content: &str) -> anyhow::Result<()> {
use std::fs::OpenOptions;
use std::io::Write as _;
match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(mut f) => {
f.write_all(content.as_bytes())?;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
}
Err(e) => return Err(e.into()),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn noclobber_first_write_wins() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("marker");
write_noclobber(&path, "first").unwrap();
write_noclobber(&path, "second").unwrap(); let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, "first");
}
#[test]
fn relaunch_sentinel_hop_is_number() {
let sentinel = RelaunchSentinel {
session_id: "test-sid".to_string(),
target_profile: "work".to_string(),
cwd: "/tmp/cwd".to_string(),
handoff: "resume".to_string(),
hop: 1,
born: 1718000000,
};
let json = serde_json::to_string(&sentinel).unwrap();
assert!(
json.contains("\"hop\":1"),
"hop should be a JSON number in .relaunch: {json}"
);
let back: RelaunchSentinel = serde_json::from_str(&json).unwrap();
assert_eq!(back.session_id, "test-sid");
assert_eq!(back.hop, 1);
assert_eq!(back.born, 1718000000);
}
#[test]
fn sidecar_hop_serialized_as_string() {
let mut val = serde_json::json!({
"sessionId": "abc",
"permissionMode": "default"
});
val["hop"] = serde_json::Value::String(1_i64.to_string());
let json = serde_json::to_string(&val).unwrap();
assert!(
json.contains("\"hop\":\"1\""),
"hop should be a JSON string in sidecar: {json}"
);
}
#[test]
fn relaunch_sentinel_born_and_hop_types() {
let json = r#"{"session_id":"s","target_profile":"p","cwd":"/","handoff":"h","hop":2,"born":1234567890}"#;
let s: RelaunchSentinel = serde_json::from_str(json).unwrap();
assert_eq!(s.hop, 2i64);
assert_eq!(s.born, 1234567890i64);
}
}