zinit/sdk/
state.rs

1//! Service state machine definitions.
2//!
3//! Defines the 7 explicit states a service can be in, along with failure reasons.
4
5use serde::{Deserialize, Serialize};
6
7use super::signal;
8
9/// Reason why a service failed.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(tag = "type", rename_all = "snake_case")]
12pub enum FailureReason {
13    /// Process exited with non-zero code.
14    ExitCode { code: i32 },
15    /// Process was killed by a signal.
16    Signal { signal: i32 },
17    /// Process did not start within timeout.
18    StartTimeout,
19    /// Process did not stop within timeout after SIGTERM.
20    StopTimeout,
21    /// Health check failed after retries.
22    HealthCheckFailed { attempts: u32 },
23    /// A required dependency failed.
24    DependencyFailed { service: String },
25    /// Failed to spawn process.
26    SpawnError { message: String },
27    /// Configuration error - a required dependency doesn't exist.
28    MissingDependency { dependency: String },
29}
30
31impl FailureReason {
32    /// Returns a human-readable description of the failure.
33    pub fn display(&self) -> String {
34        match self {
35            FailureReason::ExitCode { code } => format!("exited with code {}", code),
36            FailureReason::Signal { signal: sig } => {
37                format!("killed by {} ({})", signal::name(*sig), sig)
38            }
39            FailureReason::StartTimeout => "start timeout".to_string(),
40            FailureReason::StopTimeout => "stop timeout".to_string(),
41            FailureReason::HealthCheckFailed { attempts } => {
42                format!("health check failed after {} attempts", attempts)
43            }
44            FailureReason::DependencyFailed { service } => {
45                format!("dependency '{}' failed", service)
46            }
47            FailureReason::SpawnError { message } => format!("spawn error: {}", message),
48            FailureReason::MissingDependency { dependency } => {
49                format!("missing dependency '{}'", dependency)
50            }
51        }
52    }
53}
54
55impl std::fmt::Display for FailureReason {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        write!(f, "{}", self.display())
58    }
59}
60
61/// The state of a service in the supervisor.
62#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(tag = "state", rename_all = "snake_case")]
64pub enum ServiceState {
65    /// Service has never been started.
66    #[default]
67    Inactive,
68    /// Service is waiting on dependencies.
69    Blocked { waiting_on: Vec<String> },
70    /// Process has been spawned, waiting for health check or startup.
71    Starting { pid: u32 },
72    /// Process is running and healthy.
73    Running { pid: u32 },
74    /// SIGTERM sent, waiting for process to exit.
75    Stopping { pid: u32 },
76    /// Process exited cleanly.
77    Exited { exit_code: Option<i32> },
78    /// Process failed.
79    Failed { reason: FailureReason },
80}
81
82impl ServiceState {
83    /// Returns the state name as a string.
84    pub fn name(&self) -> &'static str {
85        match self {
86            ServiceState::Inactive => "inactive",
87            ServiceState::Blocked { .. } => "blocked",
88            ServiceState::Starting { .. } => "starting",
89            ServiceState::Running { .. } => "running",
90            ServiceState::Stopping { .. } => "stopping",
91            ServiceState::Exited { .. } => "exited",
92            ServiceState::Failed { .. } => "failed",
93        }
94    }
95
96    /// Returns a symbol representing the state for display.
97    pub fn symbol(&self) -> &'static str {
98        match self {
99            ServiceState::Inactive => "[-]",
100            ServiceState::Blocked { .. } => "[?]",
101            ServiceState::Starting { .. } => "[>]",
102            ServiceState::Running { .. } => "[+]",
103            ServiceState::Stopping { .. } => "[!]",
104            ServiceState::Exited { .. } => "[.]",
105            ServiceState::Failed { .. } => "[X]",
106        }
107    }
108
109    /// Returns the PID if the service has a running process.
110    pub fn pid(&self) -> Option<u32> {
111        match self {
112            ServiceState::Starting { pid }
113            | ServiceState::Running { pid }
114            | ServiceState::Stopping { pid } => Some(*pid),
115            _ => None,
116        }
117    }
118
119    /// Returns true if the service has an active process.
120    pub fn is_active(&self) -> bool {
121        matches!(
122            self,
123            ServiceState::Starting { .. }
124                | ServiceState::Running { .. }
125                | ServiceState::Stopping { .. }
126        )
127    }
128
129    /// Returns true if the service can satisfy a "requires" dependency.
130    /// A service satisfies requires if it's Running OR exited successfully (exit code 0).
131    /// This allows oneshot services to satisfy dependencies after they complete.
132    pub fn is_satisfied(&self) -> bool {
133        matches!(
134            self,
135            ServiceState::Running { .. } | ServiceState::Exited { exit_code: Some(0) }
136        )
137    }
138
139    /// Returns true if the service is in a state where start can be attempted.
140    /// Note: Services that failed due to MissingDependency cannot be restarted
141    /// because the dependency will never appear - it's a permanent config error.
142    pub fn can_attempt_start(&self) -> bool {
143        match self {
144            ServiceState::Inactive | ServiceState::Blocked { .. } | ServiceState::Exited { .. } => {
145                true
146            }
147            ServiceState::Failed { reason } => {
148                // MissingDependency is a permanent config error - can't retry
149                !matches!(reason, FailureReason::MissingDependency { .. })
150            }
151            _ => false,
152        }
153    }
154}
155
156impl std::fmt::Display for ServiceState {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        write!(f, "{}", self.name())
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn test_state_names() {
168        assert_eq!(ServiceState::Inactive.name(), "inactive");
169        assert_eq!(
170            ServiceState::Blocked { waiting_on: vec![] }.name(),
171            "blocked"
172        );
173        assert_eq!(ServiceState::Starting { pid: 1 }.name(), "starting");
174        assert_eq!(ServiceState::Running { pid: 1 }.name(), "running");
175        assert_eq!(ServiceState::Stopping { pid: 1 }.name(), "stopping");
176        assert_eq!(ServiceState::Exited { exit_code: None }.name(), "exited");
177        assert_eq!(
178            ServiceState::Failed {
179                reason: FailureReason::StartTimeout
180            }
181            .name(),
182            "failed"
183        );
184    }
185
186    #[test]
187    fn test_state_symbols() {
188        assert_eq!(ServiceState::Inactive.symbol(), "[-]");
189        assert_eq!(ServiceState::Running { pid: 1 }.symbol(), "[+]");
190        assert_eq!(
191            ServiceState::Failed {
192                reason: FailureReason::StartTimeout
193            }
194            .symbol(),
195            "[X]"
196        );
197    }
198
199    #[test]
200    fn test_pid_extraction() {
201        assert_eq!(ServiceState::Inactive.pid(), None);
202        assert_eq!(ServiceState::Starting { pid: 123 }.pid(), Some(123));
203        assert_eq!(ServiceState::Running { pid: 456 }.pid(), Some(456));
204        assert_eq!(ServiceState::Stopping { pid: 789 }.pid(), Some(789));
205        assert_eq!(ServiceState::Exited { exit_code: Some(0) }.pid(), None);
206    }
207
208    #[test]
209    fn test_is_active() {
210        assert!(!ServiceState::Inactive.is_active());
211        assert!(ServiceState::Starting { pid: 1 }.is_active());
212        assert!(ServiceState::Running { pid: 1 }.is_active());
213        assert!(ServiceState::Stopping { pid: 1 }.is_active());
214        assert!(!ServiceState::Exited { exit_code: None }.is_active());
215    }
216
217    #[test]
218    fn test_is_satisfied() {
219        assert!(!ServiceState::Inactive.is_satisfied());
220        assert!(!ServiceState::Starting { pid: 1 }.is_satisfied());
221        assert!(ServiceState::Running { pid: 1 }.is_satisfied());
222        assert!(!ServiceState::Stopping { pid: 1 }.is_satisfied());
223    }
224
225    #[test]
226    fn test_can_attempt_start() {
227        assert!(ServiceState::Inactive.can_attempt_start());
228        assert!(!ServiceState::Running { pid: 1 }.can_attempt_start());
229        assert!(ServiceState::Exited { exit_code: Some(0) }.can_attempt_start());
230        // Regular failures can retry
231        assert!(
232            ServiceState::Failed {
233                reason: FailureReason::StartTimeout
234            }
235            .can_attempt_start()
236        );
237        assert!(
238            ServiceState::Failed {
239                reason: FailureReason::ExitCode { code: 1 }
240            }
241            .can_attempt_start()
242        );
243        // MissingDependency is permanent - cannot retry
244        assert!(
245            !ServiceState::Failed {
246                reason: FailureReason::MissingDependency {
247                    dependency: "missing".to_string()
248                }
249            }
250            .can_attempt_start()
251        );
252    }
253
254    #[test]
255    fn test_failure_reason_display() {
256        assert_eq!(
257            FailureReason::ExitCode { code: 1 }.display(),
258            "exited with code 1"
259        );
260        assert_eq!(FailureReason::StartTimeout.display(), "start timeout");
261        assert_eq!(
262            FailureReason::DependencyFailed {
263                service: "foo".to_string()
264            }
265            .display(),
266            "dependency 'foo' failed"
267        );
268    }
269
270    #[test]
271    fn test_serialization() {
272        let state = ServiceState::Running { pid: 123 };
273        let json = serde_json::to_string(&state).unwrap();
274        assert!(json.contains("running"));
275        assert!(json.contains("123"));
276
277        let parsed: ServiceState = serde_json::from_str(&json).unwrap();
278        assert_eq!(parsed, state);
279    }
280}