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
64fn pinned_worker_generation() -> bool {
66 std::env::var_os("MAGI_WORKER_GENERATION").is_some()
67}
68
69const OWNER: &str = "yukimemi";
71const REPO: &str = "magi";
73const BIN: &str = "magi";
75const CRATE: &str = "magi-cli";
77
78pub fn repo_name() -> &'static str {
82 REPO
83}
84
85fn options() -> kaishin::KaishinOptions {
93 kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION")).crate_name(CRATE)
94}
95
96fn state_path() -> Option<PathBuf> {
99 dirs::cache_dir().map(|d| d.join("magi").join("last_update_check.json"))
100}
101
102pub async fn run_self_update(yes: bool, check_only: bool, non_interactive: bool) -> Result<()> {
104 if !check_only && pinned_worker_generation() {
105 anyhow::bail!(
106 "this worker executable generation is immutable; run self-update \
107 from the installed magi executable outside the worker"
108 );
109 }
110 let opts = kaishin::UpdateOptions::new()
111 .yes(yes)
112 .check_only(check_only)
113 .non_interactive(non_interactive);
114 kaishin::run_self_update(&options(), opts).await
115}
116
117pub enum Pending {
119 Cached {
121 checker: Checker,
123 latest: kaishin::LatestRelease,
125 },
126 Notify {
128 checker: Checker,
130 handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
132 },
133 Install {
135 handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
137 },
138}
139
140#[derive(Clone)]
142pub struct Checker {
143 inner: kaishin::Checker,
144}
145
146impl Checker {
147 pub fn new(cfg: &Update) -> Option<Self> {
161 if cfg.mode == UpdateMode::Off || pinned_worker_generation() {
162 return None;
163 }
164 let mut inner = kaishin::Checker::new(BIN, options());
165 if let Some(path) = state_path() {
166 inner = inner.state_path(path);
167 }
168 Some(Self {
169 inner: inner.interval(effective_interval(cfg)),
170 })
171 }
172
173 pub fn should_check(&self) -> bool {
175 self.inner.should_check()
176 }
177
178 pub async fn newer_release(&self) -> Result<Option<kaishin::LatestRelease>> {
190 self.inner.check_and_save().await
191 }
192
193 pub fn cached_update(&self) -> Option<kaishin::LatestRelease> {
195 self.inner.cached_update()
196 }
197
198 pub fn format_banner(&self, latest: &kaishin::LatestRelease) -> String {
200 self.inner.format_banner(latest)
201 }
202
203 #[cfg(test)]
208 pub(crate) fn for_test(interval: Duration, state_path: PathBuf) -> Self {
209 let opts = kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION"));
210 Self {
211 inner: kaishin::Checker::new(BIN, opts)
212 .state_path(state_path)
213 .interval(interval),
214 }
215 }
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(rename_all = "snake_case")]
225pub enum Stage {
226 Downloading,
234 Replaced,
237 Parking,
240 Restarting,
243 Done,
246 Failed,
249}
250
251impl Stage {
252 #[must_use]
255 pub fn rank(self) -> u8 {
256 match self {
257 Self::Downloading => 0,
258 Self::Replaced => 1,
259 Self::Parking => 2,
260 Self::Restarting => 3,
261 Self::Done | Self::Failed => 4,
262 }
263 }
264
265 #[must_use]
267 pub fn terminal(self) -> bool {
268 matches!(self, Self::Done | Self::Failed)
269 }
270
271 #[must_use]
273 pub fn as_str(self) -> &'static str {
274 match self {
275 Self::Downloading => "downloading",
276 Self::Replaced => "replaced",
277 Self::Parking => "parking",
278 Self::Restarting => "restarting",
279 Self::Done => "done",
280 Self::Failed => "failed",
281 }
282 }
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct Progress {
296 pub stage: Stage,
298 pub from: String,
300 pub to: Option<String>,
302 #[serde(default)]
304 pub parked_run: Option<String>,
305 #[serde(default)]
307 pub parked_talks: Vec<String>,
308 pub started_at: Timestamp,
310 pub updated_at: Timestamp,
312 #[serde(default)]
314 pub detail: Option<String>,
315}
316
317impl Progress {
318 #[must_use]
320 pub fn new(from: String, to: String) -> Self {
321 let now = Timestamp::now();
322 Self {
323 stage: Stage::Downloading,
324 from,
325 to: Some(to),
326 parked_run: None,
327 parked_talks: Vec::new(),
328 started_at: now,
329 updated_at: now,
330 detail: None,
331 }
332 }
333
334 pub fn advance(&mut self, stage: Stage) {
336 self.stage = stage;
337 self.updated_at = Timestamp::now();
338 self.detail = None;
340 self.parked_talks.clear();
341 }
342
343 pub fn fail(&mut self, detail: impl Into<String>) {
345 self.stage = Stage::Failed;
346 self.updated_at = Timestamp::now();
347 self.detail = Some(detail.into());
348 }
349}
350
351#[must_use]
354pub fn progress_path(home: &Path) -> PathBuf {
355 home.join("upgrade.json")
356}
357
358static WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
361
362#[must_use]
371pub fn monotonic(current: Option<&Progress>, candidate: &Progress) -> Progress {
372 match current {
373 Some(cur)
374 if !cur.stage.terminal()
375 && !candidate.stage.terminal()
376 && candidate.stage.rank() <= cur.stage.rank() =>
377 {
378 let mut kept = cur.clone();
379 if candidate.to.is_some() {
380 kept.to.clone_from(&candidate.to);
381 }
382 kept
383 }
384 _ => candidate.clone(),
385 }
386}
387
388pub fn write_progress(home: &Path, progress: &Progress) -> Result<()> {
395 let _guard = WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
396 write_progress_locked(home, progress)
397}
398
399fn write_progress_locked(home: &Path, progress: &Progress) -> Result<()> {
400 let path = progress_path(home);
401 if let Some(parent) = path.parent() {
402 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
403 }
404 let to_write = monotonic(read_progress(home).as_ref(), progress);
405 store_progress(home, &to_write)
406}
407
408fn store_progress(home: &Path, progress: &Progress) -> Result<()> {
411 let path = progress_path(home);
412 let body = serde_json::to_string_pretty(progress).context("serialize upgrade progress")?;
413 let tmp = path.with_extension("json.tmp");
414 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
415 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
416 Ok(())
417}
418
419#[must_use]
421pub fn talks_phrase(talks: &[String]) -> String {
422 match talks {
423 [] => String::new(),
424 [one] => format!("chat turn {one} is"),
425 many => format!("chat turns {} are", many.join(", ")),
426 }
427}
428
429pub fn fail_progress(home: &Path, detail: &str) -> Result<bool> {
432 let _guard = WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
433 let Some(mut progress) = read_progress(home) else {
434 return Ok(false);
435 };
436 if progress.stage == Stage::Parking {
437 return Ok(false);
438 }
439 progress.fail(detail);
440 write_progress_locked(home, &progress)?;
441 Ok(true)
442}
443
444#[must_use]
446pub fn read_progress(home: &Path) -> Option<Progress> {
447 let body = std::fs::read_to_string(progress_path(home)).ok()?;
448 serde_json::from_str(&body).ok()
449}
450
451#[must_use]
454pub fn log_path(home: &Path) -> PathBuf {
455 home.join("upgrade.log")
456}
457
458pub const LOG_MAX_BYTES: u64 = 256 * 1024;
460
461pub const STALL_AFTER_SECS: i64 = 120;
463
464pub const LEASE_TTL_SECS: i64 = 90;
467
468pub const HEARTBEAT_SECS: i64 = 60;
470
471pub const WATCHDOG_POLL: Duration = Duration::from_secs(30);
473
474pub fn append_bounded(path: &Path, line: &str, max: u64) -> std::io::Result<()> {
478 use std::io::Write as _;
479 if std::fs::metadata(path).is_ok_and(|m| m.len() >= max) {
480 let mut old = path.as_os_str().to_owned();
481 old.push(".1");
482 std::fs::rename(path, PathBuf::from(old))?;
483 }
484 if let Some(parent) = path.parent() {
485 std::fs::create_dir_all(parent)?;
486 }
487 let mut file = std::fs::OpenOptions::new()
488 .create(true)
489 .append(true)
490 .open(path)?;
491 writeln!(file, "{line}")
492}
493
494pub fn log_step(home: &Path, msg: &str) {
498 tracing::info!("handover: {msg}");
499 log_line(home, "INFO", msg);
500}
501
502pub fn log_warn(home: &Path, msg: &str) {
504 tracing::warn!("handover: {msg}");
505 log_line(home, "WARN", msg);
506}
507
508fn log_line(home: &Path, level: &str, msg: &str) {
509 let line = format!(
510 "{} pid={} {level} {msg}",
511 Timestamp::now(),
512 std::process::id()
513 );
514 if let Err(e) = append_bounded(&log_path(home), &line, LOG_MAX_BYTES) {
515 tracing::warn!("could not append to {}: {e}", log_path(home).display());
516 }
517}
518
519pub fn write_progress_logged(home: &Path, progress: &Progress) {
523 if let Err(e) = write_progress(home, progress) {
524 log_warn(home, &format!("could not write upgrade.json: {e:#}"));
525 }
526}
527
528#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct HandoverLease {
532 pub entered_at: Timestamp,
534 pub beat_at: Timestamp,
536 #[serde(default)]
538 pub parked_run: Option<String>,
539}
540
541impl HandoverLease {
542 #[must_use]
544 pub fn fresh(&self, now: Timestamp) -> bool {
545 now.as_second() - self.beat_at.as_second() <= LEASE_TTL_SECS
546 }
547}
548
549#[must_use]
551pub fn lease_path(home: &Path) -> PathBuf {
552 home.join("upgrade.handover.json")
553}
554
555#[must_use]
557pub fn read_lease(home: &Path) -> Option<HandoverLease> {
558 let body = std::fs::read_to_string(lease_path(home)).ok()?;
559 serde_json::from_str(&body).ok()
560}
561
562#[cfg(test)]
563fn write_lease(home: &Path, lease: &HandoverLease) {
564 let path = lease_path(home);
565 let tmp = path.with_extension("json.tmp");
566 let written = serde_json::to_string(lease)
567 .map_err(std::io::Error::other)
568 .and_then(|body| std::fs::write(&tmp, body))
569 .and_then(|()| std::fs::rename(&tmp, &path));
570 if let Err(e) = written {
571 log_warn(home, &format!("could not write the handover lease: {e}"));
572 }
573}
574
575#[cfg(test)]
577#[derive(Debug)]
578pub struct LeaseGuard {
579 home: PathBuf,
580 lease: HandoverLease,
581}
582
583#[cfg(test)]
584impl LeaseGuard {
585 #[must_use]
587 pub fn enter(home: &Path, parked_run: Option<String>) -> Self {
588 let now = Timestamp::now();
589 let lease = HandoverLease {
590 entered_at: now,
591 beat_at: now,
592 parked_run,
593 };
594 write_lease(home, &lease);
595 Self {
596 home: home.to_owned(),
597 lease,
598 }
599 }
600
601 pub fn beat(&mut self) {
603 self.lease.beat_at = Timestamp::now();
604 write_lease(&self.home, &self.lease);
605 }
606}
607
608#[cfg(test)]
609impl Drop for LeaseGuard {
610 fn drop(&mut self) {
611 let _ = std::fs::remove_file(lease_path(&self.home));
612 }
613}
614
615#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
617#[serde(rename_all = "snake_case")]
618pub enum StallKind {
619 NeverEntered,
621 StoppedBeating,
624}
625
626#[derive(Debug, Clone, PartialEq, Eq)]
628pub struct Stall {
629 pub stage: Stage,
631 pub kind: StallKind,
633 pub age_secs: i64,
635 pub waiting_on: String,
637}
638
639#[must_use]
642pub fn stage_age_secs(progress: &Progress, now: Timestamp) -> i64 {
643 (now.as_second() - progress.updated_at.as_second()).max(0)
644}
645
646#[must_use]
648pub fn live_lease<'a>(
649 progress: &Progress,
650 lease: Option<&'a HandoverLease>,
651 now: Timestamp,
652) -> Option<&'a HandoverLease> {
653 lease.filter(|l| {
654 !progress.stage.terminal() && l.fresh(now) && l.entered_at >= progress.started_at
655 })
656}
657
658fn waiting_on(progress: &Progress, lease: Option<&HandoverLease>) -> String {
660 match progress.stage {
661 Stage::Replaced => "hand_over to start (HANDOVER was signalled; hand_over has left no \
662 record that it was entered)"
663 .to_owned(),
664 Stage::Parking => {
665 let run = lease
666 .and_then(|l| l.parked_run.as_ref())
667 .or(progress.parked_run.as_ref());
668 let talks = talks_phrase(&progress.parked_talks);
669 let talks = if talks.is_empty() {
670 String::new()
671 } else {
672 format!("{talks} still finishing; ")
673 };
674 match run {
675 Some(run) => {
676 format!("{talks}the loop to finish run {run} at its next node boundary")
677 }
678 None => format!("{talks}the loop to stop (no run was recorded as in flight)"),
679 }
680 }
681 Stage::Restarting => "spawn_successor returning and this process exiting".to_owned(),
682 Stage::Downloading => "the release download and binary replacement".to_owned(),
683 Stage::Done | Stage::Failed => String::new(),
684 }
685}
686
687#[must_use]
689pub fn waited_secs(lease: &HandoverLease, now: Timestamp) -> i64 {
690 (now.as_second() - lease.entered_at.as_second()).max(0)
691}
692
693#[must_use]
698pub fn stall(progress: &Progress, lease: Option<&HandoverLease>, now: Timestamp) -> Option<Stall> {
699 if progress.stage.terminal() || progress.stage == Stage::Downloading {
700 return None;
701 }
702 if live_lease(progress, lease, now).is_some() {
703 return None;
704 }
705 let stale = lease.filter(|l| l.entered_at >= progress.started_at);
707 let (kind, since) = match (progress.stage, stale) {
708 (_, Some(l)) => (StallKind::StoppedBeating, l.beat_at),
709 (Stage::Replaced, None) => (StallKind::NeverEntered, progress.updated_at),
710 _ => (StallKind::StoppedBeating, progress.updated_at),
711 };
712 let age_secs = (now.as_second() - since.as_second()).max(0);
713 let limit = if stale.is_some() {
714 LEASE_TTL_SECS
715 } else {
716 STALL_AFTER_SECS
717 };
718 (age_secs > limit).then(|| Stall {
719 stage: progress.stage,
720 kind,
721 age_secs,
722 waiting_on: waiting_on(progress, lease),
723 })
724}
725
726#[derive(Debug, Clone, PartialEq, Eq)]
728pub struct Beat {
729 pub stage: Stage,
731 pub warn: bool,
733 pub message: String,
735}
736
737#[derive(Debug, Default)]
739pub struct Watchdog {
740 last: Option<(Stage, Timestamp)>,
741}
742
743impl Watchdog {
744 pub fn tick(
747 &mut self,
748 progress: &Progress,
749 lease: Option<&HandoverLease>,
750 now: Timestamp,
751 ) -> Option<Beat> {
752 if progress.stage.terminal() {
753 self.last = None;
754 return None;
755 }
756 if self.last.is_some_and(|(stage, _)| stage != progress.stage) {
757 self.last = None;
758 }
759 let stalled = stall(progress, lease, now);
760 let alive = live_lease(progress, lease, now);
761 if stalled.is_none() && alive.is_none() && progress.stage != Stage::Parking {
764 return None;
765 }
766 if let Some((_, at)) = self.last
767 && now.as_second() - at.as_second() < HEARTBEAT_SECS
768 {
769 return None;
770 }
771 self.last = Some((progress.stage, now));
772 let age = alive.map_or_else(|| stage_age_secs(progress, now), |l| waited_secs(l, now));
773 let (warn, message) = match &stalled {
774 Some(s) => (
775 true,
776 format!(
777 "stuck in {:?} for {} min {} s without progress, waiting on {}",
778 s.stage,
779 s.age_secs / 60,
780 s.age_secs % 60,
781 s.waiting_on
782 ),
783 ),
784 None => (
785 false,
786 format!(
787 "parking for {} min {} s, waiting on {}",
788 age / 60,
789 age % 60,
790 waiting_on(progress, lease)
791 ),
792 ),
793 };
794 Some(Beat {
795 stage: progress.stage,
796 warn,
797 message,
798 })
799 }
800}
801
802#[derive(Debug, Serialize, Deserialize)]
805struct Note {
806 stage: Stage,
807 stage_since: Timestamp,
808 message: String,
809}
810
811fn note_path(home: &Path) -> PathBuf {
812 home.join("upgrade.note.json")
813}
814
815fn write_note(home: &Path, progress: &Progress, message: &str) {
816 let note = Note {
817 stage: progress.stage,
818 stage_since: progress.updated_at,
819 message: message.to_owned(),
820 };
821 let path = note_path(home);
822 let tmp = path.with_extension("json.tmp");
823 let written = serde_json::to_string(¬e)
824 .map_err(std::io::Error::other)
825 .and_then(|body| std::fs::write(&tmp, body))
826 .and_then(|()| std::fs::rename(&tmp, &path));
827 if let Err(e) = written {
828 tracing::warn!("could not write {}: {e}", path.display());
829 }
830}
831
832#[must_use]
835pub fn read_note(home: &Path, progress: &Progress) -> Option<String> {
836 let body = std::fs::read_to_string(note_path(home)).ok()?;
837 let note: Note = serde_json::from_str(&body).ok()?;
838 (note.stage == progress.stage && note.stage_since == progress.updated_at)
839 .then_some(note.message)
840}
841
842pub fn spawn_watchdog(home: PathBuf) {
846 let spawned = std::thread::Builder::new()
847 .name("upgrade-watchdog".to_owned())
848 .spawn(move || {
849 let mut dog = Watchdog::default();
850 loop {
851 std::thread::sleep(WATCHDOG_POLL);
852 let Some(progress) = read_progress(&home) else {
853 continue;
854 };
855 let lease = read_lease(&home);
856 let Some(beat) = dog.tick(&progress, lease.as_ref(), Timestamp::now()) else {
857 continue;
858 };
859 if beat.warn {
860 log_warn(&home, &beat.message);
861 } else {
862 log_step(&home, &beat.message);
863 }
864 write_note(&home, &progress, &beat.message);
869 }
870 });
871 if let Err(e) = spawned {
872 tracing::warn!("could not start the upgrade watchdog: {e}");
873 }
874}
875
876pub fn reconcile_after_restart(home: &Path) {
888 let Some(mut progress) = read_progress(home) else {
889 return;
890 };
891 let _ = std::fs::remove_file(lease_path(home));
893 if progress.stage.terminal() {
894 return;
895 }
896 let running = env!("CARGO_PKG_VERSION");
897 if progress
903 .to
904 .as_deref()
905 .is_some_and(|to| to.trim_start_matches('v') == running)
906 {
907 progress.advance(Stage::Done);
908 } else {
909 let to = progress
910 .to
911 .clone()
912 .unwrap_or_else(|| "the expected release".to_owned());
913 progress.fail(format!(
914 "this process came up on {running}, not {to} - the upgrade may \
915 not have replaced the binary"
916 ));
917 }
918 write_progress_logged(home, &progress);
919}
920
921pub fn spawn(cfg: &Update, rt: &tokio::runtime::Handle) -> Option<Pending> {
923 if disabled_by_env() || pinned_worker_generation() || cfg.mode == UpdateMode::Off {
924 return None;
925 }
926 let checker = Checker::new(cfg)?;
927 match cfg.mode {
928 UpdateMode::Off => None,
929 UpdateMode::Notify => {
930 if !checker.should_check() {
931 let latest = checker.cached_update()?;
932 return Some(Pending::Cached { checker, latest });
933 }
934 let inner = checker.inner.clone();
935 let handle = rt.spawn(async move { inner.check_and_save().await });
936 Some(Pending::Notify { checker, handle })
937 }
938 UpdateMode::Install => {
939 let inner = checker.inner.clone();
940 let handle = rt.spawn(async move { inner.auto_update().await });
941 Some(Pending::Install { handle })
942 }
943 }
944}
945
946pub async fn finalize(pending: Option<Pending>, budget: Duration) {
951 let Some(pending) = pending else {
952 return;
953 };
954 match pending {
955 Pending::Cached { checker, latest } => {
956 eprintln!("{}", checker.format_banner(&latest));
957 }
958 Pending::Notify { checker, handle } => {
959 if let Some(Ok(Some(latest))) = finalize_task(handle, budget).await {
960 eprintln!("{}", checker.format_banner(&latest));
961 }
962 }
963 Pending::Install { handle } => {
964 if let Some(Ok(Some(latest))) = finalize_task(handle, budget).await {
965 eprintln!("magi updated itself to {}", latest.tag_name);
966 }
967 }
968 }
969}
970
971async fn finalize_task<T>(handle: tokio::task::JoinHandle<T>, budget: Duration) -> Option<T> {
975 let (send, deadline) = tokio::sync::oneshot::channel();
976 if let Err(error) = std::thread::Builder::new()
977 .name("update-finalize-deadline".to_owned())
978 .spawn(move || {
979 std::thread::sleep(budget);
980 let _ = send.send(());
981 })
982 {
983 tracing::warn!("could not start release-check deadline: {error}");
984 return None;
985 }
986 tokio::select! {
987 result = handle => result.ok(),
988 _ = deadline => None,
989 }
990}
991
992#[cfg(test)]
993mod tests {
994 use super::*;
995
996 #[test]
1001 fn effective_interval_floors_a_configured_interval_below_githubs_rate_limit() {
1002 let cfg = Update {
1003 mode: UpdateMode::Notify,
1004 interval: Some("1s".to_owned()),
1005 };
1006 assert_eq!(
1007 effective_interval(&cfg),
1008 MIN_INTERVAL,
1009 "an interval that would exceed GitHub's rate limit under continuous \
1010 polling must be floored rather than honoured verbatim"
1011 );
1012
1013 let sane = Update {
1014 mode: UpdateMode::Notify,
1015 interval: Some("2h".to_owned()),
1016 };
1017 assert_eq!(
1018 effective_interval(&sane),
1019 Duration::from_secs(2 * 60 * 60),
1020 "an interval already above the floor must pass through unchanged"
1021 );
1022 }
1023
1024 #[test]
1025 fn env_kill_switch_semantics() {
1026 unsafe {
1028 std::env::remove_var(NO_AUTOUPDATE_ENV);
1029 }
1030 assert!(!disabled_by_env());
1031 for (value, disabled) in [
1032 ("1", true),
1033 ("true", true),
1034 ("yes", true),
1035 ("0", false),
1036 ("false", false),
1037 ("FALSE", false),
1038 ("", false),
1039 (" ", false),
1040 ] {
1041 unsafe {
1042 std::env::set_var(NO_AUTOUPDATE_ENV, value);
1043 }
1044 assert_eq!(
1045 disabled_by_env(),
1046 disabled,
1047 "MAGI_NO_AUTOUPDATE={value:?} should {} disable",
1048 if disabled { "" } else { "not" }
1049 );
1050 }
1051 unsafe {
1052 std::env::remove_var(NO_AUTOUPDATE_ENV);
1053 }
1054 }
1055
1056 #[test]
1057 fn off_mode_never_spawns() {
1058 let rt = tokio::runtime::Builder::new_current_thread()
1059 .enable_all()
1060 .build()
1061 .unwrap();
1062 let cfg = Update {
1063 mode: UpdateMode::Off,
1064 interval: None,
1065 };
1066 assert!(spawn(&cfg, rt.handle()).is_none());
1067 }
1068
1069 #[test]
1070 fn state_path_lives_under_the_cache_dir() {
1071 let path = state_path().expect("a cache dir on every supported platform");
1072 assert!(path.ends_with("magi/last_update_check.json"));
1073 let data = dirs::data_local_dir().unwrap_or_default();
1074 assert!(
1075 !path.starts_with(&data) || dirs::cache_dir() == dirs::data_local_dir(),
1076 "throttle state must not sit in the run history directory"
1077 );
1078 }
1079
1080 #[tokio::test]
1081 async fn finalize_of_nothing_is_a_no_op() {
1082 finalize(None, Duration::from_millis(1)).await;
1083 }
1084
1085 #[tokio::test]
1086 async fn finalize_task_preserves_a_timely_result() {
1087 let handle = tokio::spawn(async { 42 });
1088 assert_eq!(
1089 finalize_task(handle, Duration::from_secs(1)).await,
1090 Some(42)
1091 );
1092 }
1093
1094 #[test]
1104 fn checking_is_off_for_every_caller_when_the_config_says_off() {
1105 assert!(
1106 Checker::new(&Update {
1107 mode: UpdateMode::Off,
1108 interval: None,
1109 })
1110 .is_none(),
1111 "an operator who writes mode = \"off\" means it"
1112 );
1113 for mode in [UpdateMode::Notify, UpdateMode::Install] {
1114 assert!(
1115 Checker::new(&Update {
1116 mode,
1117 interval: None,
1118 })
1119 .is_some(),
1120 "{mode:?} still asks the forge"
1121 );
1122 }
1123 }
1124
1125 #[test]
1136 fn cached_update_answers_from_disk_with_no_network_call() {
1137 let dir = tempfile::tempdir().expect("temp dir");
1138 let path = dir.path().join("state.json");
1139 let opts = kaishin::KaishinOptions::new("yukimemi", "magi", "magi", "0.1.0");
1140 let checker = Checker {
1141 inner: kaishin::Checker::new("magi", opts).state_path(path.clone()),
1142 };
1143
1144 assert!(
1145 checker.cached_update().is_none(),
1146 "no state file yet must read as \"unknown\", not an error"
1147 );
1148
1149 let state = kaishin::UpdateCheckState {
1150 last_checked_unix: 0,
1151 last_known_latest: Some("v9.9.9".to_owned()),
1152 last_known_url: Some("https://example.invalid/9.9.9".to_owned()),
1153 };
1154 kaishin::save_check_state(&path, &state).expect("seed the state file");
1155
1156 let latest = checker.cached_update().expect("a newer release was cached");
1157 assert_eq!(latest.tag_name, "v9.9.9");
1158 }
1159
1160 #[test]
1161 fn reconcile_after_restart_confirms_a_matching_version() {
1162 let home = tempfile::tempdir().expect("temp home");
1167 let mut progress = Progress::new(
1168 "0.1.0".to_owned(),
1169 format!("v{}", env!("CARGO_PKG_VERSION")),
1170 );
1171 progress.advance(Stage::Restarting);
1172 write_progress(home.path(), &progress).expect("seed progress");
1173
1174 reconcile_after_restart(home.path());
1175
1176 let after = read_progress(home.path()).expect("progress on disk");
1177 assert_eq!(
1178 after.stage,
1179 Stage::Done,
1180 "the successor is running exactly the release that was asked for, \
1181 `v` prefix and all"
1182 );
1183 }
1184
1185 #[test]
1186 fn reconcile_after_restart_flags_a_mismatched_version() {
1187 let home = tempfile::tempdir().expect("temp home");
1188 let mut progress = Progress::new("0.1.0".to_owned(), "v9.9.9".to_owned());
1189 progress.advance(Stage::Restarting);
1190 write_progress(home.path(), &progress).expect("seed progress");
1191
1192 reconcile_after_restart(home.path());
1193
1194 let after = read_progress(home.path()).expect("progress on disk");
1195 assert_eq!(after.stage, Stage::Failed);
1196 assert!(
1197 after.detail.is_some_and(|d| d.contains("9.9.9")),
1198 "the operator needs to know which release it did not come back on"
1199 );
1200 }
1201
1202 #[test]
1203 fn reconcile_after_restart_leaves_a_settled_record_alone() {
1204 let home = tempfile::tempdir().expect("temp home");
1205 let mut progress = Progress::new("0.1.0".to_owned(), "9.9.9".to_owned());
1206 progress.advance(Stage::Done);
1207 write_progress(home.path(), &progress).expect("seed progress");
1208
1209 reconcile_after_restart(home.path());
1210
1211 let after = read_progress(home.path()).expect("progress on disk");
1212 assert_eq!(
1213 after.stage,
1214 Stage::Done,
1215 "an already-settled record must not be rewritten by a later, unrelated start"
1216 );
1217 }
1218
1219 #[test]
1220 fn reconcile_after_restart_with_nothing_on_disk_is_a_quiet_no_op() {
1221 let home = tempfile::tempdir().expect("temp home");
1222 reconcile_after_restart(home.path());
1223 assert!(read_progress(home.path()).is_none());
1224 }
1225
1226 fn at(secs: i64) -> Timestamp {
1227 Timestamp::from_second(secs).expect("timestamp")
1228 }
1229
1230 fn staged(stage: Stage, since: i64) -> Progress {
1231 let mut p = Progress::new("0.1.0".to_owned(), "v0.2.0".to_owned());
1232 p.stage = stage;
1233 p.started_at = at(since);
1234 p.updated_at = at(since);
1235 p
1236 }
1237
1238 fn lease(entered: i64, beat: i64) -> HandoverLease {
1239 HandoverLease {
1240 entered_at: at(entered),
1241 beat_at: at(beat),
1242 parked_run: Some("r1".to_owned()),
1243 }
1244 }
1245
1246 #[test]
1247 fn a_handover_never_entered_is_stuck_and_says_only_what_is_known() {
1248 let p = staged(Stage::Replaced, 1000);
1249 assert!(stall(&p, None, at(1000 + STALL_AFTER_SECS)).is_none());
1250 let s = stall(&p, None, at(1000 + STALL_AFTER_SECS + 1)).expect("stalled");
1251 assert_eq!(s.stage, Stage::Replaced);
1252 assert_eq!(s.kind, StallKind::NeverEntered);
1253 assert_eq!(s.age_secs, STALL_AFTER_SECS + 1);
1254 assert!(s.waiting_on.contains("hand_over"), "{}", s.waiting_on);
1255
1256 let p = staged(Stage::Restarting, 1000);
1257 let s = stall(&p, None, at(1000 + STALL_AFTER_SECS + 1)).expect("stalled");
1258 assert_eq!(s.kind, StallKind::StoppedBeating);
1259
1260 for stage in [Stage::Done, Stage::Failed, Stage::Downloading] {
1261 assert!(stall(&staged(stage, 0), None, at(1_000_000)).is_none());
1262 }
1263 }
1264
1265 #[test]
1266 fn a_live_parking_wait_is_never_stuck_however_long_it_lasts() {
1267 let mut p = staged(Stage::Parking, 1000);
1268 p.started_at = at(900);
1269 let hours = 5 * 3600;
1270 let l = lease(1000, 1000 + hours);
1271 assert!(stall(&p, Some(&l), at(1000 + hours + 10)).is_none());
1272 let r = staged(Stage::Replaced, 1000);
1274 assert!(stall(&r, Some(&l), at(1000 + hours + 10)).is_none());
1275 let s = stall(&p, Some(&l), at(1000 + hours + LEASE_TTL_SECS + 1)).expect("stuck");
1277 assert_eq!(s.kind, StallKind::StoppedBeating);
1278 }
1279
1280 #[test]
1281 fn a_lease_from_an_earlier_upgrade_proves_nothing() {
1282 let p = staged(Stage::Replaced, 2000);
1283 let old = lease(10, 3000);
1284 assert!(live_lease(&p, Some(&old), at(3001)).is_none());
1285 }
1286
1287 #[test]
1288 fn a_stage_never_goes_backwards_but_a_new_upgrade_after_a_terminal_one_starts() {
1289 let parking = staged(Stage::Parking, 1000);
1290 for back in [Stage::Replaced, Stage::Downloading, Stage::Parking] {
1291 let mut cand = staged(back, 5000);
1292 cand.to = Some("v9.9.9".to_owned());
1293 let kept = monotonic(Some(&parking), &cand);
1294 assert_eq!(kept.stage, Stage::Parking);
1295 assert_eq!(kept.updated_at, at(1000));
1296 assert_eq!(kept.started_at, parking.started_at);
1297 assert_eq!(kept.to.as_deref(), Some("v9.9.9"), "data is refreshed");
1298 }
1299 assert_eq!(
1300 monotonic(Some(&parking), &staged(Stage::Restarting, 5000)).stage,
1301 Stage::Restarting
1302 );
1303 assert_eq!(
1304 monotonic(Some(&parking), &staged(Stage::Failed, 5000)).stage,
1305 Stage::Failed
1306 );
1307 let done = staged(Stage::Done, 1000);
1308 assert_eq!(
1309 monotonic(Some(&done), &staged(Stage::Downloading, 5000)).stage,
1310 Stage::Downloading
1311 );
1312 }
1313
1314 #[test]
1315 fn write_progress_refuses_a_regression_on_disk() {
1316 let home = tempfile::tempdir().expect("temp home");
1317 write_progress(home.path(), &staged(Stage::Parking, 1000)).expect("write");
1318 write_progress(home.path(), &staged(Stage::Replaced, 5000)).expect("write");
1319 let on_disk = read_progress(home.path()).expect("record");
1320 assert_eq!(on_disk.stage, Stage::Parking);
1321 assert_eq!(on_disk.updated_at, at(1000));
1322 }
1323
1324 #[test]
1325 fn a_failed_request_cannot_overwrite_a_live_handover() {
1326 let home = tempfile::tempdir().expect("temp home");
1327 write_progress(home.path(), &staged(Stage::Parking, 1000)).expect("write");
1328 assert!(!fail_progress(home.path(), "boom").expect("fail"));
1329 assert_eq!(read_progress(home.path()).unwrap().stage, Stage::Parking);
1330 write_progress(home.path(), &staged(Stage::Replaced, 1000)).ok();
1331 let fresh = tempfile::tempdir().expect("temp home");
1332 write_progress(fresh.path(), &staged(Stage::Downloading, 1000)).expect("write");
1333 assert!(fail_progress(fresh.path(), "boom").expect("fail"));
1334 assert_eq!(read_progress(fresh.path()).unwrap().stage, Stage::Failed);
1335 }
1336
1337 #[test]
1338 fn a_failed_detached_restart_reports_the_failure() {
1339 let home = tempfile::tempdir().expect("temp home");
1340 write_progress(home.path(), &staged(Stage::Restarting, 1000)).expect("write");
1341 assert!(fail_progress(home.path(), "successor spawn failed").expect("fail"));
1342 let progress = read_progress(home.path()).expect("progress");
1343 assert_eq!(progress.stage, Stage::Failed);
1344 assert_eq!(progress.detail.as_deref(), Some("successor spawn failed"));
1345 }
1346
1347 #[test]
1348 fn the_lease_guard_writes_beats_and_removes_the_lease() {
1349 let home = tempfile::tempdir().expect("temp home");
1350 {
1351 let mut guard = LeaseGuard::enter(home.path(), Some("r1".to_owned()));
1352 let first = read_lease(home.path()).expect("lease");
1353 assert_eq!(first.parked_run.as_deref(), Some("r1"));
1354 guard.beat();
1355 assert!(read_lease(home.path()).is_some());
1356 }
1357 assert!(read_lease(home.path()).is_none());
1358 }
1359
1360 #[test]
1361 fn a_clock_that_went_backwards_is_age_zero() {
1362 let p = staged(Stage::Replaced, 5000);
1363 assert_eq!(stage_age_secs(&p, at(100)), 0);
1364 assert!(stall(&p, None, at(100)).is_none());
1365 }
1366
1367 #[test]
1368 fn a_note_is_kept_beside_the_record_and_matches_only_its_own_stage() {
1369 let home = tempfile::tempdir().expect("temp home");
1370 let p = staged(Stage::Replaced, 1000);
1371 write_progress(home.path(), &p).expect("write");
1372 write_note(home.path(), &p, "stuck");
1373 assert_eq!(read_note(home.path(), &p).as_deref(), Some("stuck"));
1374 assert_eq!(read_progress(home.path()).unwrap().updated_at, at(1000));
1375 assert!(read_note(home.path(), &staged(Stage::Parking, 1000)).is_none());
1376 assert!(read_note(home.path(), &staged(Stage::Replaced, 2000)).is_none());
1377 }
1378
1379 #[test]
1380 fn the_watchdog_speaks_once_a_minute_and_resets_on_a_new_stage() {
1381 let mut dog = Watchdog::default();
1382 let p = staged(Stage::Replaced, 1000);
1383 assert!(dog.tick(&p, None, at(1060)).is_none(), "not stalled yet");
1384 let beat = dog.tick(&p, None, at(1200)).expect("stalled");
1385 assert!(beat.warn);
1386 assert!(dog.tick(&p, None, at(1230)).is_none(), "spoke 30 s ago");
1387 assert!(dog.tick(&p, None, at(1260)).is_some(), "a minute later");
1388
1389 let parking = staged(Stage::Parking, 1260);
1390 let l = lease(1260, 1270);
1391 let beat = dog
1392 .tick(&parking, Some(&l), at(1275))
1393 .expect("parking heartbeat at once");
1394 assert!(!beat.warn, "a live wait is not a warning");
1395 assert!(dog.tick(&parking, Some(&l), at(1300)).is_none());
1396 let l = lease(1260, 1260 + 4 * 3600);
1397 let later = dog
1398 .tick(&parking, Some(&l), at(1260 + 4 * 3600 + 5))
1399 .expect("heartbeat");
1400 assert!(!later.warn, "hours of waiting on a run is still not stuck");
1401 assert!(later.message.contains("r1"), "{}", later.message);
1402
1403 let done = staged(Stage::Done, 0);
1404 assert!(dog.tick(&done, None, at(9_999_999)).is_none());
1405 }
1406
1407 #[test]
1408 fn the_upgrade_log_appends_and_rotates_to_one_generation() {
1409 let dir = tempfile::tempdir().expect("temp dir");
1410 let path = dir.path().join("upgrade.log");
1411 append_bounded(&path, "one", 16).expect("append");
1412 append_bounded(&path, "two", 16).expect("append");
1413 assert_eq!(std::fs::read_to_string(&path).unwrap(), "one\ntwo\n");
1414 append_bounded(&path, "three-and-more", 16).expect("append");
1415 append_bounded(&path, "four", 16).expect("append");
1416 assert_eq!(std::fs::read_to_string(&path).unwrap(), "four\n");
1417 let old = dir.path().join("upgrade.log.1");
1418 assert!(
1419 std::fs::read_to_string(old)
1420 .unwrap()
1421 .contains("three-and-more")
1422 );
1423 }
1424}