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;