1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
//! Stale-daemon version comparison for `tm doctor` (issue #2332).
//!
//! Why: the #2332 incident traced a daemon that ran 46.8h on pre-migration
//! code purely by hand-correlating tmux timestamps against
//! `~/Library/Logs/trusty-mpm/stderr.log` restart banners — nothing anywhere
//! in the stack told the operator the RUNNING daemon's build had drifted from
//! the INSTALLED `tm` binary (the common `cargo install` restart-forgot
//! failure, same family as #2214). `tm doctor` always executes as the
//! just-installed binary (that is what running the command means), so
//! comparing its own `CARGO_PKG_VERSION` against the version the daemon
//! self-reports on `GET /health` (see [`crate::daemon::api::types::HealthResponse::version`])
//! is a purely client-side COMPARISON — the two values it folds together need
//! no shared process or IPC to compare. It is NOT free of network cost,
//! though: `tm doctor` issues one `GET /health` round-trip in addition to the
//! primary `GET /api/v1/doctor` call, and the two requests are not atomic — the
//! daemon can restart between them. That is handled by treating a transport
//! failure on the `/health` call as `Warn` rather than propagating an error (see
//! `stale_daemon_check` in the `tm` CLI binary's `commands::doctor_stale`
//! module — a separate crate target from this library, so it cannot be an
//! intra-doc link here), not by this module, which only ever sees the
//! already-fetched strings. As of #4230 that single snapshot is fetched ONCE by
//! `commands::misc::doctor` and shared with the `daemon_orphan` check, so the two
//! client-side checks always describe the same daemon.
//! What: [`parse_version_triple`] extracts a `(major, minor, patch)` triple
//! from a `CARGO_PKG_VERSION`-shaped string (mirrors the lightweight parser in
//! [`crate::core::output_style::parse_claude_version`] rather than pulling in
//! the `semver` crate for a same-process comparison this simple).
//! [`check_daemon_version_staleness`] folds `(installed, daemon_reported)`
//! into a [`DoctorCheck`] the CLI prints alongside the server-side probes.
//! Test: the `tests` module below covers match / older / newer / unparseable /
//! empty-daemon-version branches.
use crate::core::doctor::{CheckStatus, DoctorCheck};
/// Stable check name for [`check_daemon_version_staleness`]'s [`DoctorCheck`].
pub const CHECK_NAME: &str = "daemon_version";
/// Parse a `major.minor.patch[-suffix]` string into a comparable triple.
///
/// Why: `CARGO_PKG_VERSION` values are already clean semver (no `v` prefix,
/// no extra whitespace), so this needs far less tolerance than
/// [`crate::core::output_style::parse_claude_version`]'s CLI-output parser —
/// but a pre-release suffix on patch (`"0.42.0-beta"`) must still parse the
/// leading digits rather than failing outright.
/// What: splits on `.`; requires three numeric components, taking only the
/// leading digits of the patch segment. Returns `None` for anything that
/// doesn't start with `major.minor.patch`.
/// Test: `parse_version_triple_plain`, `parse_version_triple_prerelease_suffix`,
/// `parse_version_triple_rejects_malformed`.
pub fn parse_version_triple(raw: &str) -> Option<(u64, u64, u64)> {
let mut parts = raw.split('.');
let major = parts.next()?.parse::<u64>().ok()?;
let minor = parts.next()?.parse::<u64>().ok()?;
let patch_raw = parts.next()?;
let patch_digits: String = patch_raw
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if patch_digits.is_empty() {
return None;
}
let patch = patch_digits.parse::<u64>().ok()?;
Some((major, minor, patch))
}
/// Compare the installed binary's version against the running daemon's
/// self-reported version and fold the result into a [`DoctorCheck`].
///
/// Why: the whole point of #2332 — surface drift BEFORE it costs another
/// multi-hour forensics session, without ever hard-failing `tm doctor` over
/// something a simple restart fixes.
/// What: `daemon_reported == ""` (an older daemon predating
/// [`crate::daemon::api::types::HealthResponse::version`], or a daemon that
/// is simply unreachable and never got this far) → `Warn`, since that is
/// itself evidence of exactly the staleness this check exists to catch.
/// Equal strings → `Ok`. Different strings that both parse as
/// `major.minor.patch` → `Warn` when the daemon is older, `Warn` (a distinct,
/// less common message) when the daemon is newer than the installed binary
/// (a downgrade — still worth flagging). Different strings where either side
/// fails to parse → `Warn` with a "could not compare" message rather than
/// guessing. Never `Fail` — a stale daemon still serves traffic; this is
/// advisory, matching every other doctor probe's severity convention.
///
/// `restart_hint` is the command that ACTUALLY restarts the daemon on the calling
/// host (issue #4230 review, HIGH-3). This used to be a hardcoded `tm restart`,
/// which #4230 makes refuse on any host where launchd owns the daemon — so
/// `tm doctor` printed "run `tm restart`" two lines above its own new orphan
/// check, and the prescribed command errored out. The caller resolves the verb via
/// `commands::launchd_probe::daemon_restart_command`; taking it as a parameter
/// keeps this module pure (no filesystem or launchd probing in `core`).
/// Test: `staleness_ok_when_versions_match`, `staleness_warns_when_daemon_older`,
/// `staleness_warns_when_daemon_newer`, `staleness_warns_when_daemon_version_empty`,
/// `staleness_warns_when_unparseable`, `staleness_uses_the_caller_restart_hint`.
pub fn check_daemon_version_staleness(
installed: &str,
daemon_reported: &str,
restart_hint: &str,
) -> DoctorCheck {
if daemon_reported.is_empty() {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Warn,
format!(
"daemon did not report a version on /health — it likely predates this build \
(installed binary is v{installed}); restart the daemon (`{restart_hint}`)"
),
);
}
if daemon_reported == installed {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Ok,
format!("running daemon (v{daemon_reported}) matches the installed binary"),
);
}
match (
parse_version_triple(installed),
parse_version_triple(daemon_reported),
) {
(Some(installed_triple), Some(daemon_triple)) if daemon_triple < installed_triple => {
DoctorCheck::new(
CHECK_NAME,
CheckStatus::Warn,
format!(
"running daemon is older than installed binary — restart the daemon \
(daemon: v{daemon_reported}, installed: v{installed}); run \
`{restart_hint}`"
),
)
}
(Some(installed_triple), Some(daemon_triple)) if daemon_triple > installed_triple => {
DoctorCheck::new(
CHECK_NAME,
CheckStatus::Warn,
format!(
"running daemon (v{daemon_reported}) is NEWER than the installed binary \
(v{installed}) — the installed binary may have been downgraded"
),
)
}
// Equal triples with differing raw strings (e.g. a pre-release suffix
// difference) — not the drift this check targets.
(Some(_), Some(_)) => DoctorCheck::new(
CHECK_NAME,
CheckStatus::Ok,
format!("running daemon (v{daemon_reported}) matches the installed binary"),
),
_ => DoctorCheck::new(
CHECK_NAME,
CheckStatus::Warn,
format!(
"could not compare daemon version `{daemon_reported}` against installed \
binary version `{installed}` — restart the daemon if unsure \
(`{restart_hint}`)"
),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A stand-in restart hint. Deliberately NOT `tm restart`: #4230 made that
/// verb refuse on launchd hosts, and a test that passed the old hardcoded
/// string could not tell whether the message used the caller's value or a
/// leftover literal.
const HINT: &str = "launchctl kickstart -k gui/$(id -u)/com.trusty.mpm";
#[test]
fn parse_version_triple_plain() {
assert_eq!(parse_version_triple("0.42.1"), Some((0, 42, 1)));
assert_eq!(parse_version_triple("12.0.7"), Some((12, 0, 7)));
}
#[test]
fn parse_version_triple_prerelease_suffix() {
assert_eq!(parse_version_triple("0.42.0-beta"), Some((0, 42, 0)));
}
#[test]
fn parse_version_triple_rejects_malformed() {
assert_eq!(parse_version_triple(""), None);
assert_eq!(parse_version_triple("0.42"), None);
assert_eq!(parse_version_triple("not-a-version"), None);
}
#[test]
fn staleness_ok_when_versions_match() {
let check = check_daemon_version_staleness("0.42.0", "0.42.0", HINT);
assert_eq!(check.status, CheckStatus::Ok, "message: {}", check.message);
assert_eq!(check.name, CHECK_NAME);
}
#[test]
fn staleness_warns_when_daemon_older() {
let check = check_daemon_version_staleness("0.42.0", "0.41.9", HINT);
assert_eq!(check.status, CheckStatus::Warn);
assert!(check.message.contains("older than installed binary"));
assert!(check.message.contains(HINT), "message: {}", check.message);
}
/// #4230 review, HIGH-3: every remediation must use the CALLER's verb. The
/// hardcoded `tm restart` this replaced is a hard error on a launchd host, so
/// `tm doctor` prescribed a command its own next line had just broken. All
/// three hint-carrying branches are checked, and none may leak the literal.
#[test]
fn staleness_uses_the_caller_restart_hint() {
for (installed, reported) in [("0.42.0", "0.41.9"), ("0.42.0", ""), ("0.42.0", "garbage")] {
let msg = check_daemon_version_staleness(installed, reported, HINT).message;
assert!(msg.contains(HINT), "message: {msg}");
assert!(
!msg.contains("tm restart"),
"must not leak the old hardcoded verb: {msg}"
);
}
}
#[test]
fn staleness_warns_when_daemon_newer() {
let check = check_daemon_version_staleness("0.41.9", "0.42.0", HINT);
assert_eq!(check.status, CheckStatus::Warn);
assert!(check.message.contains("NEWER"));
}
#[test]
fn staleness_warns_when_daemon_version_empty() {
let check = check_daemon_version_staleness("0.42.0", "", HINT);
assert_eq!(check.status, CheckStatus::Warn);
assert!(check.message.contains("did not report a version"));
}
#[test]
fn staleness_warns_when_unparseable() {
let check = check_daemon_version_staleness("0.42.0", "garbage", HINT);
assert_eq!(check.status, CheckStatus::Warn);
assert!(check.message.contains("could not compare"));
}
#[test]
fn staleness_ok_when_triples_match_but_raw_strings_differ() {
// Covers the `(Some(_), Some(_))` equal-triple arm specifically — a
// pre-release suffix difference (e.g. "0.42.0" vs "0.42.0-beta") must
// NOT hit the `daemon_reported == installed` exact-string
// short-circuit above it, since the raw strings differ; it must also
// NOT hit either `<`/`>` ordering arm, since the triples are equal.
let check = check_daemon_version_staleness("0.42.0", "0.42.0-beta", HINT);
assert_eq!(check.status, CheckStatus::Ok, "message: {}", check.message);
assert_eq!(check.name, CHECK_NAME);
}
}