Skip to main content

robonix_cli/
launch.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2//
3// Cross-component package bring-up primitives.
4//
5// `rbnx boot` and Soma both need to: spawn a package process, wait for it
6// to register a CapabilityProvider on Atlas, find that
7// provider's `*/driver` capability, and drive `Driver(CMD_INIT)` /
8// `Driver(CMD_ACTIVATE)` over gRPC. Historically this lived entirely
9// inside `cmd::deploy.rs` and was tangled with terminal UI (`output::*`).
10//
11// This module exposes the **pure** parts of that flow as crate-public
12// helpers so Soma (which orchestrates primitive/skill bring-up at
13// runtime) can do exactly the same lifecycle dance without copy-pasting
14// the timeout / channel-hygiene / FSM-step logic. The UI side stays
15// inside `cmd::deploy` — Soma logs via `scribe` instead.
16//
17// What's here vs what's not:
18//   * `wait_for_registration_core` — atlas-side poll loop, no UI. Accepts
19//     a `before` snapshot so callers can reason about "which provider
20//     appeared because of this spawn." Returns the provider plus every
21//     lifecycle Driver observed during declaration settling.
22//   * `call_driver_cmd` — one Driver(cmd) RPC against a freshly-connected
23//     channel, no UI. Returns the response's `state` string.
24//   * `CMD_*` constants and the `DRIVER_*_TIMEOUT` values — single source
25//     of truth so rbnx + soma agree on the deadlines.
26//   * `contract_id_to_service_name` — mirrors codegen's PascalCase rule,
27//     needed by both the rbnx CLI and Soma to assemble the
28//     `/robonix.contracts.<Name>/Driver` gRPC path.
29//
30// What's NOT here:
31//   * `spawn_package` — depends on `cmd::install` / `cmd::run` / the
32//     manifest-driven start.sh wrapper, which is a heavy subtree we
33//     don't want to drag into Soma. Soma spawns packages through its
34//     own `ProcessManager` (existing) and only borrows the *post-spawn*
35//     bookkeeping from here.
36//   * Terminal output (spinners, ok/fail lines) — these are
37//     `cmd::deploy::output::*` and are deliberately left out so this
38//     module compiles in any caller that doesn't want a TTY.
39//
40// Stability: this is an *internal* crate boundary between rbnx-cli and
41// Soma; both are workspace members and bumped together. The API is
42// free to evolve as long as we keep the two callers in sync.
43
44use crate::pb::lifecycle::{DriverRequest, DriverResponse};
45use anyhow::{Context, Result};
46use robonix_atlas::client::AtlasClient;
47use robonix_atlas::pb as atlas_pb;
48use robonix_scribe::{info, warn};
49use serde::{Deserialize, Serialize};
50use std::collections::HashMap;
51use std::path::PathBuf;
52use std::process::Stdio;
53use std::time::{Duration, Instant};
54use tokio::process::Command;
55use tonic::Request;
56use tonic::transport::Endpoint;
57
58// ── Driver.srv command discriminators (mirrors capabilities/lib/lifecycle/srv/Driver.srv) ──
59
60pub const CMD_INIT: u32 = 0;
61pub const CMD_ACTIVATE: u32 = 1;
62#[allow(dead_code)]
63pub const CMD_DEACTIVATE: u32 = 2;
64#[allow(dead_code)]
65pub const CMD_SHUTDOWN: u32 = 3;
66/// Max time we'll wait for a freshly spawned package to register its
67/// driver capability with atlas before giving up.
68pub const DRIVER_REGISTER_TIMEOUT: Duration = Duration::from_secs(60);
69
70/// Max time we'll wait for one `Driver(CMD_*)` RPC to return. 90s gives
71/// generous slack for slow-warming sensors (Webots's camera can take
72/// 30–50s on cold boot). Primitive driver-side waits should stay
73/// strictly below this so they own their own timeout semantics rather
74/// than racing the CLI deadline.
75pub const DRIVER_INIT_TIMEOUT: Duration = Duration::from_secs(90);
76
77/// Consumer id used by both rbnx-cli (deploy) and Soma when opening
78/// channels to call `Driver(...)` during boot. Atlas just records the
79/// edge for bookkeeping — the id is only ever read in `inspect_atlas`
80/// output, so we keep one stable string instead of inventing per-caller
81/// ones (would only add noise).
82pub const BOOT_CONSUMER_ID: &str = "rbnx-cli/deploy";
83
84/// Runtime-owned process and lifecycle metadata for one package wrapper.
85///
86/// Both `rbnx boot` and Soma spawn long-lived `rbnx start -p` wrappers.
87/// Shutdown must be ordered the same way in both callers: provider lifecycle,
88/// package stop hook, process-group fallback. Keeping the record here prevents
89/// Soma and rbnx from growing divergent cleanup semantics.
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct PackageRuntimeRecord {
92    pub name: String,
93    pub kind: String,
94    pub pid: u32,
95    pub pgid: u32,
96    #[serde(default)]
97    pub provider_id: Option<String>,
98    #[serde(default)]
99    pub driver_contract: Option<String>,
100    #[serde(default)]
101    pub config_json: Option<String>,
102    #[serde(default)]
103    pub package_dir: Option<String>,
104    #[serde(default)]
105    pub stop: Option<String>,
106}
107
108/// Stop one package runtime using the canonical shutdown order:
109/// Driver(CMD_SHUTDOWN) -> manifest stop -> TERM PGID -> KILL PGID.
110pub async fn shutdown_package_runtime(
111    atlas_endpoint: Option<&str>,
112    record: &PackageRuntimeRecord,
113    term_wait: Duration,
114) {
115    let _ = shutdown_package_runtime_checked(atlas_endpoint, record, term_wait, None).await;
116}
117
118/// Checked variant used by persisted boot-state teardown. A boot id is
119/// inherited by every wrapper and provider through `RBNX_BOOT_ID`; requiring a
120/// matching process in the recorded PGID prevents a stale state file from
121/// killing an unrelated process group after PID/PGID reuse.
122pub async fn shutdown_package_runtime_checked(
123    atlas_endpoint: Option<&str>,
124    record: &PackageRuntimeRecord,
125    term_wait: Duration,
126    boot_id: Option<&str>,
127) -> bool {
128    if let Some(boot_id) = boot_id {
129        if !process_group_has_members(record.pgid).await {
130            return true;
131        }
132        if !process_group_has_boot_id(record.pgid, boot_id).await {
133            warn!(
134                "refusing to stop {} pgid {}: no process carries RBNX_BOOT_ID={}",
135                record.name, record.pgid, boot_id
136            );
137            return false;
138        }
139    }
140
141    if let (Some(endpoint), Some(provider_id), Some(driver_contract)) = (
142        atlas_endpoint,
143        record.provider_id.as_deref(),
144        record.driver_contract.as_deref(),
145    ) {
146        let normalized = if endpoint.starts_with("http") {
147            endpoint.to_string()
148        } else {
149            format!("http://{endpoint}")
150        };
151        match AtlasClient::connect(&normalized).await {
152            Ok(mut atlas) => {
153                let config_json = record.config_json.clone().unwrap_or_else(|| "{}".into());
154                match call_driver_cmd(
155                    &mut atlas,
156                    provider_id,
157                    driver_contract,
158                    CMD_SHUTDOWN,
159                    config_json,
160                    &record.name,
161                )
162                .await
163                {
164                    Ok(state) => info!(
165                        "shutdown {} via Driver(CMD_SHUTDOWN): {}",
166                        record.name, state
167                    ),
168                    Err(e) => warn!(
169                        "shutdown {} Driver(CMD_SHUTDOWN) failed: {e:#}",
170                        record.name
171                    ),
172                }
173            }
174            Err(e) => warn!(
175                "shutdown {} could not connect atlas at {}: {e:#}",
176                record.name, normalized
177            ),
178        }
179    }
180
181    if let Some(stop) = record
182        .stop
183        .as_deref()
184        .map(str::trim)
185        .filter(|s| !s.is_empty())
186    {
187        run_package_stop_hook(record, stop, atlas_endpoint).await;
188    }
189
190    if !terminate_process_group_guarded(record.pgid, term_wait, boot_id).await {
191        warn!(
192            "refusing to finish shutdown of {} pgid {}: boot ownership changed before signaling",
193            record.name, record.pgid
194        );
195        return false;
196    }
197    !process_group_has_members(record.pgid).await
198}
199
200/// Prove that at least one process in a recorded group still carries the exact
201/// inherited boot id. Enumeration or environment-inspection failure is an
202/// ownership failure so checked teardown cannot signal a stale numeric PGID.
203async fn process_group_has_boot_id(pgid: u32, boot_id: &str) -> bool {
204    let output = match Command::new("pgrep")
205        .arg("-g")
206        .arg(pgid.to_string())
207        .stdout(Stdio::piped())
208        .stderr(Stdio::null())
209        .output()
210        .await
211    {
212        Ok(output) if output.status.success() => output,
213        _ => return false,
214    };
215    String::from_utf8_lossy(&output.stdout)
216        .lines()
217        .filter_map(|line| line.trim().parse::<u32>().ok())
218        .any(|pid| process_has_boot_id(pid, boot_id))
219}
220
221/// Prove that one process carries the exact boot ownership marker inherited
222/// from its `rbnx boot` parent. This is also the portable identity fallback for
223/// the detached watchdog on targets (notably macOS) without Linux procfs start
224/// ticks. Inspection failure is a mismatch so stale PIDs are never trusted.
225pub fn process_has_boot_id(pid: u32, boot_id: &str) -> bool {
226    if boot_id.is_empty() {
227        return false;
228    }
229    let expected = format!("RBNX_BOOT_ID={boot_id}");
230    process_has_environment_entry(pid, expected.as_bytes())
231}
232
233/// Linux exposes the immutable exec environment as NUL-delimited procfs
234/// bytes. Failure to read it means ownership is unproven, so stale-PGID
235/// protection refuses teardown rather than guessing.
236#[cfg(target_os = "linux")]
237fn process_has_environment_entry(pid: u32, expected: &[u8]) -> bool {
238    std::fs::read(format!("/proc/{pid}/environ"))
239        .ok()
240        .is_some_and(|env| env.split(|byte| *byte == 0).any(|entry| entry == expected))
241}
242
243/// Parse a Darwin `KERN_PROCARGS2` buffer and look for one exact environment
244/// entry after the executable path and argc-counted argv strings. `None`
245/// denotes malformed/incomplete kernel data rather than a proven mismatch.
246#[cfg(any(target_os = "macos", test))]
247fn macos_procargs_has_environment_entry(buffer: &[u8], expected: &[u8]) -> Option<bool> {
248    let argc_bytes: [u8; std::mem::size_of::<i32>()] =
249        buffer.get(..std::mem::size_of::<i32>())?.try_into().ok()?;
250    let argc = usize::try_from(i32::from_ne_bytes(argc_bytes)).ok()?;
251    let mut cursor = std::mem::size_of::<i32>();
252
253    cursor += buffer.get(cursor..)?.iter().position(|byte| *byte == 0)? + 1;
254    while buffer.get(cursor) == Some(&0) {
255        cursor += 1;
256    }
257    for _ in 0..argc {
258        cursor += buffer.get(cursor..)?.iter().position(|byte| *byte == 0)? + 1;
259    }
260
261    while cursor < buffer.len() {
262        while buffer.get(cursor) == Some(&0) {
263            cursor += 1;
264        }
265        if cursor == buffer.len() {
266            break;
267        }
268        let tail = buffer.get(cursor..)?;
269        let end = tail.iter().position(|byte| *byte == 0)?;
270        if &tail[..end] == expected {
271            return Some(true);
272        }
273        cursor += end + 1;
274    }
275    Some(false)
276}
277
278/// macOS has no procfs. Read `KERN_PROCARGS2` directly so persisted shutdown
279/// can prove that a live process group belongs to its recorded boot without
280/// parsing human-formatted `ps` output.
281#[cfg(target_os = "macos")]
282fn process_has_environment_entry(pid: u32, expected: &[u8]) -> bool {
283    use std::ptr::null_mut;
284
285    let Ok(pid) = i32::try_from(pid) else {
286        return false;
287    };
288    let mut mib = [nix::libc::CTL_KERN, nix::libc::KERN_PROCARGS2, pid];
289    let mut buffer_size = 0;
290    // SAFETY: the MIB is initialized and its length is correct. A null output
291    // pointer asks sysctl for the required byte count only.
292    if unsafe {
293        nix::libc::sysctl(
294            mib.as_mut_ptr(),
295            mib.len() as u32,
296            null_mut(),
297            &mut buffer_size,
298            null_mut(),
299            0,
300        )
301    } != 0
302        || buffer_size == 0
303    {
304        return false;
305    }
306
307    let mut buffer = vec![0_u8; buffer_size];
308    // SAFETY: `buffer` owns `buffer_size` writable bytes and sysctl receives
309    // that exact capacity. The kernel updates `buffer_size` to bytes written.
310    if unsafe {
311        nix::libc::sysctl(
312            mib.as_mut_ptr(),
313            mib.len() as u32,
314            buffer.as_mut_ptr().cast(),
315            &mut buffer_size,
316            null_mut(),
317            0,
318        )
319    } != 0
320        || buffer_size > buffer.len()
321    {
322        return false;
323    }
324    buffer.truncate(buffer_size);
325    macos_procargs_has_environment_entry(&buffer, expected) == Some(true)
326}
327
328/// Unknown targets cannot prove boot ownership yet, so checked teardown must
329/// refuse the process group instead of risking a PID/PGID-reuse kill.
330#[cfg(not(any(target_os = "linux", target_os = "macos")))]
331fn process_has_environment_entry(_pid: u32, _expected: &[u8]) -> bool {
332    false
333}
334
335/// Linux `/proc/<pid>/stat` field 22. The value is stable for the lifetime of
336/// a process and lets the watchdog distinguish its boot parent from a reused
337/// PID. Returns `None` when procfs is unavailable or the process exited.
338#[cfg(target_os = "linux")]
339pub fn proc_start_time_ticks(pid: u32) -> Option<u64> {
340    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
341    let after_comm = stat.get(stat.rfind(')')? + 1..)?.trim_start();
342    after_comm.split_whitespace().nth(19)?.parse().ok()
343}
344
345/// Darwin process start time from `PROC_PIDTBSDINFO`, normalized to
346/// microseconds since the Unix epoch. Unlike `/proc`, this is available on a
347/// stock macOS host and remains stable across the process lifetime.
348#[cfg(target_os = "macos")]
349pub fn proc_start_time_ticks(pid: u32) -> Option<u64> {
350    let pid = i32::try_from(pid).ok()?;
351    let mut info = std::mem::MaybeUninit::<nix::libc::proc_bsdinfo>::zeroed();
352    let info_size = std::mem::size_of::<nix::libc::proc_bsdinfo>();
353    // SAFETY: `info` points to `info_size` writable bytes of the exact struct
354    // requested by PROC_PIDTBSDINFO. A full-size return initializes it.
355    let written = unsafe {
356        nix::libc::proc_pidinfo(
357            pid,
358            nix::libc::PROC_PIDTBSDINFO,
359            0,
360            info.as_mut_ptr().cast(),
361            i32::try_from(info_size).ok()?,
362        )
363    };
364    if usize::try_from(written).ok()? != info_size {
365        return None;
366    }
367    // SAFETY: the kernel reported that it filled the complete structure.
368    let info = unsafe { info.assume_init() };
369    info.pbi_start_tvsec
370        .checked_mul(1_000_000)?
371        .checked_add(info.pbi_start_tvusec)
372}
373
374#[cfg(not(any(target_os = "linux", target_os = "macos")))]
375pub fn proc_start_time_ticks(_pid: u32) -> Option<u64> {
376    None
377}
378
379async fn run_package_stop_hook(
380    record: &PackageRuntimeRecord,
381    stop: &str,
382    atlas_endpoint: Option<&str>,
383) {
384    let Some(package_dir) = record.package_dir.as_deref() else {
385        warn!("shutdown {} has stop hook but no package_dir", record.name);
386        return;
387    };
388    let mut cmd = Command::new("bash");
389    cmd.arg("-c")
390        .arg(stop)
391        .current_dir(PathBuf::from(package_dir))
392        .stdin(Stdio::null())
393        .stdout(Stdio::null())
394        .stderr(Stdio::null());
395    if let Some(endpoint) = atlas_endpoint {
396        cmd.env("ROBONIX_ATLAS", endpoint);
397    }
398    match tokio::time::timeout(Duration::from_secs(20), cmd.status()).await {
399        Ok(Ok(status)) if status.success() => {
400            info!("shutdown {} manifest stop hook completed", record.name);
401        }
402        Ok(Ok(status)) => warn!(
403            "shutdown {} manifest stop hook exited with {}",
404            record.name, status
405        ),
406        Ok(Err(e)) => warn!(
407            "shutdown {} manifest stop hook failed to start: {e:#}",
408            record.name
409        ),
410        Err(_) => warn!(
411            "shutdown {} manifest stop hook timed out after 20s",
412            record.name
413        ),
414    }
415}
416
417/// TERM one wrapper process group, wait until it exits, then KILL stragglers.
418pub async fn terminate_process_group(pgid: u32, term_wait: Duration) {
419    let _ = terminate_process_group_guarded(pgid, term_wait, None).await;
420}
421
422/// Signal one process group while optionally preserving its recorded boot
423/// identity across slow Driver/stop cleanup and the TERM grace period. Returns
424/// false only when a live group no longer proves the expected boot id.
425async fn terminate_process_group_guarded(
426    pgid: u32,
427    term_wait: Duration,
428    boot_id: Option<&str>,
429) -> bool {
430    use nix::sys::signal::{Signal, killpg};
431    use nix::unistd::Pid;
432    if let Some(boot_id) = boot_id {
433        if !process_group_has_members(pgid).await {
434            return true;
435        }
436        if !process_group_has_boot_id(pgid, boot_id).await {
437            // The group may have finished between the liveness and ownership
438            // probes (common after Driver(SHUTDOWN)). Only refuse if a live
439            // group still exists after the failed identity lookup.
440            return !process_group_has_members(pgid).await;
441        }
442    }
443
444    let pgid_raw = pgid;
445    let pgid = Pid::from_raw(pgid as i32);
446    match killpg(pgid, Signal::SIGTERM) {
447        Ok(()) => {}
448        Err(nix::errno::Errno::ESRCH) => return true,
449        Err(e) => warn!("SIGTERM pgid {} failed: {e}", pgid),
450    }
451
452    let deadline = Instant::now() + term_wait;
453    while Instant::now() < deadline {
454        if !process_group_has_members(pgid_raw).await {
455            return true;
456        }
457        tokio::time::sleep(Duration::from_millis(200)).await;
458    }
459
460    if let Some(boot_id) = boot_id {
461        if !process_group_has_members(pgid_raw).await {
462            return true;
463        }
464        if !process_group_has_boot_id(pgid_raw, boot_id).await {
465            return !process_group_has_members(pgid_raw).await;
466        }
467    }
468    let _ = killpg(pgid, Signal::SIGKILL);
469    true
470}
471
472/// Return whether a group contains at least one non-zombie process. Tooling or
473/// metadata failures count as non-empty so shutdown never skips Driver/TERM or
474/// removes persisted state based on inconclusive process inspection.
475async fn process_group_has_members(pgid: u32) -> bool {
476    let output = match Command::new("pgrep")
477        .arg("-g")
478        .arg(pgid.to_string())
479        .stdout(Stdio::piped())
480        .stderr(Stdio::null())
481        .output()
482        .await
483    {
484        Ok(output) => output,
485        Err(_) => return true,
486    };
487    if !output.status.success() {
488        // pgrep reserves exit 1 for "no processes matched". Treat every
489        // execution/tooling failure conservatively so shutdown cannot erase
490        // state or skip cleanup merely because process inspection failed.
491        return output.status.code() != Some(1);
492    }
493
494    let stdout = String::from_utf8_lossy(&output.stdout);
495    for line in stdout.lines() {
496        let Ok(pid) = line.trim().parse::<u32>() else {
497            return true;
498        };
499        if process_is_not_zombie(pid).await {
500            return true;
501        }
502    }
503    false
504}
505
506/// Linux reports zombie state through procfs. Missing or malformed process
507/// metadata is treated as live: the caller must not skip Driver/TERM cleanup
508/// based on an inconclusive inspection.
509#[cfg(target_os = "linux")]
510async fn process_is_not_zombie(pid: u32) -> bool {
511    let status_path = format!("/proc/{pid}/status");
512    let Ok(status) = std::fs::read_to_string(status_path) else {
513        return true;
514    };
515    linux_proc_status_is_not_zombie(&status)
516}
517
518#[cfg(any(target_os = "linux", test))]
519/// Parse Linux's `State:` record, treating absent/malformed state as live.
520fn linux_proc_status_is_not_zombie(status: &str) -> bool {
521    !status.lines().any(|line| {
522        line.strip_prefix("State:")
523            .is_some_and(|state| state.trim_start().starts_with('Z'))
524    })
525}
526
527/// Parse the one-letter Darwin `ps` state field. Missing output is
528/// inconclusive; `Z` (with optional suffix flags) is the only zombie state.
529#[cfg(any(target_os = "macos", test))]
530fn macos_ps_state_is_not_zombie(status: &str) -> Option<bool> {
531    status
532        .lines()
533        .find_map(|line| line.trim().chars().next())
534        .map(|state| state != 'Z')
535}
536
537/// Darwin has no procfs, and libproc omits already-exited zombie processes.
538/// Query the machine-readable `ps` state field; inspection failures count as
539/// live so cleanup is never skipped on inconclusive evidence.
540#[cfg(target_os = "macos")]
541async fn process_is_not_zombie(pid: u32) -> bool {
542    let output = match Command::new("ps")
543        .arg("-o")
544        .arg("state=")
545        .arg("-p")
546        .arg(pid.to_string())
547        .stdout(Stdio::piped())
548        .stderr(Stdio::null())
549        .output()
550        .await
551    {
552        Ok(output) => output,
553        Err(_) => return true,
554    };
555    if output.status.success() {
556        return macos_ps_state_is_not_zombie(&String::from_utf8_lossy(&output.stdout))
557            .unwrap_or(true);
558    }
559    true
560}
561
562/// Other Unix targets still get safe shutdown semantics even when no native
563/// zombie-state backend has been added: a PID reported by `pgrep` counts as a
564/// live member instead of risking a false empty-group result.
565#[cfg(not(any(target_os = "linux", target_os = "macos")))]
566async fn process_is_not_zombie(_pid: u32) -> bool {
567    true
568}
569
570// ── helpers ────────────────────────────────────────────────────────────────
571
572/// Mirrors `robonix_codegen::contract_gen::contract_id_to_service_name`.
573/// Uniform PascalCase: `robonix/primitive/chassis/driver` →
574/// `RobonixPrimitiveChassisDriver`. No prefix stripping. Full gRPC
575/// service path: `/robonix.contracts.<this>/Driver`.
576///
577/// Lifted out of `cmd::deploy` so Soma can build the same gRPC URLs
578/// without re-implementing the rule (which would drift).
579pub fn contract_id_to_service_name(id: &str) -> String {
580    id.split('/')
581        .filter(|x| !x.is_empty())
582        .map(|seg| {
583            seg.split('_')
584                .filter(|p| !p.is_empty())
585                .map(|p| {
586                    let mut c = p.chars();
587                    match c.next() {
588                        None => String::new(),
589                        Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
590                    }
591                })
592                .collect::<String>()
593        })
594        .collect::<String>()
595}
596
597/// Snapshot provider ids and their opaque registration generations. Stable
598/// package ids may be taken over on restart, so ids alone cannot correlate a
599/// spawn; `registration_id` changes on every successful Atlas Register but not
600/// on heartbeat/state/capability updates.
601pub type ProviderRegistrationSnapshot = HashMap<String, String>;
602
603pub async fn snapshot_provider_ids(
604    atlas: &mut AtlasClient,
605) -> Result<ProviderRegistrationSnapshot> {
606    Ok(atlas
607        .query_capabilities("", "", atlas_pb::Transport::Unspecified)
608        .await
609        .context("pre-spawn atlas snapshot")?
610        .into_iter()
611        .map(|provider| (provider.id, provider.registration_id))
612        .collect())
613}
614
615/// True when `provider` did not exist in the snapshot or has since taken over
616/// its stable id with a new Atlas registration generation.
617pub fn is_new_provider_registration(
618    provider: &atlas_pb::CapabilityProvider,
619    before: &ProviderRegistrationSnapshot,
620) -> bool {
621    before
622        .get(&provider.id)
623        .is_none_or(|registration_id| registration_id != &provider.registration_id)
624}
625
626/// True when this is a fresh registration for the exact deployment instance.
627///
628/// Other providers may register concurrently while a package is starting.
629/// They are unrelated and must never receive this instance's lifecycle config.
630pub fn is_expected_provider_registration(
631    provider: &atlas_pb::CapabilityProvider,
632    before: &ProviderRegistrationSnapshot,
633    expected_provider_id: &str,
634) -> bool {
635    provider.id == expected_provider_id
636        && !before.contains_key(expected_provider_id)
637        && is_new_provider_registration(provider, before)
638}
639
640/// Outcome of `wait_for_registration_core`:
641///   * `provider_id` — the new provider that appeared after `before`.
642///   * `provider_kind` — Atlas provider kind, used to keep skills INACTIVE.
643///   * `provider_namespace` — primary namespace used to validate the exact
644///     namespace Driver allowed for old-artifact fallback.
645///   * `driver_contracts` — every distinct `*/driver` gRPC capability
646///     observed after the declaration settle window. The launch owner must
647///     validate this list against the exact selected package manifest before
648///     sending lifecycle commands.
649#[derive(Debug, Clone)]
650pub struct RegistrationOutcome {
651    pub provider_id: String,
652    pub provider_kind: i32,
653    pub provider_namespace: String,
654    pub registration_id: String,
655    pub driver_contracts: Vec<String>,
656}
657
658/// Resolve the single runtime Driver selected by a package manifest.
659///
660/// Omitted and explicit shared selections accept only the shared Driver. An
661/// exact legacy `<provider namespace>/driver` selection may opt into the
662/// forward-compatible shared runtime Driver while manifests are migrated. A
663/// missing Driver is always fatal, as are multiple, unrelated, or reverse
664/// shared-to-legacy substitutions.
665pub fn resolve_runtime_driver_contract(
666    provider_id: &str,
667    provider_namespace: &str,
668    expected: &str,
669    observed: &[String],
670    allow_shared_driver_upgrade: bool,
671) -> Result<String> {
672    const SHARED: &str = "robonix/lifecycle/driver";
673    let namespace = provider_namespace.trim_matches('/');
674    let exact_legacy = if namespace.is_empty() {
675        (expected != SHARED && expected.ends_with("/driver")).then(|| expected.to_string())
676    } else {
677        Some(format!("{namespace}/driver"))
678    };
679    let expected_is_supported = expected == SHARED || exact_legacy.as_deref() == Some(expected);
680    if !expected_is_supported {
681        anyhow::bail!(
682            "provider '{provider_id}' selected unsupported lifecycle Driver '{expected}'; expected '{SHARED}' or exact namespace Driver '{}'",
683            exact_legacy
684                .as_deref()
685                .unwrap_or("<provider namespace>/driver")
686        );
687    }
688    if observed.len() > 1 {
689        anyhow::bail!(
690            "provider '{provider_id}' registered multiple lifecycle Drivers: {}; expected exactly '{expected}'",
691            observed.join(", ")
692        );
693    }
694    if observed.is_empty() {
695        anyhow::bail!(
696            "provider '{provider_id}' registered without a lifecycle Driver; expected exactly one Driver: '{expected}'{}; rebuild the package with `rbnx build` to refresh generated stubs or migrate the manifest",
697            if allow_shared_driver_upgrade && exact_legacy.as_deref() == Some(expected) {
698                format!(" or upgraded shared '{SHARED}'")
699            } else {
700                String::new()
701            }
702        );
703    }
704    if observed[0] == expected {
705        return Ok(observed[0].clone());
706    }
707    if allow_shared_driver_upgrade
708        && exact_legacy.as_deref() == Some(expected)
709        && observed[0] == SHARED
710    {
711        return Ok(observed[0].clone());
712    }
713    anyhow::bail!(
714        "provider '{provider_id}' registered lifecycle Driver '{}', expected '{expected}' from the selected package manifest",
715        observed[0]
716    )
717}
718
719/// Enforce that Atlas exposes exactly the lifecycle Driver selected by the
720/// package manifest. This check runs before INIT, so config can never be sent
721/// to a missing, mismatched, or ambiguous runtime service.
722pub fn validate_runtime_driver_contracts(
723    provider_id: &str,
724    expected: &str,
725    observed: &[String],
726) -> Result<String> {
727    resolve_runtime_driver_contract(provider_id, "", expected, observed, false)
728}
729
730fn runtime_driver_contracts(provider: &atlas_pb::CapabilityProvider) -> Vec<String> {
731    let mut contracts = provider
732        .capabilities
733        .iter()
734        .filter(|capability| {
735            capability.transport == atlas_pb::Transport::Grpc as i32
736                && capability.contract_id.ends_with("/driver")
737        })
738        .map(|capability| capability.contract_id.clone())
739        .collect::<Vec<_>>();
740    contracts.sort();
741    contracts.dedup();
742    contracts
743}
744
745/// Poll atlas until the exact deployment instance has a new registration,
746/// then briefly settle to see if it also declares a `*/driver` gRPC
747/// capability.
748///
749/// No terminal UI — pure RPC + sleep loop. Callers that want spinner /
750/// boot_progress output (rbnx CLI) wrap this with their own decorator.
751/// Soma calls it directly and logs through scribe.
752///
753/// Errors:
754///   * timeout (no new provider before `DRIVER_REGISTER_TIMEOUT`),
755///   * the expected provider vanished between matching and settling (crashed
756///     or was evicted by heartbeat lapse).
757///
758/// `who` is a free-form label included in error messages so the caller
759/// can tell which spawn this poll belonged to.
760pub async fn wait_for_registration_core(
761    atlas: &mut AtlasClient,
762    before: &ProviderRegistrationSnapshot,
763    expected_provider_id: &str,
764    who: &str,
765) -> Result<RegistrationOutcome> {
766    if before.contains_key(expected_provider_id) {
767        anyhow::bail!(
768            "[{who}] deployment instance '{expected_provider_id}' was already registered before spawn"
769        );
770    }
771
772    // Poll cadence is decoupled from any spinner refresh: we just sleep
773    // 200ms between Atlas queries. That's the same cadence the CLI used
774    // (one query per 2 spinner ticks at 100ms/tick).
775    const POLL_INTERVAL: Duration = Duration::from_millis(200);
776    let started = Instant::now();
777    let deadline = started + DRIVER_REGISTER_TIMEOUT;
778    loop {
779        let providers = atlas
780            .query_capabilities("", "", atlas_pb::Transport::Unspecified)
781            .await
782            .with_context(|| format!("[{who}] poll atlas"))?;
783        let matched = providers.iter().find(|provider| {
784            is_expected_provider_registration(provider, before, expected_provider_id)
785        });
786        if let Some(first) = matched {
787            let provider_id = first.id.clone();
788            let registration_id = first.registration_id.clone();
789            // RegisterPrimitive/Service/Skill and DeclareCapability are
790            // two separate RPCs from the package side — Register lands
791            // first, declares follow within a few hundred ms. Give it
792            // up to a 1s settle window so we don't false-fire a missing
793            // Driver error on a fast poll. Capped by the outer
794            // deadline so we never exceed the user-facing timeout.
795            let settle_until = Instant::now()
796                .checked_add(Duration::from_millis(1000))
797                .map(|t| t.min(deadline))
798                .unwrap_or(deadline);
799            let mut current: atlas_pb::CapabilityProvider = (*first).clone();
800            // Always consume the whole settle window. RegisterProvider and
801            // DeclareCapability are separate RPCs; returning after the first
802            // Driver would hide a second shared/legacy declaration and make
803            // lifecycle ownership ambiguous.
804            loop {
805                if Instant::now() >= settle_until {
806                    break;
807                }
808                tokio::time::sleep(Duration::from_millis(100)).await;
809                let providers = atlas
810                    .query_capabilities(&provider_id, "", atlas_pb::Transport::Unspecified)
811                    .await
812                    .with_context(|| format!("[{who}] re-poll for driver"))?;
813                match providers.into_iter().find(|p| p.id == provider_id) {
814                    Some(p) if p.registration_id == registration_id => current = p,
815                    Some(p) => {
816                        anyhow::bail!(
817                            "[{who}] provider '{provider_id}' registration changed during settle ('{registration_id}' -> '{}')",
818                            p.registration_id,
819                        );
820                    }
821                    None => {
822                        // Provider vanished between the original match
823                        // and now (crashed mid-settle, atlas evicted,
824                        // heartbeat lapsed). Caller's downstream logic must
825                        // not march on against a dead process; instead fail
826                        // here so the caller can log + reap.
827                        anyhow::bail!(
828                            "[{who}] provider '{provider_id}' unregistered during settle window",
829                        );
830                    }
831                }
832            }
833            let driver_contracts = runtime_driver_contracts(&current);
834            return Ok(RegistrationOutcome {
835                provider_id,
836                provider_kind: current.kind,
837                provider_namespace: current.namespace,
838                registration_id: current.registration_id,
839                driver_contracts,
840            });
841        }
842        if Instant::now() >= deadline {
843            anyhow::bail!(
844                "[{who}] timed out after {DRIVER_REGISTER_TIMEOUT:?} — package never \
845                 registered expected deployment instance '{expected_provider_id}' with atlas",
846            );
847        }
848        tokio::time::sleep(POLL_INTERVAL).await;
849    }
850}
851
852/// Issue one `Driver(cmd)` RPC against a freshly-connected channel, then
853/// release the channel. Returns the response's `state` string on success;
854/// bails when ok=false or the RPC itself fails. Mirrors rbnx-cli's boot
855/// behaviour for both CMD_INIT and CMD_ACTIVATE so Soma drives the same
856/// lifecycle transitions.
857///
858/// `who` shows up in every error message so callers can attribute
859/// failures to a specific package without re-wrapping each return.
860pub async fn call_driver_cmd(
861    atlas: &mut AtlasClient,
862    provider_id: &str,
863    driver_contract: &str,
864    cmd: u32,
865    config_json: String,
866    who: &str,
867) -> Result<String> {
868    let cmd_name = match cmd {
869        CMD_INIT => "INIT",
870        CMD_ACTIVATE => "ACTIVATE",
871        CMD_DEACTIVATE => "DEACTIVATE",
872        CMD_SHUTDOWN => "SHUTDOWN",
873        _ => "?",
874    };
875    let (channel_id, endpoint, _params) = atlas
876        .connect_capability(
877            BOOT_CONSUMER_ID,
878            provider_id,
879            driver_contract,
880            atlas_pb::Transport::Grpc,
881        )
882        .await
883        .with_context(|| format!("[{who}] ConnectCapability for {driver_contract}"))?;
884    let normalized = if endpoint.starts_with("http") {
885        endpoint
886    } else {
887        format!("http://{endpoint}")
888    };
889    let result = async {
890        let channel = Endpoint::new(normalized.clone())
891            .with_context(|| format!("invalid driver endpoint '{normalized}'"))?
892            .connect()
893            .await
894            .with_context(|| format!("dial driver at '{normalized}'"))?;
895        let svc_name = contract_id_to_service_name(driver_contract);
896        let path: tonic::codegen::http::uri::PathAndQuery =
897            format!("/robonix.contracts.{svc_name}/Driver")
898                .parse()
899                .with_context(|| format!("build gRPC path for '{driver_contract}'"))?;
900        let mut grpc = tonic::client::Grpc::new(channel);
901        grpc.ready().await.with_context(|| "gRPC ready")?;
902        let codec: tonic_prost::ProstCodec<DriverRequest, DriverResponse> = Default::default();
903        let resp = tokio::time::timeout(
904            DRIVER_INIT_TIMEOUT,
905            grpc.unary(
906                Request::new(DriverRequest {
907                    command: cmd,
908                    config_json,
909                }),
910                path,
911                codec,
912            ),
913        )
914        .await
915        .map_err(|_| {
916            anyhow::anyhow!(
917                "Driver(CMD_{cmd_name}) timed out after {:?}",
918                DRIVER_INIT_TIMEOUT
919            )
920        })?
921        .with_context(|| format!("Driver(CMD_{cmd_name}) RPC failed"))?;
922        Ok::<_, anyhow::Error>(resp.into_inner())
923    }
924    .await;
925    // Always disconnect, even on error — Atlas counts open channels and
926    // we don't want them to leak when boot races a half-up provider.
927    let _ = atlas.disconnect_capability(&channel_id).await;
928    let r = result.map_err(|e| anyhow::anyhow!("[{who}] Driver(CMD_{cmd_name}): {e:#}"))?;
929    if !r.ok {
930        anyhow::bail!(
931            "[{who}] Driver(CMD_{cmd_name}) returned ok=false (state={}, error={})",
932            r.state,
933            r.error
934        );
935    }
936    Ok(r.state)
937}
938
939#[cfg(test)]
940mod tests {
941    use super::{
942        PackageRuntimeRecord, atlas_pb, is_expected_provider_registration,
943        is_new_provider_registration, linux_proc_status_is_not_zombie,
944        macos_procargs_has_environment_entry, macos_ps_state_is_not_zombie, proc_start_time_ticks,
945        process_group_has_members, resolve_runtime_driver_contract,
946        shutdown_package_runtime_checked, terminate_process_group_guarded,
947        validate_runtime_driver_contracts,
948    };
949
950    const SHARED: &str = "robonix/lifecycle/driver";
951    const LEGACY: &str = "robonix/primitive/camera/driver";
952
953    #[cfg(any(target_os = "linux", target_os = "macos"))]
954    #[test]
955    fn live_process_start_identity_is_available_and_stable() {
956        let first = proc_start_time_ticks(std::process::id())
957            .expect("supported host must expose a live process start identity");
958        std::thread::sleep(std::time::Duration::from_millis(10));
959        assert_eq!(proc_start_time_ticks(std::process::id()), Some(first));
960    }
961
962    #[test]
963    fn runtime_driver_must_exactly_match_shared_or_legacy_selection() {
964        assert_eq!(
965            validate_runtime_driver_contracts("camera", SHARED, &[SHARED.to_string()]).unwrap(),
966            SHARED
967        );
968        assert_eq!(
969            validate_runtime_driver_contracts("camera", LEGACY, &[LEGACY.to_string()]).unwrap(),
970            LEGACY
971        );
972    }
973
974    #[test]
975    fn runtime_driver_rejects_missing_mismatched_and_dual_declarations() {
976        let missing = validate_runtime_driver_contracts("camera", SHARED, &[])
977            .unwrap_err()
978            .to_string();
979        assert!(missing.contains("without a lifecycle Driver"));
980        assert!(missing.contains("rbnx build"));
981
982        let mismatch = validate_runtime_driver_contracts("camera", SHARED, &[LEGACY.to_string()])
983            .unwrap_err()
984            .to_string();
985        assert!(mismatch.contains("expected 'robonix/lifecycle/driver'"));
986
987        let dual = validate_runtime_driver_contracts(
988            "camera",
989            SHARED,
990            &[SHARED.to_string(), LEGACY.to_string()],
991        )
992        .unwrap_err()
993        .to_string();
994        assert!(dual.contains("multiple lifecycle Drivers"));
995    }
996
997    #[test]
998    fn only_exact_legacy_manifest_may_upgrade_to_shared_runtime() {
999        assert_eq!(
1000            resolve_runtime_driver_contract(
1001                "camera",
1002                "robonix/primitive/camera",
1003                SHARED,
1004                &[SHARED.to_string()],
1005                false,
1006            )
1007            .unwrap(),
1008            SHARED
1009        );
1010        let unmarked_upgrade = resolve_runtime_driver_contract(
1011            "camera",
1012            "robonix/primitive/camera",
1013            LEGACY,
1014            &[SHARED.to_string()],
1015            false,
1016        )
1017        .unwrap_err()
1018        .to_string();
1019        assert!(unmarked_upgrade.contains("expected 'robonix/primitive/camera/driver'"));
1020        assert_eq!(
1021            resolve_runtime_driver_contract(
1022                "camera",
1023                "robonix/primitive/camera",
1024                LEGACY,
1025                &[SHARED.to_string()],
1026                true,
1027            )
1028            .unwrap(),
1029            SHARED
1030        );
1031        assert_eq!(
1032            resolve_runtime_driver_contract(
1033                "camera",
1034                "robonix/primitive/camera",
1035                LEGACY,
1036                &[LEGACY.to_string()],
1037                true,
1038            )
1039            .unwrap(),
1040            LEGACY
1041        );
1042        let missing = resolve_runtime_driver_contract(
1043            "camera",
1044            "robonix/primitive/camera",
1045            LEGACY,
1046            &[],
1047            true,
1048        )
1049        .unwrap_err()
1050        .to_string();
1051        assert!(missing.contains("without a lifecycle Driver"));
1052        assert!(missing.contains("robonix/primitive/camera/driver"));
1053        assert!(missing.contains("robonix/lifecycle/driver"));
1054        assert!(missing.contains("rbnx build"));
1055
1056        let reverse = resolve_runtime_driver_contract(
1057            "camera",
1058            "robonix/primitive/camera",
1059            SHARED,
1060            &[LEGACY.to_string()],
1061            true,
1062        )
1063        .unwrap_err()
1064        .to_string();
1065        assert!(reverse.contains("expected 'robonix/lifecycle/driver'"));
1066
1067        assert!(
1068            resolve_runtime_driver_contract(
1069                "camera",
1070                "robonix/primitive/camera",
1071                LEGACY,
1072                &["robonix/primitive/lidar/driver".to_string()],
1073                true,
1074            )
1075            .unwrap_err()
1076            .to_string()
1077            .contains("expected 'robonix/primitive/camera/driver'")
1078        );
1079        assert!(
1080            resolve_runtime_driver_contract(
1081                "camera",
1082                "robonix/primitive/camera",
1083                "robonix/primitive/lidar/driver",
1084                &["robonix/primitive/lidar/driver".to_string()],
1085                true,
1086            )
1087            .unwrap_err()
1088            .to_string()
1089            .contains("unsupported lifecycle Driver")
1090        );
1091    }
1092
1093    #[test]
1094    fn registration_snapshot_detects_same_id_takeover_but_not_same_generation() {
1095        let before =
1096            std::collections::HashMap::from([("camera".to_string(), "registration-a".to_string())]);
1097        let provider = |id: &str, registration_id: &str| atlas_pb::CapabilityProvider {
1098            id: id.to_string(),
1099            registration_id: registration_id.to_string(),
1100            ..Default::default()
1101        };
1102
1103        assert!(!is_new_provider_registration(
1104            &provider("camera", "registration-a"),
1105            &before,
1106        ));
1107        assert!(is_new_provider_registration(
1108            &provider("camera", "registration-b"),
1109            &before,
1110        ));
1111        assert!(is_new_provider_registration(
1112            &provider("lidar", "registration-a"),
1113            &before,
1114        ));
1115    }
1116
1117    #[test]
1118    fn expected_registration_ignores_unrelated_concurrent_provider() {
1119        let before =
1120            std::collections::HashMap::from([("existing_camera".to_string(), "old".to_string())]);
1121        let provider = |id: &str, registration_id: &str| atlas_pb::CapabilityProvider {
1122            id: id.to_string(),
1123            registration_id: registration_id.to_string(),
1124            ..Default::default()
1125        };
1126
1127        assert!(!is_expected_provider_registration(
1128            &provider("unrelated_lidar", "new"),
1129            &before,
1130            "camera",
1131        ));
1132        assert!(is_expected_provider_registration(
1133            &provider("camera", "new"),
1134            &before,
1135            "camera",
1136        ));
1137    }
1138
1139    #[test]
1140    fn expected_registration_does_not_accept_takeover_of_existing_id() {
1141        let before = std::collections::HashMap::from([("camera".to_string(), "old".to_string())]);
1142        let provider = atlas_pb::CapabilityProvider {
1143            id: "camera".to_string(),
1144            registration_id: "new".to_string(),
1145            ..Default::default()
1146        };
1147
1148        assert!(!is_expected_provider_registration(
1149            &provider, &before, "camera",
1150        ));
1151    }
1152
1153    #[test]
1154    fn linux_proc_status_zombie_detection_is_conservative() {
1155        assert!(!linux_proc_status_is_not_zombie(
1156            "Name:\tworker\nState:\tZ (zombie)\n"
1157        ));
1158        assert!(linux_proc_status_is_not_zombie(
1159            "Name:\tworker\nState:\tS (sleeping)\n"
1160        ));
1161        assert!(linux_proc_status_is_not_zombie("Name:\tworker\n"));
1162    }
1163
1164    #[test]
1165    fn macos_procargs_parser_matches_exact_environment_entry() {
1166        let mut procargs = 2_i32.to_ne_bytes().to_vec();
1167        procargs.extend_from_slice(
1168            b"/usr/local/bin/worker\0\0worker\0--serve\0MODE=test\0RBNX_BOOT_ID=boot-123\0\0",
1169        );
1170
1171        assert_eq!(
1172            macos_procargs_has_environment_entry(&procargs, b"RBNX_BOOT_ID=boot-123"),
1173            Some(true)
1174        );
1175        assert_eq!(
1176            macos_procargs_has_environment_entry(&procargs, b"RBNX_BOOT_ID=boot"),
1177            Some(false)
1178        );
1179        assert_eq!(
1180            macos_procargs_has_environment_entry(&2_i32.to_ne_bytes(), b"MODE=test"),
1181            None
1182        );
1183
1184        let mut truncated = procargs;
1185        truncated.pop();
1186        truncated.pop();
1187        assert_eq!(
1188            macos_procargs_has_environment_entry(&truncated, b"RBNX_BOOT_ID=boot-123"),
1189            None
1190        );
1191    }
1192
1193    #[test]
1194    fn macos_ps_state_parser_only_excludes_zombies() {
1195        assert_eq!(macos_ps_state_is_not_zombie("Z+  \n"), Some(false));
1196        assert_eq!(macos_ps_state_is_not_zombie("S   \n"), Some(true));
1197        assert_eq!(macos_ps_state_is_not_zombie(" \n"), None);
1198    }
1199
1200    #[tokio::test]
1201    async fn live_process_group_is_not_reported_empty() {
1202        use std::os::unix::process::CommandExt;
1203
1204        let mut child = std::process::Command::new("sleep")
1205            .arg("30")
1206            .process_group(0)
1207            .spawn()
1208            .unwrap();
1209        let pgid = child.id();
1210        let detected = process_group_has_members(pgid).await;
1211        let _ = nix::sys::signal::killpg(
1212            nix::unistd::Pid::from_raw(pgid as i32),
1213            nix::sys::signal::Signal::SIGKILL,
1214        );
1215        let _ = child.wait();
1216
1217        assert!(detected);
1218    }
1219
1220    #[tokio::test]
1221    async fn checked_shutdown_accepts_matching_boot_group_and_terminates_it() {
1222        use std::os::unix::process::CommandExt;
1223
1224        let boot_id = format!("shutdown-test-{}", std::process::id());
1225        let mut child = std::process::Command::new(std::env::current_exe().unwrap())
1226            .args([
1227                "--exact",
1228                "launch::tests::boot_identity_fixture_process",
1229                "--ignored",
1230                "--test-threads=1",
1231            ])
1232            .env("RBNX_BOOT_ID", &boot_id)
1233            .process_group(0)
1234            .spawn()
1235            .unwrap();
1236        let pgid = child.id();
1237        let record = PackageRuntimeRecord {
1238            name: "shutdown-test".into(),
1239            pid: pgid,
1240            pgid,
1241            ..PackageRuntimeRecord::default()
1242        };
1243
1244        let complete = shutdown_package_runtime_checked(
1245            None,
1246            &record,
1247            std::time::Duration::from_secs(2),
1248            Some(&boot_id),
1249        )
1250        .await;
1251        let still_running = child.try_wait().unwrap().is_none();
1252        if still_running {
1253            let _ = nix::sys::signal::killpg(
1254                nix::unistd::Pid::from_raw(pgid as i32),
1255                nix::sys::signal::Signal::SIGKILL,
1256            );
1257        }
1258        let _ = child.wait();
1259
1260        assert!(complete);
1261        assert!(!still_running);
1262    }
1263
1264    #[tokio::test]
1265    async fn mismatched_boot_group_is_rejected_without_signaling() {
1266        use std::os::unix::process::CommandExt;
1267
1268        let boot_id = format!("mismatch-test-{}", std::process::id());
1269        let mut child = std::process::Command::new(std::env::current_exe().unwrap())
1270            .args([
1271                "--exact",
1272                "launch::tests::boot_identity_fixture_process",
1273                "--ignored",
1274                "--test-threads=1",
1275            ])
1276            .env("RBNX_BOOT_ID", &boot_id)
1277            .process_group(0)
1278            .spawn()
1279            .unwrap();
1280        let pgid = child.id();
1281
1282        let accepted = terminate_process_group_guarded(
1283            pgid,
1284            std::time::Duration::from_millis(10),
1285            Some("different-boot-id"),
1286        )
1287        .await;
1288        let still_running = child.try_wait().unwrap().is_none();
1289        let _ = nix::sys::signal::killpg(
1290            nix::unistd::Pid::from_raw(pgid as i32),
1291            nix::sys::signal::Signal::SIGKILL,
1292        );
1293        let _ = child.wait();
1294
1295        assert!(!accepted);
1296        assert!(still_running);
1297    }
1298
1299    #[tokio::test]
1300    async fn zombie_only_process_group_is_reported_empty() {
1301        use super::process_is_not_zombie;
1302        use std::os::unix::process::CommandExt;
1303
1304        let mut child = std::process::Command::new("sh")
1305            .arg("-c")
1306            .arg("exit 0")
1307            .process_group(0)
1308            .spawn()
1309            .unwrap();
1310        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
1311        while process_is_not_zombie(child.id()).await && std::time::Instant::now() < deadline {
1312            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1313        }
1314        let zombie_detected = !process_is_not_zombie(child.id()).await;
1315        let group_has_members = process_group_has_members(child.id()).await;
1316        let _ = child.wait();
1317
1318        assert!(zombie_detected);
1319        assert!(!group_has_members);
1320    }
1321
1322    #[test]
1323    #[ignore = "spawned by checked_shutdown_accepts_matching_boot_group_and_terminates_it"]
1324    fn boot_identity_fixture_process() {
1325        std::thread::sleep(std::time::Duration::from_secs(30));
1326    }
1327}