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 (for a live child that serves a whole
8//! conversation, until its turn has ended and, for a nested SCV, its own
9//! background jobs have been reported to it) and its report is stored, and no
10//! owner message is being answered, or until the request's deadline. It then
11//! records a plan, keeps a copy of its own binary for rollback, and starts a
12//! watchdog outside its own cgroup (`systemd-run`). The watchdog restarts the
13//! unit, checks that the new release comes up with the channels that were
14//! connected before, and otherwise puts the previous binary back when both
15//! releases share a config layout. The daemon that starts next announces the
16//! outcome in the chat that asked, or through the notify list.
17//!
18//! The same notifier tells the owner about restarts after a crash and about
19//! accounts that stay disconnected.
20
21use 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
40/// Where configuration and state files live and how they are shaped. Bump it
41/// when a release reads or writes them in a way the previous release cannot:
42/// a rollback between releases with different layouts is refused.
43pub(crate) const CONFIG_LAYOUT: u32 = 1;
44
45const DEFAULT_MAX_WAIT: u64 = 10 * 60;
46const MAX_WAIT_LIMIT: u64 = 60 * 60;
47/// How long the watchdog gives a new release to report its version and
48/// reconnect the channels that were connected before.
49const VERIFY_SECONDS: u64 = 180;
50/// How long the watchdog waits for a rolled-back release to come back.
51const ROLLBACK_SECONDS: u64 = 90;
52/// Checks a restart must pass in a row before it goes ahead, a second apart,
53/// so a job that just finished has time to start its report.
54const CLEAR_CHECKS: u32 = 2;
55/// An account disconnected this long gets a notice through another account.
56const DOWN_NOTICE_AFTER: Duration = Duration::from_secs(10 * 60);
57const MONITOR_INTERVAL: Duration = Duration::from_secs(30);
58/// A plan restarted this long ago no longer explains interrupted work.
59const RESTART_CONTEXT_MAX_AGE: u64 = 60 * 60;
60/// How long a restart that goes ahead waits for mail actions under way.
61const MAIL_DRAIN: Duration = Duration::from_secs(60);
62
63/// What a binary reports about itself for a planned restart.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct BuildInfo {
66    pub(crate) version: String,
67    pub(crate) config_layout: u32,
68}
69
70/// This binary's build information, printed by `scv build-info`.
71pub 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 for the requesting work to end.
82    Waiting,
83    /// The watchdog is restarting the unit and checking the new release.
84    Restarting,
85    /// The new release came up with its channels.
86    Verified,
87    /// The new release failed and the previous binary was put back.
88    RolledBack,
89    /// The new release failed and was not rolled back, or the restart could
90    /// not start.
91    Failed,
92}
93
94/// The delegation that asked for a restart.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub(crate) struct Requester {
97    pub(crate) handle: String,
98    pub(crate) session: String,
99}
100
101/// A planned restart, saved in `<home>/state/update.json` (mode 0600) and
102/// shared by the daemon that plans it, the watchdog, and the next daemon.
103#[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    /// The daemon's executable, where the new release was installed.
115    pub(crate) binary: PathBuf,
116    /// A copy of the release the daemon ran, for rollback.
117    #[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    /// The chat that asked, which hears the outcome.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub(crate) origin: Option<Origin>,
124    /// Accounts connected when the restart went ahead; the new release
125    /// must reconnect them.
126    #[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    /// The restart went ahead at the deadline while work still ran.
133    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
134    pub(crate) waited_out: bool,
135    /// Why the new release failed, for the announcement.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub(crate) detail: Option<String>,
138    /// How long the watchdog gives the new release.
139    #[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
197/// The daemon's executable path. Linux names an executable that was
198/// replaced on disk `<path> (deleted)`; the path is where the new one is.
199fn 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
215/// Whether this process runs in `unit`'s cgroup.
216fn 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
222/// Run `binary build-info` and parse what it reports.
223async 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
240// ---------------------------------------------------------------------------
241// Sessions' own activity, which a restart waits out: the session that asked
242// (its report turn, or a turn the TUI started), and every session's
243// background jobs.
244
245struct SessionActivity {
246    /// The instance the session belongs to.
247    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
255/// A daemon session's entry in the activity table while it lives.
256pub(crate) struct SessionTracker {
257    id: String,
258    activity: Arc<SessionActivity>,
259}
260
261impl SessionTracker {
262    /// Track session `id` of the instance at `home`.
263    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    /// A turn runs, or a finished background job waits for its report turn.
280    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
296/// Whether session `id` has work a planned restart waits out: a turn, or a
297/// background job still running or not yet reported (one whose report turn
298/// failed and waits to be tried again included).
299fn 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
315/// The jobs of a session a planned restart waits for: those still running
316/// or unreported, but not the queued prompts that wait only for the
317/// owner's takeover of their conversation, which the restart ends.
318fn waited_jobs(jobs: &BackgroundJobs) -> usize {
319    jobs.pending().saturating_sub(jobs.behind_takeovers())
320}
321
322/// The sessions of the instance at `home` that have background jobs still
323/// running, queued, or not yet reported (cancelled reviewed jobs whose final
324/// outcome is owed included), and how many such jobs they have in all.
325fn 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// ---------------------------------------------------------------------------
344// Notices: where a message nobody asked for goes.
345
346/// A place a notice may go: an account, and the chat partner there, or the
347/// account's owner.
348#[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    /// An earlier candidate may still connect.
361    Wait,
362    Nothing,
363}
364
365/// The first candidate that is connected and whose peer is known. Until the
366/// grace period is over, a candidate that may still connect keeps its place
367/// ahead of later ones.
368fn 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), &registered, 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
407/// The human name of a component's channel.
408fn 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/// Where component states come from.
419#[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/// Sends notices to the owner through the hub.
448#[derive(Clone)]
449pub(crate) struct Notifier {
450    hub: Arc<Hub>,
451    states: States,
452    /// How long an account ahead in line may take to connect.
453    grace: Duration,
454    /// When an undeliverable notice is dropped.
455    give_up: Duration,
456    poll: Duration,
457    /// Where the notify list is configured.
458    instance: Instance,
459    /// The notify list; `None` reads it from the user configuration.
460    #[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    /// A notifier that sees `states` and the notify `list`, for other
483    /// modules' tests.
484    #[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    /// The owner chat a notice would go to right now, without waiting for
518    /// an account to connect: the first connected notify target, or else
519    /// the chat the owner last wrote from.
520    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    /// The notify list, or else the chat the owner last wrote from; never a
530    /// mail chat, which carries only mail, and never a mailbox.
531    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    /// Store `text` for the `origin` chat, or, when it is not given or does
560    /// not connect in time, for the first reachable notify target other than
561    /// `exclude`. Returns where it went.
562    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        // The asking chat alone first; the notify targets once its grace is
572        // over, saying why the answer comes there.
573        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
630// ---------------------------------------------------------------------------
631// The daemon side: requests, waiting, and handing over to the watchdog.
632
633/// How the restart is carried out once it may go ahead.
634enum Launcher {
635    /// A watchdog unit started with `systemd-run`.
636    Systemd,
637    /// Tests record the plan instead.
638    #[cfg(test)]
639    Record(Arc<SyncMutex<Vec<Plan>>>),
640}
641
642/// Plans restarts for the daemon.
643pub(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    /// The plan being waited on or carried out, and what it waits for.
652    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    /// The scheduled restart, for status replies.
684    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    /// Handle `restart_when_idle`. The error is shown to the caller.
693    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        // An owner confirmation step would go here, before the plan is armed.
766        self.arm(plan)
767    }
768
769    /// Save `plan` and wait for it in the background.
770    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    /// The delegation of this daemon named in a `SCV_PARENT` chain.
792    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    /// What the restart still waits for, or `None` when it may go ahead.
800    fn waiting_for(&self, plan: &Plan) -> Option<String> {
801        if let Some(requester) = &plan.requester {
802            // A per-turn run works while its processes live; a live child
803            // (nested SCV, ACP agent) keeps its process for the whole
804            // conversation, so it works only while a turn runs on it or,
805            // for a nested SCV, background jobs of its own still run or wait
806            // to be reported to it.
807            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        // Every session's background jobs, not only the requester's: the
824        // restart would stop them.
825        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                // The daemon is stopping: the next one finds the plan waiting.
850                () = 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        // No mail action starts from here on; one under way finishes first,
877        // for at most a minute.
878        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    /// Record the plan as restarting, keep this release's binary, and start
899    /// the watchdog that restarts the unit.
900    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        // The watchdog runs the release known to work: this one.
933        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        // The watchdog selects the same instance and configuration.
942        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
973/// Copy the running executable (still readable through `/proc/self/exe`
974/// after it was replaced on disk) next to `binary` as `<binary>.prev`.
975fn 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
987/// Copy `source` to `target` through a temporary file beside it, executable.
988fn 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
1009// ---------------------------------------------------------------------------
1010// The watchdog, run by `scv restart-watchdog` outside the daemon.
1011
1012/// Restart the unit, check the new release, and roll back when it fails and
1013/// the releases share a config layout. Records the outcome in the plan.
1014pub 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
1081/// Why the previous binary may not be put back, or `None` when it may.
1082fn 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
1096/// Wait until the daemon reports `version` and every `expected` account is
1097/// connected, or explain what was missing when `seconds` run out.
1098async 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
1136// ---------------------------------------------------------------------------
1137// Startup: explain the previous run, then announce.
1138
1139/// What the daemon found at startup about how its predecessor ended.
1140pub(crate) struct Startup {
1141    plan: Option<Plan>,
1142    /// The previous daemon stopped without shutting down: its version and
1143    /// start time.
1144    unclean: Option<(String, u64)>,
1145}
1146
1147#[derive(Serialize, Deserialize)]
1148struct Marker {
1149    pid: u32,
1150    version: String,
1151    started_unix: u64,
1152}
1153
1154/// Read the restart plan and the running marker, tell the hub whether this
1155/// start is a planned restart (before any bridge recovers), and mark this
1156/// daemon running until [`clean_shutdown`].
1157pub(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(&current)
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
1192/// The daemon stopped on request: the next one will not report a crash.
1193pub(crate) fn clean_shutdown(layout: &Layout) {
1194    let _ = std::fs::remove_file(layout.daemon_marker());
1195}
1196
1197/// What the next daemon should say about a plan, given its own version.
1198#[derive(Debug, PartialEq, Eq)]
1199enum Decision {
1200    Say(String),
1201    /// The watchdog is still deciding.
1202    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
1237/// The announcement of a finished plan.
1238fn 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
1264/// Announce how the previous run ended, once the accounts can take it.
1265pub(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
1323// ---------------------------------------------------------------------------
1324// Accounts that stay disconnected.
1325
1326/// Tell the owner, through another account, when an enabled account stays
1327/// disconnected for [`DOWN_NOTICE_AFTER`]; once per outage.
1328pub(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;