1use 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
16pub const NO_AUTOUPDATE_ENV: &str = "MAGI_NO_AUTOUPDATE";
20
21pub fn default_interval() -> Duration {
23 kaishin::default_interval()
24}
25
26const MIN_INTERVAL: Duration = Duration::from_secs(60);
37
38pub 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
53pub 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
64const OWNER: &str = "yukimemi";
66const REPO: &str = "magi";
68const BIN: &str = "magi";
70const CRATE: &str = "magi-cli";
72
73pub fn repo_name() -> &'static str {
77 REPO
78}
79
80fn options() -> kaishin::KaishinOptions {
88 kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION")).crate_name(CRATE)
89}
90
91fn state_path() -> Option<PathBuf> {
94 dirs::cache_dir().map(|d| d.join("magi").join("last_update_check.json"))
95}
96
97pub async fn run_self_update(yes: bool, check_only: bool, non_interactive: bool) -> Result<()> {
99 let opts = kaishin::UpdateOptions::new()
100 .yes(yes)
101 .check_only(check_only)
102 .non_interactive(non_interactive);
103 kaishin::run_self_update(&options(), opts).await
104}
105
106pub enum Pending {
108 Cached {
110 checker: Checker,
112 latest: kaishin::LatestRelease,
114 },
115 Notify {
117 checker: Checker,
119 handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
121 },
122 Install {
124 handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
126 },
127}
128
129#[derive(Clone)]
131pub struct Checker {
132 inner: kaishin::Checker,
133}
134
135impl Checker {
136 pub fn new(cfg: &Update) -> Option<Self> {
150 if cfg.mode == UpdateMode::Off {
151 return None;
152 }
153 let mut inner = kaishin::Checker::new(BIN, options());
154 if let Some(path) = state_path() {
155 inner = inner.state_path(path);
156 }
157 Some(Self {
158 inner: inner.interval(effective_interval(cfg)),
159 })
160 }
161
162 pub fn should_check(&self) -> bool {
164 self.inner.should_check()
165 }
166
167 pub async fn newer_release(&self) -> Result<Option<kaishin::LatestRelease>> {
179 self.inner.check_and_save().await
180 }
181
182 pub fn cached_update(&self) -> Option<kaishin::LatestRelease> {
184 self.inner.cached_update()
185 }
186
187 pub fn format_banner(&self, latest: &kaishin::LatestRelease) -> String {
189 self.inner.format_banner(latest)
190 }
191
192 #[cfg(test)]
197 pub(crate) fn for_test(interval: Duration, state_path: PathBuf) -> Self {
198 let opts = kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION"));
199 Self {
200 inner: kaishin::Checker::new(BIN, opts)
201 .state_path(state_path)
202 .interval(interval),
203 }
204 }
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(rename_all = "snake_case")]
214pub enum Stage {
215 Downloading,
223 Replaced,
226 Parking,
230 Restarting,
234 Done,
237 Failed,
240}
241
242impl Stage {
243 #[must_use]
246 pub fn rank(self) -> u8 {
247 match self {
248 Self::Downloading => 0,
249 Self::Replaced => 1,
250 Self::Parking => 2,
251 Self::Restarting => 3,
252 Self::Done | Self::Failed => 4,
253 }
254 }
255
256 #[must_use]
258 pub fn terminal(self) -> bool {
259 matches!(self, Self::Done | Self::Failed)
260 }
261
262 #[must_use]
264 pub fn as_str(self) -> &'static str {
265 match self {
266 Self::Downloading => "downloading",
267 Self::Replaced => "replaced",
268 Self::Parking => "parking",
269 Self::Restarting => "restarting",
270 Self::Done => "done",
271 Self::Failed => "failed",
272 }
273 }
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct Progress {
287 pub stage: Stage,
289 pub from: String,
291 pub to: Option<String>,
293 #[serde(default)]
295 pub parked_run: Option<String>,
296 #[serde(default)]
298 pub parked_talks: Vec<String>,
299 pub started_at: Timestamp,
301 pub updated_at: Timestamp,
303 #[serde(default)]
305 pub detail: Option<String>,
306}
307
308impl Progress {
309 #[must_use]
311 pub fn new(from: String, to: String) -> Self {
312 let now = Timestamp::now();
313 Self {
314 stage: Stage::Downloading,
315 from,
316 to: Some(to),
317 parked_run: None,
318 parked_talks: Vec::new(),
319 started_at: now,
320 updated_at: now,
321 detail: None,
322 }
323 }
324
325 pub fn advance(&mut self, stage: Stage) {
327 self.stage = stage;
328 self.updated_at = Timestamp::now();
329 self.detail = None;
331 self.parked_talks.clear();
332 }
333
334 pub fn fail(&mut self, detail: impl Into<String>) {
336 self.stage = Stage::Failed;
337 self.updated_at = Timestamp::now();
338 self.detail = Some(detail.into());
339 }
340}
341
342#[must_use]
345pub fn progress_path(home: &Path) -> PathBuf {
346 home.join("upgrade.json")
347}
348
349static WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
352
353#[must_use]
362pub fn monotonic(current: Option<&Progress>, candidate: &Progress) -> Progress {
363 match current {
364 Some(cur)
365 if !cur.stage.terminal()
366 && !candidate.stage.terminal()
367 && candidate.stage.rank() <= cur.stage.rank() =>
368 {
369 let mut kept = cur.clone();
370 if candidate.to.is_some() {
371 kept.to.clone_from(&candidate.to);
372 }
373 kept
374 }
375 _ => candidate.clone(),
376 }
377}
378
379pub fn write_progress(home: &Path, progress: &Progress) -> Result<()> {
386 let _guard = WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
387 write_progress_locked(home, progress)
388}
389
390fn write_progress_locked(home: &Path, progress: &Progress) -> Result<()> {
391 let path = progress_path(home);
392 if let Some(parent) = path.parent() {
393 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
394 }
395 let to_write = monotonic(read_progress(home).as_ref(), progress);
396 store_progress(home, &to_write)
397}
398
399fn store_progress(home: &Path, progress: &Progress) -> Result<()> {
402 let path = progress_path(home);
403 let body = serde_json::to_string_pretty(progress).context("serialize upgrade progress")?;
404 let tmp = path.with_extension("json.tmp");
405 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
406 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
407 Ok(())
408}
409
410pub fn set_parked_talks(home: &Path, talks: &[String]) {
414 let _guard = WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
415 let Some(mut progress) = read_progress(home) else {
416 return;
417 };
418 if progress.stage != Stage::Parking || progress.parked_talks == talks {
419 return;
420 }
421 progress.parked_talks = talks.to_vec();
422 if let Err(e) = store_progress(home, &progress) {
424 log_warn(home, &format!("could not write upgrade.json: {e:#}"));
425 }
426}
427
428#[must_use]
430pub fn talks_phrase(talks: &[String]) -> String {
431 match talks {
432 [] => String::new(),
433 [one] => format!("chat turn {one} is"),
434 many => format!("chat turns {} are", many.join(", ")),
435 }
436}
437
438pub fn fail_progress(home: &Path, detail: &str) -> Result<bool> {
444 let _guard = WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
445 let Some(mut progress) = read_progress(home) else {
446 return Ok(false);
447 };
448 if matches!(progress.stage, Stage::Parking | Stage::Restarting) {
449 return Ok(false);
450 }
451 progress.fail(detail);
452 write_progress_locked(home, &progress)?;
453 Ok(true)
454}
455
456#[must_use]
458pub fn read_progress(home: &Path) -> Option<Progress> {
459 let body = std::fs::read_to_string(progress_path(home)).ok()?;
460 serde_json::from_str(&body).ok()
461}
462
463#[must_use]
466pub fn log_path(home: &Path) -> PathBuf {
467 home.join("upgrade.log")
468}
469
470pub const LOG_MAX_BYTES: u64 = 256 * 1024;
472
473pub const STALL_AFTER_SECS: i64 = 120;
475
476pub const LEASE_TTL_SECS: i64 = 90;
479
480pub const HEARTBEAT_SECS: i64 = 60;
482
483pub const WATCHDOG_POLL: Duration = Duration::from_secs(30);
485
486pub fn append_bounded(path: &Path, line: &str, max: u64) -> std::io::Result<()> {
490 use std::io::Write as _;
491 if std::fs::metadata(path).is_ok_and(|m| m.len() >= max) {
492 let mut old = path.as_os_str().to_owned();
493 old.push(".1");
494 std::fs::rename(path, PathBuf::from(old))?;
495 }
496 if let Some(parent) = path.parent() {
497 std::fs::create_dir_all(parent)?;
498 }
499 let mut file = std::fs::OpenOptions::new()
500 .create(true)
501 .append(true)
502 .open(path)?;
503 writeln!(file, "{line}")
504}
505
506pub fn log_step(home: &Path, msg: &str) {
510 tracing::info!("handover: {msg}");
511 log_line(home, "INFO", msg);
512}
513
514pub fn log_warn(home: &Path, msg: &str) {
516 tracing::warn!("handover: {msg}");
517 log_line(home, "WARN", msg);
518}
519
520fn log_line(home: &Path, level: &str, msg: &str) {
521 let line = format!(
522 "{} pid={} {level} {msg}",
523 Timestamp::now(),
524 std::process::id()
525 );
526 if let Err(e) = append_bounded(&log_path(home), &line, LOG_MAX_BYTES) {
527 tracing::warn!("could not append to {}: {e}", log_path(home).display());
528 }
529}
530
531pub fn write_progress_logged(home: &Path, progress: &Progress) {
535 if let Err(e) = write_progress(home, progress) {
536 log_warn(home, &format!("could not write upgrade.json: {e:#}"));
537 }
538}
539
540#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct HandoverLease {
544 pub entered_at: Timestamp,
546 pub beat_at: Timestamp,
548 #[serde(default)]
550 pub parked_run: Option<String>,
551}
552
553impl HandoverLease {
554 #[must_use]
556 pub fn fresh(&self, now: Timestamp) -> bool {
557 now.as_second() - self.beat_at.as_second() <= LEASE_TTL_SECS
558 }
559}
560
561#[must_use]
563pub fn lease_path(home: &Path) -> PathBuf {
564 home.join("upgrade.handover.json")
565}
566
567#[must_use]
569pub fn read_lease(home: &Path) -> Option<HandoverLease> {
570 let body = std::fs::read_to_string(lease_path(home)).ok()?;
571 serde_json::from_str(&body).ok()
572}
573
574fn write_lease(home: &Path, lease: &HandoverLease) {
575 let path = lease_path(home);
576 let tmp = path.with_extension("json.tmp");
577 let written = serde_json::to_string(lease)
578 .map_err(std::io::Error::other)
579 .and_then(|body| std::fs::write(&tmp, body))
580 .and_then(|()| std::fs::rename(&tmp, &path));
581 if let Err(e) = written {
582 log_warn(home, &format!("could not write the handover lease: {e}"));
583 }
584}
585
586#[derive(Debug)]
588pub struct LeaseGuard {
589 home: PathBuf,
590 lease: HandoverLease,
591}
592
593impl LeaseGuard {
594 #[must_use]
596 pub fn enter(home: &Path, parked_run: Option<String>) -> Self {
597 let now = Timestamp::now();
598 let lease = HandoverLease {
599 entered_at: now,
600 beat_at: now,
601 parked_run,
602 };
603 write_lease(home, &lease);
604 Self {
605 home: home.to_owned(),
606 lease,
607 }
608 }
609
610 #[must_use]
616 pub fn enter_parking(home: &Path) -> (Self, bool) {
617 let _guard = WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
618 let progress = read_progress(home);
619 let this = Self::enter(home, progress.as_ref().and_then(|p| p.parked_run.clone()));
620 let recorded = match progress {
621 Some(mut p) => {
622 p.advance(Stage::Parking);
623 if let Err(e) = write_progress_locked(home, &p) {
624 log_warn(home, &format!("could not write upgrade.json: {e:#}"));
625 }
626 true
627 }
628 None => false,
629 };
630 (this, recorded)
631 }
632
633 pub fn beat(&mut self) {
635 self.lease.beat_at = Timestamp::now();
636 write_lease(&self.home, &self.lease);
637 }
638}
639
640impl Drop for LeaseGuard {
641 fn drop(&mut self) {
642 let _ = std::fs::remove_file(lease_path(&self.home));
643 }
644}
645
646#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
648#[serde(rename_all = "snake_case")]
649pub enum StallKind {
650 NeverEntered,
652 StoppedBeating,
655}
656
657#[derive(Debug, Clone, PartialEq, Eq)]
659pub struct Stall {
660 pub stage: Stage,
662 pub kind: StallKind,
664 pub age_secs: i64,
666 pub waiting_on: String,
668}
669
670#[must_use]
673pub fn stage_age_secs(progress: &Progress, now: Timestamp) -> i64 {
674 (now.as_second() - progress.updated_at.as_second()).max(0)
675}
676
677#[must_use]
679pub fn live_lease<'a>(
680 progress: &Progress,
681 lease: Option<&'a HandoverLease>,
682 now: Timestamp,
683) -> Option<&'a HandoverLease> {
684 lease.filter(|l| {
685 !progress.stage.terminal() && l.fresh(now) && l.entered_at >= progress.started_at
686 })
687}
688
689fn waiting_on(progress: &Progress, lease: Option<&HandoverLease>) -> String {
691 match progress.stage {
692 Stage::Replaced => "hand_over to start (HANDOVER was signalled; hand_over has left no \
693 record that it was entered)"
694 .to_owned(),
695 Stage::Parking => {
696 let run = lease
697 .and_then(|l| l.parked_run.as_ref())
698 .or(progress.parked_run.as_ref());
699 let talks = talks_phrase(&progress.parked_talks);
700 let talks = if talks.is_empty() {
701 String::new()
702 } else {
703 format!("{talks} still finishing; ")
704 };
705 match run {
706 Some(run) => {
707 format!("{talks}the loop to finish run {run} at its next node boundary")
708 }
709 None => format!("{talks}the loop to stop (no run was recorded as in flight)"),
710 }
711 }
712 Stage::Restarting => "spawn_successor returning and this process exiting".to_owned(),
713 Stage::Downloading => "the release download and binary replacement".to_owned(),
714 Stage::Done | Stage::Failed => String::new(),
715 }
716}
717
718#[must_use]
720pub fn waited_secs(lease: &HandoverLease, now: Timestamp) -> i64 {
721 (now.as_second() - lease.entered_at.as_second()).max(0)
722}
723
724#[must_use]
729pub fn stall(progress: &Progress, lease: Option<&HandoverLease>, now: Timestamp) -> Option<Stall> {
730 if progress.stage.terminal() || progress.stage == Stage::Downloading {
731 return None;
732 }
733 if live_lease(progress, lease, now).is_some() {
734 return None;
735 }
736 let stale = lease.filter(|l| l.entered_at >= progress.started_at);
738 let (kind, since) = match (progress.stage, stale) {
739 (_, Some(l)) => (StallKind::StoppedBeating, l.beat_at),
740 (Stage::Replaced, None) => (StallKind::NeverEntered, progress.updated_at),
741 _ => (StallKind::StoppedBeating, progress.updated_at),
742 };
743 let age_secs = (now.as_second() - since.as_second()).max(0);
744 let limit = if stale.is_some() {
745 LEASE_TTL_SECS
746 } else {
747 STALL_AFTER_SECS
748 };
749 (age_secs > limit).then(|| Stall {
750 stage: progress.stage,
751 kind,
752 age_secs,
753 waiting_on: waiting_on(progress, lease),
754 })
755}
756
757#[derive(Debug, Clone, PartialEq, Eq)]
759pub struct Beat {
760 pub stage: Stage,
762 pub warn: bool,
764 pub message: String,
766}
767
768#[derive(Debug, Default)]
770pub struct Watchdog {
771 last: Option<(Stage, Timestamp)>,
772}
773
774impl Watchdog {
775 pub fn tick(
778 &mut self,
779 progress: &Progress,
780 lease: Option<&HandoverLease>,
781 now: Timestamp,
782 ) -> Option<Beat> {
783 if progress.stage.terminal() {
784 self.last = None;
785 return None;
786 }
787 if self.last.is_some_and(|(stage, _)| stage != progress.stage) {
788 self.last = None;
789 }
790 let stalled = stall(progress, lease, now);
791 let alive = live_lease(progress, lease, now);
792 if stalled.is_none() && alive.is_none() && progress.stage != Stage::Parking {
795 return None;
796 }
797 if let Some((_, at)) = self.last
798 && now.as_second() - at.as_second() < HEARTBEAT_SECS
799 {
800 return None;
801 }
802 self.last = Some((progress.stage, now));
803 let age = alive.map_or_else(|| stage_age_secs(progress, now), |l| waited_secs(l, now));
804 let (warn, message) = match &stalled {
805 Some(s) => (
806 true,
807 format!(
808 "stuck in {:?} for {} min {} s without progress, waiting on {}",
809 s.stage,
810 s.age_secs / 60,
811 s.age_secs % 60,
812 s.waiting_on
813 ),
814 ),
815 None => (
816 false,
817 format!(
818 "parking for {} min {} s, waiting on {}",
819 age / 60,
820 age % 60,
821 waiting_on(progress, lease)
822 ),
823 ),
824 };
825 Some(Beat {
826 stage: progress.stage,
827 warn,
828 message,
829 })
830 }
831}
832
833#[derive(Debug, Serialize, Deserialize)]
836struct Note {
837 stage: Stage,
838 stage_since: Timestamp,
839 message: String,
840}
841
842fn note_path(home: &Path) -> PathBuf {
843 home.join("upgrade.note.json")
844}
845
846fn write_note(home: &Path, progress: &Progress, message: &str) {
847 let note = Note {
848 stage: progress.stage,
849 stage_since: progress.updated_at,
850 message: message.to_owned(),
851 };
852 let path = note_path(home);
853 let tmp = path.with_extension("json.tmp");
854 let written = serde_json::to_string(¬e)
855 .map_err(std::io::Error::other)
856 .and_then(|body| std::fs::write(&tmp, body))
857 .and_then(|()| std::fs::rename(&tmp, &path));
858 if let Err(e) = written {
859 tracing::warn!("could not write {}: {e}", path.display());
860 }
861}
862
863#[must_use]
866pub fn read_note(home: &Path, progress: &Progress) -> Option<String> {
867 let body = std::fs::read_to_string(note_path(home)).ok()?;
868 let note: Note = serde_json::from_str(&body).ok()?;
869 (note.stage == progress.stage && note.stage_since == progress.updated_at)
870 .then_some(note.message)
871}
872
873pub fn spawn_watchdog(home: PathBuf) {
877 let spawned = std::thread::Builder::new()
878 .name("upgrade-watchdog".to_owned())
879 .spawn(move || {
880 let mut dog = Watchdog::default();
881 loop {
882 std::thread::sleep(WATCHDOG_POLL);
883 let Some(progress) = read_progress(&home) else {
884 continue;
885 };
886 let lease = read_lease(&home);
887 let Some(beat) = dog.tick(&progress, lease.as_ref(), Timestamp::now()) else {
888 continue;
889 };
890 if beat.warn {
891 log_warn(&home, &beat.message);
892 } else {
893 log_step(&home, &beat.message);
894 }
895 write_note(&home, &progress, &beat.message);
900 }
901 });
902 if let Err(e) = spawned {
903 tracing::warn!("could not start the upgrade watchdog: {e}");
904 }
905}
906
907pub fn reconcile_after_restart(home: &Path) {
919 let Some(mut progress) = read_progress(home) else {
920 return;
921 };
922 let _ = std::fs::remove_file(lease_path(home));
924 if progress.stage.terminal() {
925 return;
926 }
927 let running = env!("CARGO_PKG_VERSION");
928 if progress
934 .to
935 .as_deref()
936 .is_some_and(|to| to.trim_start_matches('v') == running)
937 {
938 progress.advance(Stage::Done);
939 } else {
940 let to = progress
941 .to
942 .clone()
943 .unwrap_or_else(|| "the expected release".to_owned());
944 progress.fail(format!(
945 "this process came up on {running}, not {to} - the upgrade may \
946 not have replaced the binary"
947 ));
948 }
949 write_progress_logged(home, &progress);
950}
951
952pub fn spawn(cfg: &Update, rt: &tokio::runtime::Handle) -> Option<Pending> {
954 if disabled_by_env() || cfg.mode == UpdateMode::Off {
955 return None;
956 }
957 let checker = Checker::new(cfg)?;
958 match cfg.mode {
959 UpdateMode::Off => None,
960 UpdateMode::Notify => {
961 if !checker.should_check() {
962 let latest = checker.cached_update()?;
963 return Some(Pending::Cached { checker, latest });
964 }
965 let inner = checker.inner.clone();
966 let handle = rt.spawn(async move { inner.check_and_save().await });
967 Some(Pending::Notify { checker, handle })
968 }
969 UpdateMode::Install => {
970 let inner = checker.inner.clone();
971 let handle = rt.spawn(async move { inner.auto_update().await });
972 Some(Pending::Install { handle })
973 }
974 }
975}
976
977pub async fn finalize(pending: Option<Pending>, budget: Duration) {
982 let Some(pending) = pending else {
983 return;
984 };
985 match pending {
986 Pending::Cached { checker, latest } => {
987 eprintln!("{}", checker.format_banner(&latest));
988 }
989 Pending::Notify { checker, handle } => {
990 if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
991 eprintln!("{}", checker.format_banner(&latest));
992 }
993 }
994 Pending::Install { handle } => {
995 if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
996 eprintln!("magi updated itself to {}", latest.tag_name);
997 }
998 }
999 }
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004 use super::*;
1005
1006 #[test]
1011 fn effective_interval_floors_a_configured_interval_below_githubs_rate_limit() {
1012 let cfg = Update {
1013 mode: UpdateMode::Notify,
1014 interval: Some("1s".to_owned()),
1015 };
1016 assert_eq!(
1017 effective_interval(&cfg),
1018 MIN_INTERVAL,
1019 "an interval that would exceed GitHub's rate limit under continuous \
1020 polling must be floored rather than honoured verbatim"
1021 );
1022
1023 let sane = Update {
1024 mode: UpdateMode::Notify,
1025 interval: Some("2h".to_owned()),
1026 };
1027 assert_eq!(
1028 effective_interval(&sane),
1029 Duration::from_secs(2 * 60 * 60),
1030 "an interval already above the floor must pass through unchanged"
1031 );
1032 }
1033
1034 #[test]
1035 fn env_kill_switch_semantics() {
1036 unsafe {
1038 std::env::remove_var(NO_AUTOUPDATE_ENV);
1039 }
1040 assert!(!disabled_by_env());
1041 for (value, disabled) in [
1042 ("1", true),
1043 ("true", true),
1044 ("yes", true),
1045 ("0", false),
1046 ("false", false),
1047 ("FALSE", false),
1048 ("", false),
1049 (" ", false),
1050 ] {
1051 unsafe {
1052 std::env::set_var(NO_AUTOUPDATE_ENV, value);
1053 }
1054 assert_eq!(
1055 disabled_by_env(),
1056 disabled,
1057 "MAGI_NO_AUTOUPDATE={value:?} should {} disable",
1058 if disabled { "" } else { "not" }
1059 );
1060 }
1061 unsafe {
1062 std::env::remove_var(NO_AUTOUPDATE_ENV);
1063 }
1064 }
1065
1066 #[test]
1067 fn off_mode_never_spawns() {
1068 let rt = tokio::runtime::Builder::new_current_thread()
1069 .enable_all()
1070 .build()
1071 .unwrap();
1072 let cfg = Update {
1073 mode: UpdateMode::Off,
1074 interval: None,
1075 };
1076 assert!(spawn(&cfg, rt.handle()).is_none());
1077 }
1078
1079 #[test]
1080 fn state_path_lives_under_the_cache_dir() {
1081 let path = state_path().expect("a cache dir on every supported platform");
1082 assert!(path.ends_with("magi/last_update_check.json"));
1083 let data = dirs::data_local_dir().unwrap_or_default();
1084 assert!(
1085 !path.starts_with(&data) || dirs::cache_dir() == dirs::data_local_dir(),
1086 "throttle state must not sit in the run history directory"
1087 );
1088 }
1089
1090 #[tokio::test]
1091 async fn finalize_of_nothing_is_a_no_op() {
1092 finalize(None, Duration::from_millis(1)).await;
1093 }
1094
1095 #[test]
1105 fn checking_is_off_for_every_caller_when_the_config_says_off() {
1106 assert!(
1107 Checker::new(&Update {
1108 mode: UpdateMode::Off,
1109 interval: None,
1110 })
1111 .is_none(),
1112 "an operator who writes mode = \"off\" means it"
1113 );
1114 for mode in [UpdateMode::Notify, UpdateMode::Install] {
1115 assert!(
1116 Checker::new(&Update {
1117 mode,
1118 interval: None,
1119 })
1120 .is_some(),
1121 "{mode:?} still asks the forge"
1122 );
1123 }
1124 }
1125
1126 #[test]
1137 fn cached_update_answers_from_disk_with_no_network_call() {
1138 let dir = tempfile::tempdir().expect("temp dir");
1139 let path = dir.path().join("state.json");
1140 let opts = kaishin::KaishinOptions::new("yukimemi", "magi", "magi", "0.1.0");
1141 let checker = Checker {
1142 inner: kaishin::Checker::new("magi", opts).state_path(path.clone()),
1143 };
1144
1145 assert!(
1146 checker.cached_update().is_none(),
1147 "no state file yet must read as \"unknown\", not an error"
1148 );
1149
1150 let state = kaishin::UpdateCheckState {
1151 last_checked_unix: 0,
1152 last_known_latest: Some("v9.9.9".to_owned()),
1153 last_known_url: Some("https://example.invalid/9.9.9".to_owned()),
1154 };
1155 kaishin::save_check_state(&path, &state).expect("seed the state file");
1156
1157 let latest = checker.cached_update().expect("a newer release was cached");
1158 assert_eq!(latest.tag_name, "v9.9.9");
1159 }
1160
1161 #[test]
1162 fn reconcile_after_restart_confirms_a_matching_version() {
1163 let home = tempfile::tempdir().expect("temp home");
1168 let mut progress = Progress::new(
1169 "0.1.0".to_owned(),
1170 format!("v{}", env!("CARGO_PKG_VERSION")),
1171 );
1172 progress.advance(Stage::Restarting);
1173 write_progress(home.path(), &progress).expect("seed progress");
1174
1175 reconcile_after_restart(home.path());
1176
1177 let after = read_progress(home.path()).expect("progress on disk");
1178 assert_eq!(
1179 after.stage,
1180 Stage::Done,
1181 "the successor is running exactly the release that was asked for, \
1182 `v` prefix and all"
1183 );
1184 }
1185
1186 #[test]
1187 fn reconcile_after_restart_flags_a_mismatched_version() {
1188 let home = tempfile::tempdir().expect("temp home");
1189 let mut progress = Progress::new("0.1.0".to_owned(), "v9.9.9".to_owned());
1190 progress.advance(Stage::Restarting);
1191 write_progress(home.path(), &progress).expect("seed progress");
1192
1193 reconcile_after_restart(home.path());
1194
1195 let after = read_progress(home.path()).expect("progress on disk");
1196 assert_eq!(after.stage, Stage::Failed);
1197 assert!(
1198 after.detail.is_some_and(|d| d.contains("9.9.9")),
1199 "the operator needs to know which release it did not come back on"
1200 );
1201 }
1202
1203 #[test]
1204 fn reconcile_after_restart_leaves_a_settled_record_alone() {
1205 let home = tempfile::tempdir().expect("temp home");
1206 let mut progress = Progress::new("0.1.0".to_owned(), "9.9.9".to_owned());
1207 progress.advance(Stage::Done);
1208 write_progress(home.path(), &progress).expect("seed progress");
1209
1210 reconcile_after_restart(home.path());
1211
1212 let after = read_progress(home.path()).expect("progress on disk");
1213 assert_eq!(
1214 after.stage,
1215 Stage::Done,
1216 "an already-settled record must not be rewritten by a later, unrelated start"
1217 );
1218 }
1219
1220 #[test]
1221 fn reconcile_after_restart_with_nothing_on_disk_is_a_quiet_no_op() {
1222 let home = tempfile::tempdir().expect("temp home");
1223 reconcile_after_restart(home.path());
1224 assert!(read_progress(home.path()).is_none());
1225 }
1226
1227 fn at(secs: i64) -> Timestamp {
1228 Timestamp::from_second(secs).expect("timestamp")
1229 }
1230
1231 fn staged(stage: Stage, since: i64) -> Progress {
1232 let mut p = Progress::new("0.1.0".to_owned(), "v0.2.0".to_owned());
1233 p.stage = stage;
1234 p.started_at = at(since);
1235 p.updated_at = at(since);
1236 p
1237 }
1238
1239 fn lease(entered: i64, beat: i64) -> HandoverLease {
1240 HandoverLease {
1241 entered_at: at(entered),
1242 beat_at: at(beat),
1243 parked_run: Some("r1".to_owned()),
1244 }
1245 }
1246
1247 #[test]
1248 fn a_handover_never_entered_is_stuck_and_says_only_what_is_known() {
1249 let p = staged(Stage::Replaced, 1000);
1250 assert!(stall(&p, None, at(1000 + STALL_AFTER_SECS)).is_none());
1251 let s = stall(&p, None, at(1000 + STALL_AFTER_SECS + 1)).expect("stalled");
1252 assert_eq!(s.stage, Stage::Replaced);
1253 assert_eq!(s.kind, StallKind::NeverEntered);
1254 assert_eq!(s.age_secs, STALL_AFTER_SECS + 1);
1255 assert!(s.waiting_on.contains("hand_over"), "{}", s.waiting_on);
1256
1257 let p = staged(Stage::Restarting, 1000);
1258 let s = stall(&p, None, at(1000 + STALL_AFTER_SECS + 1)).expect("stalled");
1259 assert_eq!(s.kind, StallKind::StoppedBeating);
1260
1261 for stage in [Stage::Done, Stage::Failed, Stage::Downloading] {
1262 assert!(stall(&staged(stage, 0), None, at(1_000_000)).is_none());
1263 }
1264 }
1265
1266 #[test]
1267 fn a_live_parking_wait_is_never_stuck_however_long_it_lasts() {
1268 let mut p = staged(Stage::Parking, 1000);
1269 p.started_at = at(900);
1270 let hours = 5 * 3600;
1271 let l = lease(1000, 1000 + hours);
1272 assert!(stall(&p, Some(&l), at(1000 + hours + 10)).is_none());
1273 let r = staged(Stage::Replaced, 1000);
1275 assert!(stall(&r, Some(&l), at(1000 + hours + 10)).is_none());
1276 let s = stall(&p, Some(&l), at(1000 + hours + LEASE_TTL_SECS + 1)).expect("stuck");
1278 assert_eq!(s.kind, StallKind::StoppedBeating);
1279 }
1280
1281 #[test]
1282 fn a_lease_from_an_earlier_upgrade_proves_nothing() {
1283 let p = staged(Stage::Replaced, 2000);
1284 let old = lease(10, 3000);
1285 assert!(live_lease(&p, Some(&old), at(3001)).is_none());
1286 }
1287
1288 #[test]
1289 fn a_stage_never_goes_backwards_but_a_new_upgrade_after_a_terminal_one_starts() {
1290 let parking = staged(Stage::Parking, 1000);
1291 for back in [Stage::Replaced, Stage::Downloading, Stage::Parking] {
1292 let mut cand = staged(back, 5000);
1293 cand.to = Some("v9.9.9".to_owned());
1294 let kept = monotonic(Some(&parking), &cand);
1295 assert_eq!(kept.stage, Stage::Parking);
1296 assert_eq!(kept.updated_at, at(1000));
1297 assert_eq!(kept.started_at, parking.started_at);
1298 assert_eq!(kept.to.as_deref(), Some("v9.9.9"), "data is refreshed");
1299 }
1300 assert_eq!(
1301 monotonic(Some(&parking), &staged(Stage::Restarting, 5000)).stage,
1302 Stage::Restarting
1303 );
1304 assert_eq!(
1305 monotonic(Some(&parking), &staged(Stage::Failed, 5000)).stage,
1306 Stage::Failed
1307 );
1308 let done = staged(Stage::Done, 1000);
1309 assert_eq!(
1310 monotonic(Some(&done), &staged(Stage::Downloading, 5000)).stage,
1311 Stage::Downloading
1312 );
1313 }
1314
1315 #[test]
1316 fn write_progress_refuses_a_regression_on_disk() {
1317 let home = tempfile::tempdir().expect("temp home");
1318 write_progress(home.path(), &staged(Stage::Parking, 1000)).expect("write");
1319 write_progress(home.path(), &staged(Stage::Replaced, 5000)).expect("write");
1320 let on_disk = read_progress(home.path()).expect("record");
1321 assert_eq!(on_disk.stage, Stage::Parking);
1322 assert_eq!(on_disk.updated_at, at(1000));
1323 }
1324
1325 #[test]
1326 fn a_failed_request_cannot_overwrite_a_live_handover() {
1327 let home = tempfile::tempdir().expect("temp home");
1328 write_progress(home.path(), &staged(Stage::Parking, 1000)).expect("write");
1329 assert!(!fail_progress(home.path(), "boom").expect("fail"));
1330 assert_eq!(read_progress(home.path()).unwrap().stage, Stage::Parking);
1331 write_progress(home.path(), &staged(Stage::Replaced, 1000)).ok();
1332 let fresh = tempfile::tempdir().expect("temp home");
1333 write_progress(fresh.path(), &staged(Stage::Downloading, 1000)).expect("write");
1334 assert!(fail_progress(fresh.path(), "boom").expect("fail"));
1335 assert_eq!(read_progress(fresh.path()).unwrap().stage, Stage::Failed);
1336 }
1337
1338 #[test]
1339 fn entering_parking_is_one_step_that_keeps_the_lease_newer_than_the_record() {
1340 let home = tempfile::tempdir().expect("temp home");
1341 write_progress(home.path(), &staged(Stage::Replaced, 1000)).expect("write");
1342 let (guard, recorded) = LeaseGuard::enter_parking(home.path());
1343 assert!(recorded);
1344 let p = read_progress(home.path()).expect("record");
1345 assert_eq!(p.stage, Stage::Parking);
1346 let l = read_lease(home.path()).expect("lease");
1347 assert!(l.entered_at >= p.started_at);
1348 assert!(!fail_progress(home.path(), "boom").expect("fail"));
1349 drop(guard);
1350 }
1351
1352 #[test]
1353 fn the_lease_guard_writes_beats_and_removes_the_lease() {
1354 let home = tempfile::tempdir().expect("temp home");
1355 {
1356 let mut guard = LeaseGuard::enter(home.path(), Some("r1".to_owned()));
1357 let first = read_lease(home.path()).expect("lease");
1358 assert_eq!(first.parked_run.as_deref(), Some("r1"));
1359 guard.beat();
1360 assert!(read_lease(home.path()).is_some());
1361 }
1362 assert!(read_lease(home.path()).is_none());
1363 }
1364
1365 #[test]
1366 fn a_clock_that_went_backwards_is_age_zero() {
1367 let p = staged(Stage::Replaced, 5000);
1368 assert_eq!(stage_age_secs(&p, at(100)), 0);
1369 assert!(stall(&p, None, at(100)).is_none());
1370 }
1371
1372 #[test]
1373 fn a_note_is_kept_beside_the_record_and_matches_only_its_own_stage() {
1374 let home = tempfile::tempdir().expect("temp home");
1375 let p = staged(Stage::Replaced, 1000);
1376 write_progress(home.path(), &p).expect("write");
1377 write_note(home.path(), &p, "stuck");
1378 assert_eq!(read_note(home.path(), &p).as_deref(), Some("stuck"));
1379 assert_eq!(read_progress(home.path()).unwrap().updated_at, at(1000));
1380 assert!(read_note(home.path(), &staged(Stage::Parking, 1000)).is_none());
1381 assert!(read_note(home.path(), &staged(Stage::Replaced, 2000)).is_none());
1382 }
1383
1384 #[test]
1385 fn the_watchdog_speaks_once_a_minute_and_resets_on_a_new_stage() {
1386 let mut dog = Watchdog::default();
1387 let p = staged(Stage::Replaced, 1000);
1388 assert!(dog.tick(&p, None, at(1060)).is_none(), "not stalled yet");
1389 let beat = dog.tick(&p, None, at(1200)).expect("stalled");
1390 assert!(beat.warn);
1391 assert!(dog.tick(&p, None, at(1230)).is_none(), "spoke 30 s ago");
1392 assert!(dog.tick(&p, None, at(1260)).is_some(), "a minute later");
1393
1394 let parking = staged(Stage::Parking, 1260);
1395 let l = lease(1260, 1270);
1396 let beat = dog
1397 .tick(&parking, Some(&l), at(1275))
1398 .expect("parking heartbeat at once");
1399 assert!(!beat.warn, "a live wait is not a warning");
1400 assert!(dog.tick(&parking, Some(&l), at(1300)).is_none());
1401 let l = lease(1260, 1260 + 4 * 3600);
1402 let later = dog
1403 .tick(&parking, Some(&l), at(1260 + 4 * 3600 + 5))
1404 .expect("heartbeat");
1405 assert!(!later.warn, "hours of waiting on a run is still not stuck");
1406 assert!(later.message.contains("r1"), "{}", later.message);
1407
1408 let done = staged(Stage::Done, 0);
1409 assert!(dog.tick(&done, None, at(9_999_999)).is_none());
1410 }
1411
1412 #[test]
1413 fn the_upgrade_log_appends_and_rotates_to_one_generation() {
1414 let dir = tempfile::tempdir().expect("temp dir");
1415 let path = dir.path().join("upgrade.log");
1416 append_bounded(&path, "one", 16).expect("append");
1417 append_bounded(&path, "two", 16).expect("append");
1418 assert_eq!(std::fs::read_to_string(&path).unwrap(), "one\ntwo\n");
1419 append_bounded(&path, "three-and-more", 16).expect("append");
1420 append_bounded(&path, "four", 16).expect("append");
1421 assert_eq!(std::fs::read_to_string(&path).unwrap(), "four\n");
1422 let old = dir.path().join("upgrade.log.1");
1423 assert!(
1424 std::fs::read_to_string(old)
1425 .unwrap()
1426 .contains("three-and-more")
1427 );
1428 }
1429}