use std::io::{BufRead, BufWriter, Write};
use std::path::{Path, PathBuf};
pub(crate) const SESSION_LOCK_FILE: &str = ".lock";
#[derive(Debug, Clone)]
pub struct SessionLockBusy {
pub pid: u32,
pub hostname: String,
pub started_at_unix: u64,
pub session_dir: PathBuf,
}
impl std::fmt::Display for SessionLockBusy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"session at {} is being written by another process \
(pid {}, host {}, started {}). \
If you believe this is stale, remove {}/{} and retry.",
self.session_dir.display(),
self.pid,
self.hostname,
self.started_at_unix,
self.session_dir.display(),
SESSION_LOCK_FILE,
)
}
}
impl std::error::Error for SessionLockBusy {}
pub(crate) struct SentinelInfo {
pub(crate) pid: u32,
pub(crate) hostname: String,
pub(crate) started_at_unix: u64,
}
impl SentinelInfo {
fn for_self() -> Self {
Self {
pid: std::process::id(),
hostname: current_hostname(),
started_at_unix: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
}
}
pub(crate) fn parse(text: &str) -> Option<Self> {
let mut lines = text.lines();
let pid: u32 = lines.next()?.trim().parse().ok()?;
let hostname = lines.next()?.trim().to_string();
let started_at_unix: u64 = lines.next().unwrap_or("0").trim().parse().unwrap_or(0);
Some(Self {
pid,
hostname,
started_at_unix,
})
}
pub(crate) fn serialise(&self) -> String {
format!(
"{}\n{}\n{}\n",
self.pid, self.hostname, self.started_at_unix
)
}
}
pub(crate) fn current_hostname() -> String {
let raw = std::env::var("HOSTNAME")
.or_else(|_| std::env::var("COMPUTERNAME"))
.unwrap_or_else(|_| {
std::process::Command::new("hostname")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.unwrap_or_default()
});
raw.replace(['\n', '\r'], "_").trim().to_string()
}
fn is_pid_alive(pid: u32) -> bool {
#[cfg(target_os = "linux")]
{
std::path::Path::new(&format!("/proc/{pid}")).exists()
}
#[cfg(all(unix, not(target_os = "linux")))]
{
std::process::Command::new("/bin/kill")
.arg("-0")
.arg(pid.to_string())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(target_os = "windows")]
{
let output = std::process::Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"])
.output();
match output {
Ok(o) => {
let stdout = String::from_utf8_lossy(&o.stdout);
stdout.contains(&format!("\"{pid}\""))
}
Err(_) => true,
}
}
#[cfg(not(any(unix, target_os = "windows")))]
{
let _ = pid;
true
}
}
#[derive(Debug)]
pub struct SessionLock {
lock_path: PathBuf,
}
impl SessionLock {
pub fn acquire(session_dir: &Path) -> std::io::Result<Self> {
use std::fs::OpenOptions;
use std::io::Write;
let lock_path = session_dir.join(SESSION_LOCK_FILE);
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent)?;
}
let info = SentinelInfo::for_self();
let sentinel = info.serialise();
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
{
Ok(mut f) => {
f.write_all(sentinel.as_bytes())?;
return Ok(Self { lock_path });
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
}
Err(e) => return Err(e),
}
match std::fs::read_to_string(&lock_path) {
Ok(text) => {
if let Some(existing) = SentinelInfo::parse(&text) {
let our_host = current_hostname();
if existing.hostname != our_host || is_pid_alive(existing.pid) {
return Err(std::io::Error::other(SessionLockBusy {
pid: existing.pid,
hostname: existing.hostname,
started_at_unix: existing.started_at_unix,
session_dir: session_dir.to_path_buf(),
}));
}
eprintln!(
"warning: recovered stale session lock at {} \
(pid {} not running)",
lock_path.display(),
existing.pid,
);
}
}
Err(_) => {
}
}
std::fs::write(&lock_path, &sentinel)?;
Ok(Self { lock_path })
}
pub fn path(&self) -> &Path {
&self.lock_path
}
}
impl Drop for SessionLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.lock_path);
}
}
pub(crate) fn read_last_message_uuid(jsonl_path: &Path) -> Option<String> {
let file = std::fs::File::open(jsonl_path).ok()?;
let reader = std::io::BufReader::new(file);
let mut last = None;
for line in reader.lines().map_while(Result::ok) {
if line.trim().is_empty() {
continue;
}
if let Ok(entry) = serde_json::from_str::<super::serialize::TranscriptEntry>(&line) {
if !entry.uuid.is_empty() {
last = Some(entry.uuid);
}
}
}
last
}
pub fn truncate_transcript_to_turn(
session_dir: &Path,
cutoff_turn: usize,
) -> std::io::Result<TruncateStats> {
let jsonl_path = session_dir.join("transcript.jsonl");
if !jsonl_path.exists() {
return Ok(TruncateStats {
kept: 0,
dropped: 0,
});
}
let file = std::fs::File::open(&jsonl_path)?;
let reader = std::io::BufReader::new(file);
let tmp_path = jsonl_path.with_extension("jsonl.rewind-tmp");
let tmp = std::fs::File::create(&tmp_path)?;
let mut writer = BufWriter::new(tmp);
let mut user_seen = 0usize;
let mut kept = 0u64;
let mut dropped = 0u64;
let mut stop = false;
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
if stop {
dropped += 1;
continue;
}
let role = serde_json::from_str::<serde_json::Value>(&line)
.ok()
.and_then(|v| v.get("role").and_then(|r| r.as_str()).map(str::to_string));
let is_turn_boundary = matches!(role.as_deref(), Some("user"));
if is_turn_boundary {
if user_seen >= cutoff_turn {
stop = true;
dropped += 1;
continue;
}
user_seen += 1;
}
writer.write_all(line.as_bytes())?;
writer.write_all(b"\n")?;
kept += 1;
}
writer.flush()?;
drop(writer);
std::fs::rename(&tmp_path, &jsonl_path)?;
let meta_path = session_dir.join(".meta.json");
if meta_path.exists() {
if let Ok(bytes) = std::fs::read(&meta_path) {
if let Ok(mut meta) = serde_json::from_slice::<super::SessionMeta>(&bytes) {
meta.message_count = kept;
meta.updated_at = super::chrono_lite_now();
if let Ok(json) = serde_json::to_string_pretty(&meta) {
let _ = crate::atomic::atomic_write(&meta_path, json.as_bytes());
}
}
}
}
Ok(TruncateStats { kept, dropped })
}
#[derive(Debug, Clone, Copy)]
pub struct TruncateStats {
pub kept: u64,
pub dropped: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::Message;
use crate::session::{SessionReader, SessionStatus, SessionWriter};
#[test]
fn sentinel_parse_reads_three_lines() {
let text = "12345\nmyhostname\n1700000000\n";
let rec = SentinelInfo::parse(text).expect("must parse");
assert_eq!(rec.pid, 12345);
assert_eq!(rec.hostname, "myhostname");
assert_eq!(rec.started_at_unix, 1_700_000_000);
}
#[test]
fn sentinel_serialise_round_trips() {
let rec = SentinelInfo {
pid: 99,
hostname: "box01".to_string(),
started_at_unix: 42,
};
let text = rec.serialise();
let restored = SentinelInfo::parse(&text).expect("round-trip must succeed");
assert_eq!(restored.pid, 99);
assert_eq!(restored.hostname, "box01");
assert_eq!(restored.started_at_unix, 42);
}
#[test]
fn current_hostname_is_non_empty() {
let h = current_hostname();
assert!(!h.is_empty(), "hostname must be non-empty");
}
#[test]
fn lock_dead_pid_recovered() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let session_dir = tmp.path().join("session-B");
std::fs::create_dir_all(&session_dir).unwrap();
let stale = SentinelInfo {
pid: u32::MAX,
hostname: current_hostname(),
started_at_unix: 0,
};
std::fs::write(session_dir.join(SESSION_LOCK_FILE), stale.serialise()).unwrap();
let lock = SessionLock::acquire(&session_dir).unwrap();
let raw = std::fs::read_to_string(lock.path()).unwrap();
let parsed = SentinelInfo::parse(&raw).unwrap();
assert_eq!(parsed.pid, std::process::id());
}
#[test]
fn lock_cross_host_aborts() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let session_dir = tmp.path().join("session-C");
std::fs::create_dir_all(&session_dir).unwrap();
let cross = SentinelInfo {
pid: u32::MAX,
hostname: "definitely-not-our-host-123".to_string(),
started_at_unix: 0,
};
std::fs::write(session_dir.join(SESSION_LOCK_FILE), cross.serialise()).unwrap();
let err = SessionLock::acquire(&session_dir).expect_err("cross-host should fail");
assert!(
err.to_string().contains("definitely-not-our-host-123"),
"expected cross-host error to mention recorded host, got: {err}"
);
}
#[test]
fn lock_released_on_drop() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let session_dir = tmp.path().join("session-D");
std::fs::create_dir_all(&session_dir).unwrap();
let lock = SessionLock::acquire(&session_dir).unwrap();
assert!(lock.path().exists());
drop(lock);
assert!(
!session_dir.join(SESSION_LOCK_FILE).exists(),
"sentinel must be removed on Drop"
);
let _lock2 = SessionLock::acquire(&session_dir).unwrap();
}
#[test]
fn lock_alive_pid_blocks_acquire() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let session_dir = tmp.path().join("session-A");
std::fs::create_dir_all(&session_dir).unwrap();
let lock = SessionLock::acquire(&session_dir).unwrap();
let err = SessionLock::acquire(&session_dir).expect_err("second acquire should fail");
assert!(
err.to_string()
.contains(&format!("pid {}", std::process::id())),
"expected error to mention our pid {}, got: {}",
std::process::id(),
err
);
drop(lock);
}
#[test]
fn truncate_transcript_to_turn_drops_at_user_boundary() {
let dir = crate::test_util::IsolatedWorkspace::new();
let mut w = SessionWriter::create(dir.path(), "g", "m", "p").unwrap();
w.append(&Message::system("sys".to_string()), None, None)
.unwrap();
w.append(&Message::user("u0".to_string()), None, None)
.unwrap();
w.append(&Message::assistant("a0".to_string()), None, None)
.unwrap();
w.append(&Message::user("u1".to_string()), None, None)
.unwrap();
w.append(&Message::assistant("a1".to_string()), None, None)
.unwrap();
w.append(&Message::user("u2".to_string()), None, None)
.unwrap();
w.append(&Message::assistant("a2".to_string()), None, None)
.unwrap();
w.finish(SessionStatus::Completed).unwrap();
let session_dir = w.session_dir().to_path_buf();
let stats = truncate_transcript_to_turn(&session_dir, 1).unwrap();
assert_eq!(stats.kept, 3);
assert_eq!(stats.dropped, 4);
let entries = SessionReader::load_transcript(&session_dir).unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].role, "system");
assert_eq!(entries[1].role, "user");
assert_eq!(entries[1].content, "u0");
assert_eq!(entries[2].role, "assistant");
assert_eq!(entries[2].content, "a0");
let meta = SessionReader::load_meta(&session_dir).unwrap();
assert_eq!(meta.message_count, 3);
}
#[test]
fn truncate_transcript_to_zero_drops_all_turns_keeps_system() {
let dir = crate::test_util::IsolatedWorkspace::new();
let mut w = SessionWriter::create(dir.path(), "g", "m", "p").unwrap();
w.append(&Message::system("sys".to_string()), None, None)
.unwrap();
w.append(&Message::user("u0".to_string()), None, None)
.unwrap();
w.append(&Message::assistant("a0".to_string()), None, None)
.unwrap();
w.finish(SessionStatus::Completed).unwrap();
let session_dir = w.session_dir().to_path_buf();
let stats = truncate_transcript_to_turn(&session_dir, 0).unwrap();
assert_eq!(stats.kept, 1, "system message should remain");
assert_eq!(stats.dropped, 2);
let entries = SessionReader::load_transcript(&session_dir).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].role, "system");
}
#[test]
fn truncate_transcript_missing_file_is_noop() {
let dir = crate::test_util::IsolatedWorkspace::new();
let stats = truncate_transcript_to_turn(dir.path(), 5).unwrap();
assert_eq!(stats.kept, 0);
assert_eq!(stats.dropped, 0);
}
#[test]
fn truncate_transcript_cutoff_beyond_end_keeps_all() {
let dir = crate::test_util::IsolatedWorkspace::new();
let mut w = SessionWriter::create(dir.path(), "g", "m", "p").unwrap();
w.append(&Message::system("sys".to_string()), None, None)
.unwrap();
w.append(&Message::user("u0".to_string()), None, None)
.unwrap();
w.append(&Message::assistant("a0".to_string()), None, None)
.unwrap();
w.finish(SessionStatus::Completed).unwrap();
let session_dir = w.session_dir().to_path_buf();
let stats = truncate_transcript_to_turn(&session_dir, 99).unwrap();
assert_eq!(stats.dropped, 0, "cutoff beyond end must drop nothing");
assert_eq!(stats.kept, 3, "all 3 messages must be kept");
}
#[test]
fn truncate_transcript_exact_boundary_drops_last_turn() {
let dir = crate::test_util::IsolatedWorkspace::new();
let mut w = SessionWriter::create(dir.path(), "g", "m", "p").unwrap();
w.append(&Message::user("u0".to_string()), None, None)
.unwrap();
w.append(&Message::assistant("a0".to_string()), None, None)
.unwrap();
w.append(&Message::user("u1".to_string()), None, None)
.unwrap();
w.append(&Message::assistant("a1".to_string()), None, None)
.unwrap();
w.finish(SessionStatus::Completed).unwrap();
let session_dir = w.session_dir().to_path_buf();
let stats = truncate_transcript_to_turn(&session_dir, 1).unwrap();
assert_eq!(stats.kept, 2, "must keep u0+a0");
assert_eq!(stats.dropped, 2, "must drop u1+a1");
}
}