zeph_session/log.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! The append-only JSONL event log: [`SessionEventLog`].
5//!
6//! Mirrors the append + fsync pattern of `zeph-durable`'s `JournalWriter`
7//! (`crates/zeph-durable/src/writer.rs`) at the conversation-semantics level, but persists to a
8//! plain JSONL file rather than a `SQLite`-backed journal (spec-068 §3, §14).
9//!
10//! # Invariants
11//!
12//! - INV-SP-1 (log-first ordering): callers must append to this log before updating any
13//! downstream projection (`SQLite` `messages`, `acp_sessions.last_seq`).
14//! - INV-SP-2 (torn-append truncation): every read validates each line and drops a garbled/
15//! incomplete trailing line from the in-memory result, which can only occur as the very last
16//! line because appends are serialized through a single writer (INV-D2). Only
17//! [`SessionEventLog::open_exclusive`] additionally repairs the torn tail physically on disk —
18//! a lockless [`SessionEventLog::open`]/[`SessionEventLog::read_all`] cannot prove a "torn"
19//! line isn't a live writer's in-flight, not-yet-fsynced append, so it must never mutate the
20//! file (#5487 Finding B).
21//! - INV-D2 (single writer): only the session's owning actor/agent process may hold a
22//! `SessionEventLog` for a given session directory at a time. [`SessionEventLog::open`]
23//! does not itself enforce cross-process exclusion — it is also used by read-only
24//! tooling (session export/inspection) that may legitimately run alongside a live
25//! writer. The session's owning actor/agent process should instead use
26//! [`SessionEventLog::open_exclusive`], which takes a non-blocking `flock(2)` advisory
27//! lock (Unix only) and fails with [`SessionError::AlreadyLocked`] if another writer
28//! already holds the session directory.
29
30use std::ops::ControlFlow;
31use std::path::{Path, PathBuf};
32use std::sync::atomic::{AtomicU64, Ordering};
33
34use tokio::fs::{self, File, OpenOptions};
35use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
36use tokio::sync::Mutex;
37
38use crate::error::SessionError;
39use crate::event::{SessionEvent, SessionEventEnvelope};
40
41const EVENTS_FILE_NAME: &str = "events.jsonl";
42const LOCK_FILE_NAME: &str = "events.jsonl.lock";
43
44/// Chunk size for [`SessionEventLog::read_chunked`] (spec §6.2 step 3: "bounded buffer, ≤ 100
45/// events in memory at once").
46const REPLAY_CHUNK_SIZE: usize = 100;
47
48/// Append-only JSONL log for one conversation-session's `events.jsonl`.
49///
50/// # Examples
51///
52/// ```
53/// use tempfile::tempdir;
54/// use zeph_session::event::SessionEvent;
55/// use zeph_session::log::SessionEventLog;
56///
57/// # #[tokio::main]
58/// # async fn main() {
59/// let dir = tempdir().unwrap();
60/// let log = SessionEventLog::open(dir.path()).await.unwrap();
61/// log.append(None, None, SessionEvent::SessionEnded { reason: "user_quit".to_owned() })
62/// .await
63/// .unwrap();
64/// assert_eq!(log.last_seq(), Some(0));
65/// # }
66/// ```
67pub struct SessionEventLog {
68 events_path: PathBuf,
69 writer: Mutex<File>,
70 next_seq: AtomicU64,
71 #[allow(dead_code)] // held only for its Drop (releases the flock, if taken)
72 lock: Option<AdvisoryLock>,
73}
74
75impl SessionEventLog {
76 /// Open (creating if absent) the `events.jsonl` log under `session_dir`.
77 ///
78 /// Validates the existing file per INV-SP-2, dropping a torn trailing line from the
79 /// in-memory result, then opens the file in append mode for subsequent writes. Sets
80 /// file/directory permissions to `0o700`/`0o600` on Unix (spec §4.1); a no-op on other
81 /// platforms.
82 ///
83 /// Does not take the cross-process advisory lock, and never physically truncates the file
84 /// (even if a torn tail is found) — safe for read-only tooling that may run alongside a live
85 /// writer whose in-flight, not-yet-fsynced line could otherwise be mistaken for "torn" and
86 /// destroyed (#5487 Finding B). The session's owning actor/agent process should use
87 /// [`Self::open_exclusive`] instead, which does perform the physical repair.
88 ///
89 /// # Errors
90 ///
91 /// Returns [`SessionError::Io`] if the directory or file cannot be created, or
92 /// [`SessionError::Serde`] surfaces only via [`Self::read_all`], never here (torn lines are
93 /// discarded, not treated as fatal).
94 pub async fn open(session_dir: &Path) -> Result<Self, SessionError> {
95 Self::open_with_lock(session_dir, None).await
96 }
97
98 /// Open the `events.jsonl` log under `session_dir` like [`Self::open`], but additionally
99 /// take a non-blocking, exclusive advisory lock (`flock(2)` on Unix, mirroring
100 /// `zeph-scheduler`'s `PidFile`) enforcing INV-D2's single-writer invariant.
101 ///
102 /// Intended for the session's owning actor/agent process. On non-Unix targets the lock
103 /// is a no-op (the workspace has no vetted cross-platform advisory-locking primitive), so
104 /// this degrades to [`Self::open`]'s behavior there.
105 ///
106 /// # Errors
107 ///
108 /// Returns [`SessionError::AlreadyLocked`] if another process already holds the session's
109 /// write lock, or any error [`Self::open`] can return.
110 pub async fn open_exclusive(session_dir: &Path) -> Result<Self, SessionError> {
111 fs::create_dir_all(session_dir).await?;
112 let lock = AdvisoryLock::acquire(session_dir)?;
113 Self::open_with_lock(session_dir, Some(lock)).await
114 }
115
116 async fn open_with_lock(
117 session_dir: &Path,
118 lock: Option<AdvisoryLock>,
119 ) -> Result<Self, SessionError> {
120 fs::create_dir_all(session_dir).await?;
121 set_permissions(session_dir, 0o700).await?;
122
123 let events_path = session_dir.join(EVENTS_FILE_NAME);
124 // Only the exclusive-lock holder may physically repair a torn tail (see
125 // `read_events`'s doc comment) — a lockless `open()` cannot prove the "torn" line
126 // isn't a live writer's in-flight, not-yet-fsynced append.
127 let (_, max_seq) = read_events(&events_path, lock.is_some()).await?;
128
129 let file = OpenOptions::new()
130 .create(true)
131 .append(true)
132 .open(&events_path)
133 .await?;
134 set_permissions(&events_path, 0o600).await?;
135
136 let next_seq = max_seq.map_or(0, |seq| seq + 1);
137 Ok(Self {
138 events_path,
139 writer: Mutex::new(file),
140 next_seq: AtomicU64::new(next_seq),
141 lock,
142 })
143 }
144
145 /// The path to this session's `events.jsonl` file.
146 #[must_use]
147 pub fn path(&self) -> &Path {
148 &self.events_path
149 }
150
151 /// The highest `seq` durably appended so far, or `None` if the log is empty.
152 #[must_use]
153 pub fn last_seq(&self) -> Option<u64> {
154 let next = self.next_seq.load(Ordering::SeqCst);
155 next.checked_sub(1)
156 }
157
158 /// Append one event, assigning it the next monotonic `seq`, and `fsync` before returning.
159 ///
160 /// The single `write_all` + `sync_all` pair is the atomicity boundary INV-SP-2 relies on: a
161 /// crash mid-write can only ever corrupt this one trailing line.
162 ///
163 /// # Errors
164 ///
165 /// Returns [`SessionError::Serde`] if the event cannot be JSON-encoded, or
166 /// [`SessionError::Io`] if the write or fsync fails.
167 #[tracing::instrument(name = "session.log.append", skip_all, level = "debug")]
168 pub async fn append(
169 &self,
170 turn_id: Option<u64>,
171 parent_seq: Option<u64>,
172 kind: SessionEvent,
173 ) -> Result<SessionEventEnvelope, SessionError> {
174 let mut file = self.writer.lock().await;
175
176 // seq assignment MUST happen while holding the writer lock: two concurrent
177 // callers assigned seq N and N+1 before the lock could still race for the
178 // lock and land their physical writes in the opposite order, breaking
179 // INV-SP-2's ascending-seq-order assumption (#5487).
180 let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
181 let envelope = SessionEventEnvelope::new(seq, turn_id, parent_seq, kind);
182
183 let mut line = serde_json::to_vec(&envelope)?;
184 line.push(b'\n');
185
186 file.write_all(&line).await?;
187 file.sync_all().await?;
188
189 Ok(envelope)
190 }
191
192 /// Read and validate every event currently in the log, dropping a torn trailing line from
193 /// the result (INV-SP-2). Only physically repairs the file if this handle was opened via
194 /// [`Self::open_exclusive`] — see that method's doc comment.
195 ///
196 /// # Errors
197 ///
198 /// Returns [`SessionError::Io`] if the file cannot be read.
199 #[tracing::instrument(name = "session.log.read_all", skip_all, level = "debug")]
200 pub async fn read_all(&self) -> Result<Vec<SessionEventEnvelope>, SessionError> {
201 // Same repair gating as `open_with_lock`: only repair the physical file when this
202 // handle holds the exclusive lock (i.e. is the session's owning writer). A read-only
203 // handle (`open()`) calling `read_all()` — e.g. `sessions show --events`, the ACP HTTP
204 // inspection endpoint — must never truncate a live writer's in-flight tail out from
205 // under it (#5487 Finding B).
206 let (events, _) = read_events(&self.events_path, self.lock.is_some()).await?;
207 Ok(events)
208 }
209
210 /// Read this log's events in bounded chunks of at most [`REPLAY_CHUNK_SIZE`], invoking
211 /// `on_chunk` per chunk instead of materializing the whole file's parsed events into one
212 /// `Vec` the way [`Self::read_all`] does (spec §6.2 step 3). Used by
213 /// [`crate::replay::ReplayEngine::replay`] to keep peak memory bounded when replaying large
214 /// session logs.
215 ///
216 /// `on_chunk` returns [`ControlFlow::Break`] to stop reading early (e.g. once a replay
217 /// `up_to` bound is reached) — remaining lines, including any torn tail beyond the stop
218 /// point, are then left uninspected.
219 ///
220 /// Note the over-read this implies: when `up_to` falls inside a chunk still being
221 /// accumulated, that entire chunk (up to [`REPLAY_CHUNK_SIZE`] events) is read and parsed
222 /// from disk before `on_chunk` gets a chance to evaluate the break — this never exceeds the
223 /// ≤ [`REPLAY_CHUNK_SIZE`]-in-memory bound, but a future refactor must not assume the read
224 /// stops the instant the `up_to` seq is reached.
225 ///
226 /// Same torn-tail detection/repair gating as [`Self::read_all`]: only physically repairs
227 /// the file when this handle was opened via [`Self::open_exclusive`].
228 ///
229 /// # Errors
230 ///
231 /// Returns [`SessionError::Io`] if the file cannot be read.
232 #[tracing::instrument(name = "session.log.read_chunked", skip_all, level = "debug")]
233 pub(crate) async fn read_chunked(
234 &self,
235 on_chunk: impl FnMut(Vec<SessionEventEnvelope>) -> ControlFlow<()>,
236 ) -> Result<(), SessionError> {
237 read_events_chunked(&self.events_path, self.lock.is_some(), on_chunk).await
238 }
239}
240
241/// The outcome of parsing one physical line from an `events.jsonl` file.
242enum LineOutcome {
243 /// End of file reached (0 bytes read).
244 Eof,
245 /// A blank line (allowed, e.g. trailing newline) — no envelope produced.
246 Blank,
247 /// A well-formed, newline-terminated envelope.
248 Event(SessionEventEnvelope),
249 /// A garbled or unterminated line — the torn tail (INV-SP-2). Can only be the final line
250 /// because appends are serialized through a single writer (INV-D2).
251 Torn,
252}
253
254/// Line-oriented cursor over an `events.jsonl` file, shared by [`read_events`] (whole-file,
255/// `Vec`-accumulating) and [`read_events_chunked`] (bounded-chunk streaming) so both read paths
256/// apply identical per-line validation (INV-SP-2).
257struct EventLineReader {
258 reader: BufReader<File>,
259 line: String,
260 offset: u64,
261 valid_len: u64,
262}
263
264impl EventLineReader {
265 /// Opens `path`, returning `None` if the file does not exist (an empty/absent log).
266 async fn open(path: &Path) -> Result<Option<Self>, SessionError> {
267 let file = match File::open(path).await {
268 Ok(file) => file,
269 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
270 Err(e) => return Err(e.into()),
271 };
272 Ok(Some(Self {
273 reader: BufReader::new(file),
274 line: String::new(),
275 offset: 0,
276 valid_len: 0,
277 }))
278 }
279
280 async fn next_line(&mut self) -> Result<LineOutcome, SessionError> {
281 self.line.clear();
282 let bytes_read = self.reader.read_line(&mut self.line).await? as u64;
283 if bytes_read == 0 {
284 return Ok(LineOutcome::Eof);
285 }
286
287 let is_terminated = self.line.ends_with('\n');
288 let trimmed = self.line.trim_end_matches(['\n', '\r']);
289 if trimmed.is_empty() {
290 self.offset += bytes_read;
291 if is_terminated {
292 self.valid_len = self.offset;
293 }
294 return Ok(LineOutcome::Blank);
295 }
296
297 match serde_json::from_str::<SessionEventEnvelope>(trimmed) {
298 Ok(envelope) if is_terminated => {
299 self.offset += bytes_read;
300 self.valid_len = self.offset;
301 Ok(LineOutcome::Event(envelope))
302 }
303 _ => Ok(LineOutcome::Torn),
304 }
305 }
306}
307
308/// Physically truncates `path` to `valid_len` if it is shorter than the file's actual length,
309/// repairing a torn tail on disk (INV-SP-2). Only called when `repair` gating (see
310/// [`SessionEventLog::open_exclusive`]) has already authorized it.
311async fn repair_torn_tail(path: &Path, valid_len: u64) -> Result<(), SessionError> {
312 let actual_len = fs::metadata(path).await?.len();
313 if valid_len < actual_len {
314 let file = OpenOptions::new().write(true).open(path).await?;
315 file.set_len(valid_len).await?;
316 }
317 Ok(())
318}
319
320/// Read every valid line of `path`, dropping a garbled/incomplete trailing line from the
321/// in-memory result (INV-SP-2).
322///
323/// When `repair` is `true`, additionally truncates that torn tail physically on disk. Only the
324/// session's exclusive-lock holder (see [`SessionEventLog::open_exclusive`]) may pass `true`: it
325/// is the only caller that can prove a "torn" trailing line isn't actually a live writer's
326/// in-flight, not-yet-fsynced append (#5487 Finding B) — a lockless reader physically truncating
327/// the file could destroy a concurrent writer's tail out from under it.
328///
329/// Returns the validated events and the maximum `seq` seen (`None` for an empty/absent log).
330async fn read_events(
331 path: &Path,
332 repair: bool,
333) -> Result<(Vec<SessionEventEnvelope>, Option<u64>), SessionError> {
334 let Some(mut lines) = EventLineReader::open(path).await? else {
335 return Ok((Vec::new(), None));
336 };
337
338 let mut events = Vec::new();
339 let mut max_seq = None;
340 let mut torn = false;
341
342 loop {
343 match lines.next_line().await? {
344 LineOutcome::Eof => break,
345 LineOutcome::Blank => {}
346 LineOutcome::Event(envelope) => {
347 // Track the true running maximum, not just the last line's value: a
348 // file whose physical order doesn't match seq order (e.g. from a
349 // pre-fix #5487 race) must still yield the correct next seq.
350 max_seq = Some(max_seq.map_or(envelope.seq, |m: u64| m.max(envelope.seq)));
351 events.push(envelope);
352 }
353 LineOutcome::Torn => {
354 torn = true;
355 break;
356 }
357 }
358 }
359 let valid_len = lines.valid_len;
360 drop(lines);
361
362 if torn {
363 tracing::warn!(
364 path = %path.display(),
365 valid_len,
366 repair,
367 "dropped torn tail in session event log (INV-SP-2)"
368 );
369 }
370
371 if repair {
372 repair_torn_tail(path, valid_len).await?;
373 }
374
375 Ok((events, max_seq))
376}
377
378/// Read `path`'s events in bounded chunks of at most [`REPLAY_CHUNK_SIZE`], invoking `on_chunk`
379/// for each chunk instead of materializing the whole file into one `Vec` (spec §6.2 step 3).
380/// Torn-tail detection/repair semantics match [`read_events`] exactly — the torn check happens
381/// once, when EOF is reached (or not at all, if `on_chunk` breaks early).
382async fn read_events_chunked(
383 path: &Path,
384 repair: bool,
385 mut on_chunk: impl FnMut(Vec<SessionEventEnvelope>) -> ControlFlow<()>,
386) -> Result<(), SessionError> {
387 let Some(mut lines) = EventLineReader::open(path).await? else {
388 return Ok(());
389 };
390
391 let mut chunk = Vec::with_capacity(REPLAY_CHUNK_SIZE);
392 let mut torn = false;
393 let mut broke_early = false;
394
395 loop {
396 match lines.next_line().await? {
397 LineOutcome::Eof => break,
398 LineOutcome::Blank => {}
399 LineOutcome::Event(envelope) => {
400 chunk.push(envelope);
401 if chunk.len() >= REPLAY_CHUNK_SIZE {
402 let flushed =
403 std::mem::replace(&mut chunk, Vec::with_capacity(REPLAY_CHUNK_SIZE));
404 if on_chunk(flushed).is_break() {
405 broke_early = true;
406 break;
407 }
408 }
409 }
410 LineOutcome::Torn => {
411 torn = true;
412 break;
413 }
414 }
415 }
416
417 if !broke_early && !chunk.is_empty() && on_chunk(chunk).is_break() {
418 broke_early = true;
419 }
420
421 // An early `Break` means the caller (e.g. replay's `up_to` bound) stopped before EOF —
422 // whatever lies beyond that point, torn or not, is irrelevant to this read.
423 if broke_early {
424 return Ok(());
425 }
426
427 let valid_len = lines.valid_len;
428 drop(lines);
429
430 if torn {
431 tracing::warn!(
432 path = %path.display(),
433 valid_len,
434 repair,
435 "dropped torn tail in session event log (INV-SP-2)"
436 );
437 }
438
439 if repair {
440 repair_torn_tail(path, valid_len).await?;
441 }
442
443 Ok(())
444}
445
446#[cfg(unix)]
447async fn set_permissions(path: &Path, mode: u32) -> Result<(), SessionError> {
448 use std::os::unix::fs::PermissionsExt;
449 fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).await?;
450 Ok(())
451}
452
453/// Cross-process advisory lock enforcing INV-D2's single-writer invariant, held for the
454/// lifetime of a [`SessionEventLog`] opened via [`SessionEventLog::open_exclusive`].
455///
456/// Backed by `flock(2)` on a sibling lock file (`events.jsonl.lock`) rather than
457/// `events.jsonl` itself, so the lock is independent of the append-mode file handle already
458/// held for writing. Mirrors `zeph-scheduler`'s `PidFile`, but — unlike a pid file — the lock
459/// file is never unlinked on drop: it is a permanent sentinel, not ephemeral process identity,
460/// and unlinking it would reopen an unlink/re-create race between the releasing and the next
461/// acquiring process.
462#[cfg(unix)]
463struct AdvisoryLock(#[allow(dead_code)] rustix::fd::OwnedFd);
464
465#[cfg(unix)]
466impl AdvisoryLock {
467 fn acquire(session_dir: &Path) -> Result<Self, SessionError> {
468 use rustix::fs::{FlockOperation, Mode, OFlags};
469
470 let lock_path = session_dir.join(LOCK_FILE_NAME);
471 let fd = rustix::fs::open(
472 &lock_path,
473 OFlags::RDWR | OFlags::CREATE | OFlags::CLOEXEC,
474 Mode::from_raw_mode(0o600),
475 )
476 .map_err(std::io::Error::from)?;
477
478 rustix::fs::flock(&fd, FlockOperation::NonBlockingLockExclusive).map_err(|e| {
479 if e == rustix::io::Errno::WOULDBLOCK {
480 SessionError::AlreadyLocked(lock_path.display().to_string())
481 } else {
482 SessionError::Io(e.into())
483 }
484 })?;
485
486 Ok(Self(fd))
487 }
488}
489
490/// No vetted cross-platform advisory-locking primitive exists in this workspace, so
491/// [`SessionEventLog::open_exclusive`] does not enforce INV-D2 on non-Unix targets.
492#[cfg(not(unix))]
493struct AdvisoryLock;
494
495#[cfg(not(unix))]
496impl AdvisoryLock {
497 fn acquire(_session_dir: &Path) -> Result<Self, SessionError> {
498 Ok(Self)
499 }
500}
501
502#[cfg(not(unix))]
503async fn set_permissions(_path: &Path, _mode: u32) -> Result<(), SessionError> {
504 Ok(())
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[tokio::test]
512 async fn test_append_and_read_roundtrip() {
513 let dir = tempfile::tempdir().unwrap();
514 let log = SessionEventLog::open(dir.path()).await.unwrap();
515
516 for i in 0..5u64 {
517 log.append(
518 Some(i),
519 None,
520 SessionEvent::UserMessage {
521 text: format!("msg-{i}"),
522 image_refs: vec![],
523 },
524 )
525 .await
526 .unwrap();
527 }
528
529 assert_eq!(log.last_seq(), Some(4));
530 let events = log.read_all().await.unwrap();
531 assert_eq!(events.len(), 5);
532 for (i, envelope) in events.iter().enumerate() {
533 assert_eq!(envelope.seq, i as u64);
534 }
535 }
536
537 #[tokio::test]
538 async fn test_reopen_resumes_seq() {
539 let dir = tempfile::tempdir().unwrap();
540 {
541 let log = SessionEventLog::open(dir.path()).await.unwrap();
542 log.append(
543 None,
544 None,
545 SessionEvent::SessionEnded { reason: "x".into() },
546 )
547 .await
548 .unwrap();
549 }
550 let log = SessionEventLog::open(dir.path()).await.unwrap();
551 assert_eq!(log.last_seq(), Some(0));
552 let appended = log
553 .append(
554 None,
555 None,
556 SessionEvent::SessionEnded { reason: "y".into() },
557 )
558 .await
559 .unwrap();
560 assert_eq!(appended.seq, 1);
561 }
562
563 #[tokio::test]
564 async fn test_torn_write_truncation() {
565 let dir = tempfile::tempdir().unwrap();
566 let path;
567 {
568 let log = SessionEventLog::open(dir.path()).await.unwrap();
569 for i in 0..3u64 {
570 log.append(
571 None,
572 None,
573 SessionEvent::UserMessage {
574 text: format!("msg-{i}"),
575 image_refs: vec![],
576 },
577 )
578 .await
579 .unwrap();
580 }
581 path = log.path().to_path_buf();
582 }
583
584 // Simulate a torn write: truncate the file mid-way through the last line.
585 let full = tokio::fs::read(&path).await.unwrap();
586 let cut = full.len() - 5;
587 tokio::fs::write(&path, &full[..cut]).await.unwrap();
588
589 let log = SessionEventLog::open(dir.path()).await.unwrap();
590 assert_eq!(
591 log.last_seq(),
592 Some(1),
593 "torn last line must be dropped cleanly"
594 );
595 let events = log.read_all().await.unwrap();
596 assert_eq!(events.len(), 2);
597 }
598
599 /// Regression test for #5487 Finding B: a lockless `open()`/`read_all()` must never
600 /// physically truncate a torn tail — it cannot distinguish a genuinely torn line from a
601 /// live writer's in-flight, not-yet-fsynced append, so mutating the file could destroy that
602 /// writer's data out from under it. Only `open_exclusive()` may repair.
603 #[cfg(unix)]
604 #[tokio::test]
605 async fn test_open_does_not_physically_truncate_torn_tail() {
606 let dir = tempfile::tempdir().unwrap();
607 let path;
608 {
609 let log = SessionEventLog::open(dir.path()).await.unwrap();
610 for i in 0..3u64 {
611 log.append(
612 None,
613 None,
614 SessionEvent::UserMessage {
615 text: format!("msg-{i}"),
616 image_refs: vec![],
617 },
618 )
619 .await
620 .unwrap();
621 }
622 path = log.path().to_path_buf();
623 }
624
625 let full = tokio::fs::read(&path).await.unwrap();
626 let cut = full.len() - 5;
627 tokio::fs::write(&path, &full[..cut]).await.unwrap();
628 let torn_len = tokio::fs::metadata(&path).await.unwrap().len();
629
630 // Lockless open()/read_all(): in-memory result drops the torn line, but the file on
631 // disk must be untouched.
632 let log = SessionEventLog::open(dir.path()).await.unwrap();
633 assert_eq!(log.last_seq(), Some(1));
634 let events = log.read_all().await.unwrap();
635 assert_eq!(events.len(), 2);
636 assert_eq!(
637 tokio::fs::metadata(&path).await.unwrap().len(),
638 torn_len,
639 "open()/read_all() must never physically truncate the file"
640 );
641 drop(log);
642
643 // open_exclusive(): now physically repairs the file.
644 let log = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
645 assert_eq!(log.last_seq(), Some(1));
646 let repaired_len = tokio::fs::metadata(&path).await.unwrap().len();
647 assert!(
648 repaired_len < torn_len,
649 "open_exclusive() must physically truncate the torn tail"
650 );
651 }
652
653 #[tokio::test]
654 async fn test_torn_write_truncation_various_offsets() {
655 for cut_from_end in [1usize, 3, 10, 20] {
656 let dir = tempfile::tempdir().unwrap();
657 let path;
658 {
659 let log = SessionEventLog::open(dir.path()).await.unwrap();
660 for i in 0..4u64 {
661 log.append(
662 None,
663 None,
664 SessionEvent::UserMessage {
665 text: format!("event-number-{i}"),
666 image_refs: vec![],
667 },
668 )
669 .await
670 .unwrap();
671 }
672 path = log.path().to_path_buf();
673 }
674 let full = tokio::fs::read(&path).await.unwrap();
675 let cut = full.len().saturating_sub(cut_from_end);
676 tokio::fs::write(&path, &full[..cut]).await.unwrap();
677
678 // Must not panic and must never see more than the 4 originally-committed events.
679 let log = SessionEventLog::open(dir.path()).await.unwrap();
680 let events = log.read_all().await.unwrap();
681 assert!(events.len() <= 4);
682 }
683 }
684
685 #[tokio::test]
686 async fn test_empty_log_read_all() {
687 let dir = tempfile::tempdir().unwrap();
688 let log = SessionEventLog::open(dir.path()).await.unwrap();
689 assert_eq!(log.last_seq(), None);
690 assert!(log.read_all().await.unwrap().is_empty());
691 }
692
693 #[cfg(unix)]
694 #[tokio::test]
695 async fn test_file_permissions_are_0600() {
696 use std::os::unix::fs::PermissionsExt;
697 let dir = tempfile::tempdir().unwrap();
698 let log = SessionEventLog::open(dir.path()).await.unwrap();
699 let meta = tokio::fs::metadata(log.path()).await.unwrap();
700 assert_eq!(meta.permissions().mode() & 0o777, 0o600);
701 }
702
703 /// Regression test for #5487 bug B: `read_events` must compute the true running
704 /// maximum `seq`, not just take the last physical line's value. Simulates the on-disk
705 /// shape a pre-fix concurrent-append race could produce: seq 7 written physically before
706 /// seq 6.
707 #[tokio::test]
708 async fn test_max_seq_survives_out_of_order_physical_lines() {
709 let dir = tempfile::tempdir().unwrap();
710 let path = dir.path().join(EVENTS_FILE_NAME);
711
712 let make_line = |seq: u64| {
713 let envelope = SessionEventEnvelope::new(
714 seq,
715 None,
716 None,
717 SessionEvent::SessionEnded { reason: "x".into() },
718 );
719 let mut line = serde_json::to_vec(&envelope).unwrap();
720 line.push(b'\n');
721 line
722 };
723
724 // Physical order is seq=7 then seq=6 — out of seq order, as a pre-fix race could
725 // produce, but every line individually well-formed and fsynced.
726 let mut contents = make_line(7);
727 contents.extend(make_line(6));
728 tokio::fs::write(&path, &contents).await.unwrap();
729
730 let log = SessionEventLog::open(dir.path()).await.unwrap();
731 assert_eq!(
732 log.last_seq(),
733 Some(7),
734 "next_seq must be derived from the true max seq, not the last physical line"
735 );
736 let appended = log
737 .append(
738 None,
739 None,
740 SessionEvent::SessionEnded { reason: "z".into() },
741 )
742 .await
743 .unwrap();
744 assert_eq!(
745 appended.seq, 8,
746 "must not reuse a seq already present earlier in the file"
747 );
748 }
749
750 #[cfg(unix)]
751 #[tokio::test]
752 async fn test_open_exclusive_rejects_second_writer() {
753 let dir = tempfile::tempdir().unwrap();
754 let _first = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
755 match SessionEventLog::open_exclusive(dir.path()).await {
756 Err(SessionError::AlreadyLocked(_)) => {}
757 Err(e) => panic!("expected AlreadyLocked, got different error: {e}"),
758 Ok(_) => panic!("expected AlreadyLocked, but second open_exclusive succeeded"),
759 }
760 }
761
762 #[cfg(unix)]
763 #[tokio::test]
764 async fn test_open_exclusive_allows_reacquire_after_drop() {
765 let dir = tempfile::tempdir().unwrap();
766 {
767 let _first = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
768 }
769 // Lock released when the first handle dropped — must not still be held.
770 let _second = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
771 }
772
773 #[cfg(unix)]
774 #[tokio::test]
775 async fn test_open_is_not_blocked_by_open_exclusive() {
776 let dir = tempfile::tempdir().unwrap();
777 let _writer = SessionEventLog::open_exclusive(dir.path()).await.unwrap();
778 // Read-only `open()` must still succeed while a writer holds the exclusive lock.
779 let _reader = SessionEventLog::open(dir.path()).await.unwrap();
780 }
781
782 /// Regression test for #5487 bug A: drives genuine concurrent `append()` calls (on real
783 /// OS threads, not just cooperative interleaving) against one shared `SessionEventLog` and
784 /// asserts seq assignment and physical write order never diverge. Before the fix, `seq`
785 /// was assigned via `fetch_add` before acquiring the writer lock, so a task could win a
786 /// low seq but lose the race for the lock, landing its line after a higher-seq task's line.
787 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
788 async fn test_concurrent_append_preserves_seq_order() {
789 const N: u64 = 100;
790
791 let dir = tempfile::tempdir().unwrap();
792 let log = std::sync::Arc::new(SessionEventLog::open(dir.path()).await.unwrap());
793
794 let mut tasks = tokio::task::JoinSet::new();
795 for i in 0..N {
796 let log = log.clone();
797 tasks.spawn(async move {
798 log.append(
799 None,
800 None,
801 SessionEvent::UserMessage {
802 text: format!("msg-{i}"),
803 image_refs: vec![],
804 },
805 )
806 .await
807 .unwrap()
808 .seq
809 });
810 }
811
812 let mut assigned_seqs: Vec<u64> = tasks.join_all().await;
813 assigned_seqs.sort_unstable();
814 assert_eq!(
815 assigned_seqs,
816 (0..N).collect::<Vec<_>>(),
817 "every seq in 0..{N} must be assigned exactly once, with no gaps or duplicates"
818 );
819
820 // Physical order on disk must match seq order: seq assignment and the write it
821 // guards must never diverge under contention (#5487 fix 2).
822 let events = log.read_all().await.unwrap();
823 assert_eq!(events.len(), usize::try_from(N).unwrap());
824 for (i, envelope) in events.iter().enumerate() {
825 assert_eq!(
826 envelope.seq, i as u64,
827 "physical line {i} must carry seq {i}; seq and write order diverged"
828 );
829 }
830 }
831
832 /// Regression test for #5445 Finding 3: `read_events_chunked` must never hold more than
833 /// [`REPLAY_CHUNK_SIZE`] raw envelopes at once, and the concatenation of all chunks must
834 /// exactly reproduce what `read_events` (the whole-file `Vec` path) returns, in order.
835 #[tokio::test]
836 async fn test_read_chunked_bounds_memory_and_matches_whole_file_read() {
837 const N: u64 = 733; // comfortably > REPLAY_CHUNK_SIZE, not an exact multiple of it
838
839 let dir = tempfile::tempdir().unwrap();
840 let log = SessionEventLog::open(dir.path()).await.unwrap();
841 for i in 0..N {
842 log.append(
843 None,
844 None,
845 SessionEvent::UserMessage {
846 text: format!("msg-{i}"),
847 image_refs: vec![],
848 },
849 )
850 .await
851 .unwrap();
852 }
853
854 let (whole_file_events, _) = read_events(log.path(), false).await.unwrap();
855 assert_eq!(whole_file_events.len(), usize::try_from(N).unwrap());
856
857 let mut chunked_events = Vec::new();
858 let mut chunk_sizes = Vec::new();
859 read_events_chunked(log.path(), false, |chunk| {
860 assert!(
861 chunk.len() <= REPLAY_CHUNK_SIZE,
862 "a single chunk must never exceed REPLAY_CHUNK_SIZE ({REPLAY_CHUNK_SIZE}), got {}",
863 chunk.len()
864 );
865 chunk_sizes.push(chunk.len());
866 chunked_events.extend(chunk);
867 ControlFlow::Continue(())
868 })
869 .await
870 .unwrap();
871
872 assert_eq!(
873 chunked_events.len(),
874 whole_file_events.len(),
875 "chunked read must yield the same total event count as the whole-file read"
876 );
877 for (whole, chunked) in whole_file_events.iter().zip(chunked_events.iter()) {
878 assert_eq!(whole.seq, chunked.seq);
879 }
880 assert!(
881 chunk_sizes.len() > 1,
882 "expected multiple chunks for N={N} events with REPLAY_CHUNK_SIZE={REPLAY_CHUNK_SIZE}"
883 );
884 }
885}