Skip to main content

liminal_server/services/
lifecycle.rs

1//! The services registry and its lifecycle (SRV-028 R2 to R5).
2//!
3//! A service is made from one of the server's own templates
4//! (`[[auth.lys.templates]]`), never from a command line a request names. Its
5//! record is kept under the persistence path, one JSON file per service,
6//! written whole with one full flush per transition and read back by one
7//! directory scan at start: nothing is replayed. A held or retired service is
8//! kept with its history; nothing here removes a record.
9//!
10//! Who may act is decided before any method here is called (the lifecycle
11//! route asks Lys); this module records the holder it is handed. A create is
12//! placed in Lys first, the service under the network and each subject under
13//! the network, and is recorded only when every placement is answered.
14
15use std::collections::{BTreeMap, HashMap};
16use std::io::Write;
17use std::path::{Path, PathBuf};
18use std::sync::{Arc, Mutex, MutexGuard};
19
20use serde::{Deserialize, Serialize};
21
22use super::lys_http::LysHttp;
23use crate::config::templates::is_segment;
24use crate::config::types::ServiceTemplateConfig;
25
26#[path = "lifecycle_run.rs"]
27mod run;
28
29/// The Lys app that owns the `liminal.*` kinds.
30const APP: &str = "liminal";
31
32/// Where a service stands.
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum ServiceState {
36    /// Registered, not running: created, or exited without being asked.
37    Asleep,
38    /// Its worker registered its subjects.
39    Live,
40    /// Stopped by a holder; woken by one.
41    Held,
42    /// Stopped for good; kept with its history.
43    Retired,
44}
45
46/// One change of state, with who made it and when.
47#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Transition {
49    /// The state before.
50    pub from: ServiceState,
51    /// The state after.
52    pub to: ServiceState,
53    /// The Lys holder that asked, or `process` for the service's own exit.
54    pub holder: String,
55    /// Milliseconds since the epoch.
56    pub at_ms: u64,
57    /// What happened beside the state, such as an exit status.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub note: Option<String>,
60}
61
62/// One service, as kept.
63#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ServiceRecord {
65    /// Its id, which is also its `liminal.service` id in Lys.
66    pub id: String,
67    /// The name it was created with, one segment.
68    pub name: String,
69    /// The template it runs.
70    pub template: String,
71    /// The subjects it serves, `<root>.<name>` for each template root.
72    pub subjects: Vec<String>,
73    /// Where it stands.
74    pub state: ServiceState,
75    /// The holder that created it.
76    pub created_by: String,
77    /// Every transition, oldest first.
78    pub transitions: Vec<Transition>,
79}
80
81/// Why a lifecycle request did nothing, by name.
82#[derive(Clone, Debug, PartialEq, Eq)]
83pub enum LifecycleError {
84    /// No template of that name.
85    UnknownTemplate(String),
86    /// The name is not one segment.
87    NameInvalid,
88    /// A service of that template already has that name.
89    NameTaken(String),
90    /// No service of that id.
91    UnknownService(String),
92    /// The verb does not apply in the service's state.
93    NotNow {
94        /// The verb asked.
95        verb: &'static str,
96        /// The state it was asked in.
97        state: ServiceState,
98        /// Whether a process was running.
99        running: bool,
100    },
101    /// Lys refused or could not be asked to place the service.
102    Placement(String),
103    /// No app credential is configured, so nothing can be placed.
104    NoPlacer,
105    /// The registry could not be read or written.
106    Store(String),
107    /// The template's command could not be started or stopped.
108    Process(String),
109}
110
111impl std::fmt::Display for LifecycleError {
112    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self {
114            Self::UnknownTemplate(name) => write!(formatter, "unknown_template: {name}"),
115            Self::NameInvalid => formatter
116                .write_str("name_invalid: a name is one segment of letters, digits, '-' or '_'"),
117            Self::NameTaken(name) => write!(formatter, "name_taken: {name}"),
118            Self::UnknownService(id) => write!(formatter, "unknown_service: {id}"),
119            Self::NotNow {
120                verb,
121                state,
122                running,
123            } => write!(
124                formatter,
125                "not_now: {verb} does not apply to a service {state:?} with a process {}",
126                if *running { "running" } else { "stopped" }
127            ),
128            Self::Placement(why) => write!(formatter, "placement_refused: {why}"),
129            Self::NoPlacer => {
130                formatter.write_str("placement_unavailable: no app_credential_path is configured")
131            }
132            Self::Store(why) => write!(formatter, "registry_unavailable: {why}"),
133            Self::Process(why) => write!(formatter, "process_failed: {why}"),
134        }
135    }
136}
137
138impl std::error::Error for LifecycleError {}
139
140/// Places a resource under its parent in Lys.
141pub trait Placer: Send + Sync + std::fmt::Debug {
142    /// Places `child` (kind, id) under `parent` (kind, id) once, under
143    /// `operation`.
144    ///
145    /// # Errors
146    /// Lys's refusal by its name, or why it could not be asked.
147    fn place(
148        &self,
149        operation: &str,
150        child: (&str, &str),
151        parent: (&str, &str),
152    ) -> Result<(), String>;
153}
154
155/// [`Placer`] over Lys's placement route with this liminal's app credential.
156pub struct LysPlacer {
157    http: Arc<dyn LysHttp>,
158    credential: String,
159}
160
161impl LysPlacer {
162    /// A placer asking through `http` with `credential`.
163    #[must_use]
164    pub fn new(http: Arc<dyn LysHttp>, credential: String) -> Self {
165        Self { http, credential }
166    }
167}
168
169impl std::fmt::Debug for LysPlacer {
170    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        formatter.debug_struct("LysPlacer").finish_non_exhaustive()
172    }
173}
174
175impl Placer for LysPlacer {
176    fn place(
177        &self,
178        operation: &str,
179        child: (&str, &str),
180        parent: (&str, &str),
181    ) -> Result<(), String> {
182        let route = format!("apps/{APP}/placements");
183        let body = serde_json::json!({
184            "operation": operation,
185            "child": {"kind": child.0, "id": child.1},
186            "parent": {"kind": parent.0, "id": parent.1},
187        });
188        let answer = self
189            .http
190            .post(&self.credential, &route, &body)
191            .map_err(|error| error.0)?;
192        if answer.succeeded() {
193            Ok(())
194        } else {
195            Err(answer.refusal())
196        }
197    }
198}
199
200/// What a service process's own credential admits: registering its
201/// subjects, and nothing else.
202#[derive(Clone, Debug, PartialEq, Eq)]
203pub struct ProcessGrant {
204    /// The service the process runs.
205    pub service: String,
206    /// The only subjects it may register.
207    pub subjects: Vec<String>,
208}
209
210impl ProcessGrant {
211    /// Whether the process may serve `subject`.
212    #[must_use]
213    pub fn serves(&self, subject: &str) -> bool {
214        self.subjects.iter().any(|own| own == subject)
215    }
216}
217
218/// How long a start admitted before the bound address is handed over waits
219/// to be told it.
220const ADDRESS_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
221
222/// The registry, its templates and the processes it started.
223#[derive(Debug)]
224pub struct Lifecycle {
225    templates: BTreeMap<String, ServiceTemplateConfig>,
226    dir: PathBuf,
227    network: String,
228    /// The address a started process connects to, set once the listener binds.
229    server: std::sync::OnceLock<String>,
230    /// Woken when `server` is set: the accept worker starts at bind, before
231    /// the runtime hands the address over, so a start admitted in between
232    /// waits to be told it rather than being refused.
233    told: (Mutex<()>, std::sync::Condvar),
234    placer: Option<Arc<dyn Placer>>,
235    registry: Mutex<Registry>,
236}
237
238#[derive(Debug, Default)]
239struct Registry {
240    records: BTreeMap<String, ServiceRecord>,
241    running: HashMap<String, run::Running>,
242    tokens: HashMap<[u8; 32], ProcessGrant>,
243    stopping_all: bool,
244}
245
246impl Lifecycle {
247    /// Opens the registry in `dir`, reading every record once.
248    ///
249    /// `network` is the Lys network services are placed under; the address a
250    /// started process connects to is given by [`Self::listening`].
251    ///
252    /// # Errors
253    /// The directory cannot be made or read, or a record in it is not one.
254    pub fn open(
255        templates: &[ServiceTemplateConfig],
256        dir: PathBuf,
257        network: String,
258        placer: Option<Arc<dyn Placer>>,
259    ) -> Result<Arc<Self>, LifecycleError> {
260        std::fs::create_dir_all(&dir).map_err(|error| store(&dir, &error))?;
261        let mut records = BTreeMap::new();
262        for entry in std::fs::read_dir(&dir).map_err(|error| store(&dir, &error))? {
263            let path = entry.map_err(|error| store(&dir, &error))?.path();
264            if path.extension().is_none_or(|ext| ext != "json") {
265                continue;
266            }
267            let bytes = std::fs::read(&path).map_err(|error| store(&path, &error))?;
268            let record: ServiceRecord = serde_json::from_slice(&bytes).map_err(|error| {
269                LifecycleError::Store(format!(
270                    "{} is not a service record: {error}",
271                    path.display()
272                ))
273            })?;
274            records.insert(record.id.clone(), record);
275        }
276        Ok(Arc::new(Self {
277            templates: templates
278                .iter()
279                .map(|template| (template.name.clone(), template.clone()))
280                .collect(),
281            dir,
282            network,
283            server: std::sync::OnceLock::new(),
284            told: (Mutex::new(()), std::sync::Condvar::new()),
285            placer,
286            registry: Mutex::new(Registry {
287                records,
288                ..Registry::default()
289            }),
290        }))
291    }
292
293    /// Records the address the server listens on; a process is started only
294    /// after it is known. A second call changes nothing.
295    pub fn listening(&self, address: String) {
296        let _first = self.server.set(address);
297        let (lock, told) = &self.told;
298        drop(
299            lock.lock()
300                .unwrap_or_else(std::sync::PoisonError::into_inner),
301        );
302        told.notify_all();
303    }
304
305    /// The address the server listens on, waiting up to [`ADDRESS_WAIT`] to be
306    /// told it: the runtime sets it straight after the bind, so the wait is
307    /// only ever the few instructions between the two; the bound covers a
308    /// listener that never binds.
309    pub(super) fn server_address(&self) -> Option<&str> {
310        if let Some(address) = self.server.get() {
311            return Some(address);
312        }
313        let (lock, told) = &self.told;
314        drop(
315            told.wait_timeout_while(
316                lock.lock()
317                    .unwrap_or_else(std::sync::PoisonError::into_inner),
318                ADDRESS_WAIT,
319                |()| self.server.get().is_none(),
320            )
321            .unwrap_or_else(std::sync::PoisonError::into_inner),
322        );
323        self.server.get().map(String::as_str)
324    }
325
326    fn lock(&self) -> Result<MutexGuard<'_, Registry>, LifecycleError> {
327        self.registry
328            .lock()
329            .map_err(|_| LifecycleError::Store("the registry lock is poisoned".to_owned()))
330    }
331
332    /// The templates and every service `visible` keeps, with whether a
333    /// process is running for it, as the `services.list` answer.
334    ///
335    /// # Errors
336    /// The registry lock is poisoned.
337    pub fn list(
338        &self,
339        visible: impl Fn(&ServiceRecord) -> bool,
340    ) -> Result<serde_json::Value, LifecycleError> {
341        let registry = self.lock()?;
342        let services: Vec<serde_json::Value> = registry
343            .records
344            .values()
345            .filter(|record| visible(record))
346            .map(|record| {
347                let mut value = serde_json::to_value(record).unwrap_or_default();
348                if let Some(object) = value.as_object_mut() {
349                    object.insert(
350                        "running".to_owned(),
351                        registry.running.contains_key(&record.id).into(),
352                    );
353                }
354                value
355            })
356            .collect();
357        drop(registry);
358        let templates: Vec<serde_json::Value> = self
359            .templates
360            .values()
361            .map(|template| {
362                serde_json::json!({"name": template.name, "subjects": template.subjects})
363            })
364            .collect();
365        Ok(serde_json::json!({"templates": templates, "services": services}))
366    }
367
368    /// The record of `id`.
369    ///
370    /// # Errors
371    /// No such service, or the lock is poisoned.
372    pub fn record(&self, id: &str) -> Result<ServiceRecord, LifecycleError> {
373        self.lock()?
374            .records
375            .get(id)
376            .cloned()
377            .ok_or_else(|| LifecycleError::UnknownService(id.to_owned()))
378    }
379
380    /// Creates a service named `name` from `template` for `holder`, asleep,
381    /// after placing it and its subjects in Lys. The same `operation` from
382    /// the same holder answers the same service and places nothing twice.
383    ///
384    /// # Errors
385    /// By name: the template, the name, the placement or the store.
386    pub fn create(
387        &self,
388        template: &str,
389        name: &str,
390        holder: &str,
391        operation: &str,
392        now_ms: u64,
393    ) -> Result<ServiceRecord, LifecycleError> {
394        let found = self
395            .templates
396            .get(template)
397            .ok_or_else(|| LifecycleError::UnknownTemplate(template.to_owned()))?;
398        if !is_segment(name) {
399            return Err(LifecycleError::NameInvalid);
400        }
401        let id = format!("svc-{}", digest(&["liminal-service/v1", holder, operation]));
402        {
403            let registry = self.lock()?;
404            if let Some(record) = registry.records.get(&id) {
405                return Ok(record.clone());
406            }
407            if registry
408                .records
409                .values()
410                .any(|record| record.template == template && record.name == name)
411            {
412                return Err(LifecycleError::NameTaken(name.to_owned()));
413            }
414        }
415        let subjects: Vec<String> = found
416            .subjects
417            .iter()
418            .map(|root| format!("{root}.{name}"))
419            .collect();
420        let placer = self.placer.as_ref().ok_or(LifecycleError::NoPlacer)?;
421        let network = ("liminal.network", self.network.as_str());
422        let mut children = vec![("liminal.service", id.as_str())];
423        children.extend(
424            subjects
425                .iter()
426                .map(|subject| ("liminal.subject", subject.as_str())),
427        );
428        for child in children {
429            let op = format!("op-{}", digest(&["liminal-place/v1", child.0, child.1]));
430            placer
431                .place(&op, child, network)
432                .map_err(LifecycleError::Placement)?;
433        }
434        let record = ServiceRecord {
435            id: id.clone(),
436            name: name.to_owned(),
437            template: template.to_owned(),
438            subjects,
439            state: ServiceState::Asleep,
440            created_by: holder.to_owned(),
441            transitions: vec![Transition {
442                from: ServiceState::Asleep,
443                to: ServiceState::Asleep,
444                holder: holder.to_owned(),
445                at_ms: now_ms,
446                note: Some("created".to_owned()),
447            }],
448        };
449        let mut registry = self.lock()?;
450        if let Some(raced) = registry.records.get(&id) {
451            return Ok(raced.clone());
452        }
453        self.keep(&record)?;
454        registry.records.insert(id, record.clone());
455        drop(registry);
456        Ok(record)
457    }
458
459    /// Starts an asleep service's template for `holder`; it is recorded
460    /// live when its worker registers.
461    ///
462    /// # Errors
463    /// By name: the service, its state, or the start.
464    pub fn deploy(
465        self: &Arc<Self>,
466        id: &str,
467        holder: &str,
468    ) -> Result<ServiceRecord, LifecycleError> {
469        self.start(id, holder, "deploy", ServiceState::Asleep)
470    }
471
472    /// Starts a held service's template again for `holder`.
473    ///
474    /// # Errors
475    /// As [`Self::deploy`].
476    pub fn wake(self: &Arc<Self>, id: &str, holder: &str) -> Result<ServiceRecord, LifecycleError> {
477        self.start(id, holder, "wake", ServiceState::Held)
478    }
479
480    fn start(
481        self: &Arc<Self>,
482        id: &str,
483        holder: &str,
484        verb: &'static str,
485        from: ServiceState,
486    ) -> Result<ServiceRecord, LifecycleError> {
487        let mut registry = self.lock()?;
488        let record = registry
489            .records
490            .get(id)
491            .cloned()
492            .ok_or_else(|| LifecycleError::UnknownService(id.to_owned()))?;
493        let running = registry.running.contains_key(id);
494        if record.state != from || running || registry.stopping_all {
495            return Err(LifecycleError::NotNow {
496                verb,
497                state: record.state,
498                running,
499            });
500        }
501        let template = self
502            .templates
503            .get(&record.template)
504            .ok_or_else(|| LifecycleError::UnknownTemplate(record.template.clone()))?;
505        let (process, digest) = run::start(self, &record, template, holder)?;
506        registry.tokens.insert(
507            digest,
508            ProcessGrant {
509                service: record.id.clone(),
510                subjects: record.subjects.clone(),
511            },
512        );
513        registry.running.insert(record.id.clone(), process);
514        drop(registry);
515        Ok(record)
516    }
517
518    /// Stops a running or live service for `holder` and records it held.
519    ///
520    /// # Errors
521    /// By name: the service, its state, the stop or the store.
522    pub fn hold(
523        &self,
524        id: &str,
525        holder: &str,
526        now_ms: u64,
527    ) -> Result<ServiceRecord, LifecycleError> {
528        self.stop(id, holder, now_ms, "hold", ServiceState::Held)
529    }
530
531    /// Stops a service if it runs and records it retired, for good.
532    ///
533    /// # Errors
534    /// As [`Self::hold`].
535    pub fn retire(
536        &self,
537        id: &str,
538        holder: &str,
539        now_ms: u64,
540    ) -> Result<ServiceRecord, LifecycleError> {
541        self.stop(id, holder, now_ms, "retire", ServiceState::Retired)
542    }
543
544    fn stop(
545        &self,
546        id: &str,
547        holder: &str,
548        now_ms: u64,
549        verb: &'static str,
550        to: ServiceState,
551    ) -> Result<ServiceRecord, LifecycleError> {
552        let (state, stopping) = {
553            let mut registry = self.lock()?;
554            let state = registry
555                .records
556                .get(id)
557                .map(|record| record.state)
558                .ok_or_else(|| LifecycleError::UnknownService(id.to_owned()))?;
559            let running = registry.running.contains_key(id);
560            let applies = match to {
561                ServiceState::Held => state == ServiceState::Live || running,
562                _ => state != ServiceState::Retired,
563            };
564            if !applies {
565                return Err(LifecycleError::NotNow {
566                    verb,
567                    state,
568                    running,
569                });
570            }
571            let process = revoke(&mut registry, id);
572            drop(registry);
573            (state, process)
574        };
575        if let Some(process) = stopping {
576            run::stop(&process, self.timeout_of(id)).map_err(LifecycleError::Process)?;
577        }
578        self.transition(id, state, to, holder, now_ms, None)
579    }
580
581    fn timeout_of(&self, id: &str) -> u64 {
582        self.lock()
583            .ok()
584            .and_then(|registry| {
585                registry
586                    .records
587                    .get(id)
588                    .map(|record| record.template.clone())
589            })
590            .and_then(|template| self.templates.get(&template).map(|t| t.stop_timeout_ms))
591            .unwrap_or(1)
592    }
593
594    fn transition(
595        &self,
596        id: &str,
597        from: ServiceState,
598        to: ServiceState,
599        holder: &str,
600        now_ms: u64,
601        note: Option<String>,
602    ) -> Result<ServiceRecord, LifecycleError> {
603        let mut registry = self.lock()?;
604        let mut record = registry
605            .records
606            .get(id)
607            .cloned()
608            .ok_or_else(|| LifecycleError::UnknownService(id.to_owned()))?;
609        record.state = to;
610        record.transitions.push(Transition {
611            from,
612            to,
613            holder: holder.to_owned(),
614            at_ms: now_ms,
615            note,
616        });
617        self.keep(&record)?;
618        registry.records.insert(id.to_owned(), record.clone());
619        drop(registry);
620        Ok(record)
621    }
622
623    /// What `token` admits, when it is a running service process's own.
624    #[must_use]
625    pub fn admit_process(&self, token: &[u8]) -> Option<ProcessGrant> {
626        let digest = *blake3::hash(token).as_bytes();
627        self.lock().ok()?.tokens.get(&digest).cloned()
628    }
629
630    /// Records `id` live when its worker registered, for the holder that
631    /// started it; nothing when it already is.
632    ///
633    /// # Errors
634    /// The service is unknown or the store failed.
635    pub fn mark_live(&self, id: &str, now_ms: u64) -> Result<(), LifecycleError> {
636        let (state, starter) = {
637            let registry = self.lock()?;
638            let state = registry
639                .records
640                .get(id)
641                .map(|record| record.state)
642                .ok_or_else(|| LifecycleError::UnknownService(id.to_owned()))?;
643            let starter = registry
644                .running
645                .get(id)
646                .map_or_else(|| "process".to_owned(), |process| process.starter.clone());
647            drop(registry);
648            (state, starter)
649        };
650        if state == ServiceState::Live {
651            return Ok(());
652        }
653        self.transition(id, state, ServiceState::Live, &starter, now_ms, None)
654            .map(|_| ())
655    }
656
657    /// Starts every service last recorded live, as the holder that last
658    /// made it so, after a restart. A start that fails is recorded on the
659    /// service and the rest still start.
660    pub fn restart_live(self: &Arc<Self>, now_ms: u64) -> Vec<(String, LifecycleError)> {
661        let live: Vec<(String, String)> = self.lock().map_or_else(
662            |_| Vec::new(),
663            |registry| {
664                registry
665                    .records
666                    .values()
667                    .filter(|record| record.state == ServiceState::Live)
668                    .map(|record| {
669                        let starter = record
670                            .transitions
671                            .last()
672                            .map_or_else(|| record.created_by.clone(), |t| t.holder.clone());
673                        (record.id.clone(), starter)
674                    })
675                    .collect()
676            },
677        );
678        let mut failed = Vec::new();
679        for (id, starter) in live {
680            if let Err(error) = self.start(&id, &starter, "restart", ServiceState::Live) {
681                let note = Some(format!("restart failed: {error}"));
682                let _kept = self.transition(
683                    &id,
684                    ServiceState::Live,
685                    ServiceState::Asleep,
686                    "process",
687                    now_ms,
688                    note,
689                );
690                failed.push((id, error));
691            }
692        }
693        failed
694    }
695
696    /// Whether a service serving `subject`, not retired, was placed in Lys at
697    /// or after `since_ms`: a pass issued before then cannot carry its
698    /// rights, so its offline refusal says nothing and Lys is asked live.
699    #[must_use]
700    pub fn placed_since(&self, subject: &str, since_ms: u64) -> bool {
701        self.lock().is_ok_and(|registry| {
702            registry.records.values().any(|record| {
703                record.state != ServiceState::Retired
704                    && record.subjects.iter().any(|served| served == subject)
705                    && record
706                        .transitions
707                        .first()
708                        .is_some_and(|created| created.at_ms >= since_ms)
709            })
710        })
711    }
712
713    /// The server's shutdown has begun: from here no service starts, and a
714    /// process that exits (a worker leaves when its connection closes) keeps
715    /// its service's recorded state, so a live service is live at the next
716    /// start. Called when the stop is asked, before any connection drains.
717    pub fn shutdown_begun(&self) {
718        if let Ok(mut registry) = self.lock() {
719            registry.stopping_all = true;
720        }
721    }
722
723    /// Stops every running process at server shutdown without changing a
724    /// state, so a live service is started again by the next start.
725    pub fn shutdown(&self) {
726        self.shutdown_begun();
727        let ids: Vec<String> = match self.lock() {
728            Ok(registry) => registry.running.keys().cloned().collect(),
729            Err(_) => return,
730        };
731        for id in ids {
732            let timeout = self.timeout_of(&id);
733            let process = match self.lock() {
734                Ok(mut registry) => revoke(&mut registry, &id),
735                Err(_) => return,
736            };
737            if let Some(process) = process
738                && let Err(error) = run::stop(&process, timeout)
739            {
740                tracing::warn!(service = %id, error, "a service process did not stop at shutdown");
741            }
742        }
743    }
744
745    /// A process exited: unasked, it is recorded on its service; a stop that
746    /// asked records its own state, and so does the server's shutdown once it
747    /// has begun.
748    fn exited(&self, id: &str, status: &str, now_ms: u64) {
749        let (asked, stopping) = match self.lock() {
750            Ok(mut registry) => {
751                let asked = registry.running.remove(id).is_none_or(|process| {
752                    registry.tokens.remove(&process.digest);
753                    false
754                });
755                (asked, registry.stopping_all)
756            }
757            Err(_) => return,
758        };
759        if asked {
760            return;
761        }
762        if stopping {
763            tracing::info!(
764                service = id,
765                status,
766                "a service process exited during shutdown; its state stays"
767            );
768            return;
769        }
770        let Ok(record) = self.record(id) else {
771            return;
772        };
773        let note = Some(format!("exited unasked: {status}"));
774        if let Err(error) = self.transition(
775            id,
776            record.state,
777            ServiceState::Asleep,
778            "process",
779            now_ms,
780            note,
781        ) {
782            tracing::warn!(service = id, %error, "a service exit could not be recorded");
783        }
784    }
785
786    /// Writes `record` whole: a temporary file, one full flush, a rename and
787    /// a flush of the directory, so a crash leaves the old record or the new.
788    fn keep(&self, record: &ServiceRecord) -> Result<(), LifecycleError> {
789        let path = self.dir.join(format!("{}.json", record.id));
790        let temporary = self.dir.join(format!("{}.json.new", record.id));
791        let bytes = serde_json::to_vec_pretty(record)
792            .map_err(|error| LifecycleError::Store(error.to_string()))?;
793        let mut file =
794            std::fs::File::create(&temporary).map_err(|error| store(&temporary, &error))?;
795        file.write_all(&bytes)
796            .and_then(|()| file.sync_all())
797            .map_err(|error| store(&temporary, &error))?;
798        std::fs::rename(&temporary, &path).map_err(|error| store(&path, &error))?;
799        std::fs::File::open(&self.dir)
800            .and_then(|dir| dir.sync_all())
801            .map_err(|error| store(&self.dir, &error))
802    }
803
804    /// The service's log file.
805    fn log_path(&self, id: &str) -> PathBuf {
806        self.dir.join(format!("{id}.log"))
807    }
808}
809
810/// Takes a running process out of the registry and revokes its credential,
811/// marking it asked to stop.
812fn revoke(registry: &mut Registry, id: &str) -> Option<run::Running> {
813    let process = registry.running.remove(id)?;
814    registry.tokens.remove(&process.digest);
815    Some(process)
816}
817
818fn store(path: &Path, error: &std::io::Error) -> LifecycleError {
819    LifecycleError::Store(format!("{}: {}", path.display(), error.kind()))
820}
821
822/// 32 hex digits of a domain-separated digest of `parts`.
823fn digest(parts: &[&str]) -> String {
824    let mut hasher = blake3::Hasher::new();
825    for part in parts {
826        hasher.update(&u64::try_from(part.len()).unwrap_or(u64::MAX).to_be_bytes());
827        hasher.update(part.as_bytes());
828    }
829    hasher.finalize().to_hex()[..32].to_owned()
830}
831
832#[cfg(test)]
833#[path = "lifecycle_tests.rs"]
834mod tests;