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::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
77/// A reply (or a request) belongs to a host generation that is no longer the current one. Not
78/// retryable: the request may have run on the old host.
79pub const ERROR_STALE_GENERATION: &str = "stale_generation";
80/// The host is attached, not owned: this lifecycle may not stop it.
81pub const ERROR_NOT_OWNED: &str = "not_owned";
82/// The owned host exited and the restart budget is spent.
83pub const ERROR_RESTART_EXHAUSTED: &str = "restart_exhausted";
84/// The session or the lifecycle was already released or shut down.
85pub const ERROR_SESSION_RELEASED: &str = "session_released";
86/// The host could not be launched or never published itself.
87pub 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/// Who started the host this client uses, and so what the client may do to it.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
95pub enum Provenance {
96    /// This process launched the host and holds its identity. It may stop it.
97    Owned,
98    /// Another process started the host. Attaching never implies a right to stop it.
99    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    /// Whether this lifecycle may signal the host. True only for [`Provenance::Owned`].
111    pub fn may_stop_host(self) -> bool {
112        matches!(self, Provenance::Owned)
113    }
114}
115
116/// One incarnation of a published host: the facts in `service.json` that change when the host is
117/// replaced. Two values are the same generation only when every field is equal.
118///
119/// Limit: `started_ms` is the publication time in milliseconds. A host replaced inside the same
120/// process within the same millisecond is indistinguishable here; a replacement process differs
121/// in pid or process start time. A per-host instance nonce in `service.json` would close that
122/// gap and is an open decision (see the CHANGELOG).
123#[derive(Debug, Clone, PartialEq, Eq, Hash)]
124pub struct HostGeneration {
125    pub service: String,
126    pub endpoint: String,
127    pub pid: u32,
128    /// Birth time of the host process as the record states it; 0 when the writer did not know it.
129    pub process_started_ms: u64,
130    /// Publication time of the record.
131    pub started_ms: u64,
132}
133
134impl HostGeneration {
135    /// The generation a record names.
136    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/// Why a lifecycle operation failed.
148#[derive(Debug)]
149#[non_exhaustive]
150pub enum LifecycleError {
151    /// A wire-level or discovery error (kept with its `retryable` flag).
152    Service(ServiceError),
153    /// The process behind the endpoint is not the host the caller expected. Nothing was sent to
154    /// it, and nothing was attached.
155    HostIdentity(HostIdentityError),
156    /// No live host, and no launcher to start one. Retryable.
157    NoHost,
158    /// The launcher failed, or the launched process left before it published itself.
159    Launch(io::Error),
160    /// The launched process did not publish itself within the startup timeout (it was stopped).
161    LaunchTimeout,
162    /// The owned host exited and the restart budget is spent.
163    RestartExhausted,
164    /// [`DaemonLifecycle::shutdown_owned`] on a host this lifecycle did not launch.
165    NotOwned,
166    /// The session or the lifecycle was already released or shut down.
167    Released,
168    /// The host the session was bound to is gone or was replaced. `issued` is the generation
169    /// epoch the session (or request) was bound to, `current` the epoch now. The request may or
170    /// may not have run on the old host.
171    StaleGeneration { issued: u64, current: u64 },
172    /// The host generation could not be confirmed (its record was unreadable). Treated like
173    /// stale: a reply that cannot be tied to the current host is not delivered.
174    Unconfirmed,
175    /// A process operation failed (identity read, signal).
176    Process(io::Error),
177}
178
179impl LifecycleError {
180    /// The wire-style code.
181    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    /// Whether trying again (a fresh `acquire_session`) can succeed. Never true for a stale
198    /// reply as such: the caller decides whether the request may run twice.
199    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    /// Collapse into the single wire-style error.
208    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
272// ---------------------------------------------------------------------------------------------
273// Generation fence
274// ---------------------------------------------------------------------------------------------
275
276/// How long a request whose connection closed waits for the host's exit to become observable before
277/// its transport error is reported as such.
278const HOST_EXIT_SETTLE: Duration = Duration::from_millis(500);
279
280/// A request was issued under, or a reply tagged with, an epoch that is no longer current.
281#[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/// A request in flight, bound to the epoch it was issued under.
297#[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/// Binds requests to a host generation. The epoch advances every time the host the lifecycle is
314/// bound to changes (first bind, replacement, exit). A [`Ticket`] issued under an older epoch can
315/// never complete: its reply is [`Stale`], so a late reply from a previous host cannot answer a
316/// request made to the next one.
317///
318/// Transports that multiplex requests over one connection use it directly: `issue` when a request
319/// is written, `admit_reply` (or `admit_reply_from` when the reply carries the epoch it was read
320/// under) when its reply arrives.
321#[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    /// The current epoch (0 before any host was bound).
333    pub fn epoch(&self) -> u64 {
334        self.epoch.load(Ordering::SeqCst)
335    }
336
337    /// A different host generation was bound (or the host left): every outstanding [`Ticket`] is
338    /// now stale. Returns the new epoch.
339    pub fn advance(&self) -> u64 {
340        self.epoch.fetch_add(1, Ordering::SeqCst) + 1
341    }
342
343    /// Issue a request under `bound_epoch`, the epoch the caller's connection was bound to.
344    /// Refused when that is no longer the current epoch.
345    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    /// A reply arrived for `ticket`. Admitted only while the epoch it was issued under is still
360    /// the current one.
361    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    /// [`admit_reply`](Self::admit_reply) for a reply that itself names the epoch of the
373    /// connection it was read from: it must equal the ticket's epoch as well.
374    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
385// ---------------------------------------------------------------------------------------------
386// Backend: how a host is found and reached
387// ---------------------------------------------------------------------------------------------
388
389/// A connection to exactly one host generation, as the lifecycle uses it.
390pub trait HostLink: Send + Sync {
391    fn call(&self, method: &str, params: Value) -> Result<Value, ServiceError>;
392    /// Whether the connection is known closed.
393    fn is_closed(&self) -> bool;
394    /// Close the connection. Never stops the host.
395    fn close(&self);
396}
397
398/// Whether this process may start a host now.
399pub enum SpawnPermit {
400    /// This process holds the cross-process claim until the value is dropped.
401    Granted(Box<dyn Any + Send>),
402    /// Another process is starting a host: wait for it to publish.
403    Contended,
404}
405
406/// How a lifecycle finds, verifies and reaches a host. [`LocalBackend`] is the `service.json`
407/// transport; tests and other transports implement it too.
408pub trait HostBackend: Send + Sync + 'static {
409    /// The live published host now, or `None`. An unreadable record is `Err` (unknown), never
410    /// `None`: the lifecycle must not start a second host on an unknown.
411    fn discover(&self) -> io::Result<Option<HostGeneration>>;
412
413    /// Open a connection to exactly `host`, after verifying who is on the other end. A host that
414    /// fails the identity check is [`LifecycleError::HostIdentity`]; nothing application-level was
415    /// sent to it.
416    fn connect(&self, host: &HostGeneration) -> Result<Box<dyn HostLink>, LifecycleError>;
417
418    /// Whether `host` is still the published, live generation. Called before every request and
419    /// before every reply is handed back, so it should be cheap (no connection).
420    fn is_current(&self, host: &HostGeneration) -> io::Result<bool> {
421        Ok(self.discover()?.as_ref() == Some(host))
422    }
423
424    /// Take the cross-process claim that serialises starting a host.
425    fn claim_spawn(&self) -> io::Result<SpawnPermit>;
426}
427
428/// The local `service.json` transport: discovery through the suite, identity through
429/// [`Client::connect_expecting`].
430pub struct LocalBackend {
431    client: ClientConfig,
432    expect: HostExpectation,
433}
434
435impl LocalBackend {
436    /// `client` names the service, the app label and the suite (see [`ClientConfig`]); `expect`
437    /// names the executable the host may be. An expectation is required: attaching to a host
438    /// whose program was never checked is not offered.
439    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        // The record the handshake read must be the generation that was discovered.
480        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        // The process the kernel put behind the socket must be the process the record names.
488        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
540// ---------------------------------------------------------------------------------------------
541// Configuration
542// ---------------------------------------------------------------------------------------------
543
544/// Starts the host. Called with the cross-process claim held, at most once per start. The child
545/// it returns is owned by the lifecycle from then on; the app configures the command, including
546/// how it detaches.
547pub type Launcher = Box<dyn FnMut() -> io::Result<OwnedChild> + Send>;
548
549/// The app's own polite stop request (for example its `shutdown` method), sent over a freshly
550/// verified connection to the owned host before any signal. An `Err` is not a failure of the
551/// shutdown: a host that exits as it answers often drops the connection.
552pub type GracefulStop = Arc<dyn Fn(&dyn HostLink) -> Result<(), ServiceError> + Send + Sync>;
553
554/// Timeouts, restart budget and the graceful stop request.
555#[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    /// How long a launch (or a wait for another process's launch) may take. Default 30 s.
582    #[must_use]
583    pub fn startup_timeout(mut self, timeout: Duration) -> Self {
584        self.startup_timeout = timeout;
585        self
586    }
587
588    /// Time between checks while waiting for a host to publish. Default 50 ms, at least 1 ms.
589    #[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    /// How long an owned host gets to exit after the graceful request before its process tree is
596    /// killed. Default 5 s.
597    #[must_use]
598    pub fn stop_grace(mut self, grace: Duration) -> Self {
599        self.stop_grace = grace;
600        self
601    }
602
603    /// The restart budget for an owned host that exits or fails to start. Default
604    /// [`RestartPolicy::default`] (automatic, 3 per 60 s).
605    #[must_use]
606    pub fn restart_policy(mut self, policy: RestartPolicy) -> Self {
607        self.restart = policy;
608        self
609    }
610
611    /// The graceful stop request for [`DaemonLifecycle::shutdown_owned`].
612    #[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
622// ---------------------------------------------------------------------------------------------
623// The lifecycle
624// ---------------------------------------------------------------------------------------------
625
626struct OwnedHost {
627    child: OwnedChild,
628    /// pid plus start time of the launched process: the only thing that authorises a signal.
629    identity: AdoptedIdentity,
630    /// The generation the launched process published, once it did.
631    host: Option<HostGeneration>,
632}
633
634struct Slow {
635    owned: Option<OwnedHost>,
636    tracker: RestartTracker,
637    origin: Instant,
638    generation: Option<Generation>,
639    /// What the tracker decided at the last exit or failed start, applied at the next launch.
640    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    /// Serialises launching, adopting and stopping. Held across a launch wait; never taken by
656    /// [`Session::call`].
657    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
665/// A client's handle on a resident service host, with typed [`Provenance`].
666pub struct DaemonLifecycle {
667    inner: Arc<Inner>,
668}
669
670/// What [`DaemonLifecycle::release_session`] did. The host was not touched.
671#[derive(Debug, Clone, PartialEq, Eq)]
672pub struct ReleaseReport {
673    pub label: String,
674    pub provenance: Provenance,
675    /// Sessions still held by this lifecycle after the release.
676    pub remaining_sessions: usize,
677}
678
679/// What [`DaemonLifecycle::shutdown_owned`] did.
680#[derive(Debug, Clone, PartialEq, Eq)]
681pub struct ShutdownReport {
682    /// A graceful request was configured, the host was confirmed current, and it was sent.
683    pub graceful_requested: bool,
684    /// The host acknowledged the graceful request (it may also drop the connection instead).
685    pub graceful_acknowledged: bool,
686    /// The process tree had to be killed after the grace period.
687    pub killed: bool,
688    /// The launched process's exit code, when it has one.
689    pub exit_code: Option<i32>,
690}
691
692impl DaemonLifecycle {
693    /// Attach to the live host of the backend's service, or launch one. See the module docs.
694    ///
695    /// `launcher` is `None` for a client that only ever attaches. With a launcher, a host that is
696    /// not running is started under the cross-process claim, so two shells starting together
697    /// launch exactly one: the loser waits for the winner's host and attaches to it.
698    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    /// [`attach_or_launch`](Self::attach_or_launch) over the local `service.json` transport.
727    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    /// How the host this lifecycle uses was started, or `None` when no host is bound now.
741    pub fn provenance(&self) -> Option<Provenance> {
742        lock(&self.inner.current).as_ref().map(|c| c.provenance)
743    }
744
745    /// The generation of the host bound now.
746    pub fn host(&self) -> Option<HostGeneration> {
747        lock(&self.inner.current).as_ref().map(|c| c.host.clone())
748    }
749
750    /// The fence epoch: advances whenever the bound host changes.
751    pub fn epoch(&self) -> u64 {
752        self.inner.fence.epoch()
753    }
754
755    /// Sessions held now. The count a presence or idle-exit policy reads.
756    pub fn session_count(&self) -> usize {
757        lock(&self.inner.sessions).len()
758    }
759
760    /// Forget the spent restart budget (an explicit user start).
761    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    /// Revalidate the host (identity, generation, process liveness; relaunch an owned host that
769    /// exited when the budget allows) and open a verified connection to it as a [`Session`].
770    /// `label` names the holder for diagnostics.
771    pub fn acquire_session(&self, label: &str) -> Result<Session, LifecycleError> {
772        let current = self.inner.ensure_host()?;
773        let link = self.inner.backend.connect(&current.host)?;
774        // The host must still be the one that was verified when the connection is handed out.
775        match self.inner.backend.is_current(&current.host) {
776            Ok(true) => {}
777            Ok(false) => {
778                link.close();
779                self.inner.host_left(&current.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    /// End one session: its connection closes and it leaves the count. **Never signals the
805    /// host**, whatever its provenance: an attached client leaves a shared service running, and
806    /// an owned host keeps running until [`shutdown_owned`](Self::shutdown_owned).
807    pub fn release_session(&self, session: Session) -> ReleaseReport {
808        session.finish()
809    }
810
811    /// Stop the host this lifecycle launched: the app's graceful request first (only after the
812    /// host is confirmed to still be the launched generation), then up to the configured grace
813    /// for the process to exit, then its process tree is killed, and only while the recorded
814    /// [`AdoptedIdentity`] still names the launched process.
815    ///
816    /// An attached host (or no owned host) is [`LifecycleError::NotOwned`]: nothing is sent and
817    /// nothing is signalled. Afterwards this lifecycle is shut down: sessions fail with
818    /// [`LifecycleError::Released`] and `acquire_session` is refused.
819    pub fn shutdown_owned(&self) -> Result<ShutdownReport, LifecycleError> {
820        let inner = &self.inner;
821        let mut slow = lock(&inner.slow);
822        // Check before changing anything: a refusal leaves the lifecycle exactly as it was.
823        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            // Only a host that is still the launched generation hears the request.
843            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        // Whoever held a session is stale now.
862        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    /// `host` was observed gone or replaced: unbind it (and so advance the epoch) if it is still
896    /// the bound generation.
897    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    /// Whether the child this lifecycle launched for `host` has exited. A child that exited but
912    /// was not reaped yet still looks alive to a pid probe on Unix (a zombie), so an owned host
913    /// is asked through its handle. Never waits behind a launch in progress: when the slow path
914    /// is busy the answer is "not known to have exited".
915    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    /// The bound, verified host; launching or relaunching one when this lifecycle may.
930    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    /// An owned child that exited is dropped and the tracker decides about a restart.
947    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                    // The state of the child cannot be read: do not guess it is fine.
954                    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    /// A live host is published. Verify it, decide whether it is ours, and bind it.
970    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        // Identity first: nothing is recorded, kept or signalled for a host that fails it.
981        let link = self.backend.connect(&host)?;
982        link.close();
983        // The launched child may re-publish itself (new publication time, same pid); any other
984        // pid is somebody else's host, whatever its record says.
985        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            // Our own launched host is not the published one: it lost the election or is stale.
995            // It is ours to stop; the published host is not.
996            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    /// Stop the child this lifecycle launched without counting it against the budget.
1008    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    /// Stop a launched child that failed to come up and let the tracker decide about a retry.
1016    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            // A winner may have published between our discovery and the claim.
1063            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    /// Apply what the restart tracker decided at the last exit or failed start.
1074    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            // This call is the demand.
1083            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    /// Wait for the child this lifecycle launched to publish itself.
1163    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                        // The program we launched is not the program we expected: it is ours.
1185                        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                    // Another host won the election: ours is useless. It is ours to stop; the
1200                    // winner is not.
1201                    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
1215/// Stop a child this lifecycle launched. The identity gate comes first: a pid that now names a
1216/// different process is never signalled.
1217fn 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
1224/// One client's verified connection to the host, bound to the generation it was acquired
1225/// against. Dropping it releases it like [`DaemonLifecycle::release_session`].
1226pub 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    /// The fence epoch this session is bound to.
1247    pub fn epoch(&self) -> u64 {
1248        self.epoch
1249    }
1250
1251    pub fn label(&self) -> &str {
1252        &self.label
1253    }
1254
1255    /// Whether the connection is usable: not released, not closed, and still the current epoch.
1256    /// A `true` is not a promise about the next call, which revalidates again.
1257    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    /// One request. The host generation is confirmed before the request is written and again
1265    /// before the reply is handed back; a reply from a host that is no longer the current one is
1266    /// [`LifecycleError::StaleGeneration`], never a success. Handler errors arrive as
1267    /// [`LifecycleError::Service`].
1268    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        // A connection the host closed under this request is the common sign that the host left: give
1276        // its exit a moment to become observable (the owned child reaped, the record's process gone)
1277        // so the caller sees StaleGeneration, not a bare transport error, when that is what happened.
1278        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        // The reply counts only while the host is still the one this session was bound to.
1286        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        /// Replaces the published host while the next call is in flight.
1358        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        // The refusal sends nothing, signals nothing and leaves the lifecycle usable.
1458        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        // The host is untouched: the other shell still gets answers.
1502        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        // The host is replaced while this request is in flight on the old one.
1567        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        // Not replayed: the old host saw the one call, the new host none.
1574        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        // The old request can no longer complete, and nothing can be issued under the old epoch.
1586        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        // A reply read from the old connection cannot answer the new request.
1597        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        // The attached host goes away; this lifecycle may start its own, within the budget.
1624        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        // Spent stays spent, and nothing more is launched.
1634        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        // An explicit start forgets the budget.
1638        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}