1use std::{
22 collections::HashMap,
23 path::{Path, PathBuf},
24 sync::{Arc, LazyLock, Mutex as SyncMutex, PoisonError, Weak, atomic::AtomicBool},
25 time::Duration,
26};
27
28use anyhow::{Context, Result, anyhow, bail};
29use scv_channels::hub::{Hub, Origin, Restart};
30use scv_client::Layout;
31use scv_protocol::{ComponentState, DaemonCommand, RestartInfo};
32use scv_tools::{background::BackgroundJobs, delegation::DelegationRegistry};
33use serde::{Deserialize, Serialize};
34use tokio::sync::Mutex;
35use tokio_util::sync::CancellationToken;
36
37use crate::components::Components;
38use crate::config::Instance;
39
40pub(crate) const CONFIG_LAYOUT: u32 = 1;
44
45const DEFAULT_MAX_WAIT: u64 = 10 * 60;
46const MAX_WAIT_LIMIT: u64 = 60 * 60;
47const VERIFY_SECONDS: u64 = 180;
50const ROLLBACK_SECONDS: u64 = 90;
52const CLEAR_CHECKS: u32 = 2;
55const DOWN_NOTICE_AFTER: Duration = Duration::from_secs(10 * 60);
57const MONITOR_INTERVAL: Duration = Duration::from_secs(30);
58const RESTART_CONTEXT_MAX_AGE: u64 = 60 * 60;
60const MAIL_DRAIN: Duration = Duration::from_secs(60);
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct BuildInfo {
66 pub(crate) version: String,
67 pub(crate) config_layout: u32,
68}
69
70pub fn build_info() -> BuildInfo {
72 BuildInfo {
73 version: env!("CARGO_PKG_VERSION").into(),
74 config_layout: CONFIG_LAYOUT,
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub(crate) enum PlanState {
81 Waiting,
83 Restarting,
85 Verified,
87 RolledBack,
89 Failed,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub(crate) struct Requester {
97 pub(crate) handle: String,
98 pub(crate) session: String,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub(crate) struct Plan {
105 pub(crate) id: String,
106 pub(crate) state: PlanState,
107 pub(crate) from_version: String,
108 pub(crate) to_version: String,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub(crate) commit: Option<String>,
111 pub(crate) from_layout: u32,
112 pub(crate) to_layout: u32,
113 pub(crate) unit: String,
114 pub(crate) binary: PathBuf,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub(crate) previous: Option<PathBuf>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub(crate) requester: Option<Requester>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub(crate) origin: Option<Origin>,
124 #[serde(default, skip_serializing_if = "Vec::is_empty")]
127 pub(crate) expected: Vec<String>,
128 pub(crate) requested_unix: u64,
129 pub(crate) deadline_unix: u64,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub(crate) restart_unix: Option<u64>,
132 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
134 pub(crate) waited_out: bool,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub(crate) detail: Option<String>,
138 #[serde(default = "default_verify_seconds")]
140 pub(crate) verify_seconds: u64,
141}
142
143fn default_verify_seconds() -> u64 {
144 VERIFY_SECONDS
145}
146
147impl Plan {
148 fn info(&self, waiting_for: Option<String>) -> RestartInfo {
149 RestartInfo {
150 to_version: self.to_version.clone(),
151 waiting_for,
152 requester: self.requester.as_ref().map(|r| r.handle.clone()),
153 origin: self.origin.as_ref().map(|origin| origin.component.clone()),
154 deadline_unix_seconds: self.deadline_unix,
155 }
156 }
157
158 fn label(&self) -> String {
159 match &self.commit {
160 Some(commit) => format!("v{} ({commit})", self.to_version),
161 None => format!("v{}", self.to_version),
162 }
163 }
164}
165
166pub(crate) fn load_plan(path: &Path) -> Result<Option<Plan>> {
167 match std::fs::read(path) {
168 Ok(bytes) => {
169 Ok(Some(serde_json::from_slice(&bytes).with_context(|| {
170 format!("parse restart plan {}", path.display())
171 })?))
172 }
173 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
174 Err(error) => Err(error).with_context(|| format!("read {}", path.display())),
175 }
176}
177
178pub(crate) fn save_plan(path: &Path, plan: &Plan) -> Result<()> {
179 write_private(path, &serde_json::to_vec_pretty(plan)?)
180}
181
182fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
183 let parent = path
184 .parent()
185 .ok_or_else(|| anyhow!("{} has no parent", path.display()))?;
186 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
187 scv_client::fs::replace_private(path, bytes)
188 .with_context(|| format!("write {}", path.display()))
189}
190
191fn unix_now() -> u64 {
192 std::time::SystemTime::now()
193 .duration_since(std::time::UNIX_EPOCH)
194 .map_or(0, |elapsed| elapsed.as_secs())
195}
196
197fn own_executable() -> Result<PathBuf> {
200 std::env::current_exe()
201 .map(strip_deleted)
202 .context("locate the daemon's executable")
203}
204
205fn strip_deleted(path: PathBuf) -> PathBuf {
206 match path
207 .to_str()
208 .and_then(|text| text.strip_suffix(" (deleted)"))
209 {
210 Some(stripped) => PathBuf::from(stripped),
211 None => path,
212 }
213}
214
215fn runs_as_unit(unit: &str) -> bool {
217 let suffix = format!("/{unit}");
218 std::fs::read_to_string("/proc/self/cgroup")
219 .is_ok_and(|text| text.lines().any(|line| line.ends_with(&suffix)))
220}
221
222async fn probe(binary: &Path) -> Result<BuildInfo> {
224 let output = tokio::time::timeout(
225 Duration::from_secs(10),
226 tokio::process::Command::new(binary)
227 .arg("build-info")
228 .stdin(std::process::Stdio::null())
229 .kill_on_drop(true)
230 .output(),
231 )
232 .await
233 .map_err(|_| anyhow!("it did not answer within 10 seconds"))??;
234 if !output.status.success() {
235 bail!("it exited with {}", output.status);
236 }
237 serde_json::from_slice(&output.stdout).context("it printed no build information")
238}
239
240struct SessionActivity {
246 home: PathBuf,
248 busy: AtomicBool,
249 background: Option<Weak<BackgroundJobs>>,
250}
251
252static SESSIONS: LazyLock<SyncMutex<HashMap<String, Arc<SessionActivity>>>> =
253 LazyLock::new(Default::default);
254
255pub(crate) struct SessionTracker {
257 id: String,
258 activity: Arc<SessionActivity>,
259}
260
261impl SessionTracker {
262 pub(crate) fn new(home: &Path, id: &str, background: Option<&Arc<BackgroundJobs>>) -> Self {
264 let activity = Arc::new(SessionActivity {
265 home: home.to_owned(),
266 busy: AtomicBool::new(false),
267 background: background.map(Arc::downgrade),
268 });
269 SESSIONS
270 .lock()
271 .unwrap_or_else(PoisonError::into_inner)
272 .insert(id.to_owned(), Arc::clone(&activity));
273 Self {
274 id: id.to_owned(),
275 activity,
276 }
277 }
278
279 pub(crate) fn set_busy(&self, busy: bool) {
281 self.activity
282 .busy
283 .store(busy, std::sync::atomic::Ordering::Release);
284 }
285}
286
287impl Drop for SessionTracker {
288 fn drop(&mut self) {
289 SESSIONS
290 .lock()
291 .unwrap_or_else(PoisonError::into_inner)
292 .remove(&self.id);
293 }
294}
295
296fn session_busy(id: &str) -> bool {
300 let activity = SESSIONS
301 .lock()
302 .unwrap_or_else(PoisonError::into_inner)
303 .get(id)
304 .cloned();
305 activity.is_some_and(|activity| {
306 activity.busy.load(std::sync::atomic::Ordering::Acquire)
307 || activity
308 .background
309 .as_ref()
310 .and_then(Weak::upgrade)
311 .is_some_and(|jobs| waited_jobs(&jobs) > 0)
312 })
313}
314
315fn waited_jobs(jobs: &BackgroundJobs) -> usize {
319 jobs.pending().saturating_sub(jobs.behind_takeovers())
320}
321
322fn sessions_with_jobs(home: &Path) -> (usize, usize) {
326 let sessions: Vec<Arc<SessionActivity>> = SESSIONS
327 .lock()
328 .unwrap_or_else(PoisonError::into_inner)
329 .values()
330 .filter(|activity| activity.home == home)
331 .cloned()
332 .collect();
333 sessions
334 .iter()
335 .filter_map(|activity| activity.background.as_ref().and_then(Weak::upgrade))
336 .map(|jobs| waited_jobs(&jobs))
337 .filter(|&pending| pending > 0)
338 .fold((0, 0), |(sessions, jobs), pending| {
339 (sessions + 1, jobs + pending)
340 })
341}
342
343#[derive(Debug, Clone, PartialEq, Eq)]
349struct Candidate {
350 component: String,
351 peer: Option<String>,
352}
353
354#[derive(Debug, Clone, PartialEq, Eq)]
355enum Pick {
356 Send {
357 component: String,
358 peer: String,
359 },
360 Wait,
362 Nothing,
363}
364
365fn pick(
369 candidates: &[Candidate],
370 states: &HashMap<String, ComponentState>,
371 owner: &dyn Fn(&str) -> Option<Option<String>>,
372 exclude: Option<&str>,
373 grace_over: bool,
374) -> Pick {
375 for candidate in candidates {
376 if exclude == Some(candidate.component.as_str()) {
377 continue;
378 }
379 let registered = owner(&candidate.component);
380 let peer = candidate
381 .peer
382 .clone()
383 .or_else(|| registered.clone().flatten());
384 match (states.get(&candidate.component), ®istered, peer) {
385 (Some(ComponentState::Connected), Some(_), Some(peer)) => {
386 return Pick::Send {
387 component: candidate.component.clone(),
388 peer,
389 };
390 }
391 (
392 Some(
393 ComponentState::Starting
394 | ComponentState::Connected
395 | ComponentState::Disconnected
396 | ComponentState::Backoff,
397 ),
398 _,
399 _,
400 ) if !grace_over => return Pick::Wait,
401 _ => {}
402 }
403 }
404 Pick::Nothing
405}
406
407fn channel_title(component: &str) -> &str {
409 match component.split(':').next() {
410 Some("wechat") => "WeChat",
411 Some("feishu") => "Feishu",
412 Some("email") => "Email",
413 Some(other) => other,
414 None => component,
415 }
416}
417
418#[derive(Clone)]
420enum States {
421 Components(Weak<Mutex<Components>>),
422 #[cfg(test)]
423 Fixed(Arc<SyncMutex<HashMap<String, ComponentState>>>),
424}
425
426impl States {
427 async fn get(&self) -> HashMap<String, ComponentState> {
428 match self {
429 Self::Components(components) => match components.upgrade() {
430 Some(components) => components
431 .lock()
432 .await
433 .status()
434 .components
435 .into_iter()
436 .filter(|health| health.enabled)
437 .map(|health| (health.id, health.state))
438 .collect(),
439 None => HashMap::new(),
440 },
441 #[cfg(test)]
442 Self::Fixed(states) => states.lock().unwrap().clone(),
443 }
444 }
445}
446
447#[derive(Clone)]
449pub(crate) struct Notifier {
450 hub: Arc<Hub>,
451 states: States,
452 grace: Duration,
454 give_up: Duration,
456 poll: Duration,
457 instance: Instance,
459 #[cfg(test)]
461 list: Option<Vec<String>>,
462}
463
464impl Notifier {
465 pub(crate) fn new(
466 instance: Instance,
467 hub: Arc<Hub>,
468 components: Weak<Mutex<Components>>,
469 ) -> Self {
470 Self {
471 hub,
472 instance,
473 states: States::Components(components),
474 grace: Duration::from_secs(120),
475 give_up: Duration::from_secs(15 * 60),
476 poll: Duration::from_secs(2),
477 #[cfg(test)]
478 list: None,
479 }
480 }
481
482 #[cfg(test)]
485 pub(crate) fn fixed(
486 hub: &Arc<Hub>,
487 list: Vec<String>,
488 states: HashMap<String, ComponentState>,
489 ) -> Self {
490 Self {
491 hub: Arc::clone(hub),
492 states: States::Fixed(Arc::new(SyncMutex::new(states))),
493 grace: Duration::from_millis(200),
494 give_up: Duration::from_secs(5),
495 poll: Duration::from_millis(20),
496 instance: crate::test_support::test_instance("/unused"),
497 list: Some(list),
498 }
499 }
500
501 fn notify_list(&self) -> Vec<String> {
502 #[cfg(test)]
503 if let Some(list) = &self.list {
504 return list.clone();
505 }
506 self.instance.load_user().map_or_else(
507 |error| {
508 tracing::warn!(
509 "Notices use the owner's last chat; configuration failed: {error:#}"
510 );
511 Vec::new()
512 },
513 |config| config.notify.owner,
514 )
515 }
516
517 pub(crate) async fn owner_chat(&self) -> Option<Origin> {
521 let states = self.states.get().await;
522 let owner = |component: &str| self.hub.owner(component);
523 match pick(&self.candidates(), &states, &owner, None, true) {
524 Pick::Send { component, peer } => Some(Origin { component, peer }),
525 Pick::Wait | Pick::Nothing => None,
526 }
527 }
528
529 fn candidates(&self) -> Vec<Candidate> {
532 let list = self.notify_list();
533 let candidates: Vec<Candidate> = if list.is_empty() {
534 self.hub
535 .last_owner()
536 .map(|last| Candidate {
537 component: last.component,
538 peer: Some(last.peer),
539 })
540 .into_iter()
541 .collect()
542 } else {
543 list.into_iter()
544 .map(|component| Candidate {
545 component,
546 peer: None,
547 })
548 .collect()
549 };
550 candidates
551 .into_iter()
552 .filter(|candidate| {
553 !self.hub.is_mail_chat(&candidate.component)
554 && !candidate.component.starts_with("email:")
555 })
556 .collect()
557 }
558
559 pub(crate) async fn deliver(
563 &self,
564 origin: Option<&Origin>,
565 text: &str,
566 exclude: Option<&str>,
567 cancel: &CancellationToken,
568 ) -> Option<String> {
569 let started = tokio::time::Instant::now();
570 let fallback = self.candidates();
571 let mut phase = match origin {
574 Some(origin) => (
575 vec![Candidate {
576 component: origin.component.clone(),
577 peer: Some(origin.peer.clone()),
578 }],
579 None,
580 text.to_owned(),
581 ),
582 None => (fallback.clone(), exclude, text.to_owned()),
583 };
584 let mut phase_started = started;
585 loop {
586 let states = self.states.get().await;
587 let grace_over = phase_started.elapsed() >= self.grace;
588 if let Some(origin) = origin
589 && grace_over
590 && phase.1.is_none()
591 {
592 phase = (
593 fallback.clone(),
594 Some(origin.component.as_str()),
595 format!(
596 "(You asked on {}, which is not connected, so this comes here.) {text}",
597 channel_title(&origin.component)
598 ),
599 );
600 phase_started = tokio::time::Instant::now();
601 continue;
602 }
603 let (candidates, exclude, text) = &phase;
604 let owner = |component: &str| self.hub.owner(component);
605 match pick(candidates, &states, &owner, *exclude, grace_over) {
606 Pick::Send { component, peer } => {
607 match self.hub.notify(&component, &peer, text).await {
608 Ok(()) => return Some(component),
609 Err(error) => tracing::warn!("Notice to {component} not stored: {error}"),
610 }
611 }
612 Pick::Nothing if grace_over => {
613 tracing::warn!("No connected account can take this notice: {text}");
614 return None;
615 }
616 Pick::Wait | Pick::Nothing => {}
617 }
618 if started.elapsed() >= self.give_up {
619 tracing::warn!("Gave up delivering a notice: {text}");
620 return None;
621 }
622 tokio::select! {
623 () = cancel.cancelled() => return None,
624 () = tokio::time::sleep(self.poll) => {}
625 }
626 }
627 }
628}
629
630enum Launcher {
635 Systemd,
637 #[cfg(test)]
639 Record(Arc<SyncMutex<Vec<Plan>>>),
640}
641
642pub(crate) struct Restarter {
644 launcher: Launcher,
645 instance: Instance,
646 hub: Arc<Hub>,
647 registry: Arc<DelegationRegistry>,
648 notifier: Notifier,
649 components: Weak<Mutex<Components>>,
650 cancel: CancellationToken,
651 current: SyncMutex<Option<(Plan, Option<String>)>>,
653}
654
655impl Restarter {
656 pub(crate) fn new(
657 instance: Instance,
658 hub: Arc<Hub>,
659 registry: Arc<DelegationRegistry>,
660 components: &Arc<Mutex<Components>>,
661 cancel: CancellationToken,
662 ) -> Arc<Self> {
663 Arc::new(Self {
664 launcher: Launcher::Systemd,
665 notifier: Notifier::new(
666 instance.clone(),
667 Arc::clone(&hub),
668 Arc::downgrade(components),
669 ),
670 instance,
671 hub,
672 registry,
673 components: Arc::downgrade(components),
674 cancel,
675 current: SyncMutex::new(None),
676 })
677 }
678
679 pub(crate) fn notifier(&self) -> &Notifier {
680 &self.notifier
681 }
682
683 pub(crate) fn info(&self) -> Option<RestartInfo> {
685 self.current
686 .lock()
687 .unwrap_or_else(PoisonError::into_inner)
688 .as_ref()
689 .map(|(plan, waiting)| plan.info(waiting.clone()))
690 }
691
692 pub(crate) async fn request(
694 self: &Arc<Self>,
695 command: DaemonCommand,
696 ) -> std::result::Result<RestartInfo, String> {
697 let DaemonCommand::RestartWhenIdle {
698 version,
699 commit,
700 parent,
701 max_wait_seconds,
702 } = command
703 else {
704 return Err("not a restart request".into());
705 };
706 if let Some(info) = self.info() {
707 return if version.as_deref().is_none_or(|v| v == info.to_version) {
708 Ok(info)
709 } else {
710 Err(format!(
711 "a restart into v{} is already scheduled",
712 info.to_version
713 ))
714 };
715 }
716 let unit = self.instance.layout.service_name();
717 if !runs_as_unit(&unit) {
718 return Err(format!(
719 "this daemon does not run as {unit}, so it cannot restart itself; \
720 restart it yourself"
721 ));
722 }
723 let binary = own_executable().map_err(|error| format!("{error:#}"))?;
724 let installed = probe(&binary).await.map_err(|error| {
725 format!(
726 "the binary at {} does not run ({error:#}); not restarting",
727 binary.display()
728 )
729 })?;
730 if let Some(version) = &version
731 && version != &installed.version
732 {
733 return Err(format!(
734 "{} reports v{}, not v{version}; not restarting",
735 binary.display(),
736 installed.version
737 ));
738 }
739 let requester = parent.as_deref().and_then(|chain| self.requester(chain));
740 let now = unix_now();
741 let wait = max_wait_seconds
742 .unwrap_or(DEFAULT_MAX_WAIT)
743 .min(MAX_WAIT_LIMIT);
744 let plan = Plan {
745 id: uuid::Uuid::new_v4().simple().to_string()[..8].to_owned(),
746 state: PlanState::Waiting,
747 from_version: env!("CARGO_PKG_VERSION").into(),
748 to_version: installed.version,
749 commit: commit.filter(|commit| !commit.trim().is_empty()),
750 from_layout: CONFIG_LAYOUT,
751 to_layout: installed.config_layout,
752 unit,
753 previous: None,
754 binary,
755 requester,
756 origin: None,
757 expected: Vec::new(),
758 requested_unix: now,
759 deadline_unix: now + wait,
760 restart_unix: None,
761 waited_out: false,
762 detail: None,
763 verify_seconds: VERIFY_SECONDS,
764 };
765 self.arm(plan)
767 }
768
769 fn arm(self: &Arc<Self>, mut plan: Plan) -> std::result::Result<RestartInfo, String> {
771 plan.origin = plan
772 .requester
773 .as_ref()
774 .and_then(|requester| self.hub.origin(&requester.session));
775 save_plan(&self.instance.layout.update_plan(), &plan)
776 .map_err(|error| format!("{error:#}"))?;
777 let waiting = self.waiting_for(&plan);
778 let info = plan.info(waiting.clone());
779 *self.current.lock().unwrap_or_else(PoisonError::into_inner) =
780 Some((plan.clone(), waiting));
781 tracing::info!(
782 "Restart into v{} scheduled; waiting at most {} seconds",
783 plan.to_version,
784 plan.deadline_unix.saturating_sub(plan.requested_unix)
785 );
786 let restarter = Arc::clone(self);
787 tokio::spawn(async move { restarter.wait_and_restart(plan).await });
788 Ok(info)
789 }
790
791 fn requester(&self, chain: &str) -> Option<Requester> {
793 self.registry.own_run(chain).map(|run| Requester {
794 handle: run.handle,
795 session: run.session,
796 })
797 }
798
799 fn waiting_for(&self, plan: &Plan) -> Option<String> {
801 if let Some(requester) = &plan.requester {
802 let working = self
808 .registry
809 .list(true)
810 .into_iter()
811 .find(|entry| entry.record.handle == requester.handle && entry.working());
812 if let Some(entry) = working {
813 return Some(if entry.record.idle_since_unix.is_some() {
814 format!("{}'s background jobs", requester.handle)
815 } else {
816 format!("{} to finish", requester.handle)
817 });
818 }
819 if session_busy(&requester.session) || self.hub.session_work(&requester.session) > 0 {
820 return Some(format!("{}'s report", requester.handle));
821 }
822 }
823 let (sessions, jobs) = sessions_with_jobs(self.instance.layout.home());
826 if jobs > 0 {
827 return Some(format!(
828 "{jobs} background job{} in {sessions} session{}",
829 if jobs == 1 { "" } else { "s" },
830 if sessions == 1 { "" } else { "s" }
831 ));
832 }
833 if self.hub.background_work() > 0 {
834 return Some("a background report to be stored".into());
835 }
836 if self.hub.owner_claims() > 0 {
837 return Some("an owner message to be answered".into());
838 }
839 if self.hub.mail_executing() > 0 {
840 return Some("a mail action to finish".into());
841 }
842 None
843 }
844
845 async fn wait_and_restart(self: Arc<Self>, mut plan: Plan) {
846 let mut clear = 0;
847 loop {
848 tokio::select! {
849 () = self.cancel.cancelled() => return,
851 () = tokio::time::sleep(Duration::from_secs(1)) => {}
852 }
853 let waiting = self.waiting_for(&plan);
854 clear = if waiting.is_none() { clear + 1 } else { 0 };
855 if let Some((_, current)) = self
856 .current
857 .lock()
858 .unwrap_or_else(PoisonError::into_inner)
859 .as_mut()
860 {
861 current.clone_from(&waiting);
862 }
863 if clear >= CLEAR_CHECKS {
864 break;
865 }
866 if unix_now() >= plan.deadline_unix {
867 tracing::warn!(
868 "Restarting into v{} at its deadline while waiting for {}",
869 plan.to_version,
870 waiting.as_deref().unwrap_or("work")
871 );
872 plan.waited_out = true;
873 break;
874 }
875 }
876 self.hub.set_mail_drain(true);
879 let drained = tokio::time::Instant::now() + MAIL_DRAIN;
880 while self.hub.mail_executing() > 0 && tokio::time::Instant::now() < drained {
881 tokio::time::sleep(Duration::from_millis(200)).await;
882 }
883 if let Err(error) = self.hand_over(&mut plan).await {
884 self.hub.set_mail_drain(false);
885 tracing::error!("Restart into v{} did not start: {error:#}", plan.to_version);
886 plan.state = PlanState::Failed;
887 plan.detail = Some(format!("the restart did not start: {error:#}"));
888 let _ = save_plan(&self.instance.layout.update_plan(), &plan);
889 *self.current.lock().unwrap_or_else(PoisonError::into_inner) = None;
890 let text = announcement(&plan, env!("CARGO_PKG_VERSION"));
891 self.notifier
892 .deliver(plan.origin.as_ref(), &text, None, &self.cancel)
893 .await;
894 let _ = std::fs::remove_file(self.instance.layout.update_plan());
895 }
896 }
897
898 async fn hand_over(&self, plan: &mut Plan) -> Result<()> {
901 plan.state = PlanState::Restarting;
902 plan.restart_unix = Some(unix_now());
903 if let Some(components) = self.components.upgrade() {
904 plan.expected = components
905 .lock()
906 .await
907 .status()
908 .components
909 .into_iter()
910 .filter(|health| health.enabled && health.state == ComponentState::Connected)
911 .map(|health| health.id)
912 .collect();
913 }
914 match &self.launcher {
915 Launcher::Systemd => {}
916 #[cfg(test)]
917 Launcher::Record(plans) => {
918 save_plan(&self.instance.layout.update_plan(), plan)?;
919 plans.lock().unwrap().push(plan.clone());
920 return Ok(());
921 }
922 }
923 plan.previous = match keep_previous(&plan.binary) {
924 Ok(path) => Some(path),
925 Err(error) => {
926 tracing::warn!("No rollback copy of this release: {error:#}");
927 None
928 }
929 };
930 let path = self.instance.layout.update_plan();
931 save_plan(&path, plan)?;
932 let watchdog = plan.previous.clone().unwrap_or_else(|| plan.binary.clone());
934 let mut command = std::process::Command::new("systemd-run");
935 command.args([
936 "--user",
937 "--quiet",
938 "--collect",
939 &format!("--unit=scv-update-{}", plan.id),
940 ]);
941 let layout = &self.instance.layout;
943 let home = (!layout.is_default()).then(|| layout.home());
944 let config = self.instance.overrides.config_file.as_deref();
945 for (variable, value) in [("SCV_HOME", home), ("SCV_CONFIG", config)] {
946 if let Some(value) = value {
947 let mut setting = std::ffi::OsString::from(format!("--setenv={variable}="));
948 setting.push(value);
949 command.arg(setting);
950 }
951 }
952 command
953 .arg(watchdog)
954 .arg("restart-watchdog")
955 .arg("--plan")
956 .arg(&path)
957 .stdin(std::process::Stdio::null());
958 let status = tokio::task::spawn_blocking(move || command.status())
959 .await?
960 .context("run systemd-run")?;
961 if !status.success() {
962 bail!("systemd-run exited with {status}");
963 }
964 tracing::info!(
965 "Handed the restart into v{} to unit scv-update-{}",
966 plan.to_version,
967 plan.id
968 );
969 Ok(())
970 }
971}
972
973fn keep_previous(binary: &Path) -> Result<PathBuf> {
976 let previous = binary.with_file_name(format!(
977 "{}.prev",
978 binary
979 .file_name()
980 .and_then(|name| name.to_str())
981 .unwrap_or("scv")
982 ));
983 install_copy(Path::new("/proc/self/exe"), &previous)?;
984 Ok(previous)
985}
986
987fn install_copy(source: &Path, target: &Path) -> Result<()> {
989 let parent = target
990 .parent()
991 .ok_or_else(|| anyhow!("{} has no parent", target.display()))?;
992 let temporary = tempfile::Builder::new()
993 .prefix(".scv-install")
994 .tempfile_in(parent)?;
995 std::fs::copy(source, temporary.path())
996 .with_context(|| format!("copy {} to {}", source.display(), target.display()))?;
997 #[cfg(unix)]
998 {
999 use std::os::unix::fs::PermissionsExt;
1000 std::fs::set_permissions(temporary.path(), std::fs::Permissions::from_mode(0o755))?;
1001 }
1002 temporary
1003 .persist(target)
1004 .map_err(|error| error.error)
1005 .with_context(|| format!("install {}", target.display()))?;
1006 Ok(())
1007}
1008
1009pub async fn watchdog(layout: &Layout, plan_path: &Path) -> Result<()> {
1015 let mut plan = load_plan(plan_path)?.context("no restart plan")?;
1016 if plan.state != PlanState::Restarting {
1017 bail!("the restart plan is {:?}, not restarting", plan.state);
1018 }
1019 let socket = layout.socket();
1020 eprintln!("Restarting {} into v{}", plan.unit, plan.to_version);
1021 systemctl_restart(&plan.unit);
1022 let outcome = verify(
1023 &socket,
1024 &plan.to_version,
1025 &plan.expected,
1026 plan.verify_seconds,
1027 )
1028 .await;
1029 match outcome {
1030 Ok(()) => {
1031 eprintln!("v{} is up with its channels", plan.to_version);
1032 plan.state = PlanState::Verified;
1033 }
1034 Err(reason) => {
1035 eprintln!("v{} failed: {reason}", plan.to_version);
1036 match rollback_refusal(&plan) {
1037 None => {
1038 let previous = plan.previous.clone().expect("checked by rollback_refusal");
1039 let detail = match install_copy(&previous, &plan.binary) {
1040 Ok(()) => {
1041 systemctl_restart(&plan.unit);
1042 let seconds = plan.verify_seconds.min(ROLLBACK_SECONDS);
1043 match verify(&socket, &plan.from_version, &[], seconds).await {
1044 Ok(()) => reason,
1045 Err(again) => format!(
1046 "{reason}; after the rollback v{} did not come back either ({again})",
1047 plan.from_version
1048 ),
1049 }
1050 }
1051 Err(error) => format!(
1052 "{reason}; putting v{} back failed: {error:#}",
1053 plan.from_version
1054 ),
1055 };
1056 plan.state = PlanState::RolledBack;
1057 plan.detail = Some(detail);
1058 }
1059 Some(refusal) => {
1060 plan.state = PlanState::Failed;
1061 plan.detail = Some(format!("{reason}; not rolled back: {refusal}"));
1062 }
1063 }
1064 }
1065 }
1066 save_plan(plan_path, &plan)?;
1067 Ok(())
1068}
1069
1070fn systemctl_restart(unit: &str) {
1071 match std::process::Command::new("systemctl")
1072 .args(["--user", "restart", unit])
1073 .status()
1074 {
1075 Ok(status) if status.success() => {}
1076 Ok(status) => eprintln!("systemctl --user restart {unit} exited with {status}"),
1077 Err(error) => eprintln!("could not run systemctl: {error}"),
1078 }
1079}
1080
1081fn rollback_refusal(plan: &Plan) -> Option<String> {
1083 if plan.to_layout != plan.from_layout {
1084 return Some(format!(
1085 "v{} uses config layout {} and v{} uses {}, so the older binary cannot read the \
1086 current configuration",
1087 plan.to_version, plan.to_layout, plan.from_version, plan.from_layout
1088 ));
1089 }
1090 match &plan.previous {
1091 Some(previous) if previous.is_file() => None,
1092 _ => Some(format!("no copy of v{} was kept", plan.from_version)),
1093 }
1094}
1095
1096async fn verify(
1099 socket: &Path,
1100 version: &str,
1101 expected: &[String],
1102 seconds: u64,
1103) -> std::result::Result<(), String> {
1104 let deadline = tokio::time::Instant::now() + Duration::from_secs(seconds);
1105 let mut last = format!("v{version} did not start");
1106 loop {
1107 match scv_client::control(socket, DaemonCommand::Status).await {
1108 Ok(status) if status.version == version => {
1109 let missing: Vec<_> = expected
1110 .iter()
1111 .filter(|id| {
1112 !status.components.iter().any(|health| {
1113 &health.id == *id && health.state == ComponentState::Connected
1114 })
1115 })
1116 .map(String::as_str)
1117 .collect();
1118 if missing.is_empty() {
1119 return Ok(());
1120 }
1121 last = format!(
1122 "v{version} started, but {} did not reconnect",
1123 missing.join(" and ")
1124 );
1125 }
1126 Ok(status) => last = format!("SCV still reports v{}", status.version),
1127 Err(_) => {}
1128 }
1129 if tokio::time::Instant::now() >= deadline {
1130 return Err(last);
1131 }
1132 tokio::time::sleep(Duration::from_secs(2)).await;
1133 }
1134}
1135
1136pub(crate) struct Startup {
1141 plan: Option<Plan>,
1142 unclean: Option<(String, u64)>,
1145}
1146
1147#[derive(Serialize, Deserialize)]
1148struct Marker {
1149 pid: u32,
1150 version: String,
1151 started_unix: u64,
1152}
1153
1154pub(crate) fn startup(layout: &Layout, hub: &Hub) -> Startup {
1158 let plan = load_plan(&layout.update_plan()).unwrap_or_else(|error| {
1159 tracing::warn!("Ignoring an unreadable restart plan: {error:#}");
1160 let _ = std::fs::remove_file(layout.update_plan());
1161 None
1162 });
1163 let planned = plan.as_ref().filter(|plan| {
1164 plan.state != PlanState::Waiting
1165 && plan
1166 .restart_unix
1167 .is_some_and(|at| unix_now().saturating_sub(at) < RESTART_CONTEXT_MAX_AGE)
1168 });
1169 hub.set_restart(planned.map(|plan| Restart {
1170 to_version: plan.to_version.clone(),
1171 }));
1172 let marker = layout.daemon_marker();
1173 let unclean = std::fs::read(&marker)
1174 .ok()
1175 .and_then(|bytes| serde_json::from_slice::<Marker>(&bytes).ok())
1176 .filter(|previous| previous.pid != std::process::id())
1177 .map(|previous| (previous.version, previous.started_unix));
1178 let current = Marker {
1179 pid: std::process::id(),
1180 version: env!("CARGO_PKG_VERSION").into(),
1181 started_unix: unix_now(),
1182 };
1183 if let Err(error) = serde_json::to_vec(¤t)
1184 .map_err(anyhow::Error::from)
1185 .and_then(|bytes| write_private(&marker, &bytes))
1186 {
1187 tracing::warn!("Could not record the running daemon: {error:#}");
1188 }
1189 Startup { plan, unclean }
1190}
1191
1192pub(crate) fn clean_shutdown(layout: &Layout) {
1194 let _ = std::fs::remove_file(layout.daemon_marker());
1195}
1196
1197#[derive(Debug, PartialEq, Eq)]
1199enum Decision {
1200 Say(String),
1201 Wait,
1203 Drop,
1204}
1205
1206fn decide(plan: &Plan, own: &str, watchdog_overdue: bool) -> Decision {
1207 match plan.state {
1208 PlanState::Waiting => Decision::Say(if own == plan.to_version {
1209 format!(
1210 "SCV is now running {}. It stopped before the planned restart, so work that \
1211 was running then was stopped.",
1212 plan.label()
1213 )
1214 } else {
1215 format!(
1216 "SCV stopped before it could restart into v{}; it is running v{own}. Deploy \
1217 again to finish the update.",
1218 plan.to_version
1219 )
1220 }),
1221 PlanState::Restarting if !watchdog_overdue => Decision::Wait,
1222 PlanState::Restarting if own == plan.to_version => Decision::Say(format!(
1223 "SCV is now running {}; the update watchdog did not report back.",
1224 plan.label()
1225 )),
1226 PlanState::Restarting if own == plan.from_version => Decision::Say(format!(
1227 "The update to v{} did not take effect; SCV is still running v{own}.",
1228 plan.to_version
1229 )),
1230 PlanState::Restarting => Decision::Drop,
1231 PlanState::Verified | PlanState::RolledBack | PlanState::Failed => {
1232 Decision::Say(announcement(plan, own))
1233 }
1234 }
1235}
1236
1237fn announcement(plan: &Plan, own: &str) -> String {
1239 let detail = plan.detail.as_deref().unwrap_or("it did not come up");
1240 let mut text = match plan.state {
1241 PlanState::Verified => format!("SCV updated: now running {}.", plan.label()),
1242 PlanState::RolledBack => format!(
1243 "The update to v{} failed: {detail}. SCV rolled back to v{}.",
1244 plan.to_version, plan.from_version
1245 ),
1246 PlanState::Failed if own == plan.to_version => {
1247 format!("SCV is running {}, but {detail}.", plan.label())
1248 }
1249 _ => format!("The update to v{} failed: {detail}.", plan.to_version),
1250 };
1251 if plan.waited_out {
1252 let minutes = plan
1253 .deadline_unix
1254 .saturating_sub(plan.requested_unix)
1255 .div_ceil(60);
1256 text.push_str(&format!(
1257 " It waited {minutes} minutes for running work, then restarted anyway; work \
1258 still running then was stopped."
1259 ));
1260 }
1261 text
1262}
1263
1264pub(crate) async fn announce(
1266 layout: Layout,
1267 startup: Startup,
1268 notifier: Notifier,
1269 cancel: CancellationToken,
1270) {
1271 let own = env!("CARGO_PKG_VERSION");
1272 if let Some(mut plan) = startup.plan {
1273 let path = layout.update_plan();
1274 let overdue_at = plan.restart_unix.unwrap_or(plan.requested_unix)
1275 + plan.verify_seconds
1276 + ROLLBACK_SECONDS
1277 + 60;
1278 let text = loop {
1279 match decide(&plan, own, unix_now() >= overdue_at) {
1280 Decision::Say(text) => break Some(text),
1281 Decision::Drop => break None,
1282 Decision::Wait => {}
1283 }
1284 tokio::select! {
1285 () = cancel.cancelled() => return,
1286 () = tokio::time::sleep(Duration::from_secs(2)) => {}
1287 }
1288 match load_plan(&path) {
1289 Ok(Some(reloaded)) if reloaded.id == plan.id => plan = reloaded,
1290 _ => break None,
1291 }
1292 };
1293 if let Some(text) = text {
1294 tracing::info!("{text}");
1295 notifier
1296 .deliver(plan.origin.as_ref(), &text, None, &cancel)
1297 .await;
1298 }
1299 if !cancel.is_cancelled() {
1300 let _ = std::fs::remove_file(&path);
1301 }
1302 } else if let Some((version, started)) = startup.unclean {
1303 let text = format!(
1304 "SCV started again after an unexpected stop (a crash or a host restart); it had run \
1305 v{version} since {}. Work in progress then was stopped.",
1306 format_time(started)
1307 );
1308 tracing::warn!("{text}");
1309 notifier.deliver(None, &text, None, &cancel).await;
1310 }
1311}
1312
1313fn format_time(unix: u64) -> String {
1314 let age = unix_now().saturating_sub(unix);
1315 match age {
1316 0..=119 => "moments before".into(),
1317 120..=7199 => format!("{} minutes before", age / 60),
1318 7200..=172_799 => format!("{} hours before", age / 3600),
1319 _ => format!("{} days before", age / 86_400),
1320 }
1321}
1322
1323pub(crate) async fn monitor(notifier: Notifier, cancel: CancellationToken) {
1329 let mut down: HashMap<String, (tokio::time::Instant, bool)> = HashMap::new();
1330 loop {
1331 tokio::select! {
1332 () = cancel.cancelled() => return,
1333 () = tokio::time::sleep(MONITOR_INTERVAL) => {}
1334 }
1335 let states = notifier.states.get().await;
1336 down.retain(|id, _| {
1337 states
1338 .get(id)
1339 .is_some_and(|state| *state != ComponentState::Connected)
1340 });
1341 for (id, state) in &states {
1342 if *state == ComponentState::Connected {
1343 continue;
1344 }
1345 let (since, told) = down
1346 .entry(id.clone())
1347 .or_insert((tokio::time::Instant::now(), false));
1348 if *told || since.elapsed() < DOWN_NOTICE_AFTER {
1349 continue;
1350 }
1351 *told = true;
1352 let (channel, account) = id.split_once(':').unwrap_or((id, "default"));
1353 let text = format!(
1354 "SCV's {} account {account} has been disconnected for {} minutes; its sign-in \
1355 may have expired. On the host, check `scv channels status {channel}` and sign \
1356 in again with `scv channels login {channel}` if needed.",
1357 channel_title(id),
1358 since.elapsed().as_secs() / 60
1359 );
1360 tracing::warn!("{text}");
1361 let notifier = notifier.clone();
1362 let cancel = cancel.clone();
1363 let id = id.clone();
1364 tokio::spawn(async move { notifier.deliver(None, &text, Some(&id), &cancel).await });
1365 }
1366 }
1367}
1368
1369#[cfg(test)]
1370mod tests;