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 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}
43fn 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
89fn 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 #[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); 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)); 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); 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}