Skip to main content

mesh_llm_analytics/
version_state.rs

1//! The last build version this install reported under.
2//!
3//! An upgrade is invisible to every other signal here. `install_first_run`
4//! fires once and never again, and `serve_started` carries the current
5//! version but says nothing about what came before it — so an install that
6//! upgraded and one that never moved look identical unless it happens to
7//! serve. Recording the last-seen version turns that into an explicit
8//! [`Event::InstallUpdated`](crate::Event::InstallUpdated).
9//!
10//! Comparing against stored state rather than hooking the updater is what
11//! makes this cover every route a new binary can arrive by: `--auto-update`,
12//! `mesh-llm update`, a re-run of `install.sh`, a package manager, or a
13//! hand-swapped binary. The updater does not have to cooperate, and a process
14//! that `exec`s itself away mid-update does not have to deliver anything: the
15//! next run observes the change and reports it with a normal lifetime and a
16//! normal flush.
17//!
18//! # Reporting exactly once
19//!
20//! The transition is a read-compare-write across processes, and an upgrade
21//! must produce one `install_updated`, not none and not several. Three things
22//! hold that together:
23//!
24//! - **A lock.** Several mesh-llm processes can start at once after an
25//!   upgrade. Without a lock each would read the old version and each would
26//!   report the same transition. The read and the write happen under one
27//!   exclusive lock on the state directory, and a process that cannot take it
28//!   reports nothing rather than racing.
29//! - **An atomic replace.** The record is written to a temporary file and
30//!   renamed over the old one, so a crash mid-write leaves the previous
31//!   version intact instead of a truncated file that reads as no record.
32//! - **Commit before report.** [`PendingVersion::commit`] returns whether the
33//!   record now actually reflects this build, and the caller only reports the
34//!   upgrade when it does. A write that failed leaves the old version in
35//!   place, so the next run sees the same transition again and can retry —
36//!   the opposite of the old behaviour, which reported a change it had failed
37//!   to persist and then reported it again on every subsequent run.
38
39use std::fs::{self, File};
40use std::io::Write;
41use std::path::{Path, PathBuf};
42
43/// File name inside the mesh-llm state directory.
44pub const VERSION_FILE: &str = "analytics-version";
45
46/// Lock held across the read-compare-write.
47const VERSION_LOCK_FILE: &str = "analytics-version.lock";
48
49/// Temporary file the new record is staged in before being renamed into place.
50const VERSION_TEMP_FILE: &str = "analytics-version.tmp";
51
52/// What changed about the build version since the last run.
53#[derive(Clone, Debug, Eq, PartialEq)]
54pub enum VersionTransition {
55    /// No version was recorded before, so there is nothing to compare to.
56    /// A genuine first run reports `install_first_run` instead.
57    Fresh,
58    /// The recorded version matches this build.
59    Unchanged,
60    /// This build differs from the recorded one.
61    Changed {
62        /// The version recorded by the previous run.
63        from: String,
64    },
65}
66
67/// A read transition that has not been written back yet.
68///
69/// Holds the state-directory lock until it is committed or dropped, so no
70/// other process can observe the same transition in the meantime.
71#[must_use = "the transition is not persisted until it is committed"]
72pub struct PendingVersion {
73    transition: VersionTransition,
74    /// `None` when there is nothing to persist, or when the lock could not be
75    /// taken and this process must stay out of the way.
76    target: Option<Target>,
77    /// Dropping the handle releases the lock. Also released by the OS if the
78    /// process dies, which is why a crash cannot wedge later runs.
79    _lock: Option<File>,
80}
81
82struct Target {
83    dir: PathBuf,
84    current: String,
85}
86
87impl PendingVersion {
88    /// What the previous run recorded, relative to this build.
89    #[must_use]
90    pub fn transition(&self) -> &VersionTransition {
91        &self.transition
92    }
93
94    /// Persist this build as the recorded version.
95    ///
96    /// Returns whether the record now reflects this build. `false` means the
97    /// write failed or was skipped, the previous record still stands, and the
98    /// caller must not report the transition — the next run will see it again.
99    pub fn commit(self) -> bool {
100        let Some(target) = self.target.as_ref() else {
101            // Nothing to write (already current), or the lock was not ours.
102            // Either way this process must not report a transition.
103            return false;
104        };
105        write_atomically(&target.dir, &target.current).is_ok()
106    }
107
108    /// A transition this process must not act on.
109    fn inert() -> Self {
110        Self {
111            transition: VersionTransition::Fresh,
112            target: None,
113            _lock: None,
114        }
115    }
116}
117
118/// Read the recorded version, holding the state lock until the caller commits.
119///
120/// Best effort by design: a state directory that cannot be locked or read
121/// yields an inert [`PendingVersion`], which reports nothing. The alternative
122/// — treating an unreadable directory as a version change — would report an
123/// upgrade on every single run.
124///
125/// The version is stored verbatim rather than sanitized. It is compared for
126/// equality here and passed through
127/// [`Label::sanitize_or_redact`](crate::Label::sanitize_or_redact) before it
128/// reaches the wire, so a build environment that stamped something strange
129/// into the version cannot smuggle it into a property.
130pub fn begin(dir: &Path, current: &str) -> PendingVersion {
131    if fs::create_dir_all(dir).is_err() {
132        return PendingVersion::inert();
133    }
134
135    // An exclusive lock, not a shared one: this is a read-compare-write, and
136    // two readers that both see the old version both report the upgrade.
137    let Ok(lock) = File::create(dir.join(VERSION_LOCK_FILE)) else {
138        return PendingVersion::inert();
139    };
140    if lock.try_lock().is_err() {
141        // Another mesh-llm process holds it and is resolving the same
142        // transition. Exactly one of them should report it, and it is not us.
143        return PendingVersion::inert();
144    }
145
146    let previous = match read_record(dir) {
147        Ok(record) => record,
148        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
149        Err(_) => {
150            // The record exists but cannot be read (permissions, IO). That is
151            // not "no record": committing here would rename a new record over
152            // the unreadable one and destroy the previous version without the
153            // upgrade ever being reported. Step aside instead.
154            return PendingVersion::inert();
155        }
156    };
157
158    let transition = match &previous {
159        None => VersionTransition::Fresh,
160        Some(recorded) if recorded == current => VersionTransition::Unchanged,
161        Some(recorded) => VersionTransition::Changed {
162            from: recorded.clone(),
163        },
164    };
165
166    // Nothing to write when the record already names this build. Skipping it
167    // keeps an ordinary run from touching the state directory at all.
168    let target = (previous.as_deref() != Some(current)).then(|| Target {
169        dir: dir.to_owned(),
170        current: current.to_owned(),
171    });
172
173    PendingVersion {
174        transition,
175        target,
176        _lock: Some(lock),
177    }
178}
179
180/// Read the recorded version, or `None` when there is no record yet.
181///
182/// Separates "no record" (a missing or empty file) from "cannot read the
183/// record" so the caller can decline to overwrite a record it could not read.
184fn read_record(dir: &Path) -> std::io::Result<Option<String>> {
185    let raw = fs::read_to_string(dir.join(VERSION_FILE))?;
186    let recorded = raw.trim();
187    Ok((!recorded.is_empty()).then(|| recorded.to_owned()))
188}
189
190/// Replace the record by renaming a fully written temporary file over it.
191///
192/// A direct write truncates first, so a crash between truncate and write
193/// leaves an empty record that reads as "no version ever seen" — which would
194/// silently swallow the next upgrade. `rename` within one directory is atomic,
195/// so a reader sees either the old record or the new one.
196///
197/// The fixed temporary name is safe because the caller holds the exclusive
198/// lock for the whole read-commit window.
199fn write_atomically(dir: &Path, current: &str) -> std::io::Result<()> {
200    let temp = dir.join(VERSION_TEMP_FILE);
201    // Scoped so the handle is closed before the rename; Windows refuses to
202    // rename a file that is still open.
203    {
204        let mut file = File::create(&temp)?;
205        file.write_all(format!("{current}\n").as_bytes())?;
206        // Without this the rename can be durable while the contents are not,
207        // leaving an empty record after a crash.
208        file.sync_all()?;
209    }
210    fs::rename(&temp, dir.join(VERSION_FILE)).inspect_err(|_| {
211        let _ = fs::remove_file(&temp);
212    })
213}
214
215#[cfg(test)]
216#[path = "version_state/tests.rs"]
217mod tests;