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}