Skip to main content

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}