Skip to main content

rightkit_daemon/
lifecycle.rs

1//! Daemon client lifecycle with typed provenance (PK06-4, K06 "Host composition").
2//!
3//! Two supported lifecycles for a service host that outlives one window of one app:
4//!
5//! * **[`Provenance::Owned`]**: this process launched the host (through the [`Launcher`] the app
6//!   supplied), recorded the launched process's [`AdoptedIdentity`] (pid plus start time), and
7//!   waited for that same pid to publish itself. Only an owned host may be stopped by this
8//!   lifecycle ([`DaemonLifecycle::shutdown_owned`]), and only while the recorded identity still
9//!   names the launched process. A replacement the lifecycle launches itself is owned too.
10//! * **[`Provenance::Attached`]**: some other process started the host (another shell of the same
11//!   app, a CLI, an installer). The lifecycle verifies the endpoint's identity and generation and
12//!   uses it; it never signals it, never stops it and never infers a right to. An arbitrary
13//!   discovered pid is never owned. [`DaemonLifecycle::release_session`] on an attached client
14//!   closes that client's connection and nothing else.
15//!
16//! The pieces, in the order the spec names them:
17//!
18//! * [`DaemonLifecycle::attach_or_launch`]: discover the live host. A live, reachable host whose
19//!   process generation still matches is attached (after the identity check below). Otherwise,
20//!   when a [`Launcher`] was given, take the cross-process spawn claim, launch, and wait for the
21//!   launched pid to publish. Without a launcher, no host is [`LifecycleError::NoHost`].
22//! * [`DaemonLifecycle::acquire_session`]: **revalidates** the host (record, process generation,
23//!   and the connection's own identity check) before it hands out a [`Session`]. A host that
24//!   exited is relaunched only when this lifecycle owns the launcher and the restart budget
25//!   ([`RestartPolicy`], the same tracker the private-child supervisor uses) allows it; a spent
26//!   budget is [`LifecycleError::RestartExhausted`] until
27//!   [`reset_restart_budget`](DaemonLifecycle::reset_restart_budget).
28//! * [`Session::call`]: revalidates **before the request is written and again before the reply is
29//!   handed back**. A reply that arrives after the host changed generation is not delivered
30//!   ([`LifecycleError::StaleGeneration`]): whether the request ran on the old host is unknown,
31//!   so it is never replayed automatically. [`GenerationFence`] is the same rule for transports
32//!   that multiplex requests.
33//! * [`DaemonLifecycle::release_session`]: ends one session. Never signals the host.
34//! * [`DaemonLifecycle::shutdown_owned`]: owned hosts only. The app's own graceful request first
35//!   (when one was configured), the grace period, then the launched process tree is killed if it
36//!   is still there. [`LifecycleError::NotOwned`] for an attached host, with nothing sent and
37//!   nothing signalled.
38//!
39//! **Identity.** The local backend ([`LocalBackend`]) connects with
40//! [`Client::connect_expecting`]: the OS names the process on the listening end of the socket and
41//! its executable must be the [`HostExpectation`]'s, before any byte is written. The kernel pid
42//! must also equal the pid `service.json` names, and the record read during the handshake must be
43//! the generation that was discovered ([`HostGeneration`]: endpoint, pid, process start time and
44//! publication time). A host that fails any of it is [`LifecycleError::HostIdentity`] or a
45//! retryable change error, and is never attached.
46//!
47//! **Not replaced.** [`supervise_adopted`](crate::supervisor::supervise_adopted) stays the way to
48//! run a private child (`PrivateChild`, secret on stdin). This module is for resident services
49//! published through `service.json`.
50//!
51//! Dropping a [`DaemonLifecycle`] that owns a host drops its [`OwnedChild`], which ends the
52//! launched process tree (the `OwnedChild` drop rule). Dropping an attached lifecycle ends nothing.
53
54use std::any::Any;
55use std::collections::BTreeMap;
56use std::fmt;
57use std::io;
58use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
59use std::sync::{Arc, Mutex, MutexGuard};
60use std::time::{Duration, Instant};
61
62use rightkit_child_process::{
63    AdoptedIdentity, Generation, IdentityCheck, OwnedChild, RestartOutcome, RestartPolicy,
64    RestartTracker, WaitOutcome,
65};
66use serde_json::Value;
67
68use crate::client::{Client, ClientConfig};
69use crate::discovery::{
70    record_process_matches, try_adopt_or_spawn, AdoptOrSpawn, SpawnClaim, SpawnClaimError,
71};
72use crate::error::{ConnectError, ServiceError, ERROR_UNAVAILABLE};
73use crate::host_identity::{HostExpectation, HostIdentityError, HostIdentityReason};
74use crate::record::{try_read_record, ServiceRecord};
75
76pub use crate::incumbent::{stop_incumbent, IncumbentError, IncumbentStop};
77pub use crate::shutdown::{OrderedShutdown, PhaseOutcome, PhaseReport};
78use std::num::NonZeroU32;
79
80/// A reply (or a request) belongs to a host generation that is no longer the current one. Not
81/// retryable: the request may have run on the old host.
82pub const ERROR_STALE_GENERATION: &str = "stale_generation";
83/// The host is attached, not owned: this lifecycle may not stop it.
84pub const ERROR_NOT_OWNED: &str = "not_owned";
85/// The owned host exited and the restart budget is spent.
86pub const ERROR_RESTART_EXHAUSTED: &str = "restart_exhausted";
87/// The session or the lifecycle was already released or shut down.
88pub const ERROR_SESSION_RELEASED: &str = "session_released";
89/// The host could not be launched or never published itself.
90pub const ERROR_LAUNCH_FAILED: &str = "launch_failed";
91
92fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
93    m.lock().unwrap_or_else(|e| e.into_inner())
94}
95
96/// Who started the host this client uses, and so what the client may do to it.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
98pub enum Provenance {
99    /// This process launched the host and holds its identity. It may stop it.
100    Owned,
101    /// Another process started the host. Attaching never implies a right to stop it.
102    Attached,
103}
104
105impl Provenance {
106    pub fn as_str(self) -> &'static str {
107        match self {
108            Provenance::Owned => "owned",
109            Provenance::Attached => "attached",
110        }
111    }
112
113    /// Whether this lifecycle may signal the host. True only for [`Provenance::Owned`].
114    pub fn may_stop_host(self) -> bool {
115        matches!(self, Provenance::Owned)
116    }
117}
118
119/// One incarnation of a published host: the facts in `service.json` that change when the host is
120/// replaced. Two values are the same generation only when every field is equal.
121///
122/// Limit: `started_ms` is the publication time in milliseconds. A host replaced inside the same
123/// process within the same millisecond is indistinguishable here; a replacement process differs
124/// in pid or process start time. A per-host instance nonce in `service.json` would close that
125/// gap and is an open decision (see the CHANGELOG).
126#[derive(Debug, Clone, PartialEq, Eq, Hash)]
127pub struct HostGeneration {
128    pub service: String,
129    pub endpoint: String,
130    pub pid: u32,
131    /// Birth time of the host process as the record states it; 0 when the writer did not know it.
132    pub process_started_ms: u64,
133    /// Publication time of the record.
134    pub started_ms: u64,
135}
136
137impl HostGeneration {
138    /// The generation a record names.
139    pub fn of(record: &ServiceRecord) -> Self {
140        Self {
141            service: record.service.clone(),
142            endpoint: record.endpoint.clone(),
143            pid: record.pid,
144            process_started_ms: record.process_started_ms,
145            started_ms: record.started_ms,
146        }
147    }
148}
149
150/// Why a lifecycle operation failed.
151#[derive(Debug)]
152#[non_exhaustive]
153pub enum LifecycleError {
154    /// A wire-level or discovery error (kept with its `retryable` flag).
155    Service(ServiceError),
156    /// The process behind the endpoint is not the host the caller expected. Nothing was sent to
157    /// it, and nothing was attached.
158    HostIdentity(HostIdentityError),
159    /// No live host, and no launcher to start one. Retryable.
160    NoHost,
161    /// The launcher failed, or the launched process left before it published itself.
162    Launch(io::Error),
163    /// The launched process did not publish itself within the startup timeout (it was stopped).
164    LaunchTimeout,
165    /// The owned host exited and the restart budget is spent.
166    RestartExhausted,
167    /// [`DaemonLifecycle::shutdown_owned`] on a host this lifecycle did not launch.
168    NotOwned,
169    /// The session or the lifecycle was already released or shut down.
170    Released,
171    /// The host the session was bound to is gone or was replaced. `issued` is the generation
172    /// epoch the session (or request) was bound to, `current` the epoch now. The request may or
173    /// may not have run on the old host.
174    StaleGeneration { issued: u64, current: u64 },
175    /// The host generation could not be confirmed (its record was unreadable). Treated like
176    /// stale: a reply that cannot be tied to the current host is not delivered.
177    Unconfirmed,
178    /// A process operation failed (identity read, signal).
179    Process(io::Error),
180}
181
182impl LifecycleError {
183    /// The wire-style code.
184    pub fn code(&self) -> &str {
185        match self {
186            LifecycleError::Service(e) => &e.code,
187            LifecycleError::HostIdentity(_) => crate::error::ERROR_HOST_IDENTITY,
188            LifecycleError::NoHost => ERROR_UNAVAILABLE,
189            LifecycleError::Launch(_) | LifecycleError::LaunchTimeout => ERROR_LAUNCH_FAILED,
190            LifecycleError::RestartExhausted => ERROR_RESTART_EXHAUSTED,
191            LifecycleError::NotOwned => ERROR_NOT_OWNED,
192            LifecycleError::Released => ERROR_SESSION_RELEASED,
193            LifecycleError::StaleGeneration { .. } | LifecycleError::Unconfirmed => {
194                ERROR_STALE_GENERATION
195            }
196            LifecycleError::Process(_) => "io",
197        }
198    }
199
200    /// Whether trying again (a fresh `acquire_session`) can succeed. Never true for a stale
201    /// reply as such: the caller decides whether the request may run twice.
202    pub fn is_retryable(&self) -> bool {
203        match self {
204            LifecycleError::Service(e) => e.retryable,
205            LifecycleError::NoHost => true,
206            _ => false,
207        }
208    }
209
210    /// Collapse into the single wire-style error.
211    pub fn into_service_error(self) -> ServiceError {
212        let text = self.to_string();
213        match self {
214            LifecycleError::Service(e) => e,
215            LifecycleError::HostIdentity(e) => e.into(),
216            LifecycleError::NoHost => ServiceError::retryable(ERROR_UNAVAILABLE, text),
217            other => ServiceError::new(other.code().to_string(), text),
218        }
219    }
220}
221
222impl fmt::Display for LifecycleError {
223    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224        match self {
225            LifecycleError::Service(e) => fmt::Display::fmt(e, f),
226            LifecycleError::HostIdentity(e) => fmt::Display::fmt(e, f),
227            LifecycleError::NoHost => f.write_str("no live host and no launcher to start one"),
228            LifecycleError::Launch(e) => write!(f, "the host could not be launched: {e}"),
229            LifecycleError::LaunchTimeout => {
230                f.write_str("the launched host did not publish itself in time; it was stopped")
231            }
232            LifecycleError::RestartExhausted => {
233                f.write_str("the owned host exited and the restart budget is spent")
234            }
235            LifecycleError::NotOwned => f.write_str(
236                "this host was not launched by this lifecycle; attaching never implies the right to stop it",
237            ),
238            LifecycleError::Released => f.write_str("the session or the lifecycle was released"),
239            LifecycleError::StaleGeneration { issued, current } => write!(
240                f,
241                "the host generation changed (bound to epoch {issued}, now {current}); the request may have run on the previous host and is not replayed"
242            ),
243            LifecycleError::Unconfirmed => f.write_str(
244                "the host generation could not be confirmed; the reply is not delivered",
245            ),
246            LifecycleError::Process(e) => write!(f, "process operation failed: {e}"),
247        }
248    }
249}
250
251impl std::error::Error for LifecycleError {
252    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
253        match self {
254            LifecycleError::Service(e) => Some(e),
255            LifecycleError::HostIdentity(e) => Some(e),
256            LifecycleError::Launch(e) | LifecycleError::Process(e) => Some(e),
257            _ => None,
258        }
259    }
260}
261
262impl From<ServiceError> for LifecycleError {
263    fn from(e: ServiceError) -> Self {
264        LifecycleError::Service(e)
265    }
266}
267
268fn unavailable(e: io::Error) -> LifecycleError {
269    LifecycleError::Service(ServiceError::retryable(
270        ERROR_UNAVAILABLE,
271        format!("host discovery failed (try again): {e}"),
272    ))
273}
274
275// ---------------------------------------------------------------------------------------------
276// Generation fence
277// ---------------------------------------------------------------------------------------------
278
279/// How long a request whose connection closed waits for the host's exit to become observable before
280/// its transport error is reported as such.
281const HOST_EXIT_SETTLE: Duration = Duration::from_millis(500);
282
283/// A request was issued under, or a reply tagged with, an epoch that is no longer current.
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub struct Stale {
286    pub issued: u64,
287    pub current: u64,
288}
289
290impl From<Stale> for LifecycleError {
291    fn from(s: Stale) -> Self {
292        LifecycleError::StaleGeneration {
293            issued: s.issued,
294            current: s.current,
295        }
296    }
297}
298
299/// A request in flight, bound to the epoch it was issued under.
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301pub struct Ticket {
302    epoch: u64,
303    request: u64,
304}
305
306impl Ticket {
307    pub fn epoch(&self) -> u64 {
308        self.epoch
309    }
310
311    pub fn request(&self) -> u64 {
312        self.request
313    }
314}
315
316/// Binds requests to a host generation. The epoch advances every time the host the lifecycle is
317/// bound to changes (first bind, replacement, exit). A [`Ticket`] issued under an older epoch can
318/// never complete: its reply is [`Stale`], so a late reply from a previous host cannot answer a
319/// request made to the next one.
320///
321/// Transports that multiplex requests over one connection use it directly: `issue` when a request
322/// is written, `admit_reply` (or `admit_reply_from` when the reply carries the epoch it was read
323/// under) when its reply arrives.
324#[derive(Debug, Default)]
325pub struct GenerationFence {
326    epoch: AtomicU64,
327    next_request: AtomicU64,
328}
329
330impl GenerationFence {
331    pub fn new() -> Self {
332        Self::default()
333    }
334
335    /// The current epoch (0 before any host was bound).
336    pub fn epoch(&self) -> u64 {
337        self.epoch.load(Ordering::SeqCst)
338    }
339
340    /// A different host generation was bound (or the host left): every outstanding [`Ticket`] is
341    /// now stale. Returns the new epoch.
342    pub fn advance(&self) -> u64 {
343        self.epoch.fetch_add(1, Ordering::SeqCst) + 1
344    }
345
346    /// Issue a request under `bound_epoch`, the epoch the caller's connection was bound to.
347    /// Refused when that is no longer the current epoch.
348    pub fn issue(&self, bound_epoch: u64) -> Result<Ticket, Stale> {
349        let current = self.epoch();
350        if bound_epoch != current {
351            return Err(Stale {
352                issued: bound_epoch,
353                current,
354            });
355        }
356        Ok(Ticket {
357            epoch: bound_epoch,
358            request: self.next_request.fetch_add(1, Ordering::SeqCst) + 1,
359        })
360    }
361
362    /// A reply arrived for `ticket`. Admitted only while the epoch it was issued under is still
363    /// the current one.
364    pub fn admit_reply(&self, ticket: &Ticket) -> Result<(), Stale> {
365        let current = self.epoch();
366        if ticket.epoch != current {
367            return Err(Stale {
368                issued: ticket.epoch,
369                current,
370            });
371        }
372        Ok(())
373    }
374
375    /// [`admit_reply`](Self::admit_reply) for a reply that itself names the epoch of the
376    /// connection it was read from: it must equal the ticket's epoch as well.
377    pub fn admit_reply_from(&self, ticket: &Ticket, reply_epoch: u64) -> Result<(), Stale> {
378        if reply_epoch != ticket.epoch {
379            return Err(Stale {
380                issued: ticket.epoch,
381                current: reply_epoch,
382            });
383        }
384        self.admit_reply(ticket)
385    }
386}
387
388// ---------------------------------------------------------------------------------------------
389// Backend: how a host is found and reached
390// ---------------------------------------------------------------------------------------------
391
392/// A connection to exactly one host generation, as the lifecycle uses it.
393pub trait HostLink: Send + Sync {
394    fn call(&self, method: &str, params: Value) -> Result<Value, ServiceError>;
395    /// Whether the connection is known closed.
396    fn is_closed(&self) -> bool;
397    /// Close the connection. Never stops the host.
398    fn close(&self);
399}
400
401/// Whether this process may start a host now.
402pub enum SpawnPermit {
403    /// This process holds the cross-process claim until the value is dropped.
404    Granted(Box<dyn Any + Send>),
405    /// Another process is starting a host: wait for it to publish.
406    Contended,
407}
408
409/// How a lifecycle finds, verifies and reaches a host. [`LocalBackend`] is the `service.json`
410/// transport; tests and other transports implement it too.
411pub trait HostBackend: Send + Sync + 'static {
412    /// The live published host now, or `None`. An unreadable record is `Err` (unknown), never
413    /// `None`: the lifecycle must not start a second host on an unknown.
414    fn discover(&self) -> io::Result<Option<HostGeneration>>;
415
416    /// Open a connection to exactly `host`, after verifying who is on the other end. A host that
417    /// fails the identity check is [`LifecycleError::HostIdentity`]; nothing application-level was
418    /// sent to it.
419    fn connect(&self, host: &HostGeneration) -> Result<Box<dyn HostLink>, LifecycleError>;
420
421    /// Whether `host` is still the published, live generation. Called before every request and
422    /// before every reply is handed back, so it should be cheap (no connection).
423    fn is_current(&self, host: &HostGeneration) -> io::Result<bool> {
424        Ok(self.discover()?.as_ref() == Some(host))
425    }
426
427    /// Take the cross-process claim that serialises starting a host.
428    fn claim_spawn(&self) -> io::Result<SpawnPermit>;
429}
430
431/// The local `service.json` transport: discovery through the suite, identity through
432/// [`Client::connect_expecting`].
433pub struct LocalBackend {
434    client: ClientConfig,
435    expect: HostExpectation,
436}
437
438impl LocalBackend {
439    /// `client` names the service, the app label and the suite (see [`ClientConfig`]); `expect`
440    /// names the executable the host may be. An expectation is required: attaching to a host
441    /// whose program was never checked is not offered.
442    pub fn new(client: ClientConfig, expect: HostExpectation) -> Self {
443        Self { client, expect }
444    }
445
446    fn suite(&self) -> io::Result<crate::suite::Suite> {
447        self.client.resolve_suite().map_err(io::Error::other)
448    }
449}
450
451struct LocalLink(Client);
452
453impl HostLink for LocalLink {
454    fn call(&self, method: &str, params: Value) -> Result<Value, ServiceError> {
455        self.0.call(method, params)
456    }
457
458    fn is_closed(&self) -> bool {
459        self.0.is_closed()
460    }
461
462    fn close(&self) {
463        self.0.abort();
464    }
465}
466
467impl HostBackend for LocalBackend {
468    fn discover(&self) -> io::Result<Option<HostGeneration>> {
469        let suite = self.suite()?;
470        Ok(match try_adopt_or_spawn(&suite, self.client.service())? {
471            AdoptOrSpawn::Adopt(record) => Some(HostGeneration::of(&record)),
472            AdoptOrSpawn::Spawn => None,
473        })
474    }
475
476    fn connect(&self, host: &HostGeneration) -> Result<Box<dyn HostLink>, LifecycleError> {
477        let client =
478            Client::connect_expecting(&self.client, &self.expect).map_err(|e| match e {
479                ConnectError::HostIdentity(identity) => LifecycleError::HostIdentity(identity),
480                other => LifecycleError::Service(other.into_service_error()),
481            })?;
482        // The record the handshake read must be the generation that was discovered.
483        if HostGeneration::of(client.record()) != *host {
484            client.abort();
485            return Err(LifecycleError::Service(ServiceError::retryable(
486                ERROR_UNAVAILABLE,
487                "the host changed while connecting; discover it again",
488            )));
489        }
490        // The process the kernel put behind the socket must be the process the record names.
491        let identity_error = |found_pid: Option<u32>,
492                              found_executable: Option<std::path::PathBuf>,
493                              reason: HostIdentityReason| {
494            LifecycleError::HostIdentity(HostIdentityError {
495                expected: self.expect.executables().to_vec(),
496                found_pid,
497                found_executable,
498                reason,
499            })
500        };
501        match client.verified_host() {
502            Some(process) if process.pid == host.pid => {}
503            Some(process) => {
504                let error = identity_error(
505                    Some(process.pid),
506                    Some(process.executable.clone()),
507                    HostIdentityReason::HostChanged(format!(
508                        "the endpoint is served by pid {} but service.json names pid {}",
509                        process.pid, host.pid
510                    )),
511                );
512                client.abort();
513                return Err(error);
514            }
515            None => {
516                client.abort();
517                return Err(identity_error(
518                    None,
519                    None,
520                    HostIdentityReason::PidUnavailable,
521                ));
522            }
523        }
524        Ok(Box::new(LocalLink(client)))
525    }
526
527    fn is_current(&self, host: &HostGeneration) -> io::Result<bool> {
528        let suite = self.suite()?;
529        Ok(try_read_record(&suite, self.client.service())?
530            .is_some_and(|r| record_process_matches(&r) && HostGeneration::of(&r) == *host))
531    }
532
533    fn claim_spawn(&self) -> io::Result<SpawnPermit> {
534        let suite = self.suite()?;
535        match SpawnClaim::acquire(&suite, self.client.service()) {
536            Ok(claim) => Ok(SpawnPermit::Granted(Box::new(claim))),
537            Err(SpawnClaimError::AlreadyHeld(_)) => Ok(SpawnPermit::Contended),
538            Err(SpawnClaimError::Io(e)) => Err(e),
539        }
540    }
541}
542
543// ---------------------------------------------------------------------------------------------
544// Configuration
545// ---------------------------------------------------------------------------------------------
546
547/// Starts the host. Called with the cross-process claim held, at most once per start. The child
548/// it returns is owned by the lifecycle from then on; the app configures the command, including
549/// how it detaches.
550pub type Launcher = Box<dyn FnMut() -> io::Result<OwnedChild> + Send>;
551
552/// The app's own polite stop request (for example its `shutdown` method), sent over a freshly
553/// verified connection to the owned host before any signal. An `Err` is not a failure of the
554/// shutdown: a host that exits as it answers often drops the connection.
555pub type GracefulStop = Arc<dyn Fn(&dyn HostLink) -> Result<(), ServiceError> + Send + Sync>;
556
557/// Timeouts, restart budget and the graceful stop request.
558#[derive(Clone)]
559pub struct LifecycleConfig {
560    startup_timeout: Duration,
561    poll_interval: Duration,
562    stop_grace: Duration,
563    restart: RestartPolicy,
564    graceful: Option<GracefulStop>,
565}
566
567impl Default for LifecycleConfig {
568    fn default() -> Self {
569        Self {
570            startup_timeout: Duration::from_secs(30),
571            poll_interval: Duration::from_millis(50),
572            stop_grace: Duration::from_secs(5),
573            restart: RestartPolicy::default(),
574            graceful: None,
575        }
576    }
577}
578
579impl LifecycleConfig {
580    pub fn new() -> Self {
581        Self::default()
582    }
583
584    /// How long a launch (or a wait for another process's launch) may take. Default 30 s.
585    #[must_use]
586    pub fn startup_timeout(mut self, timeout: Duration) -> Self {
587        self.startup_timeout = timeout;
588        self
589    }
590
591    /// Time between checks while waiting for a host to publish. Default 50 ms, at least 1 ms.
592    #[must_use]
593    pub fn poll_interval(mut self, interval: Duration) -> Self {
594        self.poll_interval = interval.max(Duration::from_millis(1));
595        self
596    }
597
598    /// How long an owned host gets to exit after the graceful request before its process tree is
599    /// killed. Default 5 s.
600    #[must_use]
601    pub fn stop_grace(mut self, grace: Duration) -> Self {
602        self.stop_grace = grace;
603        self
604    }
605
606    /// The restart budget for an owned host that exits or fails to start. Default
607    /// [`RestartPolicy::default`] (automatic, 3 per 60 s).
608    #[must_use]
609    pub fn restart_policy(mut self, policy: RestartPolicy) -> Self {
610        self.restart = policy;
611        self
612    }
613
614    /// The graceful stop request for [`DaemonLifecycle::shutdown_owned`].
615    #[must_use]
616    pub fn graceful_stop<F>(mut self, stop: F) -> Self
617    where
618        F: Fn(&dyn HostLink) -> Result<(), ServiceError> + Send + Sync + 'static,
619    {
620        self.graceful = Some(Arc::new(stop));
621        self
622    }
623}
624
625// ---------------------------------------------------------------------------------------------
626// The lifecycle
627// ---------------------------------------------------------------------------------------------
628
629struct OwnedHost {
630    child: OwnedChild,
631    /// pid plus start time of the launched process: the only thing that authorises a signal.
632    identity: AdoptedIdentity,
633    /// The generation the launched process published, once it did.
634    host: Option<HostGeneration>,
635}
636
637struct Slow {
638    owned: Option<OwnedHost>,
639    tracker: RestartTracker,
640    origin: Instant,
641    generation: Option<Generation>,
642    /// What the tracker decided at the last exit or failed start, applied at the next launch.
643    pending: Option<RestartOutcome>,
644    exhausted: bool,
645}
646
647#[derive(Clone)]
648struct Current {
649    host: HostGeneration,
650    provenance: Provenance,
651    epoch: u64,
652}
653
654struct Inner {
655    backend: Arc<dyn HostBackend>,
656    cfg: LifecycleConfig,
657    launcher: Mutex<Option<Launcher>>,
658    /// Serialises launching, adopting and stopping. Held across a launch wait; never taken by
659    /// [`Session::call`].
660    slow: Mutex<Slow>,
661    current: Mutex<Option<Current>>,
662    sessions: Mutex<BTreeMap<u64, String>>,
663    fence: GenerationFence,
664    next_session: AtomicU64,
665    stopped: AtomicBool,
666}
667
668/// A client's handle on a resident service host, with typed [`Provenance`].
669pub struct DaemonLifecycle {
670    inner: Arc<Inner>,
671}
672
673/// What [`DaemonLifecycle::release_session`] did. The host was not touched.
674#[derive(Debug, Clone, PartialEq, Eq)]
675pub struct ReleaseReport {
676    pub label: String,
677    pub provenance: Provenance,
678    /// Sessions still held by this lifecycle after the release.
679    pub remaining_sessions: usize,
680}
681
682/// What [`DaemonLifecycle::shutdown_owned`] did.
683#[derive(Debug, Clone, PartialEq, Eq)]
684pub struct ShutdownReport {
685    /// A graceful request was configured, the host was confirmed current, and it was sent.
686    pub graceful_requested: bool,
687    /// The host acknowledged the graceful request (it may also drop the connection instead).
688    pub graceful_acknowledged: bool,
689    /// The process tree had to be killed after the grace period.
690    pub killed: bool,
691    /// The launched process's exit code, when it has one.
692    pub exit_code: Option<i32>,
693}
694
695impl DaemonLifecycle {
696    /// Attach to the live host of the backend's service, or launch one. See the module docs.
697    ///
698    /// `launcher` is `None` for a client that only ever attaches. With a launcher, a host that is
699    /// not running is started under the cross-process claim, so two shells starting together
700    /// launch exactly one: the loser waits for the winner's host and attaches to it.
701    pub fn attach_or_launch(
702        backend: Arc<dyn HostBackend>,
703        launcher: Option<Launcher>,
704        config: LifecycleConfig,
705    ) -> Result<Self, LifecycleError> {
706        let tracker = RestartTracker::new(config.restart);
707        let inner = Arc::new(Inner {
708            backend,
709            cfg: config,
710            launcher: Mutex::new(launcher),
711            slow: Mutex::new(Slow {
712                owned: None,
713                tracker,
714                origin: Instant::now(),
715                generation: None,
716                pending: None,
717                exhausted: false,
718            }),
719            current: Mutex::new(None),
720            sessions: Mutex::new(BTreeMap::new()),
721            fence: GenerationFence::new(),
722            next_session: AtomicU64::new(1),
723            stopped: AtomicBool::new(false),
724        });
725        inner.ensure_host()?;
726        Ok(Self { inner })
727    }
728
729    /// [`attach_or_launch`](Self::attach_or_launch) over the local `service.json` transport.
730    pub fn attach_or_launch_local(
731        client: ClientConfig,
732        expect: HostExpectation,
733        launcher: Option<Launcher>,
734        config: LifecycleConfig,
735    ) -> Result<Self, LifecycleError> {
736        Self::attach_or_launch(
737            Arc::new(LocalBackend::new(client, expect)),
738            launcher,
739            config,
740        )
741    }
742
743    /// How the host this lifecycle uses was started, or `None` when no host is bound now.
744    pub fn provenance(&self) -> Option<Provenance> {
745        lock(&self.inner.current).as_ref().map(|c| c.provenance)
746    }
747
748    /// The generation of the host bound now.
749    pub fn host(&self) -> Option<HostGeneration> {
750        lock(&self.inner.current).as_ref().map(|c| c.host.clone())
751    }
752
753    /// The fence epoch: advances whenever the bound host changes.
754    pub fn epoch(&self) -> u64 {
755        self.inner.fence.epoch()
756    }
757
758    /// Sessions held now. The count a presence or idle-exit policy reads.
759    pub fn session_count(&self) -> usize {
760        lock(&self.inner.sessions).len()
761    }
762
763    /// Forget the spent restart budget (an explicit user start).
764    pub fn reset_restart_budget(&self) {
765        let mut slow = lock(&self.inner.slow);
766        slow.tracker.reset_budget();
767        slow.pending = None;
768        slow.exhausted = false;
769    }
770
771    /// Revalidate the host (identity, generation, process liveness; relaunch an owned host that
772    /// exited when the budget allows) and open a verified connection to it as a [`Session`].
773    /// `label` names the holder for diagnostics.
774    pub fn acquire_session(&self, label: &str) -> Result<Session, LifecycleError> {
775        let current = self.inner.ensure_host()?;
776        let link = self.inner.backend.connect(&current.host)?;
777        // The host must still be the one that was verified when the connection is handed out.
778        match self.inner.backend.is_current(&current.host) {
779            Ok(true) => {}
780            Ok(false) => {
781                link.close();
782                self.inner.host_left(&current.host);
783                return Err(LifecycleError::StaleGeneration {
784                    issued: current.epoch,
785                    current: self.inner.fence.epoch(),
786                });
787            }
788            Err(_) => {
789                link.close();
790                return Err(LifecycleError::Unconfirmed);
791            }
792        }
793        let id = self.inner.next_session.fetch_add(1, Ordering::SeqCst);
794        lock(&self.inner.sessions).insert(id, label.to_string());
795        Ok(Session {
796            inner: self.inner.clone(),
797            id,
798            label: label.to_string(),
799            provenance: current.provenance,
800            host: current.host,
801            epoch: current.epoch,
802            link,
803            released: AtomicBool::new(false),
804        })
805    }
806
807    /// End one session: its connection closes and it leaves the count. **Never signals the
808    /// host**, whatever its provenance: an attached client leaves a shared service running, and
809    /// an owned host keeps running until [`shutdown_owned`](Self::shutdown_owned).
810    pub fn release_session(&self, session: Session) -> ReleaseReport {
811        session.finish()
812    }
813
814    /// Stop the host this lifecycle launched: the app's graceful request first (only after the
815    /// host is confirmed to still be the launched generation), then up to the configured grace
816    /// for the process to exit, then its process tree is killed, and only while the recorded
817    /// [`AdoptedIdentity`] still names the launched process.
818    ///
819    /// An attached host (or no owned host) is [`LifecycleError::NotOwned`]: nothing is sent and
820    /// nothing is signalled. Afterwards this lifecycle is shut down: sessions fail with
821    /// [`LifecycleError::Released`] and `acquire_session` is refused.
822    pub fn shutdown_owned(&self) -> Result<ShutdownReport, LifecycleError> {
823        let inner = &self.inner;
824        let mut slow = lock(&inner.slow);
825        // Check before changing anything: a refusal leaves the lifecycle exactly as it was.
826        let identity_state = match slow.owned.as_ref() {
827            None => return Err(LifecycleError::NotOwned),
828            Some(owned) => owned.identity.verify().map_err(LifecycleError::Process)?,
829        };
830        if identity_state == IdentityCheck::Replaced {
831            return Err(LifecycleError::Process(io::Error::other(
832                "the launched pid now names a different process; nothing was signalled",
833            )));
834        }
835        let Some(mut owned) = slow.owned.take() else {
836            return Err(LifecycleError::NotOwned);
837        };
838        inner.stopped.store(true, Ordering::SeqCst);
839        slow.tracker.shutdown();
840        slow.generation = None;
841
842        let mut graceful_requested = false;
843        let mut graceful_acknowledged = false;
844        if let (Some(stop), Some(host)) = (inner.cfg.graceful.as_ref(), owned.host.as_ref()) {
845            // Only a host that is still the launched generation hears the request.
846            if matches!(inner.backend.is_current(host), Ok(true)) {
847                if let Ok(link) = inner.backend.connect(host) {
848                    graceful_requested = true;
849                    graceful_acknowledged = stop(&*link).is_ok();
850                    link.close();
851                }
852            }
853        }
854        let outcome = owned
855            .child
856            .wait_then_kill(inner.cfg.stop_grace)
857            .map_err(LifecycleError::Process)?;
858        let (killed, exit_code) = match outcome {
859            WaitOutcome::Exited(status) => (false, status.code()),
860            WaitOutcome::Terminated(status) => (true, status.code()),
861        };
862        drop(owned);
863        drop(slow);
864        // Whoever held a session is stale now.
865        let mut current = lock(&inner.current);
866        if current.take().is_some() {
867            inner.fence.advance();
868        }
869        Ok(ShutdownReport {
870            graceful_requested,
871            graceful_acknowledged,
872            killed,
873            exit_code,
874        })
875    }
876}
877
878impl Inner {
879    fn bind(&self, to: Option<(HostGeneration, Provenance)>) -> Option<Current> {
880        let mut current = lock(&self.current);
881        let unchanged = match (current.as_ref(), to.as_ref()) {
882            (Some(c), Some((host, provenance))) => c.host == *host && c.provenance == *provenance,
883            (None, None) => true,
884            _ => false,
885        };
886        if unchanged {
887            return current.clone();
888        }
889        let epoch = self.fence.advance();
890        *current = to.map(|(host, provenance)| Current {
891            host,
892            provenance,
893            epoch,
894        });
895        current.clone()
896    }
897
898    /// `host` was observed gone or replaced: unbind it (and so advance the epoch) if it is still
899    /// the bound generation.
900    fn host_left(&self, host: &HostGeneration) {
901        let mut current = lock(&self.current);
902        if current.as_ref().is_some_and(|c| c.host == *host) {
903            *current = None;
904            self.fence.advance();
905        }
906    }
907
908    fn release(&self, id: u64) -> usize {
909        let mut sessions = lock(&self.sessions);
910        sessions.remove(&id);
911        sessions.len()
912    }
913
914    /// Whether the child this lifecycle launched for `host` has exited. A child that exited but
915    /// was not reaped yet still looks alive to a pid probe on Unix (a zombie), so an owned host
916    /// is asked through its handle. Never waits behind a launch in progress: when the slow path
917    /// is busy the answer is "not known to have exited".
918    fn owned_exited(&self, host: &HostGeneration) -> bool {
919        let mut slow = match self.slow.try_lock() {
920            Ok(guard) => guard,
921            Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(),
922            Err(std::sync::TryLockError::WouldBlock) => return false,
923        };
924        match slow.owned.as_mut() {
925            Some(owned) if owned.host.as_ref() == Some(host) => {
926                !matches!(owned.child.try_wait(), Ok(None))
927            }
928            _ => false,
929        }
930    }
931
932    /// The bound, verified host; launching or relaunching one when this lifecycle may.
933    fn ensure_host(&self) -> Result<Current, LifecycleError> {
934        let mut slow = lock(&self.slow);
935        if self.stopped.load(Ordering::SeqCst) {
936            return Err(LifecycleError::Released);
937        }
938        let deadline = Instant::now() + self.cfg.startup_timeout;
939        self.reap_exited_owned(&mut slow);
940        match self.backend.discover().map_err(unavailable)? {
941            Some(host) => self.adopt_found(&mut slow, host),
942            None => {
943                self.bind(None);
944                self.launch(&mut slow, deadline)
945            }
946        }
947    }
948
949    /// An owned child that exited is dropped and the tracker decides about a restart.
950    fn reap_exited_owned(&self, slow: &mut Slow) {
951        let exited = match slow.owned.as_mut() {
952            Some(owned) => match owned.child.try_wait() {
953                Ok(None) => false,
954                Ok(Some(_)) => true,
955                Err(_) => {
956                    // The state of the child cannot be read: do not guess it is fine.
957                    let _ = owned.child.terminate_tree();
958                    true
959                }
960            },
961            None => false,
962        };
963        if exited {
964            slow.owned = None;
965            if let Some(generation) = slow.generation.take() {
966                let at = slow.origin.elapsed();
967                slow.pending = Some(slow.tracker.exited(generation, at));
968            }
969        }
970    }
971
972    /// A live host is published. Verify it, decide whether it is ours, and bind it.
973    fn adopt_found(
974        &self,
975        slow: &mut Slow,
976        host: HostGeneration,
977    ) -> Result<Current, LifecycleError> {
978        if let Some(current) = lock(&self.current).clone() {
979            if current.host == host {
980                return Ok(current);
981            }
982        }
983        // Identity first: nothing is recorded, kept or signalled for a host that fails it.
984        let link = self.backend.connect(&host)?;
985        link.close();
986        // The launched child may re-publish itself (new publication time, same pid); any other
987        // pid is somebody else's host, whatever its record says.
988        let ours = slow
989            .owned
990            .as_ref()
991            .is_some_and(|owned| owned.child.id() == host.pid);
992        if ours {
993            if let Some(owned) = slow.owned.as_mut() {
994                owned.host = Some(host.clone());
995            }
996        } else if slow.owned.is_some() {
997            // Our own launched host is not the published one: it lost the election or is stale.
998            // It is ours to stop; the published host is not.
999            self.retire_owned(slow);
1000        }
1001        let provenance = if ours {
1002            Provenance::Owned
1003        } else {
1004            Provenance::Attached
1005        };
1006        self.bind(Some((host, provenance)))
1007            .ok_or(LifecycleError::NoHost)
1008    }
1009
1010    /// Stop the child this lifecycle launched without counting it against the budget.
1011    fn retire_owned(&self, slow: &mut Slow) {
1012        if let Some(mut owned) = slow.owned.take() {
1013            stop_launched(&mut owned, self.cfg.stop_grace);
1014        }
1015        slow.generation = None;
1016    }
1017
1018    /// Stop a launched child that failed to come up and let the tracker decide about a retry.
1019    fn fail_owned(&self, slow: &mut Slow) {
1020        if let Some(mut owned) = slow.owned.take() {
1021            stop_launched(&mut owned, self.cfg.stop_grace);
1022        }
1023        let at = slow.origin.elapsed();
1024        if let Some(generation) = slow.generation.take() {
1025            slow.pending = Some(slow.tracker.exited(generation, at));
1026        } else {
1027            slow.pending = Some(slow.tracker.request_restart(at));
1028        }
1029    }
1030
1031    fn charge_failed_launch(&self, slow: &mut Slow) {
1032        let at = slow.origin.elapsed();
1033        slow.pending = Some(slow.tracker.request_restart(at));
1034    }
1035
1036    fn launch(&self, slow: &mut Slow, deadline: Instant) -> Result<Current, LifecycleError> {
1037        if slow.exhausted {
1038            return Err(LifecycleError::RestartExhausted);
1039        }
1040        if lock(&self.launcher).is_none() {
1041            return Err(LifecycleError::NoHost);
1042        }
1043        if let Some(outcome) = slow.pending.take() {
1044            self.honour(slow, outcome, deadline)?;
1045        }
1046        let mut claim: Option<Box<dyn Any + Send>> = None;
1047        if slow.owned.is_none() {
1048            loop {
1049                match self.backend.claim_spawn().map_err(LifecycleError::Launch)? {
1050                    SpawnPermit::Granted(held) => {
1051                        claim = Some(held);
1052                        break;
1053                    }
1054                    SpawnPermit::Contended => {
1055                        if let Some(host) = self.backend.discover().map_err(unavailable)? {
1056                            return self.adopt_found(slow, host);
1057                        }
1058                        if Instant::now() >= deadline {
1059                            return Err(LifecycleError::LaunchTimeout);
1060                        }
1061                        std::thread::sleep(self.cfg.poll_interval);
1062                    }
1063                }
1064            }
1065            // A winner may have published between our discovery and the claim.
1066            if let Some(host) = self.backend.discover().map_err(unavailable)? {
1067                return self.adopt_found(slow, host);
1068            }
1069            self.spawn_child(slow)?;
1070        }
1071        let result = self.await_publication(slow, deadline);
1072        drop(claim);
1073        result
1074    }
1075
1076    /// Apply what the restart tracker decided at the last exit or failed start.
1077    fn honour(
1078        &self,
1079        slow: &mut Slow,
1080        outcome: RestartOutcome,
1081        deadline: Instant,
1082    ) -> Result<(), LifecycleError> {
1083        let at = slow.origin.elapsed();
1084        let outcome = match outcome {
1085            // This call is the demand.
1086            RestartOutcome::AwaitingDemand => slow.tracker.request_restart(at),
1087            other => other,
1088        };
1089        match outcome {
1090            RestartOutcome::Restart(permit) => {
1091                let delay = permit.delay();
1092                if delay > deadline.saturating_duration_since(Instant::now()) {
1093                    slow.pending = Some(RestartOutcome::Restart(permit));
1094                    return Err(LifecycleError::Service(ServiceError::retryable(
1095                        "restart_backoff",
1096                        format!(
1097                            "the next start of the owned host waits {} ms (restart backoff)",
1098                            delay.as_millis()
1099                        ),
1100                    )));
1101                }
1102                std::thread::sleep(delay);
1103                if slow.tracker.permit_valid(&permit, slow.origin.elapsed()) {
1104                    Ok(())
1105                } else {
1106                    Err(LifecycleError::NoHost)
1107                }
1108            }
1109            RestartOutcome::Exhausted => {
1110                slow.exhausted = true;
1111                Err(LifecycleError::RestartExhausted)
1112            }
1113            RestartOutcome::AwaitingDemand
1114            | RestartOutcome::Stopped
1115            | RestartOutcome::IgnoredStale => Err(LifecycleError::NoHost),
1116        }
1117    }
1118
1119    fn spawn_child(&self, slow: &mut Slow) -> Result<(), LifecycleError> {
1120        let launched = {
1121            let mut launcher = lock(&self.launcher);
1122            match launcher.as_mut() {
1123                Some(launch) => launch(),
1124                None => return Err(LifecycleError::NoHost),
1125            }
1126        };
1127        let mut child = match launched {
1128            Ok(child) => child,
1129            Err(e) => {
1130                self.charge_failed_launch(slow);
1131                return Err(LifecycleError::Launch(e));
1132            }
1133        };
1134        let Some(pid) = NonZeroU32::new(child.id()) else {
1135            let _ = child.terminate_tree();
1136            self.charge_failed_launch(slow);
1137            return Err(LifecycleError::Launch(io::Error::other(
1138                "the launcher returned a child without a process id",
1139            )));
1140        };
1141        let identity = match AdoptedIdentity::capture(pid) {
1142            Ok(Some(identity)) => identity,
1143            Ok(None) => {
1144                let _ = child.terminate_tree();
1145                self.charge_failed_launch(slow);
1146                return Err(LifecycleError::Launch(io::Error::other(
1147                    "the launched process exited before its identity could be recorded",
1148                )));
1149            }
1150            Err(e) => {
1151                let _ = child.terminate_tree();
1152                self.charge_failed_launch(slow);
1153                return Err(LifecycleError::Process(e));
1154            }
1155        };
1156        slow.generation = Some(slow.tracker.started(slow.origin.elapsed()));
1157        slow.owned = Some(OwnedHost {
1158            child,
1159            identity,
1160            host: None,
1161        });
1162        Ok(())
1163    }
1164
1165    /// Wait for the child this lifecycle launched to publish itself.
1166    fn await_publication(
1167        &self,
1168        slow: &mut Slow,
1169        deadline: Instant,
1170    ) -> Result<Current, LifecycleError> {
1171        loop {
1172            let pid = match slow.owned.as_mut() {
1173                Some(owned) => {
1174                    if !matches!(owned.child.try_wait(), Ok(None)) {
1175                        self.fail_owned(slow);
1176                        return Err(LifecycleError::Launch(io::Error::other(
1177                            "the launched host exited before it published itself",
1178                        )));
1179                    }
1180                    owned.child.id()
1181                }
1182                None => return Err(LifecycleError::NoHost),
1183            };
1184            match self.backend.discover().map_err(unavailable)? {
1185                Some(host) if host.pid == pid => {
1186                    if let Err(e) = self.backend.connect(&host).map(|link| link.close()) {
1187                        // The program we launched is not the program we expected: it is ours.
1188                        self.fail_owned(slow);
1189                        return Err(e);
1190                    }
1191                    if let Some(owned) = slow.owned.as_mut() {
1192                        owned.host = Some(host.clone());
1193                    }
1194                    if let Some(generation) = slow.generation {
1195                        slow.tracker.ready(generation);
1196                    }
1197                    return self
1198                        .bind(Some((host, Provenance::Owned)))
1199                        .ok_or(LifecycleError::NoHost);
1200                }
1201                Some(host) => {
1202                    // Another host won the election: ours is useless. It is ours to stop; the
1203                    // winner is not.
1204                    self.retire_owned(slow);
1205                    return self.adopt_found(slow, host);
1206                }
1207                None => {}
1208            }
1209            if Instant::now() >= deadline {
1210                self.fail_owned(slow);
1211                return Err(LifecycleError::LaunchTimeout);
1212            }
1213            std::thread::sleep(self.cfg.poll_interval);
1214        }
1215    }
1216}
1217
1218/// Stop a child this lifecycle launched. The identity gate comes first: a pid that now names a
1219/// different process is never signalled.
1220fn stop_launched(owned: &mut OwnedHost, grace: Duration) {
1221    if let Ok(IdentityCheck::Replaced) = owned.identity.verify() {
1222        return;
1223    }
1224    let _ = owned.child.wait_then_kill(grace);
1225}
1226
1227/// One client's verified connection to the host, bound to the generation it was acquired
1228/// against. Dropping it releases it like [`DaemonLifecycle::release_session`].
1229pub struct Session {
1230    inner: Arc<Inner>,
1231    id: u64,
1232    label: String,
1233    provenance: Provenance,
1234    host: HostGeneration,
1235    epoch: u64,
1236    link: Box<dyn HostLink>,
1237    released: AtomicBool,
1238}
1239
1240impl Session {
1241    pub fn provenance(&self) -> Provenance {
1242        self.provenance
1243    }
1244
1245    pub fn host(&self) -> &HostGeneration {
1246        &self.host
1247    }
1248
1249    /// The fence epoch this session is bound to.
1250    pub fn epoch(&self) -> u64 {
1251        self.epoch
1252    }
1253
1254    pub fn label(&self) -> &str {
1255        &self.label
1256    }
1257
1258    /// Whether the connection is usable: not released, not closed, and still the current epoch.
1259    /// A `true` is not a promise about the next call, which revalidates again.
1260    pub fn is_valid(&self) -> bool {
1261        !self.released.load(Ordering::SeqCst)
1262            && !self.inner.stopped.load(Ordering::SeqCst)
1263            && !self.link.is_closed()
1264            && self.inner.fence.epoch() == self.epoch
1265    }
1266
1267    /// One request. The host generation is confirmed before the request is written and again
1268    /// before the reply is handed back; a reply from a host that is no longer the current one is
1269    /// [`LifecycleError::StaleGeneration`], never a success. Handler errors arrive as
1270    /// [`LifecycleError::Service`].
1271    pub fn call(&self, method: &str, params: Value) -> Result<Value, LifecycleError> {
1272        if self.released.load(Ordering::SeqCst) || self.inner.stopped.load(Ordering::SeqCst) {
1273            return Err(LifecycleError::Released);
1274        }
1275        self.confirm_current()?;
1276        let ticket = self.inner.fence.issue(self.epoch)?;
1277        let reply = self.link.call(method, params);
1278        // A connection the host closed under this request is the common sign that the host left: give
1279        // its exit a moment to become observable (the owned child reaped, the record's process gone)
1280        // so the caller sees StaleGeneration, not a bare transport error, when that is what happened.
1281        if reply.is_err() && self.link.is_closed() {
1282            let settle = Instant::now() + HOST_EXIT_SETTLE;
1283            while Instant::now() < settle {
1284                self.confirm_current()?;
1285                std::thread::sleep(Duration::from_millis(20));
1286            }
1287        }
1288        // The reply counts only while the host is still the one this session was bound to.
1289        self.confirm_current()?;
1290        self.inner.fence.admit_reply(&ticket)?;
1291        reply.map_err(LifecycleError::Service)
1292    }
1293
1294    fn confirm_current(&self) -> Result<(), LifecycleError> {
1295        let left = if self.provenance == Provenance::Owned && self.inner.owned_exited(&self.host) {
1296            true
1297        } else {
1298            match self.inner.backend.is_current(&self.host) {
1299                Ok(true) => false,
1300                Ok(false) => true,
1301                Err(_) => return Err(LifecycleError::Unconfirmed),
1302            }
1303        };
1304        if left {
1305            self.inner.host_left(&self.host);
1306            return Err(LifecycleError::StaleGeneration {
1307                issued: self.epoch,
1308                current: self.inner.fence.epoch(),
1309            });
1310        }
1311        Ok(())
1312    }
1313
1314    fn finish(&self) -> ReleaseReport {
1315        let first = !self.released.swap(true, Ordering::SeqCst);
1316        let remaining = if first {
1317            self.inner.release(self.id)
1318        } else {
1319            lock(&self.inner.sessions).len()
1320        };
1321        self.link.close();
1322        ReleaseReport {
1323            label: self.label.clone(),
1324            provenance: self.provenance,
1325            remaining_sessions: remaining,
1326        }
1327    }
1328}
1329
1330impl Drop for Session {
1331    fn drop(&mut self) {
1332        let _ = self.finish();
1333    }
1334}