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";
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}