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
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
//! Down versus slow: what bare `tm` may conclude from a failed session listing.
//!
//! Why: bare `tm` read every failed listing as "daemon down", auto-started a
//! second daemon and then redirected the PM pane to a managed clone. A daemon
//! whose store stalled for 60 s took that path in seven panes at once (#9034).
//! A timeout proves the daemon accepted the request; it proves nothing about
//! the daemon being absent.
//! What: [`classify_list_failure`] maps a listing error plus the lock file's
//! live pid to [`DaemonReach`]. [`picker_or_autostart`] is the picker,
//! autostart and retry sequence of the guided default; only a `Down` verdict
//! reaches autostart, and only a failed autostart of a down daemon reaches the
//! offline fallback.
//! Test: `guided_liveness_tests.rs`.
use std::future::Future;
use std::path::Path;
/// The project a picker attempt is about — the four values `try_show_picker`
/// needs, passed as one borrow.
#[derive(Clone, Copy)]
pub(crate) struct PickerProject<'a> {
/// `owner/repo` the listing is filtered by.
pub source_id: &'a str,
/// Managed workspace path shown in the project context.
pub workspace: &'a Path,
/// Git root, sent as `repo_url` so the daemon resolves `source_id`.
pub repo_url: &'a str,
/// The operator's working directory, for the banner.
pub cwd: &'a Path,
}
/// Result of one picker attempt: it ran, or the listing it needs failed.
pub(crate) enum PickerAttempt {
/// The daemon answered and the picker (or non-TTY summary) ran.
Shown(anyhow::Result<()>),
/// The session listing failed; the error decides down versus slow.
ListFailed(anyhow::Error),
}
/// What the evidence says about the daemon after a failed listing.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum DaemonReach {
/// Positive evidence it is not running: the connection was refused and no
/// live pid holds the lock for this address.
Down,
/// It may be running. The string says why `tm` will not treat it as down.
Unknown(String),
}
/// Where the guided default goes after [`picker_or_autostart`].
pub(crate) enum PickerFlow {
/// Finished: the picker ran, or a slow daemon was reported.
Done(anyhow::Result<()>),
/// The daemon is down and could not be started; take the offline fallback.
Offline,
}
/// Autostart saw liveness evidence (a launchd job reporting `state = running`,
/// a live daemon lock pid, or a spawned child still starting) but `/health`
/// never answered. The daemon exists; it is slow.
#[derive(Debug, thiserror::Error)]
#[error("the daemon is running ({evidence}) but did not answer /health within 5 s")]
pub(crate) struct DaemonAliveUnresponsive {
/// The evidence that the daemon exists.
pub evidence: String,
}
/// Autostart found a live `daemon.lock` pid it could not confirm as a
/// daemon, and kept the lock (#9034). The operator decides.
#[derive(Debug, thiserror::Error)]
#[error("not starting a daemon: {evidence}")]
pub(crate) struct DaemonLockUnverified {
/// The lock evidence and its recovery.
pub evidence: String,
}
/// The host facts [`picker_or_autostart_with`] reads, injected for tests.
pub(crate) struct HostProbe<'a> {
/// The daemon lock file.
pub lock_path: &'a Path,
/// What the process table says about a pid.
pub identify: super::guided_autostart_plan::IdentifyPid<'a>,
/// The restart command named in the stop message.
pub restart_cmd: &'a str,
}
/// Classify a failed session listing as down or unknown.
///
/// Why: #9034 — "down" is the only verdict that may start a daemon or redirect
/// to a managed clone, so it needs positive evidence. A timeout, an HTTP error
/// status, or an unreadable body all mean a process answered the socket.
/// What: a non-`reqwest` error is `Unknown`; a `reqwest` error is decided by
/// [`reach_from_flags`].
/// Test: `classify_timeout_is_unknown_not_down`,
/// `classify_refused_with_live_lock_pid_is_unknown`,
/// `classify_refused_without_live_pid_is_down`.
pub(crate) fn classify_list_failure(
err: &anyhow::Error,
lock_evidence: Option<&str>,
) -> DaemonReach {
let Some(re) = err.chain().find_map(|c| c.downcast_ref::<reqwest::Error>()) else {
return DaemonReach::Unknown(format!("it answered with an unusable reply: {err}"));
};
reach_from_flags(
re.is_timeout(),
re.is_connect(),
lock_evidence,
&re.to_string(),
)
}
/// The down/unknown decision over a `reqwest` error's flags.
///
/// Why: a connect-phase timeout sets BOTH `is_connect` and `is_timeout`; it is
/// a listener too busy to accept, so the timeout check must win.
/// What: timeout → `Unknown`; not a connect error → `Unknown`; a connect error
/// while the lock holds a live pid (`lock_evidence`, daemon or unverified) →
/// `Unknown` naming the recovery; otherwise `Down`.
/// Test: `flags_connect_phase_timeout_is_unknown`,
/// `classify_refused_with_live_lock_pid_is_unknown`.
pub(crate) fn reach_from_flags(
is_timeout: bool,
is_connect: bool,
lock_evidence: Option<&str>,
detail: &str,
) -> DaemonReach {
// #9034: timeout check must precede is_connect — a connect-phase timeout
// carries both flags and is a slow daemon, not an absent one.
if is_timeout {
return DaemonReach::Unknown("the request timed out".to_string());
}
if !is_connect {
return DaemonReach::Unknown(format!("it answered with an error: {detail}"));
}
match lock_evidence {
// #9034: a refused connection while the lock names a live pid is not
// positive evidence of absence — the same rule as lock removal.
Some(evidence) => DaemonReach::Unknown(format!("the connection failed, but {evidence}")),
None => DaemonReach::Down,
}
}
/// What the lock at `lock_path` proves about the daemon `url` points at.
///
/// Why: a live pid is evidence about the daemon at the lock's own address
/// only; a lock for port 7880 says nothing about an explicit `--url` elsewhere.
/// #9034: the same rule as lock removal — a live pid counts unless it is
/// positively dead, whether or not its identity is confirmed.
/// What: `None` when the lock is absent, not ours, for another address, or
/// stale; otherwise [`super::guided_autostart_plan::lock_evidence`] of
/// [`super::guided_autostart_plan::lock_verdict`] (no mutation).
/// Test: `live_pid_requires_matching_addr`,
/// `unverified_lock_pid_is_evidence_not_absence`.
pub(crate) fn lock_evidence_for(
url: &str,
lock_path: &Path,
identify: super::guided_autostart_plan::IdentifyPid<'_>,
) -> Option<String> {
use super::guided_autostart_plan::{lock_evidence, lock_verdict};
let text = std::fs::read_to_string(lock_path).ok()?;
let lock = trusty_mpm::core::daemon_identity::parse_lock(&text)?;
let addr = lock.addr.trim_end_matches('/');
if addr.is_empty() || !url.trim_end_matches('/').starts_with(addr) {
return None;
}
lock_evidence(lock_verdict(lock_path, identify))
}
/// The error bare `tm` stops with when the daemon may be running.
///
/// What: names the evidence and, #9034, the host's restart command
/// ([`super::launchd_probe::daemon_restart_command_for`]).
/// Test: `slow_listing_stops_without_autostart`.
pub(crate) fn slow_daemon_error(url: &str, why: &str, restart_cmd: &str) -> anyhow::Error {
anyhow::anyhow!(
"the trusty-mpm daemon at {url} may be running but is not responding ({why}). \
Not starting a second daemon and not redirecting to a managed clone. \
Run `tm` again in a moment; if it stays unresponsive, restart it with \
`{restart_cmd}`. `tm doctor` and ~/.trusty-mpm/daemon.log show its state."
)
}
/// The guided default's picker → autostart → retry sequence.
///
/// Why: #9034 — this sequence decided "down" from any failed listing.
/// What: [`picker_or_autostart_with`] against the real lock file, the
/// argv-reading [`super::daemon_pid_identity::pid_identity`], the host's
/// restart command, and
/// [`super::guided_autostart::ensure_daemon_started`].
/// Test: `slow_listing_stops_without_autostart` and siblings, through
/// [`picker_or_autostart_with`].
pub(crate) async fn picker_or_autostart(
client: &reqwest::Client,
url: &str,
project: &PickerProject<'_>,
) -> PickerFlow {
let lock = trusty_mpm::core::lock_file_path();
let identify = super::daemon_pid_identity::pid_identity;
let restart_cmd = super::launchd_probe::daemon_restart_command();
let host = HostProbe {
lock_path: &lock,
identify: &identify,
restart_cmd: &restart_cmd,
};
picker_or_autostart_with(client, url, project, &host, || {
super::guided_autostart::ensure_daemon_started(client, url)
})
.await
}
/// [`picker_or_autostart`] with the host facts and the autostart step injected.
///
/// Why: the regression tests must drive the decision against stub servers
/// and a temp lock, and must never start a real daemon or touch the real
/// `~/.trusty-mpm/daemon.lock`.
/// What: (1) try the picker; (2) on a listing failure, classify it with the
/// lock's live daemon pid — `Unknown` stops with [`slow_daemon_error`];
/// (3) `Down` runs `autostart`; (4) after a successful autostart the daemon
/// answered `/health`, so a second listing failure also stops; (5) an
/// autostart error of type [`DaemonAliveUnresponsive`] stops; any other
/// autostart error is [`PickerFlow::Offline`].
/// Test: `slow_listing_stops_without_autostart`,
/// `refused_with_live_lock_pid_stops_without_autostart`,
/// `refused_with_unknown_identity_lock_pid_stops_and_keeps_the_lock`,
/// `unverified_lock_autostart_error_stops_not_offline`,
/// `refused_without_live_pid_autostarts_then_goes_offline`,
/// `slow_listing_after_autostart_stops_not_offline`,
/// `alive_unresponsive_autostart_stops_not_offline`.
pub(crate) async fn picker_or_autostart_with<F, Fut>(
client: &reqwest::Client,
url: &str,
project: &PickerProject<'_>,
host: &HostProbe<'_>,
autostart: F,
) -> PickerFlow
where
F: FnOnce() -> Fut,
Fut: Future<Output = anyhow::Result<String>>,
{
let stop =
|at: &str, why: &str| PickerFlow::Done(Err(slow_daemon_error(at, why, host.restart_cmd)));
let err = match super::guided::try_show_picker(client, url, project).await {
PickerAttempt::Shown(r) => return PickerFlow::Done(r),
PickerAttempt::ListFailed(e) => e,
};
// #9034: only positive evidence of absence may start a daemon.
let evidence = lock_evidence_for(url, host.lock_path, host.identify);
if let DaemonReach::Unknown(why) = classify_list_failure(&err, evidence.as_deref()) {
return stop(url, &why);
}
eprintln!("tm: daemon not running — starting it…");
match autostart().await {
Ok(new_url) => match super::guided::try_show_picker(client, &new_url, project).await {
PickerAttempt::Shown(r) => PickerFlow::Done(r),
// #9034: `/health` just answered, so this daemon is up and slow.
PickerAttempt::ListFailed(e) => stop(&new_url, &format!("{e:#}")),
},
// #9034: a running launchd job, a live daemon lock pid, or a spawned
// child still starting is a slow daemon.
// #9034: an unverified live lock pid fails closed too.
Err(e)
if e.downcast_ref::<DaemonAliveUnresponsive>().is_some()
|| e.downcast_ref::<DaemonLockUnverified>().is_some() =>
{
stop(url, &format!("{e}"))
}
Err(e) => {
eprintln!("tm: auto-start failed ({e}); falling back to offline mode");
PickerFlow::Offline
}
}
}
#[cfg(test)]
#[path = "guided_liveness_tests.rs"]
mod tests;