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