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
263#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct Progress {
274 pub stage: Stage,
276 pub from: String,
278 pub to: Option<String>,
280 #[serde(default)]
282 pub parked_run: Option<String>,
283 pub started_at: Timestamp,
285 pub updated_at: Timestamp,
287 #[serde(default)]
289 pub detail: Option<String>,
290}
291
292impl Progress {
293 #[must_use]
295 pub fn new(from: String, to: String) -> Self {
296 let now = Timestamp::now();
297 Self {
298 stage: Stage::Downloading,
299 from,
300 to: Some(to),
301 parked_run: None,
302 started_at: now,
303 updated_at: now,
304 detail: None,
305 }
306 }
307
308 pub fn advance(&mut self, stage: Stage) {
310 self.stage = stage;
311 self.updated_at = Timestamp::now();
312 self.detail = None;
314 }
315
316 pub fn fail(&mut self, detail: impl Into<String>) {
318 self.stage = Stage::Failed;
319 self.updated_at = Timestamp::now();
320 self.detail = Some(detail.into());
321 }
322}
323
324#[must_use]
327pub fn progress_path(home: &Path) -> PathBuf {
328 home.join("upgrade.json")
329}
330
331static WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
334
335#[must_use]
344pub fn monotonic(current: Option<&Progress>, candidate: &Progress) -> Progress {
345 match current {
346 Some(cur)
347 if !cur.stage.terminal()
348 && !candidate.stage.terminal()
349 && candidate.stage.rank() <= cur.stage.rank() =>
350 {
351 let mut kept = cur.clone();
352 if candidate.to.is_some() {
353 kept.to.clone_from(&candidate.to);
354 }
355 kept
356 }
357 _ => candidate.clone(),
358 }
359}
360
361pub fn write_progress(home: &Path, progress: &Progress) -> Result<()> {
368 let _guard = WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
369 write_progress_locked(home, progress)
370}
371
372fn write_progress_locked(home: &Path, progress: &Progress) -> Result<()> {
373 let path = progress_path(home);
374 if let Some(parent) = path.parent() {
375 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
376 }
377 let to_write = monotonic(read_progress(home).as_ref(), progress);
378 let body = serde_json::to_string_pretty(&to_write).context("serialize upgrade progress")?;
379 let tmp = path.with_extension("json.tmp");
380 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
381 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
382 Ok(())
383}
384
385pub fn fail_progress(home: &Path, detail: &str) -> Result<bool> {
391 let _guard = WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
392 let Some(mut progress) = read_progress(home) else {
393 return Ok(false);
394 };
395 if matches!(progress.stage, Stage::Parking | Stage::Restarting) {
396 return Ok(false);
397 }
398 progress.fail(detail);
399 write_progress_locked(home, &progress)?;
400 Ok(true)
401}
402
403#[must_use]
405pub fn read_progress(home: &Path) -> Option<Progress> {
406 let body = std::fs::read_to_string(progress_path(home)).ok()?;
407 serde_json::from_str(&body).ok()
408}
409
410#[must_use]
413pub fn log_path(home: &Path) -> PathBuf {
414 home.join("upgrade.log")
415}
416
417pub const LOG_MAX_BYTES: u64 = 256 * 1024;
419
420pub const STALL_AFTER_SECS: i64 = 120;
422
423pub const LEASE_TTL_SECS: i64 = 90;
426
427pub const HEARTBEAT_SECS: i64 = 60;
429
430pub const WATCHDOG_POLL: Duration = Duration::from_secs(30);
432
433pub fn append_bounded(path: &Path, line: &str, max: u64) -> std::io::Result<()> {
437 use std::io::Write as _;
438 if std::fs::metadata(path).is_ok_and(|m| m.len() >= max) {
439 let mut old = path.as_os_str().to_owned();
440 old.push(".1");
441 std::fs::rename(path, PathBuf::from(old))?;
442 }
443 if let Some(parent) = path.parent() {
444 std::fs::create_dir_all(parent)?;
445 }
446 let mut file = std::fs::OpenOptions::new()
447 .create(true)
448 .append(true)
449 .open(path)?;
450 writeln!(file, "{line}")
451}
452
453pub fn log_step(home: &Path, msg: &str) {
457 tracing::info!("handover: {msg}");
458 log_line(home, "INFO", msg);
459}
460
461pub fn log_warn(home: &Path, msg: &str) {
463 tracing::warn!("handover: {msg}");
464 log_line(home, "WARN", msg);
465}
466
467fn log_line(home: &Path, level: &str, msg: &str) {
468 let line = format!(
469 "{} pid={} {level} {msg}",
470 Timestamp::now(),
471 std::process::id()
472 );
473 if let Err(e) = append_bounded(&log_path(home), &line, LOG_MAX_BYTES) {
474 tracing::warn!("could not append to {}: {e}", log_path(home).display());
475 }
476}
477
478pub fn write_progress_logged(home: &Path, progress: &Progress) {
482 if let Err(e) = write_progress(home, progress) {
483 log_warn(home, &format!("could not write upgrade.json: {e:#}"));
484 }
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize)]
490pub struct HandoverLease {
491 pub entered_at: Timestamp,
493 pub beat_at: Timestamp,
495 #[serde(default)]
497 pub parked_run: Option<String>,
498}
499
500impl HandoverLease {
501 #[must_use]
503 pub fn fresh(&self, now: Timestamp) -> bool {
504 now.as_second() - self.beat_at.as_second() <= LEASE_TTL_SECS
505 }
506}
507
508#[must_use]
510pub fn lease_path(home: &Path) -> PathBuf {
511 home.join("upgrade.handover.json")
512}
513
514#[must_use]
516pub fn read_lease(home: &Path) -> Option<HandoverLease> {
517 let body = std::fs::read_to_string(lease_path(home)).ok()?;
518 serde_json::from_str(&body).ok()
519}
520
521fn write_lease(home: &Path, lease: &HandoverLease) {
522 let path = lease_path(home);
523 let tmp = path.with_extension("json.tmp");
524 let written = serde_json::to_string(lease)
525 .map_err(std::io::Error::other)
526 .and_then(|body| std::fs::write(&tmp, body))
527 .and_then(|()| std::fs::rename(&tmp, &path));
528 if let Err(e) = written {
529 log_warn(home, &format!("could not write the handover lease: {e}"));
530 }
531}
532
533#[derive(Debug)]
535pub struct LeaseGuard {
536 home: PathBuf,
537 lease: HandoverLease,
538}
539
540impl LeaseGuard {
541 #[must_use]
543 pub fn enter(home: &Path, parked_run: Option<String>) -> Self {
544 let now = Timestamp::now();
545 let lease = HandoverLease {
546 entered_at: now,
547 beat_at: now,
548 parked_run,
549 };
550 write_lease(home, &lease);
551 Self {
552 home: home.to_owned(),
553 lease,
554 }
555 }
556
557 #[must_use]
563 pub fn enter_parking(home: &Path) -> (Self, bool) {
564 let _guard = WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
565 let progress = read_progress(home);
566 let this = Self::enter(home, progress.as_ref().and_then(|p| p.parked_run.clone()));
567 let recorded = match progress {
568 Some(mut p) => {
569 p.advance(Stage::Parking);
570 if let Err(e) = write_progress_locked(home, &p) {
571 log_warn(home, &format!("could not write upgrade.json: {e:#}"));
572 }
573 true
574 }
575 None => false,
576 };
577 (this, recorded)
578 }
579
580 pub fn beat(&mut self) {
582 self.lease.beat_at = Timestamp::now();
583 write_lease(&self.home, &self.lease);
584 }
585}
586
587impl Drop for LeaseGuard {
588 fn drop(&mut self) {
589 let _ = std::fs::remove_file(lease_path(&self.home));
590 }
591}
592
593#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
595#[serde(rename_all = "snake_case")]
596pub enum StallKind {
597 NeverEntered,
599 StoppedBeating,
602}
603
604#[derive(Debug, Clone, PartialEq, Eq)]
606pub struct Stall {
607 pub stage: Stage,
609 pub kind: StallKind,
611 pub age_secs: i64,
613 pub waiting_on: String,
615}
616
617#[must_use]
620pub fn stage_age_secs(progress: &Progress, now: Timestamp) -> i64 {
621 (now.as_second() - progress.updated_at.as_second()).max(0)
622}
623
624#[must_use]
626pub fn live_lease<'a>(
627 progress: &Progress,
628 lease: Option<&'a HandoverLease>,
629 now: Timestamp,
630) -> Option<&'a HandoverLease> {
631 lease.filter(|l| {
632 !progress.stage.terminal() && l.fresh(now) && l.entered_at >= progress.started_at
633 })
634}
635
636fn waiting_on(progress: &Progress, lease: Option<&HandoverLease>) -> String {
638 match progress.stage {
639 Stage::Replaced => "hand_over to start (HANDOVER was signalled; hand_over has left no \
640 record that it was entered)"
641 .to_owned(),
642 Stage::Parking => {
643 let run = lease
644 .and_then(|l| l.parked_run.as_ref())
645 .or(progress.parked_run.as_ref());
646 match run {
647 Some(run) => format!("the loop to finish run {run} at its next node boundary"),
648 None => "the loop to stop (no run was recorded as in flight)".to_owned(),
649 }
650 }
651 Stage::Restarting => "spawn_successor returning and this process exiting".to_owned(),
652 Stage::Downloading => "the release download and binary replacement".to_owned(),
653 Stage::Done | Stage::Failed => String::new(),
654 }
655}
656
657#[must_use]
659pub fn waited_secs(lease: &HandoverLease, now: Timestamp) -> i64 {
660 (now.as_second() - lease.entered_at.as_second()).max(0)
661}
662
663#[must_use]
668pub fn stall(progress: &Progress, lease: Option<&HandoverLease>, now: Timestamp) -> Option<Stall> {
669 if progress.stage.terminal() || progress.stage == Stage::Downloading {
670 return None;
671 }
672 if live_lease(progress, lease, now).is_some() {
673 return None;
674 }
675 let stale = lease.filter(|l| l.entered_at >= progress.started_at);
677 let (kind, since) = match (progress.stage, stale) {
678 (_, Some(l)) => (StallKind::StoppedBeating, l.beat_at),
679 (Stage::Replaced, None) => (StallKind::NeverEntered, progress.updated_at),
680 _ => (StallKind::StoppedBeating, progress.updated_at),
681 };
682 let age_secs = (now.as_second() - since.as_second()).max(0);
683 let limit = if stale.is_some() {
684 LEASE_TTL_SECS
685 } else {
686 STALL_AFTER_SECS
687 };
688 (age_secs > limit).then(|| Stall {
689 stage: progress.stage,
690 kind,
691 age_secs,
692 waiting_on: waiting_on(progress, lease),
693 })
694}
695
696#[derive(Debug, Clone, PartialEq, Eq)]
698pub struct Beat {
699 pub stage: Stage,
701 pub warn: bool,
703 pub message: String,
705}
706
707#[derive(Debug, Default)]
709pub struct Watchdog {
710 last: Option<(Stage, Timestamp)>,
711}
712
713impl Watchdog {
714 pub fn tick(
717 &mut self,
718 progress: &Progress,
719 lease: Option<&HandoverLease>,
720 now: Timestamp,
721 ) -> Option<Beat> {
722 if progress.stage.terminal() {
723 self.last = None;
724 return None;
725 }
726 if self.last.is_some_and(|(stage, _)| stage != progress.stage) {
727 self.last = None;
728 }
729 let stalled = stall(progress, lease, now);
730 let alive = live_lease(progress, lease, now);
731 if stalled.is_none() && alive.is_none() && progress.stage != Stage::Parking {
734 return None;
735 }
736 if let Some((_, at)) = self.last
737 && now.as_second() - at.as_second() < HEARTBEAT_SECS
738 {
739 return None;
740 }
741 self.last = Some((progress.stage, now));
742 let age = alive.map_or_else(|| stage_age_secs(progress, now), |l| waited_secs(l, now));
743 let (warn, message) = match &stalled {
744 Some(s) => (
745 true,
746 format!(
747 "stuck in {:?} for {} min {} s without progress, waiting on {}",
748 s.stage,
749 s.age_secs / 60,
750 s.age_secs % 60,
751 s.waiting_on
752 ),
753 ),
754 None => (
755 false,
756 format!(
757 "parking for {} min {} s, waiting on {}",
758 age / 60,
759 age % 60,
760 waiting_on(progress, lease)
761 ),
762 ),
763 };
764 Some(Beat {
765 stage: progress.stage,
766 warn,
767 message,
768 })
769 }
770}
771
772#[derive(Debug, Serialize, Deserialize)]
775struct Note {
776 stage: Stage,
777 stage_since: Timestamp,
778 message: String,
779}
780
781fn note_path(home: &Path) -> PathBuf {
782 home.join("upgrade.note.json")
783}
784
785fn write_note(home: &Path, progress: &Progress, message: &str) {
786 let note = Note {
787 stage: progress.stage,
788 stage_since: progress.updated_at,
789 message: message.to_owned(),
790 };
791 let path = note_path(home);
792 let tmp = path.with_extension("json.tmp");
793 let written = serde_json::to_string(¬e)
794 .map_err(std::io::Error::other)
795 .and_then(|body| std::fs::write(&tmp, body))
796 .and_then(|()| std::fs::rename(&tmp, &path));
797 if let Err(e) = written {
798 tracing::warn!("could not write {}: {e}", path.display());
799 }
800}
801
802#[must_use]
805pub fn read_note(home: &Path, progress: &Progress) -> Option<String> {
806 let body = std::fs::read_to_string(note_path(home)).ok()?;
807 let note: Note = serde_json::from_str(&body).ok()?;
808 (note.stage == progress.stage && note.stage_since == progress.updated_at)
809 .then_some(note.message)
810}
811
812pub fn spawn_watchdog(home: PathBuf) {
816 let spawned = std::thread::Builder::new()
817 .name("upgrade-watchdog".to_owned())
818 .spawn(move || {
819 let mut dog = Watchdog::default();
820 loop {
821 std::thread::sleep(WATCHDOG_POLL);
822 let Some(progress) = read_progress(&home) else {
823 continue;
824 };
825 let lease = read_lease(&home);
826 let Some(beat) = dog.tick(&progress, lease.as_ref(), Timestamp::now()) else {
827 continue;
828 };
829 if beat.warn {
830 log_warn(&home, &beat.message);
831 } else {
832 log_step(&home, &beat.message);
833 }
834 write_note(&home, &progress, &beat.message);
839 }
840 });
841 if let Err(e) = spawned {
842 tracing::warn!("could not start the upgrade watchdog: {e}");
843 }
844}
845
846pub fn reconcile_after_restart(home: &Path) {
858 let Some(mut progress) = read_progress(home) else {
859 return;
860 };
861 let _ = std::fs::remove_file(lease_path(home));
863 if progress.stage.terminal() {
864 return;
865 }
866 let running = env!("CARGO_PKG_VERSION");
867 if progress
873 .to
874 .as_deref()
875 .is_some_and(|to| to.trim_start_matches('v') == running)
876 {
877 progress.advance(Stage::Done);
878 } else {
879 let to = progress
880 .to
881 .clone()
882 .unwrap_or_else(|| "the expected release".to_owned());
883 progress.fail(format!(
884 "this process came up on {running}, not {to} - the upgrade may \
885 not have replaced the binary"
886 ));
887 }
888 write_progress_logged(home, &progress);
889}
890
891pub fn spawn(cfg: &Update, rt: &tokio::runtime::Handle) -> Option<Pending> {
893 if disabled_by_env() || cfg.mode == UpdateMode::Off {
894 return None;
895 }
896 let checker = Checker::new(cfg)?;
897 match cfg.mode {
898 UpdateMode::Off => None,
899 UpdateMode::Notify => {
900 if !checker.should_check() {
901 let latest = checker.cached_update()?;
902 return Some(Pending::Cached { checker, latest });
903 }
904 let inner = checker.inner.clone();
905 let handle = rt.spawn(async move { inner.check_and_save().await });
906 Some(Pending::Notify { checker, handle })
907 }
908 UpdateMode::Install => {
909 let inner = checker.inner.clone();
910 let handle = rt.spawn(async move { inner.auto_update().await });
911 Some(Pending::Install { handle })
912 }
913 }
914}
915
916pub async fn finalize(pending: Option<Pending>, budget: Duration) {
921 let Some(pending) = pending else {
922 return;
923 };
924 match pending {
925 Pending::Cached { checker, latest } => {
926 eprintln!("{}", checker.format_banner(&latest));
927 }
928 Pending::Notify { checker, handle } => {
929 if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
930 eprintln!("{}", checker.format_banner(&latest));
931 }
932 }
933 Pending::Install { handle } => {
934 if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
935 eprintln!("magi updated itself to {}", latest.tag_name);
936 }
937 }
938 }
939}
940
941#[cfg(test)]
942mod tests {
943 use super::*;
944
945 #[test]
950 fn effective_interval_floors_a_configured_interval_below_githubs_rate_limit() {
951 let cfg = Update {
952 mode: UpdateMode::Notify,
953 interval: Some("1s".to_owned()),
954 };
955 assert_eq!(
956 effective_interval(&cfg),
957 MIN_INTERVAL,
958 "an interval that would exceed GitHub's rate limit under continuous \
959 polling must be floored rather than honoured verbatim"
960 );
961
962 let sane = Update {
963 mode: UpdateMode::Notify,
964 interval: Some("2h".to_owned()),
965 };
966 assert_eq!(
967 effective_interval(&sane),
968 Duration::from_secs(2 * 60 * 60),
969 "an interval already above the floor must pass through unchanged"
970 );
971 }
972
973 #[test]
974 fn env_kill_switch_semantics() {
975 unsafe {
977 std::env::remove_var(NO_AUTOUPDATE_ENV);
978 }
979 assert!(!disabled_by_env());
980 for (value, disabled) in [
981 ("1", true),
982 ("true", true),
983 ("yes", true),
984 ("0", false),
985 ("false", false),
986 ("FALSE", false),
987 ("", false),
988 (" ", false),
989 ] {
990 unsafe {
991 std::env::set_var(NO_AUTOUPDATE_ENV, value);
992 }
993 assert_eq!(
994 disabled_by_env(),
995 disabled,
996 "MAGI_NO_AUTOUPDATE={value:?} should {} disable",
997 if disabled { "" } else { "not" }
998 );
999 }
1000 unsafe {
1001 std::env::remove_var(NO_AUTOUPDATE_ENV);
1002 }
1003 }
1004
1005 #[test]
1006 fn off_mode_never_spawns() {
1007 let rt = tokio::runtime::Builder::new_current_thread()
1008 .enable_all()
1009 .build()
1010 .unwrap();
1011 let cfg = Update {
1012 mode: UpdateMode::Off,
1013 interval: None,
1014 };
1015 assert!(spawn(&cfg, rt.handle()).is_none());
1016 }
1017
1018 #[test]
1019 fn state_path_lives_under_the_cache_dir() {
1020 let path = state_path().expect("a cache dir on every supported platform");
1021 assert!(path.ends_with("magi/last_update_check.json"));
1022 let data = dirs::data_local_dir().unwrap_or_default();
1023 assert!(
1024 !path.starts_with(&data) || dirs::cache_dir() == dirs::data_local_dir(),
1025 "throttle state must not sit in the run history directory"
1026 );
1027 }
1028
1029 #[tokio::test]
1030 async fn finalize_of_nothing_is_a_no_op() {
1031 finalize(None, Duration::from_millis(1)).await;
1032 }
1033
1034 #[test]
1044 fn checking_is_off_for_every_caller_when_the_config_says_off() {
1045 assert!(
1046 Checker::new(&Update {
1047 mode: UpdateMode::Off,
1048 interval: None,
1049 })
1050 .is_none(),
1051 "an operator who writes mode = \"off\" means it"
1052 );
1053 for mode in [UpdateMode::Notify, UpdateMode::Install] {
1054 assert!(
1055 Checker::new(&Update {
1056 mode,
1057 interval: None,
1058 })
1059 .is_some(),
1060 "{mode:?} still asks the forge"
1061 );
1062 }
1063 }
1064
1065 #[test]
1076 fn cached_update_answers_from_disk_with_no_network_call() {
1077 let dir = tempfile::tempdir().expect("temp dir");
1078 let path = dir.path().join("state.json");
1079 let opts = kaishin::KaishinOptions::new("yukimemi", "magi", "magi", "0.1.0");
1080 let checker = Checker {
1081 inner: kaishin::Checker::new("magi", opts).state_path(path.clone()),
1082 };
1083
1084 assert!(
1085 checker.cached_update().is_none(),
1086 "no state file yet must read as \"unknown\", not an error"
1087 );
1088
1089 let state = kaishin::UpdateCheckState {
1090 last_checked_unix: 0,
1091 last_known_latest: Some("v9.9.9".to_owned()),
1092 last_known_url: Some("https://example.invalid/9.9.9".to_owned()),
1093 };
1094 kaishin::save_check_state(&path, &state).expect("seed the state file");
1095
1096 let latest = checker.cached_update().expect("a newer release was cached");
1097 assert_eq!(latest.tag_name, "v9.9.9");
1098 }
1099
1100 #[test]
1101 fn reconcile_after_restart_confirms_a_matching_version() {
1102 let home = tempfile::tempdir().expect("temp home");
1107 let mut progress = Progress::new(
1108 "0.1.0".to_owned(),
1109 format!("v{}", env!("CARGO_PKG_VERSION")),
1110 );
1111 progress.advance(Stage::Restarting);
1112 write_progress(home.path(), &progress).expect("seed progress");
1113
1114 reconcile_after_restart(home.path());
1115
1116 let after = read_progress(home.path()).expect("progress on disk");
1117 assert_eq!(
1118 after.stage,
1119 Stage::Done,
1120 "the successor is running exactly the release that was asked for, \
1121 `v` prefix and all"
1122 );
1123 }
1124
1125 #[test]
1126 fn reconcile_after_restart_flags_a_mismatched_version() {
1127 let home = tempfile::tempdir().expect("temp home");
1128 let mut progress = Progress::new("0.1.0".to_owned(), "v9.9.9".to_owned());
1129 progress.advance(Stage::Restarting);
1130 write_progress(home.path(), &progress).expect("seed progress");
1131
1132 reconcile_after_restart(home.path());
1133
1134 let after = read_progress(home.path()).expect("progress on disk");
1135 assert_eq!(after.stage, Stage::Failed);
1136 assert!(
1137 after.detail.is_some_and(|d| d.contains("9.9.9")),
1138 "the operator needs to know which release it did not come back on"
1139 );
1140 }
1141
1142 #[test]
1143 fn reconcile_after_restart_leaves_a_settled_record_alone() {
1144 let home = tempfile::tempdir().expect("temp home");
1145 let mut progress = Progress::new("0.1.0".to_owned(), "9.9.9".to_owned());
1146 progress.advance(Stage::Done);
1147 write_progress(home.path(), &progress).expect("seed progress");
1148
1149 reconcile_after_restart(home.path());
1150
1151 let after = read_progress(home.path()).expect("progress on disk");
1152 assert_eq!(
1153 after.stage,
1154 Stage::Done,
1155 "an already-settled record must not be rewritten by a later, unrelated start"
1156 );
1157 }
1158
1159 #[test]
1160 fn reconcile_after_restart_with_nothing_on_disk_is_a_quiet_no_op() {
1161 let home = tempfile::tempdir().expect("temp home");
1162 reconcile_after_restart(home.path());
1163 assert!(read_progress(home.path()).is_none());
1164 }
1165
1166 fn at(secs: i64) -> Timestamp {
1167 Timestamp::from_second(secs).expect("timestamp")
1168 }
1169
1170 fn staged(stage: Stage, since: i64) -> Progress {
1171 let mut p = Progress::new("0.1.0".to_owned(), "v0.2.0".to_owned());
1172 p.stage = stage;
1173 p.started_at = at(since);
1174 p.updated_at = at(since);
1175 p
1176 }
1177
1178 fn lease(entered: i64, beat: i64) -> HandoverLease {
1179 HandoverLease {
1180 entered_at: at(entered),
1181 beat_at: at(beat),
1182 parked_run: Some("r1".to_owned()),
1183 }
1184 }
1185
1186 #[test]
1187 fn a_handover_never_entered_is_stuck_and_says_only_what_is_known() {
1188 let p = staged(Stage::Replaced, 1000);
1189 assert!(stall(&p, None, at(1000 + STALL_AFTER_SECS)).is_none());
1190 let s = stall(&p, None, at(1000 + STALL_AFTER_SECS + 1)).expect("stalled");
1191 assert_eq!(s.stage, Stage::Replaced);
1192 assert_eq!(s.kind, StallKind::NeverEntered);
1193 assert_eq!(s.age_secs, STALL_AFTER_SECS + 1);
1194 assert!(s.waiting_on.contains("hand_over"), "{}", s.waiting_on);
1195
1196 let p = staged(Stage::Restarting, 1000);
1197 let s = stall(&p, None, at(1000 + STALL_AFTER_SECS + 1)).expect("stalled");
1198 assert_eq!(s.kind, StallKind::StoppedBeating);
1199
1200 for stage in [Stage::Done, Stage::Failed, Stage::Downloading] {
1201 assert!(stall(&staged(stage, 0), None, at(1_000_000)).is_none());
1202 }
1203 }
1204
1205 #[test]
1206 fn a_live_parking_wait_is_never_stuck_however_long_it_lasts() {
1207 let mut p = staged(Stage::Parking, 1000);
1208 p.started_at = at(900);
1209 let hours = 5 * 3600;
1210 let l = lease(1000, 1000 + hours);
1211 assert!(stall(&p, Some(&l), at(1000 + hours + 10)).is_none());
1212 let r = staged(Stage::Replaced, 1000);
1214 assert!(stall(&r, Some(&l), at(1000 + hours + 10)).is_none());
1215 let s = stall(&p, Some(&l), at(1000 + hours + LEASE_TTL_SECS + 1)).expect("stuck");
1217 assert_eq!(s.kind, StallKind::StoppedBeating);
1218 }
1219
1220 #[test]
1221 fn a_lease_from_an_earlier_upgrade_proves_nothing() {
1222 let p = staged(Stage::Replaced, 2000);
1223 let old = lease(10, 3000);
1224 assert!(live_lease(&p, Some(&old), at(3001)).is_none());
1225 }
1226
1227 #[test]
1228 fn a_stage_never_goes_backwards_but_a_new_upgrade_after_a_terminal_one_starts() {
1229 let parking = staged(Stage::Parking, 1000);
1230 for back in [Stage::Replaced, Stage::Downloading, Stage::Parking] {
1231 let mut cand = staged(back, 5000);
1232 cand.to = Some("v9.9.9".to_owned());
1233 let kept = monotonic(Some(&parking), &cand);
1234 assert_eq!(kept.stage, Stage::Parking);
1235 assert_eq!(kept.updated_at, at(1000));
1236 assert_eq!(kept.started_at, parking.started_at);
1237 assert_eq!(kept.to.as_deref(), Some("v9.9.9"), "data is refreshed");
1238 }
1239 assert_eq!(
1240 monotonic(Some(&parking), &staged(Stage::Restarting, 5000)).stage,
1241 Stage::Restarting
1242 );
1243 assert_eq!(
1244 monotonic(Some(&parking), &staged(Stage::Failed, 5000)).stage,
1245 Stage::Failed
1246 );
1247 let done = staged(Stage::Done, 1000);
1248 assert_eq!(
1249 monotonic(Some(&done), &staged(Stage::Downloading, 5000)).stage,
1250 Stage::Downloading
1251 );
1252 }
1253
1254 #[test]
1255 fn write_progress_refuses_a_regression_on_disk() {
1256 let home = tempfile::tempdir().expect("temp home");
1257 write_progress(home.path(), &staged(Stage::Parking, 1000)).expect("write");
1258 write_progress(home.path(), &staged(Stage::Replaced, 5000)).expect("write");
1259 let on_disk = read_progress(home.path()).expect("record");
1260 assert_eq!(on_disk.stage, Stage::Parking);
1261 assert_eq!(on_disk.updated_at, at(1000));
1262 }
1263
1264 #[test]
1265 fn a_failed_request_cannot_overwrite_a_live_handover() {
1266 let home = tempfile::tempdir().expect("temp home");
1267 write_progress(home.path(), &staged(Stage::Parking, 1000)).expect("write");
1268 assert!(!fail_progress(home.path(), "boom").expect("fail"));
1269 assert_eq!(read_progress(home.path()).unwrap().stage, Stage::Parking);
1270 write_progress(home.path(), &staged(Stage::Replaced, 1000)).ok();
1271 let fresh = tempfile::tempdir().expect("temp home");
1272 write_progress(fresh.path(), &staged(Stage::Downloading, 1000)).expect("write");
1273 assert!(fail_progress(fresh.path(), "boom").expect("fail"));
1274 assert_eq!(read_progress(fresh.path()).unwrap().stage, Stage::Failed);
1275 }
1276
1277 #[test]
1278 fn entering_parking_is_one_step_that_keeps_the_lease_newer_than_the_record() {
1279 let home = tempfile::tempdir().expect("temp home");
1280 write_progress(home.path(), &staged(Stage::Replaced, 1000)).expect("write");
1281 let (guard, recorded) = LeaseGuard::enter_parking(home.path());
1282 assert!(recorded);
1283 let p = read_progress(home.path()).expect("record");
1284 assert_eq!(p.stage, Stage::Parking);
1285 let l = read_lease(home.path()).expect("lease");
1286 assert!(l.entered_at >= p.started_at);
1287 assert!(!fail_progress(home.path(), "boom").expect("fail"));
1288 drop(guard);
1289 }
1290
1291 #[test]
1292 fn the_lease_guard_writes_beats_and_removes_the_lease() {
1293 let home = tempfile::tempdir().expect("temp home");
1294 {
1295 let mut guard = LeaseGuard::enter(home.path(), Some("r1".to_owned()));
1296 let first = read_lease(home.path()).expect("lease");
1297 assert_eq!(first.parked_run.as_deref(), Some("r1"));
1298 guard.beat();
1299 assert!(read_lease(home.path()).is_some());
1300 }
1301 assert!(read_lease(home.path()).is_none());
1302 }
1303
1304 #[test]
1305 fn a_clock_that_went_backwards_is_age_zero() {
1306 let p = staged(Stage::Replaced, 5000);
1307 assert_eq!(stage_age_secs(&p, at(100)), 0);
1308 assert!(stall(&p, None, at(100)).is_none());
1309 }
1310
1311 #[test]
1312 fn a_note_is_kept_beside_the_record_and_matches_only_its_own_stage() {
1313 let home = tempfile::tempdir().expect("temp home");
1314 let p = staged(Stage::Replaced, 1000);
1315 write_progress(home.path(), &p).expect("write");
1316 write_note(home.path(), &p, "stuck");
1317 assert_eq!(read_note(home.path(), &p).as_deref(), Some("stuck"));
1318 assert_eq!(read_progress(home.path()).unwrap().updated_at, at(1000));
1319 assert!(read_note(home.path(), &staged(Stage::Parking, 1000)).is_none());
1320 assert!(read_note(home.path(), &staged(Stage::Replaced, 2000)).is_none());
1321 }
1322
1323 #[test]
1324 fn the_watchdog_speaks_once_a_minute_and_resets_on_a_new_stage() {
1325 let mut dog = Watchdog::default();
1326 let p = staged(Stage::Replaced, 1000);
1327 assert!(dog.tick(&p, None, at(1060)).is_none(), "not stalled yet");
1328 let beat = dog.tick(&p, None, at(1200)).expect("stalled");
1329 assert!(beat.warn);
1330 assert!(dog.tick(&p, None, at(1230)).is_none(), "spoke 30 s ago");
1331 assert!(dog.tick(&p, None, at(1260)).is_some(), "a minute later");
1332
1333 let parking = staged(Stage::Parking, 1260);
1334 let l = lease(1260, 1270);
1335 let beat = dog
1336 .tick(&parking, Some(&l), at(1275))
1337 .expect("parking heartbeat at once");
1338 assert!(!beat.warn, "a live wait is not a warning");
1339 assert!(dog.tick(&parking, Some(&l), at(1300)).is_none());
1340 let l = lease(1260, 1260 + 4 * 3600);
1341 let later = dog
1342 .tick(&parking, Some(&l), at(1260 + 4 * 3600 + 5))
1343 .expect("heartbeat");
1344 assert!(!later.warn, "hours of waiting on a run is still not stuck");
1345 assert!(later.message.contains("r1"), "{}", later.message);
1346
1347 let done = staged(Stage::Done, 0);
1348 assert!(dog.tick(&done, None, at(9_999_999)).is_none());
1349 }
1350
1351 #[test]
1352 fn the_upgrade_log_appends_and_rotates_to_one_generation() {
1353 let dir = tempfile::tempdir().expect("temp dir");
1354 let path = dir.path().join("upgrade.log");
1355 append_bounded(&path, "one", 16).expect("append");
1356 append_bounded(&path, "two", 16).expect("append");
1357 assert_eq!(std::fs::read_to_string(&path).unwrap(), "one\ntwo\n");
1358 append_bounded(&path, "three-and-more", 16).expect("append");
1359 append_bounded(&path, "four", 16).expect("append");
1360 assert_eq!(std::fs::read_to_string(&path).unwrap(), "four\n");
1361 let old = dir.path().join("upgrade.log.1");
1362 assert!(
1363 std::fs::read_to_string(old)
1364 .unwrap()
1365 .contains("three-and-more")
1366 );
1367 }
1368}