Skip to main content

scv_server/
restart.rs

1//! Planned restarts into a newly installed release, and the notices around
2//! them.
3//!
4//! `scv restart --when-idle` (run by the feature-flow deploy script after
5//! `cargo install`) asks the daemon to restart into the binary now at its own
6//! path. The daemon checks that binary runs, then waits until the delegation
7//! that asked has finished and its report is stored, and no owner message is
8//! being answered, or until the request's deadline. It then records a plan,
9//! keeps a copy of its own binary for rollback, and starts a watchdog outside
10//! its own cgroup (`systemd-run`). The watchdog restarts the unit, checks that
11//! the new release comes up with the channels that were connected before,
12//! and otherwise puts the previous binary back when both releases share a
13//! config layout. The daemon that starts next announces the outcome in the
14//! chat that asked, or through the notify list.
15//!
16//! The same notifier tells the owner about restarts after a crash and about
17//! accounts that stay disconnected.
18
19use 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
38/// Where configuration and state files live and how they are shaped. Bump it
39/// when a release reads or writes them in a way the previous release cannot:
40/// a rollback between releases with different layouts is refused.
41pub(crate) const CONFIG_LAYOUT: u32 = 1;
42
43const DEFAULT_MAX_WAIT: u64 = 10 * 60;
44const MAX_WAIT_LIMIT: u64 = 60 * 60;
45/// How long the watchdog gives a new release to report its version and
46/// reconnect the channels that were connected before.
47const VERIFY_SECONDS: u64 = 180;
48/// How long the watchdog waits for a rolled-back release to come back.
49const ROLLBACK_SECONDS: u64 = 90;
50/// Checks a restart must pass in a row before it goes ahead, a second apart,
51/// so a job that just finished has time to start its report.
52const CLEAR_CHECKS: u32 = 2;
53/// An account disconnected this long gets a notice through another account.
54const DOWN_NOTICE_AFTER: Duration = Duration::from_secs(10 * 60);
55const MONITOR_INTERVAL: Duration = Duration::from_secs(30);
56/// A plan restarted this long ago no longer explains interrupted work.
57const RESTART_CONTEXT_MAX_AGE: u64 = 60 * 60;
58
59/// What a binary reports about itself for a planned restart.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct BuildInfo {
62    pub(crate) version: String,
63    pub(crate) config_layout: u32,
64}
65
66/// This binary's build information, printed by `scv build-info`.
67pub 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 for the requesting work to end.
78    Waiting,
79    /// The watchdog is restarting the unit and checking the new release.
80    Restarting,
81    /// The new release came up with its channels.
82    Verified,
83    /// The new release failed and the previous binary was put back.
84    RolledBack,
85    /// The new release failed and was not rolled back, or the restart could
86    /// not start.
87    Failed,
88}
89
90/// The delegation that asked for a restart.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub(crate) struct Requester {
93    pub(crate) handle: String,
94    pub(crate) session: String,
95}
96
97/// A planned restart, saved in `<home>/state/update.json` (mode 0600) and
98/// shared by the daemon that plans it, the watchdog, and the next daemon.
99#[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    /// The daemon's executable, where the new release was installed.
111    pub(crate) binary: PathBuf,
112    /// A copy of the release the daemon ran, for rollback.
113    #[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    /// The chat that asked, which hears the outcome.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub(crate) origin: Option<Origin>,
120    /// Accounts connected when the restart went ahead; the new release
121    /// must reconnect them.
122    #[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    /// The restart went ahead at the deadline while work still ran.
129    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
130    pub(crate) waited_out: bool,
131    /// Why the new release failed, for the announcement.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub(crate) detail: Option<String>,
134    /// How long the watchdog gives the new release.
135    #[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
193/// The daemon's executable path. Linux names an executable that was
194/// replaced on disk `<path> (deleted)`; the path is where the new one is.
195fn 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
211/// Whether this process runs in `unit`'s cgroup.
212fn 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
218/// Run `binary build-info` and parse what it reports.
219async 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
236// ---------------------------------------------------------------------------
237// Sessions' own activity, which a restart waits out for the session that
238// asked (its report turn, or a turn the TUI started).
239
240struct 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
248/// A daemon session's entry in the activity table while it lives.
249pub(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    /// A turn runs, or a finished background job waits for its report turn.
271    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// ---------------------------------------------------------------------------
304// Notices: where a message nobody asked for goes.
305
306/// A place a notice may go: an account, and the chat partner there, or the
307/// account's owner.
308#[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    /// An earlier candidate may still connect.
321    Wait,
322    Nothing,
323}
324
325/// The first candidate that is connected and whose peer is known. Until the
326/// grace period is over, a candidate that may still connect keeps its place
327/// ahead of later ones.
328fn 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), &registered, 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
367/// The human name of a component's channel.
368fn 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/// Where component states come from.
378#[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/// Sends notices to the owner through the hub.
407#[derive(Clone)]
408pub(crate) struct Notifier {
409    hub: Arc<Hub>,
410    states: States,
411    /// How long an account ahead in line may take to connect.
412    grace: Duration,
413    /// When an undeliverable notice is dropped.
414    give_up: Duration,
415    poll: Duration,
416    /// Where the notify list is configured.
417    instance: Instance,
418    /// The notify list; `None` reads it from the user configuration.
419    #[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    /// A notifier that sees `states` and the notify `list`, for other
442    /// modules' tests.
443    #[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    /// The owner chat a notice would go to right now, without waiting for
477    /// an account to connect: the first connected notify target, or else
478    /// the chat the owner last wrote from.
479    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    /// The notify list, or else the chat the owner last wrote from.
489    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    /// Store `text` for the `origin` chat, or, when it is not given or does
511    /// not connect in time, for the first reachable notify target other than
512    /// `exclude`. Returns where it went.
513    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        // The asking chat alone first; the notify targets once its grace is
523        // over, saying why the answer comes there.
524        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
581// ---------------------------------------------------------------------------
582// The daemon side: requests, waiting, and handing over to the watchdog.
583
584/// How the restart is carried out once it may go ahead.
585enum Launcher {
586    /// A watchdog unit started with `systemd-run`.
587    Systemd,
588    /// Tests record the plan instead.
589    #[cfg(test)]
590    Record(Arc<SyncMutex<Vec<Plan>>>),
591}
592
593/// Plans restarts for the daemon.
594pub(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    /// The plan being waited on or carried out, and what it waits for.
603    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    /// The scheduled restart, for status replies.
635    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    /// Handle `restart_when_idle`. The error is shown to the caller.
644    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        // An owner confirmation step would go here, before the plan is armed.
717        self.arm(plan)
718    }
719
720    /// Save `plan` and wait for it in the background.
721    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    /// The delegation of this daemon named in a `SCV_PARENT` chain.
743    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    /// What the restart still waits for, or `None` when it may go ahead.
751    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                // The daemon is stopping: the next one finds the plan waiting.
776                () = 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    /// Record the plan as restarting, keep this release's binary, and start
817    /// the watchdog that restarts the unit.
818    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        // The watchdog runs the release known to work: this one.
851        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        // The watchdog selects the same instance and configuration.
860        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
891/// Copy the running executable (still readable through `/proc/self/exe`
892/// after it was replaced on disk) next to `binary` as `<binary>.prev`.
893fn 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
905/// Copy `source` to `target` through a temporary file beside it, executable.
906fn 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
927// ---------------------------------------------------------------------------
928// The watchdog, run by `scv restart-watchdog` outside the daemon.
929
930/// Restart the unit, check the new release, and roll back when it fails and
931/// the releases share a config layout. Records the outcome in the plan.
932pub 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
999/// Why the previous binary may not be put back, or `None` when it may.
1000fn 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
1014/// Wait until the daemon reports `version` and every `expected` account is
1015/// connected, or explain what was missing when `seconds` run out.
1016async 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
1054// ---------------------------------------------------------------------------
1055// Startup: explain the previous run, then announce.
1056
1057/// What the daemon found at startup about how its predecessor ended.
1058pub(crate) struct Startup {
1059    plan: Option<Plan>,
1060    /// The previous daemon stopped without shutting down: its version and
1061    /// start time.
1062    unclean: Option<(String, u64)>,
1063}
1064
1065#[derive(Serialize, Deserialize)]
1066struct Marker {
1067    pid: u32,
1068    version: String,
1069    started_unix: u64,
1070}
1071
1072/// Read the restart plan and the running marker, tell the hub whether this
1073/// start is a planned restart (before any bridge recovers), and mark this
1074/// daemon running until [`clean_shutdown`].
1075pub(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(&current)
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
1110/// The daemon stopped on request: the next one will not report a crash.
1111pub(crate) fn clean_shutdown(layout: &Layout) {
1112    let _ = std::fs::remove_file(layout.daemon_marker());
1113}
1114
1115/// What the next daemon should say about a plan, given its own version.
1116#[derive(Debug, PartialEq, Eq)]
1117enum Decision {
1118    Say(String),
1119    /// The watchdog is still deciding.
1120    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
1155/// The announcement of a finished plan.
1156fn 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
1182/// Announce how the previous run ended, once the accounts can take it.
1183pub(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
1241// ---------------------------------------------------------------------------
1242// Accounts that stay disconnected.
1243
1244/// Tell the owner, through another account, when an enabled account stays
1245/// disconnected for [`DOWN_NOTICE_AFTER`]; once per outage.
1246pub(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;