Skip to content

Commit b076b91

Browse files
authored
feat(daemon): add protocol version compatibility checks (#141)
1 parent 819fa36 commit b076b91

File tree

5 files changed

+283
-34
lines changed

5 files changed

+283
-34
lines changed

.github/workflows/ci.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,22 @@ jobs:
8989
workspaces: cli
9090
- run: cargo check --all-targets --all-features
9191

92+
rust-test:
93+
name: Rust Test
94+
needs: changes
95+
if: needs.changes.outputs.rust == 'true'
96+
runs-on: macos-latest
97+
defaults:
98+
run:
99+
working-directory: ./cli
100+
steps:
101+
- uses: actions/checkout@v6
102+
- uses: dtolnay/rust-toolchain@stable
103+
- uses: Swatinem/rust-cache@v2
104+
with:
105+
workspaces: cli
106+
- run: cargo test --workspace
107+
92108
rust-build:
93109
name: Build ${{ matrix.target }}
94110
needs: changes

cli/src/app/daemon.rs

Lines changed: 38 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use color_eyre::eyre::Result;
99
use tracing::{debug, info};
1010

1111
use super::App;
12-
use crate::daemon::{DaemonClient, DataSnapshot};
12+
use crate::daemon::{ClientError, DaemonClient, DataSnapshot};
1313

1414
impl App {
1515
/// Attempts to connect to the daemon and subscribe for real-time updates.
@@ -37,43 +37,48 @@ impl App {
3737
/// Attempts to subscribe to the daemon for real-time updates.
3838
/// Returns true if subscription was successful.
3939
fn try_subscribe_to_daemon(&mut self) -> bool {
40-
if let Ok(mut client) = DaemonClient::connect() {
41-
if client.subscribe().is_ok() && client.set_nonblocking(true).is_ok() {
42-
info!("Subscribed to daemon for real-time data");
43-
44-
// Create channel for background thread to send snapshots
45-
let (tx, rx) = std::sync::mpsc::channel();
46-
self.snapshot_rx = Some(rx);
47-
48-
// Spawn background thread to continuously read from socket
49-
std::thread::spawn(move || {
50-
debug!("Background daemon reader thread started");
51-
let mut client = client;
52-
loop {
53-
match client.read_update() {
54-
Ok(Some(snapshot)) => {
55-
if tx.send(snapshot).is_err() {
56-
debug!("Channel closed, reader thread exiting");
57-
break;
58-
}
59-
}
60-
Ok(None) => {
61-
// No data available, sleep briefly to avoid busy loop
62-
std::thread::sleep(std::time::Duration::from_millis(10));
63-
}
64-
Err(e) => {
65-
debug!(error = %e, "Background reader connection lost");
40+
let client = match DaemonClient::connect_with_version_check() {
41+
Ok(c) => c,
42+
Err(ClientError::VersionMismatch(e)) => {
43+
tracing::warn!("{}", e);
44+
return false;
45+
}
46+
Err(_) => return false,
47+
};
48+
49+
let mut client = client;
50+
if client.subscribe().is_ok() && client.set_nonblocking(true).is_ok() {
51+
info!("Subscribed to daemon for real-time data");
52+
53+
let (tx, rx) = std::sync::mpsc::channel();
54+
self.snapshot_rx = Some(rx);
55+
56+
std::thread::spawn(move || {
57+
debug!("Background daemon reader thread started");
58+
let mut client = client;
59+
loop {
60+
match client.read_update() {
61+
Ok(Some(snapshot)) => {
62+
if tx.send(snapshot).is_err() {
63+
debug!("Channel closed, reader thread exiting");
6664
break;
6765
}
6866
}
67+
Ok(None) => {
68+
std::thread::sleep(std::time::Duration::from_millis(10));
69+
}
70+
Err(e) => {
71+
debug!(error = %e, "Background reader connection lost");
72+
break;
73+
}
6974
}
70-
});
75+
}
76+
});
7177

72-
self.using_daemon_data = true;
73-
self.daemon_connected = true;
74-
self.sync_daemon_broadcast_interval();
75-
return true;
76-
}
78+
self.using_daemon_data = true;
79+
self.daemon_connected = true;
80+
self.sync_daemon_broadcast_interval();
81+
return true;
7782
}
7883
false
7984
}

cli/src/daemon/client.rs

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,56 @@ use std::time::Duration;
55
use crate::daemon::protocol::{
66
ChargeSession, CycleSummary, DaemonRequest, DaemonResponse, DaemonStatus, DailyCycle,
77
DailyStat, DailyTopProcess, DataSnapshot, HourlyStat, KillProcessResult, KillSignal, Sample,
8+
MIN_SUPPORTED_VERSION, PROTOCOL_VERSION,
89
};
910
use crate::daemon::socket_path;
1011

12+
#[derive(Debug, Clone)]
13+
pub struct VersionMismatchError {
14+
pub tui_protocol_version: u32,
15+
pub tui_min_supported: u32,
16+
pub daemon_protocol_version: u32,
17+
pub daemon_min_supported: u32,
18+
pub daemon_binary_version: String,
19+
pub kind: VersionMismatchKind,
20+
}
21+
22+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23+
pub enum VersionMismatchKind {
24+
TuiTooOld,
25+
DaemonTooOld,
26+
}
27+
28+
impl std::fmt::Display for VersionMismatchError {
29+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30+
match self.kind {
31+
VersionMismatchKind::TuiTooOld => {
32+
write!(
33+
f,
34+
"Protocol version mismatch: TUI uses protocol v{}, but daemon (v{}) requires v{}+.\n\n\
35+
Please update jolt:\n \
36+
brew upgrade jolt\n \
37+
# or: cargo install jolt-tui",
38+
self.tui_protocol_version,
39+
self.daemon_binary_version,
40+
self.daemon_min_supported
41+
)
42+
}
43+
VersionMismatchKind::DaemonTooOld => {
44+
write!(
45+
f,
46+
"Protocol version mismatch: daemon (v{}) uses protocol v{}, but this TUI requires v{}+.\n\n\
47+
Please restart the daemon:\n \
48+
jolt daemon restart",
49+
self.daemon_binary_version,
50+
self.daemon_protocol_version,
51+
self.tui_min_supported
52+
)
53+
}
54+
}
55+
}
56+
}
57+
1158
#[derive(Debug, thiserror::Error)]
1259
pub enum ClientError {
1360
#[error("Connection failed: {0}")]
@@ -21,10 +68,43 @@ pub enum ClientError {
2168

2269
#[error("Subscription rejected: {0}")]
2370
SubscriptionRejected(String),
71+
72+
#[error("{0}")]
73+
VersionMismatch(VersionMismatchError),
2474
}
2575

2676
pub type Result<T> = std::result::Result<T, ClientError>;
2777

78+
/// Checks if the TUI and daemon protocol versions are compatible.
79+
/// Returns Ok(()) if compatible, or Err with detailed mismatch info.
80+
pub fn check_version_compatibility(status: &DaemonStatus) -> Result<()> {
81+
// Check 1: Can daemon understand TUI's messages?
82+
if PROTOCOL_VERSION < status.min_supported_version {
83+
return Err(ClientError::VersionMismatch(VersionMismatchError {
84+
tui_protocol_version: PROTOCOL_VERSION,
85+
tui_min_supported: MIN_SUPPORTED_VERSION,
86+
daemon_protocol_version: status.protocol_version,
87+
daemon_min_supported: status.min_supported_version,
88+
daemon_binary_version: status.version.clone(),
89+
kind: VersionMismatchKind::TuiTooOld,
90+
}));
91+
}
92+
93+
// Check 2: Can TUI understand daemon's messages?
94+
if status.protocol_version < MIN_SUPPORTED_VERSION {
95+
return Err(ClientError::VersionMismatch(VersionMismatchError {
96+
tui_protocol_version: PROTOCOL_VERSION,
97+
tui_min_supported: MIN_SUPPORTED_VERSION,
98+
daemon_protocol_version: status.protocol_version,
99+
daemon_min_supported: status.min_supported_version,
100+
daemon_binary_version: status.version.clone(),
101+
kind: VersionMismatchKind::DaemonTooOld,
102+
}));
103+
}
104+
105+
Ok(())
106+
}
107+
28108
pub struct DaemonClient {
29109
stream: UnixStream,
30110
read_buffer: Vec<u8>,
@@ -42,6 +122,15 @@ impl DaemonClient {
42122
})
43123
}
44124

125+
/// Connects to the daemon and validates protocol version compatibility.
126+
/// This is the preferred connection method for the TUI.
127+
pub fn connect_with_version_check() -> Result<Self> {
128+
let mut client = Self::connect()?;
129+
let status = client.get_status()?;
130+
check_version_compatibility(&status)?;
131+
Ok(client)
132+
}
133+
45134
fn read_line_blocking(&mut self) -> Result<String> {
46135
let mut temp_buf = [0u8; 8192];
47136
loop {
@@ -293,3 +382,107 @@ impl DaemonClient {
293382
Ok(())
294383
}
295384
}
385+
386+
#[cfg(test)]
387+
mod tests {
388+
use super::*;
389+
390+
fn make_status(
391+
protocol_version: u32,
392+
min_supported_version: u32,
393+
version: &str,
394+
) -> DaemonStatus {
395+
DaemonStatus {
396+
running: true,
397+
uptime_secs: 0,
398+
sample_count: 0,
399+
last_sample_time: None,
400+
database_size_bytes: 0,
401+
version: version.to_string(),
402+
subscriber_count: 0,
403+
history_enabled: false,
404+
protocol_version,
405+
min_supported_version,
406+
}
407+
}
408+
409+
#[test]
410+
fn test_version_compatible_same_version() {
411+
let status = make_status(PROTOCOL_VERSION, MIN_SUPPORTED_VERSION, "1.0.0");
412+
assert!(check_version_compatibility(&status).is_ok());
413+
}
414+
415+
#[test]
416+
fn test_version_compatible_daemon_newer() {
417+
let status = make_status(PROTOCOL_VERSION + 1, MIN_SUPPORTED_VERSION, "2.0.0");
418+
assert!(check_version_compatibility(&status).is_ok());
419+
}
420+
421+
#[test]
422+
fn test_version_compatible_at_min_boundary() {
423+
let status = make_status(MIN_SUPPORTED_VERSION, MIN_SUPPORTED_VERSION, "0.5.0");
424+
assert!(check_version_compatibility(&status).is_ok());
425+
}
426+
427+
#[test]
428+
fn test_version_tui_too_old() {
429+
let status = make_status(10, PROTOCOL_VERSION + 1, "3.0.0");
430+
let result = check_version_compatibility(&status);
431+
assert!(result.is_err());
432+
if let Err(ClientError::VersionMismatch(e)) = result {
433+
assert_eq!(e.kind, VersionMismatchKind::TuiTooOld);
434+
assert_eq!(e.tui_protocol_version, PROTOCOL_VERSION);
435+
assert_eq!(e.daemon_min_supported, PROTOCOL_VERSION + 1);
436+
assert!(e.to_string().contains("update jolt"));
437+
} else {
438+
panic!("Expected VersionMismatch error");
439+
}
440+
}
441+
442+
#[test]
443+
fn test_version_daemon_too_old() {
444+
let status = make_status(0, 0, "0.1.0");
445+
let result = check_version_compatibility(&status);
446+
assert!(result.is_err());
447+
if let Err(ClientError::VersionMismatch(e)) = result {
448+
assert_eq!(e.kind, VersionMismatchKind::DaemonTooOld);
449+
assert_eq!(e.daemon_protocol_version, 0);
450+
assert_eq!(e.tui_min_supported, MIN_SUPPORTED_VERSION);
451+
assert!(e.to_string().contains("restart the daemon"));
452+
} else {
453+
panic!("Expected VersionMismatch error");
454+
}
455+
}
456+
457+
#[test]
458+
fn test_version_mismatch_error_display_tui_too_old() {
459+
let error = VersionMismatchError {
460+
tui_protocol_version: 1,
461+
tui_min_supported: 1,
462+
daemon_protocol_version: 3,
463+
daemon_min_supported: 2,
464+
daemon_binary_version: "2.0.0".to_string(),
465+
kind: VersionMismatchKind::TuiTooOld,
466+
};
467+
let msg = error.to_string();
468+
assert!(msg.contains("TUI uses protocol v1"));
469+
assert!(msg.contains("daemon (v2.0.0) requires v2+"));
470+
assert!(msg.contains("brew upgrade jolt"));
471+
}
472+
473+
#[test]
474+
fn test_version_mismatch_error_display_daemon_too_old() {
475+
let error = VersionMismatchError {
476+
tui_protocol_version: 3,
477+
tui_min_supported: 2,
478+
daemon_protocol_version: 1,
479+
daemon_min_supported: 1,
480+
daemon_binary_version: "0.5.0".to_string(),
481+
kind: VersionMismatchKind::DaemonTooOld,
482+
};
483+
let msg = error.to_string();
484+
assert!(msg.contains("daemon (v0.5.0) uses protocol v1"));
485+
assert!(msg.contains("TUI requires v2+"));
486+
assert!(msg.contains("jolt daemon restart"));
487+
}
488+
}

cli/src/daemon/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ mod protocol;
33
mod server;
44
pub mod service;
55

6-
pub use client::DaemonClient;
6+
pub use client::{ClientError, DaemonClient};
77
#[allow(unused_imports)]
88
pub use jolt_protocol::{
99
BatterySnapshot, BatteryState, ChargeSession, ChargingState, CycleSummary, DaemonRequest,

crates/protocol/src/version.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,37 @@
1+
//! Protocol versioning for daemon IPC communication.
2+
//!
3+
//! # Version History
4+
//!
5+
//! | Version | Changes |
6+
//! |---------|---------|
7+
//! | 1 | Initial protocol version |
8+
//! | 2 | Added `os_name` to SystemSnapshot, forecast fields |
9+
//!
10+
//! # Breaking Changes (require PROTOCOL_VERSION bump)
11+
//!
12+
//! - Removing fields from request/response types
13+
//! - Changing field types
14+
//! - Renaming fields without `#[serde(alias)]`
15+
//! - Removing enum variants
16+
//!
17+
//! # Non-Breaking Changes (safe without version bump)
18+
//!
19+
//! - Adding new optional fields with `#[serde(default)]`
20+
//! - Adding new request/response variants
21+
//! - Adding new enum variants
22+
//!
23+
//! # Support Policy
24+
//!
25+
//! We maintain N-1 backwards compatibility, meaning the current version
26+
//! supports communication with the previous version. When updating:
27+
//!
28+
//! 1. Bump `PROTOCOL_VERSION` for breaking changes
29+
//! 2. Keep `MIN_SUPPORTED_VERSION` one behind to allow gradual upgrades
30+
//! 3. Only bump `MIN_SUPPORTED_VERSION` when dropping support for old versions
31+
32+
/// Current protocol version. Bump when making breaking changes.
133
pub const PROTOCOL_VERSION: u32 = 2;
34+
35+
/// Minimum protocol version this build can communicate with.
36+
/// Kept at N-1 to allow one version of backwards compatibility.
237
pub const MIN_SUPPORTED_VERSION: u32 = 1;

0 commit comments

Comments
 (0)