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_protocol::{ComponentState, DaemonCommand, RestartInfo};
29use scv_tools::{background::BackgroundJobs, delegation::DelegationRegistry};
30use serde::{Deserialize, Serialize};
31use tokio::sync::Mutex;
32use tokio_util::sync::CancellationToken;
33
34use crate::components::Components;
35
36/// Where configuration and state files live and how they are shaped. Bump it
37/// when a release reads or writes them in a way the previous release cannot:
38/// a rollback between releases with different layouts is refused.
39pub const CONFIG_LAYOUT: u32 = 1;
40
41const DEFAULT_MAX_WAIT: u64 = 10 * 60;
42const MAX_WAIT_LIMIT: u64 = 60 * 60;
43/// How long the watchdog gives a new release to report its version and
44/// reconnect the channels that were connected before.
45const VERIFY_SECONDS: u64 = 180;
46/// How long the watchdog waits for a rolled-back release to come back.
47const ROLLBACK_SECONDS: u64 = 90;
48/// Checks a restart must pass in a row before it goes ahead, a second apart,
49/// so a job that just finished has time to start its report.
50const CLEAR_CHECKS: u32 = 2;
51/// An account disconnected this long gets a notice through another account.
52const DOWN_NOTICE_AFTER: Duration = Duration::from_secs(10 * 60);
53const MONITOR_INTERVAL: Duration = Duration::from_secs(30);
54/// A plan restarted this long ago no longer explains interrupted work.
55const RESTART_CONTEXT_MAX_AGE: u64 = 60 * 60;
56
57/// The directory of SCV's runtime files in an instance home.
58fn runtime_dir(home: &Path) -> PathBuf {
59    scv_client::Layout::new(home).state()
60}
61
62pub(crate) fn plan_path(home: &Path) -> PathBuf {
63    runtime_dir(home).join("update.json")
64}
65
66pub(crate) fn last_owner_path(home: &Path) -> PathBuf {
67    runtime_dir(home).join("last-owner.json")
68}
69
70fn marker_path(home: &Path) -> PathBuf {
71    runtime_dir(home).join("daemon.json")
72}
73
74/// What a binary reports about itself for a planned restart.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct BuildInfo {
77    pub version: String,
78    pub config_layout: u32,
79}
80
81/// This binary's build information, printed by `scv build-info`.
82pub fn build_info() -> BuildInfo {
83    BuildInfo {
84        version: env!("CARGO_PKG_VERSION").into(),
85        config_layout: CONFIG_LAYOUT,
86    }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum PlanState {
92    /// Waiting for the requesting work to end.
93    Waiting,
94    /// The watchdog is restarting the unit and checking the new release.
95    Restarting,
96    /// The new release came up with its channels.
97    Verified,
98    /// The new release failed and the previous binary was put back.
99    RolledBack,
100    /// The new release failed and was not rolled back, or the restart could
101    /// not start.
102    Failed,
103}
104
105/// The delegation that asked for a restart.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct Requester {
108    pub handle: String,
109    pub session: String,
110}
111
112/// A planned restart, saved in `<home>/state/update.json` (mode 0600) and
113/// shared by the daemon that plans it, the watchdog, and the next daemon.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct Plan {
116    pub id: String,
117    pub state: PlanState,
118    pub from_version: String,
119    pub to_version: String,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub commit: Option<String>,
122    pub from_layout: u32,
123    pub to_layout: u32,
124    pub unit: String,
125    /// The daemon's executable, where the new release was installed.
126    pub binary: PathBuf,
127    /// A copy of the release the daemon ran, for rollback.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub previous: Option<PathBuf>,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub requester: Option<Requester>,
132    /// The chat that asked, which hears the outcome.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub origin: Option<Origin>,
135    /// Accounts connected when the restart went ahead; the new release
136    /// must reconnect them.
137    #[serde(default, skip_serializing_if = "Vec::is_empty")]
138    pub expected: Vec<String>,
139    pub requested_unix: u64,
140    pub deadline_unix: u64,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub restart_unix: Option<u64>,
143    /// The restart went ahead at the deadline while work still ran.
144    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
145    pub waited_out: bool,
146    /// Why the new release failed, for the announcement.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub detail: Option<String>,
149    /// How long the watchdog gives the new release.
150    #[serde(default = "default_verify_seconds")]
151    pub verify_seconds: u64,
152}
153
154fn default_verify_seconds() -> u64 {
155    VERIFY_SECONDS
156}
157
158impl Plan {
159    fn info(&self, waiting_for: Option<String>) -> RestartInfo {
160        RestartInfo {
161            to_version: self.to_version.clone(),
162            waiting_for,
163            requester: self.requester.as_ref().map(|r| r.handle.clone()),
164            origin: self.origin.as_ref().map(|origin| origin.component.clone()),
165            deadline_unix_seconds: self.deadline_unix,
166        }
167    }
168
169    fn label(&self) -> String {
170        match &self.commit {
171            Some(commit) => format!("v{} ({commit})", self.to_version),
172            None => format!("v{}", self.to_version),
173        }
174    }
175}
176
177pub(crate) fn load_plan(path: &Path) -> Result<Option<Plan>> {
178    match std::fs::read(path) {
179        Ok(bytes) => {
180            Ok(Some(serde_json::from_slice(&bytes).with_context(|| {
181                format!("parse restart plan {}", path.display())
182            })?))
183        }
184        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
185        Err(error) => Err(error).with_context(|| format!("read {}", path.display())),
186    }
187}
188
189pub(crate) fn save_plan(path: &Path, plan: &Plan) -> Result<()> {
190    write_private(path, &serde_json::to_vec_pretty(plan)?)
191}
192
193fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
194    use std::io::Write as _;
195    let parent = path
196        .parent()
197        .ok_or_else(|| anyhow!("{} has no parent", path.display()))?;
198    std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
199    let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
200    #[cfg(unix)]
201    {
202        use std::os::unix::fs::PermissionsExt;
203        temporary
204            .as_file()
205            .set_permissions(std::fs::Permissions::from_mode(0o600))?;
206    }
207    temporary.write_all(bytes)?;
208    temporary.as_file().sync_all()?;
209    temporary
210        .persist(path)
211        .map_err(|error| error.error)
212        .with_context(|| format!("write {}", path.display()))?;
213    Ok(())
214}
215
216fn unix_now() -> u64 {
217    std::time::SystemTime::now()
218        .duration_since(std::time::UNIX_EPOCH)
219        .map_or(0, |elapsed| elapsed.as_secs())
220}
221
222/// The daemon's executable path. Linux names an executable that was
223/// replaced on disk `<path> (deleted)`; the path is where the new one is.
224fn own_executable() -> Result<PathBuf> {
225    std::env::current_exe()
226        .map(strip_deleted)
227        .context("locate the daemon's executable")
228}
229
230fn strip_deleted(path: PathBuf) -> PathBuf {
231    match path
232        .to_str()
233        .and_then(|text| text.strip_suffix(" (deleted)"))
234    {
235        Some(stripped) => PathBuf::from(stripped),
236        None => path,
237    }
238}
239
240/// Whether this process runs in `unit`'s cgroup.
241fn runs_as_unit(unit: &str) -> bool {
242    let suffix = format!("/{unit}");
243    std::fs::read_to_string("/proc/self/cgroup")
244        .is_ok_and(|text| text.lines().any(|line| line.ends_with(&suffix)))
245}
246
247/// Run `binary build-info` and parse what it reports.
248async fn probe(binary: &Path) -> Result<BuildInfo> {
249    let output = tokio::time::timeout(
250        Duration::from_secs(10),
251        tokio::process::Command::new(binary)
252            .arg("build-info")
253            .stdin(std::process::Stdio::null())
254            .kill_on_drop(true)
255            .output(),
256    )
257    .await
258    .map_err(|_| anyhow!("it did not answer within 10 seconds"))??;
259    if !output.status.success() {
260        bail!("it exited with {}", output.status);
261    }
262    serde_json::from_slice(&output.stdout).context("it printed no build information")
263}
264
265// ---------------------------------------------------------------------------
266// Sessions' own activity, which a restart waits out for the session that
267// asked (its report turn, or a turn the TUI started).
268
269struct SessionActivity {
270    busy: AtomicBool,
271    background: Option<Weak<BackgroundJobs>>,
272}
273
274static SESSIONS: LazyLock<SyncMutex<HashMap<String, Arc<SessionActivity>>>> =
275    LazyLock::new(Default::default);
276
277/// A daemon session's entry in the activity table while it lives.
278pub(crate) struct SessionTracker {
279    id: String,
280    activity: Arc<SessionActivity>,
281}
282
283impl SessionTracker {
284    pub(crate) fn new(id: &str, background: Option<&Arc<BackgroundJobs>>) -> Self {
285        let activity = Arc::new(SessionActivity {
286            busy: AtomicBool::new(false),
287            background: background.map(Arc::downgrade),
288        });
289        SESSIONS
290            .lock()
291            .unwrap_or_else(PoisonError::into_inner)
292            .insert(id.to_owned(), Arc::clone(&activity));
293        Self {
294            id: id.to_owned(),
295            activity,
296        }
297    }
298
299    /// A turn runs, or a finished background job waits for its report turn.
300    pub(crate) fn set_busy(&self, busy: bool) {
301        self.activity
302            .busy
303            .store(busy, std::sync::atomic::Ordering::Release);
304    }
305}
306
307impl Drop for SessionTracker {
308    fn drop(&mut self) {
309        SESSIONS
310            .lock()
311            .unwrap_or_else(PoisonError::into_inner)
312            .remove(&self.id);
313    }
314}
315
316fn session_busy(id: &str) -> bool {
317    let activity = SESSIONS
318        .lock()
319        .unwrap_or_else(PoisonError::into_inner)
320        .get(id)
321        .cloned();
322    activity.is_some_and(|activity| {
323        activity.busy.load(std::sync::atomic::Ordering::Acquire)
324            || activity
325                .background
326                .as_ref()
327                .and_then(Weak::upgrade)
328                .is_some_and(|jobs| jobs.running() > 0)
329    })
330}
331
332// ---------------------------------------------------------------------------
333// Notices: where a message nobody asked for goes.
334
335/// A place a notice may go: an account, and the chat partner there, or the
336/// account's owner.
337#[derive(Debug, Clone, PartialEq, Eq)]
338struct Candidate {
339    component: String,
340    peer: Option<String>,
341}
342
343#[derive(Debug, Clone, PartialEq, Eq)]
344enum Pick {
345    Send {
346        component: String,
347        peer: String,
348    },
349    /// An earlier candidate may still connect.
350    Wait,
351    Nothing,
352}
353
354/// The first candidate that is connected and whose peer is known. Until the
355/// grace period is over, a candidate that may still connect keeps its place
356/// ahead of later ones.
357fn pick(
358    candidates: &[Candidate],
359    states: &HashMap<String, ComponentState>,
360    owner: &dyn Fn(&str) -> Option<Option<String>>,
361    exclude: Option<&str>,
362    grace_over: bool,
363) -> Pick {
364    for candidate in candidates {
365        if exclude == Some(candidate.component.as_str()) {
366            continue;
367        }
368        let registered = owner(&candidate.component);
369        let peer = candidate
370            .peer
371            .clone()
372            .or_else(|| registered.clone().flatten());
373        match (states.get(&candidate.component), &registered, peer) {
374            (Some(ComponentState::Connected), Some(_), Some(peer)) => {
375                return Pick::Send {
376                    component: candidate.component.clone(),
377                    peer,
378                };
379            }
380            (
381                Some(
382                    ComponentState::Starting
383                    | ComponentState::Connected
384                    | ComponentState::Disconnected
385                    | ComponentState::Backoff,
386                ),
387                _,
388                _,
389            ) if !grace_over => return Pick::Wait,
390            _ => {}
391        }
392    }
393    Pick::Nothing
394}
395
396/// The human name of a component's channel.
397fn channel_title(component: &str) -> &str {
398    match component.split(':').next() {
399        Some("wechat") => "WeChat",
400        Some("feishu") => "Feishu",
401        Some(other) => other,
402        None => component,
403    }
404}
405
406/// Where component states come from.
407#[derive(Clone)]
408enum States {
409    Components(Weak<Mutex<Components>>),
410    #[cfg(test)]
411    Fixed(Arc<SyncMutex<HashMap<String, ComponentState>>>),
412}
413
414impl States {
415    async fn get(&self) -> HashMap<String, ComponentState> {
416        match self {
417            Self::Components(components) => match components.upgrade() {
418                Some(components) => components
419                    .lock()
420                    .await
421                    .status()
422                    .components
423                    .into_iter()
424                    .filter(|health| health.enabled)
425                    .map(|health| (health.id, health.state))
426                    .collect(),
427                None => HashMap::new(),
428            },
429            #[cfg(test)]
430            Self::Fixed(states) => states.lock().unwrap().clone(),
431        }
432    }
433}
434
435/// Sends notices to the owner through the hub.
436#[derive(Clone)]
437pub(crate) struct Notifier {
438    hub: Arc<Hub>,
439    states: States,
440    /// How long an account ahead in line may take to connect.
441    grace: Duration,
442    /// When an undeliverable notice is dropped.
443    give_up: Duration,
444    poll: Duration,
445    /// The notify list; `None` reads it from the user configuration.
446    #[cfg(test)]
447    list: Option<Vec<String>>,
448}
449
450impl Notifier {
451    pub(crate) fn new(hub: Arc<Hub>, components: Weak<Mutex<Components>>) -> Self {
452        Self {
453            hub,
454            states: States::Components(components),
455            grace: Duration::from_secs(120),
456            give_up: Duration::from_secs(15 * 60),
457            poll: Duration::from_secs(2),
458            #[cfg(test)]
459            list: None,
460        }
461    }
462
463    fn notify_list(&self) -> Vec<String> {
464        #[cfg(test)]
465        if let Some(list) = &self.list {
466            return list.clone();
467        }
468        crate::Config::load_user(crate::ConfigOverrides::default())
469            .map(|config| config.notify.owner)
470            .unwrap_or_else(|error| {
471                tracing::warn!(
472                    "Notices use the owner's last chat; configuration failed: {error:#}"
473                );
474                Vec::new()
475            })
476    }
477
478    /// The notify list, or else the chat the owner last wrote from.
479    fn candidates(&self) -> Vec<Candidate> {
480        let list = self.notify_list();
481        if !list.is_empty() {
482            return list
483                .into_iter()
484                .map(|component| Candidate {
485                    component,
486                    peer: None,
487                })
488                .collect();
489        }
490        self.hub
491            .last_owner()
492            .map(|last| Candidate {
493                component: last.component,
494                peer: Some(last.peer),
495            })
496            .into_iter()
497            .collect()
498    }
499
500    /// Store `text` for the `origin` chat, or, when it is not given or does
501    /// not connect in time, for the first reachable notify target other than
502    /// `exclude`. Returns where it went.
503    pub(crate) async fn deliver(
504        &self,
505        origin: Option<&Origin>,
506        text: &str,
507        exclude: Option<&str>,
508        cancel: &CancellationToken,
509    ) -> Option<String> {
510        let started = tokio::time::Instant::now();
511        let fallback = self.candidates();
512        // The asking chat alone first; the notify targets once its grace is
513        // over, saying why the answer comes there.
514        let mut phase = match origin {
515            Some(origin) => (
516                vec![Candidate {
517                    component: origin.component.clone(),
518                    peer: Some(origin.peer.clone()),
519                }],
520                None,
521                text.to_owned(),
522            ),
523            None => (fallback.clone(), exclude, text.to_owned()),
524        };
525        let mut phase_started = started;
526        loop {
527            let states = self.states.get().await;
528            let grace_over = phase_started.elapsed() >= self.grace;
529            if let Some(origin) = origin
530                && grace_over
531                && phase.1.is_none()
532            {
533                phase = (
534                    fallback.clone(),
535                    Some(origin.component.as_str()),
536                    format!(
537                        "(You asked on {}, which is not connected, so this comes here.) {text}",
538                        channel_title(&origin.component)
539                    ),
540                );
541                phase_started = tokio::time::Instant::now();
542                continue;
543            }
544            let (candidates, exclude, text) = &phase;
545            let owner = |component: &str| self.hub.owner(component);
546            match pick(candidates, &states, &owner, *exclude, grace_over) {
547                Pick::Send { component, peer } => {
548                    match self.hub.notify(&component, &peer, text).await {
549                        Ok(()) => return Some(component),
550                        Err(error) => tracing::warn!("Notice to {component} not stored: {error}"),
551                    }
552                }
553                Pick::Nothing if grace_over => {
554                    tracing::warn!("No connected account can take this notice: {text}");
555                    return None;
556                }
557                Pick::Wait | Pick::Nothing => {}
558            }
559            if started.elapsed() >= self.give_up {
560                tracing::warn!("Gave up delivering a notice: {text}");
561                return None;
562            }
563            tokio::select! {
564                _ = cancel.cancelled() => return None,
565                _ = tokio::time::sleep(self.poll) => {}
566            }
567        }
568    }
569}
570
571// ---------------------------------------------------------------------------
572// The daemon side: requests, waiting, and handing over to the watchdog.
573
574/// How the restart is carried out once it may go ahead.
575enum Launcher {
576    /// A watchdog unit started with `systemd-run`.
577    Systemd,
578    /// Tests record the plan instead.
579    #[cfg(test)]
580    Record(Arc<SyncMutex<Vec<Plan>>>),
581}
582
583/// Plans restarts for the daemon.
584pub(crate) struct Restarter {
585    launcher: Launcher,
586    home: PathBuf,
587    hub: Arc<Hub>,
588    registry: Arc<DelegationRegistry>,
589    notifier: Notifier,
590    components: Weak<Mutex<Components>>,
591    cancel: CancellationToken,
592    /// The plan being waited on or carried out, and what it waits for.
593    current: SyncMutex<Option<(Plan, Option<String>)>>,
594}
595
596impl Restarter {
597    pub(crate) fn new(
598        home: PathBuf,
599        hub: Arc<Hub>,
600        registry: Arc<DelegationRegistry>,
601        components: &Arc<Mutex<Components>>,
602        cancel: CancellationToken,
603    ) -> Arc<Self> {
604        Arc::new(Self {
605            launcher: Launcher::Systemd,
606            notifier: Notifier::new(Arc::clone(&hub), Arc::downgrade(components)),
607            home,
608            hub,
609            registry,
610            components: Arc::downgrade(components),
611            cancel,
612            current: SyncMutex::new(None),
613        })
614    }
615
616    pub(crate) fn notifier(&self) -> &Notifier {
617        &self.notifier
618    }
619
620    /// The scheduled restart, for status replies.
621    pub(crate) fn info(&self) -> Option<RestartInfo> {
622        self.current
623            .lock()
624            .unwrap_or_else(PoisonError::into_inner)
625            .as_ref()
626            .map(|(plan, waiting)| plan.info(waiting.clone()))
627    }
628
629    /// Handle `restart_when_idle`. The error is shown to the caller.
630    pub(crate) async fn request(
631        self: &Arc<Self>,
632        command: DaemonCommand,
633    ) -> std::result::Result<RestartInfo, String> {
634        let DaemonCommand::RestartWhenIdle {
635            version,
636            commit,
637            parent,
638            max_wait_seconds,
639        } = command
640        else {
641            return Err("not a restart request".into());
642        };
643        if let Some(info) = self.info() {
644            return if version.as_deref().is_none_or(|v| v == info.to_version) {
645                Ok(info)
646            } else {
647                Err(format!(
648                    "a restart into v{} is already scheduled",
649                    info.to_version
650                ))
651            };
652        }
653        let unit = crate::service_name().map_err(|error| error.to_string())?;
654        if !runs_as_unit(&unit) {
655            return Err(format!(
656                "this daemon does not run as {unit}, so it cannot restart itself; \
657                 restart it yourself"
658            ));
659        }
660        let binary = own_executable().map_err(|error| format!("{error:#}"))?;
661        let installed = probe(&binary).await.map_err(|error| {
662            format!(
663                "the binary at {} does not run ({error:#}); not restarting",
664                binary.display()
665            )
666        })?;
667        if let Some(version) = &version
668            && version != &installed.version
669        {
670            return Err(format!(
671                "{} reports v{}, not v{version}; not restarting",
672                binary.display(),
673                installed.version
674            ));
675        }
676        let requester = parent.as_deref().and_then(|chain| self.requester(chain));
677        let now = unix_now();
678        let wait = max_wait_seconds
679            .unwrap_or(DEFAULT_MAX_WAIT)
680            .min(MAX_WAIT_LIMIT);
681        let plan = Plan {
682            id: uuid::Uuid::new_v4().simple().to_string()[..8].to_owned(),
683            state: PlanState::Waiting,
684            from_version: env!("CARGO_PKG_VERSION").into(),
685            to_version: installed.version,
686            commit: commit.filter(|commit| !commit.trim().is_empty()),
687            from_layout: CONFIG_LAYOUT,
688            to_layout: installed.config_layout,
689            unit,
690            previous: None,
691            binary,
692            requester,
693            origin: None,
694            expected: Vec::new(),
695            requested_unix: now,
696            deadline_unix: now + wait,
697            restart_unix: None,
698            waited_out: false,
699            detail: None,
700            verify_seconds: VERIFY_SECONDS,
701        };
702        // An owner confirmation step would go here, before the plan is armed.
703        self.arm(plan)
704    }
705
706    /// Save `plan` and wait for it in the background.
707    fn arm(self: &Arc<Self>, mut plan: Plan) -> std::result::Result<RestartInfo, String> {
708        plan.origin = plan
709            .requester
710            .as_ref()
711            .and_then(|requester| self.hub.origin(&requester.session));
712        save_plan(&plan_path(&self.home), &plan).map_err(|error| format!("{error:#}"))?;
713        let waiting = self.waiting_for(&plan);
714        let info = plan.info(waiting.clone());
715        *self.current.lock().unwrap_or_else(PoisonError::into_inner) =
716            Some((plan.clone(), waiting));
717        tracing::info!(
718            "Restart into v{} scheduled; waiting at most {} seconds",
719            plan.to_version,
720            plan.deadline_unix.saturating_sub(plan.requested_unix)
721        );
722        let restarter = Arc::clone(self);
723        tokio::spawn(async move { restarter.wait_and_restart(plan).await });
724        Ok(info)
725    }
726
727    /// The delegation of this daemon named in a `SCV_PARENT` chain.
728    fn requester(&self, chain: &str) -> Option<Requester> {
729        let own = std::process::id();
730        let entries = self.registry.list(true);
731        chain.split(';').find_map(|entry| {
732            let mut parts = entry.splitn(3, '/');
733            let (instance, session, handle) = (parts.next()?, parts.next()?, parts.next()?);
734            if instance != self.registry.instance() {
735                return None;
736            }
737            entries
738                .iter()
739                .find(|running| running.record.handle == handle && running.record.owner.pid == own)
740                .map(|_| Requester {
741                    handle: handle.to_owned(),
742                    session: session.to_owned(),
743                })
744        })
745    }
746
747    /// What the restart still waits for, or `None` when it may go ahead.
748    fn waiting_for(&self, plan: &Plan) -> Option<String> {
749        if let Some(requester) = &plan.requester {
750            let running = self
751                .registry
752                .list(true)
753                .into_iter()
754                .any(|entry| entry.record.handle == requester.handle && entry.processes > 0);
755            if running {
756                return Some(format!("{} to finish", requester.handle));
757            }
758            if session_busy(&requester.session) || self.hub.session_work(&requester.session) > 0 {
759                return Some(format!("{}'s report", requester.handle));
760            }
761        }
762        if self.hub.owner_claims() > 0 {
763            return Some("an owner message to be answered".into());
764        }
765        None
766    }
767
768    async fn wait_and_restart(self: Arc<Self>, mut plan: Plan) {
769        let mut clear = 0;
770        loop {
771            tokio::select! {
772                // The daemon is stopping: the next one finds the plan waiting.
773                _ = self.cancel.cancelled() => return,
774                _ = tokio::time::sleep(Duration::from_secs(1)) => {}
775            }
776            let waiting = self.waiting_for(&plan);
777            clear = if waiting.is_none() { clear + 1 } else { 0 };
778            if let Some((_, current)) = self
779                .current
780                .lock()
781                .unwrap_or_else(PoisonError::into_inner)
782                .as_mut()
783            {
784                current.clone_from(&waiting);
785            }
786            if clear >= CLEAR_CHECKS {
787                break;
788            }
789            if unix_now() >= plan.deadline_unix {
790                tracing::warn!(
791                    "Restarting into v{} at its deadline while waiting for {}",
792                    plan.to_version,
793                    waiting.as_deref().unwrap_or("work")
794                );
795                plan.waited_out = true;
796                break;
797            }
798        }
799        if let Err(error) = self.hand_over(&mut plan).await {
800            tracing::error!("Restart into v{} did not start: {error:#}", plan.to_version);
801            plan.state = PlanState::Failed;
802            plan.detail = Some(format!("the restart did not start: {error:#}"));
803            let _ = save_plan(&plan_path(&self.home), &plan);
804            *self.current.lock().unwrap_or_else(PoisonError::into_inner) = None;
805            let text = announcement(&plan, env!("CARGO_PKG_VERSION"));
806            self.notifier
807                .deliver(plan.origin.as_ref(), &text, None, &self.cancel)
808                .await;
809            let _ = std::fs::remove_file(plan_path(&self.home));
810        }
811    }
812
813    /// Record the plan as restarting, keep this release's binary, and start
814    /// the watchdog that restarts the unit.
815    async fn hand_over(&self, plan: &mut Plan) -> Result<()> {
816        plan.state = PlanState::Restarting;
817        plan.restart_unix = Some(unix_now());
818        if let Some(components) = self.components.upgrade() {
819            plan.expected = components
820                .lock()
821                .await
822                .status()
823                .components
824                .into_iter()
825                .filter(|health| health.enabled && health.state == ComponentState::Connected)
826                .map(|health| health.id)
827                .collect();
828        }
829        match &self.launcher {
830            Launcher::Systemd => {}
831            #[cfg(test)]
832            Launcher::Record(plans) => {
833                save_plan(&plan_path(&self.home), plan)?;
834                plans.lock().unwrap().push(plan.clone());
835                return Ok(());
836            }
837        }
838        plan.previous = match keep_previous(&plan.binary) {
839            Ok(path) => Some(path),
840            Err(error) => {
841                tracing::warn!("No rollback copy of this release: {error:#}");
842                None
843            }
844        };
845        let path = plan_path(&self.home);
846        save_plan(&path, plan)?;
847        // The watchdog runs the release known to work: this one.
848        let watchdog = plan.previous.clone().unwrap_or_else(|| plan.binary.clone());
849        let mut command = std::process::Command::new("systemd-run");
850        command.args([
851            "--user",
852            "--quiet",
853            "--collect",
854            &format!("--unit=scv-update-{}", plan.id),
855        ]);
856        for variable in ["SCV_HOME", "SCV_CONFIG"] {
857            if let Some(value) = std::env::var_os(variable) {
858                let mut setting = std::ffi::OsString::from(format!("--setenv={variable}="));
859                setting.push(value);
860                command.arg(setting);
861            }
862        }
863        command
864            .arg(watchdog)
865            .arg("restart-watchdog")
866            .arg("--plan")
867            .arg(&path)
868            .stdin(std::process::Stdio::null());
869        let status = tokio::task::spawn_blocking(move || command.status())
870            .await?
871            .context("run systemd-run")?;
872        if !status.success() {
873            bail!("systemd-run exited with {status}");
874        }
875        tracing::info!(
876            "Handed the restart into v{} to unit scv-update-{}",
877            plan.to_version,
878            plan.id
879        );
880        Ok(())
881    }
882}
883
884/// Copy the running executable (still readable through `/proc/self/exe`
885/// after it was replaced on disk) next to `binary` as `<binary>.prev`.
886fn keep_previous(binary: &Path) -> Result<PathBuf> {
887    let previous = binary.with_file_name(format!(
888        "{}.prev",
889        binary
890            .file_name()
891            .and_then(|name| name.to_str())
892            .unwrap_or("scv")
893    ));
894    install_copy(Path::new("/proc/self/exe"), &previous)?;
895    Ok(previous)
896}
897
898/// Copy `source` to `target` through a temporary file beside it, executable.
899fn install_copy(source: &Path, target: &Path) -> Result<()> {
900    let parent = target
901        .parent()
902        .ok_or_else(|| anyhow!("{} has no parent", target.display()))?;
903    let temporary = tempfile::Builder::new()
904        .prefix(".scv-install")
905        .tempfile_in(parent)?;
906    std::fs::copy(source, temporary.path())
907        .with_context(|| format!("copy {} to {}", source.display(), target.display()))?;
908    #[cfg(unix)]
909    {
910        use std::os::unix::fs::PermissionsExt;
911        std::fs::set_permissions(temporary.path(), std::fs::Permissions::from_mode(0o755))?;
912    }
913    temporary
914        .persist(target)
915        .map_err(|error| error.error)
916        .with_context(|| format!("install {}", target.display()))?;
917    Ok(())
918}
919
920// ---------------------------------------------------------------------------
921// The watchdog, run by `scv restart-watchdog` outside the daemon.
922
923/// Restart the unit, check the new release, and roll back when it fails and
924/// the releases share a config layout. Records the outcome in the plan.
925pub async fn watchdog(plan_path: &Path) -> Result<()> {
926    let mut plan = load_plan(plan_path)?.context("no restart plan")?;
927    if plan.state != PlanState::Restarting {
928        bail!("the restart plan is {:?}, not restarting", plan.state);
929    }
930    let socket = scv_client::default_socket_path()?;
931    eprintln!("Restarting {} into v{}", plan.unit, plan.to_version);
932    systemctl_restart(&plan.unit);
933    let outcome = verify(
934        &socket,
935        &plan.to_version,
936        &plan.expected,
937        plan.verify_seconds,
938    )
939    .await;
940    match outcome {
941        Ok(()) => {
942            eprintln!("v{} is up with its channels", plan.to_version);
943            plan.state = PlanState::Verified;
944        }
945        Err(reason) => {
946            eprintln!("v{} failed: {reason}", plan.to_version);
947            match rollback_refusal(&plan) {
948                None => {
949                    let previous = plan.previous.clone().expect("checked by rollback_refusal");
950                    let detail = match install_copy(&previous, &plan.binary) {
951                        Ok(()) => {
952                            systemctl_restart(&plan.unit);
953                            let seconds = plan.verify_seconds.min(ROLLBACK_SECONDS);
954                            match verify(&socket, &plan.from_version, &[], seconds).await {
955                                Ok(()) => reason,
956                                Err(again) => format!(
957                                    "{reason}; after the rollback v{} did not come back either ({again})",
958                                    plan.from_version
959                                ),
960                            }
961                        }
962                        Err(error) => format!(
963                            "{reason}; putting v{} back failed: {error:#}",
964                            plan.from_version
965                        ),
966                    };
967                    plan.state = PlanState::RolledBack;
968                    plan.detail = Some(detail);
969                }
970                Some(refusal) => {
971                    plan.state = PlanState::Failed;
972                    plan.detail = Some(format!("{reason}; not rolled back: {refusal}"));
973                }
974            }
975        }
976    }
977    save_plan(plan_path, &plan)?;
978    Ok(())
979}
980
981fn systemctl_restart(unit: &str) {
982    match std::process::Command::new("systemctl")
983        .args(["--user", "restart", unit])
984        .status()
985    {
986        Ok(status) if status.success() => {}
987        Ok(status) => eprintln!("systemctl --user restart {unit} exited with {status}"),
988        Err(error) => eprintln!("could not run systemctl: {error}"),
989    }
990}
991
992/// Why the previous binary may not be put back, or `None` when it may.
993fn rollback_refusal(plan: &Plan) -> Option<String> {
994    if plan.to_layout != plan.from_layout {
995        return Some(format!(
996            "v{} uses config layout {} and v{} uses {}, so the older binary cannot read the \
997             current configuration",
998            plan.to_version, plan.to_layout, plan.from_version, plan.from_layout
999        ));
1000    }
1001    match &plan.previous {
1002        Some(previous) if previous.is_file() => None,
1003        _ => Some(format!("no copy of v{} was kept", plan.from_version)),
1004    }
1005}
1006
1007/// Wait until the daemon reports `version` and every `expected` account is
1008/// connected, or explain what was missing when `seconds` run out.
1009async fn verify(
1010    socket: &Path,
1011    version: &str,
1012    expected: &[String],
1013    seconds: u64,
1014) -> std::result::Result<(), String> {
1015    let deadline = tokio::time::Instant::now() + Duration::from_secs(seconds);
1016    let mut last = format!("v{version} did not start");
1017    loop {
1018        match scv_client::control(socket, DaemonCommand::Status).await {
1019            Ok(status) if status.version == version => {
1020                let missing: Vec<_> = expected
1021                    .iter()
1022                    .filter(|id| {
1023                        !status.components.iter().any(|health| {
1024                            &health.id == *id && health.state == ComponentState::Connected
1025                        })
1026                    })
1027                    .map(String::as_str)
1028                    .collect();
1029                if missing.is_empty() {
1030                    return Ok(());
1031                }
1032                last = format!(
1033                    "v{version} started, but {} did not reconnect",
1034                    missing.join(" and ")
1035                );
1036            }
1037            Ok(status) => last = format!("SCV still reports v{}", status.version),
1038            Err(_) => {}
1039        }
1040        if tokio::time::Instant::now() >= deadline {
1041            return Err(last);
1042        }
1043        tokio::time::sleep(Duration::from_secs(2)).await;
1044    }
1045}
1046
1047// ---------------------------------------------------------------------------
1048// Startup: explain the previous run, then announce.
1049
1050/// What the daemon found at startup about how its predecessor ended.
1051pub(crate) struct Startup {
1052    plan: Option<Plan>,
1053    /// The previous daemon stopped without shutting down: its version and
1054    /// start time.
1055    unclean: Option<(String, u64)>,
1056}
1057
1058#[derive(Serialize, Deserialize)]
1059struct Marker {
1060    pid: u32,
1061    version: String,
1062    started_unix: u64,
1063}
1064
1065/// Read the restart plan and the running marker, tell the hub whether this
1066/// start is a planned restart (before any bridge recovers), and mark this
1067/// daemon running until [`clean_shutdown`].
1068pub(crate) fn startup(home: &Path, hub: &Hub) -> Startup {
1069    let plan = load_plan(&plan_path(home)).unwrap_or_else(|error| {
1070        tracing::warn!("Ignoring an unreadable restart plan: {error:#}");
1071        let _ = std::fs::remove_file(plan_path(home));
1072        None
1073    });
1074    let planned = plan.as_ref().filter(|plan| {
1075        plan.state != PlanState::Waiting
1076            && plan
1077                .restart_unix
1078                .is_some_and(|at| unix_now().saturating_sub(at) < RESTART_CONTEXT_MAX_AGE)
1079    });
1080    hub.set_restart(planned.map(|plan| Restart {
1081        to_version: plan.to_version.clone(),
1082    }));
1083    let marker = marker_path(home);
1084    let unclean = std::fs::read(&marker)
1085        .ok()
1086        .and_then(|bytes| serde_json::from_slice::<Marker>(&bytes).ok())
1087        .filter(|previous| previous.pid != std::process::id())
1088        .map(|previous| (previous.version, previous.started_unix));
1089    let current = Marker {
1090        pid: std::process::id(),
1091        version: env!("CARGO_PKG_VERSION").into(),
1092        started_unix: unix_now(),
1093    };
1094    if let Err(error) = serde_json::to_vec(&current)
1095        .map_err(anyhow::Error::from)
1096        .and_then(|bytes| write_private(&marker, &bytes))
1097    {
1098        tracing::warn!("Could not record the running daemon: {error:#}");
1099    }
1100    Startup { plan, unclean }
1101}
1102
1103/// The daemon stopped on request: the next one will not report a crash.
1104pub(crate) fn clean_shutdown(home: &Path) {
1105    let _ = std::fs::remove_file(marker_path(home));
1106}
1107
1108/// What the next daemon should say about a plan, given its own version.
1109#[derive(Debug, PartialEq, Eq)]
1110enum Decision {
1111    Say(String),
1112    /// The watchdog is still deciding.
1113    Wait,
1114    Drop,
1115}
1116
1117fn decide(plan: &Plan, own: &str, watchdog_overdue: bool) -> Decision {
1118    match plan.state {
1119        PlanState::Waiting => Decision::Say(if own == plan.to_version {
1120            format!(
1121                "SCV is now running {}. It stopped before the planned restart, so work that \
1122                 was running then was stopped.",
1123                plan.label()
1124            )
1125        } else {
1126            format!(
1127                "SCV stopped before it could restart into v{}; it is running v{own}. Deploy \
1128                 again to finish the update.",
1129                plan.to_version
1130            )
1131        }),
1132        PlanState::Restarting if !watchdog_overdue => Decision::Wait,
1133        PlanState::Restarting if own == plan.to_version => Decision::Say(format!(
1134            "SCV is now running {}; the update watchdog did not report back.",
1135            plan.label()
1136        )),
1137        PlanState::Restarting if own == plan.from_version => Decision::Say(format!(
1138            "The update to v{} did not take effect; SCV is still running v{own}.",
1139            plan.to_version
1140        )),
1141        PlanState::Restarting => Decision::Drop,
1142        PlanState::Verified | PlanState::RolledBack | PlanState::Failed => {
1143            Decision::Say(announcement(plan, own))
1144        }
1145    }
1146}
1147
1148/// The announcement of a finished plan.
1149fn announcement(plan: &Plan, own: &str) -> String {
1150    let detail = plan.detail.as_deref().unwrap_or("it did not come up");
1151    let mut text = match plan.state {
1152        PlanState::Verified => format!("SCV updated: now running {}.", plan.label()),
1153        PlanState::RolledBack => format!(
1154            "The update to v{} failed: {detail}. SCV rolled back to v{}.",
1155            plan.to_version, plan.from_version
1156        ),
1157        PlanState::Failed if own == plan.to_version => {
1158            format!("SCV is running {}, but {detail}.", plan.label())
1159        }
1160        _ => format!("The update to v{} failed: {detail}.", plan.to_version),
1161    };
1162    if plan.waited_out {
1163        let minutes = plan
1164            .deadline_unix
1165            .saturating_sub(plan.requested_unix)
1166            .div_ceil(60);
1167        text.push_str(&format!(
1168            " It waited {minutes} minutes for running work, then restarted anyway; work \
1169             still running then was stopped."
1170        ));
1171    }
1172    text
1173}
1174
1175/// Announce how the previous run ended, once the accounts can take it.
1176pub(crate) async fn announce(
1177    home: PathBuf,
1178    startup: Startup,
1179    notifier: Notifier,
1180    cancel: CancellationToken,
1181) {
1182    let own = env!("CARGO_PKG_VERSION");
1183    if let Some(mut plan) = startup.plan {
1184        let path = plan_path(&home);
1185        let overdue_at = plan.restart_unix.unwrap_or(plan.requested_unix)
1186            + plan.verify_seconds
1187            + ROLLBACK_SECONDS
1188            + 60;
1189        let text = loop {
1190            match decide(&plan, own, unix_now() >= overdue_at) {
1191                Decision::Say(text) => break Some(text),
1192                Decision::Drop => break None,
1193                Decision::Wait => {}
1194            }
1195            tokio::select! {
1196                _ = cancel.cancelled() => return,
1197                _ = tokio::time::sleep(Duration::from_secs(2)) => {}
1198            }
1199            match load_plan(&path) {
1200                Ok(Some(reloaded)) if reloaded.id == plan.id => plan = reloaded,
1201                _ => break None,
1202            }
1203        };
1204        if let Some(text) = text {
1205            tracing::info!("{text}");
1206            notifier
1207                .deliver(plan.origin.as_ref(), &text, None, &cancel)
1208                .await;
1209        }
1210        if !cancel.is_cancelled() {
1211            let _ = std::fs::remove_file(&path);
1212        }
1213    } else if let Some((version, started)) = startup.unclean {
1214        let text = format!(
1215            "SCV started again after an unexpected stop (a crash or a host restart); it had run \
1216             v{version} since {}. Work in progress then was stopped.",
1217            format_time(started)
1218        );
1219        tracing::warn!("{text}");
1220        notifier.deliver(None, &text, None, &cancel).await;
1221    }
1222}
1223
1224fn format_time(unix: u64) -> String {
1225    let age = unix_now().saturating_sub(unix);
1226    match age {
1227        0..=119 => "moments before".into(),
1228        120..=7199 => format!("{} minutes before", age / 60),
1229        7200..=172_799 => format!("{} hours before", age / 3600),
1230        _ => format!("{} days before", age / 86_400),
1231    }
1232}
1233
1234// ---------------------------------------------------------------------------
1235// Accounts that stay disconnected.
1236
1237/// Tell the owner, through another account, when an enabled account stays
1238/// disconnected for [`DOWN_NOTICE_AFTER`]; once per outage.
1239pub(crate) async fn monitor(notifier: Notifier, cancel: CancellationToken) {
1240    let mut down: HashMap<String, (tokio::time::Instant, bool)> = HashMap::new();
1241    loop {
1242        tokio::select! {
1243            _ = cancel.cancelled() => return,
1244            _ = tokio::time::sleep(MONITOR_INTERVAL) => {}
1245        }
1246        let states = notifier.states.get().await;
1247        down.retain(|id, _| {
1248            states
1249                .get(id)
1250                .is_some_and(|state| *state != ComponentState::Connected)
1251        });
1252        for (id, state) in &states {
1253            if *state == ComponentState::Connected {
1254                continue;
1255            }
1256            let (since, told) = down
1257                .entry(id.clone())
1258                .or_insert((tokio::time::Instant::now(), false));
1259            if *told || since.elapsed() < DOWN_NOTICE_AFTER {
1260                continue;
1261            }
1262            *told = true;
1263            let (channel, account) = id.split_once(':').unwrap_or((id, "default"));
1264            let text = format!(
1265                "SCV's {} account {account} has been disconnected for {} minutes; its sign-in \
1266                 may have expired. On the host, check `scv channels status {channel}` and sign \
1267                 in again with `scv channels login {channel}` if needed.",
1268                channel_title(id),
1269                since.elapsed().as_secs() / 60
1270            );
1271            tracing::warn!("{text}");
1272            let notifier = notifier.clone();
1273            let cancel = cancel.clone();
1274            let id = id.clone();
1275            tokio::spawn(async move { notifier.deliver(None, &text, Some(&id), &cancel).await });
1276        }
1277    }
1278}
1279
1280#[cfg(test)]
1281mod tests {
1282    use super::*;
1283    use scv_channels::hub::Link;
1284
1285    /// Notices a bridge stand-in stored, as (to, text).
1286    type Stored = Arc<SyncMutex<Vec<(String, String)>>>;
1287    /// Plans a recording restarter would have carried out.
1288    type Launched = Arc<SyncMutex<Vec<Plan>>>;
1289
1290    fn plan(state: PlanState) -> Plan {
1291        Plan {
1292            id: "abcd1234".into(),
1293            state,
1294            from_version: "0.1.36".into(),
1295            to_version: "0.1.37".into(),
1296            commit: Some("abc1234".into()),
1297            from_layout: 1,
1298            to_layout: 1,
1299            unit: "scv.service".into(),
1300            binary: PathBuf::from("/bin/scv"),
1301            previous: None,
1302            requester: None,
1303            origin: None,
1304            expected: Vec::new(),
1305            requested_unix: 1000,
1306            deadline_unix: 1600,
1307            restart_unix: Some(1100),
1308            waited_out: false,
1309            detail: None,
1310            verify_seconds: VERIFY_SECONDS,
1311        }
1312    }
1313
1314    fn states(entries: &[(&str, ComponentState)]) -> HashMap<String, ComponentState> {
1315        entries
1316            .iter()
1317            .map(|(id, state)| ((*id).to_owned(), state.clone()))
1318            .collect()
1319    }
1320
1321    fn candidates(ids: &[&str]) -> Vec<Candidate> {
1322        ids.iter()
1323            .map(|id| Candidate {
1324                component: (*id).to_owned(),
1325                peer: None,
1326            })
1327            .collect()
1328    }
1329
1330    #[test]
1331    fn notices_go_to_the_first_connected_account_in_order() {
1332        let owner = |_: &str| Some(Some("owner".to_owned()));
1333        let list = candidates(&["feishu:default", "wechat:default"]);
1334        let both = states(&[
1335            ("feishu:default", ComponentState::Connected),
1336            ("wechat:default", ComponentState::Connected),
1337        ]);
1338        assert_eq!(
1339            pick(&list, &both, &owner, None, false),
1340            Pick::Send {
1341                component: "feishu:default".into(),
1342                peer: "owner".into()
1343            }
1344        );
1345        // Feishu is still connecting: it keeps its place during the grace.
1346        let starting = states(&[
1347            ("feishu:default", ComponentState::Starting),
1348            ("wechat:default", ComponentState::Connected),
1349        ]);
1350        assert_eq!(pick(&list, &starting, &owner, None, false), Pick::Wait);
1351        assert_eq!(
1352            pick(&list, &starting, &owner, None, true),
1353            Pick::Send {
1354                component: "wechat:default".into(),
1355                peer: "owner".into()
1356            }
1357        );
1358        // Never on the excluded account, such as the one that is down.
1359        assert_eq!(
1360            pick(&list, &both, &owner, Some("feishu:default"), false),
1361            Pick::Send {
1362                component: "wechat:default".into(),
1363                peer: "owner".into()
1364            }
1365        );
1366        let failed = states(&[
1367            ("feishu:default", ComponentState::Failed),
1368            ("wechat:default", ComponentState::Disabled),
1369        ]);
1370        assert_eq!(pick(&list, &failed, &owner, None, false), Pick::Nothing);
1371    }
1372
1373    #[test]
1374    fn an_account_without_a_known_owner_is_skipped() {
1375        let owner =
1376            |component: &str| Some((component == "wechat:default").then(|| "wx-owner".to_owned()));
1377        let list = candidates(&["feishu:default", "wechat:default"]);
1378        let both = states(&[
1379            ("feishu:default", ComponentState::Connected),
1380            ("wechat:default", ComponentState::Connected),
1381        ]);
1382        assert_eq!(
1383            pick(&list, &both, &owner, None, true),
1384            Pick::Send {
1385                component: "wechat:default".into(),
1386                peer: "wx-owner".into()
1387            }
1388        );
1389    }
1390
1391    fn notifier(
1392        hub: &Arc<Hub>,
1393        list: Option<Vec<String>>,
1394    ) -> (Notifier, Arc<SyncMutex<HashMap<String, ComponentState>>>) {
1395        let states = Arc::new(SyncMutex::new(HashMap::new()));
1396        (
1397            Notifier {
1398                hub: Arc::clone(hub),
1399                states: States::Fixed(Arc::clone(&states)),
1400                grace: Duration::from_millis(200),
1401                give_up: Duration::from_secs(5),
1402                poll: Duration::from_millis(20),
1403                list,
1404            },
1405            states,
1406        )
1407    }
1408
1409    /// Run a bridge stand-in that stores every notice it receives.
1410    fn bridge(
1411        hub: &Arc<Hub>,
1412        component: &str,
1413        owner: &str,
1414    ) -> (scv_channels::hub::Registration, Stored) {
1415        let link = Link::new(Arc::clone(hub), component, Some(owner.into()));
1416        let (registration, mut notices) = link.register();
1417        let stored = Arc::new(SyncMutex::new(Vec::new()));
1418        let sink = Arc::clone(&stored);
1419        tokio::spawn(async move {
1420            while let Some(notice) = notices.recv().await {
1421                sink.lock()
1422                    .unwrap()
1423                    .push((notice.to.clone(), notice.text.clone()));
1424                notice.stored();
1425            }
1426        });
1427        (registration, stored)
1428    }
1429
1430    #[tokio::test]
1431    async fn an_announcement_goes_to_the_chat_that_asked() {
1432        let hub = Hub::new(None);
1433        let (notifier, health) = notifier(&hub, Some(vec!["feishu:default".into()]));
1434        let (_wechat, wechat) = bridge(&hub, "wechat:default", "wx-owner");
1435        let (_feishu, feishu) = bridge(&hub, "feishu:default", "ou-owner");
1436        *health.lock().unwrap() = states(&[
1437            ("wechat:default", ComponentState::Connected),
1438            ("feishu:default", ComponentState::Connected),
1439        ]);
1440        let origin = Origin {
1441            component: "wechat:default".into(),
1442            peer: "wx-owner".into(),
1443        };
1444        let went = notifier
1445            .deliver(Some(&origin), "updated", None, &CancellationToken::new())
1446            .await;
1447        assert_eq!(went.as_deref(), Some("wechat:default"));
1448        assert_eq!(
1449            *wechat.lock().unwrap(),
1450            [("wx-owner".into(), "updated".into())]
1451        );
1452        assert!(feishu.lock().unwrap().is_empty());
1453    }
1454
1455    #[tokio::test]
1456    async fn an_announcement_falls_back_when_the_asking_chat_stays_down() {
1457        let hub = Hub::new(None);
1458        let (notifier, health) = notifier(
1459            &hub,
1460            Some(vec!["feishu:default".into(), "wechat:default".into()]),
1461        );
1462        let (_feishu, feishu) = bridge(&hub, "feishu:default", "ou-owner");
1463        *health.lock().unwrap() = states(&[
1464            ("wechat:default", ComponentState::Backoff),
1465            ("feishu:default", ComponentState::Connected),
1466        ]);
1467        let origin = Origin {
1468            component: "wechat:default".into(),
1469            peer: "wx-owner".into(),
1470        };
1471        let went = notifier
1472            .deliver(Some(&origin), "updated", None, &CancellationToken::new())
1473            .await;
1474        assert_eq!(went.as_deref(), Some("feishu:default"));
1475        let stored = feishu.lock().unwrap().clone();
1476        assert_eq!(stored.len(), 1);
1477        assert_eq!(stored[0].0, "ou-owner");
1478        assert!(
1479            stored[0]
1480                .1
1481                .starts_with("(You asked on WeChat, which is not connected"),
1482            "{}",
1483            stored[0].1
1484        );
1485        assert!(stored[0].1.ends_with("updated"));
1486    }
1487
1488    #[tokio::test]
1489    async fn without_a_notify_list_notices_go_to_the_owners_last_chat() {
1490        let directory = tempfile::tempdir().unwrap();
1491        let hub = Hub::new(Some(directory.path().join("last-owner.json")));
1492        let (notifier, health) = notifier(&hub, Some(Vec::new()));
1493        let (wechat_registration, wechat) = bridge(&hub, "wechat:default", "wx-owner");
1494        let (_feishu, feishu) = bridge(&hub, "feishu:default", "ou-owner");
1495        *health.lock().unwrap() = states(&[
1496            ("wechat:default", ComponentState::Connected),
1497            ("feishu:default", ComponentState::Connected),
1498        ]);
1499        // Nobody wrote yet: there is nowhere to send it.
1500        assert_eq!(
1501            notifier
1502                .deliver(None, "crashed", None, &CancellationToken::new())
1503                .await,
1504            None
1505        );
1506        wechat_registration.owner_wrote("wx-owner");
1507        assert_eq!(
1508            notifier
1509                .deliver(None, "crashed", None, &CancellationToken::new())
1510                .await
1511                .as_deref(),
1512            Some("wechat:default")
1513        );
1514        assert_eq!(wechat.lock().unwrap().len(), 1);
1515        assert!(feishu.lock().unwrap().is_empty());
1516    }
1517
1518    #[test]
1519    fn the_next_daemon_announces_each_outcome() {
1520        let verified = plan(PlanState::Verified);
1521        assert_eq!(
1522            decide(&verified, "0.1.37", false),
1523            Decision::Say("SCV updated: now running v0.1.37 (abc1234).".into())
1524        );
1525        let mut rolled_back = plan(PlanState::RolledBack);
1526        rolled_back.detail = Some("v0.1.37 started, but wechat:default did not reconnect".into());
1527        assert_eq!(
1528            decide(&rolled_back, "0.1.36", false),
1529            Decision::Say(
1530                "The update to v0.1.37 failed: v0.1.37 started, but wechat:default did not \
1531                 reconnect. SCV rolled back to v0.1.36."
1532                    .into()
1533            )
1534        );
1535        let mut refused = plan(PlanState::Failed);
1536        refused.detail = Some("x; not rolled back: y".into());
1537        assert_eq!(
1538            decide(&refused, "0.1.37", false),
1539            Decision::Say("SCV is running v0.1.37 (abc1234), but x; not rolled back: y.".into())
1540        );
1541        // The watchdog is still checking: say nothing yet.
1542        let restarting = plan(PlanState::Restarting);
1543        assert_eq!(decide(&restarting, "0.1.37", false), Decision::Wait);
1544        assert!(
1545            matches!(decide(&restarting, "0.1.37", true), Decision::Say(text) if text.contains("did not report back"))
1546        );
1547        assert!(
1548            matches!(decide(&restarting, "0.1.36", true), Decision::Say(text) if text.contains("did not take effect"))
1549        );
1550        assert_eq!(decide(&restarting, "0.2.0", true), Decision::Drop);
1551        let mut waited = plan(PlanState::Verified);
1552        waited.waited_out = true;
1553        assert!(
1554            matches!(decide(&waited, "0.1.37", false), Decision::Say(text) if text.contains("waited 10 minutes"))
1555        );
1556    }
1557
1558    #[test]
1559    fn rollback_is_binary_only_and_refused_across_config_layouts() {
1560        let directory = tempfile::tempdir().unwrap();
1561        let previous = directory.path().join("scv.prev");
1562        std::fs::write(&previous, b"old").unwrap();
1563        let mut same = plan(PlanState::Restarting);
1564        same.previous = Some(previous.clone());
1565        assert_eq!(rollback_refusal(&same), None);
1566        let mut changed = same.clone();
1567        changed.to_layout = 2;
1568        assert!(
1569            rollback_refusal(&changed)
1570                .unwrap()
1571                .contains("config layout 2")
1572        );
1573        let mut missing = same.clone();
1574        missing.previous = None;
1575        assert!(
1576            rollback_refusal(&missing)
1577                .unwrap()
1578                .contains("no copy of v0.1.36")
1579        );
1580    }
1581
1582    #[test]
1583    fn a_rollback_copy_replaces_the_binary_whole_and_executable() {
1584        let directory = tempfile::tempdir().unwrap();
1585        let previous = directory.path().join("scv.prev");
1586        let binary = directory.path().join("scv");
1587        std::fs::write(&previous, b"old release").unwrap();
1588        std::fs::write(&binary, b"new release").unwrap();
1589        install_copy(&previous, &binary).unwrap();
1590        assert_eq!(std::fs::read(&binary).unwrap(), b"old release");
1591        #[cfg(unix)]
1592        {
1593            use std::os::unix::fs::PermissionsExt;
1594            let mode = std::fs::metadata(&binary).unwrap().permissions().mode();
1595            assert_eq!(mode & 0o777, 0o755);
1596        }
1597        let leftovers: Vec<_> = std::fs::read_dir(directory.path())
1598            .unwrap()
1599            .filter_map(Result::ok)
1600            .filter(|entry| {
1601                entry
1602                    .file_name()
1603                    .to_string_lossy()
1604                    .starts_with(".scv-install")
1605            })
1606            .collect();
1607        assert!(leftovers.is_empty());
1608    }
1609
1610    #[test]
1611    fn only_a_recent_restart_explains_interrupted_work() {
1612        let directory = tempfile::tempdir().unwrap();
1613        let hub = Hub::new(None);
1614        let mut recent = plan(PlanState::Verified);
1615        recent.restart_unix = Some(unix_now() - 30);
1616        save_plan(&plan_path(directory.path()), &recent).unwrap();
1617        startup(directory.path(), &hub);
1618        assert_eq!(hub.restart().unwrap().to_version, "0.1.37");
1619
1620        let mut old = recent.clone();
1621        old.restart_unix = Some(unix_now() - 2 * RESTART_CONTEXT_MAX_AGE);
1622        save_plan(&plan_path(directory.path()), &old).unwrap();
1623        startup(directory.path(), &hub);
1624        assert!(hub.restart().is_none());
1625
1626        let waiting = plan(PlanState::Waiting);
1627        save_plan(&plan_path(directory.path()), &waiting).unwrap();
1628        startup(directory.path(), &hub);
1629        assert!(hub.restart().is_none(), "a plan that never restarted");
1630    }
1631
1632    #[test]
1633    fn an_unclean_stop_is_detected_once() {
1634        let directory = tempfile::tempdir().unwrap();
1635        let hub = Hub::new(None);
1636        let first = startup(directory.path(), &hub);
1637        assert!(first.unclean.is_none());
1638        // Pretend the marker was left by another daemon that died.
1639        let marker = Marker {
1640            pid: u32::MAX,
1641            version: "0.1.30".into(),
1642            started_unix: 5,
1643        };
1644        std::fs::write(
1645            marker_path(directory.path()),
1646            serde_json::to_vec(&marker).unwrap(),
1647        )
1648        .unwrap();
1649        let second = startup(directory.path(), &hub);
1650        assert_eq!(
1651            second.unclean.map(|(version, _)| version).as_deref(),
1652            Some("0.1.30")
1653        );
1654        clean_shutdown(directory.path());
1655        let third = startup(directory.path(), &hub);
1656        assert!(third.unclean.is_none());
1657    }
1658
1659    #[test]
1660    fn plans_are_private_files() {
1661        let directory = tempfile::tempdir().unwrap();
1662        let path = plan_path(directory.path());
1663        save_plan(&path, &plan(PlanState::Waiting)).unwrap();
1664        assert_eq!(load_plan(&path).unwrap(), Some(plan(PlanState::Waiting)));
1665        #[cfg(unix)]
1666        {
1667            use std::os::unix::fs::PermissionsExt;
1668            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1669            assert_eq!(mode & 0o777, 0o600);
1670        }
1671        assert_eq!(
1672            load_plan(&directory.path().join("missing.json")).unwrap(),
1673            None
1674        );
1675    }
1676
1677    #[test]
1678    fn a_replaced_executable_is_named_by_its_path() {
1679        assert_eq!(
1680            strip_deleted(PathBuf::from("/home/u/.cargo/bin/scv (deleted)")),
1681            PathBuf::from("/home/u/.cargo/bin/scv")
1682        );
1683        assert_eq!(
1684            strip_deleted(PathBuf::from("/usr/bin/scv")),
1685            PathBuf::from("/usr/bin/scv")
1686        );
1687    }
1688
1689    /// A restarter whose restarts are recorded, not carried out.
1690    fn recording(
1691        home: &Path,
1692        hub: &Arc<Hub>,
1693        registry: &Arc<DelegationRegistry>,
1694    ) -> (Arc<Restarter>, Launched, Arc<Mutex<Components>>) {
1695        let components = Arc::new(Mutex::new(Components::new(
1696            PathBuf::from("/unused.sock"),
1697            PathBuf::from("/"),
1698        )));
1699        let launched = Arc::new(SyncMutex::new(Vec::new()));
1700        let restarter = Arc::new(Restarter {
1701            launcher: Launcher::Record(Arc::clone(&launched)),
1702            notifier: Notifier::new(Arc::clone(hub), Arc::downgrade(&components)),
1703            home: home.to_owned(),
1704            hub: Arc::clone(hub),
1705            registry: Arc::clone(registry),
1706            components: Arc::downgrade(&components),
1707            cancel: CancellationToken::new(),
1708            current: SyncMutex::new(None),
1709        });
1710        (restarter, launched, components)
1711    }
1712
1713    async fn eventually(mut condition: impl FnMut() -> bool) {
1714        tokio::time::timeout(Duration::from_secs(10), async {
1715            while !condition() {
1716                tokio::time::sleep(Duration::from_millis(20)).await;
1717            }
1718        })
1719        .await
1720        .expect("condition holds in time");
1721    }
1722
1723    #[tokio::test]
1724    async fn a_restart_waits_for_the_requesting_job_its_report_and_owner_messages() {
1725        use scv_tools::delegation::{DelegationRecord, ProcessIdentity};
1726        use std::os::unix::process::CommandExt as _;
1727        let home = tempfile::tempdir().unwrap();
1728        let registry = Arc::new(DelegationRegistry::new(home.path()));
1729        let hub = Hub::new(None);
1730        let (restarter, launched, _components) = recording(home.path(), &hub, &registry);
1731        // The delegation running the deploy, started by this daemon.
1732        let mut agent = std::process::Command::new("sleep")
1733            .arg("30")
1734            .process_group(0)
1735            .spawn()
1736            .unwrap();
1737        let record = DelegationRecord {
1738            handle: "codex-a1b2c3".into(),
1739            agent: "codex".into(),
1740            instance: registry.instance().into(),
1741            session: "s".into(),
1742            owner: ProcessIdentity::current().unwrap(),
1743            process: ProcessIdentity::of(agent.id()).unwrap(),
1744            pgid: agent.id(),
1745            cwd: "/work".into(),
1746            started_unix: 1,
1747            depth: 1,
1748            conversation: None,
1749            turn: None,
1750        };
1751        std::fs::create_dir_all(registry.record_dir()).unwrap();
1752        std::fs::write(
1753            registry.record_dir().join("codex-a1b2c3.json"),
1754            serde_json::to_vec(&record).unwrap(),
1755        )
1756        .unwrap();
1757        let chain = format!("{}/s/codex-a1b2c3", registry.instance());
1758        assert_eq!(
1759            restarter.requester(&format!("elsewhere/x/codex-000000;{chain}")),
1760            Some(Requester {
1761                handle: "codex-a1b2c3".into(),
1762                session: "s".into()
1763            })
1764        );
1765        assert_eq!(restarter.requester("elsewhere/s/codex-a1b2c3"), None);
1766        // Its chat on WeChat, whose report is not stored yet.
1767        let link = Link::new(Arc::clone(&hub), "wechat:default", Some("owner".into()));
1768        let (bridge, _notices) = link.register();
1769        let chat = bridge.conversation("owner");
1770        chat.update(Some("s"), 1);
1771
1772        let mut waiting = plan(PlanState::Waiting);
1773        waiting.requester = restarter.requester(&chain);
1774        waiting.requested_unix = unix_now();
1775        waiting.deadline_unix = unix_now() + 600;
1776        let info = restarter.arm(waiting).unwrap();
1777        assert_eq!(info.waiting_for.as_deref(), Some("codex-a1b2c3 to finish"));
1778        assert_eq!(info.origin.as_deref(), Some("wechat:default"));
1779
1780        agent.kill().unwrap();
1781        agent.wait().unwrap();
1782        eventually(|| {
1783            restarter
1784                .info()
1785                .and_then(|info| info.waiting_for)
1786                .as_deref()
1787                == Some("codex-a1b2c3's report")
1788        })
1789        .await;
1790        bridge.set_owner_claims(1);
1791        chat.update(Some("s"), 0);
1792        eventually(|| {
1793            restarter
1794                .info()
1795                .and_then(|info| info.waiting_for)
1796                .as_deref()
1797                == Some("an owner message to be answered")
1798        })
1799        .await;
1800        assert!(launched.lock().unwrap().is_empty());
1801        bridge.set_owner_claims(0);
1802        eventually(|| launched.lock().unwrap().len() == 1).await;
1803        let started = launched.lock().unwrap()[0].clone();
1804        assert_eq!(started.state, PlanState::Restarting);
1805        assert!(!started.waited_out);
1806        assert!(started.restart_unix.is_some());
1807        assert_eq!(
1808            started.origin,
1809            Some(Origin {
1810                component: "wechat:default".into(),
1811                peer: "owner".into()
1812            })
1813        );
1814        assert_eq!(
1815            load_plan(&plan_path(home.path())).unwrap().unwrap().state,
1816            PlanState::Restarting
1817        );
1818    }
1819
1820    #[tokio::test]
1821    async fn a_restart_goes_ahead_at_its_deadline_and_says_so() {
1822        let home = tempfile::tempdir().unwrap();
1823        let registry = Arc::new(DelegationRegistry::new(home.path()));
1824        let hub = Hub::new(None);
1825        let (restarter, launched, _components) = recording(home.path(), &hub, &registry);
1826        let link = Link::new(Arc::clone(&hub), "feishu:default", Some("owner".into()));
1827        let (bridge, _notices) = link.register();
1828        bridge.set_owner_claims(1);
1829        let mut waiting = plan(PlanState::Waiting);
1830        waiting.requested_unix = unix_now();
1831        waiting.deadline_unix = unix_now();
1832        let info = restarter.arm(waiting).unwrap();
1833        assert_eq!(
1834            info.waiting_for.as_deref(),
1835            Some("an owner message to be answered")
1836        );
1837        eventually(|| launched.lock().unwrap().len() == 1).await;
1838        assert!(launched.lock().unwrap()[0].waited_out);
1839    }
1840
1841    #[test]
1842    fn session_activity_counts_turns_until_the_session_ends() {
1843        let tracker = SessionTracker::new("session-activity-test", None);
1844        assert!(!session_busy("session-activity-test"));
1845        tracker.set_busy(true);
1846        assert!(session_busy("session-activity-test"));
1847        drop(tracker);
1848        assert!(!session_busy("session-activity-test"));
1849    }
1850}