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
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
//! Daemon address discovery for `status`, `doctor`, and `ensure_daemon`.
//!
//! Why: Issue #50 — `trusty-memory status` and `trusty-memory doctor` were
//! falsely reporting the daemon as "not running" when the discovery file at
//! `~/Library/Application Support/trusty-memory/http_addr` was missing or
//! stale (e.g. launchd-managed daemons with a different `HOME`, or a daemon
//! that crashed before writing the file). The fix is to probe multiple
//! sources in priority order rather than relying solely on the addr file.
//! What: A single helper, `probe_daemon`, returns the first `SocketAddr` that
//! actually answers a TCP connect. Sources, in order: the env var
//! `TRUSTY_MEMORY_HTTP_PORT`, the shared `trusty_common` discovery file, then
//! a small range of well-known candidate ports (3031..=3050). The candidate
//! range matches the CLI default (3031) plus the auto-walk window (+20) used
//! by `bind_with_auto_port`, so any port a `trusty-memory serve` could have
//! ended up on is covered.
//! Test: `probe_candidate_ports_returns_none_when_nothing_listens` and
//! `parse_addr_handles_bare_port` cover the pure helpers; end-to-end probe
//! behavior is exercised manually via `trusty-memory doctor`.
use ;
use ;
use Duration;
/// Env var override for the daemon's HTTP port. Set to a bare port (e.g.
/// `3038`) or a full `host:port`. Useful for users who launch the daemon via
/// launchd / systemd with a pinned port and want `doctor` / `status` to find
/// it deterministically regardless of the discovery file state.
pub const HTTP_PORT_ENV: &str = "TRUSTY_MEMORY_HTTP_PORT";
/// First candidate port probed when neither the env var nor the discovery
/// file yields a live address.
const CANDIDATE_PORT_START: u16 = 3031;
/// Last candidate port probed (inclusive). Matches the +20 auto-walk window
/// used by `trusty_common::bind_with_auto_port` when the default port is
/// taken, so any port a normally-configured daemon could have landed on is
/// covered. Keep this in sync with `Serve.http` / `Start.http` defaults.
const CANDIDATE_PORT_END: u16 = 3050;
/// Default TCP connect timeout for liveness probes. Short enough to keep
/// `status` / `doctor` snappy, long enough to tolerate a busy localhost.
const PROBE_TIMEOUT: Duration = from_millis;
/// Result of probing for the running daemon.
///
/// Why: Callers need to distinguish *which* source succeeded — the addr-file
/// path is preferred for status output ("HTTP: http://X"), while a
/// candidate-port hit should warn the user that the discovery file is stale.
/// What: Carries the live `SocketAddr` plus a tag describing its origin.
/// Test: Constructed in `probe_daemon` and consumed by `doctor::handle` /
/// status output; covered by manual smoke.
/// Origin of a discovered daemon address.
///
/// Why: `doctor` should emit a warning when the discovery file is missing or
/// points at a dead port but a candidate-port probe still found the daemon —
/// users need to know the file is out of sync.
/// What: Three variants matching the three probe sources.
/// Test: Pattern-matched in `doctor::check_daemon_running`.
/// Result of a process-level (non-HTTP) liveness probe.
///
/// Why: `--no-http` daemons (Claude Code stdio path) have no listener to
/// connect to but are still very much alive. `status` / `doctor` need to
/// distinguish that case from "no daemon at all". The PID file is the
/// authoritative marker for "a daemon process exists" regardless of
/// transport.
/// What: Carries the daemon's PID. The probe asserts the process is
/// signalable (i.e. exists and we have permission to signal it) via
/// `kill -0` semantics.
/// Test: `pid_alive_returns_false_for_unused_pid` covers the negative case.
/// Returns the running daemon's PID if a live process matches the PID file.
///
/// Why: Provides a transport-independent liveness signal for `--no-http`
/// daemons. The HTTP probes in `probe_daemon` cannot detect a stdio-only
/// daemon because there is nothing listening on TCP.
/// What: Reads `<service_root>/trusty-memory.pid` via
/// `cli::stop::read_pid_file`; if present, calls `pid_alive` (kill 0) to
/// confirm the process still exists. Returns `Some(DaemonProcess)` only
/// when both conditions hold.
/// Test: Indirectly via `status` integration when running `serve --no-http`.
/// Best-effort "is this PID currently signalable?" check.
///
/// Why: We want to know if the daemon process from the PID file is still
/// running without killing it. Sending signal 0 to a PID is the canonical
/// POSIX liveness probe — exit code 0 means "exists and signalable".
/// What: On Unix invokes `/bin/kill -0 <pid>` via `std::process::Command`
/// and reports success based on the exit status. Avoids a new `libc`/`nix`
/// dependency. On non-Unix platforms returns `false` so callers degrade
/// gracefully to the HTTP-only discovery path.
/// Test: `pid_alive_returns_false_for_unused_pid` asserts a definitely-unused
/// PID is reported dead.
/// Probe every known source for a live trusty-memory daemon address.
///
/// Why: Issue #50. Previously, `doctor` and `status` relied solely on the
/// `http_addr` discovery file; when the file was absent (e.g. launchd
/// daemon writing to a different `HOME`) the commands reported "not
/// running" despite a healthy daemon on a known port. Probing multiple
/// sources eliminates the false negative.
/// What: Returns `Some(DaemonAddr)` for the first source whose address
/// answers a TCP connect within `PROBE_TIMEOUT`. Sources, in priority
/// order: `TRUSTY_MEMORY_HTTP_PORT` env var → discovery file → candidate
/// ports `3031..=3050` on `127.0.0.1`. Returns `None` if nothing answers.
/// Test: Manual via `trusty-memory doctor` against a daemon on 3038
/// without a discovery file; the helper-level tests cover the pure parts.
/// Try every candidate port on `127.0.0.1` and return the first that answers
/// as a trusty-memory daemon (verified via `/health`).
///
/// Why: Fallback for when both the env var and the discovery file are
/// missing or stale — covers the launchd-with-different-HOME case from
/// issue #50. Crucially, a bare TCP connect is not enough: macOS reserves
/// TCP 3031 for the `eppc` (Remote AppleEvents) system service, which
/// accepts connects and then resets — that produced a false positive where
/// `trusty-memory status` claimed the daemon lived on 3031 even when the
/// daemon was running with `--no-http` (no HTTP listener at all).
/// What: Iterates `CANDIDATE_PORT_START..=CANDIDATE_PORT_END`. For each
/// port that accepts a TCP connect we issue a minimal HTTP/1.1 `GET /health`
/// and require the response to start with `HTTP/1.1 200`. Only then do we
/// accept the port as a trusty-memory daemon. Returns the first verified
/// live address or `None` if none respond like trusty-memory.
/// Test: `probe_candidate_ports_returns_none_when_nothing_listens` runs
/// after binding nothing and asserts `None`; the eppc false-positive case
/// is exercised by manual smoke (run `trusty-memory serve --no-http` and
/// confirm `trusty-memory status` reports "daemon: not running").
/// Verify that the endpoint at `addr` is a trusty-memory HTTP daemon by
/// hitting `/health` and checking for `HTTP/1.1 200`.
///
/// Why: A bare TCP connect is insufficient for liveness — macOS's built-in
/// `eppc` service binds TCP 3031 by default and accepts connects (then
/// resets), which produces a false positive when the candidate-port probe
/// runs on a Mac with no trusty-memory HTTP listener. We must speak HTTP to
/// confirm the listener is ours. We keep the implementation dependency-free
/// (std `TcpStream`) rather than reaching for `reqwest` because the probe
/// runs from sync contexts (e.g. `lock_file_held`, sync `status`) and a
/// short hand-rolled GET request is sufficient.
/// What: Opens a short-timeout TCP connection, writes a minimal HTTP/1.1
/// `GET /health` request with `Connection: close`, reads up to 64 bytes of
/// the response, and returns true iff the bytes start with `HTTP/1.1 200`.
/// Any I/O error, timeout, or non-200 status returns false.
/// Test: Implicitly covered by `probe_candidate_ports_returns_none_when_nothing_listens`;
/// manual smoke with `trusty-memory serve --no-http` confirms port 3031
/// (macOS eppc) no longer triggers a false positive.
/// Quick TCP-connect liveness check.
///
/// Why: Reachability is the single source of truth for "is the daemon up?" —
/// the discovery file alone can lie if the daemon crashed without cleaning
/// up.
/// What: Returns true iff `TcpStream::connect_timeout` succeeds within
/// `PROBE_TIMEOUT`.
/// Test: Implicitly covered by `probe_candidate_ports_returns_none_when_nothing_listens`.
/// Parse an address string that may be `host:port` or just a bare port.
///
/// Why: The `TRUSTY_MEMORY_HTTP_PORT` env var is documented as accepting
/// either form so users can write `3038` instead of `127.0.0.1:3038`.
/// What: First tries `parse::<SocketAddr>`; on failure, parses the input as
/// a `u16` and assumes `127.0.0.1`. Returns `None` if neither form matches.
/// Test: `parse_addr_handles_bare_port` and `parse_addr_handles_full_addr`.