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, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
287#[serde(rename_all = "lowercase")]
288pub enum UpgradeTrigger {
289 #[default]
291 Manual,
292 Auto,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct Progress {
307 #[serde(default)]
309 pub trigger: UpgradeTrigger,
310 pub stage: Stage,
312 pub from: String,
314 pub to: Option<String>,
316 #[serde(default)]
318 pub parked_run: Option<String>,
319 #[serde(default)]
321 pub parked_talks: Vec<String>,
322 pub started_at: Timestamp,
324 pub updated_at: Timestamp,
326 #[serde(default)]
328 pub detail: Option<String>,
329}
330
331impl Progress {
332 #[must_use]
334 pub fn new(from: String, to: String) -> Self {
335 let now = Timestamp::now();
336 Self {
337 trigger: UpgradeTrigger::Manual,
338 stage: Stage::Downloading,
339 from,
340 to: Some(to),
341 parked_run: None,
342 parked_talks: Vec::new(),
343 started_at: now,
344 updated_at: now,
345 detail: None,
346 }
347 }
348
349 pub fn advance(&mut self, stage: Stage) {
351 self.stage = stage;
352 self.updated_at = Timestamp::now();
353 self.detail = None;
355 self.parked_talks.clear();
356 }
357
358 pub fn fail(&mut self, detail: impl Into<String>) {
360 self.stage = Stage::Failed;
361 self.updated_at = Timestamp::now();
362 self.detail = Some(detail.into());
363 }
364}
365
366#[must_use]
369pub fn progress_path(home: &Path) -> PathBuf {
370 home.join("upgrade.json")
371}
372
373static WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
376
377#[must_use]
386pub fn monotonic(current: Option<&Progress>, candidate: &Progress) -> Progress {
387 match current {
388 Some(cur)
389 if !cur.stage.terminal()
390 && !candidate.stage.terminal()
391 && candidate.stage.rank() <= cur.stage.rank() =>
392 {
393 let mut kept = cur.clone();
394 if candidate.to.is_some() {
395 kept.to.clone_from(&candidate.to);
396 }
397 kept
398 }
399 _ => candidate.clone(),
400 }
401}
402
403pub fn write_progress(home: &Path, progress: &Progress) -> Result<()> {
410 let _guard = WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
411 write_progress_locked(home, progress)
412}
413
414fn write_progress_locked(home: &Path, progress: &Progress) -> Result<()> {
415 let path = progress_path(home);
416 if let Some(parent) = path.parent() {
417 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
418 }
419 let to_write = monotonic(read_progress(home).as_ref(), progress);
420 store_progress(home, &to_write)
421}
422
423fn store_progress(home: &Path, progress: &Progress) -> Result<()> {
426 let path = progress_path(home);
427 let body = serde_json::to_string_pretty(progress).context("serialize upgrade progress")?;
428 let tmp = path.with_extension("json.tmp");
429 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
430 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
431 Ok(())
432}
433
434#[must_use]
436pub fn talks_phrase(talks: &[String]) -> String {
437 match talks {
438 [] => String::new(),
439 [one] => format!("chat turn {one} is"),
440 many => format!("chat turns {} are", many.join(", ")),
441 }
442}
443
444pub fn fail_progress(home: &Path, detail: &str) -> Result<bool> {
447 let _guard = WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
448 let Some(mut progress) = read_progress(home) else {
449 return Ok(false);
450 };
451 if progress.stage == Stage::Parking {
452 return Ok(false);
453 }
454 progress.fail(detail);
455 write_progress_locked(home, &progress)?;
456 Ok(true)
457}
458
459#[must_use]
461pub fn read_progress(home: &Path) -> Option<Progress> {
462 let body = std::fs::read_to_string(progress_path(home)).ok()?;
463 serde_json::from_str(&body).ok()
464}
465
466#[must_use]
469pub fn log_path(home: &Path) -> PathBuf {
470 home.join("upgrade.log")
471}
472
473pub const LOG_MAX_BYTES: u64 = 256 * 1024;
475
476pub const STALL_AFTER_SECS: i64 = 120;
478
479pub const LEASE_TTL_SECS: i64 = 90;
482
483pub const HEARTBEAT_SECS: i64 = 60;
485
486pub const WATCHDOG_POLL: Duration = Duration::from_secs(30);
488
489pub fn append_bounded(path: &Path, line: &str, max: u64) -> std::io::Result<()> {
493 use std::io::Write as _;
494 if std::fs::metadata(path).is_ok_and(|m| m.len() >= max) {
495 let mut old = path.as_os_str().to_owned();
496 old.push(".1");
497 std::fs::rename(path, PathBuf::from(old))?;
498 }
499 if let Some(parent) = path.parent() {
500 std::fs::create_dir_all(parent)?;
501 }
502 let mut file = std::fs::OpenOptions::new()
503 .create(true)
504 .append(true)
505 .open(path)?;
506 writeln!(file, "{line}")
507}
508
509pub fn log_step(home: &Path, msg: &str) {
513 tracing::info!("handover: {msg}");
514 log_line(home, "INFO", msg);
515}
516
517pub fn log_warn(home: &Path, msg: &str) {
519 tracing::warn!("handover: {msg}");
520 log_line(home, "WARN", msg);
521}
522
523fn log_line(home: &Path, level: &str, msg: &str) {
524 let line = format!(
525 "{} pid={} {level} {msg}",
526 Timestamp::now(),
527 std::process::id()
528 );
529 if let Err(e) = append_bounded(&log_path(home), &line, LOG_MAX_BYTES) {
530 tracing::warn!("could not append to {}: {e}", log_path(home).display());
531 }
532}
533
534pub fn write_progress_logged(home: &Path, progress: &Progress) {
538 if let Err(e) = write_progress(home, progress) {
539 log_warn(home, &format!("could not write upgrade.json: {e:#}"));
540 }
541}
542
543#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct HandoverLease {
547 pub entered_at: Timestamp,
549 pub beat_at: Timestamp,
551 #[serde(default)]
553 pub parked_run: Option<String>,
554}
555
556impl HandoverLease {
557 #[must_use]
559 pub fn fresh(&self, now: Timestamp) -> bool {
560 now.as_second() - self.beat_at.as_second() <= LEASE_TTL_SECS
561 }
562}
563
564#[must_use]
566pub fn lease_path(home: &Path) -> PathBuf {
567 home.join("upgrade.handover.json")
568}
569
570#[must_use]
572pub fn read_lease(home: &Path) -> Option<HandoverLease> {
573 let body = std::fs::read_to_string(lease_path(home)).ok()?;
574 serde_json::from_str(&body).ok()
575}
576
577#[cfg(test)]
578fn write_lease(home: &Path, lease: &HandoverLease) {
579 let path = lease_path(home);
580 let tmp = path.with_extension("json.tmp");
581 let written = serde_json::to_string(lease)
582 .map_err(std::io::Error::other)
583 .and_then(|body| std::fs::write(&tmp, body))
584 .and_then(|()| std::fs::rename(&tmp, &path));
585 if let Err(e) = written {
586 log_warn(home, &format!("could not write the handover lease: {e}"));
587 }
588}
589
590#[cfg(test)]
592#[derive(Debug)]
593pub struct LeaseGuard {
594 home: PathBuf,
595 lease: HandoverLease,
596}
597
598#[cfg(test)]
599impl LeaseGuard {
600 #[must_use]
602 pub fn enter(home: &Path, parked_run: Option<String>) -> Self {
603 let now = Timestamp::now();
604 let lease = HandoverLease {
605 entered_at: now,
606 beat_at: now,
607 parked_run,
608 };
609 write_lease(home, &lease);
610 Self {
611 home: home.to_owned(),
612 lease,
613 }
614 }
615
616 pub fn beat(&mut self) {
618 self.lease.beat_at = Timestamp::now();
619 write_lease(&self.home, &self.lease);
620 }
621}
622
623#[cfg(test)]
624impl Drop for LeaseGuard {
625 fn drop(&mut self) {
626 let _ = std::fs::remove_file(lease_path(&self.home));
627 }
628}
629
630#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
632#[serde(rename_all = "snake_case")]
633pub enum StallKind {
634 NeverEntered,
636 StoppedBeating,
639}
640
641#[derive(Debug, Clone, PartialEq, Eq)]
643pub struct Stall {
644 pub stage: Stage,
646 pub kind: StallKind,
648 pub age_secs: i64,
650 pub waiting_on: String,
652}
653
654#[must_use]
657pub fn stage_age_secs(progress: &Progress, now: Timestamp) -> i64 {
658 (now.as_second() - progress.updated_at.as_second()).max(0)
659}
660
661#[must_use]
663pub fn live_lease<'a>(
664 progress: &Progress,
665 lease: Option<&'a HandoverLease>,
666 now: Timestamp,
667) -> Option<&'a HandoverLease> {
668 lease.filter(|l| {
669 !progress.stage.terminal() && l.fresh(now) && l.entered_at >= progress.started_at
670 })
671}
672
673fn waiting_on(progress: &Progress, lease: Option<&HandoverLease>) -> String {
675 match progress.stage {
676 Stage::Replaced => "hand_over to start (HANDOVER was signalled; hand_over has left no \
677 record that it was entered)"
678 .to_owned(),
679 Stage::Parking => {
680 let run = lease
681 .and_then(|l| l.parked_run.as_ref())
682 .or(progress.parked_run.as_ref());
683 let talks = talks_phrase(&progress.parked_talks);
684 let talks = if talks.is_empty() {
685 String::new()
686 } else {
687 format!("{talks} still finishing; ")
688 };
689 match run {
690 Some(run) => {
691 format!("{talks}the loop to finish run {run} at its next node boundary")
692 }
693 None => format!("{talks}the loop to stop (no run was recorded as in flight)"),
694 }
695 }
696 Stage::Restarting => "spawn_successor returning and this process exiting".to_owned(),
697 Stage::Downloading => "the release download and binary replacement".to_owned(),
698 Stage::Done | Stage::Failed => String::new(),
699 }
700}
701
702#[must_use]
704pub fn waited_secs(lease: &HandoverLease, now: Timestamp) -> i64 {
705 (now.as_second() - lease.entered_at.as_second()).max(0)
706}
707
708#[must_use]
713pub fn stall(progress: &Progress, lease: Option<&HandoverLease>, now: Timestamp) -> Option<Stall> {
714 if progress.stage.terminal() || progress.stage == Stage::Downloading {
715 return None;
716 }
717 if live_lease(progress, lease, now).is_some() {
718 return None;
719 }
720 let stale = lease.filter(|l| l.entered_at >= progress.started_at);
722 let (kind, since) = match (progress.stage, stale) {
723 (_, Some(l)) => (StallKind::StoppedBeating, l.beat_at),
724 (Stage::Replaced, None) => (StallKind::NeverEntered, progress.updated_at),
725 _ => (StallKind::StoppedBeating, progress.updated_at),
726 };
727 let age_secs = (now.as_second() - since.as_second()).max(0);
728 let limit = if stale.is_some() {
729 LEASE_TTL_SECS
730 } else {
731 STALL_AFTER_SECS
732 };
733 (age_secs > limit).then(|| Stall {
734 stage: progress.stage,
735 kind,
736 age_secs,
737 waiting_on: waiting_on(progress, lease),
738 })
739}
740
741#[derive(Debug, Clone, PartialEq, Eq)]
743pub struct Beat {
744 pub stage: Stage,
746 pub warn: bool,
748 pub message: String,
750}
751
752#[derive(Debug, Default)]
754pub struct Watchdog {
755 last: Option<(Stage, Timestamp)>,
756}
757
758impl Watchdog {
759 pub fn tick(
762 &mut self,
763 progress: &Progress,
764 lease: Option<&HandoverLease>,
765 now: Timestamp,
766 ) -> Option<Beat> {
767 if progress.stage.terminal() {
768 self.last = None;
769 return None;
770 }
771 if self.last.is_some_and(|(stage, _)| stage != progress.stage) {
772 self.last = None;
773 }
774 let stalled = stall(progress, lease, now);
775 let alive = live_lease(progress, lease, now);
776 if stalled.is_none() && alive.is_none() && progress.stage != Stage::Parking {
779 return None;
780 }
781 if let Some((_, at)) = self.last
782 && now.as_second() - at.as_second() < HEARTBEAT_SECS
783 {
784 return None;
785 }
786 self.last = Some((progress.stage, now));
787 let age = alive.map_or_else(|| stage_age_secs(progress, now), |l| waited_secs(l, now));
788 let (warn, message) = match &stalled {
789 Some(s) => (
790 true,
791 format!(
792 "stuck in {:?} for {} min {} s without progress, waiting on {}",
793 s.stage,
794 s.age_secs / 60,
795 s.age_secs % 60,
796 s.waiting_on
797 ),
798 ),
799 None => (
800 false,
801 format!(
802 "parking for {} min {} s, waiting on {}",
803 age / 60,
804 age % 60,
805 waiting_on(progress, lease)
806 ),
807 ),
808 };
809 Some(Beat {
810 stage: progress.stage,
811 warn,
812 message,
813 })
814 }
815}
816
817#[derive(Debug, Serialize, Deserialize)]
820struct Note {
821 stage: Stage,
822 stage_since: Timestamp,
823 message: String,
824}
825
826fn note_path(home: &Path) -> PathBuf {
827 home.join("upgrade.note.json")
828}
829
830fn write_note(home: &Path, progress: &Progress, message: &str) {
831 let note = Note {
832 stage: progress.stage,
833 stage_since: progress.updated_at,
834 message: message.to_owned(),
835 };
836 let path = note_path(home);
837 let tmp = path.with_extension("json.tmp");
838 let written = serde_json::to_string(¬e)
839 .map_err(std::io::Error::other)
840 .and_then(|body| std::fs::write(&tmp, body))
841 .and_then(|()| std::fs::rename(&tmp, &path));
842 if let Err(e) = written {
843 tracing::warn!("could not write {}: {e}", path.display());
844 }
845}
846
847#[must_use]
850pub fn read_note(home: &Path, progress: &Progress) -> Option<String> {
851 let body = std::fs::read_to_string(note_path(home)).ok()?;
852 let note: Note = serde_json::from_str(&body).ok()?;
853 (note.stage == progress.stage && note.stage_since == progress.updated_at)
854 .then_some(note.message)
855}
856
857pub fn spawn_watchdog(home: PathBuf) {
861 let spawned = std::thread::Builder::new()
862 .name("upgrade-watchdog".to_owned())
863 .spawn(move || {
864 let mut dog = Watchdog::default();
865 loop {
866 std::thread::sleep(WATCHDOG_POLL);
867 let Some(progress) = read_progress(&home) else {
868 continue;
869 };
870 let lease = read_lease(&home);
871 let Some(beat) = dog.tick(&progress, lease.as_ref(), Timestamp::now()) else {
872 continue;
873 };
874 if beat.warn {
875 log_warn(&home, &beat.message);
876 } else {
877 log_step(&home, &beat.message);
878 }
879 write_note(&home, &progress, &beat.message);
884 }
885 });
886 if let Err(e) = spawned {
887 tracing::warn!("could not start the upgrade watchdog: {e}");
888 }
889}
890
891pub fn reconcile_after_restart(home: &Path) {
903 let Some(mut progress) = read_progress(home) else {
904 return;
905 };
906 let _ = std::fs::remove_file(lease_path(home));
908 if progress.stage.terminal() {
909 return;
910 }
911 let running = env!("CARGO_PKG_VERSION");
912 if progress
918 .to
919 .as_deref()
920 .is_some_and(|to| to.trim_start_matches('v') == running)
921 {
922 progress.advance(Stage::Done);
923 } else {
924 let to = progress
925 .to
926 .clone()
927 .unwrap_or_else(|| "the expected release".to_owned());
928 progress.fail(format!(
929 "this process came up on {running}, not {to} - the upgrade may \
930 not have replaced the binary"
931 ));
932 }
933 write_progress_logged(home, &progress);
934}
935
936pub fn spawn(cfg: &Update, rt: &tokio::runtime::Handle) -> Option<Pending> {
938 if disabled_by_env() || pinned_worker_generation() || cfg.mode == UpdateMode::Off {
939 return None;
940 }
941 let checker = Checker::new(cfg)?;
942 match cfg.mode {
943 UpdateMode::Off => None,
944 UpdateMode::Notify => {
945 if !checker.should_check() {
946 let latest = checker.cached_update()?;
947 return Some(Pending::Cached { checker, latest });
948 }
949 let inner = checker.inner.clone();
950 let handle = rt.spawn(async move { inner.check_and_save().await });
951 Some(Pending::Notify { checker, handle })
952 }
953 UpdateMode::Install => {
954 let inner = checker.inner.clone();
955 let handle = rt.spawn(async move { inner.auto_update().await });
956 Some(Pending::Install { handle })
957 }
958 }
959}
960
961pub async fn finalize(pending: Option<Pending>, budget: Duration) {
966 let Some(pending) = pending else {
967 return;
968 };
969 match pending {
970 Pending::Cached { checker, latest } => {
971 eprintln!("{}", checker.format_banner(&latest));
972 }
973 Pending::Notify { checker, handle } => {
974 if let Some(Ok(Some(latest))) = finalize_task(handle, budget).await {
975 eprintln!("{}", checker.format_banner(&latest));
976 }
977 }
978 Pending::Install { handle } => {
979 if let Some(Ok(Some(latest))) = finalize_task(handle, budget).await {
980 eprintln!("magi updated itself to {}", latest.tag_name);
981 }
982 }
983 }
984}
985
986async fn finalize_task<T>(handle: tokio::task::JoinHandle<T>, budget: Duration) -> Option<T> {
990 let (send, deadline) = tokio::sync::oneshot::channel();
991 if let Err(error) = std::thread::Builder::new()
992 .name("update-finalize-deadline".to_owned())
993 .spawn(move || {
994 std::thread::sleep(budget);
995 let _ = send.send(());
996 })
997 {
998 tracing::warn!("could not start release-check deadline: {error}");
999 return None;
1000 }
1001 tokio::select! {
1002 result = handle => result.ok(),
1003 _ = deadline => None,
1004 }
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009 use super::*;
1010
1011 #[test]
1016 fn effective_interval_floors_a_configured_interval_below_githubs_rate_limit() {
1017 let cfg = Update {
1018 mode: UpdateMode::Notify,
1019 interval: Some("1s".to_owned()),
1020 auto_restart: false,
1021 };
1022 assert_eq!(
1023 effective_interval(&cfg),
1024 MIN_INTERVAL,
1025 "an interval that would exceed GitHub's rate limit under continuous \
1026 polling must be floored rather than honoured verbatim"
1027 );
1028
1029 let sane = Update {
1030 mode: UpdateMode::Notify,
1031 interval: Some("2h".to_owned()),
1032 auto_restart: false,
1033 };
1034 assert_eq!(
1035 effective_interval(&sane),
1036 Duration::from_secs(2 * 60 * 60),
1037 "an interval already above the floor must pass through unchanged"
1038 );
1039 }
1040
1041 #[test]
1042 fn env_kill_switch_semantics() {
1043 unsafe {
1045 std::env::remove_var(NO_AUTOUPDATE_ENV);
1046 }
1047 assert!(!disabled_by_env());
1048 for (value, disabled) in [
1049 ("1", true),
1050 ("true", true),
1051 ("yes", true),
1052 ("0", false),
1053 ("false", false),
1054 ("FALSE", false),
1055 ("", false),
1056 (" ", false),
1057 ] {
1058 unsafe {
1059 std::env::set_var(NO_AUTOUPDATE_ENV, value);
1060 }
1061 assert_eq!(
1062 disabled_by_env(),
1063 disabled,
1064 "MAGI_NO_AUTOUPDATE={value:?} should {} disable",
1065 if disabled { "" } else { "not" }
1066 );
1067 }
1068 unsafe {
1069 std::env::remove_var(NO_AUTOUPDATE_ENV);
1070 }
1071 }
1072
1073 #[test]
1074 fn off_mode_never_spawns() {
1075 let rt = tokio::runtime::Builder::new_current_thread()
1076 .enable_all()
1077 .build()
1078 .unwrap();
1079 let cfg = Update {
1080 mode: UpdateMode::Off,
1081 interval: None,
1082 auto_restart: false,
1083 };
1084 assert!(spawn(&cfg, rt.handle()).is_none());
1085 }
1086
1087 #[test]
1088 fn state_path_lives_under_the_cache_dir() {
1089 let path = state_path().expect("a cache dir on every supported platform");
1090 assert!(path.ends_with("magi/last_update_check.json"));
1091 let data = dirs::data_local_dir().unwrap_or_default();
1092 assert!(
1093 !path.starts_with(&data) || dirs::cache_dir() == dirs::data_local_dir(),
1094 "throttle state must not sit in the run history directory"
1095 );
1096 }
1097
1098 #[tokio::test]
1099 async fn finalize_of_nothing_is_a_no_op() {
1100 finalize(None, Duration::from_millis(1)).await;
1101 }
1102
1103 #[tokio::test]
1104 async fn finalize_task_preserves_a_timely_result() {
1105 let handle = tokio::spawn(async { 42 });
1106 assert_eq!(
1107 finalize_task(handle, Duration::from_secs(1)).await,
1108 Some(42)
1109 );
1110 }
1111
1112 #[test]
1122 fn checking_is_off_for_every_caller_when_the_config_says_off() {
1123 assert!(
1124 Checker::new(&Update {
1125 mode: UpdateMode::Off,
1126 interval: None,
1127 auto_restart: false,
1128 })
1129 .is_none(),
1130 "an operator who writes mode = \"off\" means it"
1131 );
1132 for mode in [UpdateMode::Notify, UpdateMode::Install] {
1133 assert!(
1134 Checker::new(&Update {
1135 mode,
1136 interval: None,
1137 auto_restart: false,
1138 })
1139 .is_some(),
1140 "{mode:?} still asks the forge"
1141 );
1142 }
1143 }
1144
1145 #[test]
1156 fn cached_update_answers_from_disk_with_no_network_call() {
1157 let dir = tempfile::tempdir().expect("temp dir");
1158 let path = dir.path().join("state.json");
1159 let opts = kaishin::KaishinOptions::new("yukimemi", "magi", "magi", "0.1.0");
1160 let checker = Checker {
1161 inner: kaishin::Checker::new("magi", opts).state_path(path.clone()),
1162 };
1163
1164 assert!(
1165 checker.cached_update().is_none(),
1166 "no state file yet must read as \"unknown\", not an error"
1167 );
1168
1169 let state = kaishin::UpdateCheckState {
1170 last_checked_unix: 0,
1171 last_known_latest: Some("v9.9.9".to_owned()),
1172 last_known_url: Some("https://example.invalid/9.9.9".to_owned()),
1173 };
1174 kaishin::save_check_state(&path, &state).expect("seed the state file");
1175
1176 let latest = checker.cached_update().expect("a newer release was cached");
1177 assert_eq!(latest.tag_name, "v9.9.9");
1178 }
1179
1180 #[test]
1181 fn reconcile_after_restart_confirms_a_matching_version() {
1182 let home = tempfile::tempdir().expect("temp home");
1187 let mut progress = Progress::new(
1188 "0.1.0".to_owned(),
1189 format!("v{}", env!("CARGO_PKG_VERSION")),
1190 );
1191 progress.advance(Stage::Restarting);
1192 write_progress(home.path(), &progress).expect("seed progress");
1193
1194 reconcile_after_restart(home.path());
1195
1196 let after = read_progress(home.path()).expect("progress on disk");
1197 assert_eq!(
1198 after.stage,
1199 Stage::Done,
1200 "the successor is running exactly the release that was asked for, \
1201 `v` prefix and all"
1202 );
1203 }
1204
1205 #[test]
1206 fn reconcile_after_restart_flags_a_mismatched_version() {
1207 let home = tempfile::tempdir().expect("temp home");
1208 let mut progress = Progress::new("0.1.0".to_owned(), "v9.9.9".to_owned());
1209 progress.advance(Stage::Restarting);
1210 write_progress(home.path(), &progress).expect("seed progress");
1211
1212 reconcile_after_restart(home.path());
1213
1214 let after = read_progress(home.path()).expect("progress on disk");
1215 assert_eq!(after.stage, Stage::Failed);
1216 assert!(
1217 after.detail.is_some_and(|d| d.contains("9.9.9")),
1218 "the operator needs to know which release it did not come back on"
1219 );
1220 }
1221
1222 #[test]
1223 fn reconcile_after_restart_leaves_a_settled_record_alone() {
1224 let home = tempfile::tempdir().expect("temp home");
1225 let mut progress = Progress::new("0.1.0".to_owned(), "9.9.9".to_owned());
1226 progress.advance(Stage::Done);
1227 write_progress(home.path(), &progress).expect("seed progress");
1228
1229 reconcile_after_restart(home.path());
1230
1231 let after = read_progress(home.path()).expect("progress on disk");
1232 assert_eq!(
1233 after.stage,
1234 Stage::Done,
1235 "an already-settled record must not be rewritten by a later, unrelated start"
1236 );
1237 }
1238
1239 #[test]
1240 fn reconcile_after_restart_with_nothing_on_disk_is_a_quiet_no_op() {
1241 let home = tempfile::tempdir().expect("temp home");
1242 reconcile_after_restart(home.path());
1243 assert!(read_progress(home.path()).is_none());
1244 }
1245
1246 fn at(secs: i64) -> Timestamp {
1247 Timestamp::from_second(secs).expect("timestamp")
1248 }
1249
1250 #[test]
1251 fn auto_upgrade_progress_defaults_old_records_to_manual() {
1252 let progress = Progress::new("old".into(), "new".into());
1253 let mut value = serde_json::to_value(progress).unwrap();
1254 value.as_object_mut().unwrap().remove("trigger");
1255 let old: Progress = serde_json::from_value(value).unwrap();
1256 assert_eq!(old.trigger, UpgradeTrigger::Manual);
1257 }
1258
1259 fn staged(stage: Stage, since: i64) -> Progress {
1260 let mut p = Progress::new("0.1.0".to_owned(), "v0.2.0".to_owned());
1261 p.stage = stage;
1262 p.started_at = at(since);
1263 p.updated_at = at(since);
1264 p
1265 }
1266
1267 fn lease(entered: i64, beat: i64) -> HandoverLease {
1268 HandoverLease {
1269 entered_at: at(entered),
1270 beat_at: at(beat),
1271 parked_run: Some("r1".to_owned()),
1272 }
1273 }
1274
1275 #[test]
1276 fn a_handover_never_entered_is_stuck_and_says_only_what_is_known() {
1277 let p = staged(Stage::Replaced, 1000);
1278 assert!(stall(&p, None, at(1000 + STALL_AFTER_SECS)).is_none());
1279 let s = stall(&p, None, at(1000 + STALL_AFTER_SECS + 1)).expect("stalled");
1280 assert_eq!(s.stage, Stage::Replaced);
1281 assert_eq!(s.kind, StallKind::NeverEntered);
1282 assert_eq!(s.age_secs, STALL_AFTER_SECS + 1);
1283 assert!(s.waiting_on.contains("hand_over"), "{}", s.waiting_on);
1284
1285 let p = staged(Stage::Restarting, 1000);
1286 let s = stall(&p, None, at(1000 + STALL_AFTER_SECS + 1)).expect("stalled");
1287 assert_eq!(s.kind, StallKind::StoppedBeating);
1288
1289 for stage in [Stage::Done, Stage::Failed, Stage::Downloading] {
1290 assert!(stall(&staged(stage, 0), None, at(1_000_000)).is_none());
1291 }
1292 }
1293
1294 #[test]
1295 fn a_live_parking_wait_is_never_stuck_however_long_it_lasts() {
1296 let mut p = staged(Stage::Parking, 1000);
1297 p.started_at = at(900);
1298 let hours = 5 * 3600;
1299 let l = lease(1000, 1000 + hours);
1300 assert!(stall(&p, Some(&l), at(1000 + hours + 10)).is_none());
1301 let r = staged(Stage::Replaced, 1000);
1303 assert!(stall(&r, Some(&l), at(1000 + hours + 10)).is_none());
1304 let s = stall(&p, Some(&l), at(1000 + hours + LEASE_TTL_SECS + 1)).expect("stuck");
1306 assert_eq!(s.kind, StallKind::StoppedBeating);
1307 }
1308
1309 #[test]
1310 fn a_lease_from_an_earlier_upgrade_proves_nothing() {
1311 let p = staged(Stage::Replaced, 2000);
1312 let old = lease(10, 3000);
1313 assert!(live_lease(&p, Some(&old), at(3001)).is_none());
1314 }
1315
1316 #[test]
1317 fn a_stage_never_goes_backwards_but_a_new_upgrade_after_a_terminal_one_starts() {
1318 let parking = staged(Stage::Parking, 1000);
1319 for back in [Stage::Replaced, Stage::Downloading, Stage::Parking] {
1320 let mut cand = staged(back, 5000);
1321 cand.to = Some("v9.9.9".to_owned());
1322 let kept = monotonic(Some(&parking), &cand);
1323 assert_eq!(kept.stage, Stage::Parking);
1324 assert_eq!(kept.updated_at, at(1000));
1325 assert_eq!(kept.started_at, parking.started_at);
1326 assert_eq!(kept.to.as_deref(), Some("v9.9.9"), "data is refreshed");
1327 }
1328 assert_eq!(
1329 monotonic(Some(&parking), &staged(Stage::Restarting, 5000)).stage,
1330 Stage::Restarting
1331 );
1332 assert_eq!(
1333 monotonic(Some(&parking), &staged(Stage::Failed, 5000)).stage,
1334 Stage::Failed
1335 );
1336 let done = staged(Stage::Done, 1000);
1337 assert_eq!(
1338 monotonic(Some(&done), &staged(Stage::Downloading, 5000)).stage,
1339 Stage::Downloading
1340 );
1341 }
1342
1343 #[test]
1344 fn write_progress_refuses_a_regression_on_disk() {
1345 let home = tempfile::tempdir().expect("temp home");
1346 write_progress(home.path(), &staged(Stage::Parking, 1000)).expect("write");
1347 write_progress(home.path(), &staged(Stage::Replaced, 5000)).expect("write");
1348 let on_disk = read_progress(home.path()).expect("record");
1349 assert_eq!(on_disk.stage, Stage::Parking);
1350 assert_eq!(on_disk.updated_at, at(1000));
1351 }
1352
1353 #[test]
1354 fn a_failed_request_cannot_overwrite_a_live_handover() {
1355 let home = tempfile::tempdir().expect("temp home");
1356 write_progress(home.path(), &staged(Stage::Parking, 1000)).expect("write");
1357 assert!(!fail_progress(home.path(), "boom").expect("fail"));
1358 assert_eq!(read_progress(home.path()).unwrap().stage, Stage::Parking);
1359 write_progress(home.path(), &staged(Stage::Replaced, 1000)).ok();
1360 let fresh = tempfile::tempdir().expect("temp home");
1361 write_progress(fresh.path(), &staged(Stage::Downloading, 1000)).expect("write");
1362 assert!(fail_progress(fresh.path(), "boom").expect("fail"));
1363 assert_eq!(read_progress(fresh.path()).unwrap().stage, Stage::Failed);
1364 }
1365
1366 #[test]
1367 fn a_failed_detached_restart_reports_the_failure() {
1368 let home = tempfile::tempdir().expect("temp home");
1369 write_progress(home.path(), &staged(Stage::Restarting, 1000)).expect("write");
1370 assert!(fail_progress(home.path(), "successor spawn failed").expect("fail"));
1371 let progress = read_progress(home.path()).expect("progress");
1372 assert_eq!(progress.stage, Stage::Failed);
1373 assert_eq!(progress.detail.as_deref(), Some("successor spawn failed"));
1374 }
1375
1376 #[test]
1377 fn the_lease_guard_writes_beats_and_removes_the_lease() {
1378 let home = tempfile::tempdir().expect("temp home");
1379 {
1380 let mut guard = LeaseGuard::enter(home.path(), Some("r1".to_owned()));
1381 let first = read_lease(home.path()).expect("lease");
1382 assert_eq!(first.parked_run.as_deref(), Some("r1"));
1383 guard.beat();
1384 assert!(read_lease(home.path()).is_some());
1385 }
1386 assert!(read_lease(home.path()).is_none());
1387 }
1388
1389 #[test]
1390 fn a_clock_that_went_backwards_is_age_zero() {
1391 let p = staged(Stage::Replaced, 5000);
1392 assert_eq!(stage_age_secs(&p, at(100)), 0);
1393 assert!(stall(&p, None, at(100)).is_none());
1394 }
1395
1396 #[test]
1397 fn a_note_is_kept_beside_the_record_and_matches_only_its_own_stage() {
1398 let home = tempfile::tempdir().expect("temp home");
1399 let p = staged(Stage::Replaced, 1000);
1400 write_progress(home.path(), &p).expect("write");
1401 write_note(home.path(), &p, "stuck");
1402 assert_eq!(read_note(home.path(), &p).as_deref(), Some("stuck"));
1403 assert_eq!(read_progress(home.path()).unwrap().updated_at, at(1000));
1404 assert!(read_note(home.path(), &staged(Stage::Parking, 1000)).is_none());
1405 assert!(read_note(home.path(), &staged(Stage::Replaced, 2000)).is_none());
1406 }
1407
1408 #[test]
1409 fn the_watchdog_speaks_once_a_minute_and_resets_on_a_new_stage() {
1410 let mut dog = Watchdog::default();
1411 let p = staged(Stage::Replaced, 1000);
1412 assert!(dog.tick(&p, None, at(1060)).is_none(), "not stalled yet");
1413 let beat = dog.tick(&p, None, at(1200)).expect("stalled");
1414 assert!(beat.warn);
1415 assert!(dog.tick(&p, None, at(1230)).is_none(), "spoke 30 s ago");
1416 assert!(dog.tick(&p, None, at(1260)).is_some(), "a minute later");
1417
1418 let parking = staged(Stage::Parking, 1260);
1419 let l = lease(1260, 1270);
1420 let beat = dog
1421 .tick(&parking, Some(&l), at(1275))
1422 .expect("parking heartbeat at once");
1423 assert!(!beat.warn, "a live wait is not a warning");
1424 assert!(dog.tick(&parking, Some(&l), at(1300)).is_none());
1425 let l = lease(1260, 1260 + 4 * 3600);
1426 let later = dog
1427 .tick(&parking, Some(&l), at(1260 + 4 * 3600 + 5))
1428 .expect("heartbeat");
1429 assert!(!later.warn, "hours of waiting on a run is still not stuck");
1430 assert!(later.message.contains("r1"), "{}", later.message);
1431
1432 let done = staged(Stage::Done, 0);
1433 assert!(dog.tick(&done, None, at(9_999_999)).is_none());
1434 }
1435
1436 #[test]
1437 fn the_upgrade_log_appends_and_rotates_to_one_generation() {
1438 let dir = tempfile::tempdir().expect("temp dir");
1439 let path = dir.path().join("upgrade.log");
1440 append_bounded(&path, "one", 16).expect("append");
1441 append_bounded(&path, "two", 16).expect("append");
1442 assert_eq!(std::fs::read_to_string(&path).unwrap(), "one\ntwo\n");
1443 append_bounded(&path, "three-and-more", 16).expect("append");
1444 append_bounded(&path, "four", 16).expect("append");
1445 assert_eq!(std::fs::read_to_string(&path).unwrap(), "four\n");
1446 let old = dir.path().join("upgrade.log.1");
1447 assert!(
1448 std::fs::read_to_string(old)
1449 .unwrap()
1450 .contains("three-and-more")
1451 );
1452 }
1453}