Skip to main content

subc_daemon/
watchdog.rs

1use std::{
2    collections::BTreeSet,
3    fmt,
4    net::{IpAddr, SocketAddr},
5    path::{Path, PathBuf},
6    time::Duration,
7};
8
9use subc_control::{ClientControlRequest, ClientControlResponse};
10use subc_protocol::{ErrorBody, Flags, FrameType, Priority};
11use subc_transport::{
12    authenticate_client_with_role, connection_file, ConnectionFileError, ConnectionInfo,
13    WATCHDOG_CLIENT_ROLE,
14};
15use tokio::{
16    io::AsyncWriteExt,
17    net::TcpStream,
18    task::JoinHandle,
19    time::{self, Instant},
20};
21use tracing::{error, info, warn};
22
23use crate::{read_frame, write_frame, Frame};
24
25pub const DEFAULT_SELF_WATCHDOG_INTERVAL: Duration = Duration::from_secs(60);
26const CLOCK_STEP_CHECK_INTERVAL: Duration = Duration::from_secs(5);
27
28pub(crate) fn spawn_clock_step_monitor() -> JoinHandle<()> {
29    tokio::spawn(async {
30        let mut detector = crate::clock::ClockStepDetector::new();
31        // Establish the first offset at startup, before the first periodic tick.
32        detector.check_now();
33        let mut interval = time::interval(CLOCK_STEP_CHECK_INTERVAL);
34        interval.tick().await;
35        loop {
36            interval.tick().await;
37            if let Some(step) = detector.check_now() {
38                record_clock_step(&step, std::time::SystemTime::now());
39            }
40        }
41    })
42}
43/// Log a wall-clock step where a reader will find it.
44///
45/// Day segments are named by the wall clock at write time, so a step that
46/// crosses UTC midnight files the lines before it under one day and the lines
47/// after it under another. The ordinary `warn!` lands in the corrected day's
48/// segment; when the pre-step clock names a different segment, the same record
49/// is also written there, stamped with the pre-step clock, so it sits beside
50/// the lines that clock stamped. Without it, a reader opening that segment finds
51/// the lines and not the warning, and a boot recorded under the wrong clock
52/// reads as a real boot at the wrong time.
53fn record_clock_step(step: &crate::clock::ClockStep, wall_now: std::time::SystemTime) {
54    let direction = if step.delta_ms > 0 {
55        "forward"
56    } else {
57        "backward"
58    };
59    let pre_step = pre_step_copy(step, wall_now);
60    warn!(
61        step_ms = step.delta_ms.abs(),
62        direction,
63        old_offset_ms = step.old_offset_ms,
64        new_offset_ms = step.new_offset_ms,
65        pre_step_segment = pre_step.as_ref().map(|(_, segment)| segment.as_str()),
66        "wall clock stepped; timestamps around this point in the log may not be monotonic"
67    );
68    let Some((old_clock_now, _)) = pre_step else {
69        return;
70    };
71    if let Some(logger) = cortexkit_log::installed() {
72        logger.emit_at(
73            old_clock_now,
74            tracing::Level::WARN,
75            "subc",
76            "wall clock stepped; this copy is stamped with the pre-step clock so it sits beside the lines that clock filed here",
77            &[
78                ("step_ms".to_owned(), step.delta_ms.abs().to_string()),
79                ("direction".to_owned(), direction.to_owned()),
80                (
81                    "corrected_segment".to_owned(),
82                    cortexkit_log::segment_name("subc", wall_now),
83                ),
84            ],
85        );
86    }
87}
88
89/// The instant and segment for the pre-step copy of a step marker, or `None`
90/// when the pre-step clock names the same day segment as the corrected one
91/// (then the ordinary marker is already beside the lines, and a second copy
92/// would only duplicate it).
93fn pre_step_copy(
94    step: &crate::clock::ClockStep,
95    wall_now: std::time::SystemTime,
96) -> Option<(std::time::SystemTime, String)> {
97    let old_clock_now = step.old_clock_reading(wall_now);
98    let pre_step_segment = cortexkit_log::segment_name("subc", old_clock_now);
99    (pre_step_segment != cortexkit_log::segment_name("subc", wall_now))
100        .then_some((old_clock_now, pre_step_segment))
101}
102
103pub const DEFAULT_SELF_WATCHDOG_DEADLINE: Duration = Duration::from_secs(5);
104
105#[derive(Debug, Clone)]
106pub struct DaemonSelfWatchdogConfig {
107    interval: Duration,
108    deadline: Duration,
109}
110
111impl Default for DaemonSelfWatchdogConfig {
112    fn default() -> Self {
113        Self {
114            interval: DEFAULT_SELF_WATCHDOG_INTERVAL,
115            deadline: DEFAULT_SELF_WATCHDOG_DEADLINE,
116        }
117    }
118}
119
120impl DaemonSelfWatchdogConfig {
121    pub fn with_interval(mut self, interval: Duration) -> Self {
122        self.interval = interval;
123        self
124    }
125
126    pub fn with_deadline(mut self, deadline: Duration) -> Self {
127        self.deadline = deadline;
128        self
129    }
130
131    pub fn interval(&self) -> Duration {
132        self.interval
133    }
134
135    pub fn deadline(&self) -> Duration {
136        self.deadline
137    }
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum WatchdogStage {
142    Connect,
143    Authenticate,
144    Describe,
145    ConnectionFile,
146    Timeout,
147}
148
149impl WatchdogStage {
150    pub fn as_str(self) -> &'static str {
151        match self {
152            Self::Connect => "connect",
153            Self::Authenticate => "authenticate",
154            Self::Describe => "describe",
155            Self::ConnectionFile => "connection_file",
156            Self::Timeout => "timeout",
157        }
158    }
159}
160
161#[derive(Debug, Clone)]
162pub struct WatchdogTickError {
163    stage: WatchdogStage,
164    message: String,
165}
166
167impl WatchdogTickError {
168    fn new(stage: WatchdogStage, message: impl Into<String>) -> Self {
169        Self {
170            stage,
171            message: message.into(),
172        }
173    }
174
175    pub fn stage(&self) -> WatchdogStage {
176        self.stage
177    }
178}
179
180impl fmt::Display for WatchdogTickError {
181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182        write!(f, "{}", self.message)
183    }
184}
185
186impl std::error::Error for WatchdogTickError {}
187
188#[derive(Debug, Clone)]
189pub struct DaemonSelfWatchdog {
190    live_connection_info: ConnectionInfo,
191    connection_file_path: PathBuf,
192    config: DaemonSelfWatchdogConfig,
193}
194
195impl DaemonSelfWatchdog {
196    pub fn new(
197        live_connection_info: ConnectionInfo,
198        connection_file_path: impl Into<PathBuf>,
199    ) -> Self {
200        Self {
201            live_connection_info,
202            connection_file_path: connection_file_path.into(),
203            config: DaemonSelfWatchdogConfig::default(),
204        }
205    }
206
207    pub fn with_config(mut self, config: DaemonSelfWatchdogConfig) -> Self {
208        self.config = config;
209        self
210    }
211
212    pub fn spawn(self) -> JoinHandle<()> {
213        tokio::spawn(async move {
214            self.run().await;
215        })
216    }
217
218    pub async fn run_once(&self) -> Result<(), WatchdogTickError> {
219        self.verify_loopback().await?;
220        self.verify_connection_file()?;
221        Ok(())
222    }
223
224    async fn run(self) {
225        let mut consecutive_failures = 0u64;
226        let mut tick_index = 0u64;
227        loop {
228            time::sleep_until(
229                Instant::now()
230                    + jittered_watchdog_delay(
231                        &self.live_connection_info,
232                        tick_index,
233                        self.config.interval(),
234                    ),
235            )
236            .await;
237            tick_index = tick_index.wrapping_add(1);
238
239            let result = time::timeout(self.config.deadline(), self.run_once()).await;
240            match result {
241                Ok(Ok(())) => {
242                    if consecutive_failures > 0 {
243                        info!(
244                            connection_file = %self.connection_file_path.display(),
245                            failure_streak = consecutive_failures,
246                            "daemon self-watchdog recovered"
247                        );
248                        consecutive_failures = 0;
249                    }
250                }
251                Ok(Err(err)) => {
252                    consecutive_failures = consecutive_failures.saturating_add(1);
253                    error!(
254                        connection_file = %self.connection_file_path.display(),
255                        stage = err.stage().as_str(),
256                        consecutive_failures,
257                        error = %err,
258                        "daemon self-watchdog tick failed"
259                    );
260                }
261                Err(_) => {
262                    consecutive_failures = consecutive_failures.saturating_add(1);
263                    error!(
264                        connection_file = %self.connection_file_path.display(),
265                        stage = WatchdogStage::Timeout.as_str(),
266                        consecutive_failures,
267                        deadline_ms = self.config.deadline().as_millis(),
268                        "daemon self-watchdog tick failed"
269                    );
270                }
271            }
272        }
273    }
274
275    async fn verify_loopback(&self) -> Result<(), WatchdogTickError> {
276        let endpoint = self.live_connection_info.endpoints.first().ok_or_else(|| {
277            WatchdogTickError::new(
278                WatchdogStage::Describe,
279                "live connection info has no endpoint",
280            )
281        })?;
282        let ip = endpoint.host.parse::<IpAddr>().map_err(|err| {
283            WatchdogTickError::new(
284                WatchdogStage::Connect,
285                format!(
286                    "published endpoint host '{}' is not an IP: {err}",
287                    endpoint.host
288                ),
289            )
290        })?;
291        let addr = SocketAddr::new(ip, endpoint.port);
292        let mut stream = TcpStream::connect(addr).await.map_err(|err| {
293            WatchdogTickError::new(WatchdogStage::Connect, format!("connect {addr}: {err}"))
294        })?;
295
296        authenticate_client_with_role(
297            &mut stream,
298            &self.live_connection_info,
299            self.config.deadline(),
300            WATCHDOG_CLIENT_ROLE,
301        )
302        .await
303        .map_err(|err| {
304            WatchdogTickError::new(
305                WatchdogStage::Authenticate,
306                format!("authenticate to {addr}: {err}"),
307            )
308        })?;
309
310        let request = control_request_frame()?;
311        write_frame(&mut stream, &request).await.map_err(|err| {
312            WatchdogTickError::new(
313                WatchdogStage::Describe,
314                format!("write server.describe request to {addr}: {err}"),
315            )
316        })?;
317
318        loop {
319            let Some(reply) = read_frame(&mut stream).await.map_err(|err| {
320                WatchdogTickError::new(
321                    WatchdogStage::Describe,
322                    format!("read server.describe reply from {addr}: {err}"),
323                )
324            })?
325            else {
326                return Err(WatchdogTickError::new(
327                    WatchdogStage::Describe,
328                    format!(
329                        "daemon {addr} closed the connection before replying to server.describe"
330                    ),
331                ));
332            };
333
334            if reply.header.channel != 0 {
335                continue;
336            }
337            match reply.header.ty {
338                FrameType::Response => {
339                    if reply.header.corr != request.header.corr {
340                        return Err(WatchdogTickError::new(
341                            WatchdogStage::Describe,
342                            format!(
343                                "server.describe reply correlation mismatch: expected {}, got {}",
344                                request.header.corr, reply.header.corr
345                            ),
346                        ));
347                    }
348                    match serde_json::from_slice::<ClientControlResponse>(&reply.body) {
349                        Ok(ClientControlResponse::ServerDescribe { .. }) => {
350                            let _ = stream.shutdown().await;
351                            return Ok(());
352                        }
353                        Ok(other) => {
354                            return Err(WatchdogTickError::new(
355                                WatchdogStage::Describe,
356                                format!("unexpected server.describe reply: {other:?}"),
357                            ));
358                        }
359                        Err(err) => {
360                            return Err(WatchdogTickError::new(
361                                WatchdogStage::Describe,
362                                format!("decode server.describe reply: {err}"),
363                            ));
364                        }
365                    }
366                }
367                FrameType::Error => {
368                    return Err(WatchdogTickError::new(
369                        WatchdogStage::Describe,
370                        format!(
371                            "server.describe rejected: {}",
372                            decode_error_body(&reply.body)
373                        ),
374                    ));
375                }
376                _ => continue,
377            }
378        }
379    }
380
381    fn verify_connection_file(&self) -> Result<(), WatchdogTickError> {
382        let file_info = connection_file::read_for_client(&self.connection_file_path)
383            .map_err(|err| map_connection_file_error(&self.connection_file_path, err))?;
384
385        let live_port = self
386            .live_connection_info
387            .endpoints
388            .first()
389            .map(|endpoint| endpoint.port)
390            .ok_or_else(|| {
391                WatchdogTickError::new(
392                    WatchdogStage::ConnectionFile,
393                    "live connection info has no endpoint",
394                )
395            })?;
396        let file_ports = file_info
397            .endpoints
398            .iter()
399            .map(|endpoint| endpoint.port)
400            .collect::<BTreeSet<_>>();
401
402        let mut divergences = Vec::new();
403        if file_ports.len() != 1 || !file_ports.contains(&live_port) {
404            divergences.push(format!(
405                "port (live={live_port}, file={:?})",
406                file_ports.into_iter().collect::<Vec<_>>()
407            ));
408        }
409        if file_info.key != self.live_connection_info.key {
410            divergences.push("key".to_owned());
411        }
412        if file_info.wire_version != self.live_connection_info.wire_version {
413            divergences.push(format!(
414                "wire_version (live={:?}, file={:?})",
415                self.live_connection_info.wire_version, file_info.wire_version
416            ));
417        }
418        if file_info.daemon_id != self.live_connection_info.daemon_id {
419            divergences.push("daemon_id".to_owned());
420        }
421
422        if divergences.is_empty() {
423            Ok(())
424        } else {
425            Err(WatchdogTickError::new(
426                WatchdogStage::ConnectionFile,
427                format!("connection file divergence: {}", divergences.join(", ")),
428            ))
429        }
430    }
431}
432
433fn control_request_frame() -> Result<Frame, WatchdogTickError> {
434    let body = serde_json::to_vec(&ClientControlRequest::ServerDescribe {}).map_err(|err| {
435        WatchdogTickError::new(
436            WatchdogStage::Describe,
437            format!("encode server.describe request: {err}"),
438        )
439    })?;
440    Frame::build(
441        FrameType::Request,
442        Flags::new(false, Priority::Interactive, false),
443        0,
444        0,
445        1,
446        body,
447    )
448    .map_err(|err| {
449        WatchdogTickError::new(
450            WatchdogStage::Describe,
451            format!("build server.describe request frame: {err}"),
452        )
453    })
454}
455
456fn decode_error_body(body: &[u8]) -> String {
457    match serde_json::from_slice::<ErrorBody>(body) {
458        Ok(error) => format!("{} — {}", error.code, error.message),
459        Err(_) => String::from_utf8_lossy(body).into_owned(),
460    }
461}
462
463fn map_connection_file_error(path: &Path, err: ConnectionFileError) -> WatchdogTickError {
464    let message = match err {
465        ConnectionFileError::Io { op, source, .. } => {
466            format!("connection file {} {}: {}", path.display(), op, source)
467        }
468        ConnectionFileError::JsonRead { source, .. } => {
469            format!(
470                "connection file {} parse failed: {}",
471                path.display(),
472                source
473            )
474        }
475        ConnectionFileError::UnsupportedSchema { schema, supported } => format!(
476            "connection file {} schema mismatch: file={}, supported={}",
477            path.display(),
478            schema,
479            supported
480        ),
481        ConnectionFileError::WireVersionMismatch { file, supported } => format!(
482            "connection file {} wire version mismatch: file={}, supported={}; the binary must be upgraded",
483            path.display(),
484            file,
485            supported
486        ),
487        ConnectionFileError::Invalid { reason } => {
488            format!("connection file {} invalid: {}", path.display(), reason)
489        }
490        ConnectionFileError::KeyTooShort { len, min } => format!(
491            "connection file {} key is too short: len={}, min={}",
492            path.display(),
493            len,
494            min
495        ),
496        ConnectionFileError::InsecurePermissions { mode, .. } => format!(
497            "connection file {} permissions are not owner-only: mode={mode:#o}",
498            path.display()
499        ),
500        other => format!("connection file {} error: {other}", path.display()),
501    };
502    WatchdogTickError::new(WatchdogStage::ConnectionFile, message)
503}
504
505fn jittered_watchdog_delay(
506    live_connection_info: &ConnectionInfo,
507    tick_index: u64,
508    interval: Duration,
509) -> Duration {
510    if interval.is_zero() {
511        return Duration::ZERO;
512    }
513    let interval_ms = interval.as_millis() as u64;
514    if interval_ms == 0 {
515        return interval;
516    }
517
518    let jitter_span = (interval_ms / 10).max(1);
519    let hash = live_connection_info.daemon_id.iter().fold(
520        tick_index.wrapping_mul(0x9E37_79B9_7F4A_7C15),
521        |acc, byte| {
522            acc.wrapping_mul(1099511628211)
523                .wrapping_add(u64::from(*byte))
524        },
525    );
526    let offset = (hash % (jitter_span.saturating_mul(2).saturating_add(1))) as i128
527        - i128::from(jitter_span);
528    let jittered_ms = (i128::from(interval_ms) + offset).max(0) as u64;
529    Duration::from_millis(jittered_ms)
530}
531
532impl fmt::Display for DaemonSelfWatchdogConfig {
533    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
534        write!(
535            f,
536            "interval={:?}, deadline={:?}",
537            self.interval(),
538            self.deadline()
539        )
540    }
541}
542
543impl fmt::Display for WatchdogStage {
544    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
545        f.write_str(self.as_str())
546    }
547}
548
549#[cfg(test)]
550mod clock_step_tests {
551    use std::time::{Duration, UNIX_EPOCH};
552
553    use super::pre_step_copy;
554    use crate::clock::ClockStep;
555
556    fn at(ms: u64) -> std::time::SystemTime {
557        UNIX_EPOCH + Duration::from_millis(ms)
558    }
559
560    /// The boot this was found on: an RTC holding local time (UTC+2) booted at
561    /// 22:14Z on 09-20, so the clock read 09-21 00:14 until NTP stepped it back
562    /// two hours at 22:15:17Z. Lines before the step went to the 09-21 segment;
563    /// the marker, written after, would land in 09-20's.
564    #[test]
565    fn a_step_across_utc_midnight_places_a_copy_in_the_pre_step_segment() {
566        let corrected = at(1_789_942_517_000); // 2026-09-20T22:15:17Z
567        let step = ClockStep {
568            delta_ms: -7_200_000,
569            old_offset_ms: 0,
570            new_offset_ms: -7_200_000,
571        };
572        let (instant, segment) = pre_step_copy(&step, corrected)
573            .expect("a midnight-crossing step needs a pre-step copy");
574        assert_eq!(segment, "subc.2026-09-21.log");
575        assert_eq!(instant, at(1_789_949_717_000)); // 2026-09-21T00:15:17Z
576        assert_eq!(
577            cortexkit_log::segment_name("subc", corrected),
578            "subc.2026-09-20.log",
579            "the ordinary marker lands in the other day's segment"
580        );
581    }
582
583    #[test]
584    fn a_step_inside_one_utc_day_writes_no_second_copy() {
585        let corrected = at(1_789_905_600_000); // 2026-09-20T12:00:00Z
586        let step = ClockStep {
587            delta_ms: 9_000,
588            old_offset_ms: 0,
589            new_offset_ms: 9_000,
590        };
591        assert_eq!(pre_step_copy(&step, corrected), None);
592    }
593}