use std::ops::ControlFlow;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::fs::{self, File, OpenOptions};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::Mutex;
use crate::error::SessionError;
use crate::event::{SessionEvent, SessionEventEnvelope};
const EVENTS_FILE_NAME: &str = "events.jsonl";
#[cfg(unix)]
const LOCK_FILE_NAME: &str = "events.jsonl.lock";
const REPLAY_CHUNK_SIZE: usize = 100;
pub struct SessionEventLog {
events_path: PathBuf,
writer: Mutex<File>,
next_seq: AtomicU64,
#[allow(dead_code)] lock: Option<AdvisoryLock>,
}
impl SessionEventLog {
pub async fn open(session_dir: &Path) -> Result<Self, SessionError> {
Self::open_with_lock(session_dir, None).await
}
pub async fn open_exclusive(session_dir: &Path) -> Result<Self, SessionError> {
fs::create_dir_all(session_dir).await?;
let lock = AdvisoryLock::acquire(session_dir)?;
Self::open_with_lock(session_dir, Some(lock)).await
}
async fn open_with_lock(
session_dir: &Path,
lock: Option<AdvisoryLock>,
) -> Result<Self, SessionError> {
fs::create_dir_all(session_dir).await?;
set_permissions(session_dir, 0o700).await?;
let events_path = session_dir.join(EVENTS_FILE_NAME);
let (_, max_seq) = read_events(&events_path, lock.is_some()).await?;
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&events_path)
.await?;
set_permissions(&events_path, 0o600).await?;
let next_seq = max_seq.map_or(0, |seq| seq + 1);
Ok(Self {
events_path,
writer: Mutex::new(file),
next_seq: AtomicU64::new(next_seq),
lock,
})
}
#[must_use]
pub fn path(&self) -> &Path {
&self.events_path
}
#[must_use]
pub fn last_seq(&self) -> Option<u64> {
let next = self.next_seq.load(Ordering::SeqCst);
next.checked_sub(1)
}
#[tracing::instrument(name = "session.log.append", skip_all, level = "debug")]
pub async fn append(
&self,
turn_id: Option<u64>,
parent_seq: Option<u64>,
kind: SessionEvent,
) -> Result<SessionEventEnvelope, SessionError> {
let mut file = self.writer.lock().await;
let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
let envelope = SessionEventEnvelope::new(seq, turn_id, parent_seq, kind);
let mut line = serde_json::to_vec(&envelope)?;
line.push(b'\n');
file.write_all(&line).await?;
file.sync_all().await?;
Ok(envelope)
}
#[tracing::instrument(name = "session.log.read_all", skip_all, level = "debug")]
pub async fn read_all(&self) -> Result<Vec<SessionEventEnvelope>, SessionError> {
let (events, _) = read_events(&self.events_path, self.lock.is_some()).await?;
Ok(events)
}
#[tracing::instrument(name = "session.log.read_chunked", skip_all, level = "debug")]
pub(crate) async fn read_chunked(
&self,
on_chunk: impl FnMut(Vec<SessionEventEnvelope>) -> ControlFlow<()>,
) -> Result<(), SessionError> {
read_events_chunked(&self.events_path, self.lock.is_some(), on_chunk).await
}
}
enum LineOutcome {
Eof,
Blank,
Event(SessionEventEnvelope),
Torn,
}
struct EventLineReader {
reader: BufReader<File>,
line: String,
offset: u64,
valid_len: u64,
}
impl EventLineReader {
async fn open(path: &Path) -> Result<Option<Self>, SessionError> {
let file = match File::open(path).await {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
Ok(Some(Self {
reader: BufReader::new(file),
line: String::new(),
offset: 0,
valid_len: 0,
}))
}
async fn next_line(&mut self) -> Result<LineOutcome, SessionError> {
self.line.clear();
let bytes_read = self.reader.read_line(&mut self.line).await? as u64;
if bytes_read == 0 {
return Ok(LineOutcome::Eof);
}
let is_terminated = self.line.ends_with('\n');
let trimmed = self.line.trim_end_matches(['\n', '\r']);
if trimmed.is_empty() {
self.offset += bytes_read;
if is_terminated {
self.valid_len = self.offset;
}
return Ok(LineOutcome::Blank);
}
match serde_json::from_str::<SessionEventEnvelope>(trimmed) {
Ok(envelope) if is_terminated => {
self.offset += bytes_read;
self.valid_len = self.offset;
Ok(LineOutcome::Event(envelope))
}
_ => Ok(LineOutcome::Torn),
}
}
}
async fn repair_torn_tail(path: &Path, valid_len: u64) -> Result<(), SessionError> {
let actual_len = fs::metadata(path).await?.len();
if valid_len < actual_len {
let file = OpenOptions::new().write(true).open(path).await?;
file.set_len(valid_len).await?;
}
Ok(())
}
async fn finish_torn_tail(
path: &Path,
valid_len: u64,
repair: bool,
torn: bool,
) -> Result<(), SessionError> {
if torn {
tracing::warn!(
path = %path.display(),
valid_len,
repair,
"dropped torn tail in session event log (INV-SP-2)"
);
}
if repair {
repair_torn_tail(path, valid_len).await?;
}
Ok(())
}
async fn read_events(
path: &Path,
repair: bool,
) -> Result<(Vec<SessionEventEnvelope>, Option<u64>), SessionError> {
let Some(mut lines) = EventLineReader::open(path).await? else {
return Ok((Vec::new(), None));
};
let mut events = Vec::new();
let mut max_seq = None;
let mut torn = false;
loop {
match lines.next_line().await? {
LineOutcome::Eof => break,
LineOutcome::Blank => {}
LineOutcome::Event(envelope) => {
max_seq = Some(max_seq.map_or(envelope.seq, |m: u64| m.max(envelope.seq)));
events.push(envelope);
}
LineOutcome::Torn => {
torn = true;
break;
}
}
}
let valid_len = lines.valid_len;
drop(lines);
finish_torn_tail(path, valid_len, repair, torn).await?;
Ok((events, max_seq))
}
async fn read_events_chunked(
path: &Path,
repair: bool,
mut on_chunk: impl FnMut(Vec<SessionEventEnvelope>) -> ControlFlow<()>,
) -> Result<(), SessionError> {
let Some(mut lines) = EventLineReader::open(path).await? else {
return Ok(());
};
let mut chunk = Vec::with_capacity(REPLAY_CHUNK_SIZE);
let mut torn = false;
let mut broke_early = false;
loop {
match lines.next_line().await? {
LineOutcome::Eof => break,
LineOutcome::Blank => {}
LineOutcome::Event(envelope) => {
chunk.push(envelope);
if chunk.len() >= REPLAY_CHUNK_SIZE {
let flushed =
std::mem::replace(&mut chunk, Vec::with_capacity(REPLAY_CHUNK_SIZE));
if on_chunk(flushed).is_break() {
broke_early = true;
break;
}
}
}
LineOutcome::Torn => {
torn = true;
break;
}
}
}
if !broke_early && !chunk.is_empty() && on_chunk(chunk).is_break() {
broke_early = true;
}
if broke_early {
return Ok(());
}
let valid_len = lines.valid_len;
drop(lines);
finish_torn_tail(path, valid_len, repair, torn).await?;
Ok(())
}
#[cfg(unix)]
pub(crate) async fn set_permissions(path: &Path, mode: u32) -> Result<(), SessionError> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).await?;
Ok(())
}
#[cfg(unix)]
struct AdvisoryLock(#[allow(dead_code)] rustix::fd::OwnedFd);
#[cfg(unix)]
impl AdvisoryLock {
fn acquire(session_dir: &Path) -> Result<Self, SessionError> {
use rustix::fs::{FlockOperation, Mode, OFlags};
let lock_path = session_dir.join(LOCK_FILE_NAME);
let fd = rustix::fs::open(
&lock_path,
OFlags::RDWR | OFlags::CREATE | OFlags::CLOEXEC,
Mode::from_raw_mode(0o600),
)
.map_err(std::io::Error::from)?;
rustix::fs::flock(&fd, FlockOperation::NonBlockingLockExclusive).map_err(|e| {
if e == rustix::io::Errno::WOULDBLOCK {
SessionError::AlreadyLocked(lock_path.display().to_string())
} else {
SessionError::Io(e.into())
}
})?;
Ok(Self(fd))
}
}
#[cfg(not(unix))]
struct AdvisoryLock;
#[cfg(not(unix))]
impl AdvisoryLock {
fn acquire(_session_dir: &Path) -> Result<Self, SessionError> {
Ok(Self)
}
}
#[cfg(not(unix))]
pub(crate) async fn set_permissions(_path: &Path, _mode: u32) -> Result<(), SessionError> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_append_and_read_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
for i in 0..5u64 {
log.append(
Some(i),
None,
SessionEvent::UserMessage {
text: format!("msg-{i}"),
image_refs: vec![],
},
)
.await
.unwrap();
}
assert_eq!(log.last_seq(), Some(4));
let events = log.read_all().await.unwrap();
assert_eq!(events.len(), 5);
for (i, envelope) in events.iter().enumerate() {
assert_eq!(envelope.seq, i as u64);
}
}
#[tokio::test]
async fn test_reopen_resumes_seq() {
let dir = tempfile::tempdir().unwrap();
{
let log = SessionEventLog::open(dir.path()).await.unwrap();
log.append(
None,
None,
SessionEvent::SessionEnded { reason: "x".into() },
)
.await
.unwrap();
}
let log = SessionEventLog::open(dir.path()).await.unwrap();
assert_eq!(log.last_seq(), Some(0));
let appended = log
.append(
None,
None,
SessionEvent::SessionEnded { reason: "y".into() },
)
.await
.unwrap();
assert_eq!(appended.seq, 1);
}
#[tokio::test]
async fn test_torn_write_truncation() {
let dir = tempfile::tempdir().unwrap();
let path;
{
let log = SessionEventLog::open(dir.path()).await.unwrap();
for i in 0..3u64 {
log.append(
None,
None,
SessionEvent::UserMessage {
text: format!("msg-{i}"),
image_refs: vec![],
},
)
.await
.unwrap();
}
path = log.path().to_path_buf();
}
let full = tokio::fs::read(&path).await.unwrap();
let cut = full.len() - 5;
tokio::fs::write(&path, &full[..cut]).await.unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
assert_eq!(
log.last_seq(),
Some(1),
"torn last line must be dropped cleanly"
);
let events = log.read_all().await.unwrap();
assert_eq!(events.len(), 2);
}
#[cfg(unix)]
#[tokio::test]
async fn test_open_does_not_physically_truncate_torn_tail() {
let dir = tempfile::tempdir().unwrap();
let path;
{
let log = SessionEventLog::open(dir.path()).await.unwrap();
for i in 0..3u64 {
log.append(
None,
None,
SessionEvent::UserMessage {
text: format!("msg-{i}"),
image_refs: vec![],
},
)
.await
.unwrap();
}
path = log.path().to_path_buf();
}
let full = tokio::fs::read(&path).await.unwrap();
let cut = full.len() - 5;
tokio::fs::write(&path, &full[..cut]).await.unwrap();
let torn_len = tokio::fs::metadata(&path).await.unwrap().len();
let log = SessionEventLog::open(dir.path()).await.unwrap();
assert_eq!(log.last_seq(), Some(1));
let events = log.read_all().await.unwrap();
assert_eq!(events.len(), 2);
assert_eq!(
tokio::fs::metadata(&path).await.unwrap().len(),
torn_len,
"open()/read_all() must never physically truncate the file"
);
drop(log);
let log = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
assert_eq!(log.last_seq(), Some(1));
let repaired_len = tokio::fs::metadata(&path).await.unwrap().len();
assert!(
repaired_len < torn_len,
"open_exclusive() must physically truncate the torn tail"
);
}
#[tokio::test]
async fn test_torn_write_truncation_various_offsets() {
for cut_from_end in [1usize, 3, 10, 20] {
let dir = tempfile::tempdir().unwrap();
let path;
{
let log = SessionEventLog::open(dir.path()).await.unwrap();
for i in 0..4u64 {
log.append(
None,
None,
SessionEvent::UserMessage {
text: format!("event-number-{i}"),
image_refs: vec![],
},
)
.await
.unwrap();
}
path = log.path().to_path_buf();
}
let full = tokio::fs::read(&path).await.unwrap();
let cut = full.len().saturating_sub(cut_from_end);
tokio::fs::write(&path, &full[..cut]).await.unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
let events = log.read_all().await.unwrap();
assert!(events.len() <= 4);
}
}
#[tokio::test]
async fn test_empty_log_read_all() {
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
assert_eq!(log.last_seq(), None);
assert!(log.read_all().await.unwrap().is_empty());
}
#[cfg(unix)]
#[tokio::test]
async fn test_file_permissions_are_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
let meta = tokio::fs::metadata(log.path()).await.unwrap();
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
}
#[tokio::test]
async fn test_max_seq_survives_out_of_order_physical_lines() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(EVENTS_FILE_NAME);
let make_line = |seq: u64| {
let envelope = SessionEventEnvelope::new(
seq,
None,
None,
SessionEvent::SessionEnded { reason: "x".into() },
);
let mut line = serde_json::to_vec(&envelope).unwrap();
line.push(b'\n');
line
};
let mut contents = make_line(7);
contents.extend(make_line(6));
tokio::fs::write(&path, &contents).await.unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
assert_eq!(
log.last_seq(),
Some(7),
"next_seq must be derived from the true max seq, not the last physical line"
);
let appended = log
.append(
None,
None,
SessionEvent::SessionEnded { reason: "z".into() },
)
.await
.unwrap();
assert_eq!(
appended.seq, 8,
"must not reuse a seq already present earlier in the file"
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_open_exclusive_rejects_second_writer() {
let dir = tempfile::tempdir().unwrap();
let _first = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
match SessionEventLog::open_exclusive(dir.path()).await {
Err(SessionError::AlreadyLocked(_)) => {}
Err(e) => panic!("expected AlreadyLocked, got different error: {e}"),
Ok(_) => panic!("expected AlreadyLocked, but second open_exclusive succeeded"),
}
}
#[cfg(unix)]
#[tokio::test]
async fn test_open_exclusive_allows_reacquire_after_drop() {
let dir = tempfile::tempdir().unwrap();
{
let _first = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
}
let _second = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn test_open_is_not_blocked_by_open_exclusive() {
let dir = tempfile::tempdir().unwrap();
let _writer = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
let _reader = SessionEventLog::open(dir.path()).await.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn test_concurrent_append_preserves_seq_order() {
const N: u64 = 100;
let dir = tempfile::tempdir().unwrap();
let log = std::sync::Arc::new(SessionEventLog::open(dir.path()).await.unwrap());
let mut tasks = tokio::task::JoinSet::new();
for i in 0..N {
let log = log.clone();
tasks.spawn(async move {
log.append(
None,
None,
SessionEvent::UserMessage {
text: format!("msg-{i}"),
image_refs: vec![],
},
)
.await
.unwrap()
.seq
});
}
let mut assigned_seqs: Vec<u64> = tasks.join_all().await;
assigned_seqs.sort_unstable();
assert_eq!(
assigned_seqs,
(0..N).collect::<Vec<_>>(),
"every seq in 0..{N} must be assigned exactly once, with no gaps or duplicates"
);
let events = log.read_all().await.unwrap();
assert_eq!(events.len(), usize::try_from(N).unwrap());
for (i, envelope) in events.iter().enumerate() {
assert_eq!(
envelope.seq, i as u64,
"physical line {i} must carry seq {i}; seq and write order diverged"
);
}
}
#[tokio::test]
async fn test_read_chunked_bounds_memory_and_matches_whole_file_read() {
const N: u64 = 733;
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
for i in 0..N {
log.append(
None,
None,
SessionEvent::UserMessage {
text: format!("msg-{i}"),
image_refs: vec![],
},
)
.await
.unwrap();
}
let (whole_file_events, _) = read_events(log.path(), false).await.unwrap();
assert_eq!(whole_file_events.len(), usize::try_from(N).unwrap());
let mut chunked_events = Vec::new();
let mut chunk_sizes = Vec::new();
read_events_chunked(log.path(), false, |chunk| {
assert!(
chunk.len() <= REPLAY_CHUNK_SIZE,
"a single chunk must never exceed REPLAY_CHUNK_SIZE ({REPLAY_CHUNK_SIZE}), got {}",
chunk.len()
);
chunk_sizes.push(chunk.len());
chunked_events.extend(chunk);
ControlFlow::Continue(())
})
.await
.unwrap();
assert_eq!(
chunked_events.len(),
whole_file_events.len(),
"chunked read must yield the same total event count as the whole-file read"
);
for (whole, chunked) in whole_file_events.iter().zip(chunked_events.iter()) {
assert_eq!(whole.seq, chunked.seq);
}
assert!(
chunk_sizes.len() > 1,
"expected multiple chunks for N={N} events with REPLAY_CHUNK_SIZE={REPLAY_CHUNK_SIZE}"
);
}
}