Skip to main content

kunobi_daemon/control/
watch.rs

1//! WATCH carries complete state, rather than an unbounded transition history.
2use super::ControlService;
3use crate::wire::{
4    self, Control, Hello, LifecycleChange, LifecycleEvent, MessageKind, capability, operation,
5};
6use buffa::Message;
7use std::{io, time::Duration};
8use tokio::{
9    io::{AsyncRead, AsyncWrite},
10    sync::watch,
11    time::Instant,
12};
13
14#[derive(Clone, PartialEq)]
15struct Published {
16    generation: Option<u64>,
17    build: String,
18    retiring: bool,
19}
20
21pub(super) struct Publisher(watch::Sender<Published>);
22impl Publisher {
23    pub(super) fn new() -> Self {
24        Self(
25            watch::channel(Published {
26                generation: None,
27                build: String::new(),
28                retiring: false,
29            })
30            .0,
31        )
32    }
33    pub(super) fn notify(&self) {
34        self.0.send_modify(|_| {});
35    }
36}
37
38fn invalid(message: &'static str) -> io::Error {
39    io::Error::new(io::ErrorKind::InvalidData, message)
40}
41
42impl ControlService {
43    /// Announce an already committed selection. Call only after publication
44    /// succeeds; this method does not write the application's discovery record.
45    /// A generation cannot move backwards or change its build identity. Until
46    /// the first call, WATCH reports no observed selection. A staged process
47    /// may observe an incumbent with a lower generation than its own.
48    pub fn selection_committed(&self, generation: u64, build: String) -> io::Result<()> {
49        if generation == self.generation && build != self.build {
50            return Err(invalid("selection does not match serving build"));
51        }
52        let mut result = Ok(());
53        self.events.0.send_if_modified(|state| {
54            if state
55                .generation
56                .is_some_and(|previous| generation < previous)
57                || (Some(generation) == state.generation && build != state.build)
58            {
59                result = Err(invalid("selection must advance monotonically"));
60                return false;
61            }
62            if Some(generation) == state.generation {
63                return false;
64            }
65            state.generation = Some(generation);
66            state.build = build;
67            true
68        });
69        result
70    }
71
72    /// Close admission and announce retirement. Existing requests keep their
73    /// leases; the caller still owns draining and process shutdown.
74    pub fn mark_retiring(&self) {
75        self.lifecycle.start_drain();
76        self.events.0.send_if_modified(|state| {
77            if state.retiring {
78                return false;
79            }
80            state.retiring = true;
81            true
82        });
83    }
84
85    pub(super) fn validate_watch(&self, request: &Control) -> io::Result<()> {
86        if !request.payload.is_empty()
87            || !request.token.is_empty()
88            || request.offset.is_some()
89            || (request.generation != 0 && request.generation != self.generation)
90        {
91            return Err(invalid("invalid WATCH request"));
92        }
93        Ok(())
94    }
95
96    pub(super) async fn serve_watch<S: AsyncRead + AsyncWrite + Unpin>(
97        &self,
98        mut session: wire::AsyncSession<S>,
99        request: Control,
100    ) -> io::Result<()> {
101        // Subscribe before sampling so publication between those operations
102        // cannot be lost. The channel retains state for late subscribers.
103        let mut changes = self.events.0.subscribe();
104        let mut previous: Option<LifecycleEvent> = None;
105        let mut sequence = 0u64;
106        loop {
107            let published = changes.borrow_and_update().clone();
108            let draining = self.lifecycle.snapshot().draining;
109            let mut event = LifecycleEvent {
110                process_id: self.process_id,
111                generation: self.generation,
112                build: self.build.clone(),
113                ready: self.ready.load(std::sync::atomic::Ordering::Acquire) && !draining,
114                draining,
115                selected_generation: published.generation,
116                selected_build: published.build,
117                retiring: published.retiring,
118                ..Default::default()
119            };
120            if let Some(old) = &previous {
121                let changed = [
122                    (
123                        event.ready != old.ready && event.ready,
124                        LifecycleChange::Ready,
125                    ),
126                    (event.draining != old.draining, LifecycleChange::Draining),
127                    (
128                        event.selected_generation != old.selected_generation,
129                        LifecycleChange::SelectionChanged,
130                    ),
131                    (event.retiring != old.retiring, LifecycleChange::Retiring),
132                ];
133                let mut kinds = changed
134                    .into_iter()
135                    .filter_map(|(changed, kind)| changed.then_some(kind));
136                let Some(first) = kinds.next() else {
137                    tokio::select! {
138                        result = session.subscription_closed() => return result,
139                        _ = changes.changed() => {},
140                        _ = self.lifecycle.draining(), if !draining => {},
141                    }
142                    continue;
143                };
144                event.change = if kinds.next().is_none() {
145                    first
146                } else {
147                    LifecycleChange::Snapshot
148                }
149                .into();
150            }
151            sequence = sequence
152                .checked_add(1)
153                .ok_or_else(|| invalid("WATCH sequence exhausted"))?;
154            event.sequence = sequence;
155            let frame = Control {
156                operation: operation::WATCH,
157                request_id: request.request_id,
158                generation: self.generation,
159                payload: event.encode_to_vec(),
160                ..Default::default()
161            };
162            // Idle subscribers have no timer. A client that stops reading gets
163            // a bounded write, so it cannot retain control admission forever.
164            tokio::time::timeout(Duration::from_secs(5), session.send(&frame))
165                .await
166                .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))??;
167            previous = Some(event);
168        }
169    }
170}
171
172/// A negotiated lifecycle subscription. A peer without WATCH returns `None`
173/// during setup so the application may retain discovery polling. An error
174/// after advertising WATCH is terminal; it never triggers a protocol downgrade.
175///
176/// Authenticate the OS peer before connecting and compare `snapshot().process_id`
177/// with the peer PID the kernel reports for this connection. Agreement checks the
178/// protocol's claim against transport evidence; it does not authenticate the
179/// executable or any discovery record. Cancelling `changed` poisons the
180/// underlying framed session; drop it and reconnect rather than continuing from
181/// a partial frame.
182pub struct WatchClient<S> {
183    session: wire::AsyncSession<S>,
184    request: Control,
185    latest: LifecycleEvent,
186    failed: bool,
187}
188impl<S: AsyncRead + AsyncWrite + Unpin> WatchClient<S> {
189    /// Negotiate, subscribe, and receive the initial snapshot under one deadline.
190    pub async fn connect(stream: S, offer: &Hello, deadline: Instant) -> io::Result<Option<Self>> {
191        if offer.supported & capability::WATCH == 0 {
192            return Err(invalid("client offer does not support WATCH"));
193        }
194        tokio::time::timeout_at(deadline, async {
195            let mut session = wire::AsyncSession::connect(stream, offer).await?;
196            if session.agreement().capabilities & capability::WATCH == 0 {
197                return Ok(None);
198            }
199            let request = Control {
200                operation: operation::WATCH,
201                ..Default::default()
202            };
203            session.send(&request).await?;
204            let latest = decode_event(&session.receive().await?, &request)?;
205            if latest.sequence != 1 || latest.change != LifecycleChange::Snapshot {
206                return Err(invalid("WATCH must start with a snapshot"));
207            }
208            Ok(Some(Self {
209                session,
210                request,
211                latest,
212                failed: false,
213            }))
214        })
215        .await
216        .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))?
217    }
218
219    /// Most recently received state, including the initial snapshot.
220    pub fn snapshot(&self) -> &LifecycleEvent {
221        &self.latest
222    }
223
224    /// Wait for new state. There is no background polling or idle timeout.
225    pub async fn changed(&mut self) -> io::Result<&LifecycleEvent> {
226        if self.failed {
227            return Err(invalid("WATCH failed; reconnect"));
228        }
229        self.failed = true;
230        let event = decode_event(&self.session.receive().await?, &self.request)?;
231        if self.latest.sequence.checked_add(1) != Some(event.sequence)
232            || event.process_id != self.latest.process_id
233            || event.generation != self.latest.generation
234            || event.build != self.latest.build
235            || event.selected_generation < self.latest.selected_generation
236            || (event.selected_generation == self.latest.selected_generation
237                && event.selected_build != self.latest.selected_build)
238            || (self.latest.draining && !event.draining)
239            || (self.latest.retiring && !event.retiring)
240        {
241            return Err(invalid("inconsistent WATCH event"));
242        }
243        self.latest = event;
244        self.failed = false;
245        Ok(&self.latest)
246    }
247}
248
249fn decode_event(reply: &Control, request: &Control) -> io::Result<LifecycleEvent> {
250    if reply.kind != MessageKind::Lifecycle
251        || reply.operation != operation::WATCH
252        || reply.request_id != request.request_id
253        || !reply.token.is_empty()
254        || reply.offset.is_some()
255    {
256        return Err(invalid("unexpected WATCH response"));
257    }
258    let event: LifecycleEvent = wire::decode_message(&reply.payload, wire::MAX_FRAME)?;
259    if event.change.as_known().is_none()
260        || event.process_id == 0
261        || event.generation != reply.generation
262        || (event.selected_generation.is_none() && !event.selected_build.is_empty())
263        || (event.selected_generation == Some(event.generation)
264            && event.selected_build != event.build)
265        || (event.change == LifecycleChange::SelectionChanged
266            && event.selected_generation.is_none())
267        || (event.ready && event.draining)
268        || (event.retiring && !event.draining)
269        || (event.change == LifecycleChange::Ready && !event.ready)
270        || (event.change == LifecycleChange::Draining && !event.draining)
271        || (event.change == LifecycleChange::Retiring && !event.retiring)
272    {
273        return Err(invalid("invalid WATCH state"));
274    }
275    Ok(event)
276}