Skip to main content

frame_host/dev/
adapter.rs

1//! The node-side management adapter: serves S1 requests and publishes S2
2//! status over the shared `frame:dev-management@v1` vocabulary, driving
3//! the [`BarrierEngine`] behind the [`require_dev_node`] gate.
4//!
5//! ## The pump, sanctioned (brief §Adapter pump sanction, ruled 2026-07-24)
6//!
7//! Requests arrive via frame-conv's `next_request(wait)` — a
8//! quantum-bounded pump whose `Ok(None)` is a benign quiet wait (the
9//! substrate's hardcoded 5 s IO quantum is upstream ASK-2, pinned at
10//! F-3a's tear: "elapsed IO quanta while waiting are benign re-arms").
11//! Ruling B targets polling a consumer INVENTS, not a substrate pump the
12//! consumer has no alternative to. The binding condition is structural:
13//! **quiet iterations are EMPTY** — the `Ok(None)` arm does nothing but
14//! re-arm the same pump (see [`serve`]'s single match; the witness test
15//! `quiet_ticks_do_no_work` fails if the quiet arm ever grows a body).
16//! When upstream ships a close-wakeable wait, this adapter inherits it
17//! for free and the disclosure self-retires.
18//!
19//! [`require_dev_node`]: super::require_dev_node
20
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::time::Duration;
23
24use frame_conv::id::CorrelationId;
25
26use super::{BarrierEngine, DevReply, DevRequest, DevStatusEvent, NodeControl};
27
28/// One decoded inbound management request awaiting its reply.
29pub struct InboundManagement {
30    /// The typed request.
31    pub request: DevRequest,
32    /// Correlation to answer with.
33    pub correlation: CorrelationId,
34}
35
36/// The conversation surface the adapter serves over — the real
37/// implementation wraps `frame_conv::ConversationHandle`; tests script
38/// it. Errors are rendered transport fates: any `Err` means the dev
39/// client's connection is gone and the serve loop exits with it.
40pub trait ManagementConversation {
41    /// Waits up to `wait` for the next inbound request; `Ok(None)` is the
42    /// benign quiet tick.
43    ///
44    /// # Errors
45    ///
46    /// A typed connection fate, rendered.
47    fn next_request(&mut self, wait: Duration) -> Result<Option<InboundManagement>, String>;
48    /// Publishes the exactly-one typed reply for `correlation`.
49    ///
50    /// # Errors
51    ///
52    /// A typed publish failure, rendered.
53    fn reply(&mut self, correlation: CorrelationId, reply: &DevReply) -> Result<(), String>;
54    /// Publishes one S2 status event.
55    ///
56    /// # Errors
57    ///
58    /// A typed publish failure, rendered.
59    fn publish_status(&mut self, event: &DevStatusEvent) -> Result<(), String>;
60}
61
62/// Why the serve loop returned.
63#[derive(Debug, PartialEq, Eq)]
64pub enum ServeExit {
65    /// The close flag was raised: ordered teardown.
66    Closed,
67    /// The conversation's connection died (S3): the dev client is gone.
68    ConnectionLost {
69        /// Exact rendered transport fate.
70        detail: String,
71    },
72}
73
74/// Serves management requests until closed or the connection dies.
75///
76/// Status events raised by the engine mid-activation are published as
77/// they happen; a status-publish failure is recorded and surfaces as the
78/// connection loss it is AFTER the in-flight activation completes — the
79/// node-side barrier never aborts half-way because the observer left.
80pub fn serve<C: ManagementConversation>(
81    conversation: &mut C,
82    engine: &mut BarrierEngine,
83    control: &mut dyn NodeControl,
84    close: &AtomicBool,
85    quantum: Duration,
86) -> ServeExit {
87    loop {
88        if close.load(Ordering::Acquire) {
89            return ServeExit::Closed;
90        }
91        match conversation.next_request(quantum) {
92            // The sanctioned quiet tick: EMPTY by ruling — nothing but
93            // the loop's own re-arm happens here.
94            Ok(None) => {}
95            Ok(Some(inbound)) => {
96                let mut publish_failure: Option<String> = None;
97                let reply = {
98                    let failure = &mut publish_failure;
99                    engine.handle(inbound.request, control, &mut |event| {
100                        if failure.is_none()
101                            && let Err(detail) = conversation_publish(conversation, &event)
102                        {
103                            *failure = Some(detail);
104                        }
105                    })
106                };
107                if let Err(detail) = conversation.reply(inbound.correlation, &reply) {
108                    return ServeExit::ConnectionLost { detail };
109                }
110                if let Some(detail) = publish_failure {
111                    return ServeExit::ConnectionLost { detail };
112                }
113            }
114            Err(detail) => return ServeExit::ConnectionLost { detail },
115        }
116    }
117}
118
119/// Free helper so the closure above can publish without capturing
120/// `conversation` mutably twice.
121fn conversation_publish<C: ManagementConversation>(
122    conversation: &mut C,
123    event: &DevStatusEvent,
124) -> Result<(), String> {
125    conversation.publish_status(event)
126}
127
128#[cfg(test)]
129mod tests {
130    #![allow(clippy::expect_used, clippy::panic)]
131
132    use std::sync::atomic::AtomicBool;
133    use std::time::Duration;
134
135    use frame_conv::id::CorrelationId;
136
137    use super::{InboundManagement, ManagementConversation, ServeExit, serve};
138    use crate::dev::{
139        BarrierEngine, CandidateBytes, DevReply, DevRequest, DevStatusEvent, GenerationReport,
140        NodeControl, NodeMode, StageRefusal, StartFailure,
141    };
142
143    const QUANTUM: Duration = Duration::from_millis(1);
144
145    /// Scripted conversation: a queue of pump outcomes, recorded replies
146    /// and publishes.
147    struct FakeConversation {
148        script: Vec<Option<InboundManagement>>,
149        replies: Vec<DevReply>,
150        published: Vec<DevStatusEvent>,
151        pump_ticks: usize,
152    }
153
154    impl FakeConversation {
155        fn new(mut script: Vec<Option<InboundManagement>>) -> Self {
156            script.reverse();
157            Self {
158                script,
159                replies: Vec::new(),
160                published: Vec::new(),
161                pump_ticks: 0,
162            }
163        }
164    }
165
166    impl ManagementConversation for FakeConversation {
167        fn next_request(&mut self, _wait: Duration) -> Result<Option<InboundManagement>, String> {
168            self.pump_ticks += 1;
169            match self.script.pop() {
170                Some(entry) => Ok(entry),
171                None => Err("script exhausted: connection closed".to_owned()),
172            }
173        }
174        fn reply(&mut self, _correlation: CorrelationId, reply: &DevReply) -> Result<(), String> {
175            self.replies.push(reply.clone());
176            Ok(())
177        }
178        fn publish_status(&mut self, event: &DevStatusEvent) -> Result<(), String> {
179            self.published.push(event.clone());
180            Ok(())
181        }
182    }
183
184    /// A node that counts every operation — the quiet-tick witness's
185    /// authority.
186    #[derive(Default)]
187    struct CountingNode {
188        operations: usize,
189    }
190
191    impl NodeControl for CountingNode {
192        fn stop_component(&mut self) -> Result<(), String> {
193            self.operations += 1;
194            Ok(())
195        }
196        fn stage(&mut self, _candidate: &CandidateBytes) -> Result<(), String> {
197            self.operations += 1;
198            Ok(())
199        }
200        fn start_component(&mut self) -> Result<(), StartFailure> {
201            self.operations += 1;
202            Ok(())
203        }
204        fn witness_mailbox(&mut self) -> Result<(), String> {
205            self.operations += 1;
206            Ok(())
207        }
208        fn witness_content(&mut self) -> Result<(), String> {
209            self.operations += 1;
210            Ok(())
211        }
212        fn report(
213            &mut self,
214            build_generation: u64,
215            content_digest: [u8; 32],
216        ) -> Result<GenerationReport, String> {
217            self.operations += 1;
218            Ok(GenerationReport {
219                build_generation,
220                content_digest,
221                component_incarnation: 1,
222                module_generation: 1,
223            })
224        }
225    }
226
227    fn candidate(generation: u64) -> CandidateBytes {
228        CandidateBytes {
229            build_generation: generation,
230            content_digest: [0; 32],
231            component_beam: vec![1],
232            ffi_beam: vec![2],
233        }
234    }
235
236    fn inbound(generation: u64) -> InboundManagement {
237        InboundManagement {
238            request: DevRequest::StageAndActivate(candidate(generation)),
239            correlation: CorrelationId::mint(),
240        }
241    }
242
243    /// THE quiet-tick witness (the ruling's structural condition): quiet
244    /// pump iterations perform ZERO node operations, publish nothing, and
245    /// reply to nothing — the pump is the loop's only re-arm source.
246    #[test]
247    fn quiet_ticks_do_no_work() {
248        let mut conversation = FakeConversation::new(vec![None, None, None, None, None]);
249        let mut engine = BarrierEngine::new(NodeMode::Dev, candidate(0));
250        let mut node = CountingNode::default();
251        let close = AtomicBool::new(false);
252
253        let exit = serve(&mut conversation, &mut engine, &mut node, &close, QUANTUM);
254        // The script ends in a connection fate after five quiet ticks.
255        assert!(matches!(exit, ServeExit::ConnectionLost { .. }));
256        assert_eq!(conversation.pump_ticks, 6, "five quiet ticks + the fate");
257        assert_eq!(node.operations, 0, "quiet ticks touch the node ZERO times");
258        assert!(
259            conversation.published.is_empty(),
260            "quiet ticks publish nothing"
261        );
262        assert!(
263            conversation.replies.is_empty(),
264            "quiet ticks reply to nothing"
265        );
266    }
267
268    /// A request between quiet ticks is served exactly once: one reply,
269    /// status events published as the barrier moves.
270    #[test]
271    fn requests_are_served_between_quiet_ticks() {
272        let mut conversation = FakeConversation::new(vec![None, Some(inbound(1)), None]);
273        let mut engine = BarrierEngine::new(NodeMode::Dev, candidate(0));
274        let mut node = CountingNode::default();
275        let close = AtomicBool::new(false);
276
277        let exit = serve(&mut conversation, &mut engine, &mut node, &close, QUANTUM);
278        assert!(matches!(exit, ServeExit::ConnectionLost { .. }));
279        assert_eq!(conversation.replies.len(), 1, "exactly one reply");
280        assert!(matches!(conversation.replies[0], DevReply::Activated(_)));
281        assert!(
282            matches!(
283                conversation.published.last(),
284                Some(DevStatusEvent::RunningCurrent(_))
285            ),
286            "the barrier's status transitions were published"
287        );
288    }
289
290    /// The close flag wins before the next pump arm — ordered teardown,
291    /// not an error.
292    #[test]
293    fn close_flag_exits_ordered() {
294        let mut conversation = FakeConversation::new(vec![None]);
295        let mut engine = BarrierEngine::new(NodeMode::Dev, candidate(0));
296        let mut node = CountingNode::default();
297        let close = AtomicBool::new(true);
298
299        let exit = serve(&mut conversation, &mut engine, &mut node, &close, QUANTUM);
300        assert_eq!(exit, ServeExit::Closed);
301        assert_eq!(conversation.pump_ticks, 0, "no pump arm after close");
302    }
303
304    /// A production engine behind the adapter still refuses with zero
305    /// node calls — the C3 pin holds through the full serve path.
306    #[test]
307    fn production_refusal_holds_through_the_adapter() {
308        let mut conversation = FakeConversation::new(vec![Some(inbound(1))]);
309        let mut engine = BarrierEngine::new(NodeMode::Production, candidate(0));
310        let mut node = CountingNode::default();
311        let close = AtomicBool::new(false);
312
313        let _exit = serve(&mut conversation, &mut engine, &mut node, &close, QUANTUM);
314        assert_eq!(
315            conversation.replies[0],
316            DevReply::Refused(StageRefusal::NotADevNode)
317        );
318        assert_eq!(node.operations, 0);
319        assert!(conversation.published.is_empty());
320    }
321}