aion_server/control/stop.rs
1//! The stop flow behind `aion server stop`: resolve the pid file, verify the
2//! incarnation, signal, wait bounded, and read the drain outcome back from
3//! the death note.
4//!
5//! The verb signals ONLY a process the record proves is ours — the estate's
6//! kill discipline, mechanized. Wrong-incarnation is a refusal that names
7//! both incarnations and touches nothing; a dead process with a lingering
8//! file is reconciled and reported; a server that exits without writing an
9//! outcome record has that absence reported honestly, never papered over
10//! with a fabricated summary.
11//!
12//! One window the proof cannot close: between the incarnation probe and the
13//! SIGTERM, the verified process can exit and the kernel can hand its number
14//! to a stranger. The verb handles the exit (ESRCH reconciles as
15//! already-gone) but a full pid-space wraparound inside those microseconds
16//! would land the signal on the recycled pid. Closing it needs a
17//! process-handle primitive (`pidfd_open`/`pidfd_send_signal`, Linux-only);
18//! until a piece wants that, the window is named here rather than claimed
19//! away.
20
21use std::path::{Path, PathBuf};
22use std::time::{Duration, Instant};
23
24use super::incarnation::{self, IncarnationProbe};
25use super::outcome::{self, NoteFate};
26use super::pid_file::{self, PidRecord};
27use crate::error::ServerError;
28
29/// How the stop ended when it was allowed to act.
30#[derive(Clone, Debug)]
31pub enum StopOutcome {
32 /// The server was signalled and exited within the patience window. The
33 /// death note's account of the exit rides along.
34 Stopped {
35 /// The incarnation that was stopped.
36 record: PidRecord,
37 /// What the death note records for it (the drain outcome lives
38 /// here; its absence is a state, not an error), or why the note
39 /// could not be read — a bookkeeping failure beside a server that
40 /// is PROVEN exited, carried as its own fact, never allowed to
41 /// destroy this outcome (see [`exit_bookkeeping`]).
42 fate: Result<NoteFate, String>,
43 /// How long the wait took.
44 waited: Duration,
45 /// Whether a pid file the dying server left behind was reconciled
46 /// away (a clean exit removes its own; a forced one cannot), or
47 /// why reconciliation failed — `Err` means the file may still name
48 /// the dead pid and wants a manual look.
49 pid_file_reconciled: Result<bool, String>,
50 },
51 /// The recorded process was already gone: nothing to signal. The file
52 /// is reconciled away and the note consulted for what happened.
53 AlreadyGone {
54 /// The incarnation the file named.
55 record: PidRecord,
56 /// The death note's account — `ArmedNotDisarmed` with no record
57 /// here is the `kill -9` shape, reported as exactly that — or why
58 /// the note could not be read (carried, never propagated: the
59 /// goal state already holds).
60 fate: Result<NoteFate, String>,
61 /// Whether the stale file was removed, or why removing it failed.
62 pid_file_reconciled: Result<bool, String>,
63 },
64 /// The server is still draining when the patience elapsed. Not a
65 /// failure and not a guess: the server owns its drain, and a second
66 /// `stop` is the operator's escalation (the server treats a second
67 /// termination signal as "force immediate exit").
68 StillDraining {
69 /// The incarnation still running.
70 record: PidRecord,
71 /// How long the verb waited before reporting.
72 waited: Duration,
73 },
74}
75
76/// Why the stop refused to act. Each face is distinct and names its remedy.
77#[derive(Clone, Debug, thiserror::Error)]
78pub enum StopRefusal {
79 /// No pid file exists under the home: no server has claimed it (or the
80 /// running server predates the pid-file era).
81 #[error(
82 "no pid file at `{path}`: no server has claimed this home. If a server is \
83 running, it predates `aion server stop` — find it with `ps` and signal it \
84 by hand this one time; its next start writes the pid file"
85 )]
86 NoPidFile {
87 /// The path that held no file.
88 path: PathBuf,
89 },
90 /// The file names a live pid whose start instant is not the recorded
91 /// one: the pid was recycled. Nothing is signalled.
92 #[error(
93 "refusing to signal pid {pid}: the pid file records a server started at unix \
94 second {recorded_started_at}, but the live process wearing that pid started \
95 at {live_started_at}{live_exe} — the pid was recycled onto a different \
96 process after the recorded server died. Nothing was signalled and nothing \
97 was removed; verify with `ps -p {pid}` and remove `{path}` once satisfied"
98 )]
99 StaleIncarnation {
100 /// The recycled pid.
101 pid: u32,
102 /// Start instant the file records.
103 recorded_started_at: u64,
104 /// Start instant the live process wears.
105 live_started_at: u64,
106 /// Rendered ` (running <exe>)` fragment when the table knows it.
107 live_exe: String,
108 /// The pid file's path, named for the operator's own reconciliation.
109 path: PathBuf,
110 },
111 /// The server is verified RUNNING but no wait patience could be resolved
112 /// to govern the stop — no `--patience`, no usable recorded drain window,
113 /// and the configuration could not answer. Nothing is signalled:
114 /// signalling without a governed wait would leave the verb unable to
115 /// report what became of the drain.
116 #[error(
117 "refusing to signal pid {pid}: the server is RUNNING, but no wait patience \
118 could be resolved from the configuration to govern the stop ({unresolved}). \
119 Pass `--patience <seconds>` to rule it at invocation, or repair the \
120 configuration"
121 )]
122 UnresolvedPatience {
123 /// The verified-running pid that was NOT signalled.
124 pid: u32,
125 /// Why patience resolution failed — the configuration's own bare
126 /// account; this template supplies the sentence around it.
127 unresolved: String,
128 },
129 /// This build cannot send POSIX signals.
130 #[error("`aion server stop` needs POSIX signals, which this platform does not have")]
131 UnsupportedPlatform,
132 /// The signal could not be sent to a verified-live process.
133 #[error("could not signal pid {pid}: {message}")]
134 SignalFailed {
135 /// The verified pid the signal was aimed at.
136 pid: u32,
137 /// The OS error.
138 message: String,
139 },
140}
141
142/// The verb's answer: it acted (an outcome) or it refused (a refusal).
143#[derive(Clone, Debug)]
144pub enum StopVerdict {
145 /// The verb acted; here is what happened. Boxed so the verdict's two
146 /// arms stay close in size — the outcome carries the whole record and
147 /// the note's account.
148 Outcome(Box<StopOutcome>),
149 /// The verb refused to act; here is the exact face.
150 Refusal(StopRefusal),
151}
152
153/// Stop the server recorded under `home`, waiting up to `patience` for it to
154/// exit. `patience` is operator-ruled at invocation — the CLI passes the
155/// `--patience` flag, the verified-running record's drain window, or the
156/// config's own `drain_timeout`; this function never invents a value.
157///
158/// `patience` arrives as a `Result` because its resolution can fail (a config
159/// that cannot load) and that failure must be carried to the point of need
160/// rather than destroy the verb's answer: only a VERIFIED-RUNNING server
161/// needs a wait window, so only the `Verified` arm consults the `Err` — and
162/// refuses, naming the running pid and the remedy. Every other face
163/// (no pid file, already gone, stale incarnation) decides without a window,
164/// exactly as it would with one.
165///
166/// # Errors
167///
168/// Returns [`ServerError`] only for an I/O failure reading the pid file —
169/// before any action is decided. Every decision the verb itself makes is a
170/// typed [`StopVerdict`], and once the server is proven exited, bookkeeping
171/// failures (death note, pid-file reconciliation) ride INSIDE the outcome
172/// rather than erroring out of it (see [`exit_bookkeeping`]).
173pub fn stop(home: &Path, patience: Result<Duration, String>) -> Result<StopVerdict, ServerError> {
174 let Some(record) = pid_file::read(home)? else {
175 return Ok(StopVerdict::Refusal(StopRefusal::NoPidFile {
176 path: pid_file::pid_file_path(home),
177 }));
178 };
179 match incarnation::probe(&record) {
180 IncarnationProbe::ProcessGone => {
181 let (fate, pid_file_reconciled) = exit_bookkeeping(home, &record);
182 Ok(StopVerdict::Outcome(Box::new(StopOutcome::AlreadyGone {
183 record,
184 fate,
185 pid_file_reconciled,
186 })))
187 }
188 IncarnationProbe::DifferentIncarnation {
189 live_started_at_unix_secs,
190 exe,
191 } => Ok(StopVerdict::Refusal(StopRefusal::StaleIncarnation {
192 pid: record.pid,
193 recorded_started_at: record.started_at_unix_secs,
194 live_started_at: live_started_at_unix_secs,
195 live_exe: exe.map_or_else(String::new, |path| {
196 format!(" (running `{}`)", path.display())
197 }),
198 path: pid_file::pid_file_path(home),
199 })),
200 IncarnationProbe::Verified { .. } => match patience {
201 Ok(patience) => signal_and_wait(home, record, patience),
202 // The one arm that actually needs the window. Refusing HERE —
203 // after the probe, before the signal — keeps both truths: a home
204 // where the goal state already holds never sees this refusal,
205 // and a running server is never signalled without a governed
206 // wait to report on.
207 Err(unresolved) => Ok(StopVerdict::Refusal(StopRefusal::UnresolvedPatience {
208 pid: record.pid,
209 unresolved,
210 })),
211 },
212 }
213}
214
215/// What sending the termination signal to a verified incarnation did.
216#[derive(Clone, Copy, Debug, Eq, PartialEq)]
217pub enum TerminationSignal {
218 /// SIGTERM was delivered to the verified incarnation.
219 Sent,
220 /// The process exited between the incarnation probe and the signal, so
221 /// there was nobody to signal. The goal state already holds.
222 AlreadyGone,
223}
224
225/// Send SIGTERM to `record`'s incarnation.
226///
227/// The caller MUST have verified the incarnation first
228/// ([`incarnation::probe`]): this function signals the pid it is given, and
229/// signalling an unverified pid is exactly what the stop verb exists to
230/// prevent. Split out of [`signal_and_wait`] because `aion server restart`
231/// needs the SIGNAL without the wait — it starts the successor while the
232/// predecessor drains, which is what makes a restart a handover rather than
233/// an outage.
234///
235/// # Errors
236///
237/// Returns [`StopRefusal::SignalFailed`] when the pid cannot be expressed as
238/// a signal target or the kernel refuses the signal, and
239/// [`StopRefusal::UnsupportedPlatform`] on a build without POSIX signals.
240#[cfg(unix)]
241pub fn send_termination(record: &PidRecord) -> Result<TerminationSignal, StopRefusal> {
242 let Ok(pid_i32) = i32::try_from(record.pid) else {
243 return Err(StopRefusal::SignalFailed {
244 pid: record.pid,
245 message: "pid does not fit a signal target".to_owned(),
246 });
247 };
248 let target = nix::unistd::Pid::from_raw(pid_i32);
249 match nix::sys::signal::kill(target, nix::sys::signal::Signal::SIGTERM) {
250 Ok(()) => Ok(TerminationSignal::Sent),
251 // ESRCH means the process exited between the probe and the signal —
252 // the AlreadyGone face, one instant later.
253 Err(nix::errno::Errno::ESRCH) => Ok(TerminationSignal::AlreadyGone),
254 Err(errno) => Err(StopRefusal::SignalFailed {
255 pid: record.pid,
256 message: errno.to_string(),
257 }),
258 }
259}
260
261/// The no-POSIX-signals build's answer: this verb cannot act at all.
262///
263/// # Errors
264///
265/// Always returns [`StopRefusal::UnsupportedPlatform`].
266#[cfg(not(unix))]
267pub fn send_termination(_record: &PidRecord) -> Result<TerminationSignal, StopRefusal> {
268 Err(StopRefusal::UnsupportedPlatform)
269}
270
271/// SIGTERM the verified incarnation and wait for it to leave the process
272/// table, then read the death note's account.
273#[cfg(unix)]
274fn signal_and_wait(
275 home: &Path,
276 record: PidRecord,
277 patience: Duration,
278) -> Result<StopVerdict, ServerError> {
279 match send_termination(&record) {
280 Ok(TerminationSignal::Sent) => {}
281 Ok(TerminationSignal::AlreadyGone) => {
282 let (fate, pid_file_reconciled) = exit_bookkeeping(home, &record);
283 return Ok(StopVerdict::Outcome(Box::new(StopOutcome::AlreadyGone {
284 record,
285 fate,
286 pid_file_reconciled,
287 })));
288 }
289 Err(refusal) => return Ok(StopVerdict::Refusal(refusal)),
290 }
291 let started = Instant::now();
292 let cadence = wait_cadence(patience);
293 loop {
294 match incarnation::probe(&record) {
295 IncarnationProbe::Verified { .. } => {
296 let waited = started.elapsed();
297 if waited >= patience {
298 return Ok(StopVerdict::Outcome(Box::new(StopOutcome::StillDraining {
299 record,
300 waited,
301 })));
302 }
303 std::thread::sleep(cadence.min(patience.saturating_sub(waited)));
304 }
305 // Gone, or the pid already recycled onto something else —
306 // either way OUR incarnation exited.
307 IncarnationProbe::ProcessGone | IncarnationProbe::DifferentIncarnation { .. } => {
308 let (fate, pid_file_reconciled) = exit_bookkeeping(home, &record);
309 return Ok(StopVerdict::Outcome(Box::new(StopOutcome::Stopped {
310 record,
311 fate,
312 waited: started.elapsed(),
313 pid_file_reconciled,
314 })));
315 }
316 }
317 }
318}
319
320#[cfg(not(unix))]
321fn signal_and_wait(
322 _home: &Path,
323 _record: PidRecord,
324 _patience: Duration,
325) -> Result<StopVerdict, ServerError> {
326 Ok(StopVerdict::Refusal(StopRefusal::UnsupportedPlatform))
327}
328
329/// The exit-time bookkeeping beside a proven-exited server: the death
330/// note's account and the pid-file reconciliation.
331///
332/// Both sit AFTER the action layer's fact is established (the process left
333/// the table, or the signal found nobody), and either can fail on its own —
334/// a root-owned pid file under a mixed-ownership home, an unreadable note.
335/// Propagating those failures out of the verb destroyed the outcome: a stop
336/// that genuinely stopped the server reported "the verb could not complete"
337/// (exit 2) with no line saying the server is down, and a deploy script
338/// keying on that code escalated against a server that was already stopped.
339/// So each failure is carried in the outcome as its own layer's fact and
340/// rendered beside the action's — never allowed to stand for it.
341fn exit_bookkeeping(
342 home: &Path,
343 record: &PidRecord,
344) -> (Result<NoteFate, String>, Result<bool, String>) {
345 let fate = outcome::read_fate(home, record.pid).map_err(|error| error.to_string());
346 let pid_file_reconciled =
347 pid_file::remove_if_matches(home, record).map_err(|error| error.to_string());
348 (fate, pid_file_reconciled)
349}
350
351/// The wait's poll cadence, derived from the patience rather than invented:
352/// one two-hundredth of the window, clamped to [25ms, 250ms] — the same
353/// derive-from-the-governing-span discipline as the heartbeat sweeper's
354/// quarter-window cadence. This is detection latency, not policy; the
355/// operator's ruling is the patience itself.
356fn wait_cadence(patience: Duration) -> Duration {
357 (patience / 200).clamp(Duration::from_millis(25), Duration::from_millis(250))
358}
359
360#[cfg(test)]
361#[path = "stop_tests.rs"]
362mod tests;