Skip to main content

magi/
updater.rs

1//! Self-update, via `kaishin`.
2//!
3//! A magi run takes minutes of agent latency, so a background release check
4//! costs nothing measurable: it is spawned on the same tokio runtime as the
5//! command, overlaps it, and is drained with a bounded wait at shutdown. It
6//! never delays the graph.
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use anyhow::{Context, Result};
11use jiff::Timestamp;
12use serde::{Deserialize, Serialize};
13
14use crate::config::{Update, UpdateMode};
15
16/// Env kill-switch. Any non-empty value other than `0` / `false` disables the
17/// background check, and it is read before the config so a broken `magi.toml`
18/// cannot force a network call.
19pub const NO_AUTOUPDATE_ENV: &str = "MAGI_NO_AUTOUPDATE";
20
21/// Default interval between checks.
22pub fn default_interval() -> Duration {
23    kaishin::default_interval()
24}
25
26/// Floor under [`effective_interval`], matching GitHub's unauthenticated rate
27/// limit for the releases API (60 requests/hour/address).
28///
29/// A one-off CLI invocation honouring a shorter configured interval could
30/// only ever make one call per process, so it was never at risk of tripping
31/// that limit on its own. The background recheck loop in `web.rs` is
32/// different: it polls for as long as `magi web` stays up, so an interval
33/// configured well below an hour would have it repeat the same call for as
34/// long as the deck runs - a shared floor here is what keeps it, and every
35/// other caller of [`Checker::new`], inside the budget regardless.
36const MIN_INTERVAL: Duration = Duration::from_secs(60);
37
38/// The interval `cfg` configures, floored at [`MIN_INTERVAL`], or
39/// [`default_interval`] when unset or unparsable.
40///
41/// Shared by [`Checker::new`], which throttles the network call itself
42/// against it, and the background recheck loop in `web.rs`, which uses it to
43/// decide how often to even ask - it cannot track an interval it never sees.
44pub fn effective_interval(cfg: &Update) -> Duration {
45    let interval = cfg
46        .interval
47        .as_deref()
48        .and_then(|s| kaishin::parse_interval(s).ok())
49        .unwrap_or_else(default_interval);
50    interval.max(MIN_INTERVAL)
51}
52
53/// Is the background check switched off by the environment?
54pub fn disabled_by_env() -> bool {
55    match std::env::var(NO_AUTOUPDATE_ENV) {
56        Ok(v) => {
57            let v = v.trim();
58            !(v.is_empty() || v == "0" || v.eq_ignore_ascii_case("false"))
59        }
60        Err(_) => false,
61    }
62}
63
64/// Worker descendants must never replace or re-exec their immutable binary.
65fn pinned_worker_generation() -> bool {
66    std::env::var_os("MAGI_WORKER_GENERATION").is_some()
67}
68
69/// GitHub owner.
70const OWNER: &str = "yukimemi";
71/// GitHub repository — *not* `CARGO_PKG_NAME`, which is the published package.
72const REPO: &str = "magi";
73/// Binary inside the release asset.
74const BIN: &str = "magi";
75/// Published package name, for kaishin's `cargo install` fallback.
76const CRATE: &str = "magi-cli";
77
78/// This binary's own repository name, for `--repo .` auto-discovery
79/// (`web::serve`, `main::resolve_repo`) to match a checkout against without
80/// a second, driftable copy of [`REPO`] anywhere else.
81pub fn repo_name() -> &'static str {
82    REPO
83}
84
85/// This binary's path, safe to spawn after the file was replaced on disk.
86///
87/// Linux resolves `current_exe` through `/proc/self/exe`, and once the upgrade
88/// has swapped the binary the kernel reports the old, unlinked inode as
89/// `/path/magi (deleted)`. Spawning that string fails with ENOENT. Callers that
90/// look up the executable after replacement (currently the two `chat_watch`
91/// spawn sites) use this helper to strip the suffix only when the decorated
92/// path does not exist and the plain one does. Web successor spawning uses
93/// the executable path saved at startup instead.
94pub fn current_exe() -> std::io::Result<PathBuf> {
95    let exe = std::env::current_exe()?;
96    Ok(without_deleted_suffix(exe))
97}
98
99fn without_deleted_suffix(exe: PathBuf) -> PathBuf {
100    if exe.exists() {
101        return exe;
102    }
103    match plain_of_deleted(&exe) {
104        Some(p) if p.exists() => p,
105        _ => exe,
106    }
107}
108
109/// `exe` without the kernel's ` (deleted)` suffix. Bytes, not `str`: an
110/// install path that is not UTF-8 must still be repaired.
111#[cfg(unix)]
112fn plain_of_deleted(exe: &Path) -> Option<PathBuf> {
113    use std::os::unix::ffi::{OsStrExt as _, OsStringExt as _};
114    let bytes = exe.as_os_str().as_bytes().strip_suffix(b" (deleted)")?;
115    Some(PathBuf::from(std::ffi::OsString::from_vec(bytes.to_vec())))
116}
117
118/// Only Linux decorates the path; nothing to strip elsewhere.
119#[cfg(not(unix))]
120fn plain_of_deleted(_exe: &Path) -> Option<PathBuf> {
121    None
122}
123
124/// kaishin options.
125///
126/// All four names are spelled out because three of them differ from
127/// `CARGO_PKG_NAME`: the package is `magi-cli` (the short name is a squatted
128/// placeholder on crates.io) while the repo, the binary and the library are
129/// `magi`. Deriving any of these from `CARGO_PKG_NAME` would send the updater
130/// looking for a `yukimemi/magi-cli` repository that does not exist.
131fn options() -> kaishin::KaishinOptions {
132    kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION")).crate_name(CRATE)
133}
134
135/// Throttle bookkeeping is transient, so it belongs in the cache dir rather
136/// than beside the run history in the data dir.
137fn state_path() -> Option<PathBuf> {
138    dirs::cache_dir().map(|d| d.join("magi").join("last_update_check.json"))
139}
140
141/// `magi self-update`.
142pub async fn run_self_update(yes: bool, check_only: bool, non_interactive: bool) -> Result<()> {
143    if !check_only && pinned_worker_generation() {
144        anyhow::bail!(
145            "this worker executable generation is immutable; run self-update \
146             from the installed magi executable outside the worker"
147        );
148    }
149    let opts = kaishin::UpdateOptions::new()
150        .yes(yes)
151        .check_only(check_only)
152        .non_interactive(non_interactive);
153    kaishin::run_self_update(&options(), opts).await
154}
155
156/// A background update check, resolved at shutdown.
157pub enum Pending {
158    /// A previous run already found a newer release; just print the banner.
159    Cached {
160        /// For [`Checker::format_banner`].
161        checker: Checker,
162        /// The release found earlier.
163        latest: kaishin::LatestRelease,
164    },
165    /// A notify-mode check is in flight.
166    Notify {
167        /// For [`Checker::format_banner`].
168        checker: Checker,
169        /// The spawned task.
170        handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
171    },
172    /// An install-mode update is in flight.
173    Install {
174        /// The spawned task.
175        handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
176    },
177}
178
179/// Throttled release checker.
180#[derive(Clone)]
181pub struct Checker {
182    inner: kaishin::Checker,
183}
184
185impl Checker {
186    /// Build a checker honouring `cfg`, or `None` when checking is off.
187    ///
188    /// The `Option` had no `None` arm: every caller that asked for a checker
189    /// got one, so `[update] mode = "off"` was honoured by the *notify* path
190    /// alone (see [`cached_update`], which matches on the mode itself) and
191    /// ignored everywhere else. `POST /api/upgrade` therefore called the
192    /// GitHub releases API on a deck configured never to check - and so did
193    /// every unit test that reached that route, unauthenticated, against
194    /// GitHub's 60-per-hour-per-address limit.
195    ///
196    /// An operator who writes `mode = "off"` means it. The button is still
197    /// theirs to press; what it may not do is go to the network behind a
198    /// configuration that says not to.
199    pub fn new(cfg: &Update) -> Option<Self> {
200        if cfg.mode == UpdateMode::Off || pinned_worker_generation() {
201            return None;
202        }
203        let mut inner = kaishin::Checker::new(BIN, options());
204        if let Some(path) = state_path() {
205            inner = inner.state_path(path);
206        }
207        Some(Self {
208            inner: inner.interval(effective_interval(cfg)),
209        })
210    }
211
212    /// Is a check due?
213    pub fn should_check(&self) -> bool {
214        self.inner.should_check()
215    }
216
217    /// Ask the forge now: is there a release newer than this build?
218    ///
219    /// Unlike [`Checker::cached_update`] this method does not consult
220    /// [`Checker::should_check`] itself - it has two callers, and they throttle
221    /// differently. `POST /api/upgrade` calls it unconditionally, because the
222    /// caller there is an operator who just pressed a button and is owed an
223    /// answer about the state of the world rather than about the last time
224    /// magi looked. `magi web`'s background recheck (`web::run_update_recheck`)
225    /// calls [`Checker::should_check`] itself first and only reaches here when
226    /// it says yes, which is what keeps that task's network use to at most
227    /// once per `[update] interval` no matter how often it polls.
228    pub async fn newer_release(&self) -> Result<Option<kaishin::LatestRelease>> {
229        self.inner.check_and_save().await
230    }
231
232    /// A newer release already known from a previous run.
233    pub fn cached_update(&self) -> Option<kaishin::LatestRelease> {
234        self.inner.cached_update()
235    }
236
237    /// One-line "a newer version exists" banner.
238    pub fn format_banner(&self, latest: &kaishin::LatestRelease) -> String {
239        self.inner.format_banner(latest)
240    }
241
242    /// A checker over an explicit state path and interval, for a test that
243    /// must control throttle timing without touching the operator's real
244    /// cache directory - see [`state_path`] for why sharing it would be
245    /// unsafe.
246    #[cfg(test)]
247    pub(crate) fn for_test(interval: Duration, state_path: PathBuf) -> Self {
248        let opts = kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION"));
249        Self {
250            inner: kaishin::Checker::new(BIN, opts)
251                .state_path(state_path)
252                .interval(interval),
253        }
254    }
255}
256
257/// How far a self-upgrade this deck set in motion has gotten.
258///
259/// `POST /api/upgrade` answers `202` and returns immediately - see its own
260/// doc for why - so [`Progress`] is the only way a phone that asked for an
261/// upgrade learns anything about it afterwards.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "snake_case")]
264pub enum Stage {
265    /// Downloading the release asset and replacing the binary. This bundles
266    /// what would otherwise be two stages: kaishin re-confirms the release
267    /// and downloads it inside one `await` with no hook to split, so from
268    /// here a phone cannot tell "still checking" from "still downloading" -
269    /// only that nothing has been swapped in yet. The confirmation that ran
270    /// *before* this stage started is already known to the phone: it is what
271    /// the `to` version in the `202` answered with.
272    Downloading,
273    /// The new binary is in place and [`crate::web`]'s handover has been
274    /// signalled, but has not acted yet.
275    Replaced,
276    /// Legacy progress from an embedded scheduler upgrade. Retained for
277    /// first-migration reporting only; independent workers never enter it.
278    Parking,
279    /// Restarting the scheduler and web listener. Execution workers continue
280    /// on their immutable executable and are never awaited by this stage.
281    Restarting,
282    /// A successor came up and confirmed it is running the release this
283    /// upgrade asked for.
284    Done,
285    /// The upgrade did not reach [`Stage::Done`]. `detail` on [`Progress`]
286    /// says why.
287    Failed,
288}
289
290impl Stage {
291    /// Position in the upgrade's progression. Only meaningful for the
292    /// non-terminal stages; a terminal one ends the upgrade.
293    #[must_use]
294    pub fn rank(self) -> u8 {
295        match self {
296            Self::Downloading => 0,
297            Self::Replaced => 1,
298            Self::Parking => 2,
299            Self::Restarting => 3,
300            Self::Done | Self::Failed => 4,
301        }
302    }
303
304    /// Finished, one way or the other - nothing is still moving.
305    #[must_use]
306    pub fn terminal(self) -> bool {
307        matches!(self, Self::Done | Self::Failed)
308    }
309
310    /// The wire spelling, the same one serde writes to `upgrade.json`.
311    #[must_use]
312    pub fn as_str(self) -> &'static str {
313        match self {
314            Self::Downloading => "downloading",
315            Self::Replaced => "replaced",
316            Self::Parking => "parking",
317            Self::Restarting => "restarting",
318            Self::Done => "done",
319            Self::Failed => "failed",
320        }
321    }
322}
323
324/// Source of a shared update-and-restart request.
325#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
326#[serde(rename_all = "lowercase")]
327pub enum UpgradeTrigger {
328    /// Requested through the web UI.
329    #[default]
330    Manual,
331    /// Requested by the idle-only background checker.
332    Auto,
333}
334
335/// One upgrade's progress, persisted at [`progress_path`].
336///
337/// Kept on disk rather than in memory because the process that finishes an
338/// upgrade is never the one that started it: the successor is a fresh binary
339/// (see `web::spawn_successor`), and the only thing the two share is the
340/// disk. Beside the run history rather than under the cache dir alongside
341/// [`state_path`]: this is not throttle bookkeeping, it is the record of one
342/// upgrade the operator asked for, and - like a parked run - it is meant to
343/// outlive the process that wrote it.
344#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct Progress {
346    /// Whether the operator or the background checker requested this upgrade.
347    #[serde(default)]
348    pub trigger: UpgradeTrigger,
349    /// Where this upgrade has gotten to.
350    pub stage: Stage,
351    /// Version this upgrade started from.
352    pub from: String,
353    /// Version it is replacing itself with.
354    pub to: Option<String>,
355    /// The run [`Stage::Parking`] is waiting on, when one was in flight.
356    #[serde(default)]
357    pub parked_run: Option<String>,
358    /// Chat turns [`Stage::Parking`] is still waiting on, by talk id.
359    #[serde(default)]
360    pub parked_talks: Vec<String>,
361    /// When this upgrade was asked for.
362    pub started_at: Timestamp,
363    /// Last time `stage` changed.
364    pub updated_at: Timestamp,
365    /// Why [`Stage::Failed`] happened; `None` for every other stage.
366    #[serde(default)]
367    pub detail: Option<String>,
368}
369
370impl Progress {
371    /// A fresh record for an upgrade that is about to replace the binary.
372    #[must_use]
373    pub fn new(from: String, to: String) -> Self {
374        let now = Timestamp::now();
375        Self {
376            trigger: UpgradeTrigger::Manual,
377            stage: Stage::Downloading,
378            from,
379            to: Some(to),
380            parked_run: None,
381            parked_talks: Vec::new(),
382            started_at: now,
383            updated_at: now,
384            detail: None,
385        }
386    }
387
388    /// Move to `stage`, stamping when it changed.
389    pub fn advance(&mut self, stage: Stage) {
390        self.stage = stage;
391        self.updated_at = Timestamp::now();
392        // A note belongs to the stage it was written for.
393        self.detail = None;
394        self.parked_talks.clear();
395    }
396
397    /// Stop at [`Stage::Failed`], with a reason a human can read.
398    pub fn fail(&mut self, detail: impl Into<String>) {
399        self.stage = Stage::Failed;
400        self.updated_at = Timestamp::now();
401        self.detail = Some(detail.into());
402    }
403}
404
405/// Where [`Progress`] is recorded: beside `daemon.json`, not under the cache
406/// dir - see [`Progress`]'s own doc for why the two are not the same place.
407#[must_use]
408pub fn progress_path(home: &Path) -> PathBuf {
409    home.join("upgrade.json")
410}
411
412/// Serialises the read-compare-rename of [`write_progress`], so two writers
413/// in this process cannot each compare against the same stale record.
414static WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
415
416/// What to persist when `candidate` is written over `current`.
417///
418/// An upgrade in progress never goes backwards: a candidate whose stage is
419/// not past the recorded one (a second `POST /api/upgrade` landing on a live
420/// handover wrote `replaced` over `parking`) keeps the recorded stage and
421/// timestamps and only refreshes `to`. A terminal record is over, so whatever
422/// comes next starts a new upgrade, and a candidate that ends the upgrade
423/// (`Done` / `Failed`) always goes through.
424#[must_use]
425pub fn monotonic(current: Option<&Progress>, candidate: &Progress) -> Progress {
426    match current {
427        Some(cur)
428            if !cur.stage.terminal()
429                && !candidate.stage.terminal()
430                && candidate.stage.rank() <= cur.stage.rank() =>
431        {
432            let mut kept = cur.clone();
433            if candidate.to.is_some() {
434                kept.to.clone_from(&candidate.to);
435            }
436            kept
437        }
438        _ => candidate.clone(),
439    }
440}
441
442/// Persist `progress`, atomically, never moving an upgrade in progress back
443/// to an earlier stage - see [`monotonic`].
444///
445/// Written to a sibling `.tmp` and renamed, the same reason
446/// `daemon::write_status_to` does it: `/api/health` reads this file on every
447/// poll and must never see a half-written one.
448pub fn write_progress(home: &Path, progress: &Progress) -> Result<()> {
449    let _guard = WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
450    write_progress_locked(home, progress)
451}
452
453fn write_progress_locked(home: &Path, progress: &Progress) -> Result<()> {
454    let path = progress_path(home);
455    if let Some(parent) = path.parent() {
456        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
457    }
458    let to_write = monotonic(read_progress(home).as_ref(), progress);
459    store_progress(home, &to_write)
460}
461
462/// Write `progress` as given, atomically. The caller holds [`WRITE_LOCK`] and
463/// has already decided the record may replace the current one.
464fn store_progress(home: &Path, progress: &Progress) -> Result<()> {
465    let path = progress_path(home);
466    let body = serde_json::to_string_pretty(progress).context("serialize upgrade progress")?;
467    let tmp = path.with_extension("json.tmp");
468    std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
469    std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
470    Ok(())
471}
472
473/// The phrase naming the chat turns a park waits on, empty for none.
474#[must_use]
475pub fn talks_phrase(talks: &[String]) -> String {
476    match talks {
477        [] => String::new(),
478        [one] => format!("chat turn {one} is"),
479        many => format!("chat turns {} are", many.join(", ")),
480    }
481}
482
483/// Record an upgrade failure. A legacy embedded handover owns its own
484/// parking state; new detached restarts may fail and must report that failure.
485pub fn fail_progress(home: &Path, detail: &str) -> Result<bool> {
486    let _guard = WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
487    let Some(mut progress) = read_progress(home) else {
488        return Ok(false);
489    };
490    if progress.stage == Stage::Parking {
491        return Ok(false);
492    }
493    progress.fail(detail);
494    write_progress_locked(home, &progress)?;
495    Ok(true)
496}
497
498/// The last upgrade this deck recorded, if it has ever started one.
499#[must_use]
500pub fn read_progress(home: &Path) -> Option<Progress> {
501    let body = std::fs::read_to_string(progress_path(home)).ok()?;
502    serde_json::from_str(&body).ok()
503}
504
505/// Where every handover step is appended, beside `upgrade.json`. Written by
506/// this module directly, so no supervisor redirection of stderr can orphan it.
507#[must_use]
508pub fn log_path(home: &Path) -> PathBuf {
509    home.join("upgrade.log")
510}
511
512/// The log is rotated to `upgrade.log.1` (one generation) past this size.
513pub const LOG_MAX_BYTES: u64 = 256 * 1024;
514
515/// A non-terminal `replaced` / `restarting` stage older than this is stuck.
516pub const STALL_AFTER_SECS: i64 = 120;
517
518/// A handover lease with no beat for this long is not alive. Same idiom as
519/// `ask::LEASE_TTL`; no pid is consulted.
520pub const LEASE_TTL_SECS: i64 = 90;
521
522/// How often the watchdog repeats itself for one stage.
523pub const HEARTBEAT_SECS: i64 = 60;
524
525/// How often the watchdog thread looks at `upgrade.json`.
526pub const WATCHDOG_POLL: Duration = Duration::from_secs(30);
527
528/// Append `line` to `path`, rotating to `<path>.1` first when the file has
529/// reached `max` bytes. Open-append-close every time, so a rename or a
530/// deleted file never leaves a stale handle.
531pub fn append_bounded(path: &Path, line: &str, max: u64) -> std::io::Result<()> {
532    use std::io::Write as _;
533    if std::fs::metadata(path).is_ok_and(|m| m.len() >= max) {
534        let mut old = path.as_os_str().to_owned();
535        old.push(".1");
536        std::fs::rename(path, PathBuf::from(old))?;
537    }
538    if let Some(parent) = path.parent() {
539        std::fs::create_dir_all(parent)?;
540    }
541    let mut file = std::fs::OpenOptions::new()
542        .create(true)
543        .append(true)
544        .open(path)?;
545    writeln!(file, "{line}")
546}
547
548/// One handover step: to `tracing` at INFO and to `<home>/upgrade.log`, with
549/// a UTC timestamp and this process's pid. Best-effort - a failed write is a
550/// warning, never a failed upgrade.
551pub fn log_step(home: &Path, msg: &str) {
552    tracing::info!("handover: {msg}");
553    log_line(home, "INFO", msg);
554}
555
556/// Like [`log_step`], at WARN.
557pub fn log_warn(home: &Path, msg: &str) {
558    tracing::warn!("handover: {msg}");
559    log_line(home, "WARN", msg);
560}
561
562fn log_line(home: &Path, level: &str, msg: &str) {
563    let line = format!(
564        "{} pid={} {level} {msg}",
565        Timestamp::now(),
566        std::process::id()
567    );
568    if let Err(e) = append_bounded(&log_path(home), &line, LOG_MAX_BYTES) {
569        tracing::warn!("could not append to {}: {e}", log_path(home).display());
570    }
571}
572
573/// [`write_progress`] that says so when it fails, instead of dropping the
574/// error: a progress file that silently stops moving is the symptom this
575/// module exists to explain.
576pub fn write_progress_logged(home: &Path, progress: &Progress) {
577    if let Err(e) = write_progress(home, progress) {
578        log_warn(home, &format!("could not write upgrade.json: {e:#}"));
579    }
580}
581
582/// Proof that `hand_over` is running: written when it is entered, beaten
583/// while it waits on the loop, removed when it leaves.
584#[derive(Debug, Clone, Serialize, Deserialize)]
585pub struct HandoverLease {
586    /// When `hand_over` was entered.
587    pub entered_at: Timestamp,
588    /// Last time it said it was alive.
589    pub beat_at: Timestamp,
590    /// The run it is waiting on, when one was in flight.
591    #[serde(default)]
592    pub parked_run: Option<String>,
593}
594
595impl HandoverLease {
596    /// Whether the last beat is recent enough at `now`.
597    #[must_use]
598    pub fn fresh(&self, now: Timestamp) -> bool {
599        now.as_second() - self.beat_at.as_second() <= LEASE_TTL_SECS
600    }
601}
602
603/// Where the [`HandoverLease`] lives, beside `upgrade.json`.
604#[must_use]
605pub fn lease_path(home: &Path) -> PathBuf {
606    home.join("upgrade.handover.json")
607}
608
609/// The lease on disk, if there is one.
610#[must_use]
611pub fn read_lease(home: &Path) -> Option<HandoverLease> {
612    let body = std::fs::read_to_string(lease_path(home)).ok()?;
613    serde_json::from_str(&body).ok()
614}
615
616#[cfg(test)]
617fn write_lease(home: &Path, lease: &HandoverLease) {
618    let path = lease_path(home);
619    let tmp = path.with_extension("json.tmp");
620    let written = serde_json::to_string(lease)
621        .map_err(std::io::Error::other)
622        .and_then(|body| std::fs::write(&tmp, body))
623        .and_then(|()| std::fs::rename(&tmp, &path));
624    if let Err(e) = written {
625        log_warn(home, &format!("could not write the handover lease: {e}"));
626    }
627}
628
629/// Legacy handover fixture for compatibility tests; new handovers never park.
630#[cfg(test)]
631#[derive(Debug)]
632pub struct LeaseGuard {
633    home: PathBuf,
634    lease: HandoverLease,
635}
636
637#[cfg(test)]
638impl LeaseGuard {
639    /// Record that `hand_over` has been entered.
640    #[must_use]
641    pub fn enter(home: &Path, parked_run: Option<String>) -> Self {
642        let now = Timestamp::now();
643        let lease = HandoverLease {
644            entered_at: now,
645            beat_at: now,
646            parked_run,
647        };
648        write_lease(home, &lease);
649        Self {
650            home: home.to_owned(),
651            lease,
652        }
653    }
654
655    /// Say it is still alive.
656    pub fn beat(&mut self) {
657        self.lease.beat_at = Timestamp::now();
658        write_lease(&self.home, &self.lease);
659    }
660}
661
662#[cfg(test)]
663impl Drop for LeaseGuard {
664    fn drop(&mut self) {
665        let _ = std::fs::remove_file(lease_path(&self.home));
666    }
667}
668
669/// How a non-terminal stage came to be called stuck.
670#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
671#[serde(rename_all = "snake_case")]
672pub enum StallKind {
673    /// The handover was signalled but `hand_over` left no record of entering.
674    NeverEntered,
675    /// `hand_over` did enter (or the successor is starting) but nothing has
676    /// moved or beaten for longer than allowed.
677    StoppedBeating,
678}
679
680/// A non-terminal stage that has outlived what it should take.
681#[derive(Debug, Clone, PartialEq, Eq)]
682pub struct Stall {
683    /// The stage that is stuck.
684    pub stage: Stage,
685    /// Which kind of stuck, which decides what the operator is told.
686    pub kind: StallKind,
687    /// How long it has been without progress.
688    pub age_secs: i64,
689    /// What it is waiting on, in words.
690    pub waiting_on: String,
691}
692
693/// Seconds `progress` has been in its stage at `now`. A clock that went
694/// backwards counts as zero, never as a negative age.
695#[must_use]
696pub fn stage_age_secs(progress: &Progress, now: Timestamp) -> i64 {
697    (now.as_second() - progress.updated_at.as_second()).max(0)
698}
699
700/// The lease, when it proves `hand_over` of *this* upgrade is alive at `now`.
701#[must_use]
702pub fn live_lease<'a>(
703    progress: &Progress,
704    lease: Option<&'a HandoverLease>,
705    now: Timestamp,
706) -> Option<&'a HandoverLease> {
707    lease.filter(|l| {
708        !progress.stage.terminal() && l.fresh(now) && l.entered_at >= progress.started_at
709    })
710}
711
712/// What a stage is waiting on, in words.
713fn waiting_on(progress: &Progress, lease: Option<&HandoverLease>) -> String {
714    match progress.stage {
715        Stage::Replaced => "hand_over to start (HANDOVER was signalled; hand_over has left no \
716                            record that it was entered)"
717            .to_owned(),
718        Stage::Parking => {
719            let run = lease
720                .and_then(|l| l.parked_run.as_ref())
721                .or(progress.parked_run.as_ref());
722            let talks = talks_phrase(&progress.parked_talks);
723            let talks = if talks.is_empty() {
724                String::new()
725            } else {
726                format!("{talks} still finishing; ")
727            };
728            match run {
729                Some(run) => {
730                    format!("{talks}the loop to finish run {run} at its next node boundary")
731                }
732                None => format!("{talks}the loop to stop (no run was recorded as in flight)"),
733            }
734        }
735        Stage::Restarting => "spawn_successor returning and this process exiting".to_owned(),
736        Stage::Downloading => "the release download and binary replacement".to_owned(),
737        Stage::Done | Stage::Failed => String::new(),
738    }
739}
740
741/// Seconds `hand_over` has been alive and waiting, when `lease` proves it.
742#[must_use]
743pub fn waited_secs(lease: &HandoverLease, now: Timestamp) -> i64 {
744    (now.as_second() - lease.entered_at.as_second()).max(0)
745}
746
747/// Pure: whether `progress` is stuck at `now`.
748///
749/// A fresh lease means `hand_over` is alive and waiting on the loop, which is
750/// legitimate for as long as the run's node takes, so it is never stuck.
751#[must_use]
752pub fn stall(progress: &Progress, lease: Option<&HandoverLease>, now: Timestamp) -> Option<Stall> {
753    if progress.stage.terminal() || progress.stage == Stage::Downloading {
754        return None;
755    }
756    if live_lease(progress, lease, now).is_some() {
757        return None;
758    }
759    // A stale lease for this upgrade means hand_over was alive and went quiet.
760    let stale = lease.filter(|l| l.entered_at >= progress.started_at);
761    let (kind, since) = match (progress.stage, stale) {
762        (_, Some(l)) => (StallKind::StoppedBeating, l.beat_at),
763        (Stage::Replaced, None) => (StallKind::NeverEntered, progress.updated_at),
764        _ => (StallKind::StoppedBeating, progress.updated_at),
765    };
766    let age_secs = (now.as_second() - since.as_second()).max(0);
767    let limit = if stale.is_some() {
768        LEASE_TTL_SECS
769    } else {
770        STALL_AFTER_SECS
771    };
772    (age_secs > limit).then(|| Stall {
773        stage: progress.stage,
774        kind,
775        age_secs,
776        waiting_on: waiting_on(progress, lease),
777    })
778}
779
780/// What the watchdog wants said this tick.
781#[derive(Debug, Clone, PartialEq, Eq)]
782pub struct Beat {
783    /// The stage this beat is about.
784    pub stage: Stage,
785    /// Whether to say it as a warning (stuck) rather than a heartbeat.
786    pub warn: bool,
787    /// The line to log and to persist as `detail`.
788    pub message: String,
789}
790
791/// Decides when the watchdog speaks. Pure: the caller supplies `now`.
792#[derive(Debug, Default)]
793pub struct Watchdog {
794    last: Option<(Stage, Timestamp)>,
795}
796
797impl Watchdog {
798    /// Look at the record at `now`. `None` means stay quiet: terminal, too
799    /// early, or already spoken within [`HEARTBEAT_SECS`] for this stage.
800    pub fn tick(
801        &mut self,
802        progress: &Progress,
803        lease: Option<&HandoverLease>,
804        now: Timestamp,
805    ) -> Option<Beat> {
806        if progress.stage.terminal() {
807            self.last = None;
808            return None;
809        }
810        if self.last.is_some_and(|(stage, _)| stage != progress.stage) {
811            self.last = None;
812        }
813        let stalled = stall(progress, lease, now);
814        let alive = live_lease(progress, lease, now);
815        // A live `hand_over` is allowed to wait long, but says what it waits
816        // on; the other stages are silent until they are stalled.
817        if stalled.is_none() && alive.is_none() && progress.stage != Stage::Parking {
818            return None;
819        }
820        if let Some((_, at)) = self.last
821            && now.as_second() - at.as_second() < HEARTBEAT_SECS
822        {
823            return None;
824        }
825        self.last = Some((progress.stage, now));
826        let age = alive.map_or_else(|| stage_age_secs(progress, now), |l| waited_secs(l, now));
827        let (warn, message) = match &stalled {
828            Some(s) => (
829                true,
830                format!(
831                    "stuck in {:?} for {} min {} s without progress, waiting on {}",
832                    s.stage,
833                    s.age_secs / 60,
834                    s.age_secs % 60,
835                    s.waiting_on
836                ),
837            ),
838            None => (
839                false,
840                format!(
841                    "parking for {} min {} s, waiting on {}",
842                    age / 60,
843                    age % 60,
844                    waiting_on(progress, lease)
845                ),
846            ),
847        };
848        Some(Beat {
849            stage: progress.stage,
850            warn,
851            message,
852        })
853    }
854}
855
856/// The watchdog's latest message, kept apart from `upgrade.json` so a
857/// diagnostic write can never race a stage transition.
858#[derive(Debug, Serialize, Deserialize)]
859struct Note {
860    stage: Stage,
861    stage_since: Timestamp,
862    message: String,
863}
864
865fn note_path(home: &Path) -> PathBuf {
866    home.join("upgrade.note.json")
867}
868
869fn write_note(home: &Path, progress: &Progress, message: &str) {
870    let note = Note {
871        stage: progress.stage,
872        stage_since: progress.updated_at,
873        message: message.to_owned(),
874    };
875    let path = note_path(home);
876    let tmp = path.with_extension("json.tmp");
877    let written = serde_json::to_string(&note)
878        .map_err(std::io::Error::other)
879        .and_then(|body| std::fs::write(&tmp, body))
880        .and_then(|()| std::fs::rename(&tmp, &path));
881    if let Err(e) = written {
882        tracing::warn!("could not write {}: {e}", path.display());
883    }
884}
885
886/// The watchdog's message for exactly this stage of this upgrade, if any;
887/// a note from another stage or another upgrade is ignored.
888#[must_use]
889pub fn read_note(home: &Path, progress: &Progress) -> Option<String> {
890    let body = std::fs::read_to_string(note_path(home)).ok()?;
891    let note: Note = serde_json::from_str(&body).ok()?;
892    (note.stage == progress.stage && note.stage_since == progress.updated_at)
893        .then_some(note.message)
894}
895
896/// Run the watchdog on a thread of its own, so a blocked runtime, or a
897/// process half-way through dropping one, still speaks. It never ends; it is
898/// a daemon thread and dies with the process.
899pub fn spawn_watchdog(home: PathBuf) {
900    let spawned = std::thread::Builder::new()
901        .name("upgrade-watchdog".to_owned())
902        .spawn(move || {
903            let mut dog = Watchdog::default();
904            loop {
905                std::thread::sleep(WATCHDOG_POLL);
906                let Some(progress) = read_progress(&home) else {
907                    continue;
908                };
909                let lease = read_lease(&home);
910                let Some(beat) = dog.tick(&progress, lease.as_ref(), Timestamp::now()) else {
911                    continue;
912                };
913                if beat.warn {
914                    log_warn(&home, &beat.message);
915                } else {
916                    log_step(&home, &beat.message);
917                }
918                // Never rewrites upgrade.json: a read-modify-write here could
919                // overwrite a stage the handover or the successor saved in
920                // between. The note goes to its own file, which only this
921                // thread writes, and is matched to the record when read.
922                write_note(&home, &progress, &beat.message);
923            }
924        });
925    if let Err(e) = spawned {
926        tracing::warn!("could not start the upgrade watchdog: {e}");
927    }
928}
929
930/// Reconcile a leftover progress record on startup, before the server starts
931/// answering requests.
932///
933/// A non-terminal record on disk when a process starts can only mean one of
934/// two things: this *is* the successor `spawn_successor` started, or the
935/// predecessor died before finishing the handover (a crash, a reboot, an
936/// operator killing it by hand). Either way waiting longer will not resolve
937/// it - this process is already up - so it is settled immediately:
938/// [`Stage::Done`] when the running version matches what was asked for,
939/// [`Stage::Failed`] otherwise, so the operator is told rather than left
940/// watching a stage that will never move again.
941pub fn reconcile_after_restart(home: &Path) {
942    let Some(mut progress) = read_progress(home) else {
943        return;
944    };
945    // Whoever held it is not this process.
946    let _ = std::fs::remove_file(lease_path(home));
947    if progress.stage.terminal() {
948        return;
949    }
950    let running = env!("CARGO_PKG_VERSION");
951    // `progress.to` is `latest.tag_name` from the forge, which - like every
952    // tag in this repository - carries a `v` prefix `CARGO_PKG_VERSION` does
953    // not. kaishin's own `is_update_available` strips it before comparing;
954    // an exact-string match here would call a successful upgrade `Failed`
955    // every time, because "v0.5.2" is never equal to "0.5.2".
956    if progress
957        .to
958        .as_deref()
959        .is_some_and(|to| to.trim_start_matches('v') == running)
960    {
961        progress.advance(Stage::Done);
962    } else {
963        let to = progress
964            .to
965            .clone()
966            .unwrap_or_else(|| "the expected release".to_owned());
967        progress.fail(format!(
968            "this process came up on {running}, not {to} - the upgrade may \
969             not have replaced the binary"
970        ));
971    }
972    write_progress_logged(home, &progress);
973}
974
975/// Spawn the background check for `cfg`, unless it is switched off.
976pub fn spawn(cfg: &Update, rt: &tokio::runtime::Handle) -> Option<Pending> {
977    if disabled_by_env() || pinned_worker_generation() || cfg.mode == UpdateMode::Off {
978        return None;
979    }
980    let checker = Checker::new(cfg)?;
981    match cfg.mode {
982        UpdateMode::Off => None,
983        UpdateMode::Notify => {
984            if !checker.should_check() {
985                let latest = checker.cached_update()?;
986                return Some(Pending::Cached { checker, latest });
987            }
988            let inner = checker.inner.clone();
989            let handle = rt.spawn(async move { inner.check_and_save().await });
990            Some(Pending::Notify { checker, handle })
991        }
992        UpdateMode::Install => {
993            let inner = checker.inner.clone();
994            let handle = rt.spawn(async move { inner.auto_update().await });
995            Some(Pending::Install { handle })
996        }
997    }
998}
999
1000/// Drain a pending check and print at most one line.
1001///
1002/// Bounds only this async wait. The caller must also bound runtime shutdown:
1003/// synchronous or blocking-pool work can outlive the timeout.
1004pub async fn finalize(pending: Option<Pending>, budget: Duration) {
1005    let Some(pending) = pending else {
1006        return;
1007    };
1008    match pending {
1009        Pending::Cached { checker, latest } => {
1010            eprintln!("{}", checker.format_banner(&latest));
1011        }
1012        Pending::Notify { checker, handle } => {
1013            if let Some(Ok(Some(latest))) = finalize_task(handle, budget).await {
1014                eprintln!("{}", checker.format_banner(&latest));
1015            }
1016        }
1017        Pending::Install { handle } => {
1018            if let Some(Ok(Some(latest))) = finalize_task(handle, budget).await {
1019                eprintln!("magi updated itself to {}", latest.tag_name);
1020            }
1021        }
1022    }
1023}
1024
1025/// The check can block the only runtime worker in synchronous authentication,
1026/// starving Tokio's timer driver. Deliver the deadline from an OS thread so
1027/// the thread polling `async_main` can reach bounded runtime shutdown anyway.
1028async fn finalize_task<T>(handle: tokio::task::JoinHandle<T>, budget: Duration) -> Option<T> {
1029    let (send, deadline) = tokio::sync::oneshot::channel();
1030    if let Err(error) = std::thread::Builder::new()
1031        .name("update-finalize-deadline".to_owned())
1032        .spawn(move || {
1033            std::thread::sleep(budget);
1034            let _ = send.send(());
1035        })
1036    {
1037        tracing::warn!("could not start release-check deadline: {error}");
1038        return None;
1039    }
1040    tokio::select! {
1041        result = handle => result.ok(),
1042        _ = deadline => None,
1043    }
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048    use super::*;
1049
1050    /// After the binary is replaced, Linux names the running one
1051    /// `<path> (deleted)`; spawning that fails, so the plain path must win
1052    /// when it exists - and a real path must never be rewritten.
1053    #[cfg(unix)]
1054    #[test]
1055    fn a_replaced_binarys_deleted_suffix_is_stripped_only_when_the_plain_path_exists() {
1056        let dir = tempfile::tempdir().unwrap();
1057        let plain = dir.path().join("magi");
1058        let decorated = dir.path().join("magi (deleted)");
1059
1060        assert_eq!(without_deleted_suffix(decorated.clone()), decorated);
1061        std::fs::write(&plain, b"new").unwrap();
1062        assert_eq!(without_deleted_suffix(decorated.clone()), plain);
1063        assert_eq!(without_deleted_suffix(plain.clone()), plain);
1064        std::fs::write(&decorated, b"odd").unwrap();
1065        assert_eq!(without_deleted_suffix(decorated.clone()), decorated);
1066    }
1067
1068    #[cfg(unix)]
1069    #[test]
1070    fn a_deleted_suffix_is_stripped_from_non_utf8_bytes_without_filesystem_access() {
1071        use std::os::unix::ffi::OsStringExt as _;
1072
1073        let raw = PathBuf::from(std::ffi::OsString::from_vec(b"m\xffgi".to_vec()));
1074        let mut decorated = raw.clone().into_os_string();
1075        decorated.push(" (deleted)");
1076        assert_eq!(plain_of_deleted(Path::new(&decorated)), Some(raw.clone()));
1077        assert_eq!(plain_of_deleted(&raw), None);
1078    }
1079
1080    // macOS filesystems reject invalid UTF-8 names with EILSEQ. Keep the
1081    // filesystem case on Linux; the byte-only test above also runs on macOS.
1082    #[cfg(target_os = "linux")]
1083    #[test]
1084    fn a_replaced_binarys_deleted_suffix_is_stripped_from_a_non_utf8_path() {
1085        use std::os::unix::ffi::OsStringExt as _;
1086
1087        let dir = tempfile::tempdir().unwrap();
1088        let raw = dir
1089            .path()
1090            .join(std::ffi::OsString::from_vec(b"m\xffgi".to_vec()));
1091        let raw_decorated = {
1092            let mut s = raw.clone().into_os_string();
1093            s.push(" (deleted)");
1094            PathBuf::from(s)
1095        };
1096        std::fs::write(&raw, b"new").unwrap();
1097        assert_eq!(without_deleted_suffix(raw_decorated), raw);
1098    }
1099
1100    /// An interval configured below GitHub's unauthenticated 60 req/hour/IP
1101    /// limit must be floored, or `web`'s background recheck loop - which,
1102    /// unlike a one-off CLI invocation, keeps polling for as long as `magi
1103    /// web` stays up - would repeat the same call far past that limit.
1104    #[test]
1105    fn effective_interval_floors_a_configured_interval_below_githubs_rate_limit() {
1106        let cfg = Update {
1107            mode: UpdateMode::Notify,
1108            interval: Some("1s".to_owned()),
1109            auto_restart: false,
1110        };
1111        assert_eq!(
1112            effective_interval(&cfg),
1113            MIN_INTERVAL,
1114            "an interval that would exceed GitHub's rate limit under continuous \
1115             polling must be floored rather than honoured verbatim"
1116        );
1117
1118        let sane = Update {
1119            mode: UpdateMode::Notify,
1120            interval: Some("2h".to_owned()),
1121            auto_restart: false,
1122        };
1123        assert_eq!(
1124            effective_interval(&sane),
1125            Duration::from_secs(2 * 60 * 60),
1126            "an interval already above the floor must pass through unchanged"
1127        );
1128    }
1129
1130    #[test]
1131    fn env_kill_switch_semantics() {
1132        // SAFETY: single-threaded test, no other thread reads the variable.
1133        unsafe {
1134            std::env::remove_var(NO_AUTOUPDATE_ENV);
1135        }
1136        assert!(!disabled_by_env());
1137        for (value, disabled) in [
1138            ("1", true),
1139            ("true", true),
1140            ("yes", true),
1141            ("0", false),
1142            ("false", false),
1143            ("FALSE", false),
1144            ("", false),
1145            ("  ", false),
1146        ] {
1147            unsafe {
1148                std::env::set_var(NO_AUTOUPDATE_ENV, value);
1149            }
1150            assert_eq!(
1151                disabled_by_env(),
1152                disabled,
1153                "MAGI_NO_AUTOUPDATE={value:?} should {} disable",
1154                if disabled { "" } else { "not" }
1155            );
1156        }
1157        unsafe {
1158            std::env::remove_var(NO_AUTOUPDATE_ENV);
1159        }
1160    }
1161
1162    #[test]
1163    fn off_mode_never_spawns() {
1164        let rt = tokio::runtime::Builder::new_current_thread()
1165            .enable_all()
1166            .build()
1167            .unwrap();
1168        let cfg = Update {
1169            mode: UpdateMode::Off,
1170            interval: None,
1171            auto_restart: false,
1172        };
1173        assert!(spawn(&cfg, rt.handle()).is_none());
1174    }
1175
1176    #[test]
1177    fn state_path_lives_under_the_cache_dir() {
1178        let path = state_path().expect("a cache dir on every supported platform");
1179        assert!(path.ends_with("magi/last_update_check.json"));
1180        let data = dirs::data_local_dir().unwrap_or_default();
1181        assert!(
1182            !path.starts_with(&data) || dirs::cache_dir() == dirs::data_local_dir(),
1183            "throttle state must not sit in the run history directory"
1184        );
1185    }
1186
1187    #[tokio::test]
1188    async fn finalize_of_nothing_is_a_no_op() {
1189        finalize(None, Duration::from_millis(1)).await;
1190    }
1191
1192    #[tokio::test]
1193    async fn finalize_task_preserves_a_timely_result() {
1194        let handle = tokio::spawn(async { 42 });
1195        assert_eq!(
1196            finalize_task(handle, Duration::from_secs(1)).await,
1197            Some(42)
1198        );
1199    }
1200
1201    /// `mode = "off"` means no checker, for every caller.
1202    ///
1203    /// It used to mean it only for the notify path: `Checker::new` returned
1204    /// `Some` unconditionally, so `POST /api/upgrade` went to the GitHub
1205    /// releases API on a deck configured never to check. A test that reached
1206    /// that route made a live, unauthenticated request, and GitHub's
1207    /// 60-per-hour-per-address limit then turned the suite red on one runner
1208    /// at a time - for as long as anyone kept re-running it, since each
1209    /// attempt spent another request.
1210    #[test]
1211    fn checking_is_off_for_every_caller_when_the_config_says_off() {
1212        if pinned_worker_generation() {
1213            for mode in [UpdateMode::Notify, UpdateMode::Install] {
1214                assert!(
1215                    Checker::new(&Update {
1216                        mode,
1217                        interval: None,
1218                        auto_restart: false
1219                    })
1220                    .is_none(),
1221                    "pinned worker descendants must not check for updates"
1222                );
1223            }
1224        }
1225        if !crate::standalone_test(
1226            "updater::tests::checking_is_off_for_every_caller_when_the_config_says_off",
1227        ) {
1228            return;
1229        }
1230        assert!(
1231            Checker::new(&Update {
1232                mode: UpdateMode::Off,
1233                interval: None,
1234                auto_restart: false,
1235            })
1236            .is_none(),
1237            "an operator who writes mode = \"off\" means it"
1238        );
1239        for mode in [UpdateMode::Notify, UpdateMode::Install] {
1240            assert!(
1241                Checker::new(&Update {
1242                    mode,
1243                    interval: None,
1244                    auto_restart: false,
1245                })
1246                .is_some(),
1247                "{mode:?} still asks the forge"
1248            );
1249        }
1250    }
1251
1252    /// `cached_update` never touches the network: a state file written the
1253    /// way `check_and_save` writes one is enough to answer, and no file at
1254    /// all answers "unknown" rather than blocking or erroring.
1255    ///
1256    /// Built from `kaishin::Checker` directly, with an explicit state path,
1257    /// rather than through [`Checker::new`]: that constructor always points
1258    /// at the real cache directory, which is right for production - every
1259    /// `magi` invocation on the machine shares one throttle file - but wrong
1260    /// for a test, which must never read or write the operator's actual
1261    /// state.
1262    #[test]
1263    fn cached_update_answers_from_disk_with_no_network_call() {
1264        let dir = tempfile::tempdir().expect("temp dir");
1265        let path = dir.path().join("state.json");
1266        let opts = kaishin::KaishinOptions::new("yukimemi", "magi", "magi", "0.1.0");
1267        let checker = Checker {
1268            inner: kaishin::Checker::new("magi", opts).state_path(path.clone()),
1269        };
1270
1271        assert!(
1272            checker.cached_update().is_none(),
1273            "no state file yet must read as \"unknown\", not an error"
1274        );
1275
1276        let state = kaishin::UpdateCheckState {
1277            last_checked_unix: 0,
1278            last_known_latest: Some("v9.9.9".to_owned()),
1279            last_known_url: Some("https://example.invalid/9.9.9".to_owned()),
1280        };
1281        kaishin::save_check_state(&path, &state).expect("seed the state file");
1282
1283        let latest = checker.cached_update().expect("a newer release was cached");
1284        assert_eq!(latest.tag_name, "v9.9.9");
1285    }
1286
1287    #[test]
1288    fn reconcile_after_restart_confirms_a_matching_version() {
1289        // `to` is `latest.tag_name` as the forge and this repository's own
1290        // tags spell it - with a `v` - which `CARGO_PKG_VERSION` never
1291        // carries. A test that leaves the `v` off would not have caught the
1292        // exact-string-equality bug this function used to have.
1293        let home = tempfile::tempdir().expect("temp home");
1294        let mut progress = Progress::new(
1295            "0.1.0".to_owned(),
1296            format!("v{}", env!("CARGO_PKG_VERSION")),
1297        );
1298        progress.advance(Stage::Restarting);
1299        write_progress(home.path(), &progress).expect("seed progress");
1300
1301        reconcile_after_restart(home.path());
1302
1303        let after = read_progress(home.path()).expect("progress on disk");
1304        assert_eq!(
1305            after.stage,
1306            Stage::Done,
1307            "the successor is running exactly the release that was asked for, \
1308             `v` prefix and all"
1309        );
1310    }
1311
1312    #[test]
1313    fn reconcile_after_restart_flags_a_mismatched_version() {
1314        let home = tempfile::tempdir().expect("temp home");
1315        let mut progress = Progress::new("0.1.0".to_owned(), "v9.9.9".to_owned());
1316        progress.advance(Stage::Restarting);
1317        write_progress(home.path(), &progress).expect("seed progress");
1318
1319        reconcile_after_restart(home.path());
1320
1321        let after = read_progress(home.path()).expect("progress on disk");
1322        assert_eq!(after.stage, Stage::Failed);
1323        assert!(
1324            after.detail.is_some_and(|d| d.contains("9.9.9")),
1325            "the operator needs to know which release it did not come back on"
1326        );
1327    }
1328
1329    #[test]
1330    fn reconcile_after_restart_leaves_a_settled_record_alone() {
1331        let home = tempfile::tempdir().expect("temp home");
1332        let mut progress = Progress::new("0.1.0".to_owned(), "9.9.9".to_owned());
1333        progress.advance(Stage::Done);
1334        write_progress(home.path(), &progress).expect("seed progress");
1335
1336        reconcile_after_restart(home.path());
1337
1338        let after = read_progress(home.path()).expect("progress on disk");
1339        assert_eq!(
1340            after.stage,
1341            Stage::Done,
1342            "an already-settled record must not be rewritten by a later, unrelated start"
1343        );
1344    }
1345
1346    #[test]
1347    fn reconcile_after_restart_with_nothing_on_disk_is_a_quiet_no_op() {
1348        let home = tempfile::tempdir().expect("temp home");
1349        reconcile_after_restart(home.path());
1350        assert!(read_progress(home.path()).is_none());
1351    }
1352
1353    fn at(secs: i64) -> Timestamp {
1354        Timestamp::from_second(secs).expect("timestamp")
1355    }
1356
1357    #[test]
1358    fn auto_upgrade_progress_defaults_old_records_to_manual() {
1359        let progress = Progress::new("old".into(), "new".into());
1360        let mut value = serde_json::to_value(progress).unwrap();
1361        value.as_object_mut().unwrap().remove("trigger");
1362        let old: Progress = serde_json::from_value(value).unwrap();
1363        assert_eq!(old.trigger, UpgradeTrigger::Manual);
1364    }
1365
1366    fn staged(stage: Stage, since: i64) -> Progress {
1367        let mut p = Progress::new("0.1.0".to_owned(), "v0.2.0".to_owned());
1368        p.stage = stage;
1369        p.started_at = at(since);
1370        p.updated_at = at(since);
1371        p
1372    }
1373
1374    fn lease(entered: i64, beat: i64) -> HandoverLease {
1375        HandoverLease {
1376            entered_at: at(entered),
1377            beat_at: at(beat),
1378            parked_run: Some("r1".to_owned()),
1379        }
1380    }
1381
1382    #[test]
1383    fn a_handover_never_entered_is_stuck_and_says_only_what_is_known() {
1384        let p = staged(Stage::Replaced, 1000);
1385        assert!(stall(&p, None, at(1000 + STALL_AFTER_SECS)).is_none());
1386        let s = stall(&p, None, at(1000 + STALL_AFTER_SECS + 1)).expect("stalled");
1387        assert_eq!(s.stage, Stage::Replaced);
1388        assert_eq!(s.kind, StallKind::NeverEntered);
1389        assert_eq!(s.age_secs, STALL_AFTER_SECS + 1);
1390        assert!(s.waiting_on.contains("hand_over"), "{}", s.waiting_on);
1391
1392        let p = staged(Stage::Restarting, 1000);
1393        let s = stall(&p, None, at(1000 + STALL_AFTER_SECS + 1)).expect("stalled");
1394        assert_eq!(s.kind, StallKind::StoppedBeating);
1395
1396        for stage in [Stage::Done, Stage::Failed, Stage::Downloading] {
1397            assert!(stall(&staged(stage, 0), None, at(1_000_000)).is_none());
1398        }
1399    }
1400
1401    #[test]
1402    fn a_live_parking_wait_is_never_stuck_however_long_it_lasts() {
1403        let mut p = staged(Stage::Parking, 1000);
1404        p.started_at = at(900);
1405        let hours = 5 * 3600;
1406        let l = lease(1000, 1000 + hours);
1407        assert!(stall(&p, Some(&l), at(1000 + hours + 10)).is_none());
1408        // Even a record that regressed to `replaced` is read through the lease.
1409        let r = staged(Stage::Replaced, 1000);
1410        assert!(stall(&r, Some(&l), at(1000 + hours + 10)).is_none());
1411        // Once the beat stops, it is stuck, and says so.
1412        let s = stall(&p, Some(&l), at(1000 + hours + LEASE_TTL_SECS + 1)).expect("stuck");
1413        assert_eq!(s.kind, StallKind::StoppedBeating);
1414    }
1415
1416    #[test]
1417    fn a_lease_from_an_earlier_upgrade_proves_nothing() {
1418        let p = staged(Stage::Replaced, 2000);
1419        let old = lease(10, 3000);
1420        assert!(live_lease(&p, Some(&old), at(3001)).is_none());
1421    }
1422
1423    #[test]
1424    fn a_stage_never_goes_backwards_but_a_new_upgrade_after_a_terminal_one_starts() {
1425        let parking = staged(Stage::Parking, 1000);
1426        for back in [Stage::Replaced, Stage::Downloading, Stage::Parking] {
1427            let mut cand = staged(back, 5000);
1428            cand.to = Some("v9.9.9".to_owned());
1429            let kept = monotonic(Some(&parking), &cand);
1430            assert_eq!(kept.stage, Stage::Parking);
1431            assert_eq!(kept.updated_at, at(1000));
1432            assert_eq!(kept.started_at, parking.started_at);
1433            assert_eq!(kept.to.as_deref(), Some("v9.9.9"), "data is refreshed");
1434        }
1435        assert_eq!(
1436            monotonic(Some(&parking), &staged(Stage::Restarting, 5000)).stage,
1437            Stage::Restarting
1438        );
1439        assert_eq!(
1440            monotonic(Some(&parking), &staged(Stage::Failed, 5000)).stage,
1441            Stage::Failed
1442        );
1443        let done = staged(Stage::Done, 1000);
1444        assert_eq!(
1445            monotonic(Some(&done), &staged(Stage::Downloading, 5000)).stage,
1446            Stage::Downloading
1447        );
1448    }
1449
1450    #[test]
1451    fn write_progress_refuses_a_regression_on_disk() {
1452        let home = tempfile::tempdir().expect("temp home");
1453        write_progress(home.path(), &staged(Stage::Parking, 1000)).expect("write");
1454        write_progress(home.path(), &staged(Stage::Replaced, 5000)).expect("write");
1455        let on_disk = read_progress(home.path()).expect("record");
1456        assert_eq!(on_disk.stage, Stage::Parking);
1457        assert_eq!(on_disk.updated_at, at(1000));
1458    }
1459
1460    #[test]
1461    fn a_failed_request_cannot_overwrite_a_live_handover() {
1462        let home = tempfile::tempdir().expect("temp home");
1463        write_progress(home.path(), &staged(Stage::Parking, 1000)).expect("write");
1464        assert!(!fail_progress(home.path(), "boom").expect("fail"));
1465        assert_eq!(read_progress(home.path()).unwrap().stage, Stage::Parking);
1466        write_progress(home.path(), &staged(Stage::Replaced, 1000)).ok();
1467        let fresh = tempfile::tempdir().expect("temp home");
1468        write_progress(fresh.path(), &staged(Stage::Downloading, 1000)).expect("write");
1469        assert!(fail_progress(fresh.path(), "boom").expect("fail"));
1470        assert_eq!(read_progress(fresh.path()).unwrap().stage, Stage::Failed);
1471    }
1472
1473    #[test]
1474    fn a_failed_detached_restart_reports_the_failure() {
1475        let home = tempfile::tempdir().expect("temp home");
1476        write_progress(home.path(), &staged(Stage::Restarting, 1000)).expect("write");
1477        assert!(fail_progress(home.path(), "successor spawn failed").expect("fail"));
1478        let progress = read_progress(home.path()).expect("progress");
1479        assert_eq!(progress.stage, Stage::Failed);
1480        assert_eq!(progress.detail.as_deref(), Some("successor spawn failed"));
1481    }
1482
1483    #[test]
1484    fn the_lease_guard_writes_beats_and_removes_the_lease() {
1485        let home = tempfile::tempdir().expect("temp home");
1486        {
1487            let mut guard = LeaseGuard::enter(home.path(), Some("r1".to_owned()));
1488            let first = read_lease(home.path()).expect("lease");
1489            assert_eq!(first.parked_run.as_deref(), Some("r1"));
1490            guard.beat();
1491            assert!(read_lease(home.path()).is_some());
1492        }
1493        assert!(read_lease(home.path()).is_none());
1494    }
1495
1496    #[test]
1497    fn a_clock_that_went_backwards_is_age_zero() {
1498        let p = staged(Stage::Replaced, 5000);
1499        assert_eq!(stage_age_secs(&p, at(100)), 0);
1500        assert!(stall(&p, None, at(100)).is_none());
1501    }
1502
1503    #[test]
1504    fn a_note_is_kept_beside_the_record_and_matches_only_its_own_stage() {
1505        let home = tempfile::tempdir().expect("temp home");
1506        let p = staged(Stage::Replaced, 1000);
1507        write_progress(home.path(), &p).expect("write");
1508        write_note(home.path(), &p, "stuck");
1509        assert_eq!(read_note(home.path(), &p).as_deref(), Some("stuck"));
1510        assert_eq!(read_progress(home.path()).unwrap().updated_at, at(1000));
1511        assert!(read_note(home.path(), &staged(Stage::Parking, 1000)).is_none());
1512        assert!(read_note(home.path(), &staged(Stage::Replaced, 2000)).is_none());
1513    }
1514
1515    #[test]
1516    fn the_watchdog_speaks_once_a_minute_and_resets_on_a_new_stage() {
1517        let mut dog = Watchdog::default();
1518        let p = staged(Stage::Replaced, 1000);
1519        assert!(dog.tick(&p, None, at(1060)).is_none(), "not stalled yet");
1520        let beat = dog.tick(&p, None, at(1200)).expect("stalled");
1521        assert!(beat.warn);
1522        assert!(dog.tick(&p, None, at(1230)).is_none(), "spoke 30 s ago");
1523        assert!(dog.tick(&p, None, at(1260)).is_some(), "a minute later");
1524
1525        let parking = staged(Stage::Parking, 1260);
1526        let l = lease(1260, 1270);
1527        let beat = dog
1528            .tick(&parking, Some(&l), at(1275))
1529            .expect("parking heartbeat at once");
1530        assert!(!beat.warn, "a live wait is not a warning");
1531        assert!(dog.tick(&parking, Some(&l), at(1300)).is_none());
1532        let l = lease(1260, 1260 + 4 * 3600);
1533        let later = dog
1534            .tick(&parking, Some(&l), at(1260 + 4 * 3600 + 5))
1535            .expect("heartbeat");
1536        assert!(!later.warn, "hours of waiting on a run is still not stuck");
1537        assert!(later.message.contains("r1"), "{}", later.message);
1538
1539        let done = staged(Stage::Done, 0);
1540        assert!(dog.tick(&done, None, at(9_999_999)).is_none());
1541    }
1542
1543    #[test]
1544    fn the_upgrade_log_appends_and_rotates_to_one_generation() {
1545        let dir = tempfile::tempdir().expect("temp dir");
1546        let path = dir.path().join("upgrade.log");
1547        append_bounded(&path, "one", 16).expect("append");
1548        append_bounded(&path, "two", 16).expect("append");
1549        assert_eq!(std::fs::read_to_string(&path).unwrap(), "one\ntwo\n");
1550        append_bounded(&path, "three-and-more", 16).expect("append");
1551        append_bounded(&path, "four", 16).expect("append");
1552        assert_eq!(std::fs::read_to_string(&path).unwrap(), "four\n");
1553        let old = dir.path().join("upgrade.log.1");
1554        assert!(
1555            std::fs::read_to_string(old)
1556                .unwrap()
1557                .contains("three-and-more")
1558        );
1559    }
1560}