1use std::{
20 collections::HashMap,
21 path::{Path, PathBuf},
22 sync::{Arc, LazyLock, Mutex as SyncMutex, PoisonError, Weak, atomic::AtomicBool},
23 time::Duration,
24};
25
26use anyhow::{Context, Result, anyhow, bail};
27use scv_channels::hub::{Hub, Origin, Restart};
28use scv_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
36pub const CONFIG_LAYOUT: u32 = 1;
40
41const DEFAULT_MAX_WAIT: u64 = 10 * 60;
42const MAX_WAIT_LIMIT: u64 = 60 * 60;
43const VERIFY_SECONDS: u64 = 180;
46const ROLLBACK_SECONDS: u64 = 90;
48const CLEAR_CHECKS: u32 = 2;
51const DOWN_NOTICE_AFTER: Duration = Duration::from_secs(10 * 60);
53const MONITOR_INTERVAL: Duration = Duration::from_secs(30);
54const RESTART_CONTEXT_MAX_AGE: u64 = 60 * 60;
56
57fn 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct BuildInfo {
77 pub version: String,
78 pub config_layout: u32,
79}
80
81pub 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,
94 Restarting,
96 Verified,
98 RolledBack,
100 Failed,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct Requester {
108 pub handle: String,
109 pub session: String,
110}
111
112#[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 pub binary: PathBuf,
127 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub origin: Option<Origin>,
135 #[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 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
145 pub waited_out: bool,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub detail: Option<String>,
149 #[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
222fn 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
240fn 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
247async 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
265struct 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
277pub(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 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#[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 Wait,
351 Nothing,
352}
353
354fn 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), ®istered, 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
396fn 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#[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#[derive(Clone)]
437pub(crate) struct Notifier {
438 hub: Arc<Hub>,
439 states: States,
440 grace: Duration,
442 give_up: Duration,
444 poll: Duration,
445 #[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 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 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 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
571enum Launcher {
576 Systemd,
578 #[cfg(test)]
580 Record(Arc<SyncMutex<Vec<Plan>>>),
581}
582
583pub(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 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 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 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 self.arm(plan)
704 }
705
706 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 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 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 _ = 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 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 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
884fn 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
898fn 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
920pub 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
992fn 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
1007async 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
1047pub(crate) struct Startup {
1052 plan: Option<Plan>,
1053 unclean: Option<(String, u64)>,
1056}
1057
1058#[derive(Serialize, Deserialize)]
1059struct Marker {
1060 pid: u32,
1061 version: String,
1062 started_unix: u64,
1063}
1064
1065pub(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(¤t)
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
1103pub(crate) fn clean_shutdown(home: &Path) {
1105 let _ = std::fs::remove_file(marker_path(home));
1106}
1107
1108#[derive(Debug, PartialEq, Eq)]
1110enum Decision {
1111 Say(String),
1112 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
1148fn 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
1175pub(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
1234pub(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 type Stored = Arc<SyncMutex<Vec<(String, String)>>>;
1287 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 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 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 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 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 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 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 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, ®istry);
1731 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 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, ®istry);
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}