Skip to main content

frame_host/dev/
engine.rs

1//! The activation barrier (F-7b R3, constraint 6) — the door behind
2//! [`require_dev_node`].
3//!
4//! One candidate at a time: close admission, drain and stop the old
5//! incarnation (the registry's ordered stop IS the drain), purge-and-stage
6//! the candidate, start the fresh tree, prove BOTH liveness witnesses,
7//! then reopen. All-or-rollback: any stage failure restores and RE-PROVES
8//! the previous bytes before the barrier reopens, and inability to
9//! restore is `NodeFailed`, never a false running report.
10//!
11//! The engine speaks to the node through [`NodeControl`] — the seam the
12//! real host implements over `ComponentRegistry`/`ComponentRuntime`, and
13//! tests script with stage-by-stage failure injection. The engine itself
14//! owns the barrier ORDER, the last-known-good state, and the honesty of
15//! every reply; it performs no node operation a control call does not
16//! name.
17
18use super::door::require_dev_node;
19use super::{
20    CandidateBytes, DevReply, DevRequest, DevStatusEvent, GenerationReport, NodeMode, ReloadStage,
21    StageRefusal,
22};
23
24/// Why a start attempt failed: candidate byte admission versus the fresh
25/// tree. The distinction names the stage in every report (R4).
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum StartFailure {
28    /// The candidate bytes were refused at load.
29    Load(String),
30    /// The bytes loaded but the fresh tree did not come up.
31    Tree(String),
32}
33
34/// The node operations the barrier drives, in the only order it drives
35/// them. Every method is one observable step; the implementation maps its
36/// substrate errors to the step's typed detail.
37pub trait NodeControl {
38    /// Ordered stop of the component: drains admitted old work, awaits
39    /// the tree's tombstones. After `Ok`, nothing runs old code.
40    ///
41    /// # Errors
42    ///
43    /// The substrate's typed stop failure, rendered.
44    fn stop_component(&mut self) -> Result<(), String>;
45    /// Purges retained old module code and stages these bytes for the
46    /// next start (the registry staging seam + support-module staging).
47    ///
48    /// # Errors
49    ///
50    /// The typed purge/staging refusal, rendered — a purge refusal is
51    /// the drain-failure signal.
52    fn stage(&mut self, candidate: &CandidateBytes) -> Result<(), String>;
53    /// Starts the component tree from the staged bytes.
54    ///
55    /// # Errors
56    ///
57    /// [`StartFailure::Load`] for byte admission, [`StartFailure::Tree`]
58    /// for a tree that did not come up.
59    fn start_component(&mut self) -> Result<(), StartFailure>;
60    /// The mailbox liveness round-trip against the live tree.
61    ///
62    /// # Errors
63    ///
64    /// The witness's typed failure, rendered.
65    fn witness_mailbox(&mut self) -> Result<(), String>;
66    /// The stored-entity/content round-trip against the live tree.
67    ///
68    /// # Errors
69    ///
70    /// The witness's typed failure, rendered.
71    fn witness_content(&mut self) -> Result<(), String>;
72    /// The generation report for what is now serving.
73    ///
74    /// # Errors
75    ///
76    /// A witness that cannot be read — the node is then NOT proven, and
77    /// the engine reports `NodeFailed` rather than inventing numbers.
78    fn report(
79        &mut self,
80        build_generation: u64,
81        content_digest: [u8; 32],
82    ) -> Result<GenerationReport, String>;
83}
84
85/// The barrier engine: one per dev node, owned by the management adapter.
86pub struct BarrierEngine {
87    mode: NodeMode,
88    /// The bytes proven live most recently — initialized from the boot
89    /// bytes, replaced only after a candidate passes every witness.
90    last_good: CandidateBytes,
91    /// The newest candidate generation ever offered; stale completions
92    /// are refused by identity (constraint 8), including retries of a
93    /// failed generation.
94    newest_seen: u64,
95    in_flight: bool,
96}
97
98impl BarrierEngine {
99    /// An engine serving the boot bytes as generation zero's last-good.
100    #[must_use]
101    pub fn new(mode: NodeMode, boot: CandidateBytes) -> Self {
102        Self {
103            mode,
104            last_good: boot,
105            newest_seen: 0,
106            in_flight: false,
107        }
108    }
109
110    /// Handles one management request, emitting every status transition
111    /// through `status` and returning the exactly-one typed reply.
112    pub fn handle(
113        &mut self,
114        request: DevRequest,
115        node: &mut dyn NodeControl,
116        status: &mut dyn FnMut(DevStatusEvent),
117    ) -> DevReply {
118        let DevRequest::StageAndActivate(candidate) = request;
119        if let Err(refusal) = require_dev_node(self.mode) {
120            return DevReply::Refused(refusal);
121        }
122        if self.in_flight {
123            return DevReply::Refused(StageRefusal::ActivationInFlight);
124        }
125        if candidate.build_generation <= self.newest_seen {
126            return DevReply::Refused(StageRefusal::StaleGeneration {
127                offered: candidate.build_generation,
128                newest_seen: self.newest_seen,
129            });
130        }
131        self.newest_seen = candidate.build_generation;
132        self.in_flight = true;
133        let reply = self.activate(&candidate, node, status);
134        self.in_flight = false;
135        reply
136    }
137
138    fn activate(
139        &mut self,
140        candidate: &CandidateBytes,
141        node: &mut dyn NodeControl,
142        status: &mut dyn FnMut(DevStatusEvent),
143    ) -> DevReply {
144        let generation = candidate.build_generation;
145        let mut stage = |at: ReloadStage| {
146            status(DevStatusEvent::Reloading {
147                build_generation: generation,
148                stage: at,
149            });
150        };
151
152        stage(ReloadStage::Drain);
153        stage(ReloadStage::StopOld);
154        if let Err(detail) = node.stop_component() {
155            return self.roll_back(ReloadStage::StopOld, detail, node, status);
156        }
157        stage(ReloadStage::PurgeOld);
158        if let Err(detail) = node.stage(candidate) {
159            return self.roll_back(ReloadStage::PurgeOld, detail, node, status);
160        }
161        stage(ReloadStage::StartFresh);
162        match node.start_component() {
163            Ok(()) => {}
164            Err(StartFailure::Load(detail)) => {
165                return self.roll_back(ReloadStage::LoadCandidate, detail, node, status);
166            }
167            Err(StartFailure::Tree(detail)) => {
168                return self.roll_back(ReloadStage::StartFresh, detail, node, status);
169            }
170        }
171        stage(ReloadStage::LivenessMailbox);
172        if let Err(detail) = node.witness_mailbox() {
173            return self.roll_back_live(ReloadStage::LivenessMailbox, detail, node, status);
174        }
175        stage(ReloadStage::LivenessContent);
176        if let Err(detail) = node.witness_content() {
177            return self.roll_back_live(ReloadStage::LivenessContent, detail, node, status);
178        }
179
180        self.last_good = candidate.clone();
181        match node.report(generation, candidate.content_digest) {
182            Ok(report) => {
183                status(DevStatusEvent::RunningCurrent(report.clone()));
184                DevReply::Activated(report)
185            }
186            Err(detail) => node_failed(ReloadStage::Report, detail, status),
187        }
188    }
189
190    /// Rollback from a state where the candidate tree is LIVE but
191    /// unproven: stop it first, then restore.
192    fn roll_back_live(
193        &mut self,
194        failed: ReloadStage,
195        detail: String,
196        node: &mut dyn NodeControl,
197        status: &mut dyn FnMut(DevStatusEvent),
198    ) -> DevReply {
199        if let Err(stop_detail) = node.stop_component() {
200            return node_failed(
201                failed,
202                format!(
203                    "{detail}; stopping the unproven candidate tree also failed: {stop_detail}"
204                ),
205                status,
206            );
207        }
208        self.roll_back(failed, detail, node, status)
209    }
210
211    /// Restores last-good and RE-PROVES it (stage → start → both
212    /// witnesses) before reporting the rollback. The node is stopped when
213    /// this runs.
214    fn roll_back(
215        &mut self,
216        failed: ReloadStage,
217        detail: String,
218        node: &mut dyn NodeControl,
219        status: &mut dyn FnMut(DevStatusEvent),
220    ) -> DevReply {
221        let restore = self.restore_last_good(node);
222        match restore {
223            Ok(()) => {
224                let serving = match node.report(
225                    self.last_good.build_generation,
226                    self.last_good.content_digest,
227                ) {
228                    Ok(serving) => serving,
229                    Err(report_detail) => {
230                        return node_failed(
231                            failed,
232                            format!("{detail}; last-good witnesses unreadable: {report_detail}"),
233                            status,
234                        );
235                    }
236                };
237                status(DevStatusEvent::ReloadFailed {
238                    failed_generation: self.newest_seen,
239                    failed,
240                    detail: detail.clone(),
241                    serving: serving.clone(),
242                });
243                DevReply::RolledBack {
244                    failed,
245                    detail,
246                    serving,
247                }
248            }
249            Err(restore_detail) => node_failed(
250                failed,
251                format!("{detail}; restoring last good failed: {restore_detail}"),
252                status,
253            ),
254        }
255    }
256
257    fn restore_last_good(&mut self, node: &mut dyn NodeControl) -> Result<(), String> {
258        node.stage(&self.last_good)
259            .map_err(|error| format!("restore stage: {error}"))?;
260        node.start_component().map_err(|error| match error {
261            StartFailure::Load(detail) => format!("restore load: {detail}"),
262            StartFailure::Tree(detail) => format!("restore start: {detail}"),
263        })?;
264        node.witness_mailbox()
265            .map_err(|error| format!("restore mailbox witness: {error}"))?;
266        node.witness_content()
267            .map_err(|error| format!("restore content witness: {error}"))
268    }
269}
270
271fn node_failed(
272    failed: ReloadStage,
273    detail: String,
274    status: &mut dyn FnMut(DevStatusEvent),
275) -> DevReply {
276    status(DevStatusEvent::NodeFailed {
277        detail: detail.clone(),
278    });
279    DevReply::NodeFailed { failed, detail }
280}
281
282#[cfg(test)]
283mod tests {
284    #![allow(
285        clippy::expect_used,
286        clippy::panic,
287        clippy::struct_excessive_bools,
288        clippy::cast_possible_truncation
289    )]
290
291    use super::{BarrierEngine, NodeControl, StartFailure};
292    use crate::dev::{
293        CandidateBytes, DevReply, DevRequest, DevStatusEvent, GenerationReport, NodeMode,
294        ReloadStage, StageRefusal,
295    };
296
297    /// Scripted node: records every call in order and fails exactly where
298    /// told, so each stage's rollback law is provable in isolation.
299    #[derive(Default)]
300    struct FakeNode {
301        calls: Vec<String>,
302        fail_stop: bool,
303        fail_stage: bool,
304        fail_start: Option<StartFailure>,
305        fail_mailbox: bool,
306        fail_content: bool,
307        /// After this many failures, stop failing (rollback succeeds).
308        failures_left: usize,
309    }
310
311    impl FakeNode {
312        fn failing(field: fn(&mut FakeNode), failures: usize) -> Self {
313            let mut node = Self {
314                failures_left: failures,
315                ..Self::default()
316            };
317            field(&mut node);
318            node
319        }
320
321        fn consume_failure(&mut self) -> bool {
322            if self.failures_left > 0 {
323                self.failures_left -= 1;
324                true
325            } else {
326                false
327            }
328        }
329    }
330
331    impl NodeControl for FakeNode {
332        fn stop_component(&mut self) -> Result<(), String> {
333            self.calls.push("stop".to_owned());
334            if self.fail_stop && self.consume_failure() {
335                return Err("stop refused".to_owned());
336            }
337            Ok(())
338        }
339        fn stage(&mut self, candidate: &CandidateBytes) -> Result<(), String> {
340            self.calls
341                .push(format!("stage:{}", candidate.build_generation));
342            if self.fail_stage && self.consume_failure() {
343                return Err("purge refused: still referenced".to_owned());
344            }
345            Ok(())
346        }
347        fn start_component(&mut self) -> Result<(), StartFailure> {
348            self.calls.push("start".to_owned());
349            if let Some(failure) = self.fail_start.clone()
350                && self.consume_failure()
351            {
352                return Err(failure);
353            }
354            Ok(())
355        }
356        fn witness_mailbox(&mut self) -> Result<(), String> {
357            self.calls.push("mailbox".to_owned());
358            if self.fail_mailbox && self.consume_failure() {
359                return Err("mailbox witness failed".to_owned());
360            }
361            Ok(())
362        }
363        fn witness_content(&mut self) -> Result<(), String> {
364            self.calls.push("content".to_owned());
365            if self.fail_content && self.consume_failure() {
366                return Err("content witness failed".to_owned());
367            }
368            Ok(())
369        }
370        fn report(
371            &mut self,
372            build_generation: u64,
373            content_digest: [u8; 32],
374        ) -> Result<GenerationReport, String> {
375            Ok(GenerationReport {
376                build_generation,
377                content_digest,
378                component_incarnation: 1,
379                module_generation: 1,
380            })
381        }
382    }
383
384    fn candidate(generation: u64) -> CandidateBytes {
385        CandidateBytes {
386            build_generation: generation,
387            content_digest: [generation as u8; 32],
388            component_beam: vec![generation as u8],
389            ffi_beam: vec![generation as u8, 0xff],
390        }
391    }
392
393    fn engine(mode: NodeMode) -> BarrierEngine {
394        BarrierEngine::new(mode, candidate(0))
395    }
396
397    fn drive(
398        engine: &mut BarrierEngine,
399        node: &mut FakeNode,
400        generation: u64,
401    ) -> (DevReply, Vec<DevStatusEvent>) {
402        let mut events = Vec::new();
403        let reply = engine.handle(
404            DevRequest::StageAndActivate(candidate(generation)),
405            node,
406            &mut |event| events.push(event),
407        );
408        (reply, events)
409    }
410
411    /// The C3 pin at the engine level: a production engine refuses with
412    /// ZERO node operations — the recorded call list is empty.
413    #[test]
414    fn production_engine_refuses_with_zero_node_calls() {
415        let mut node = FakeNode::default();
416        let (reply, events) = drive(&mut engine(NodeMode::Production), &mut node, 1);
417        assert_eq!(reply, DevReply::Refused(StageRefusal::NotADevNode));
418        assert!(node.calls.is_empty(), "no path from request to the node");
419        assert!(
420            events.is_empty(),
421            "no status invented for a refused request"
422        );
423    }
424
425    /// Happy path: barrier order is exactly stop → stage → start →
426    /// mailbox → content, the candidate becomes last-good, and the reply
427    /// carries the report.
428    #[test]
429    fn activation_runs_the_barrier_in_order() {
430        let mut node = FakeNode::default();
431        let mut engine = engine(NodeMode::Dev);
432        let (reply, events) = drive(&mut engine, &mut node, 1);
433        assert_eq!(
434            node.calls,
435            vec!["stop", "stage:1", "start", "mailbox", "content"]
436        );
437        match reply {
438            DevReply::Activated(report) => assert_eq!(report.build_generation, 1),
439            other => panic!("expected Activated, got {other:?}"),
440        }
441        assert!(matches!(
442            events.last(),
443            Some(DevStatusEvent::RunningCurrent(report)) if report.build_generation == 1
444        ));
445    }
446
447    /// Each pre-liveness stage failure rolls back: last-good is re-staged,
448    /// restarted, and RE-PROVEN by both witnesses; the reply names the
449    /// exact failed stage and what is serving.
450    #[test]
451    fn stage_failures_roll_back_and_reprove_last_good() {
452        type Inject = fn(&mut FakeNode);
453        let scripted: [(Inject, ReloadStage); 3] = [
454            (|n| n.fail_stage = true, ReloadStage::PurgeOld),
455            (
456                |n| n.fail_start = Some(StartFailure::Load("bad beam".to_owned())),
457                ReloadStage::LoadCandidate,
458            ),
459            (
460                |n| n.fail_start = Some(StartFailure::Tree("tree died".to_owned())),
461                ReloadStage::StartFresh,
462            ),
463        ];
464        for (inject, expected_stage) in scripted {
465            let mut node = FakeNode::failing(inject, 1);
466            let mut engine = engine(NodeMode::Dev);
467            let (reply, events) = drive(&mut engine, &mut node, 1);
468            match reply {
469                DevReply::RolledBack {
470                    failed, serving, ..
471                } => {
472                    assert_eq!(failed, expected_stage);
473                    assert_eq!(serving.build_generation, 0, "last-good serves");
474                }
475                other => panic!("expected RolledBack at {expected_stage:?}, got {other:?}"),
476            }
477            let tail: Vec<_> = node.calls.iter().rev().take(4).rev().cloned().collect();
478            assert_eq!(
479                tail,
480                vec!["stage:0", "start", "mailbox", "content"],
481                "restore re-proves last good at {expected_stage:?}"
482            );
483            assert!(matches!(
484                events.last(),
485                Some(DevStatusEvent::ReloadFailed { serving, .. })
486                    if serving.build_generation == 0
487            ));
488        }
489    }
490
491    /// A witness failure means the candidate tree is LIVE but unproven:
492    /// rollback stops it before restoring, and the reply still names the
493    /// witness stage.
494    #[test]
495    fn witness_failure_stops_the_unproven_tree_before_restore() {
496        let mut node = FakeNode::failing(|n| n.fail_content = true, 1);
497        let mut engine = engine(NodeMode::Dev);
498        let (reply, _events) = drive(&mut engine, &mut node, 1);
499        match reply {
500            DevReply::RolledBack { failed, .. } => {
501                assert_eq!(failed, ReloadStage::LivenessContent);
502            }
503            other => panic!("expected RolledBack, got {other:?}"),
504        }
505        assert_eq!(
506            node.calls,
507            vec![
508                "stop", "stage:1", "start", "mailbox", "content", // the failed lap
509                "stop",    // the unproven tree is stopped first
510                "stage:0", "start", "mailbox", "content" // restore, re-proven
511            ]
512        );
513    }
514
515    /// Restore failing after a stage failure is NODE FAILED — never a
516    /// false running report — and the detail carries both failures.
517    #[test]
518    fn restore_failure_is_node_failed() {
519        // stage fails for the candidate AND for the restore.
520        let mut node = FakeNode::failing(|n| n.fail_stage = true, 2);
521        let mut engine = engine(NodeMode::Dev);
522        let (reply, events) = drive(&mut engine, &mut node, 1);
523        match reply {
524            DevReply::NodeFailed { failed, detail } => {
525                assert_eq!(failed, ReloadStage::PurgeOld);
526                assert!(detail.contains("restoring last good failed"), "{detail}");
527            }
528            other => panic!("expected NodeFailed, got {other:?}"),
529        }
530        assert!(matches!(
531            events.last(),
532            Some(DevStatusEvent::NodeFailed { .. })
533        ));
534    }
535
536    /// Stale and duplicate generations are refused by identity — a failed
537    /// generation counts as seen and cannot retry under the same number.
538    #[test]
539    fn stale_generations_are_refused_by_identity() {
540        let mut node = FakeNode::failing(|n| n.fail_stage = true, 1);
541        let mut engine = engine(NodeMode::Dev);
542        let (reply, _) = drive(&mut engine, &mut node, 3);
543        assert!(matches!(reply, DevReply::RolledBack { .. }));
544
545        // The same generation again: refused without touching the node.
546        let calls_before = node.calls.len();
547        let (reply, _) = drive(&mut engine, &mut node, 3);
548        assert_eq!(
549            reply,
550            DevReply::Refused(StageRefusal::StaleGeneration {
551                offered: 3,
552                newest_seen: 3
553            })
554        );
555        assert_eq!(node.calls.len(), calls_before);
556
557        // A NEWER generation proceeds and activates.
558        let (reply, _) = drive(&mut engine, &mut node, 4);
559        assert!(matches!(reply, DevReply::Activated(_)));
560    }
561}