Skip to main content

tatara_process/
phase.rs

1//! Unix process phases — authoritative state machine.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6/// The Unix-authentic phase a Process is in.
7///
8/// Canonical transitions:
9/// ```text
10/// Pending → Forking → Execing → Running → Attested
11///                                       ↘ Failed
12/// Attested → Reconverging → Execing                       (SIGHUP, no zombie)
13/// Attested → Releasing  → Exiting → Zombie → Reaped       (export-then-SIGTERM)
14/// Attested → Exiting    → Zombie → Reaped                 (no-exports SIGTERM)
15/// Failed   → Releasing  → Zombie → Reaped                 (post-mortem exports)
16/// Failed   → Zombie     → Reaped                          (no-exports failed)
17/// Running  → Exiting    → Zombie → Reaped                 (early SIGTERM, no exports)
18/// Running  → Failed                                       (non-zero exit)
19/// ```
20///
21/// `Releasing` is the export window — the reconciler runs declared
22/// `ExportSpec`s (via tatara-export-worker Jobs) between the
23/// terminal phase reached (`Attested` or `Failed`) and `Exiting` /
24/// `Zombie`. A Process with no `lifetime.ephemeral.exports`, or
25/// where no export's trigger matches the phase reached, skips
26/// `Releasing` entirely. See [`crate::export`] + [`crate::lifetime`].
27#[derive(
28    Clone,
29    Copy,
30    Debug,
31    PartialEq,
32    Eq,
33    Hash,
34    Serialize,
35    Deserialize,
36    JsonSchema,
37    tatara_closed_set::DeriveClosedSet,
38)]
39#[closed_set(
40    via = "as_str",
41    unknown = "UnknownPhase",
42    display,
43    generate_unknown = "process phase"
44)]
45pub enum ProcessPhase {
46    /// Admitted; PID not assigned yet.
47    Pending,
48    /// PID assigned in ProcessTable; parent linked; content hash computed.
49    Forking,
50    /// RENDER phase — evaluating Nix / expanding Lisp / rendering Helm;
51    /// emitting Kustomization + HelmRelease CRs.
52    Execing,
53    /// Flux resources applied; boundary preconditions being checked.
54    Running,
55    /// All postconditions hold; three-pillar attestation written.
56    Attested,
57    /// SIGHUP received or drift detected; returning to Execing.
58    Reconverging,
59    /// Export window — running declared `ExportSpec`s before SIGTERM.
60    /// Each export becomes a typed Job; the Process advances only
61    /// when every Job has reached a terminal state. Failures here
62    /// short-circuit straight to `Zombie` (the export attempt itself
63    /// is attested; partial-success is fine for best-effort channels).
64    Releasing,
65    /// SIGTERM received; graceful shutdown; children draining.
66    Exiting,
67    /// Exited non-zero; awaiting reap.
68    Failed,
69    /// Exited; children gone; finalizer not yet released.
70    Zombie,
71    /// Finalizer released; K8s GC will remove.
72    Reaped,
73}
74
75impl Default for ProcessPhase {
76    fn default() -> Self {
77        Self::Pending
78    }
79}
80
81impl ProcessPhase {
82    /// The closed set of phases — single source of truth that drives
83    /// `as_str` / Display / `FromStr` so adding a variant updates every
84    /// projection at once (and the `display_matches_as_str` +
85    /// `all_phases_roundtrip_via_as_str` tests pin the bridge). Also
86    /// used by the test sites that need to sweep every-other-variant
87    /// (`reaped_is_sink`, `releasing_can_only_be_entered_from_terminal_gates`,
88    /// `terminal_reached_gates_are_attested_and_failed`), so a new
89    /// variant lands in ALL once and reaches every test by iteration
90    /// rather than by per-test array maintenance.
91    pub const ALL: [Self; 11] = [
92        Self::Pending,
93        Self::Forking,
94        Self::Execing,
95        Self::Running,
96        Self::Attested,
97        Self::Reconverging,
98        Self::Releasing,
99        Self::Exiting,
100        Self::Failed,
101        Self::Zombie,
102        Self::Reaped,
103    ];
104
105    /// Canonical PascalCase wire-format projection. Used by Display
106    /// (single source of truth) and by `FromStr` to identify the
107    /// variant from its annotation / status-field representation.
108    /// The serde rename derives produce the same form on the JSON
109    /// boundary; this method exposes it to Rust callers (logs,
110    /// annotation values, error messages) without re-serializing.
111    pub const fn as_str(self) -> &'static str {
112        match self {
113            Self::Pending => "Pending",
114            Self::Forking => "Forking",
115            Self::Execing => "Execing",
116            Self::Running => "Running",
117            Self::Attested => "Attested",
118            Self::Reconverging => "Reconverging",
119            Self::Releasing => "Releasing",
120            Self::Exiting => "Exiting",
121            Self::Failed => "Failed",
122            Self::Zombie => "Zombie",
123            Self::Reaped => "Reaped",
124        }
125    }
126
127    /// True if the phase is a terminal sink with no further transitions.
128    pub const fn is_terminal(self) -> bool {
129        matches!(self, Self::Reaped)
130    }
131
132    /// True if the process has reached a running state (Running or Attested).
133    pub const fn is_running(self) -> bool {
134        matches!(self, Self::Running | Self::Attested)
135    }
136
137    /// True if the process is still eligible to receive SIGHUP/SIGUSR* signals.
138    /// `Releasing` is alive — the Process hasn't been SIGTERM'd yet; its
139    /// children (export Jobs) are running.
140    pub const fn is_alive(self) -> bool {
141        !matches!(self, Self::Zombie | Self::Reaped | Self::Failed)
142    }
143
144    /// True if the process has left the alive set — the closed-set
145    /// complement of [`Self::is_alive`]. Sinks to `Failed | Zombie |
146    /// Reaped`: the three phases where a Process is no longer
147    /// converging and its supervisor (pool reconciler, allocation
148    /// controller, cascade-delete GC) treats it as a terminated
149    /// member for reap / replace / status-count decisions. Named on
150    /// the "positive" pole so caller sites read as
151    /// `phase.has_exited()` instead of `!phase.is_alive()` — the
152    /// closed-set predicate family gains a symmetric member for the
153    /// same reason [`Self::is_running`] sits next to [`Self::is_alive`]
154    /// (both express live-set membership positively).
155    ///
156    /// Pre-lift the `Failed | Zombie | Reaped` set was hand-restated
157    /// as an inline `matches!(phase, Failed | Zombie | Reaped)` on
158    /// [`crate::pool::PoolMemberSnapshot::is_failed`] (peer to that
159    /// snapshot's `is_healthy` which restated the `Running | Attested`
160    /// set of [`Self::is_running`]). Both duplications now route
161    /// through their respective substrate closed-set predicate, so a
162    /// future variant added to the alive/dead partition (a new
163    /// `Draining` phase, a rename of `Zombie` → `Terminated`) lands
164    /// at the ONE closed-set surface here rather than as silent skew
165    /// between the substrate's `is_alive` and the pool reconciler's
166    /// downstream `is_failed` restatement.
167    pub const fn has_exited(self) -> bool {
168        !self.is_alive()
169    }
170
171    /// True if the phase is the export window — declared `ExportSpec`s
172    /// run here before SIGTERM. Reserved for the reconciler's
173    /// `handle_releasing` step + tatara-export-worker Job emission.
174    pub const fn is_releasing(self) -> bool {
175        matches!(self, Self::Releasing)
176    }
177
178    /// True if the phase is a terminal-reached gate (`Attested` or
179    /// `Failed`) — the points where the reconciler decides whether
180    /// to enter `Releasing`, jump straight to `Exiting`/`Zombie`, or
181    /// stay (for inspection per `TeardownPolicy`).
182    pub const fn is_terminal_reached(self) -> bool {
183        matches!(self, Self::Attested | Self::Failed)
184    }
185
186    /// True if the phase transition `self → next` is legal.
187    pub const fn can_transition_to(self, next: Self) -> bool {
188        use ProcessPhase::*;
189        matches!(
190            (self, next),
191            (Pending, Forking)
192                | (Forking, Execing)
193                | (Execing, Running)
194                | (Execing, Failed)
195                | (Running, Attested)
196                | (Running, Exiting)
197                | (Running, Failed)
198                | (Running, Reconverging)
199                | (Attested, Reconverging)
200                | (Attested, Releasing)
201                | (Attested, Exiting)
202                | (Failed, Releasing)
203                | (Failed, Zombie)
204                | (Releasing, Exiting)
205                | (Releasing, Zombie)
206                | (Reconverging, Execing)
207                | (Exiting, Zombie)
208                | (Zombie, Reaped)
209        )
210    }
211}
212
213// `impl FromStr for ProcessPhase` +
214// `impl tatara_lisp::ClosedSet for ProcessPhase` +
215// `impl std::fmt::Display for ProcessPhase` +
216// `pub struct UnknownPhase(pub String)` are all generated by
217// `#[derive(tatara_closed_set::DeriveClosedSet)]` +
218// `#[closed_set(via = "as_str", unknown = "UnknownPhase", display,
219// generate_unknown = "process phase")]` on the enum declaration
220// above. `label` delegates to the inherent `ProcessPhase::as_str`
221// — the inherent name (PascalCase `as_str`) stays the load-bearing
222// wire-vocabulary projection that matches the serde rename + the
223// CRD `enum:` enumeration verbatim, while generic `T: ClosedSet`
224// consumers reach the STABLE workspace-wide name (`label`). The
225// `display` flag emits the `f.write_str(self.as_str())` delegation
226// block at the same proc-macro site. The carrier is named
227// `UnknownPhase` (not the auto-derived `UnknownProcessPhase`)
228// because the short name is the published public-API surface every
229// downstream caller imports — `#[closed_set(unknown =
230// "UnknownPhase")]` pins it. The explicit `generate_unknown =
231// "process phase"` label overrides the auto-derived "process phase"
232// (which happens to match byte-for-byte — pinning it here keeps the
233// pre-lift wording stable against any future change to the
234// `pascal_to_spaced_lowercase` helper's behavior). Symmetric to
235// every other `#[derive(DeriveClosedSet)]` implementor across the
236// crate (`WorkloadKind`, `VerificationPhase`, `MustReachPhase`,
237// `SighupStrategy`, `TeardownPolicy`, `ConditionKind`, every
238// classification axis, every pool/export/allocation closed-set).
239
240#[cfg(test)]
241mod tests {
242    use super::ProcessPhase::*;
243
244    #[test]
245    fn canonical_path_is_legal() {
246        assert!(Pending.can_transition_to(Forking));
247        assert!(Forking.can_transition_to(Execing));
248        assert!(Execing.can_transition_to(Running));
249        assert!(Running.can_transition_to(Attested));
250        assert!(Attested.can_transition_to(Reconverging));
251        assert!(Reconverging.can_transition_to(Execing));
252        assert!(Attested.can_transition_to(Exiting));
253        assert!(Exiting.can_transition_to(Zombie));
254        assert!(Zombie.can_transition_to(Reaped));
255    }
256
257    /// Releasing path — Attested or Failed may detour through the
258    /// export window before terminating. Releasing is itself a
259    /// legal source for Exiting (happy path) or Zombie (export-
260    /// worker terminal-failure shortcut).
261    #[test]
262    fn releasing_path_is_legal() {
263        assert!(Attested.can_transition_to(Releasing));
264        assert!(Failed.can_transition_to(Releasing));
265        assert!(Releasing.can_transition_to(Exiting));
266        assert!(Releasing.can_transition_to(Zombie));
267        // Releasing is alive — children (export Jobs) still running.
268        assert!(Releasing.is_alive());
269        // Releasing is not a terminal-reached gate.
270        assert!(!Releasing.is_terminal_reached());
271    }
272
273    #[test]
274    fn terminal_reached_gates_are_attested_and_failed() {
275        assert!(Attested.is_terminal_reached());
276        assert!(Failed.is_terminal_reached());
277        // Sweep every other variant via ALL so a future variant is
278        // covered automatically (was a hand-maintained 9-entry array).
279        for p in super::ProcessPhase::ALL {
280            if matches!(p, Attested | Failed) {
281                continue;
282            }
283            assert!(!p.is_terminal_reached(), "{p:?} is not a terminal gate");
284        }
285    }
286
287    #[test]
288    fn releasing_can_only_be_entered_from_terminal_gates() {
289        // Releasing has exactly two legal entries — the terminal-
290        // reached gates. Anything else is a state-machine bug.
291        // ALL is the source of truth for the candidate set.
292        let entries: Vec<_> = super::ProcessPhase::ALL
293            .into_iter()
294            .filter(|p| p.can_transition_to(Releasing))
295            .collect();
296        assert_eq!(entries, vec![Attested, Failed]);
297    }
298
299    #[test]
300    fn reaped_is_sink() {
301        assert!(Reaped.is_terminal());
302        // Sweep every non-Reaped variant via ALL so a new phase
303        // pins the sink-ness invariant automatically.
304        for next in super::ProcessPhase::ALL {
305            if next == Reaped {
306                continue;
307            }
308            assert!(
309                !Reaped.can_transition_to(next),
310                "Reaped → {next:?} should be illegal"
311            );
312        }
313    }
314
315    #[test]
316    fn cannot_skip_forking() {
317        assert!(!Pending.can_transition_to(Execing));
318        assert!(!Pending.can_transition_to(Running));
319    }
320
321    #[test]
322    fn running_is_alive() {
323        assert!(Running.is_alive());
324        assert!(Attested.is_alive());
325        assert!(!Zombie.is_alive());
326        assert!(!Reaped.is_alive());
327    }
328
329    /// [`ProcessPhase::has_exited`] sinks to `{Failed, Zombie, Reaped}`
330    /// verbatim — pins the closed set the substrate's
331    /// [`crate::pool::PoolMemberSnapshot::is_failed`] alias delegates
332    /// to post-lift, so a variant added inside the exited partition
333    /// (a new terminal-error variant) is caught here rather than as
334    /// silent drift at the pool reconciler's health-count seed.
335    #[test]
336    fn has_exited_sinks_to_failed_zombie_reaped() {
337        for p in super::ProcessPhase::ALL {
338            let expected = matches!(p, Failed | Zombie | Reaped);
339            assert_eq!(
340                p.has_exited(),
341                expected,
342                "{p:?}.has_exited() should be {expected}"
343            );
344        }
345    }
346
347    /// [`ProcessPhase::has_exited`] IS the boolean complement of
348    /// [`ProcessPhase::is_alive`] across every variant — pinning the
349    /// closed-set complement invariant so a future rename of either
350    /// primitive that drifted one edge (e.g. a variant classified as
351    /// both alive AND exited, or as neither) surfaces here rather
352    /// than as an operator-facing pool-count skew where the same
353    /// Process is counted both toward the alive pool AND toward the
354    /// failed-reap queue.
355    #[test]
356    fn has_exited_is_complement_of_is_alive() {
357        for p in super::ProcessPhase::ALL {
358            assert_eq!(
359                p.has_exited(),
360                !p.is_alive(),
361                "{p:?}: has_exited should equal !is_alive"
362            );
363        }
364    }
365
366    /// The exited set and the [`ProcessPhase::is_running`] set are
367    /// disjoint — no Process is both "healthy" (Running or Attested)
368    /// and "exited" (Failed/Zombie/Reaped) at the same phase. Pins
369    /// the substrate invariant the pool reconciler's `is_healthy` +
370    /// `is_failed` snapshot predicates rely on to partition members
371    /// without double-counting.
372    #[test]
373    fn is_running_and_has_exited_are_disjoint() {
374        for p in super::ProcessPhase::ALL {
375            assert!(
376                !(p.is_running() && p.has_exited()),
377                "{p:?}: cannot be both is_running() and has_exited()"
378            );
379        }
380    }
381
382    // ── closed-set algebra contracts (ALL × as_str × FromStr) ────────
383
384    /// Structural well-formedness of [`ProcessPhase`] as a
385    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
386    /// testkit lift that pins all three structural invariants
387    /// (`ALL` is non-empty, every variant round-trips through
388    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
389    /// outside the closed set) at ONE call site. Replaces the
390    /// hand-derived `all_phases_roundtrip_via_as_str` +
391    /// `all_is_unique_and_complete` + the empty-input arm of the
392    /// per-implementor unknown-error test — those three sites
393    /// re-derived byte-for-byte across 36+ closed-set implementors
394    /// pre-lift; this helper lifts them all onto the trait so any
395    /// future closed-set implementor inherits the contract by
396    /// implementing the trait + calling this one helper, with no
397    /// HashSet sweep or `FromStr` round-trip loop to copy.
398    ///
399    /// `FromStr` delegates to `<Self as tatara_closed_set::ClosedSet>::parse_label`,
400    /// so this helper exercises the exact code path the operator hits
401    /// when parsing an annotation / status-field value back to the
402    /// typed phase.
403    #[test]
404    fn process_phase_is_well_formed_closed_set() {
405        tatara_closed_set::assert_closed_set_well_formed::<super::ProcessPhase>();
406    }
407
408    /// The Display impl IS `as_str` — pinning this lets future
409    /// callers reach for either projection without drift. If a
410    /// reviewer accidentally re-introduces an inline match in
411    /// Display, this test would fail the moment a variant rename
412    /// touches one site but not the other. NOT lifted into the
413    /// `ClosedSet` testkit because `Display` is a per-implementor
414    /// concern (the trait can't provide a default `Display` impl in
415    /// stable Rust) and the projection's choice (`as_str` vs.
416    /// inherent label vs. tagged-Debug) is domain-specific.
417    #[test]
418    fn display_matches_as_str() {
419        for phase in super::ProcessPhase::ALL {
420            assert_eq!(phase.to_string(), phase.as_str());
421        }
422    }
423
424    /// `FromStr` rejects domain-specific bad inputs — case-drifted /
425    /// typo / extinct-variant — and the error echoes the input
426    /// VERBATIM so the operator-facing diagnostic carries the
427    /// offending value, not a normalized form. Kept per-implementor
428    /// because the verbatim-payload contract is a property of the
429    /// per-enum `Unknown<X>(pub String)` newtype, not of the trait's
430    /// structural surface — the trait's `make_unknown(s: &str)`
431    /// hook lets a future implementor swap the carrier for a
432    /// structured diagnostic without changing the trait contract, so
433    /// the payload-echo invariant lives with the implementor that
434    /// chose the newtype shape. (The empty-input arm is now lifted
435    /// into `process_phase_is_well_formed_closed_set`; the
436    /// case-drifted / typo / extinct-variant arms stay here as
437    /// they're representative non-canonical inputs the operator
438    /// might supply.)
439    #[test]
440    fn unknown_phase_errors() {
441        use std::str::FromStr;
442        for bad in ["attested", "FAILED", "Cancelled", "Reapped"] {
443            let err = super::ProcessPhase::from_str(bad).unwrap_err();
444            assert_eq!(err.0, bad, "error payload should echo input verbatim");
445        }
446    }
447}