Skip to main content

rbnx/cmd/
teardown.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Shared boot-state persistence + tear-down logic for `rbnx boot` and
3// `rbnx shutdown`. Boot writes `state.json`; shutdown reads it.
4//
5// Scope: package runtime shutdown. Each component record carries provider
6// lifecycle metadata, optional package `stop`, and wrapper PGID. Teardown uses
7// the same ordered contract for Ctrl-C, `rbnx shutdown`, boot failure cleanup,
8// and restart recovery: Driver(CMD_SHUTDOWN) if reachable, then manifest stop,
9// then TERM/KILL of the wrapper process group.
10//
11// Boot also doesn't currently kill its children on the `?`-error path,
12// leaking atlas/pilot/executor as orphans. Persisted state + a separate
13// `rbnx shutdown` command lets the user (or boot's own error path)
14// always reach the same teardown helper.
15
16use anyhow::{Context, Result};
17use robonix_cli::launch::{PackageRuntimeRecord, shutdown_package_runtime_checked};
18use serde::{Deserialize, Serialize};
19use std::path::{Path, PathBuf};
20use std::time::Duration;
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct BootState {
24    pub manifest_path: String,
25    pub boot_pid: u32,
26    #[serde(default)]
27    pub boot_start_time_ticks: Option<u64>,
28    #[serde(default)]
29    pub boot_id: String,
30    pub started_at_ms: u64,
31    pub atlas_endpoint: String,
32    pub components: Vec<ComponentRecord>,
33}
34
35/// Backward-compatible name for persisted boot components. New fields are
36/// optional in JSON so old state files still deserialize.
37pub type ComponentRecord = PackageRuntimeRecord;
38
39pub fn state_path(manifest_dir: &Path) -> PathBuf {
40    manifest_dir.join("rbnx-boot").join("state.json")
41}
42
43pub fn write_state(path: &Path, state: &BootState) -> Result<()> {
44    if let Some(parent) = path.parent() {
45        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
46    }
47    let text = serde_json::to_string_pretty(state)?;
48    let temp = path.with_extension(format!("json.{}.tmp", std::process::id()));
49    std::fs::write(&temp, text).with_context(|| format!("write {}", temp.display()))?;
50    std::fs::rename(&temp, path)
51        .with_context(|| format!("replace {} with {}", path.display(), temp.display()))?;
52    Ok(())
53}
54
55pub fn read_state(path: &Path) -> Result<BootState> {
56    let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
57    let state: BootState =
58        serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
59    Ok(state)
60}
61
62/// Stop each component with the canonical runtime order. Idempotent: missing
63/// providers, stop hooks, or PGIDs are treated as best-effort shutdown noise.
64pub async fn teardown(
65    atlas_endpoint: Option<&str>,
66    components: &[ComponentRecord],
67    boot_id: Option<&str>,
68) -> bool {
69    let mut complete = true;
70    // Reverse order so services/skills/primitives die before pilot/atlas.
71    for c in components.iter().rev() {
72        robonix_cli::output::sub_step(&format!(
73            "[shutdown] {} stopping (pid={}, pgid={})",
74            c.name, c.pid, c.pgid
75        ));
76        complete &=
77            shutdown_package_runtime_checked(atlas_endpoint, c, Duration::from_secs(30), boot_id)
78                .await;
79    }
80    complete
81}