Skip to main content

zeph_session/
error.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Crate-wide error type for `zeph-session`.
5
6use thiserror::Error;
7
8/// Errors produced by the session persistence, replay, and fork engines.
9#[derive(Debug, Error)]
10pub enum SessionError {
11    /// Filesystem I/O failed while reading or appending to an event log.
12    #[error("session event log I/O error: {0}")]
13    Io(#[from] std::io::Error),
14
15    /// A `SessionEvent` line failed to (de)serialize.
16    #[error("session event (de)serialization error: {0}")]
17    Serde(#[from] serde_json::Error),
18
19    /// The `acp_sessions` metadata store returned a database error.
20    #[error("session store database error: {0}")]
21    Db(#[from] zeph_db::SqlxError),
22
23    /// A lookup by [`zeph_common::SessionId`] found no matching session.
24    #[error("session not found: {0}")]
25    NotFound(String),
26
27    /// A fork was requested at a `seq` beyond the source session's `last_seq`.
28    #[error("invalid fork point: {0}")]
29    InvalidForkPoint(String),
30
31    /// A condensation or compaction range overlapped a previously replaced range (INV-SP-4).
32    #[error("condensation range overlap: {0}")]
33    CondensationOverlap(String),
34
35    /// Condensation summarization failed (LLM call error or timeout).
36    #[error("condensation summarization failed: {0}")]
37    Llm(#[from] zeph_llm::LlmError),
38
39    /// [`crate::log::SessionEventLog::open_exclusive`] found another process already
40    /// holding the session's advisory write lock.
41    #[error("{}", describe_already_locked(path, *pid, *pid_alive))]
42    AlreadyLocked {
43        /// Path to the contended lock file.
44        path: String,
45        /// PID of the process holding the lock, read back from the lock file's contents at
46        /// contention time. `None` if the file was empty or its contents could not be parsed
47        /// as a PID (e.g. the lock was acquired by a build predating PID recording).
48        pid: Option<u32>,
49        /// Whether `pid` was confirmed still running via a liveness check (`kill(pid, 0)`) at
50        /// contention time. `None` if `pid` is `None`, or on non-Unix targets where no
51        /// liveness check runs.
52        pid_alive: Option<bool>,
53    },
54
55    /// A `UserMessage.image_refs` entry was not a bare hex string, so it was rejected before
56    /// being joined into a filesystem path (#5982 follow-up, path-traversal guard).
57    #[error("invalid blob hash (must be a non-empty hex string): {0:?}")]
58    InvalidBlobHash(String),
59
60    /// A session event log's hash chain failed to verify (issue #6360): a definite tamper
61    /// verdict, a partial strip of chain metadata, an unverifiable/possibly-re-keyed chain, an
62    /// internal (non-trailing) malformed line masquerading as a torn crash-recovery tail, or a
63    /// chained log read with no history-integrity key configured. Distinct from
64    /// [`SessionError::Serde`] (JSON-syntax errors) because a chain break always escalates to a
65    /// hard failure and — critically — must be detected and reported **before** any torn-tail
66    /// repair runs, so a mid-file tamper is never silently auto-truncated away as ordinary crash
67    /// recovery (S1).
68    #[error("{0}")]
69    Integrity(String),
70}
71
72/// Formats [`SessionError::AlreadyLocked`]'s message, distinguishing a recorded-not-running
73/// holder (#6378: the flock conflict is real, but the codebase previously gave operators
74/// nothing to verify it against) from a live or unknown one. The recorded-not-running case is
75/// hedged, not asserted as definitely stale: the lock file is a permanent sentinel that is
76/// truncated and rewritten (not atomically replaced) on each acquire, so a contender can observe
77/// the *previous* holder's now-dead PID during the brief window between a new holder's
78/// successful `flock` and its PID write — see the `WOULDBLOCK` branch in
79/// [`AdvisoryLock::acquire`](crate::log::AdvisoryLock::acquire). Never suggests auto-recovery —
80/// breaking a live flock without the holder's cooperation is unsafe, so this is diagnostic-only.
81fn describe_already_locked(path: &str, pid: Option<u32>, pid_alive: Option<bool>) -> String {
82    match (pid, pid_alive) {
83        (Some(pid), Some(false)) => format!(
84            "session event log at {path} is already locked: the recorded holder pid {pid} is \
85             not running; the lock may be stale, or may have just been re-acquired by another \
86             process — verify before removing it. Not auto-recovered because breaking a live \
87             flock without holder cooperation is unsafe"
88        ),
89        (Some(pid), _) => format!(
90            "session event log at {path} is already locked by another process (held by pid {pid})"
91        ),
92        (None, _) => format!(
93            "session event log at {path} is already locked by another process (holder pid unknown)"
94        ),
95    }
96}