1use std::any::Any;
55use std::collections::BTreeMap;
56use std::fmt;
57use std::io;
58use std::num::NonZeroU32;
59use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
60use std::sync::{Arc, Mutex, MutexGuard};
61use std::time::{Duration, Instant};
62
63use rightkit_child_process::{
64 AdoptedIdentity, Generation, IdentityCheck, OwnedChild, RestartOutcome, RestartPolicy,
65 RestartTracker, WaitOutcome,
66};
67use serde_json::Value;
68
69use crate::client::{Client, ClientConfig};
70use crate::discovery::{
71 record_process_matches, try_adopt_or_spawn, AdoptOrSpawn, SpawnClaim, SpawnClaimError,
72};
73use crate::error::{ConnectError, ServiceError, ERROR_UNAVAILABLE};
74use crate::host_identity::{HostExpectation, HostIdentityError, HostIdentityReason};
75use crate::record::{try_read_record, ServiceRecord};
76
77pub const ERROR_STALE_GENERATION: &str = "stale_generation";
80pub const ERROR_NOT_OWNED: &str = "not_owned";
82pub const ERROR_RESTART_EXHAUSTED: &str = "restart_exhausted";
84pub const ERROR_SESSION_RELEASED: &str = "session_released";
86pub const ERROR_LAUNCH_FAILED: &str = "launch_failed";
88
89fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
90 m.lock().unwrap_or_else(|e| e.into_inner())
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
95pub enum Provenance {
96 Owned,
98 Attached,
100}
101
102impl Provenance {
103 pub fn as_str(self) -> &'static str {
104 match self {
105 Provenance::Owned => "owned",
106 Provenance::Attached => "attached",
107 }
108 }
109
110 pub fn may_stop_host(self) -> bool {
112 matches!(self, Provenance::Owned)
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Hash)]
124pub struct HostGeneration {
125 pub service: String,
126 pub endpoint: String,
127 pub pid: u32,
128 pub process_started_ms: u64,
130 pub started_ms: u64,
132}
133
134impl HostGeneration {
135 pub fn of(record: &ServiceRecord) -> Self {
137 Self {
138 service: record.service.clone(),
139 endpoint: record.endpoint.clone(),
140 pid: record.pid,
141 process_started_ms: record.process_started_ms,
142 started_ms: record.started_ms,
143 }
144 }
145}
146
147#[derive(Debug)]
149#[non_exhaustive]
150pub enum LifecycleError {
151 Service(ServiceError),
153 HostIdentity(HostIdentityError),
156 NoHost,
158 Launch(io::Error),
160 LaunchTimeout,
162 RestartExhausted,
164 NotOwned,
166 Released,
168 StaleGeneration { issued: u64, current: u64 },
172 Unconfirmed,
175 Process(io::Error),
177}
178
179impl LifecycleError {
180 pub fn code(&self) -> &str {
182 match self {
183 LifecycleError::Service(e) => &e.code,
184 LifecycleError::HostIdentity(_) => crate::error::ERROR_HOST_IDENTITY,
185 LifecycleError::NoHost => ERROR_UNAVAILABLE,
186 LifecycleError::Launch(_) | LifecycleError::LaunchTimeout => ERROR_LAUNCH_FAILED,
187 LifecycleError::RestartExhausted => ERROR_RESTART_EXHAUSTED,
188 LifecycleError::NotOwned => ERROR_NOT_OWNED,
189 LifecycleError::Released => ERROR_SESSION_RELEASED,
190 LifecycleError::StaleGeneration { .. } | LifecycleError::Unconfirmed => {
191 ERROR_STALE_GENERATION
192 }
193 LifecycleError::Process(_) => "io",
194 }
195 }
196
197 pub fn is_retryable(&self) -> bool {
200 match self {
201 LifecycleError::Service(e) => e.retryable,
202 LifecycleError::NoHost => true,
203 _ => false,
204 }
205 }
206
207 pub fn into_service_error(self) -> ServiceError {
209 let text = self.to_string();
210 match self {
211 LifecycleError::Service(e) => e,
212 LifecycleError::HostIdentity(e) => e.into(),
213 LifecycleError::NoHost => ServiceError::retryable(ERROR_UNAVAILABLE, text),
214 other => ServiceError::new(other.code().to_string(), text),
215 }
216 }
217}
218
219impl fmt::Display for LifecycleError {
220 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221 match self {
222 LifecycleError::Service(e) => fmt::Display::fmt(e, f),
223 LifecycleError::HostIdentity(e) => fmt::Display::fmt(e, f),
224 LifecycleError::NoHost => f.write_str("no live host and no launcher to start one"),
225 LifecycleError::Launch(e) => write!(f, "the host could not be launched: {e}"),
226 LifecycleError::LaunchTimeout => {
227 f.write_str("the launched host did not publish itself in time; it was stopped")
228 }
229 LifecycleError::RestartExhausted => {
230 f.write_str("the owned host exited and the restart budget is spent")
231 }
232 LifecycleError::NotOwned => f.write_str(
233 "this host was not launched by this lifecycle; attaching never implies the right to stop it",
234 ),
235 LifecycleError::Released => f.write_str("the session or the lifecycle was released"),
236 LifecycleError::StaleGeneration { issued, current } => write!(
237 f,
238 "the host generation changed (bound to epoch {issued}, now {current}); the request may have run on the previous host and is not replayed"
239 ),
240 LifecycleError::Unconfirmed => f.write_str(
241 "the host generation could not be confirmed; the reply is not delivered",
242 ),
243 LifecycleError::Process(e) => write!(f, "process operation failed: {e}"),
244 }
245 }
246}
247
248impl std::error::Error for LifecycleError {
249 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
250 match self {
251 LifecycleError::Service(e) => Some(e),
252 LifecycleError::HostIdentity(e) => Some(e),
253 LifecycleError::Launch(e) | LifecycleError::Process(e) => Some(e),
254 _ => None,
255 }
256 }
257}
258
259impl From<ServiceError> for LifecycleError {
260 fn from(e: ServiceError) -> Self {
261 LifecycleError::Service(e)
262 }
263}
264
265fn unavailable(e: io::Error) -> LifecycleError {
266 LifecycleError::Service(ServiceError::retryable(
267 ERROR_UNAVAILABLE,
268 format!("host discovery failed (try again): {e}"),
269 ))
270}
271
272const HOST_EXIT_SETTLE: Duration = Duration::from_millis(500);
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub struct Stale {
283 pub issued: u64,
284 pub current: u64,
285}
286
287impl From<Stale> for LifecycleError {
288 fn from(s: Stale) -> Self {
289 LifecycleError::StaleGeneration {
290 issued: s.issued,
291 current: s.current,
292 }
293 }
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298pub struct Ticket {
299 epoch: u64,
300 request: u64,
301}
302
303impl Ticket {
304 pub fn epoch(&self) -> u64 {
305 self.epoch
306 }
307
308 pub fn request(&self) -> u64 {
309 self.request
310 }
311}
312
313#[derive(Debug, Default)]
322pub struct GenerationFence {
323 epoch: AtomicU64,
324 next_request: AtomicU64,
325}
326
327impl GenerationFence {
328 pub fn new() -> Self {
329 Self::default()
330 }
331
332 pub fn epoch(&self) -> u64 {
334 self.epoch.load(Ordering::SeqCst)
335 }
336
337 pub fn advance(&self) -> u64 {
340 self.epoch.fetch_add(1, Ordering::SeqCst) + 1
341 }
342
343 pub fn issue(&self, bound_epoch: u64) -> Result<Ticket, Stale> {
346 let current = self.epoch();
347 if bound_epoch != current {
348 return Err(Stale {
349 issued: bound_epoch,
350 current,
351 });
352 }
353 Ok(Ticket {
354 epoch: bound_epoch,
355 request: self.next_request.fetch_add(1, Ordering::SeqCst) + 1,
356 })
357 }
358
359 pub fn admit_reply(&self, ticket: &Ticket) -> Result<(), Stale> {
362 let current = self.epoch();
363 if ticket.epoch != current {
364 return Err(Stale {
365 issued: ticket.epoch,
366 current,
367 });
368 }
369 Ok(())
370 }
371
372 pub fn admit_reply_from(&self, ticket: &Ticket, reply_epoch: u64) -> Result<(), Stale> {
375 if reply_epoch != ticket.epoch {
376 return Err(Stale {
377 issued: ticket.epoch,
378 current: reply_epoch,
379 });
380 }
381 self.admit_reply(ticket)
382 }
383}
384
385pub trait HostLink: Send + Sync {
391 fn call(&self, method: &str, params: Value) -> Result<Value, ServiceError>;
392 fn is_closed(&self) -> bool;
394 fn close(&self);
396}
397
398pub enum SpawnPermit {
400 Granted(Box<dyn Any + Send>),
402 Contended,
404}
405
406pub trait HostBackend: Send + Sync + 'static {
409 fn discover(&self) -> io::Result<Option<HostGeneration>>;
412
413 fn connect(&self, host: &HostGeneration) -> Result<Box<dyn HostLink>, LifecycleError>;
417
418 fn is_current(&self, host: &HostGeneration) -> io::Result<bool> {
421 Ok(self.discover()?.as_ref() == Some(host))
422 }
423
424 fn claim_spawn(&self) -> io::Result<SpawnPermit>;
426}
427
428pub struct LocalBackend {
431 client: ClientConfig,
432 expect: HostExpectation,
433}
434
435impl LocalBackend {
436 pub fn new(client: ClientConfig, expect: HostExpectation) -> Self {
440 Self { client, expect }
441 }
442
443 fn suite(&self) -> io::Result<crate::suite::Suite> {
444 self.client.resolve_suite().map_err(io::Error::other)
445 }
446}
447
448struct LocalLink(Client);
449
450impl HostLink for LocalLink {
451 fn call(&self, method: &str, params: Value) -> Result<Value, ServiceError> {
452 self.0.call(method, params)
453 }
454
455 fn is_closed(&self) -> bool {
456 self.0.is_closed()
457 }
458
459 fn close(&self) {
460 self.0.abort();
461 }
462}
463
464impl HostBackend for LocalBackend {
465 fn discover(&self) -> io::Result<Option<HostGeneration>> {
466 let suite = self.suite()?;
467 Ok(match try_adopt_or_spawn(&suite, self.client.service())? {
468 AdoptOrSpawn::Adopt(record) => Some(HostGeneration::of(&record)),
469 AdoptOrSpawn::Spawn => None,
470 })
471 }
472
473 fn connect(&self, host: &HostGeneration) -> Result<Box<dyn HostLink>, LifecycleError> {
474 let client =
475 Client::connect_expecting(&self.client, &self.expect).map_err(|e| match e {
476 ConnectError::HostIdentity(identity) => LifecycleError::HostIdentity(identity),
477 other => LifecycleError::Service(other.into_service_error()),
478 })?;
479 if HostGeneration::of(client.record()) != *host {
481 client.abort();
482 return Err(LifecycleError::Service(ServiceError::retryable(
483 ERROR_UNAVAILABLE,
484 "the host changed while connecting; discover it again",
485 )));
486 }
487 let identity_error = |found_pid: Option<u32>,
489 found_executable: Option<std::path::PathBuf>,
490 reason: HostIdentityReason| {
491 LifecycleError::HostIdentity(HostIdentityError {
492 expected: self.expect.executables().to_vec(),
493 found_pid,
494 found_executable,
495 reason,
496 })
497 };
498 match client.verified_host() {
499 Some(process) if process.pid == host.pid => {}
500 Some(process) => {
501 let error = identity_error(
502 Some(process.pid),
503 Some(process.executable.clone()),
504 HostIdentityReason::HostChanged(format!(
505 "the endpoint is served by pid {} but service.json names pid {}",
506 process.pid, host.pid
507 )),
508 );
509 client.abort();
510 return Err(error);
511 }
512 None => {
513 client.abort();
514 return Err(identity_error(
515 None,
516 None,
517 HostIdentityReason::PidUnavailable,
518 ));
519 }
520 }
521 Ok(Box::new(LocalLink(client)))
522 }
523
524 fn is_current(&self, host: &HostGeneration) -> io::Result<bool> {
525 let suite = self.suite()?;
526 Ok(try_read_record(&suite, self.client.service())?
527 .is_some_and(|r| record_process_matches(&r) && HostGeneration::of(&r) == *host))
528 }
529
530 fn claim_spawn(&self) -> io::Result<SpawnPermit> {
531 let suite = self.suite()?;
532 match SpawnClaim::acquire(&suite, self.client.service()) {
533 Ok(claim) => Ok(SpawnPermit::Granted(Box::new(claim))),
534 Err(SpawnClaimError::AlreadyHeld(_)) => Ok(SpawnPermit::Contended),
535 Err(SpawnClaimError::Io(e)) => Err(e),
536 }
537 }
538}
539
540pub type Launcher = Box<dyn FnMut() -> io::Result<OwnedChild> + Send>;
548
549pub type GracefulStop = Arc<dyn Fn(&dyn HostLink) -> Result<(), ServiceError> + Send + Sync>;
553
554#[derive(Clone)]
556pub struct LifecycleConfig {
557 startup_timeout: Duration,
558 poll_interval: Duration,
559 stop_grace: Duration,
560 restart: RestartPolicy,
561 graceful: Option<GracefulStop>,
562}
563
564impl Default for LifecycleConfig {
565 fn default() -> Self {
566 Self {
567 startup_timeout: Duration::from_secs(30),
568 poll_interval: Duration::from_millis(50),
569 stop_grace: Duration::from_secs(5),
570 restart: RestartPolicy::default(),
571 graceful: None,
572 }
573 }
574}
575
576impl LifecycleConfig {
577 pub fn new() -> Self {
578 Self::default()
579 }
580
581 #[must_use]
583 pub fn startup_timeout(mut self, timeout: Duration) -> Self {
584 self.startup_timeout = timeout;
585 self
586 }
587
588 #[must_use]
590 pub fn poll_interval(mut self, interval: Duration) -> Self {
591 self.poll_interval = interval.max(Duration::from_millis(1));
592 self
593 }
594
595 #[must_use]
598 pub fn stop_grace(mut self, grace: Duration) -> Self {
599 self.stop_grace = grace;
600 self
601 }
602
603 #[must_use]
606 pub fn restart_policy(mut self, policy: RestartPolicy) -> Self {
607 self.restart = policy;
608 self
609 }
610
611 #[must_use]
613 pub fn graceful_stop<F>(mut self, stop: F) -> Self
614 where
615 F: Fn(&dyn HostLink) -> Result<(), ServiceError> + Send + Sync + 'static,
616 {
617 self.graceful = Some(Arc::new(stop));
618 self
619 }
620}
621
622struct OwnedHost {
627 child: OwnedChild,
628 identity: AdoptedIdentity,
630 host: Option<HostGeneration>,
632}
633
634struct Slow {
635 owned: Option<OwnedHost>,
636 tracker: RestartTracker,
637 origin: Instant,
638 generation: Option<Generation>,
639 pending: Option<RestartOutcome>,
641 exhausted: bool,
642}
643
644#[derive(Clone)]
645struct Current {
646 host: HostGeneration,
647 provenance: Provenance,
648 epoch: u64,
649}
650
651struct Inner {
652 backend: Arc<dyn HostBackend>,
653 cfg: LifecycleConfig,
654 launcher: Mutex<Option<Launcher>>,
655 slow: Mutex<Slow>,
658 current: Mutex<Option<Current>>,
659 sessions: Mutex<BTreeMap<u64, String>>,
660 fence: GenerationFence,
661 next_session: AtomicU64,
662 stopped: AtomicBool,
663}
664
665pub struct DaemonLifecycle {
667 inner: Arc<Inner>,
668}
669
670#[derive(Debug, Clone, PartialEq, Eq)]
672pub struct ReleaseReport {
673 pub label: String,
674 pub provenance: Provenance,
675 pub remaining_sessions: usize,
677}
678
679#[derive(Debug, Clone, PartialEq, Eq)]
681pub struct ShutdownReport {
682 pub graceful_requested: bool,
684 pub graceful_acknowledged: bool,
686 pub killed: bool,
688 pub exit_code: Option<i32>,
690}
691
692impl DaemonLifecycle {
693 pub fn attach_or_launch(
699 backend: Arc<dyn HostBackend>,
700 launcher: Option<Launcher>,
701 config: LifecycleConfig,
702 ) -> Result<Self, LifecycleError> {
703 let tracker = RestartTracker::new(config.restart);
704 let inner = Arc::new(Inner {
705 backend,
706 cfg: config,
707 launcher: Mutex::new(launcher),
708 slow: Mutex::new(Slow {
709 owned: None,
710 tracker,
711 origin: Instant::now(),
712 generation: None,
713 pending: None,
714 exhausted: false,
715 }),
716 current: Mutex::new(None),
717 sessions: Mutex::new(BTreeMap::new()),
718 fence: GenerationFence::new(),
719 next_session: AtomicU64::new(1),
720 stopped: AtomicBool::new(false),
721 });
722 inner.ensure_host()?;
723 Ok(Self { inner })
724 }
725
726 pub fn attach_or_launch_local(
728 client: ClientConfig,
729 expect: HostExpectation,
730 launcher: Option<Launcher>,
731 config: LifecycleConfig,
732 ) -> Result<Self, LifecycleError> {
733 Self::attach_or_launch(
734 Arc::new(LocalBackend::new(client, expect)),
735 launcher,
736 config,
737 )
738 }
739
740 pub fn provenance(&self) -> Option<Provenance> {
742 lock(&self.inner.current).as_ref().map(|c| c.provenance)
743 }
744
745 pub fn host(&self) -> Option<HostGeneration> {
747 lock(&self.inner.current).as_ref().map(|c| c.host.clone())
748 }
749
750 pub fn epoch(&self) -> u64 {
752 self.inner.fence.epoch()
753 }
754
755 pub fn session_count(&self) -> usize {
757 lock(&self.inner.sessions).len()
758 }
759
760 pub fn reset_restart_budget(&self) {
762 let mut slow = lock(&self.inner.slow);
763 slow.tracker.reset_budget();
764 slow.pending = None;
765 slow.exhausted = false;
766 }
767
768 pub fn acquire_session(&self, label: &str) -> Result<Session, LifecycleError> {
772 let current = self.inner.ensure_host()?;
773 let link = self.inner.backend.connect(¤t.host)?;
774 match self.inner.backend.is_current(¤t.host) {
776 Ok(true) => {}
777 Ok(false) => {
778 link.close();
779 self.inner.host_left(¤t.host);
780 return Err(LifecycleError::StaleGeneration {
781 issued: current.epoch,
782 current: self.inner.fence.epoch(),
783 });
784 }
785 Err(_) => {
786 link.close();
787 return Err(LifecycleError::Unconfirmed);
788 }
789 }
790 let id = self.inner.next_session.fetch_add(1, Ordering::SeqCst);
791 lock(&self.inner.sessions).insert(id, label.to_string());
792 Ok(Session {
793 inner: self.inner.clone(),
794 id,
795 label: label.to_string(),
796 provenance: current.provenance,
797 host: current.host,
798 epoch: current.epoch,
799 link,
800 released: AtomicBool::new(false),
801 })
802 }
803
804 pub fn release_session(&self, session: Session) -> ReleaseReport {
808 session.finish()
809 }
810
811 pub fn shutdown_owned(&self) -> Result<ShutdownReport, LifecycleError> {
820 let inner = &self.inner;
821 let mut slow = lock(&inner.slow);
822 let identity_state = match slow.owned.as_ref() {
824 None => return Err(LifecycleError::NotOwned),
825 Some(owned) => owned.identity.verify().map_err(LifecycleError::Process)?,
826 };
827 if identity_state == IdentityCheck::Replaced {
828 return Err(LifecycleError::Process(io::Error::other(
829 "the launched pid now names a different process; nothing was signalled",
830 )));
831 }
832 let Some(mut owned) = slow.owned.take() else {
833 return Err(LifecycleError::NotOwned);
834 };
835 inner.stopped.store(true, Ordering::SeqCst);
836 slow.tracker.shutdown();
837 slow.generation = None;
838
839 let mut graceful_requested = false;
840 let mut graceful_acknowledged = false;
841 if let (Some(stop), Some(host)) = (inner.cfg.graceful.as_ref(), owned.host.as_ref()) {
842 if matches!(inner.backend.is_current(host), Ok(true)) {
844 if let Ok(link) = inner.backend.connect(host) {
845 graceful_requested = true;
846 graceful_acknowledged = stop(&*link).is_ok();
847 link.close();
848 }
849 }
850 }
851 let outcome = owned
852 .child
853 .wait_then_kill(inner.cfg.stop_grace)
854 .map_err(LifecycleError::Process)?;
855 let (killed, exit_code) = match outcome {
856 WaitOutcome::Exited(status) => (false, status.code()),
857 WaitOutcome::Terminated(status) => (true, status.code()),
858 };
859 drop(owned);
860 drop(slow);
861 let mut current = lock(&inner.current);
863 if current.take().is_some() {
864 inner.fence.advance();
865 }
866 Ok(ShutdownReport {
867 graceful_requested,
868 graceful_acknowledged,
869 killed,
870 exit_code,
871 })
872 }
873}
874
875impl Inner {
876 fn bind(&self, to: Option<(HostGeneration, Provenance)>) -> Option<Current> {
877 let mut current = lock(&self.current);
878 let unchanged = match (current.as_ref(), to.as_ref()) {
879 (Some(c), Some((host, provenance))) => c.host == *host && c.provenance == *provenance,
880 (None, None) => true,
881 _ => false,
882 };
883 if unchanged {
884 return current.clone();
885 }
886 let epoch = self.fence.advance();
887 *current = to.map(|(host, provenance)| Current {
888 host,
889 provenance,
890 epoch,
891 });
892 current.clone()
893 }
894
895 fn host_left(&self, host: &HostGeneration) {
898 let mut current = lock(&self.current);
899 if current.as_ref().is_some_and(|c| c.host == *host) {
900 *current = None;
901 self.fence.advance();
902 }
903 }
904
905 fn release(&self, id: u64) -> usize {
906 let mut sessions = lock(&self.sessions);
907 sessions.remove(&id);
908 sessions.len()
909 }
910
911 fn owned_exited(&self, host: &HostGeneration) -> bool {
916 let mut slow = match self.slow.try_lock() {
917 Ok(guard) => guard,
918 Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(),
919 Err(std::sync::TryLockError::WouldBlock) => return false,
920 };
921 match slow.owned.as_mut() {
922 Some(owned) if owned.host.as_ref() == Some(host) => {
923 !matches!(owned.child.try_wait(), Ok(None))
924 }
925 _ => false,
926 }
927 }
928
929 fn ensure_host(&self) -> Result<Current, LifecycleError> {
931 let mut slow = lock(&self.slow);
932 if self.stopped.load(Ordering::SeqCst) {
933 return Err(LifecycleError::Released);
934 }
935 let deadline = Instant::now() + self.cfg.startup_timeout;
936 self.reap_exited_owned(&mut slow);
937 match self.backend.discover().map_err(unavailable)? {
938 Some(host) => self.adopt_found(&mut slow, host),
939 None => {
940 self.bind(None);
941 self.launch(&mut slow, deadline)
942 }
943 }
944 }
945
946 fn reap_exited_owned(&self, slow: &mut Slow) {
948 let exited = match slow.owned.as_mut() {
949 Some(owned) => match owned.child.try_wait() {
950 Ok(None) => false,
951 Ok(Some(_)) => true,
952 Err(_) => {
953 let _ = owned.child.terminate_tree();
955 true
956 }
957 },
958 None => false,
959 };
960 if exited {
961 slow.owned = None;
962 if let Some(generation) = slow.generation.take() {
963 let at = slow.origin.elapsed();
964 slow.pending = Some(slow.tracker.exited(generation, at));
965 }
966 }
967 }
968
969 fn adopt_found(
971 &self,
972 slow: &mut Slow,
973 host: HostGeneration,
974 ) -> Result<Current, LifecycleError> {
975 if let Some(current) = lock(&self.current).clone() {
976 if current.host == host {
977 return Ok(current);
978 }
979 }
980 let link = self.backend.connect(&host)?;
982 link.close();
983 let ours = slow
986 .owned
987 .as_ref()
988 .is_some_and(|owned| owned.child.id() == host.pid);
989 if ours {
990 if let Some(owned) = slow.owned.as_mut() {
991 owned.host = Some(host.clone());
992 }
993 } else if slow.owned.is_some() {
994 self.retire_owned(slow);
997 }
998 let provenance = if ours {
999 Provenance::Owned
1000 } else {
1001 Provenance::Attached
1002 };
1003 self.bind(Some((host, provenance)))
1004 .ok_or(LifecycleError::NoHost)
1005 }
1006
1007 fn retire_owned(&self, slow: &mut Slow) {
1009 if let Some(mut owned) = slow.owned.take() {
1010 stop_launched(&mut owned, self.cfg.stop_grace);
1011 }
1012 slow.generation = None;
1013 }
1014
1015 fn fail_owned(&self, slow: &mut Slow) {
1017 if let Some(mut owned) = slow.owned.take() {
1018 stop_launched(&mut owned, self.cfg.stop_grace);
1019 }
1020 let at = slow.origin.elapsed();
1021 if let Some(generation) = slow.generation.take() {
1022 slow.pending = Some(slow.tracker.exited(generation, at));
1023 } else {
1024 slow.pending = Some(slow.tracker.request_restart(at));
1025 }
1026 }
1027
1028 fn charge_failed_launch(&self, slow: &mut Slow) {
1029 let at = slow.origin.elapsed();
1030 slow.pending = Some(slow.tracker.request_restart(at));
1031 }
1032
1033 fn launch(&self, slow: &mut Slow, deadline: Instant) -> Result<Current, LifecycleError> {
1034 if slow.exhausted {
1035 return Err(LifecycleError::RestartExhausted);
1036 }
1037 if lock(&self.launcher).is_none() {
1038 return Err(LifecycleError::NoHost);
1039 }
1040 if let Some(outcome) = slow.pending.take() {
1041 self.honour(slow, outcome, deadline)?;
1042 }
1043 let mut claim: Option<Box<dyn Any + Send>> = None;
1044 if slow.owned.is_none() {
1045 loop {
1046 match self.backend.claim_spawn().map_err(LifecycleError::Launch)? {
1047 SpawnPermit::Granted(held) => {
1048 claim = Some(held);
1049 break;
1050 }
1051 SpawnPermit::Contended => {
1052 if let Some(host) = self.backend.discover().map_err(unavailable)? {
1053 return self.adopt_found(slow, host);
1054 }
1055 if Instant::now() >= deadline {
1056 return Err(LifecycleError::LaunchTimeout);
1057 }
1058 std::thread::sleep(self.cfg.poll_interval);
1059 }
1060 }
1061 }
1062 if let Some(host) = self.backend.discover().map_err(unavailable)? {
1064 return self.adopt_found(slow, host);
1065 }
1066 self.spawn_child(slow)?;
1067 }
1068 let result = self.await_publication(slow, deadline);
1069 drop(claim);
1070 result
1071 }
1072
1073 fn honour(
1075 &self,
1076 slow: &mut Slow,
1077 outcome: RestartOutcome,
1078 deadline: Instant,
1079 ) -> Result<(), LifecycleError> {
1080 let at = slow.origin.elapsed();
1081 let outcome = match outcome {
1082 RestartOutcome::AwaitingDemand => slow.tracker.request_restart(at),
1084 other => other,
1085 };
1086 match outcome {
1087 RestartOutcome::Restart(permit) => {
1088 let delay = permit.delay();
1089 if delay > deadline.saturating_duration_since(Instant::now()) {
1090 slow.pending = Some(RestartOutcome::Restart(permit));
1091 return Err(LifecycleError::Service(ServiceError::retryable(
1092 "restart_backoff",
1093 format!(
1094 "the next start of the owned host waits {} ms (restart backoff)",
1095 delay.as_millis()
1096 ),
1097 )));
1098 }
1099 std::thread::sleep(delay);
1100 if slow.tracker.permit_valid(&permit, slow.origin.elapsed()) {
1101 Ok(())
1102 } else {
1103 Err(LifecycleError::NoHost)
1104 }
1105 }
1106 RestartOutcome::Exhausted => {
1107 slow.exhausted = true;
1108 Err(LifecycleError::RestartExhausted)
1109 }
1110 RestartOutcome::AwaitingDemand
1111 | RestartOutcome::Stopped
1112 | RestartOutcome::IgnoredStale => Err(LifecycleError::NoHost),
1113 }
1114 }
1115
1116 fn spawn_child(&self, slow: &mut Slow) -> Result<(), LifecycleError> {
1117 let launched = {
1118 let mut launcher = lock(&self.launcher);
1119 match launcher.as_mut() {
1120 Some(launch) => launch(),
1121 None => return Err(LifecycleError::NoHost),
1122 }
1123 };
1124 let mut child = match launched {
1125 Ok(child) => child,
1126 Err(e) => {
1127 self.charge_failed_launch(slow);
1128 return Err(LifecycleError::Launch(e));
1129 }
1130 };
1131 let Some(pid) = NonZeroU32::new(child.id()) else {
1132 let _ = child.terminate_tree();
1133 self.charge_failed_launch(slow);
1134 return Err(LifecycleError::Launch(io::Error::other(
1135 "the launcher returned a child without a process id",
1136 )));
1137 };
1138 let identity = match AdoptedIdentity::capture(pid) {
1139 Ok(Some(identity)) => identity,
1140 Ok(None) => {
1141 let _ = child.terminate_tree();
1142 self.charge_failed_launch(slow);
1143 return Err(LifecycleError::Launch(io::Error::other(
1144 "the launched process exited before its identity could be recorded",
1145 )));
1146 }
1147 Err(e) => {
1148 let _ = child.terminate_tree();
1149 self.charge_failed_launch(slow);
1150 return Err(LifecycleError::Process(e));
1151 }
1152 };
1153 slow.generation = Some(slow.tracker.started(slow.origin.elapsed()));
1154 slow.owned = Some(OwnedHost {
1155 child,
1156 identity,
1157 host: None,
1158 });
1159 Ok(())
1160 }
1161
1162 fn await_publication(
1164 &self,
1165 slow: &mut Slow,
1166 deadline: Instant,
1167 ) -> Result<Current, LifecycleError> {
1168 loop {
1169 let pid = match slow.owned.as_mut() {
1170 Some(owned) => {
1171 if !matches!(owned.child.try_wait(), Ok(None)) {
1172 self.fail_owned(slow);
1173 return Err(LifecycleError::Launch(io::Error::other(
1174 "the launched host exited before it published itself",
1175 )));
1176 }
1177 owned.child.id()
1178 }
1179 None => return Err(LifecycleError::NoHost),
1180 };
1181 match self.backend.discover().map_err(unavailable)? {
1182 Some(host) if host.pid == pid => {
1183 if let Err(e) = self.backend.connect(&host).map(|link| link.close()) {
1184 self.fail_owned(slow);
1186 return Err(e);
1187 }
1188 if let Some(owned) = slow.owned.as_mut() {
1189 owned.host = Some(host.clone());
1190 }
1191 if let Some(generation) = slow.generation {
1192 slow.tracker.ready(generation);
1193 }
1194 return self
1195 .bind(Some((host, Provenance::Owned)))
1196 .ok_or(LifecycleError::NoHost);
1197 }
1198 Some(host) => {
1199 self.retire_owned(slow);
1202 return self.adopt_found(slow, host);
1203 }
1204 None => {}
1205 }
1206 if Instant::now() >= deadline {
1207 self.fail_owned(slow);
1208 return Err(LifecycleError::LaunchTimeout);
1209 }
1210 std::thread::sleep(self.cfg.poll_interval);
1211 }
1212 }
1213}
1214
1215fn stop_launched(owned: &mut OwnedHost, grace: Duration) {
1218 if let Ok(IdentityCheck::Replaced) = owned.identity.verify() {
1219 return;
1220 }
1221 let _ = owned.child.wait_then_kill(grace);
1222}
1223
1224pub struct Session {
1227 inner: Arc<Inner>,
1228 id: u64,
1229 label: String,
1230 provenance: Provenance,
1231 host: HostGeneration,
1232 epoch: u64,
1233 link: Box<dyn HostLink>,
1234 released: AtomicBool,
1235}
1236
1237impl Session {
1238 pub fn provenance(&self) -> Provenance {
1239 self.provenance
1240 }
1241
1242 pub fn host(&self) -> &HostGeneration {
1243 &self.host
1244 }
1245
1246 pub fn epoch(&self) -> u64 {
1248 self.epoch
1249 }
1250
1251 pub fn label(&self) -> &str {
1252 &self.label
1253 }
1254
1255 pub fn is_valid(&self) -> bool {
1258 !self.released.load(Ordering::SeqCst)
1259 && !self.inner.stopped.load(Ordering::SeqCst)
1260 && !self.link.is_closed()
1261 && self.inner.fence.epoch() == self.epoch
1262 }
1263
1264 pub fn call(&self, method: &str, params: Value) -> Result<Value, LifecycleError> {
1269 if self.released.load(Ordering::SeqCst) || self.inner.stopped.load(Ordering::SeqCst) {
1270 return Err(LifecycleError::Released);
1271 }
1272 self.confirm_current()?;
1273 let ticket = self.inner.fence.issue(self.epoch)?;
1274 let reply = self.link.call(method, params);
1275 if reply.is_err() && self.link.is_closed() {
1279 let settle = Instant::now() + HOST_EXIT_SETTLE;
1280 while Instant::now() < settle {
1281 self.confirm_current()?;
1282 std::thread::sleep(Duration::from_millis(20));
1283 }
1284 }
1285 self.confirm_current()?;
1287 self.inner.fence.admit_reply(&ticket)?;
1288 reply.map_err(LifecycleError::Service)
1289 }
1290
1291 fn confirm_current(&self) -> Result<(), LifecycleError> {
1292 let left = if self.provenance == Provenance::Owned && self.inner.owned_exited(&self.host) {
1293 true
1294 } else {
1295 match self.inner.backend.is_current(&self.host) {
1296 Ok(true) => false,
1297 Ok(false) => true,
1298 Err(_) => return Err(LifecycleError::Unconfirmed),
1299 }
1300 };
1301 if left {
1302 self.inner.host_left(&self.host);
1303 return Err(LifecycleError::StaleGeneration {
1304 issued: self.epoch,
1305 current: self.inner.fence.epoch(),
1306 });
1307 }
1308 Ok(())
1309 }
1310
1311 fn finish(&self) -> ReleaseReport {
1312 let first = !self.released.swap(true, Ordering::SeqCst);
1313 let remaining = if first {
1314 self.inner.release(self.id)
1315 } else {
1316 lock(&self.inner.sessions).len()
1317 };
1318 self.link.close();
1319 ReleaseReport {
1320 label: self.label.clone(),
1321 provenance: self.provenance,
1322 remaining_sessions: remaining,
1323 }
1324 }
1325}
1326
1327impl Drop for Session {
1328 fn drop(&mut self) {
1329 let _ = self.finish();
1330 }
1331}
1332
1333#[cfg(test)]
1334mod tests {
1335 use super::*;
1336 use rightkit_child_process::RestartMode;
1337 use serde_json::json;
1338 use std::sync::atomic::AtomicUsize;
1339
1340 fn generation(pid: u32, started_ms: u64) -> HostGeneration {
1341 HostGeneration {
1342 service: "svc".into(),
1343 endpoint: "endpoint".into(),
1344 pid,
1345 process_started_ms: 1_000,
1346 started_ms,
1347 }
1348 }
1349
1350 #[derive(Default)]
1351 struct FakeState {
1352 host: Option<HostGeneration>,
1353 refuse_identity: bool,
1354 contended: bool,
1355 calls: usize,
1356 stop_requests: usize,
1357 replace_during_call: Option<Option<HostGeneration>>,
1359 }
1360
1361 struct Fake {
1362 state: Arc<Mutex<FakeState>>,
1363 }
1364
1365 struct FakeLink {
1366 state: Arc<Mutex<FakeState>>,
1367 host: HostGeneration,
1368 closed: AtomicBool,
1369 }
1370
1371 impl Fake {
1372 fn new(host: Option<HostGeneration>) -> Arc<Self> {
1373 Arc::new(Self {
1374 state: Arc::new(Mutex::new(FakeState {
1375 host,
1376 ..FakeState::default()
1377 })),
1378 })
1379 }
1380
1381 fn publish(&self, host: Option<HostGeneration>) {
1382 lock(&self.state).host = host;
1383 }
1384 }
1385
1386 impl HostLink for FakeLink {
1387 fn call(&self, method: &str, _params: Value) -> Result<Value, ServiceError> {
1388 let mut state = lock(&self.state);
1389 state.calls += 1;
1390 if method == "stop" {
1391 state.stop_requests += 1;
1392 }
1393 if let Some(next) = state.replace_during_call.take() {
1394 state.host = next;
1395 }
1396 Ok(json!({"pid": self.host.pid}))
1397 }
1398
1399 fn is_closed(&self) -> bool {
1400 self.closed.load(Ordering::SeqCst)
1401 }
1402
1403 fn close(&self) {
1404 self.closed.store(true, Ordering::SeqCst);
1405 }
1406 }
1407
1408 impl HostBackend for Fake {
1409 fn discover(&self) -> io::Result<Option<HostGeneration>> {
1410 Ok(lock(&self.state).host.clone())
1411 }
1412
1413 fn connect(&self, host: &HostGeneration) -> Result<Box<dyn HostLink>, LifecycleError> {
1414 let state = lock(&self.state);
1415 if state.refuse_identity {
1416 return Err(LifecycleError::HostIdentity(HostIdentityError {
1417 expected: Vec::new(),
1418 found_pid: Some(host.pid),
1419 found_executable: None,
1420 reason: HostIdentityReason::ExecutableMismatch,
1421 }));
1422 }
1423 if state.host.as_ref() != Some(host) {
1424 return Err(LifecycleError::Service(ServiceError::retryable(
1425 ERROR_UNAVAILABLE,
1426 "gone",
1427 )));
1428 }
1429 Ok(Box::new(FakeLink {
1430 state: self.state.clone(),
1431 host: host.clone(),
1432 closed: AtomicBool::new(false),
1433 }))
1434 }
1435
1436 fn claim_spawn(&self) -> io::Result<SpawnPermit> {
1437 if lock(&self.state).contended {
1438 Ok(SpawnPermit::Contended)
1439 } else {
1440 Ok(SpawnPermit::Granted(Box::new(())))
1441 }
1442 }
1443 }
1444
1445 fn attach(fake: &Arc<Fake>) -> DaemonLifecycle {
1446 DaemonLifecycle::attach_or_launch(fake.clone(), None, LifecycleConfig::new()).unwrap()
1447 }
1448
1449 #[test]
1450 fn an_attached_host_is_never_owned_and_never_stopped() {
1451 let fake = Fake::new(Some(generation(10, 1)));
1452 let life = attach(&fake);
1453 assert_eq!(life.provenance(), Some(Provenance::Attached));
1454 assert!(!Provenance::Attached.may_stop_host());
1455 let session = life.acquire_session("shell").unwrap();
1456 assert_eq!(session.provenance(), Provenance::Attached);
1457 let error = life.shutdown_owned().unwrap_err();
1459 assert!(matches!(error, LifecycleError::NotOwned), "{error}");
1460 assert_eq!(error.code(), ERROR_NOT_OWNED);
1461 assert_eq!(lock(&fake.state).stop_requests, 0);
1462 assert_eq!(lock(&fake.state).host, Some(generation(10, 1)));
1463 assert_eq!(session.call("echo", json!({})).unwrap(), json!({"pid": 10}));
1464 }
1465
1466 #[test]
1467 fn a_host_that_fails_the_identity_check_is_not_attached() {
1468 let fake = Fake::new(Some(generation(10, 1)));
1469 lock(&fake.state).refuse_identity = true;
1470 let error = DaemonLifecycle::attach_or_launch(fake.clone(), None, LifecycleConfig::new())
1471 .err()
1472 .expect("an impostor is refused");
1473 assert!(matches!(error, LifecycleError::HostIdentity(_)), "{error}");
1474 assert!(!error.is_retryable());
1475 assert_eq!(lock(&fake.state).calls, 0, "nothing was sent to it");
1476 }
1477
1478 #[test]
1479 fn no_host_and_no_launcher_is_a_retryable_no_host() {
1480 let fake = Fake::new(None);
1481 let error = DaemonLifecycle::attach_or_launch(fake, None, LifecycleConfig::new())
1482 .err()
1483 .expect("nothing to attach to");
1484 assert!(matches!(error, LifecycleError::NoHost));
1485 assert!(error.is_retryable());
1486 assert_eq!(error.into_service_error().code, ERROR_UNAVAILABLE);
1487 }
1488
1489 #[test]
1490 fn two_shells_attach_and_one_detaching_leaves_the_other_running() {
1491 let fake = Fake::new(Some(generation(10, 1)));
1492 let (a, b) = (attach(&fake), attach(&fake));
1493 let session_a = a.acquire_session("shell-a").unwrap();
1494 let session_b = b.acquire_session("shell-b").unwrap();
1495 assert_eq!(a.session_count(), 1);
1496 assert_eq!(b.session_count(), 1);
1497 assert_eq!(a.host(), b.host());
1498 let report = a.release_session(session_a);
1499 assert_eq!(report.provenance, Provenance::Attached);
1500 assert_eq!(report.remaining_sessions, 0);
1501 assert_eq!(lock(&fake.state).host, Some(generation(10, 1)));
1503 assert_eq!(
1504 session_b.call("echo", json!({})).unwrap(),
1505 json!({"pid": 10})
1506 );
1507 assert_eq!(b.session_count(), 1);
1508 }
1509
1510 #[test]
1511 fn a_dropped_session_leaves_the_count() {
1512 let fake = Fake::new(Some(generation(10, 1)));
1513 let life = attach(&fake);
1514 {
1515 let _held = life.acquire_session("shell").unwrap();
1516 assert_eq!(life.session_count(), 1);
1517 }
1518 assert_eq!(life.session_count(), 0);
1519 }
1520
1521 #[test]
1522 fn when_the_owner_exits_the_session_is_stale_and_there_is_no_host() {
1523 let fake = Fake::new(Some(generation(10, 1)));
1524 let life = attach(&fake);
1525 let session = life.acquire_session("shell").unwrap();
1526 let bound = session.epoch();
1527 fake.publish(None);
1528 match session.call("echo", json!({})) {
1529 Err(LifecycleError::StaleGeneration { issued, current }) => {
1530 assert_eq!(issued, bound);
1531 assert!(current > issued);
1532 }
1533 other => panic!("expected a stale generation, got {other:?}"),
1534 }
1535 assert!(!session.is_valid());
1536 let error = life
1537 .acquire_session("shell")
1538 .err()
1539 .expect("no host any more");
1540 assert!(matches!(error, LifecycleError::NoHost));
1541 assert_eq!(life.provenance(), None);
1542 }
1543
1544 #[test]
1545 fn a_replacement_host_is_a_new_generation_and_the_old_session_stays_stale() {
1546 let fake = Fake::new(Some(generation(10, 1)));
1547 let life = attach(&fake);
1548 let old = life.acquire_session("shell").unwrap();
1549 fake.publish(Some(generation(11, 2)));
1550 let new = life.acquire_session("shell").unwrap();
1551 assert_eq!(new.host().pid, 11);
1552 assert!(new.epoch() > old.epoch());
1553 assert!(matches!(
1554 old.call("echo", json!({})),
1555 Err(LifecycleError::StaleGeneration { .. })
1556 ));
1557 assert_eq!(new.call("echo", json!({})).unwrap(), json!({"pid": 11}));
1558 assert_eq!(life.provenance(), Some(Provenance::Attached));
1559 }
1560
1561 #[test]
1562 fn a_reply_that_arrives_after_the_host_was_replaced_is_not_delivered() {
1563 let fake = Fake::new(Some(generation(10, 1)));
1564 let life = attach(&fake);
1565 let session = life.acquire_session("shell").unwrap();
1566 lock(&fake.state).replace_during_call = Some(Some(generation(11, 2)));
1568 let outcome = session.call("echo", json!({}));
1569 assert!(
1570 matches!(outcome, Err(LifecycleError::StaleGeneration { .. })),
1571 "{outcome:?}"
1572 );
1573 assert_eq!(lock(&fake.state).calls, 1);
1575 }
1576
1577 #[test]
1578 fn the_fence_refuses_a_stale_ticket_and_a_stale_reply_epoch() {
1579 let fence = GenerationFence::new();
1580 let first = fence.advance();
1581 let old = fence.issue(first).unwrap();
1582 assert!(fence.admit_reply(&old).is_ok());
1583 let second = fence.advance();
1584 assert!(second > first);
1585 assert_eq!(
1587 fence.admit_reply(&old),
1588 Err(Stale {
1589 issued: first,
1590 current: second
1591 })
1592 );
1593 assert!(fence.issue(first).is_err());
1594 let new = fence.issue(second).unwrap();
1595 assert_ne!(old.request(), new.request());
1596 assert!(fence.admit_reply_from(&new, first).is_err());
1598 assert!(fence.admit_reply_from(&new, second).is_ok());
1599 }
1600
1601 #[test]
1602 fn a_launcher_that_keeps_failing_spends_the_restart_budget() {
1603 let fake = Fake::new(Some(generation(10, 1)));
1604 let attempts = Arc::new(AtomicUsize::new(0));
1605 let counter = attempts.clone();
1606 let launcher: Launcher = Box::new(move || {
1607 counter.fetch_add(1, Ordering::SeqCst);
1608 Err(io::Error::other("no such engine"))
1609 });
1610 let policy = RestartPolicy::new(
1611 RestartMode::Automatic,
1612 2,
1613 Duration::from_secs(60),
1614 Duration::from_millis(1),
1615 Duration::from_millis(2),
1616 )
1617 .unwrap();
1618 let config = LifecycleConfig::new()
1619 .restart_policy(policy)
1620 .startup_timeout(Duration::from_secs(5))
1621 .poll_interval(Duration::from_millis(1));
1622 let life = DaemonLifecycle::attach_or_launch(fake.clone(), Some(launcher), config).unwrap();
1623 fake.publish(None);
1625 for _ in 0..3 {
1626 let error = life.acquire_session("shell").err().expect("launch fails");
1627 assert!(matches!(error, LifecycleError::Launch(_)), "{error}");
1628 }
1629 assert_eq!(attempts.load(Ordering::SeqCst), 3);
1630 let error = life.acquire_session("shell").err().expect("budget spent");
1631 assert!(matches!(error, LifecycleError::RestartExhausted), "{error}");
1632 assert_eq!(error.code(), ERROR_RESTART_EXHAUSTED);
1633 let again = life.acquire_session("shell").err().expect("still spent");
1635 assert!(matches!(again, LifecycleError::RestartExhausted));
1636 assert_eq!(attempts.load(Ordering::SeqCst), 3);
1637 life.reset_restart_budget();
1639 let after = life
1640 .acquire_session("shell")
1641 .err()
1642 .expect("launch fails again");
1643 assert!(matches!(after, LifecycleError::Launch(_)), "{after}");
1644 assert_eq!(attempts.load(Ordering::SeqCst), 4);
1645 }
1646
1647 #[test]
1648 fn a_contended_claim_waits_for_the_winner_and_attaches() {
1649 let fake = Fake::new(None);
1650 lock(&fake.state).contended = true;
1651 let launcher: Launcher = Box::new(|| Err(io::Error::other("must not launch")));
1652 let publisher = {
1653 let fake = fake.clone();
1654 std::thread::spawn(move || {
1655 std::thread::sleep(Duration::from_millis(50));
1656 fake.publish(Some(generation(20, 5)));
1657 })
1658 };
1659 let config = LifecycleConfig::new()
1660 .startup_timeout(Duration::from_secs(10))
1661 .poll_interval(Duration::from_millis(5));
1662 let life = DaemonLifecycle::attach_or_launch(fake, Some(launcher), config).unwrap();
1663 publisher.join().unwrap();
1664 assert_eq!(life.provenance(), Some(Provenance::Attached));
1665 assert_eq!(life.host().map(|h| h.pid), Some(20));
1666 }
1667
1668 #[test]
1669 fn errors_collapse_to_wire_codes() {
1670 assert_eq!(
1671 LifecycleError::StaleGeneration {
1672 issued: 1,
1673 current: 2
1674 }
1675 .into_service_error()
1676 .code,
1677 ERROR_STALE_GENERATION
1678 );
1679 assert_eq!(
1680 LifecycleError::NotOwned.into_service_error().code,
1681 ERROR_NOT_OWNED
1682 );
1683 assert_eq!(
1684 LifecycleError::Unconfirmed.into_service_error().code,
1685 ERROR_STALE_GENERATION
1686 );
1687 assert!(!LifecycleError::Unconfirmed.is_retryable());
1688 }
1689}