Skip to main content

aion_server/control/
incarnation.rs

1//! Incarnation identity: proving that the process wearing a recorded pid is
2//! the server that recorded it.
3//!
4//! A pid alone is a name the operating system recycles; signalling by bare
5//! pid is how a stop verb kills a stranger. The discriminator is the process
6//! START INSTANT, read through the same instrument ([`sysinfo`]) on both the
7//! write side (the server records its own start instant at boot) and the
8//! verify side (the stop/status verbs read the live process table), so the
9//! comparison is exact equality on one instrument's answer — never clock
10//! arithmetic between two instruments. A reused pid wears a different start
11//! instant; a dead pid wears none.
12
13use std::path::PathBuf;
14
15use sysinfo::{ProcessRefreshKind, ProcessStatus, ProcessesToUpdate, System};
16
17use super::pid_file::PidRecord;
18use crate::error::ServerError;
19
20/// What this process knows about itself at boot: the fields of the pid
21/// record that come from the process table and the binary on disk.
22#[derive(Clone, Debug)]
23pub struct SelfIdentity {
24    /// This process's id.
25    pub pid: u32,
26    /// This process's start instant in whole seconds since the Unix epoch,
27    /// as the process-table instrument reports it.
28    pub started_at_unix_secs: u64,
29    /// SHA-256 of the executable's bytes at the path the OS reports for
30    /// this process.
31    pub binary_sha256: String,
32}
33
34/// The live process table's answer about a recorded incarnation.
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub enum IncarnationProbe {
37    /// No process wears the recorded pid: the recorded server is gone.
38    ProcessGone,
39    /// A process wears the pid and its start instant MATCHES the record:
40    /// this is the recorded server, still running.
41    Verified {
42        /// Executable path the process table reports for the live process,
43        /// carried for the report; `None` when the table does not know it.
44        exe: Option<PathBuf>,
45    },
46    /// A process wears the pid but its start instant does not match the
47    /// record: the pid was recycled onto a different process after the
48    /// recorded server died. Never signal it.
49    DifferentIncarnation {
50        /// Start instant of the live process now wearing the pid.
51        live_started_at_unix_secs: u64,
52        /// Executable path of the live process, for the refusal report.
53        exe: Option<PathBuf>,
54    },
55}
56
57/// Read this process's own identity at boot.
58///
59/// # Errors
60///
61/// Returns [`ServerError::Incarnation`] when the process table does not
62/// answer for our own pid (which would leave the pid record unable to carry
63/// the start instant every later verification depends on), when the
64/// executable path cannot be resolved, or when the binary cannot be read for
65/// hashing.
66pub fn self_identity() -> Result<SelfIdentity, ServerError> {
67    let pid = std::process::id();
68    let started_at_unix_secs =
69        process_start_instant(pid).ok_or_else(|| ServerError::Incarnation {
70            message: format!(
71                "the process table has no entry for this process (pid {pid}); \
72                 cannot record a verifiable incarnation"
73            ),
74        })?;
75    let exe = std::env::current_exe().map_err(|io_error| ServerError::Incarnation {
76        message: format!("cannot resolve this process's executable path: {io_error}"),
77    })?;
78    let bytes = std::fs::read(&exe).map_err(|io_error| ServerError::Incarnation {
79        message: format!(
80            "cannot read this process's executable `{}` for content hashing: {io_error}",
81            exe.display()
82        ),
83    })?;
84    Ok(SelfIdentity {
85        pid,
86        started_at_unix_secs,
87        binary_sha256: sha256_hex(&bytes),
88    })
89}
90
91/// Probe the live process table for the incarnation a record names.
92#[must_use]
93pub fn probe(record: &PidRecord) -> IncarnationProbe {
94    let mut system = System::new();
95    let pid = sysinfo::Pid::from_u32(record.pid);
96    system.refresh_processes_specifics(
97        ProcessesToUpdate::Some(&[pid]),
98        true,
99        ProcessRefreshKind::nothing().with_exe(sysinfo::UpdateKind::Always),
100    );
101    let Some(process) = system.process(pid) else {
102        return IncarnationProbe::ProcessGone;
103    };
104    // A zombie is a tombstone, not an incarnation. The process has exited; what
105    // holds the pid is an unreaped exit status, and it answers a start-instant
106    // probe with the same instant the live process wore — so on the identity
107    // axis alone it is indistinguishable from the server still draining.
108    //
109    // The caller this reaches is the one that spawned the server and is
110    // therefore the parent owing it a reap: `aion server stop` waits out its
111    // entire patience and reports `still draining` (exit 1) against a server
112    // whose own log records a clean exit milliseconds after the signal.
113    //
114    // Platform note, because it is why this went unseen: Linux keeps the
115    // `/proc` entry and sysinfo reports `Zombie`, while macOS drops the process
116    // from the table sysinfo builds, answering `ProcessGone` already. The
117    // behaviour below is what macOS gives for free and Linux does not.
118    if process.status() == ProcessStatus::Zombie {
119        return IncarnationProbe::ProcessGone;
120    }
121    let live_started_at_unix_secs = process.start_time();
122    let exe = process.exe().map(std::path::Path::to_path_buf);
123    if live_started_at_unix_secs == record.started_at_unix_secs {
124        IncarnationProbe::Verified { exe }
125    } else {
126        IncarnationProbe::DifferentIncarnation {
127            live_started_at_unix_secs,
128            exe,
129        }
130    }
131}
132
133/// The start instant the process table reports for `pid`, or `None` when no
134/// such process exists.
135fn process_start_instant(pid: u32) -> Option<u64> {
136    let mut system = System::new();
137    let pid = sysinfo::Pid::from_u32(pid);
138    system.refresh_processes_specifics(
139        ProcessesToUpdate::Some(&[pid]),
140        true,
141        ProcessRefreshKind::nothing(),
142    );
143    system.process(pid).map(sysinfo::Process::start_time)
144}
145
146/// Lowercase hex SHA-256 of `bytes`.
147fn sha256_hex(bytes: &[u8]) -> String {
148    use sha2::Digest as _;
149    const HEX: &[u8; 16] = b"0123456789abcdef";
150    let digest = sha2::Sha256::digest(bytes);
151    let mut hex = String::with_capacity(digest.len() * 2);
152    for byte in digest {
153        hex.push(char::from(HEX[usize::from(byte >> 4)]));
154        hex.push(char::from(HEX[usize::from(byte & 0x0f)]));
155    }
156    hex
157}
158
159#[cfg(test)]
160mod tests {
161    use super::{IncarnationProbe, probe, process_start_instant, self_identity};
162    use crate::control::pid_file::PidRecord;
163
164    type TestResult = Result<(), Box<dyn std::error::Error>>;
165
166    fn record_with(
167        pid: u32,
168        started_at_unix_secs: u64,
169    ) -> Result<PidRecord, std::net::AddrParseError> {
170        Ok(PidRecord {
171            pid,
172            started_at_unix_secs,
173            binary_sha256: "0".repeat(64),
174            version: "0.0.0-test".to_owned(),
175            commit: "test".to_owned(),
176            state: crate::control::IncarnationState::Serving,
177            http_address: Some("127.0.0.1:0".parse()?),
178            grpc_address: Some("127.0.0.1:0".parse()?),
179            intended_http_address: None,
180            intended_grpc_address: None,
181            stage: None,
182            stage_detail: None,
183            stage_seq: 0,
184            stage_updated_at_unix_secs: 0,
185            drain_timeout_seconds: 30,
186        })
187    }
188
189    /// The green path proven against the ONLY process a test can trust to
190    /// exist: itself. Self-identity and a probe of the resulting record must
191    /// agree — the same-instrument property the module exists for.
192    #[test]
193    fn a_record_of_this_process_verifies() -> TestResult {
194        let me = self_identity()?;
195        let record = record_with(me.pid, me.started_at_unix_secs)?;
196        assert!(
197            matches!(probe(&record), IncarnationProbe::Verified { .. }),
198            "this process's own record must verify"
199        );
200        Ok(())
201    }
202
203    /// Red first: the SAME live pid with a different recorded start instant
204    /// is a different incarnation — the reused-pid face, which must never
205    /// verify and must carry the live instant for the refusal report.
206    #[test]
207    fn a_wrong_start_instant_on_a_live_pid_is_a_different_incarnation() -> TestResult {
208        let me = self_identity()?;
209        let record = record_with(me.pid, me.started_at_unix_secs.wrapping_add(7))?;
210        match probe(&record) {
211            IncarnationProbe::DifferentIncarnation {
212                live_started_at_unix_secs,
213                ..
214            } => {
215                assert_eq!(
216                    live_started_at_unix_secs, me.started_at_unix_secs,
217                    "the refusal must carry the LIVE process's instant"
218                );
219            }
220            other => {
221                return Err(format!(
222                    "a mismatched start instant must read as a different incarnation, got {other:?}"
223                )
224                .into());
225            }
226        }
227        Ok(())
228    }
229
230    /// Red first: a pid nothing wears is `ProcessGone`. Probing our own pid
231    /// space for a vacancy is inherently racy, so the specimen takes the
232    /// maximum pid-adjacent value a real allocator will not have handed out
233    /// during the test's lifetime — and asserts only the gone/alive axis.
234    #[test]
235    fn a_vacant_pid_is_process_gone() -> TestResult {
236        // Spawn a child and wait it to completion: its pid is proven
237        // vacated by the reap, not guessed.
238        let child = std::process::Command::new("true").spawn();
239        let mut child = match child {
240            Ok(child) => child,
241            Err(io_error) => return Err(format!("cannot spawn probe child: {io_error}").into()),
242        };
243        let pid = child.id();
244        child.wait()?;
245        let record = record_with(pid, 1)?;
246        // The reaped pid COULD have been recycled between wait and probe; a
247        // recycled wearer has a different start instant, so both honest
248        // answers are accepted — the specimen refuses only a false Verified.
249        match probe(&record) {
250            IncarnationProbe::ProcessGone | IncarnationProbe::DifferentIncarnation { .. } => Ok(()),
251            IncarnationProbe::Verified { .. } => {
252                Err("a reaped child's record must never verify".into())
253            }
254        }
255    }
256
257    /// Red first: a child that has EXITED but has not been REAPED is gone.
258    ///
259    /// An unreaped child keeps its entry in the process table — `Z` on Linux,
260    /// `SZOMB` on macOS — and that entry answers a start-instant probe with the
261    /// same instant it always wore. So a probe that asks only "does something
262    /// wear this pid, and does its start instant match" says VERIFIED about a
263    /// process that has already exited.
264    ///
265    /// The caller this hurts is the one that spawned the server and therefore
266    /// holds the unreaped status: `aion server stop` waits out its whole
267    /// patience and reports `still draining` against a server whose own log
268    /// says it exited milliseconds after the signal.
269    /// `a_vacant_pid_is_process_gone` cannot see it — that specimen reaps its
270    /// child, and the reap is the one act that clears the tombstone.
271    #[test]
272    fn an_exited_but_unreaped_child_is_process_gone() -> TestResult {
273        use std::process::{Command, Stdio};
274        use std::time::{Duration, Instant};
275
276        // `read` returns the instant its stdin closes, so this child's lifetime
277        // ends exactly when we choose — no sleep for the probe to race.
278        let mut child = Command::new("sh")
279            .arg("-c")
280            .arg("read _")
281            .stdin(Stdio::piped())
282            .spawn()?;
283        let pid = child.id();
284        // Read the instant while the child is unambiguously alive and blocked
285        // on stdin: this is the record `aion server stop` holds.
286        let Some(started_at_unix_secs) = process_start_instant(pid) else {
287            return Err(format!("probe child {pid} left the table before it was read").into());
288        };
289        let record = record_with(pid, started_at_unix_secs)?;
290        // The positive control, and it is load-bearing: without it a platform
291        // whose process table never showed this child at all would pass the
292        // assertion below for the wrong reason.
293        assert!(
294            matches!(probe(&record), IncarnationProbe::Verified { .. }),
295            "the specimen only means something if the LIVE child verifies first"
296        );
297
298        // Close stdin and do not reap: the child exits, its tombstone remains.
299        drop(child.stdin.take());
300
301        let deadline = Instant::now() + Duration::from_secs(10);
302        let verdict = loop {
303            let verdict = probe(&record);
304            if !matches!(verdict, IncarnationProbe::Verified { .. }) || Instant::now() >= deadline {
305                break verdict;
306            }
307            std::thread::sleep(Duration::from_millis(20));
308        };
309        // Reaped only now: every line above is about the UNREAPED state, which
310        // is the whole specimen.
311        let reaped = child.wait();
312        match verdict {
313            IncarnationProbe::ProcessGone | IncarnationProbe::DifferentIncarnation { .. } => {
314                reaped?;
315                Ok(())
316            }
317            IncarnationProbe::Verified { .. } => Err(format!(
318                "an exited, unreaped child still read as a live incarnation after 10s of \
319                 probing; the reap that followed reported {reaped:?}"
320            )
321            .into()),
322        }
323    }
324}