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