Skip to main content

treeship_core/session/
event_log.rs

1//! Append-only, file-backed event log for session events.
2//!
3//! Events are stored as newline-delimited JSON (JSONL) in
4//! `.treeship/sessions/<session_id>/events.jsonl`.
5//!
6//! Concurrency model: `append()` is safe to call from multiple processes
7//! concurrently. Each call attempts to acquire an exclusive advisory lock
8//! (via `fs2::FileExt::try_lock_exclusive` -- backed by `flock(2)` on Unix
9//! and `LockFileEx` on Windows) on a sidecar `events.jsonl.lock` file in a
10//! ~500ms bounded retry loop. Under the lock, a counter sidecar
11//! `events.jsonl.count` is the authoritative source for the next
12//! `sequence_no`. The per-process AtomicU64 is retained as a hot-path
13//! optimization for non-contended use, but its value is overwritten by the
14//! on-disk counter after every locked append.
15//!
16//! Counter sidecar format (16 bytes):
17//!   - bytes 0..8:  count (u64 LE) -- number of events written to events.jsonl
18//!   - bytes 8..16: byte_size (u64 LE) -- size of events.jsonl when count was recorded
19//!
20//! The byte_size field is the crash detector. If a peer wrote events.jsonl
21//! but crashed before fsyncing the counter (or vice versa), the size on disk
22//! and the size in the counter disagree. On any mismatch we fall back to an
23//! O(N) line count and rewrite the counter -- one paid scan, then back to
24//! O(1) on every subsequent append.
25//!
26//! This bounds steady-state append cost at constant: read 16 bytes, write
27//! one JSONL line, write 16 bytes. The previous implementation re-streamed
28//! the entire events.jsonl on every append, which made hooks O(N) in
29//! session length and dominated PostToolUse latency on long sessions.
30//!
31//! Fail-closed semantics: the writer ALWAYS acquires the exclusive flock
32//! before reading the counter and writing the event. We use fs2's blocking
33//! `lock_exclusive()` (flock(2) without LOCK_NB on Unix, LockFileEx without
34//! LOCKFILE_FAIL_IMMEDIATELY on Windows) so contended writers queue rather
35//! than race. An earlier "best-effort, fall through on contention" path
36//! existed here -- it could produce duplicate `sequence_no` under hook
37//! contention (P0, audit lane F), so it was removed. Hook callers already
38//! sit under Claude Code's 60s hook timeout, which bounds wall-clock
39//! exposure to a wedged peer. Trading a bounded wait for guaranteed
40//! injective sequence numbers is the right call when receipts are the
41//! trust artifact.
42//!
43//! Lock file permissions are 0o600 (owner-only) on Unix, applied at file
44//! creation via `OpenOptionsExt::mode` and re-tightened on every open if
45//! a previous run left the file with looser perms.
46
47use std::io::{BufRead, Write};
48use std::path::{Path, PathBuf};
49use std::sync::atomic::{AtomicU64, Ordering};
50
51#[cfg(not(target_family = "wasm"))]
52use fs2::FileExt;
53
54use crate::session::event::SessionEvent;
55
56/// Error from event log operations.
57#[derive(Debug)]
58pub enum EventLogError {
59    Io(std::io::Error),
60    Json(serde_json::Error),
61}
62
63impl std::fmt::Display for EventLogError {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        match self {
66            Self::Io(e) => write!(f, "event log io: {e}"),
67            Self::Json(e) => write!(f, "event log json: {e}"),
68        }
69    }
70}
71
72impl std::error::Error for EventLogError {}
73impl From<std::io::Error> for EventLogError {
74    fn from(e: std::io::Error) -> Self {
75        Self::Io(e)
76    }
77}
78impl From<serde_json::Error> for EventLogError {
79    fn from(e: serde_json::Error) -> Self {
80        Self::Json(e)
81    }
82}
83
84/// An append-only event log backed by a JSONL file.
85pub struct EventLog {
86    path: PathBuf,
87    sequence: AtomicU64,
88}
89
90impl EventLog {
91    /// Open or create an event log for the given session directory.
92    ///
93    /// The session directory is typically `.treeship/sessions/<session_id>/`.
94    /// If the directory does not exist, it will be created.
95    ///
96    /// Initialization reads the counter sidecar in O(1) when present and
97    /// consistent with events.jsonl's byte size; falls back to an O(N) line
98    /// count (and rewrites the sidecar) when the sidecar is missing,
99    /// short-read, or stale from a crashed previous appender.
100    pub fn open(session_dir: &Path) -> Result<Self, EventLogError> {
101        std::fs::create_dir_all(session_dir)?;
102        let path = session_dir.join("events.jsonl");
103        let count = read_counter_or_recount(&path)?;
104        Ok(Self {
105            path,
106            sequence: AtomicU64::new(count),
107        })
108    }
109
110    /// Append a single event to the log.
111    ///
112    /// The event's `sequence_no` is set automatically. Under contention from
113    /// multiple writer processes, the sequence number is re-derived from the
114    /// on-disk line count under an exclusive flock so two parallel writers
115    /// never collide.
116    pub fn append(&self, event: &mut SessionEvent) -> Result<(), EventLogError> {
117        self.append_locked(event)
118    }
119
120    /// Cross-process safe append: acquires an exclusive advisory lock on a
121    /// sidecar `.lock` file, re-counts events.jsonl lines, assigns sequence_no,
122    /// writes the new event, then releases the lock on drop.
123    ///
124    /// Locking is BLOCKING (`fs2::FileExt::lock_exclusive`, i.e. flock(2)
125    /// without LOCK_NB). A previous implementation polled `try_lock_exclusive`
126    /// for ~500ms and then fell through to an UNLOCKED write -- which under
127    /// hook contention (multiple PostToolUse invocations racing) could
128    /// assign duplicate `sequence_no` values to different events. That
129    /// broke the injective-sequence invariant receipts depend on, even
130    /// though local merkle verification still passed. Audit lane F (P0).
131    ///
132    /// The trade-off: a wedged peer holding the lock will now stall this
133    /// caller until the peer releases (e.g. by crashing -- flock is
134    /// released by the kernel when the holder's FD closes). Hook
135    /// invocations are bounded by Claude Code's hook timeout (60s by
136    /// default), so the worst-case wedge surfaces as a hook failure
137    /// rather than a duplicate sequence_no. That mirrors how
138    /// `journal/mod.rs` treats the journal append lock as a hard
139    /// correctness barrier rather than a soft hint.
140    ///
141    /// The locked region covers BOTH the counter read AND the file write,
142    /// so two concurrent writers cannot read the same count and append
143    /// twice with that count.
144    ///
145    /// Lock file is created mode 0o600 (owner-only) so the sidecar can
146    /// never be opened by other users on a shared machine.
147    ///
148    /// Skipped on WASM (no fs, no concurrency).
149    #[cfg(not(target_family = "wasm"))]
150    fn append_locked(&self, event: &mut SessionEvent) -> Result<(), EventLogError> {
151        // Sidecar lock file: contention here doesn't block readers of events.jsonl.
152        let lock_path = self.path.with_extension("jsonl.lock");
153
154        // Open or create the lock file. On Unix we set 0o600 explicitly so
155        // the sidecar isn't group/world readable; the umask-derived default
156        // would otherwise be permissive on some setups.
157        let lock_file = open_lock_file(&lock_path)?;
158
159        // Blocking flock. Returns when the lock is held exclusively, or
160        // propagates a real I/O error (filesystem that doesn't support
161        // flock, FD revoked, etc.). EINTR retry isn't needed -- fs2 wraps
162        // the syscall and retries internally on POSIX.
163        FileExt::lock_exclusive(&lock_file)?;
164
165        // From here until `lock_file` is dropped (end of function), we
166        // hold the exclusive flock. The counter read + event write + counter
167        // update MUST stay inside this block; any early return must still
168        // drop `lock_file`, which Rust guarantees by RAII.
169        let result = (|| -> Result<(), EventLogError> {
170            // Read sequence_no from the counter sidecar in O(1) when
171            // consistent with events.jsonl size. Stale or missing counters
172            // force a one-time O(N) rescan that also rewrites the counter,
173            // so subsequent appends return to O(1). Only the on-disk state
174            // (counter + size check) is authoritative when multiple
175            // processes are appending; the per-process AtomicU64 is a
176            // stale hint.
177            let count = read_counter_or_recount(&self.path)?;
178            event.sequence_no = count;
179
180            let mut line = serde_json::to_vec(event)?;
181            line.push(b'\n');
182
183            let mut file = std::fs::OpenOptions::new()
184                .create(true)
185                .append(true)
186                .open(&self.path)?;
187            file.write_all(&line)?;
188            file.flush()?;
189
190            // Update the counter sidecar with the new count and the new
191            // events.jsonl size, so the next append can short-circuit the
192            // line scan. Failure to update the counter is non-fatal: the
193            // next reader will detect the size mismatch and recount.
194            let new_size = file.metadata().map(|m| m.len()).unwrap_or(0);
195            let _ = write_counter(&self.path, count + 1, new_size);
196
197            // Keep the in-process AtomicU64 in sync so non-contended callers
198            // see the right value via event_count() without re-reading.
199            self.sequence.store(count + 1, Ordering::SeqCst);
200            Ok(())
201        })();
202
203        // Explicit unlock matches the journal precedent (journal/mod.rs
204        // also calls `unlock` before dropping). Drop alone would release
205        // the flock via close(2), but being explicit makes the lock
206        // window obvious to readers of this function.
207        let _ = FileExt::unlock(&lock_file);
208        result
209    }
210
211    /// WASM build: no filesystem locks available, no concurrent writers.
212    /// Falls back to the simple AtomicU64 path.
213    #[cfg(target_family = "wasm")]
214    fn append_locked(&self, event: &mut SessionEvent) -> Result<(), EventLogError> {
215        event.sequence_no = self.sequence.fetch_add(1, Ordering::SeqCst);
216
217        let mut line = serde_json::to_vec(event)?;
218        line.push(b'\n');
219
220        let mut file = std::fs::OpenOptions::new()
221            .create(true)
222            .append(true)
223            .open(&self.path)?;
224        file.write_all(&line)?;
225        file.flush()?;
226
227        Ok(())
228    }
229
230    /// Read all events from the log.
231    ///
232    /// Per-line tolerant: a single bad line -- malformed JSON (unknown
233    /// event type, missing field, truncated write) OR a non-UTF-8 byte
234    /// (partial write, corruption) -- is logged to stderr, counted as a
235    /// skip, and stepped over, not propagated as an error. The caller --
236    /// session close, in particular -- composes a receipt from whatever
237    /// events parse, instead of dropping every event when any one is bad.
238    /// (The read only returns Err for a whole-file failure such as the
239    /// events file being unopenable; a per-line decode error is a skip.)
240    ///
241    /// Why this matters: events.jsonl is append-only and written by
242    /// hooks, daemons, SDKs, and bridges from multiple processes. A
243    /// single bad event from one buggy emitter would otherwise nuke
244    /// the entire receipt's side_effects / agent_graph / timeline.
245    /// Real-world repro: a hook that emitted events with an unknown
246    /// `type` field caused side_effects.files_written to come back
247    /// empty even though the rest of the events in the log were valid
248    /// agent.wrote_file events the aggregator would have happily
249    /// processed.
250    pub fn read_all(&self) -> Result<Vec<SessionEvent>, EventLogError> {
251        // Drop the skipped count for callers that don't carry it through.
252        // Receipt composition uses read_all_with_stats to record the
253        // count in-band on the sealed receipt -- see Codex finding #8.
254        self.read_all_with_stats().map(|(events, _skipped)| events)
255    }
256
257    /// Same as `read_all` but returns the count of malformed lines that
258    /// were skipped during parsing alongside the valid events.
259    ///
260    /// Codex adversarial review finding #8: skipping malformed events
261    /// on stderr only is silent data loss from the verifier's
262    /// perspective. The receipt gets sealed under a merkle root that
263    /// represents only the events that successfully parsed -- a
264    /// downstream consumer cannot tell whether the receipt is complete
265    /// or whether N events were silently dropped.
266    ///
267    /// session::close calls this and stores the count on
268    /// `receipt.proofs.event_log_skipped`. `treeship package verify`
269    /// surfaces it as a WARN when nonzero so the receipt's
270    /// completeness signal is visible without breaking byte-identical
271    /// re-verification of pre-existing receipts.
272    pub fn read_all_with_stats(&self) -> Result<(Vec<SessionEvent>, usize), EventLogError> {
273        if !self.path.exists() {
274            return Ok((Vec::new(), 0));
275        }
276        let file = std::fs::File::open(&self.path)?;
277        let reader = std::io::BufReader::new(file);
278        let mut events = Vec::new();
279        let mut skipped = 0usize;
280        for (idx, line) in reader.lines().enumerate() {
281            // A per-LINE read error (a non-UTF-8 byte from a partial write or
282            // corruption) must be counted as a skip, not propagated — the `?`
283            // here previously aborted the WHOLE read, and `session::close`
284            // maps that Err to (empty, 0), sealing an empty receipt with a
285            // zero skip-count exactly when the log is most damaged. That
286            // silently bypasses the completeness signal this function exists
287            // to carry. Treat the bad line like a malformed JSON line: skip
288            // it, count it, keep the rest.
289            let line = match line {
290                Ok(l) => l,
291                Err(e) => {
292                    skipped += 1;
293                    eprintln!(
294                        "[treeship] event_log: skipping unreadable line {} in {}: {}",
295                        idx + 1,
296                        self.path.display(),
297                        e,
298                    );
299                    continue;
300                }
301            };
302            if line.trim().is_empty() {
303                continue;
304            }
305            match serde_json::from_str::<SessionEvent>(&line) {
306                Ok(event) => events.push(event),
307                Err(e) => {
308                    skipped += 1;
309                    eprintln!(
310                        "[treeship] event_log: skipping malformed line {} in {}: {}",
311                        idx + 1,
312                        self.path.display(),
313                        e,
314                    );
315                }
316            }
317        }
318        if skipped > 0 {
319            eprintln!(
320                "[treeship] event_log: {} malformed line(s) skipped while reading {} (kept {} valid event(s))",
321                skipped,
322                self.path.display(),
323                events.len(),
324            );
325        }
326        Ok((events, skipped))
327    }
328
329    /// Return the current event count.
330    pub fn event_count(&self) -> u64 {
331        self.sequence.load(Ordering::SeqCst)
332    }
333
334    /// Return the path to the JSONL file.
335    pub fn path(&self) -> &Path {
336        &self.path
337    }
338}
339
340/// Open the sidecar lock file with owner-only permissions (0o600 on Unix).
341///
342/// On Unix the mode is set atomically via `OpenOptionsExt::mode` for newly
343/// created files. For files that already exist (e.g. left over from a
344/// prior crash or an upgrade from a pre-0.9.3 CLI that didn't tighten
345/// perms), we additionally re-chmod to 0o600 after open IF the file is
346/// owned by the current user. This is best-effort: if the chmod fails
347/// (file owned by another user, read-only filesystem, etc.) we proceed
348/// silently rather than refuse to open the lock -- the lock semantics
349/// don't depend on the perms being tight, only the privacy of the
350/// sidecar's existence does.
351///
352/// On Windows the mode concept doesn't apply; ACLs default to inheriting
353/// the parent dir's permissions, which for `.treeship/sessions/<id>/`
354/// should already be scoped to the owning user.
355#[cfg(all(not(target_family = "wasm"), unix))]
356fn open_lock_file(path: &Path) -> Result<std::fs::File, std::io::Error> {
357    use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
358    use std::os::unix::io::AsRawFd;
359
360    let file = std::fs::OpenOptions::new()
361        .create(true)
362        .read(true)
363        .write(true)
364        .mode(0o600)
365        .open(path)?;
366
367    // Re-tighten if a pre-existing file has loose perms. Use `fchmod` on the
368    // open file descriptor rather than `set_permissions(path, ...)` to
369    // eliminate the TOCTOU window -- between metadata() and a path-based
370    // chmod, an attacker could swap the file. `fchmod` operates on the
371    // already-opened inode, so the target is pinned.
372    //
373    // Only act when the file is owned by us (uid match via geteuid). If
374    // fchmod fails (NFS mount with restricted metadata writes, or some
375    // filesystems without full POSIX perm support), emit a one-line
376    // stderr warning so an operator has visibility. The lock still works;
377    // only the privacy of the sidecar's existence is affected.
378    if let Ok(meta) = file.metadata() {
379        let mode = meta.permissions().mode() & 0o777;
380        let owned_by_us = meta.uid() == nix_uid();
381        if owned_by_us && mode != 0o600 {
382            let fd = file.as_raw_fd();
383            // SAFETY: fd is valid (we just opened it), 0o600 is a
384            // well-formed mode. fchmod is async-signal-safe per POSIX.
385            let rc = unsafe { libc_fchmod(fd, 0o600) };
386            if rc != 0 {
387                let err = std::io::Error::last_os_error();
388                eprintln!(
389                    "[treeship] warning: could not tighten lock file perms on {} \
390                     to 0o600 (current: 0o{:o}). Error: {}. Lock still functions; \
391                     only the privacy of the sidecar is affected. Common cause: \
392                     NFS mount or filesystem without full POSIX perm support.",
393                    path.display(),
394                    mode,
395                    err
396                );
397            }
398        }
399    }
400
401    Ok(file)
402}
403
404/// Thin FFI wrapper around libc::fchmod. Declared here so event_log.rs
405/// doesn't need a direct libc crate dep -- the symbol is available in
406/// every Unix libc binary.
407#[cfg(all(not(target_family = "wasm"), unix))]
408fn libc_fchmod(fd: i32, mode: u32) -> i32 {
409    // SAFETY: posix-standard FFI signature; `fd` validity and `mode`
410    // bounds are enforced by the caller.
411    unsafe extern "C" {
412        fn fchmod(fd: i32, mode: u32) -> i32;
413    }
414    unsafe { fchmod(fd, mode) }
415}
416
417/// Lightweight wrapper around `geteuid` so we can compare to file ownership
418/// without pulling in the `nix` crate. Uses `libc` directly (already a
419/// transitive dep via several upstream crates).
420#[cfg(all(not(target_family = "wasm"), unix))]
421fn nix_uid() -> u32 {
422    // SAFETY: geteuid is async-signal-safe and never fails per POSIX.
423    unsafe extern "C" {
424        fn geteuid() -> u32;
425    }
426    unsafe { geteuid() }
427}
428
429#[cfg(all(not(target_family = "wasm"), not(unix)))]
430fn open_lock_file(path: &Path) -> Result<std::fs::File, std::io::Error> {
431    std::fs::OpenOptions::new()
432        .create(true)
433        .read(true)
434        .write(true)
435        .open(path)
436}
437
438/// Path of the counter sidecar for a given events.jsonl path.
439fn counter_path(events_path: &Path) -> PathBuf {
440    events_path.with_extension("jsonl.count")
441}
442
443/// Read the counter sidecar if it exists and is consistent with events.jsonl.
444///
445/// Returns `Some(count)` when the sidecar's recorded byte_size matches the
446/// current events.jsonl size, and `None` otherwise (missing sidecar, short
447/// read, parse failure, or size mismatch from a crashed previous appender).
448#[cfg(not(target_family = "wasm"))]
449fn read_counter_consistent(events_path: &Path) -> Option<u64> {
450    let counter = counter_path(events_path);
451    let bytes = std::fs::read(&counter).ok()?;
452    if bytes.len() != 16 {
453        return None;
454    }
455    let count = u64::from_le_bytes(bytes[0..8].try_into().ok()?);
456    let recorded_size = u64::from_le_bytes(bytes[8..16].try_into().ok()?);
457
458    // events.jsonl may not exist yet -- counter records (0, 0) for that case.
459    let actual_size = match std::fs::metadata(events_path) {
460        Ok(m) => m.len(),
461        Err(e) if e.kind() == std::io::ErrorKind::NotFound => 0,
462        Err(_) => return None,
463    };
464    if actual_size != recorded_size {
465        return None;
466    }
467    Some(count)
468}
469
470/// Read the counter via the sidecar (O(1)) or fall back to an O(N) line
471/// scan, rewriting the sidecar on the way out. This is the recovery path
472/// after a crash that left the counter and events.jsonl out of sync.
473#[cfg(not(target_family = "wasm"))]
474fn read_counter_or_recount(events_path: &Path) -> Result<u64, EventLogError> {
475    if let Some(count) = read_counter_consistent(events_path) {
476        return Ok(count);
477    }
478    let count = if events_path.exists() {
479        let f = std::fs::File::open(events_path)?;
480        let r = std::io::BufReader::new(f);
481        r.lines().filter(|l| l.is_ok()).count() as u64
482    } else {
483        0
484    };
485    let size = std::fs::metadata(events_path).map(|m| m.len()).unwrap_or(0);
486    let _ = write_counter(events_path, count, size);
487    Ok(count)
488}
489
490/// WASM has no fs and no concurrent writers; the in-memory AtomicU64 in the
491/// EventLog is sufficient. Initialize to zero on open.
492#[cfg(target_family = "wasm")]
493fn read_counter_or_recount(_events_path: &Path) -> Result<u64, EventLogError> {
494    Ok(0)
495}
496
497/// Atomically replace the counter sidecar with the new (count, byte_size).
498///
499/// Writes to a temp file in the same directory and renames into place so a
500/// reader either sees the old 16 bytes or the new 16 bytes, never a partial
501/// write. The 0o600 perm matches the lock file -- the counter doesn't leak
502/// secrets but its existence is a session signal worth scoping to the owner.
503#[cfg(not(target_family = "wasm"))]
504fn write_counter(events_path: &Path, count: u64, byte_size: u64) -> Result<(), std::io::Error> {
505    use std::io::Write as _;
506    let counter = counter_path(events_path);
507    let dir = counter.parent().ok_or_else(|| {
508        std::io::Error::new(
509            std::io::ErrorKind::InvalidInput,
510            "counter path has no parent",
511        )
512    })?;
513    std::fs::create_dir_all(dir)?;
514
515    let mut buf = [0u8; 16];
516    buf[0..8].copy_from_slice(&count.to_le_bytes());
517    buf[8..16].copy_from_slice(&byte_size.to_le_bytes());
518
519    let tmp = counter.with_extension("count.tmp");
520    {
521        let mut f = open_counter_tmp(&tmp)?;
522        f.write_all(&buf)?;
523        f.sync_all()?;
524    }
525    std::fs::rename(&tmp, &counter)?;
526    Ok(())
527}
528
529#[cfg(all(not(target_family = "wasm"), unix))]
530fn open_counter_tmp(path: &Path) -> Result<std::fs::File, std::io::Error> {
531    use std::os::unix::fs::OpenOptionsExt;
532    std::fs::OpenOptions::new()
533        .create(true)
534        .write(true)
535        .truncate(true)
536        .mode(0o600)
537        .open(path)
538}
539
540#[cfg(all(not(target_family = "wasm"), not(unix)))]
541fn open_counter_tmp(path: &Path) -> Result<std::fs::File, std::io::Error> {
542    std::fs::OpenOptions::new()
543        .create(true)
544        .write(true)
545        .truncate(true)
546        .open(path)
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use crate::session::event::*;
553
554    fn make_event(session_id: &str, event_type: EventType) -> SessionEvent {
555        SessionEvent {
556            session_id: session_id.into(),
557            event_id: generate_event_id(),
558            timestamp: "2026-04-05T08:00:00Z".into(),
559            sequence_no: 0,
560            trace_id: generate_trace_id(),
561            span_id: generate_span_id(),
562            parent_span_id: None,
563            agent_id: "agent://test".into(),
564            agent_instance_id: "ai_test_1".into(),
565            agent_name: "test-agent".into(),
566            agent_role: None,
567            host_id: "host_test".into(),
568            tool_runtime_id: None,
569            event_type,
570            artifact_ref: None,
571            meta: None,
572        }
573    }
574
575    #[test]
576    fn append_and_read_back() {
577        let dir =
578            std::env::temp_dir().join(format!("treeship-evtlog-test-{}", rand::random::<u32>()));
579        let log = EventLog::open(&dir).unwrap();
580
581        let mut e1 = make_event("ssn_001", EventType::SessionStarted);
582        let mut e2 = make_event(
583            "ssn_001",
584            EventType::AgentStarted {
585                parent_agent_instance_id: None,
586            },
587        );
588
589        log.append(&mut e1).unwrap();
590        log.append(&mut e2).unwrap();
591
592        assert_eq!(log.event_count(), 2);
593        assert_eq!(e1.sequence_no, 0);
594        assert_eq!(e2.sequence_no, 1);
595
596        let events = log.read_all().unwrap();
597        assert_eq!(events.len(), 2);
598        assert_eq!(events[0].sequence_no, 0);
599        assert_eq!(events[1].sequence_no, 1);
600
601        let _ = std::fs::remove_dir_all(&dir);
602    }
603
604    #[test]
605    fn read_all_skips_malformed_lines() {
606        // Regression: a single malformed line in events.jsonl used to
607        // make read_all() return Err, and the caller's
608        // .unwrap_or_default() would drop EVERY event in the log. Real
609        // bug: hooks emitting events with an unknown `type` field made
610        // side_effects.files_written come back empty even though every
611        // other event in the log was a perfectly valid agent.wrote_file
612        // event. Now we skip-and-log the bad line and keep the rest.
613        let dir = std::env::temp_dir().join(format!(
614            "treeship-evtlog-malformed-{}",
615            rand::random::<u32>()
616        ));
617        let log = EventLog::open(&dir).unwrap();
618
619        let mut good1 = make_event(
620            "ssn_001",
621            EventType::AgentWroteFile {
622                file_path: "src/before.rs".into(),
623                digest: None,
624                operation: None,
625                additions: None,
626                deletions: None,
627            },
628        );
629        let mut good2 = make_event(
630            "ssn_001",
631            EventType::AgentWroteFile {
632                file_path: "src/after.rs".into(),
633                digest: None,
634                operation: None,
635                additions: None,
636                deletions: None,
637            },
638        );
639        log.append(&mut good1).unwrap();
640        log.append(&mut good2).unwrap();
641
642        // Manually inject a malformed line between the two good ones by
643        // truncating the file and rewriting. The malformed line has an
644        // unknown event type ("custom.weird") which the closed EventType
645        // enum can't deserialize.
646        let path = log.path().to_path_buf();
647        let original = std::fs::read_to_string(&path).unwrap();
648        let mut lines: Vec<&str> = original.lines().collect();
649        lines.insert(1, r#"{"session_id":"ssn_001","event_id":"evt_bad","timestamp":"2026-04-26T00:00:00Z","sequence_no":1,"trace_id":"x","span_id":"y","agent_id":"a","agent_instance_id":"i","agent_name":"n","host_id":"h","type":"custom.weird","payload":42}"#);
650        std::fs::write(&path, lines.join("\n") + "\n").unwrap();
651
652        let events = log.read_all().unwrap();
653        assert_eq!(
654            events.len(),
655            2,
656            "expected the two valid events to come through; got {}",
657            events.len()
658        );
659        // Confirm the valid events are the file-write events and not
660        // some default fallback.
661        let written_paths: Vec<&str> = events
662            .iter()
663            .filter_map(|e| match &e.event_type {
664                EventType::AgentWroteFile { file_path, .. } => Some(file_path.as_str()),
665                _ => None,
666            })
667            .collect();
668        assert_eq!(written_paths, vec!["src/before.rs", "src/after.rs"]);
669
670        // Codex finding #8: the count must be exposed in-band so a
671        // sealed receipt can carry the incompleteness signal. Verify
672        // read_all_with_stats reports it.
673        let (events2, skipped) = log.read_all_with_stats().unwrap();
674        assert_eq!(events2.len(), 2);
675        assert_eq!(
676            skipped, 1,
677            "exactly one malformed line was injected; expected skipped == 1"
678        );
679
680        let _ = std::fs::remove_dir_all(&dir);
681    }
682
683    #[test]
684    fn read_all_with_stats_reports_zero_when_clean() {
685        // No malformed lines -> skipped == 0 -> the receipt's
686        // event_log_skipped field stays default (0) and gets omitted
687        // from canonical JSON. This preserves byte-identical receipts
688        // for the common case where the event log is clean.
689        let dir =
690            std::env::temp_dir().join(format!("treeship-evtlog-clean-{}", rand::random::<u32>()));
691        let log = EventLog::open(&dir).unwrap();
692
693        let mut e = make_event(
694            "ssn_001",
695            EventType::AgentWroteFile {
696                file_path: "x.rs".into(),
697                digest: None,
698                operation: None,
699                additions: None,
700                deletions: None,
701            },
702        );
703        log.append(&mut e).unwrap();
704
705        let (events, skipped) = log.read_all_with_stats().unwrap();
706        assert_eq!(events.len(), 1);
707        assert_eq!(skipped, 0);
708
709        let _ = std::fs::remove_dir_all(&dir);
710    }
711
712    #[test]
713    fn non_utf8_byte_is_skipped_not_aborted() {
714        // A single non-UTF-8 byte in the log (partial write / corruption)
715        // must skip THAT line and keep the rest, with a nonzero skip count —
716        // NOT abort the whole read and seal an empty receipt with skipped=0.
717        use std::io::Write;
718        let dir =
719            std::env::temp_dir().join(format!("treeship-evtlog-badbyte-{}", rand::random::<u32>()));
720        let log = EventLog::open(&dir).unwrap();
721        let mut e = make_event(
722            "ssn_001",
723            EventType::AgentWroteFile {
724                file_path: "ok.rs".into(),
725                digest: None,
726                operation: None,
727                additions: None,
728                deletions: None,
729            },
730        );
731        log.append(&mut e).unwrap();
732
733        // Append a raw non-UTF-8 line directly to the events file.
734        let events_path = dir.join("events.jsonl");
735        let mut f = std::fs::OpenOptions::new()
736            .append(true)
737            .open(&events_path)
738            .unwrap();
739        f.write_all(&[0xff, 0xfe, b'\n']).unwrap(); // invalid UTF-8 line
740        drop(f);
741
742        let (events, skipped) = log.read_all_with_stats().unwrap();
743        assert_eq!(events.len(), 1, "the one good event must survive");
744        assert_eq!(
745            skipped, 1,
746            "the bad line must be counted as skipped, not silently dropped"
747        );
748
749        let _ = std::fs::remove_dir_all(&dir);
750    }
751
752    #[test]
753    fn reopen_preserves_sequence() {
754        let dir =
755            std::env::temp_dir().join(format!("treeship-evtlog-reopen-{}", rand::random::<u32>()));
756
757        {
758            let log = EventLog::open(&dir).unwrap();
759            let mut e = make_event("ssn_001", EventType::SessionStarted);
760            log.append(&mut e).unwrap();
761        }
762
763        // Reopen
764        let log = EventLog::open(&dir).unwrap();
765        assert_eq!(log.event_count(), 1);
766
767        let mut e2 = make_event(
768            "ssn_001",
769            EventType::AgentStarted {
770                parent_agent_instance_id: None,
771            },
772        );
773        log.append(&mut e2).unwrap();
774        assert_eq!(e2.sequence_no, 1);
775
776        let _ = std::fs::remove_dir_all(&dir);
777    }
778
779    /// Regression test for #1 in the v0.9.3 Codex adversarial review.
780    ///
781    /// Multiple `EventLog` instances opened against the same directory must
782    /// not collide on `sequence_no`. This simulates what happens when each
783    /// `treeship session event` invocation (one per PostToolUse hook firing)
784    /// creates a fresh `EventLog` on a shared events.jsonl. Without the
785    /// flock-based re-derivation in `append_locked`, every instance sees
786    /// the same on-disk count at open time and assigns duplicate sequence
787    /// numbers.
788    #[cfg(not(target_family = "wasm"))]
789    #[test]
790    fn concurrent_appends_have_unique_sequence_numbers() {
791        use std::sync::Arc;
792        use std::thread;
793
794        let dir =
795            std::env::temp_dir().join(format!("treeship-evtlog-race-{}", rand::random::<u32>()));
796        std::fs::create_dir_all(&dir).unwrap();
797
798        const WRITERS: usize = 16;
799        let dir = Arc::new(dir);
800        let mut handles = Vec::with_capacity(WRITERS);
801
802        for _ in 0..WRITERS {
803            let dir = Arc::clone(&dir);
804            handles.push(thread::spawn(move || {
805                // Each thread opens its OWN EventLog -- mimics a separate
806                // process invocation. Without flock, all threads would see
807                // the same line count at open() time.
808                let log = EventLog::open(&dir).unwrap();
809                let mut e = make_event("ssn_race", EventType::SessionStarted);
810                log.append(&mut e).unwrap();
811                e.sequence_no
812            }));
813        }
814
815        let mut seqs: Vec<u64> = handles.into_iter().map(|h| h.join().unwrap()).collect();
816        seqs.sort();
817
818        // All sequence numbers must be unique and contiguous 0..WRITERS.
819        let expected: Vec<u64> = (0..WRITERS as u64).collect();
820        assert_eq!(seqs, expected, "sequence_no collisions under contention");
821
822        // Same invariant from the on-disk file's perspective.
823        let log = EventLog::open(&dir).unwrap();
824        let read = log.read_all().unwrap();
825        assert_eq!(read.len(), WRITERS);
826        let mut on_disk: Vec<u64> = read.iter().map(|e| e.sequence_no).collect();
827        on_disk.sort();
828        assert_eq!(on_disk, expected);
829
830        let _ = std::fs::remove_dir_all(&*dir);
831    }
832
833    /// Sidecar lock file must be created mode 0o600 (owner-only) on Unix.
834    /// Regression test for #5 in the second Codex adversarial review.
835    #[cfg(all(not(target_family = "wasm"), unix))]
836    #[test]
837    fn lock_file_has_owner_only_permissions() {
838        use std::os::unix::fs::PermissionsExt;
839
840        let dir =
841            std::env::temp_dir().join(format!("treeship-evtlog-perms-{}", rand::random::<u32>()));
842        let log = EventLog::open(&dir).unwrap();
843
844        let mut e = make_event("ssn_perms", EventType::SessionStarted);
845        log.append(&mut e).unwrap();
846
847        let lock_path = log.path().with_extension("jsonl.lock");
848        let meta = std::fs::metadata(&lock_path).expect("lock file must exist after first append");
849        let mode = meta.permissions().mode() & 0o777;
850        assert_eq!(
851            mode, 0o600,
852            "lock file mode is {:o}, expected 0o600 (owner-only)",
853            mode
854        );
855
856        let _ = std::fs::remove_dir_all(&dir);
857    }
858
859    /// A pre-existing lock file (e.g. from a v0.9.2 era crash) with looser
860    /// permissions must be tightened to 0o600 on next `EventLog::open`.
861    /// Regression test for the third Codex adversarial review.
862    #[cfg(all(not(target_family = "wasm"), unix))]
863    #[test]
864    fn existing_lock_file_is_re_tightened() {
865        use std::os::unix::fs::PermissionsExt;
866
867        let dir = std::env::temp_dir().join(format!(
868            "treeship-evtlog-retighten-{}",
869            rand::random::<u32>()
870        ));
871        std::fs::create_dir_all(&dir).unwrap();
872
873        // Pre-create a lock file with deliberately loose perms, simulating
874        // an upgrade from a CLI version that didn't set 0o600.
875        let lock_path = dir.join("events.jsonl.lock");
876        std::fs::write(&lock_path, b"").unwrap();
877        std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o644)).unwrap();
878        let pre_mode = std::fs::metadata(&lock_path).unwrap().permissions().mode() & 0o777;
879        assert_eq!(
880            pre_mode, 0o644,
881            "test setup: pre-existing perms should be 0o644"
882        );
883
884        // First append after upgrade -- should re-tighten.
885        let log = EventLog::open(&dir).unwrap();
886        let mut e = make_event("ssn_retighten", EventType::SessionStarted);
887        log.append(&mut e).unwrap();
888
889        let post_mode = std::fs::metadata(&lock_path).unwrap().permissions().mode() & 0o777;
890        assert_eq!(
891            post_mode, 0o600,
892            "lock file should be re-tightened to 0o600 after open; got {:o}",
893            post_mode
894        );
895
896        let _ = std::fs::remove_dir_all(&dir);
897    }
898
899    /// Counter sidecar must exist after the first append and contain
900    /// (count=1, byte_size=size of events.jsonl). This is the happy path
901    /// that lets every subsequent append skip the O(N) rescan.
902    #[cfg(not(target_family = "wasm"))]
903    #[test]
904    fn counter_sidecar_written_after_append() {
905        let dir =
906            std::env::temp_dir().join(format!("treeship-evtlog-counter-{}", rand::random::<u32>()));
907        let log = EventLog::open(&dir).unwrap();
908
909        let mut e = make_event("ssn_counter", EventType::SessionStarted);
910        log.append(&mut e).unwrap();
911
912        let counter = log.path().with_extension("jsonl.count");
913        let bytes = std::fs::read(&counter).expect("counter sidecar must exist after append");
914        assert_eq!(bytes.len(), 16, "counter sidecar must be 16 bytes");
915
916        let count = u64::from_le_bytes(bytes[0..8].try_into().unwrap());
917        let recorded_size = u64::from_le_bytes(bytes[8..16].try_into().unwrap());
918        let actual_size = std::fs::metadata(log.path()).unwrap().len();
919        assert_eq!(count, 1, "counter must reflect the one appended event");
920        assert_eq!(
921            recorded_size, actual_size,
922            "counter byte_size ({}) must match events.jsonl size ({})",
923            recorded_size, actual_size
924        );
925
926        let _ = std::fs::remove_dir_all(&dir);
927    }
928
929    /// A missing counter sidecar (fresh install, deleted by user, etc.)
930    /// must not break sequence_no assignment. The next append falls back
931    /// to an O(N) recount and rewrites the counter.
932    #[cfg(not(target_family = "wasm"))]
933    #[test]
934    fn counter_sidecar_recovers_when_missing() {
935        let dir = std::env::temp_dir().join(format!(
936            "treeship-evtlog-missing-counter-{}",
937            rand::random::<u32>()
938        ));
939
940        // Append two events, then nuke the counter sidecar.
941        {
942            let log = EventLog::open(&dir).unwrap();
943            let mut e1 = make_event("ssn_x", EventType::SessionStarted);
944            let mut e2 = make_event(
945                "ssn_x",
946                EventType::AgentStarted {
947                    parent_agent_instance_id: None,
948                },
949            );
950            log.append(&mut e1).unwrap();
951            log.append(&mut e2).unwrap();
952        }
953        let counter = dir.join("events.jsonl.count");
954        std::fs::remove_file(&counter).expect("counter must exist before deletion");
955
956        // Reopen + append. The third event must get sequence_no=2 even
957        // though the counter sidecar is gone.
958        let log = EventLog::open(&dir).unwrap();
959        assert_eq!(
960            log.event_count(),
961            2,
962            "open() must recount when counter is missing"
963        );
964
965        let mut e3 = make_event(
966            "ssn_x",
967            EventType::SessionClosed {
968                summary: None,
969                duration_ms: None,
970            },
971        );
972        log.append(&mut e3).unwrap();
973        assert_eq!(e3.sequence_no, 2);
974        assert!(counter.exists(), "counter must be rewritten after recount");
975
976        let _ = std::fs::remove_dir_all(&dir);
977    }
978
979    /// A short-read or garbage counter sidecar (corrupted, partial write,
980    /// truncated by external tool) must not be trusted. The size mismatch
981    /// path covers the "wrong content" case for a 16-byte file too.
982    #[cfg(not(target_family = "wasm"))]
983    #[test]
984    fn counter_sidecar_recovers_when_corrupt() {
985        let dir = std::env::temp_dir().join(format!(
986            "treeship-evtlog-corrupt-counter-{}",
987            rand::random::<u32>()
988        ));
989
990        {
991            let log = EventLog::open(&dir).unwrap();
992            let mut e = make_event("ssn_corrupt", EventType::SessionStarted);
993            log.append(&mut e).unwrap();
994        }
995        // Truncate the counter to a non-16 length.
996        let counter = dir.join("events.jsonl.count");
997        std::fs::write(&counter, b"junk").unwrap();
998
999        let log = EventLog::open(&dir).unwrap();
1000        assert_eq!(
1001            log.event_count(),
1002            1,
1003            "short-read counter must be ignored, recount kicks in"
1004        );
1005
1006        let _ = std::fs::remove_dir_all(&dir);
1007    }
1008
1009    /// A counter that recorded the wrong byte_size (someone or something
1010    /// appended to events.jsonl behind our back) must not be trusted.
1011    /// This is the crash-recovery path: peer wrote events.jsonl but
1012    /// crashed before fsyncing the counter, so the recorded size is stale.
1013    #[cfg(not(target_family = "wasm"))]
1014    #[test]
1015    fn counter_sidecar_recovers_when_size_disagrees() {
1016        let dir = std::env::temp_dir().join(format!(
1017            "treeship-evtlog-stale-counter-{}",
1018            rand::random::<u32>()
1019        ));
1020
1021        {
1022            let log = EventLog::open(&dir).unwrap();
1023            let mut e = make_event("ssn_stale", EventType::SessionStarted);
1024            log.append(&mut e).unwrap();
1025        }
1026
1027        // Simulate a crash mid-append: append one extra raw line to
1028        // events.jsonl WITHOUT updating the counter. Now the counter
1029        // says (1, S) but events.jsonl is (S + |line|) bytes.
1030        let events_path = dir.join("events.jsonl");
1031        let mut extra = make_event(
1032            "ssn_stale",
1033            EventType::AgentStarted {
1034                parent_agent_instance_id: None,
1035            },
1036        );
1037        extra.sequence_no = 999; // intentionally wrong; will be overwritten on read
1038        let mut line = serde_json::to_vec(&extra).unwrap();
1039        line.push(b'\n');
1040        let mut f = std::fs::OpenOptions::new()
1041            .append(true)
1042            .open(&events_path)
1043            .unwrap();
1044        std::io::Write::write_all(&mut f, &line).unwrap();
1045        std::io::Write::flush(&mut f).unwrap();
1046
1047        // Re-open. The size mismatch must trigger a recount; we should see 2.
1048        let log = EventLog::open(&dir).unwrap();
1049        assert_eq!(
1050            log.event_count(),
1051            2,
1052            "size mismatch must force recount, ignoring stale counter"
1053        );
1054
1055        let _ = std::fs::remove_dir_all(&dir);
1056    }
1057
1058    /// The counter sidecar fix must not break the cross-process race
1059    /// safety established by the flock layer. This is the same shape as
1060    /// `concurrent_appends_have_unique_sequence_numbers` but exists to
1061    /// guard against a regression where the counter is read OUTSIDE the
1062    /// lock, which would let two writers both see count=N and assign N
1063    /// to two different events.
1064    #[cfg(not(target_family = "wasm"))]
1065    #[test]
1066    fn counter_sidecar_preserves_concurrent_uniqueness() {
1067        use std::sync::Arc;
1068        use std::thread;
1069
1070        let dir = std::env::temp_dir().join(format!(
1071            "treeship-evtlog-counter-race-{}",
1072            rand::random::<u32>()
1073        ));
1074        std::fs::create_dir_all(&dir).unwrap();
1075
1076        const WRITERS: usize = 16;
1077        let dir = Arc::new(dir);
1078        let mut handles = Vec::with_capacity(WRITERS);
1079
1080        for _ in 0..WRITERS {
1081            let dir = Arc::clone(&dir);
1082            handles.push(thread::spawn(move || {
1083                let log = EventLog::open(&dir).unwrap();
1084                let mut e = make_event("ssn_counter_race", EventType::SessionStarted);
1085                log.append(&mut e).unwrap();
1086                e.sequence_no
1087            }));
1088        }
1089
1090        let mut seqs: Vec<u64> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1091        seqs.sort();
1092        let expected: Vec<u64> = (0..WRITERS as u64).collect();
1093        assert_eq!(
1094            seqs, expected,
1095            "counter must not bypass the flock race protection"
1096        );
1097
1098        // Counter should reflect the final state.
1099        let log = EventLog::open(&dir).unwrap();
1100        assert_eq!(log.event_count(), WRITERS as u64);
1101
1102        let _ = std::fs::remove_dir_all(&*dir);
1103    }
1104
1105    /// Counter sidecar must be created mode 0o600 (owner-only) on Unix --
1106    /// same scoping as the lock file; the existence of a counter is a
1107    /// session signal that doesn't need to leak to other users.
1108    #[cfg(all(not(target_family = "wasm"), unix))]
1109    #[test]
1110    fn counter_sidecar_has_owner_only_permissions() {
1111        use std::os::unix::fs::PermissionsExt;
1112
1113        let dir = std::env::temp_dir().join(format!(
1114            "treeship-evtlog-counter-perms-{}",
1115            rand::random::<u32>()
1116        ));
1117        let log = EventLog::open(&dir).unwrap();
1118
1119        let mut e = make_event("ssn_counter_perms", EventType::SessionStarted);
1120        log.append(&mut e).unwrap();
1121
1122        let counter = log.path().with_extension("jsonl.count");
1123        let mode = std::fs::metadata(&counter).unwrap().permissions().mode() & 0o777;
1124        assert_eq!(
1125            mode, 0o600,
1126            "counter sidecar mode is {:o}, expected 0o600 (owner-only)",
1127            mode
1128        );
1129
1130        let _ = std::fs::remove_dir_all(&dir);
1131    }
1132
1133    /// P0 regression (audit lane F): under heavy hook contention, multiple
1134    /// writers used to fall through and append without the flock when the
1135    /// 500ms poll exhausted, producing duplicate `sequence_no` values. The
1136    /// blocking `lock_exclusive` fix means every writer must hold the
1137    /// flock across both the counter read and the event write.
1138    ///
1139    /// This test spawns 8 threads, each calling `append` 25 times on its
1140    /// own `EventLog` (mimicking 8 separate hook processes each appending
1141    /// a burst of events). After the join, the on-disk log must contain
1142    /// exactly 8*25=200 events with `sequence_no` exactly the contiguous
1143    /// range 0..200, no duplicates and no gaps.
1144    #[cfg(not(target_family = "wasm"))]
1145    #[test]
1146    fn p0_no_duplicate_sequence_under_burst_contention() {
1147        use std::sync::Arc;
1148        use std::thread;
1149
1150        const THREADS: usize = 8;
1151        const PER_THREAD: usize = 25;
1152        const EXPECTED: usize = THREADS * PER_THREAD;
1153
1154        let dir = std::env::temp_dir().join(format!(
1155            "treeship-evtlog-p0-burst-{}",
1156            rand::random::<u32>()
1157        ));
1158        std::fs::create_dir_all(&dir).unwrap();
1159        let dir = Arc::new(dir);
1160
1161        let mut handles = Vec::with_capacity(THREADS);
1162        for t in 0..THREADS {
1163            let dir = Arc::clone(&dir);
1164            handles.push(thread::spawn(move || -> Vec<u64> {
1165                // Each thread opens its own EventLog -- this is the
1166                // per-process model the audit flagged: every PostToolUse
1167                // invocation is a fresh handle on the shared log.
1168                let log = EventLog::open(&dir).unwrap();
1169                let mut seen = Vec::with_capacity(PER_THREAD);
1170                for i in 0..PER_THREAD {
1171                    let mut e =
1172                        make_event(&format!("ssn_burst_{}_{}", t, i), EventType::SessionStarted);
1173                    log.append(&mut e).unwrap();
1174                    seen.push(e.sequence_no);
1175                }
1176                seen
1177            }));
1178        }
1179
1180        // Collect what each thread saw locally.
1181        let mut all_returned: Vec<u64> = handles
1182            .into_iter()
1183            .flat_map(|h| h.join().unwrap())
1184            .collect();
1185        all_returned.sort();
1186
1187        let expected: Vec<u64> = (0..EXPECTED as u64).collect();
1188        assert_eq!(
1189            all_returned, expected,
1190            "returned sequence_no values must be a contiguous range 0..{} \
1191             with no duplicates and no gaps",
1192            EXPECTED
1193        );
1194
1195        // Truth source: the on-disk log itself. Verify (a) count and
1196        // (b) sequence_no is exactly 0..EXPECTED on the persisted events.
1197        let log = EventLog::open(&dir).unwrap();
1198        let events = log.read_all().unwrap();
1199        assert_eq!(
1200            events.len(),
1201            EXPECTED,
1202            "on-disk event count must be exactly {} (got {})",
1203            EXPECTED,
1204            events.len()
1205        );
1206        let mut on_disk: Vec<u64> = events.iter().map(|e| e.sequence_no).collect();
1207        on_disk.sort();
1208        assert_eq!(
1209            on_disk, expected,
1210            "on-disk sequence_no must be a contiguous range with no duplicates and no gaps"
1211        );
1212
1213        // Counter sidecar must agree with both.
1214        assert_eq!(log.event_count(), EXPECTED as u64);
1215
1216        let _ = std::fs::remove_dir_all(&*dir);
1217    }
1218
1219    /// Companion stress test for the lock-file lifecycle. Each append
1220    /// opens the lock file, locks it, writes, unlocks, and drops the FD.
1221    /// Repeatedly creating + dropping `EventLog`s in a tight loop must
1222    /// not panic, must not leak FDs we can detect (no `EMFILE` after
1223    /// hundreds of iterations on a default ulimit), and must produce a
1224    /// log with contiguous sequence numbers.
1225    #[cfg(not(target_family = "wasm"))]
1226    #[test]
1227    fn lock_file_handles_drop_cleanly_under_churn() {
1228        let dir = std::env::temp_dir().join(format!(
1229            "treeship-evtlog-fd-churn-{}",
1230            rand::random::<u32>()
1231        ));
1232        std::fs::create_dir_all(&dir).unwrap();
1233
1234        // 500 sequential open + append + drop cycles. Far below the
1235        // default macOS/Linux soft limit (256/1024) for a sustained
1236        // leak, but plenty to catch one-per-iteration FD leaks.
1237        const ITERS: usize = 500;
1238        for i in 0..ITERS {
1239            let log = EventLog::open(&dir).unwrap();
1240            let mut e = make_event(&format!("ssn_churn_{}", i), EventType::SessionStarted);
1241            log.append(&mut e).unwrap();
1242            // log drops here -> lock_file FD already closed inside append.
1243        }
1244
1245        let log = EventLog::open(&dir).unwrap();
1246        let events = log.read_all().unwrap();
1247        assert_eq!(events.len(), ITERS);
1248        let mut seqs: Vec<u64> = events.iter().map(|e| e.sequence_no).collect();
1249        seqs.sort();
1250        let expected: Vec<u64> = (0..ITERS as u64).collect();
1251        assert_eq!(
1252            seqs, expected,
1253            "no FD leak should still produce contiguous seqs"
1254        );
1255
1256        let _ = std::fs::remove_dir_all(&dir);
1257    }
1258}