Skip to main content

ignition_core/actions/
restart.rs

1//! Restart + wait actions (02-05, HLTH-09/11) — built on the ONE wait
2//! engine ([`crate::poll`], 02-04): serde models OUT, no printing.
3//!
4//! Every wait anchors on the UNAUTHENTICATED [`GatewayApi::status_ping`]
5//! (strictly better than polling gateway-info, 02-RESEARCH §Restart:
6//! it separates down-ness from auth failure and answers during the
7//! STARTING window — the webserver never drops the connection).
8//!
9//! ## Probe shape (the 02-04 HRTB pattern, verbatim)
10//!
11//! Every probe returns `PollState<()>` and its poll-owned state is a
12//! `&mut`-borrowing type (the tail's `TailState<'a>` shape): a probe
13//! whose state carries no lifetime does not typecheck under poll's
14//! `for<'a> FnMut(&'a mut S) -> Probe<'a, T>` bound. The terminal
15//! STATE string rides the state itself (an outer [`Cell`] the state
16//! mutably borrows — readable after `poll` consumes the state), like
17//! the tail's `streamed` counter.
18//!
19//! ## The ONE shared floor ([`RESTART_FLOOR`])
20//!
21//! Open Question 4's fast-restart race: a very fast restart could flip
22//! back to RUNNING before the first poll observes STARTING, so
23//! "observe non-RUNNING once, then RUNNING" alone can false-positive.
24//! BOTH restart-aware waits share one mitigation constant — no
25//! duplicated literals:
26//!
27//! - [`restart_and_wait`] sleeps the floor right after the POST: any
28//!   RUNNING observed after it is genuine success (the grace window
29//!   has passed);
30//! - [`wait_restart`] (the STANDALONE arm — deliberately NOT
31//!   [`wait_gateway`] semantics) accepts an all-RUNNING poll sequence
32//!   as success only once the floor has elapsed: `ign restart` fired
33//!   the POST up to ~5 s before `ign wait restart` starts polling, and
34//!   the gateway still reports RUNNING inside that grace window.
35//!   Observing non-RUNNING→RUNNING short-circuits the floor (the
36//!   restart was WITNESSED — floor not needed).
37//!
38//! The floor is a PARAMETER (tests inject milliseconds; the CLI passes
39//! [`RESTART_FLOOR`]) — the one knob both semantics share.
40//!
41//! NEVER use `restart-tasks/pending` as the progress signal (research:
42//! it is required-restart config, not restart status).
43
44use std::sync::Mutex;
45use std::time::{Duration, Instant};
46
47use serde::Serialize;
48
49use crate::client::GatewayApi;
50use crate::error::CoreError;
51use crate::poll::{self, PollConfig, PollState};
52
53/// The ONE shared floor for both restart-aware waits (must-have
54/// key_link): 5 s of post-POST grace before an all-RUNNING poll
55/// sequence may report success. Injectable as a parameter; this is the
56/// production value the CLI passes.
57pub const RESTART_FLOOR: Duration = Duration::from_secs(5);
58
59/// Default wait interval (research §Wait-loop pattern).
60pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(2);
61
62/// Default restart budget (research: ~40 s observed; 300 s headroom).
63pub const RESTART_TIMEOUT: Duration = Duration::from_secs(300);
64
65/// Default gateway/module readiness budget.
66pub const READINESS_TIMEOUT: Duration = Duration::from_secs(120);
67
68/// The state a healthy gateway reports (observed live; the wait's
69/// terminal condition).
70const RUNNING: &str = "RUNNING";
71
72/// The state a fully-loaded healthy module reports.
73const ACTIVE: &str = "ACTIVE";
74
75/// `ign restart` (no `--wait`) output model.
76#[derive(Debug, Serialize)]
77pub struct RestartResult {
78    /// Always `true` — the POST was accepted.
79    pub restarted: bool,
80}
81
82/// `ign restart --wait` output model.
83#[derive(Debug, Serialize)]
84pub struct RestartWaitResult {
85    /// Always `true` — the POST was accepted.
86    pub restarted: bool,
87    /// The terminal state observed (`RUNNING`).
88    pub state: String,
89    /// Seconds from the POST to the terminal state (floor included).
90    pub elapsed_secs: u64,
91}
92
93/// `ign wait <target>` output model (gateway / restart / module).
94#[derive(Debug, Serialize)]
95pub struct WaitResult {
96    /// What was waited on: `gateway`, `restart`, or `module <id>`.
97    pub target: String,
98    /// The terminal state observed (`RUNNING` / `ACTIVE`).
99    pub state: String,
100    /// Seconds until the terminal state.
101    pub elapsed_secs: u64,
102}
103
104/// `ign restart` without `--wait`: fire the POST and return — the
105/// human-mode advisory line ("READY in ~1 min") belongs to the CLI.
106/// Confirmation guarding belongs to the CALLER (guard before any API
107/// construction).
108pub async fn restart(api: &dyn GatewayApi) -> Result<RestartResult, CoreError> {
109    api.restart().await?;
110    Ok(RestartResult { restarted: true })
111}
112
113/// `ign restart --wait`: POST → sleep the floor (Open Question 4) →
114/// poll `/StatusPing` until RUNNING. Timeout (default 300 s) → the
115/// poll engine's Network-class deadline error, whose message names the
116/// last observed state.
117pub async fn restart_and_wait(
118    api: &dyn GatewayApi,
119    interval: Duration,
120    timeout: Duration,
121    floor: Duration,
122) -> Result<RestartWaitResult, CoreError> {
123    api.restart().await?;
124    let started = Instant::now();
125    // The floor sleeps BEFORE the first poll: post-floor RUNNING is
126    // unambiguous success even if the STARTING window was never
127    // observed (fast-flip race closed by construction).
128    tokio::time::sleep(floor).await;
129    let state = wait_state_running(
130        api,
131        "restart completion (GET /StatusPing)".to_string(),
132        interval,
133        timeout,
134    )
135    .await?;
136    Ok(RestartWaitResult {
137        restarted: true,
138        state,
139        elapsed_secs: started.elapsed().as_secs(),
140    })
141}
142
143/// `ign wait gateway`: poll `/StatusPing` until RUNNING. Works with NO
144/// credential (the dispatch constructs the client header-less for this
145/// command). IMMEDIATE success when already RUNNING is CORRECT here:
146/// `wait gateway` answers "is it up", not "did it restart" — the
147/// restart-aware variant is [`wait_restart`].
148pub async fn wait_gateway(
149    api: &dyn GatewayApi,
150    interval: Duration,
151    timeout: Duration,
152) -> Result<WaitResult, CoreError> {
153    let started = Instant::now();
154    let state = wait_state_running(
155        api,
156        "gateway readiness (GET /StatusPing)".to_string(),
157        interval,
158        timeout,
159    )
160    .await?;
161    Ok(WaitResult {
162        target: "gateway".to_string(),
163        state,
164        elapsed_secs: started.elapsed().as_secs(),
165    })
166}
167
168/// `ign wait restart`: the STANDALONE restart-aware wait (research
169/// line 94 + Open Question 4). The moment any non-RUNNING state is
170/// observed, keep polling until RUNNING → terminal success (the
171/// restart was witnessed; floor not needed). If polls are RUNNING from
172/// the start, success is accepted ONLY after `floor` has elapsed —
173/// `ign restart` fired the POST up to `floor` before this command
174/// started and the gateway still reports RUNNING in that grace window,
175/// so immediate success would be a false positive. Deadline → the poll
176/// engine's Network-class timeout naming the last observed state.
177pub async fn wait_restart(
178    api: &dyn GatewayApi,
179    interval: Duration,
180    timeout: Duration,
181    floor: Duration,
182) -> Result<WaitResult, CoreError> {
183    let started = Instant::now();
184    let cfg = PollConfig {
185        subject: "restart completion (GET /StatusPing)".to_string(),
186        interval,
187        deadline: timeout,
188        ..PollConfig::default()
189    };
190    /// Probe scratch (borrowing state — the HRTB shape): did any poll
191    /// observe a non-RUNNING state, and where the terminal state goes.
192    /// The Mutex lives OUTSIDE poll; the state mutably borrows it, so
193    /// the result survives poll consuming the state (the tail's
194    /// `streamed` pattern). A `Mutex` (not `Cell`) so the probe future
195    /// is Send — the 06-02 TUI spawns whole waits on tokio.
196    struct Witness<'a> {
197        seen_non_running: bool,
198        final_state: &'a mut Mutex<String>,
199    }
200    let mut final_state = Mutex::new(String::new());
201    poll::poll(
202        cfg,
203        Witness {
204            seen_non_running: false,
205            final_state: &mut final_state,
206        },
207        |witness| {
208            Box::pin(async {
209                let ping = api.status_ping().await?;
210                if ping.state == RUNNING {
211                    if witness.seen_non_running || started.elapsed() >= floor {
212                        witness
213                            .final_state
214                            .get_mut()
215                            .expect("terminal state")
216                            .clone_from(&ping.state);
217                        Ok(PollState::<()>::Done(()))
218                    } else {
219                        // All-RUNNING inside the grace floor: accepting
220                        // now would false-positive on `ign restart`'s
221                        // ~5 s post-POST window.
222                        Ok(PollState::<()>::Pending(Some(format!(
223                            "{RUNNING} (all-RUNNING inside the {floor:?} restart grace floor)"
224                        ))))
225                    }
226                } else {
227                    witness.seen_non_running = true;
228                    Ok(PollState::<()>::Pending(Some(ping.state)))
229                }
230            })
231        },
232    )
233    .await?;
234    Ok(WaitResult {
235        target: "restart".to_string(),
236        state: std::mem::take(&mut *final_state.lock().expect("terminal state")),
237        elapsed_secs: started.elapsed().as_secs(),
238    })
239}
240
241/// `ign wait module <id>`: poll `modules/healthy?search=<id>` until the
242/// item with that id reports `ACTIVE` (research §Modules). Deadline →
243/// the poll engine's Network-class timeout naming the id (the subject)
244/// and the last observed state.
245pub async fn wait_module(
246    api: &dyn GatewayApi,
247    module_id: &str,
248    interval: Duration,
249    timeout: Duration,
250) -> Result<WaitResult, CoreError> {
251    let started = Instant::now();
252    let cfg = PollConfig {
253        subject: format!("module {module_id} ACTIVE (GET /data/api/v1/modules/healthy)"),
254        interval,
255        deadline: timeout,
256        ..PollConfig::default()
257    };
258    let mut final_state = Mutex::new(String::new());
259    poll::poll(cfg, &mut final_state, |final_state| {
260        Box::pin(async {
261            let query = crate::client::query::ListQuery {
262                search: Some(module_id.to_string()),
263                ..Default::default()
264            };
265            let modules = api.modules(false, &query).await?;
266            // search is a substring match over names too — the row
267            // must be THE module (id equality).
268            if let Some(module) = modules.items.iter().find(|m| m.id == module_id) {
269                if module.state.as_deref() == Some(ACTIVE) {
270                    final_state
271                        .get_mut()
272                        .expect("terminal state")
273                        .push_str(ACTIVE);
274                    Ok(PollState::<()>::Done(()))
275                } else {
276                    Ok(PollState::<()>::Pending(Some(format!(
277                        "{} state {}",
278                        module.id,
279                        module.state.as_deref().unwrap_or("-")
280                    ))))
281                }
282            } else {
283                Ok(PollState::<()>::Pending(Some(format!(
284                    "{module_id} not present in the healthy module list"
285                ))))
286            }
287        })
288    })
289    .await?;
290    Ok(WaitResult {
291        target: format!("module {module_id}"),
292        state: std::mem::take(&mut *final_state.lock().expect("terminal state")),
293        elapsed_secs: started.elapsed().as_secs(),
294    })
295}
296
297/// The shared RUNNING probe: poll `/StatusPing` until `state ==
298/// "RUNNING"` (STARTING/unknown = Pending with the observed state —
299/// unknown states surface verbatim, research Open Question 3). The
300/// terminal state rides the Mutex the state borrows (poll's T is `()`;
301/// Mutex not Cell so the probe future is Send — the 06-02 TUI spawns it).
302async fn wait_state_running(
303    api: &dyn GatewayApi,
304    subject: String,
305    interval: Duration,
306    timeout: Duration,
307) -> Result<String, CoreError> {
308    let cfg = PollConfig {
309        subject,
310        interval,
311        deadline: timeout,
312        ..PollConfig::default()
313    };
314    let mut final_state = Mutex::new(String::new());
315    poll::poll(cfg, &mut final_state, |final_state| {
316        Box::pin(async {
317            let ping = api.status_ping().await?;
318            if ping.state == RUNNING {
319                final_state
320                    .get_mut()
321                    .expect("terminal state")
322                    .clone_from(&ping.state);
323                Ok(PollState::<()>::Done(()))
324            } else {
325                Ok(PollState::<()>::Pending(Some(ping.state)))
326            }
327        })
328    })
329    .await?;
330    Ok(std::mem::take(
331        &mut *final_state.lock().expect("terminal state"),
332    ))
333}
334
335#[cfg(test)]
336mod tests {
337    use std::time::Duration;
338
339    use super::RESTART_FLOOR;
340
341    /// The must-have key_link pin: the floor BOTH restart-aware waits
342    /// share is the literal 5 s — no duplicated literals, ever.
343    #[test]
344    fn restart_floor_is_five_seconds() {
345        assert_eq!(RESTART_FLOOR, Duration::from_secs(5));
346    }
347}