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
85pub fn current_exe() -> std::io::Result<PathBuf> {
95 let exe = std::env::current_exe()?;
96 Ok(without_deleted_suffix(exe))
97}
98
99fn without_deleted_suffix(exe: PathBuf) -> PathBuf {
100 if exe.exists() {
101 return exe;
102 }
103 match plain_of_deleted(&exe) {
104 Some(p) if p.exists() => p,
105 _ => exe,
106 }
107}
108
109#[cfg(unix)]
112fn plain_of_deleted(exe: &Path) -> Option<PathBuf> {
113 use std::os::unix::ffi::{OsStrExt as _, OsStringExt as _};
114 let bytes = exe.as_os_str().as_bytes().strip_suffix(b" (deleted)")?;
115 Some(PathBuf::from(std::ffi::OsString::from_vec(bytes.to_vec())))
116}
117
118#[cfg(not(unix))]
120fn plain_of_deleted(_exe: &Path) -> Option<PathBuf> {
121 None
122}
123
124fn options() -> kaishin::KaishinOptions {
132 kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION")).crate_name(CRATE)
133}
134
135fn state_path() -> Option<PathBuf> {
138 dirs::cache_dir().map(|d| d.join("magi").join("last_update_check.json"))
139}
140
141pub async fn run_self_update(yes: bool, check_only: bool, non_interactive: bool) -> Result<()> {
143 if !check_only && pinned_worker_generation() {
144 anyhow::bail!(
145 "this worker executable generation is immutable; run self-update \
146 from the installed magi executable outside the worker"
147 );
148 }
149 let opts = kaishin::UpdateOptions::new()
150 .yes(yes)
151 .check_only(check_only)
152 .non_interactive(non_interactive);
153 kaishin::run_self_update(&options(), opts).await
154}
155
156pub enum Pending {
158 Cached {
160 checker: Checker,
162 latest: kaishin::LatestRelease,
164 },
165 Notify {
167 checker: Checker,
169 handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
171 },
172 Install {
174 handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
176 },
177}
178
179#[derive(Clone)]
181pub struct Checker {
182 inner: kaishin::Checker,
183}
184
185impl Checker {
186 pub fn new(cfg: &Update) -> Option<Self> {
200 if cfg.mode == UpdateMode::Off || pinned_worker_generation() {
201 return None;
202 }
203 let mut inner = kaishin::Checker::new(BIN, options());
204 if let Some(path) = state_path() {
205 inner = inner.state_path(path);
206 }
207 Some(Self {
208 inner: inner.interval(effective_interval(cfg)),
209 })
210 }
211
212 pub fn should_check(&self) -> bool {
214 self.inner.should_check()
215 }
216
217 pub async fn newer_release(&self) -> Result<Option<kaishin::LatestRelease>> {
229 self.inner.check_and_save().await
230 }
231
232 pub fn cached_update(&self) -> Option<kaishin::LatestRelease> {
234 self.inner.cached_update()
235 }
236
237 pub fn format_banner(&self, latest: &kaishin::LatestRelease) -> String {
239 self.inner.format_banner(latest)
240 }
241
242 #[cfg(test)]
247 pub(crate) fn for_test(interval: Duration, state_path: PathBuf) -> Self {
248 let opts = kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION"));
249 Self {
250 inner: kaishin::Checker::new(BIN, opts)
251 .state_path(state_path)
252 .interval(interval),
253 }
254 }
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "snake_case")]
264pub enum Stage {
265 Downloading,
273 Replaced,
276 Parking,
279 Restarting,
282 Done,
285 Failed,
288}
289
290impl Stage {
291 #[must_use]
294 pub fn rank(self) -> u8 {
295 match self {
296 Self::Downloading => 0,
297 Self::Replaced => 1,
298 Self::Parking => 2,
299 Self::Restarting => 3,
300 Self::Done | Self::Failed => 4,
301 }
302 }
303
304 #[must_use]
306 pub fn terminal(self) -> bool {
307 matches!(self, Self::Done | Self::Failed)
308 }
309
310 #[must_use]
312 pub fn as_str(self) -> &'static str {
313 match self {
314 Self::Downloading => "downloading",
315 Self::Replaced => "replaced",
316 Self::Parking => "parking",
317 Self::Restarting => "restarting",
318 Self::Done => "done",
319 Self::Failed => "failed",
320 }
321 }
322}
323
324#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
326#[serde(rename_all = "lowercase")]
327pub enum UpgradeTrigger {
328 #[default]
330 Manual,
331 Auto,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct Progress {
346 #[serde(default)]
348 pub trigger: UpgradeTrigger,
349 pub stage: Stage,
351 pub from: String,
353 pub to: Option<String>,
355 #[serde(default)]
357 pub parked_run: Option<String>,
358 #[serde(default)]
360 pub parked_talks: Vec<String>,
361 pub started_at: Timestamp,
363 pub updated_at: Timestamp,
365 #[serde(default)]
367 pub detail: Option<String>,
368}
369
370impl Progress {
371 #[must_use]
373 pub fn new(from: String, to: String) -> Self {
374 let now = Timestamp::now();
375 Self {
376 trigger: UpgradeTrigger::Manual,
377 stage: Stage::Downloading,
378 from,
379 to: Some(to),
380 parked_run: None,
381 parked_talks: Vec::new(),
382 started_at: now,
383 updated_at: now,
384 detail: None,
385 }
386 }
387
388 pub fn advance(&mut self, stage: Stage) {
390 self.stage = stage;
391 self.updated_at = Timestamp::now();
392 self.detail = None;
394 self.parked_talks.clear();
395 }
396
397 pub fn fail(&mut self, detail: impl Into<String>) {
399 self.stage = Stage::Failed;
400 self.updated_at = Timestamp::now();
401 self.detail = Some(detail.into());
402 }
403}
404
405#[must_use]
408pub fn progress_path(home: &Path) -> PathBuf {
409 home.join("upgrade.json")
410}
411
412static WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
415
416#[must_use]
425pub fn monotonic(current: Option<&Progress>, candidate: &Progress) -> Progress {
426 match current {
427 Some(cur)
428 if !cur.stage.terminal()
429 && !candidate.stage.terminal()
430 && candidate.stage.rank() <= cur.stage.rank() =>
431 {
432 let mut kept = cur.clone();
433 if candidate.to.is_some() {
434 kept.to.clone_from(&candidate.to);
435 }
436 kept
437 }
438 _ => candidate.clone(),
439 }
440}
441
442pub fn write_progress(home: &Path, progress: &Progress) -> Result<()> {
449 let _guard = WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
450 write_progress_locked(home, progress)
451}
452
453fn write_progress_locked(home: &Path, progress: &Progress) -> Result<()> {
454 let path = progress_path(home);
455 if let Some(parent) = path.parent() {
456 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
457 }
458 let to_write = monotonic(read_progress(home).as_ref(), progress);
459 store_progress(home, &to_write)
460}
461
462fn store_progress(home: &Path, progress: &Progress) -> Result<()> {
465 let path = progress_path(home);
466 let body = serde_json::to_string_pretty(progress).context("serialize upgrade progress")?;
467 let tmp = path.with_extension("json.tmp");
468 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
469 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
470 Ok(())
471}
472
473#[must_use]
475pub fn talks_phrase(talks: &[String]) -> String {
476 match talks {
477 [] => String::new(),
478 [one] => format!("chat turn {one} is"),
479 many => format!("chat turns {} are", many.join(", ")),
480 }
481}
482
483pub fn fail_progress(home: &Path, detail: &str) -> Result<bool> {
486 let _guard = WRITE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
487 let Some(mut progress) = read_progress(home) else {
488 return Ok(false);
489 };
490 if progress.stage == Stage::Parking {
491 return Ok(false);
492 }
493 progress.fail(detail);
494 write_progress_locked(home, &progress)?;
495 Ok(true)
496}
497
498#[must_use]
500pub fn read_progress(home: &Path) -> Option<Progress> {
501 let body = std::fs::read_to_string(progress_path(home)).ok()?;
502 serde_json::from_str(&body).ok()
503}
504
505#[must_use]
508pub fn log_path(home: &Path) -> PathBuf {
509 home.join("upgrade.log")
510}
511
512pub const LOG_MAX_BYTES: u64 = 256 * 1024;
514
515pub const STALL_AFTER_SECS: i64 = 120;
517
518pub const LEASE_TTL_SECS: i64 = 90;
521
522pub const HEARTBEAT_SECS: i64 = 60;
524
525pub const WATCHDOG_POLL: Duration = Duration::from_secs(30);
527
528pub fn append_bounded(path: &Path, line: &str, max: u64) -> std::io::Result<()> {
532 use std::io::Write as _;
533 if std::fs::metadata(path).is_ok_and(|m| m.len() >= max) {
534 let mut old = path.as_os_str().to_owned();
535 old.push(".1");
536 std::fs::rename(path, PathBuf::from(old))?;
537 }
538 if let Some(parent) = path.parent() {
539 std::fs::create_dir_all(parent)?;
540 }
541 let mut file = std::fs::OpenOptions::new()
542 .create(true)
543 .append(true)
544 .open(path)?;
545 writeln!(file, "{line}")
546}
547
548pub fn log_step(home: &Path, msg: &str) {
552 tracing::info!("handover: {msg}");
553 log_line(home, "INFO", msg);
554}
555
556pub fn log_warn(home: &Path, msg: &str) {
558 tracing::warn!("handover: {msg}");
559 log_line(home, "WARN", msg);
560}
561
562fn log_line(home: &Path, level: &str, msg: &str) {
563 let line = format!(
564 "{} pid={} {level} {msg}",
565 Timestamp::now(),
566 std::process::id()
567 );
568 if let Err(e) = append_bounded(&log_path(home), &line, LOG_MAX_BYTES) {
569 tracing::warn!("could not append to {}: {e}", log_path(home).display());
570 }
571}
572
573pub fn write_progress_logged(home: &Path, progress: &Progress) {
577 if let Err(e) = write_progress(home, progress) {
578 log_warn(home, &format!("could not write upgrade.json: {e:#}"));
579 }
580}
581
582#[derive(Debug, Clone, Serialize, Deserialize)]
585pub struct HandoverLease {
586 pub entered_at: Timestamp,
588 pub beat_at: Timestamp,
590 #[serde(default)]
592 pub parked_run: Option<String>,
593}
594
595impl HandoverLease {
596 #[must_use]
598 pub fn fresh(&self, now: Timestamp) -> bool {
599 now.as_second() - self.beat_at.as_second() <= LEASE_TTL_SECS
600 }
601}
602
603#[must_use]
605pub fn lease_path(home: &Path) -> PathBuf {
606 home.join("upgrade.handover.json")
607}
608
609#[must_use]
611pub fn read_lease(home: &Path) -> Option<HandoverLease> {
612 let body = std::fs::read_to_string(lease_path(home)).ok()?;
613 serde_json::from_str(&body).ok()
614}
615
616#[cfg(test)]
617fn write_lease(home: &Path, lease: &HandoverLease) {
618 let path = lease_path(home);
619 let tmp = path.with_extension("json.tmp");
620 let written = serde_json::to_string(lease)
621 .map_err(std::io::Error::other)
622 .and_then(|body| std::fs::write(&tmp, body))
623 .and_then(|()| std::fs::rename(&tmp, &path));
624 if let Err(e) = written {
625 log_warn(home, &format!("could not write the handover lease: {e}"));
626 }
627}
628
629#[cfg(test)]
631#[derive(Debug)]
632pub struct LeaseGuard {
633 home: PathBuf,
634 lease: HandoverLease,
635}
636
637#[cfg(test)]
638impl LeaseGuard {
639 #[must_use]
641 pub fn enter(home: &Path, parked_run: Option<String>) -> Self {
642 let now = Timestamp::now();
643 let lease = HandoverLease {
644 entered_at: now,
645 beat_at: now,
646 parked_run,
647 };
648 write_lease(home, &lease);
649 Self {
650 home: home.to_owned(),
651 lease,
652 }
653 }
654
655 pub fn beat(&mut self) {
657 self.lease.beat_at = Timestamp::now();
658 write_lease(&self.home, &self.lease);
659 }
660}
661
662#[cfg(test)]
663impl Drop for LeaseGuard {
664 fn drop(&mut self) {
665 let _ = std::fs::remove_file(lease_path(&self.home));
666 }
667}
668
669#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
671#[serde(rename_all = "snake_case")]
672pub enum StallKind {
673 NeverEntered,
675 StoppedBeating,
678}
679
680#[derive(Debug, Clone, PartialEq, Eq)]
682pub struct Stall {
683 pub stage: Stage,
685 pub kind: StallKind,
687 pub age_secs: i64,
689 pub waiting_on: String,
691}
692
693#[must_use]
696pub fn stage_age_secs(progress: &Progress, now: Timestamp) -> i64 {
697 (now.as_second() - progress.updated_at.as_second()).max(0)
698}
699
700#[must_use]
702pub fn live_lease<'a>(
703 progress: &Progress,
704 lease: Option<&'a HandoverLease>,
705 now: Timestamp,
706) -> Option<&'a HandoverLease> {
707 lease.filter(|l| {
708 !progress.stage.terminal() && l.fresh(now) && l.entered_at >= progress.started_at
709 })
710}
711
712fn waiting_on(progress: &Progress, lease: Option<&HandoverLease>) -> String {
714 match progress.stage {
715 Stage::Replaced => "hand_over to start (HANDOVER was signalled; hand_over has left no \
716 record that it was entered)"
717 .to_owned(),
718 Stage::Parking => {
719 let run = lease
720 .and_then(|l| l.parked_run.as_ref())
721 .or(progress.parked_run.as_ref());
722 let talks = talks_phrase(&progress.parked_talks);
723 let talks = if talks.is_empty() {
724 String::new()
725 } else {
726 format!("{talks} still finishing; ")
727 };
728 match run {
729 Some(run) => {
730 format!("{talks}the loop to finish run {run} at its next node boundary")
731 }
732 None => format!("{talks}the loop to stop (no run was recorded as in flight)"),
733 }
734 }
735 Stage::Restarting => "spawn_successor returning and this process exiting".to_owned(),
736 Stage::Downloading => "the release download and binary replacement".to_owned(),
737 Stage::Done | Stage::Failed => String::new(),
738 }
739}
740
741#[must_use]
743pub fn waited_secs(lease: &HandoverLease, now: Timestamp) -> i64 {
744 (now.as_second() - lease.entered_at.as_second()).max(0)
745}
746
747#[must_use]
752pub fn stall(progress: &Progress, lease: Option<&HandoverLease>, now: Timestamp) -> Option<Stall> {
753 if progress.stage.terminal() || progress.stage == Stage::Downloading {
754 return None;
755 }
756 if live_lease(progress, lease, now).is_some() {
757 return None;
758 }
759 let stale = lease.filter(|l| l.entered_at >= progress.started_at);
761 let (kind, since) = match (progress.stage, stale) {
762 (_, Some(l)) => (StallKind::StoppedBeating, l.beat_at),
763 (Stage::Replaced, None) => (StallKind::NeverEntered, progress.updated_at),
764 _ => (StallKind::StoppedBeating, progress.updated_at),
765 };
766 let age_secs = (now.as_second() - since.as_second()).max(0);
767 let limit = if stale.is_some() {
768 LEASE_TTL_SECS
769 } else {
770 STALL_AFTER_SECS
771 };
772 (age_secs > limit).then(|| Stall {
773 stage: progress.stage,
774 kind,
775 age_secs,
776 waiting_on: waiting_on(progress, lease),
777 })
778}
779
780#[derive(Debug, Clone, PartialEq, Eq)]
782pub struct Beat {
783 pub stage: Stage,
785 pub warn: bool,
787 pub message: String,
789}
790
791#[derive(Debug, Default)]
793pub struct Watchdog {
794 last: Option<(Stage, Timestamp)>,
795}
796
797impl Watchdog {
798 pub fn tick(
801 &mut self,
802 progress: &Progress,
803 lease: Option<&HandoverLease>,
804 now: Timestamp,
805 ) -> Option<Beat> {
806 if progress.stage.terminal() {
807 self.last = None;
808 return None;
809 }
810 if self.last.is_some_and(|(stage, _)| stage != progress.stage) {
811 self.last = None;
812 }
813 let stalled = stall(progress, lease, now);
814 let alive = live_lease(progress, lease, now);
815 if stalled.is_none() && alive.is_none() && progress.stage != Stage::Parking {
818 return None;
819 }
820 if let Some((_, at)) = self.last
821 && now.as_second() - at.as_second() < HEARTBEAT_SECS
822 {
823 return None;
824 }
825 self.last = Some((progress.stage, now));
826 let age = alive.map_or_else(|| stage_age_secs(progress, now), |l| waited_secs(l, now));
827 let (warn, message) = match &stalled {
828 Some(s) => (
829 true,
830 format!(
831 "stuck in {:?} for {} min {} s without progress, waiting on {}",
832 s.stage,
833 s.age_secs / 60,
834 s.age_secs % 60,
835 s.waiting_on
836 ),
837 ),
838 None => (
839 false,
840 format!(
841 "parking for {} min {} s, waiting on {}",
842 age / 60,
843 age % 60,
844 waiting_on(progress, lease)
845 ),
846 ),
847 };
848 Some(Beat {
849 stage: progress.stage,
850 warn,
851 message,
852 })
853 }
854}
855
856#[derive(Debug, Serialize, Deserialize)]
859struct Note {
860 stage: Stage,
861 stage_since: Timestamp,
862 message: String,
863}
864
865fn note_path(home: &Path) -> PathBuf {
866 home.join("upgrade.note.json")
867}
868
869fn write_note(home: &Path, progress: &Progress, message: &str) {
870 let note = Note {
871 stage: progress.stage,
872 stage_since: progress.updated_at,
873 message: message.to_owned(),
874 };
875 let path = note_path(home);
876 let tmp = path.with_extension("json.tmp");
877 let written = serde_json::to_string(¬e)
878 .map_err(std::io::Error::other)
879 .and_then(|body| std::fs::write(&tmp, body))
880 .and_then(|()| std::fs::rename(&tmp, &path));
881 if let Err(e) = written {
882 tracing::warn!("could not write {}: {e}", path.display());
883 }
884}
885
886#[must_use]
889pub fn read_note(home: &Path, progress: &Progress) -> Option<String> {
890 let body = std::fs::read_to_string(note_path(home)).ok()?;
891 let note: Note = serde_json::from_str(&body).ok()?;
892 (note.stage == progress.stage && note.stage_since == progress.updated_at)
893 .then_some(note.message)
894}
895
896pub fn spawn_watchdog(home: PathBuf) {
900 let spawned = std::thread::Builder::new()
901 .name("upgrade-watchdog".to_owned())
902 .spawn(move || {
903 let mut dog = Watchdog::default();
904 loop {
905 std::thread::sleep(WATCHDOG_POLL);
906 let Some(progress) = read_progress(&home) else {
907 continue;
908 };
909 let lease = read_lease(&home);
910 let Some(beat) = dog.tick(&progress, lease.as_ref(), Timestamp::now()) else {
911 continue;
912 };
913 if beat.warn {
914 log_warn(&home, &beat.message);
915 } else {
916 log_step(&home, &beat.message);
917 }
918 write_note(&home, &progress, &beat.message);
923 }
924 });
925 if let Err(e) = spawned {
926 tracing::warn!("could not start the upgrade watchdog: {e}");
927 }
928}
929
930pub fn reconcile_after_restart(home: &Path) {
942 let Some(mut progress) = read_progress(home) else {
943 return;
944 };
945 let _ = std::fs::remove_file(lease_path(home));
947 if progress.stage.terminal() {
948 return;
949 }
950 let running = env!("CARGO_PKG_VERSION");
951 if progress
957 .to
958 .as_deref()
959 .is_some_and(|to| to.trim_start_matches('v') == running)
960 {
961 progress.advance(Stage::Done);
962 } else {
963 let to = progress
964 .to
965 .clone()
966 .unwrap_or_else(|| "the expected release".to_owned());
967 progress.fail(format!(
968 "this process came up on {running}, not {to} - the upgrade may \
969 not have replaced the binary"
970 ));
971 }
972 write_progress_logged(home, &progress);
973}
974
975pub fn spawn(cfg: &Update, rt: &tokio::runtime::Handle) -> Option<Pending> {
977 if disabled_by_env() || pinned_worker_generation() || cfg.mode == UpdateMode::Off {
978 return None;
979 }
980 let checker = Checker::new(cfg)?;
981 match cfg.mode {
982 UpdateMode::Off => None,
983 UpdateMode::Notify => {
984 if !checker.should_check() {
985 let latest = checker.cached_update()?;
986 return Some(Pending::Cached { checker, latest });
987 }
988 let inner = checker.inner.clone();
989 let handle = rt.spawn(async move { inner.check_and_save().await });
990 Some(Pending::Notify { checker, handle })
991 }
992 UpdateMode::Install => {
993 let inner = checker.inner.clone();
994 let handle = rt.spawn(async move { inner.auto_update().await });
995 Some(Pending::Install { handle })
996 }
997 }
998}
999
1000pub async fn finalize(pending: Option<Pending>, budget: Duration) {
1005 let Some(pending) = pending else {
1006 return;
1007 };
1008 match pending {
1009 Pending::Cached { checker, latest } => {
1010 eprintln!("{}", checker.format_banner(&latest));
1011 }
1012 Pending::Notify { checker, handle } => {
1013 if let Some(Ok(Some(latest))) = finalize_task(handle, budget).await {
1014 eprintln!("{}", checker.format_banner(&latest));
1015 }
1016 }
1017 Pending::Install { handle } => {
1018 if let Some(Ok(Some(latest))) = finalize_task(handle, budget).await {
1019 eprintln!("magi updated itself to {}", latest.tag_name);
1020 }
1021 }
1022 }
1023}
1024
1025async fn finalize_task<T>(handle: tokio::task::JoinHandle<T>, budget: Duration) -> Option<T> {
1029 let (send, deadline) = tokio::sync::oneshot::channel();
1030 if let Err(error) = std::thread::Builder::new()
1031 .name("update-finalize-deadline".to_owned())
1032 .spawn(move || {
1033 std::thread::sleep(budget);
1034 let _ = send.send(());
1035 })
1036 {
1037 tracing::warn!("could not start release-check deadline: {error}");
1038 return None;
1039 }
1040 tokio::select! {
1041 result = handle => result.ok(),
1042 _ = deadline => None,
1043 }
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048 use super::*;
1049
1050 #[cfg(unix)]
1054 #[test]
1055 fn a_replaced_binarys_deleted_suffix_is_stripped_only_when_the_plain_path_exists() {
1056 let dir = tempfile::tempdir().unwrap();
1057 let plain = dir.path().join("magi");
1058 let decorated = dir.path().join("magi (deleted)");
1059
1060 assert_eq!(without_deleted_suffix(decorated.clone()), decorated);
1061 std::fs::write(&plain, b"new").unwrap();
1062 assert_eq!(without_deleted_suffix(decorated.clone()), plain);
1063 assert_eq!(without_deleted_suffix(plain.clone()), plain);
1064 std::fs::write(&decorated, b"odd").unwrap();
1065 assert_eq!(without_deleted_suffix(decorated.clone()), decorated);
1066 }
1067
1068 #[cfg(unix)]
1069 #[test]
1070 fn a_deleted_suffix_is_stripped_from_non_utf8_bytes_without_filesystem_access() {
1071 use std::os::unix::ffi::OsStringExt as _;
1072
1073 let raw = PathBuf::from(std::ffi::OsString::from_vec(b"m\xffgi".to_vec()));
1074 let mut decorated = raw.clone().into_os_string();
1075 decorated.push(" (deleted)");
1076 assert_eq!(plain_of_deleted(Path::new(&decorated)), Some(raw.clone()));
1077 assert_eq!(plain_of_deleted(&raw), None);
1078 }
1079
1080 #[cfg(target_os = "linux")]
1083 #[test]
1084 fn a_replaced_binarys_deleted_suffix_is_stripped_from_a_non_utf8_path() {
1085 use std::os::unix::ffi::OsStringExt as _;
1086
1087 let dir = tempfile::tempdir().unwrap();
1088 let raw = dir
1089 .path()
1090 .join(std::ffi::OsString::from_vec(b"m\xffgi".to_vec()));
1091 let raw_decorated = {
1092 let mut s = raw.clone().into_os_string();
1093 s.push(" (deleted)");
1094 PathBuf::from(s)
1095 };
1096 std::fs::write(&raw, b"new").unwrap();
1097 assert_eq!(without_deleted_suffix(raw_decorated), raw);
1098 }
1099
1100 #[test]
1105 fn effective_interval_floors_a_configured_interval_below_githubs_rate_limit() {
1106 let cfg = Update {
1107 mode: UpdateMode::Notify,
1108 interval: Some("1s".to_owned()),
1109 auto_restart: false,
1110 };
1111 assert_eq!(
1112 effective_interval(&cfg),
1113 MIN_INTERVAL,
1114 "an interval that would exceed GitHub's rate limit under continuous \
1115 polling must be floored rather than honoured verbatim"
1116 );
1117
1118 let sane = Update {
1119 mode: UpdateMode::Notify,
1120 interval: Some("2h".to_owned()),
1121 auto_restart: false,
1122 };
1123 assert_eq!(
1124 effective_interval(&sane),
1125 Duration::from_secs(2 * 60 * 60),
1126 "an interval already above the floor must pass through unchanged"
1127 );
1128 }
1129
1130 #[test]
1131 fn env_kill_switch_semantics() {
1132 unsafe {
1134 std::env::remove_var(NO_AUTOUPDATE_ENV);
1135 }
1136 assert!(!disabled_by_env());
1137 for (value, disabled) in [
1138 ("1", true),
1139 ("true", true),
1140 ("yes", true),
1141 ("0", false),
1142 ("false", false),
1143 ("FALSE", false),
1144 ("", false),
1145 (" ", false),
1146 ] {
1147 unsafe {
1148 std::env::set_var(NO_AUTOUPDATE_ENV, value);
1149 }
1150 assert_eq!(
1151 disabled_by_env(),
1152 disabled,
1153 "MAGI_NO_AUTOUPDATE={value:?} should {} disable",
1154 if disabled { "" } else { "not" }
1155 );
1156 }
1157 unsafe {
1158 std::env::remove_var(NO_AUTOUPDATE_ENV);
1159 }
1160 }
1161
1162 #[test]
1163 fn off_mode_never_spawns() {
1164 let rt = tokio::runtime::Builder::new_current_thread()
1165 .enable_all()
1166 .build()
1167 .unwrap();
1168 let cfg = Update {
1169 mode: UpdateMode::Off,
1170 interval: None,
1171 auto_restart: false,
1172 };
1173 assert!(spawn(&cfg, rt.handle()).is_none());
1174 }
1175
1176 #[test]
1177 fn state_path_lives_under_the_cache_dir() {
1178 let path = state_path().expect("a cache dir on every supported platform");
1179 assert!(path.ends_with("magi/last_update_check.json"));
1180 let data = dirs::data_local_dir().unwrap_or_default();
1181 assert!(
1182 !path.starts_with(&data) || dirs::cache_dir() == dirs::data_local_dir(),
1183 "throttle state must not sit in the run history directory"
1184 );
1185 }
1186
1187 #[tokio::test]
1188 async fn finalize_of_nothing_is_a_no_op() {
1189 finalize(None, Duration::from_millis(1)).await;
1190 }
1191
1192 #[tokio::test]
1193 async fn finalize_task_preserves_a_timely_result() {
1194 let handle = tokio::spawn(async { 42 });
1195 assert_eq!(
1196 finalize_task(handle, Duration::from_secs(1)).await,
1197 Some(42)
1198 );
1199 }
1200
1201 #[test]
1211 fn checking_is_off_for_every_caller_when_the_config_says_off() {
1212 if pinned_worker_generation() {
1213 for mode in [UpdateMode::Notify, UpdateMode::Install] {
1214 assert!(
1215 Checker::new(&Update {
1216 mode,
1217 interval: None,
1218 auto_restart: false
1219 })
1220 .is_none(),
1221 "pinned worker descendants must not check for updates"
1222 );
1223 }
1224 }
1225 if !crate::standalone_test(
1226 "updater::tests::checking_is_off_for_every_caller_when_the_config_says_off",
1227 ) {
1228 return;
1229 }
1230 assert!(
1231 Checker::new(&Update {
1232 mode: UpdateMode::Off,
1233 interval: None,
1234 auto_restart: false,
1235 })
1236 .is_none(),
1237 "an operator who writes mode = \"off\" means it"
1238 );
1239 for mode in [UpdateMode::Notify, UpdateMode::Install] {
1240 assert!(
1241 Checker::new(&Update {
1242 mode,
1243 interval: None,
1244 auto_restart: false,
1245 })
1246 .is_some(),
1247 "{mode:?} still asks the forge"
1248 );
1249 }
1250 }
1251
1252 #[test]
1263 fn cached_update_answers_from_disk_with_no_network_call() {
1264 let dir = tempfile::tempdir().expect("temp dir");
1265 let path = dir.path().join("state.json");
1266 let opts = kaishin::KaishinOptions::new("yukimemi", "magi", "magi", "0.1.0");
1267 let checker = Checker {
1268 inner: kaishin::Checker::new("magi", opts).state_path(path.clone()),
1269 };
1270
1271 assert!(
1272 checker.cached_update().is_none(),
1273 "no state file yet must read as \"unknown\", not an error"
1274 );
1275
1276 let state = kaishin::UpdateCheckState {
1277 last_checked_unix: 0,
1278 last_known_latest: Some("v9.9.9".to_owned()),
1279 last_known_url: Some("https://example.invalid/9.9.9".to_owned()),
1280 };
1281 kaishin::save_check_state(&path, &state).expect("seed the state file");
1282
1283 let latest = checker.cached_update().expect("a newer release was cached");
1284 assert_eq!(latest.tag_name, "v9.9.9");
1285 }
1286
1287 #[test]
1288 fn reconcile_after_restart_confirms_a_matching_version() {
1289 let home = tempfile::tempdir().expect("temp home");
1294 let mut progress = Progress::new(
1295 "0.1.0".to_owned(),
1296 format!("v{}", env!("CARGO_PKG_VERSION")),
1297 );
1298 progress.advance(Stage::Restarting);
1299 write_progress(home.path(), &progress).expect("seed progress");
1300
1301 reconcile_after_restart(home.path());
1302
1303 let after = read_progress(home.path()).expect("progress on disk");
1304 assert_eq!(
1305 after.stage,
1306 Stage::Done,
1307 "the successor is running exactly the release that was asked for, \
1308 `v` prefix and all"
1309 );
1310 }
1311
1312 #[test]
1313 fn reconcile_after_restart_flags_a_mismatched_version() {
1314 let home = tempfile::tempdir().expect("temp home");
1315 let mut progress = Progress::new("0.1.0".to_owned(), "v9.9.9".to_owned());
1316 progress.advance(Stage::Restarting);
1317 write_progress(home.path(), &progress).expect("seed progress");
1318
1319 reconcile_after_restart(home.path());
1320
1321 let after = read_progress(home.path()).expect("progress on disk");
1322 assert_eq!(after.stage, Stage::Failed);
1323 assert!(
1324 after.detail.is_some_and(|d| d.contains("9.9.9")),
1325 "the operator needs to know which release it did not come back on"
1326 );
1327 }
1328
1329 #[test]
1330 fn reconcile_after_restart_leaves_a_settled_record_alone() {
1331 let home = tempfile::tempdir().expect("temp home");
1332 let mut progress = Progress::new("0.1.0".to_owned(), "9.9.9".to_owned());
1333 progress.advance(Stage::Done);
1334 write_progress(home.path(), &progress).expect("seed progress");
1335
1336 reconcile_after_restart(home.path());
1337
1338 let after = read_progress(home.path()).expect("progress on disk");
1339 assert_eq!(
1340 after.stage,
1341 Stage::Done,
1342 "an already-settled record must not be rewritten by a later, unrelated start"
1343 );
1344 }
1345
1346 #[test]
1347 fn reconcile_after_restart_with_nothing_on_disk_is_a_quiet_no_op() {
1348 let home = tempfile::tempdir().expect("temp home");
1349 reconcile_after_restart(home.path());
1350 assert!(read_progress(home.path()).is_none());
1351 }
1352
1353 fn at(secs: i64) -> Timestamp {
1354 Timestamp::from_second(secs).expect("timestamp")
1355 }
1356
1357 #[test]
1358 fn auto_upgrade_progress_defaults_old_records_to_manual() {
1359 let progress = Progress::new("old".into(), "new".into());
1360 let mut value = serde_json::to_value(progress).unwrap();
1361 value.as_object_mut().unwrap().remove("trigger");
1362 let old: Progress = serde_json::from_value(value).unwrap();
1363 assert_eq!(old.trigger, UpgradeTrigger::Manual);
1364 }
1365
1366 fn staged(stage: Stage, since: i64) -> Progress {
1367 let mut p = Progress::new("0.1.0".to_owned(), "v0.2.0".to_owned());
1368 p.stage = stage;
1369 p.started_at = at(since);
1370 p.updated_at = at(since);
1371 p
1372 }
1373
1374 fn lease(entered: i64, beat: i64) -> HandoverLease {
1375 HandoverLease {
1376 entered_at: at(entered),
1377 beat_at: at(beat),
1378 parked_run: Some("r1".to_owned()),
1379 }
1380 }
1381
1382 #[test]
1383 fn a_handover_never_entered_is_stuck_and_says_only_what_is_known() {
1384 let p = staged(Stage::Replaced, 1000);
1385 assert!(stall(&p, None, at(1000 + STALL_AFTER_SECS)).is_none());
1386 let s = stall(&p, None, at(1000 + STALL_AFTER_SECS + 1)).expect("stalled");
1387 assert_eq!(s.stage, Stage::Replaced);
1388 assert_eq!(s.kind, StallKind::NeverEntered);
1389 assert_eq!(s.age_secs, STALL_AFTER_SECS + 1);
1390 assert!(s.waiting_on.contains("hand_over"), "{}", s.waiting_on);
1391
1392 let p = staged(Stage::Restarting, 1000);
1393 let s = stall(&p, None, at(1000 + STALL_AFTER_SECS + 1)).expect("stalled");
1394 assert_eq!(s.kind, StallKind::StoppedBeating);
1395
1396 for stage in [Stage::Done, Stage::Failed, Stage::Downloading] {
1397 assert!(stall(&staged(stage, 0), None, at(1_000_000)).is_none());
1398 }
1399 }
1400
1401 #[test]
1402 fn a_live_parking_wait_is_never_stuck_however_long_it_lasts() {
1403 let mut p = staged(Stage::Parking, 1000);
1404 p.started_at = at(900);
1405 let hours = 5 * 3600;
1406 let l = lease(1000, 1000 + hours);
1407 assert!(stall(&p, Some(&l), at(1000 + hours + 10)).is_none());
1408 let r = staged(Stage::Replaced, 1000);
1410 assert!(stall(&r, Some(&l), at(1000 + hours + 10)).is_none());
1411 let s = stall(&p, Some(&l), at(1000 + hours + LEASE_TTL_SECS + 1)).expect("stuck");
1413 assert_eq!(s.kind, StallKind::StoppedBeating);
1414 }
1415
1416 #[test]
1417 fn a_lease_from_an_earlier_upgrade_proves_nothing() {
1418 let p = staged(Stage::Replaced, 2000);
1419 let old = lease(10, 3000);
1420 assert!(live_lease(&p, Some(&old), at(3001)).is_none());
1421 }
1422
1423 #[test]
1424 fn a_stage_never_goes_backwards_but_a_new_upgrade_after_a_terminal_one_starts() {
1425 let parking = staged(Stage::Parking, 1000);
1426 for back in [Stage::Replaced, Stage::Downloading, Stage::Parking] {
1427 let mut cand = staged(back, 5000);
1428 cand.to = Some("v9.9.9".to_owned());
1429 let kept = monotonic(Some(&parking), &cand);
1430 assert_eq!(kept.stage, Stage::Parking);
1431 assert_eq!(kept.updated_at, at(1000));
1432 assert_eq!(kept.started_at, parking.started_at);
1433 assert_eq!(kept.to.as_deref(), Some("v9.9.9"), "data is refreshed");
1434 }
1435 assert_eq!(
1436 monotonic(Some(&parking), &staged(Stage::Restarting, 5000)).stage,
1437 Stage::Restarting
1438 );
1439 assert_eq!(
1440 monotonic(Some(&parking), &staged(Stage::Failed, 5000)).stage,
1441 Stage::Failed
1442 );
1443 let done = staged(Stage::Done, 1000);
1444 assert_eq!(
1445 monotonic(Some(&done), &staged(Stage::Downloading, 5000)).stage,
1446 Stage::Downloading
1447 );
1448 }
1449
1450 #[test]
1451 fn write_progress_refuses_a_regression_on_disk() {
1452 let home = tempfile::tempdir().expect("temp home");
1453 write_progress(home.path(), &staged(Stage::Parking, 1000)).expect("write");
1454 write_progress(home.path(), &staged(Stage::Replaced, 5000)).expect("write");
1455 let on_disk = read_progress(home.path()).expect("record");
1456 assert_eq!(on_disk.stage, Stage::Parking);
1457 assert_eq!(on_disk.updated_at, at(1000));
1458 }
1459
1460 #[test]
1461 fn a_failed_request_cannot_overwrite_a_live_handover() {
1462 let home = tempfile::tempdir().expect("temp home");
1463 write_progress(home.path(), &staged(Stage::Parking, 1000)).expect("write");
1464 assert!(!fail_progress(home.path(), "boom").expect("fail"));
1465 assert_eq!(read_progress(home.path()).unwrap().stage, Stage::Parking);
1466 write_progress(home.path(), &staged(Stage::Replaced, 1000)).ok();
1467 let fresh = tempfile::tempdir().expect("temp home");
1468 write_progress(fresh.path(), &staged(Stage::Downloading, 1000)).expect("write");
1469 assert!(fail_progress(fresh.path(), "boom").expect("fail"));
1470 assert_eq!(read_progress(fresh.path()).unwrap().stage, Stage::Failed);
1471 }
1472
1473 #[test]
1474 fn a_failed_detached_restart_reports_the_failure() {
1475 let home = tempfile::tempdir().expect("temp home");
1476 write_progress(home.path(), &staged(Stage::Restarting, 1000)).expect("write");
1477 assert!(fail_progress(home.path(), "successor spawn failed").expect("fail"));
1478 let progress = read_progress(home.path()).expect("progress");
1479 assert_eq!(progress.stage, Stage::Failed);
1480 assert_eq!(progress.detail.as_deref(), Some("successor spawn failed"));
1481 }
1482
1483 #[test]
1484 fn the_lease_guard_writes_beats_and_removes_the_lease() {
1485 let home = tempfile::tempdir().expect("temp home");
1486 {
1487 let mut guard = LeaseGuard::enter(home.path(), Some("r1".to_owned()));
1488 let first = read_lease(home.path()).expect("lease");
1489 assert_eq!(first.parked_run.as_deref(), Some("r1"));
1490 guard.beat();
1491 assert!(read_lease(home.path()).is_some());
1492 }
1493 assert!(read_lease(home.path()).is_none());
1494 }
1495
1496 #[test]
1497 fn a_clock_that_went_backwards_is_age_zero() {
1498 let p = staged(Stage::Replaced, 5000);
1499 assert_eq!(stage_age_secs(&p, at(100)), 0);
1500 assert!(stall(&p, None, at(100)).is_none());
1501 }
1502
1503 #[test]
1504 fn a_note_is_kept_beside_the_record_and_matches_only_its_own_stage() {
1505 let home = tempfile::tempdir().expect("temp home");
1506 let p = staged(Stage::Replaced, 1000);
1507 write_progress(home.path(), &p).expect("write");
1508 write_note(home.path(), &p, "stuck");
1509 assert_eq!(read_note(home.path(), &p).as_deref(), Some("stuck"));
1510 assert_eq!(read_progress(home.path()).unwrap().updated_at, at(1000));
1511 assert!(read_note(home.path(), &staged(Stage::Parking, 1000)).is_none());
1512 assert!(read_note(home.path(), &staged(Stage::Replaced, 2000)).is_none());
1513 }
1514
1515 #[test]
1516 fn the_watchdog_speaks_once_a_minute_and_resets_on_a_new_stage() {
1517 let mut dog = Watchdog::default();
1518 let p = staged(Stage::Replaced, 1000);
1519 assert!(dog.tick(&p, None, at(1060)).is_none(), "not stalled yet");
1520 let beat = dog.tick(&p, None, at(1200)).expect("stalled");
1521 assert!(beat.warn);
1522 assert!(dog.tick(&p, None, at(1230)).is_none(), "spoke 30 s ago");
1523 assert!(dog.tick(&p, None, at(1260)).is_some(), "a minute later");
1524
1525 let parking = staged(Stage::Parking, 1260);
1526 let l = lease(1260, 1270);
1527 let beat = dog
1528 .tick(&parking, Some(&l), at(1275))
1529 .expect("parking heartbeat at once");
1530 assert!(!beat.warn, "a live wait is not a warning");
1531 assert!(dog.tick(&parking, Some(&l), at(1300)).is_none());
1532 let l = lease(1260, 1260 + 4 * 3600);
1533 let later = dog
1534 .tick(&parking, Some(&l), at(1260 + 4 * 3600 + 5))
1535 .expect("heartbeat");
1536 assert!(!later.warn, "hours of waiting on a run is still not stuck");
1537 assert!(later.message.contains("r1"), "{}", later.message);
1538
1539 let done = staged(Stage::Done, 0);
1540 assert!(dog.tick(&done, None, at(9_999_999)).is_none());
1541 }
1542
1543 #[test]
1544 fn the_upgrade_log_appends_and_rotates_to_one_generation() {
1545 let dir = tempfile::tempdir().expect("temp dir");
1546 let path = dir.path().join("upgrade.log");
1547 append_bounded(&path, "one", 16).expect("append");
1548 append_bounded(&path, "two", 16).expect("append");
1549 assert_eq!(std::fs::read_to_string(&path).unwrap(), "one\ntwo\n");
1550 append_bounded(&path, "three-and-more", 16).expect("append");
1551 append_bounded(&path, "four", 16).expect("append");
1552 assert_eq!(std::fs::read_to_string(&path).unwrap(), "four\n");
1553 let old = dir.path().join("upgrade.log.1");
1554 assert!(
1555 std::fs::read_to_string(old)
1556 .unwrap()
1557 .contains("three-and-more")
1558 );
1559 }
1560}