agentmux_launcher/other_instances.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Read-only detection of OTHER AgentMux `(channel, version)` instances
5//! currently running on this machine, for diagnostic logging ONLY.
6//!
7//! Scope note (task #35 / `docs/specs/SPEC_WIN10_PAGEFILE_OOM_CRASH_2026_06_29.md`
8//! P1, "shut down old version on upgrade"): the original ask was a full
9//! opt-in "close the old version?" prompt with a cross-instance graceful
10//! quit. Investigation found that a cross-instance quit action is
11//! currently blocked by invariant **I4** in
12//! `docs/specs/SPEC_MULTI_INSTANCE_ISOLATION_HARDENING_2026_06_03.md`
13//! ("the only permitted interaction with another instance is the
14//! authenticated `open_new_window` forward; it is side-effect-free w.r.t.
15//! that instance's lifecycle") — extending that invariant is a design
16//! decision that needs its own review, not something to fold into a
17//! minimal patch. This module implements ONLY the read-only half:
18//!
19//! 1. Enumerate sibling `(channel, version)` directories on disk under
20//! `channels/`.
21//! 2. Connect-probe each one's single-instance pipe/socket — the SAME
22//! kind of non-destructive liveness check `second_instance.rs`
23//! already performs for the CURRENT channel's stale-socket recovery,
24//! just pointed at a different `(channel, version)` pair. A bare
25//! connect (no command sent) is side-effect-free w.r.t. the
26//! target's lifecycle, consistent with I4.
27//! 3. Log (never prompt, never dialog, never contact the other instance
28//! beyond the liveness probe itself) when a LIVE, strictly OLDER
29//! version is found.
30//!
31//! Every function here is best-effort: any failure (missing dir, unreadable
32//! entry, probe error) is treated as "nothing to report", never a hard
33//! error. This is a diagnostic nicety — it must never block or fail this
34//! process's own launch.
35
36use std::path::{Path, PathBuf};
37
38/// A sibling `(channel, version)` directory found on disk, distinct from
39/// the caller's own. Liveness is NOT implied by construction — see
40/// [`log_older_running_instances`], which probes before logging.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct SiblingInstance {
43 pub channel: String,
44 pub version: String,
45 pub data_dir: PathBuf,
46}
47
48/// Walk `<channels_root>/*/versions/*/` and return every
49/// `(channel, version)` pair other than `(own_channel, own_version)`.
50///
51/// Pure filesystem read — `read_dir` only, no pipe/socket contact, no
52/// writes. Best-effort: a missing or unreadable `channels_root` (fresh
53/// install, permissions issue) yields an empty list rather than an error.
54pub fn enumerate_sibling_instances(
55 channels_root: &Path,
56 own_channel: &str,
57 own_version: &str,
58) -> Vec<SiblingInstance> {
59 let mut out = Vec::new();
60 let Ok(channel_entries) = std::fs::read_dir(channels_root) else {
61 return out;
62 };
63 for channel_entry in channel_entries.flatten() {
64 let is_dir = channel_entry
65 .file_type()
66 .map(|t| t.is_dir())
67 .unwrap_or(false);
68 if !is_dir {
69 continue;
70 }
71 let channel = channel_entry.file_name().to_string_lossy().into_owned();
72 let versions_dir = channel_entry.path().join("versions");
73 let Ok(version_entries) = std::fs::read_dir(&versions_dir) else {
74 continue;
75 };
76 for version_entry in version_entries.flatten() {
77 let is_dir = version_entry
78 .file_type()
79 .map(|t| t.is_dir())
80 .unwrap_or(false);
81 if !is_dir {
82 continue;
83 }
84 let version = version_entry.file_name().to_string_lossy().into_owned();
85 if channel == own_channel && version == own_version {
86 continue; // this is us
87 }
88 out.push(SiblingInstance {
89 channel: channel.clone(),
90 version,
91 data_dir: version_entry.path().join("data"),
92 });
93 }
94 }
95 out
96}
97
98/// Parse a leading `major.minor.patch` out of a version string, ignoring
99/// any `-prerelease` or `+build` suffix. Returns `None` for anything that
100/// doesn't start with three dot-separated integers (e.g. a local build's
101/// labeled version, which normally lives in a `local-*` channel rather
102/// than under a released `stable` channel anyway). Callers must treat
103/// `None` as "can't compare" — never guess.
104fn parse_semver_core(v: &str) -> Option<(u64, u64, u64)> {
105 let core = v.split(['+', '-']).next().unwrap_or(v);
106 let mut parts = core.split('.');
107 let major = parts.next()?.parse().ok()?;
108 let minor = parts.next()?.parse().ok()?;
109 let patch = parts.next()?.parse().ok()?;
110 Some((major, minor, patch))
111}
112
113/// True iff `candidate` parses as strictly older than `own`. Unparseable
114/// input on either side is never treated as older — fail quiet rather
115/// than risk a misleading log line.
116fn is_strictly_older(candidate: &str, own: &str) -> bool {
117 match (parse_semver_core(candidate), parse_semver_core(own)) {
118 (Some(c), Some(o)) => c < o,
119 _ => false,
120 }
121}
122
123/// Best-effort liveness probe for a sibling instance's single-instance
124/// named pipe. `ERROR_PIPE_BUSY` (231) still means "a server is there,
125/// just mid-accept" — treated as alive. Any other error (most commonly
126/// `ERROR_FILE_NOT_FOUND`) means no live server. Never blocks: `CreateFile`
127/// on a named pipe either succeeds, fails fast with `ERROR_PIPE_BUSY`, or
128/// fails fast with "not found" — there is no server-instance wait here
129/// (that only happens via the blocking `WaitNamedPipe` API, which we do
130/// not call).
131#[cfg(target_os = "windows")]
132fn probe_liveness(pipe_path: &str) -> bool {
133 const ERROR_PIPE_BUSY: i32 = 231;
134 match tokio::net::windows::named_pipe::ClientOptions::new().open(pipe_path) {
135 Ok(_client) => true, // handle drops immediately; we never write/read
136 Err(e) => e.raw_os_error() == Some(ERROR_PIPE_BUSY),
137 }
138}
139
140/// Unix equivalent: a plain `connect()` either finds a listening peer or
141/// doesn't. Mirrors the probe `second_instance.rs::bind_socket_with_recovery`
142/// already performs for the current channel's stale-socket recovery.
143#[cfg(unix)]
144fn probe_liveness(pipe_path: &str) -> bool {
145 std::os::unix::net::UnixStream::connect(pipe_path).is_ok()
146}
147
148/// Enumerate sibling instances, probe each for liveness, and log (via
149/// [`crate::logging::log`]) every LIVE instance running a strictly OLDER
150/// version than `own_version`. Never prompts, never blocks, never sends
151/// anything beyond the liveness probe itself. Intended to be called from a
152/// detached/spawned task at startup — every failure path here is a no-op,
153/// so there is nothing for the caller to react to.
154///
155/// `own_version` should be the plain on-disk version string (the
156/// directory name under `versions/`, i.e. `CARGO_PKG_VERSION` — NOT
157/// `AGENTMUX_BUILD_LABEL`, which only affects the pipe *name*, not the
158/// data-dir layout). We can't know a sibling's build label from its
159/// directory name alone, so sibling pipe hashes are always computed from
160/// the plain on-disk version string; this correctly finds stable/
161/// portable release siblings (no build label) and simply under-detects
162/// same-machine LOCAL dev builds — a safe false-negative, never a false
163/// alarm.
164pub fn log_older_running_instances(channels_root: &Path, own_channel: &str, own_version: &str) {
165 for sibling in enumerate_sibling_instances(channels_root, own_channel, own_version) {
166 if !is_strictly_older(&sibling.version, own_version) {
167 continue;
168 }
169 let dir_hash = crate::hash::data_dir_hash16(&sibling.data_dir, &sibling.version);
170 let pipe_path = crate::ipc::pipe_name(&dir_hash);
171 if probe_liveness(&pipe_path) {
172 crate::logging::log(&format!(
173 "AgentMux v{} is also running (channel {}) — consider closing it to reduce memory/CEF overhead. [other_instances] data_dir={} pipe={}",
174 sibling.version,
175 sibling.channel,
176 sibling.data_dir.display(),
177 pipe_path
178 ));
179 }
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use tempfile::tempdir;
187
188 fn make_version_dir(channels_root: &Path, channel: &str, version: &str) {
189 let dir = channels_root
190 .join(channel)
191 .join("versions")
192 .join(version)
193 .join("data");
194 std::fs::create_dir_all(&dir).unwrap();
195 }
196
197 #[test]
198 fn enumerate_finds_sibling_channel_and_version() {
199 let tmp = tempdir().unwrap();
200 let channels_root = tmp.path().join("channels");
201 make_version_dir(&channels_root, "stable", "0.53.2");
202 make_version_dir(&channels_root, "stable", "0.53.1");
203
204 let siblings = enumerate_sibling_instances(&channels_root, "stable", "0.53.2");
205
206 assert_eq!(siblings.len(), 1);
207 assert_eq!(siblings[0].channel, "stable");
208 assert_eq!(siblings[0].version, "0.53.1");
209 assert_eq!(
210 siblings[0].data_dir,
211 channels_root
212 .join("stable")
213 .join("versions")
214 .join("0.53.1")
215 .join("data")
216 );
217 }
218
219 #[test]
220 fn enumerate_skips_own_channel_and_version_only() {
221 let tmp = tempdir().unwrap();
222 let channels_root = tmp.path().join("channels");
223 make_version_dir(&channels_root, "stable", "0.53.2");
224 make_version_dir(&channels_root, "local-main-abc123", "0.53.2");
225
226 // Same version string, different channel — NOT skipped: it's a
227 // genuinely different (channel, version) pair with its own pipe.
228 let siblings = enumerate_sibling_instances(&channels_root, "stable", "0.53.2");
229 assert_eq!(siblings.len(), 1);
230 assert_eq!(siblings[0].channel, "local-main-abc123");
231 }
232
233 #[test]
234 fn enumerate_missing_channels_root_yields_empty() {
235 let tmp = tempdir().unwrap();
236 let missing = tmp.path().join("does-not-exist");
237 let siblings = enumerate_sibling_instances(&missing, "stable", "0.53.2");
238 assert!(siblings.is_empty());
239 }
240
241 #[test]
242 fn enumerate_channel_with_no_versions_dir_is_skipped_not_fatal() {
243 let tmp = tempdir().unwrap();
244 let channels_root = tmp.path().join("channels");
245 // A channel dir that exists but has no `versions/` subdir (e.g.
246 // dev-only layout, or mid-migration) must not abort the walk.
247 std::fs::create_dir_all(channels_root.join("stable")).unwrap();
248 make_version_dir(&channels_root, "beta", "0.10.0");
249
250 let siblings = enumerate_sibling_instances(&channels_root, "beta", "0.10.0");
251 assert!(siblings.is_empty());
252 }
253
254 #[test]
255 fn semver_core_parses_plain_versions() {
256 assert_eq!(parse_semver_core("0.53.2"), Some((0, 53, 2)));
257 assert_eq!(parse_semver_core("1.2.3-rc1"), Some((1, 2, 3)));
258 assert_eq!(
259 parse_semver_core("0.39.2+g9dd2d78.dirty.20260528T1408"),
260 Some((0, 39, 2))
261 );
262 }
263
264 #[test]
265 fn semver_core_rejects_unparseable() {
266 assert_eq!(parse_semver_core(""), None);
267 assert_eq!(parse_semver_core("not-a-version"), None);
268 assert_eq!(parse_semver_core("1.2"), None);
269 }
270
271 #[test]
272 fn strictly_older_compares_correctly() {
273 assert!(is_strictly_older("0.53.1", "0.53.2"));
274 assert!(is_strictly_older("0.52.4", "0.53.0"));
275 assert!(!is_strictly_older("0.53.2", "0.53.2"));
276 assert!(!is_strictly_older("0.54.0", "0.53.2"));
277 // Unparseable on either side: never claimed older.
278 assert!(!is_strictly_older("local-build-thing", "0.53.2"));
279 assert!(!is_strictly_older("0.53.1", "local-build-thing"));
280 }
281
282 #[test]
283 fn log_older_running_instances_never_panics_on_empty_dir() {
284 // No siblings at all — must be a complete no-op, not a panic or
285 // process exit. This is the "fail silently, never block launch"
286 // contract from the task brief.
287 let tmp = tempdir().unwrap();
288 let channels_root = tmp.path().join("channels");
289 log_older_running_instances(&channels_root, "stable", "0.53.2");
290 }
291
292 #[cfg(target_os = "windows")]
293 #[tokio::test]
294 async fn probe_liveness_true_for_bound_pipe_false_otherwise() {
295 use tokio::net::windows::named_pipe::ServerOptions;
296
297 let pipe_path = format!(
298 r"\\.\pipe\agentmux-other-instances-test-{}",
299 std::process::id()
300 );
301 let _server = ServerOptions::new()
302 .first_pipe_instance(true)
303 .create(&pipe_path)
304 .unwrap();
305
306 assert!(probe_liveness(&pipe_path));
307 assert!(!probe_liveness(r"\\.\pipe\agentmux-definitely-not-a-real-pipe"));
308 }
309}