Skip to main content

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}