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