Skip to main content

agentmux_launcher/
second_instance.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4#[cfg(not(target_os = "windows"))]
5use crate::logging::log;
6
7/// Phase B.6 (post-fix) — forward an `open_new_window` request to
8/// the already-running host and let this launcher exit 0.
9///
10/// The host writes `<data-dir>/ipc-port` after CEF init as
11/// `port:token`. We open a TCP connection to 127.0.0.1:port, send a
12/// minimal HTTP/1.1 POST to /ipc with the bearer token and a JSON
13/// body, and bail. We deliberately do NOT pull in reqwest: the
14/// launcher binary should stay tiny (~325 KB) and the protocol is
15/// fixed, so a hand-rolled request is the right tool.
16///
17/// Failure classification (codex P2 PR #598):
18/// - `Transient` — port file missing / unreadable / malformed.
19///   The host is alive (pipe held) but mid-startup; caller exits
20///   0 silently so the user isn't punished for double-clicking
21///   quickly.
22/// - `Fatal` — port file is readable, but the HTTP path failed
23///   (connect refused, write failed, timeout). Either a hung
24///   host or a non-running-instance source of
25///   `ERROR_ACCESS_DENIED` (namespace conflict, security
26///   descriptor failure). Caller surfaces the dialog so the user
27///   sees a real problem rather than a silent no-op.
28pub(crate) enum ForwardError {
29    Transient(String),
30    Fatal(String),
31}
32
33impl ForwardError {
34    pub(crate) fn reason(&self) -> &str {
35        match self {
36            Self::Transient(r) | Self::Fatal(r) => r,
37        }
38    }
39}
40
41/// How long a relaunch keeps at it when the running instance's host didn't
42/// take its `open_new_window`: long enough for an instance that is quitting
43/// to let go of the single-instance socket/pipe (its launcher holds on until
44/// srv has closed every agent, up to `SRV_EXIT_WAIT`, via
45/// `upgrade::quiesce_srv`).
46pub(crate) const QUITTING_INSTANCE_WAIT: std::time::Duration =
47    std::time::Duration::from_secs(agentmux_common::process::SRV_EXIT_WAIT.as_secs() + 5);
48
49/// What [`await_running_instance`] ended with.
50pub(crate) enum Handoff<T> {
51    /// The old instance quit; this launcher holds the socket/pipe now.
52    Claimed(T),
53    /// The host answered and took the request.
54    Forwarded,
55    /// Neither within [`QUITTING_INSTANCE_WAIT`]; the last forward error.
56    GaveUp(ForwardError),
57}
58
59/// A relaunch whose forward failed with `first`: the running instance is
60/// starting (no port file yet) or quitting (its host is gone; a clean exit
61/// deletes the port file). Until [`QUITTING_INSTANCE_WAIT`] passes, take the
62/// socket/pipe as soon as the old launcher lets go of it, or forward as soon
63/// as the host answers. Shared by both supervisors.
64pub(crate) fn await_running_instance<T>(
65    mut try_claim: impl FnMut() -> Option<T>,
66    mut try_forward: impl FnMut() -> Result<(), ForwardError>,
67    first: ForwardError,
68) -> Handoff<T> {
69    let until = std::time::Instant::now() + QUITTING_INSTANCE_WAIT;
70    let mut last = first;
71    loop {
72        if let Some(claimed) = try_claim() {
73            return Handoff::Claimed(claimed);
74        }
75        if std::time::Instant::now() >= until {
76            return Handoff::GaveUp(last);
77        }
78        std::thread::sleep(std::time::Duration::from_millis(250));
79        match try_forward() {
80            Ok(()) => return Handoff::Forwarded,
81            Err(e) => last = e,
82        }
83    }
84}
85
86/// Forward an arbitrary host IPC command over the same authenticated
87/// localhost channel `forward_open_new_window` uses.
88///
89/// Extracted (issue #2977 Workstream 1) so the tray can also send `quit_app`
90/// without a second copy of the port-file read, bearer-token handshake, and
91/// the read-the-response subtlety documented below — all of which are easy to
92/// get subtly wrong and none of which are specific to opening a window.
93/// `forward_open_new_window` is now a thin wrapper, so the long-standing
94/// second-instance path keeps its exact behavior.
95pub(crate) fn forward_host_cmd(
96    data_dir: &std::path::Path,
97    dir_hash: &str,
98    cmd: &str,
99) -> Result<(), ForwardError> {
100    forward_host_cmd_impl(data_dir, dir_hash, cmd)
101}
102
103pub(crate) fn forward_open_new_window(
104    data_dir: &std::path::Path,
105    dir_hash: &str,
106) -> Result<(), ForwardError> {
107    forward_host_cmd_impl(data_dir, dir_hash, "open_new_window")
108}
109
110/// `forward_host_cmd` with an `args` object — the host's `/ipc` body is
111/// `{"cmd", "args"}` (`crates/cef/src/ipc.rs` `IpcRequest`). Used by the
112/// notification click path (`open_new_window {workspace_id}`,
113/// `focus_window {label}`).
114pub(crate) fn forward_host_cmd_with_args(
115    data_dir: &std::path::Path,
116    dir_hash: &str,
117    cmd: &str,
118    args: serde_json::Value,
119) -> Result<(), ForwardError> {
120    forward_host_body(data_dir, dir_hash, &serde_json::json!({ "cmd": cmd, "args": args }).to_string())
121}
122
123fn forward_host_cmd_impl(
124    data_dir: &std::path::Path,
125    dir_hash: &str,
126    cmd: &str,
127) -> Result<(), ForwardError> {
128    // `cmd` is a fixed internal identifier chosen by call sites in this
129    // crate, never user input, so a plain format is safe here — there is no
130    // untrusted string to escape.
131    forward_host_body(data_dir, dir_hash, &format!(r#"{{"cmd":"{}"}}"#, cmd))
132}
133
134fn forward_host_body(
135    data_dir: &std::path::Path,
136    dir_hash: &str,
137    body: &str,
138) -> Result<(), ForwardError> {
139    // Read the version-scoped port file so we reach THIS version's host,
140    // not a concurrent release's host that may have overwritten "ipc-port".
141    let port_file_name = format!("ipc-port-{}", dir_hash);
142    let port_file = data_dir.join(&port_file_name);
143    let contents = std::fs::read_to_string(&port_file).map_err(|e| {
144        ForwardError::Transient(format!("read {}: {}", port_file.display(), e))
145    })?;
146    let trimmed = contents.trim();
147    let (port_str, token) = trimmed.split_once(':').ok_or_else(|| {
148        ForwardError::Transient(format!(
149            "malformed port file (expected port:token): {}",
150            trimmed
151        ))
152    })?;
153    let port: u16 = port_str
154        .parse()
155        .map_err(|e| ForwardError::Transient(format!("invalid port {:?}: {}", port_str, e)))?;
156
157    // From here on the file was readable: any failure is a fatal
158    // forward (the host got far enough to publish but isn't
159    // serving the IPC port).
160    let addr: std::net::SocketAddr = ([127, 0, 0, 1], port).into();
161    let mut stream = std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_secs(2))
162        .map_err(|e| ForwardError::Fatal(format!("connect 127.0.0.1:{}: {}", port, e)))?;
163    stream
164        .set_write_timeout(Some(std::time::Duration::from_secs(2)))
165        .ok();
166
167    let req = format!(
168        "POST /ipc HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nAuthorization: Bearer {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
169        token,
170        body.len(),
171        body
172    );
173    use std::io::{Read, Write};
174    stream
175        .write_all(req.as_bytes())
176        .map_err(|e| ForwardError::Fatal(format!("write request: {}", e)))?;
177    // CRITICAL: read at least the status line. The host's axum
178    // handler is async — if the launcher closes the TCP socket
179    // before axum has finished parsing + dispatching to
180    // `open_new_window`, the request can be dropped (smoke caught
181    // exactly this on v0.33.481: the launcher logged "forwarded"
182    // but no second window appeared because the process exited
183    // before axum ran the handler). We don't care about the body
184    // — `Connection: close` lets the server drop the socket once
185    // the response is written, so a single short read is enough
186    // to keep the connection alive past handler dispatch.
187    stream
188        .set_read_timeout(Some(std::time::Duration::from_secs(2)))
189        .ok();
190    let mut sink = [0u8; 64];
191    let _ = stream.read(&mut sink);
192    Ok(())
193}
194
195/// User-facing message for an actual second-instance collision (not a
196/// bind-failure, not a stale socket — a real running launcher answered our
197/// connect probe). Shared by both call sites in
198/// `bind_socket_with_recovery` (fast-path and post-recovery-lock retry),
199/// which previously duplicated this message and both got it wrong the same
200/// way — see docs/specs/SPEC_DEV_INSTANCE_ISOLATION_DIAGNOSTICS_2026_09_19.md.
201///
202/// For a `dev-` channel, this used to say "Use `task dev:local` to launch a
203/// second isolated session" — plausible-sounding, and wrong for the common
204/// case. `task dev:local`'s version bump only changes the launcher's own
205/// IPC socket key (`hash.rs`'s `data_dir_hash16`, keyed on
206/// `CARGO_PKG_VERSION`/`AGENTMUX_BUILD_LABEL` at compile time) — it does
207/// NOT change the CEF profile/data directory, which for dev mode is keyed
208/// on `(branch, clone_id)` — NOT version — via
209/// `crates/common/src/data_paths.rs`'s `resolve_channel_and_dir`
210/// (`honor_env_channel=false` for dev launches). `clone_id`
211/// (`runtime_mode.rs`'s `derive_clone_id`) is a hash of the CLONE'S OWN
212/// CANONICAL WORKSPACE-ROOT PATH — git-worktree-aware by design, so a
213/// second worktree of the SAME branch already gets a distinct `clone_id`
214/// and therefore a distinct data dir, with no branch switch needed;
215/// `resolve_channel_and_dir` folds both into the one `channel` string this
216/// function receives (`format!("dev-{branch}-{clone_id}")` — confirmed by
217/// reading that call site directly). Two dev instances collide on CEF's
218/// own `SingletonLock` only when BOTH branch and clone/workspace path are
219/// identical — reproduced live: a real rebuild under a bumped version, run
220/// from the unchanged clone path on the unchanged branch, got a new
221/// launcher socket and STILL hit `CEF early exit (process singleton or
222/// similar)` on the unchanged data dir. The fix is a different branch OR a
223/// different clone/worktree path (either one changes `channel`) — named
224/// directly here instead of pointing at a command that cannot solve this
225/// specific problem. (ReAgent P1 on this PR: an earlier revision of this
226/// message claimed branch was the ONLY lever, which is what this revision
227/// corrects — verified against `resolve_channel_and_dir`'s actual source,
228/// not just re-asserted.)
229#[cfg(not(target_os = "windows"))]
230fn print_dev_instance_collision(channel: &str, data_dir: &std::path::Path, socket_path: &str) {
231    eprintln!(
232        "AgentMux dev instance already running for channel \"{channel}\"\n\
233         (dev-<branch>-<clone_id>, where clone_id identifies THIS checkout's\n\
234         own workspace-root path). Data-dir isolation is keyed on branch AND\n\
235         clone/workspace path together, not build version — a `task dev:local`\n\
236         version bump will NOT let a second instance run alongside this one\n\
237         from this same checkout on this same branch (it only changes this\n\
238         launcher's own IPC socket key, not the CEF profile directory below).\n\
239         To run a second dev instance concurrently, either check out a\n\
240         DIFFERENT branch, or run `task dev` from a DIFFERENT clone/worktree\n\
241         path (even on this same branch — clone/worktree path alone is\n\
242         enough to change the channel above).\n\
243         Data dir: {}\n\
244         Socket:   {socket_path}",
245        data_dir.display()
246    );
247}
248
249/// Best-effort `open_new_window` forward for the unix second-instance path.
250/// Unlike the Windows path (which pops a dialog on a fatal forward), unix just
251/// logs and lets the caller exit 0: the existing instance is alive (its socket
252/// answered our connect probe), so a transient/fatal forward failure shouldn't
253/// block — at worst the relaunch is a silent no-op instead of a new window.
254/// SPEC_MACOS_LAUNCH_COHERENCE_2026_06_18.md.
255#[cfg(target_os = "macos")]
256pub(crate) fn forward_open_new_window_or_log(data_dir: &std::path::Path, dir_hash: &str) {
257    match forward_open_new_window(data_dir, dir_hash) {
258        Ok(()) => log("forwarded open_new_window to existing instance"),
259        Err(ForwardError::Transient(reason)) => {
260            log(&format!("open_new_window forward transient (host mid-startup?): {}", reason))
261        }
262        Err(ForwardError::Fatal(reason)) => {
263            log(&format!("open_new_window forward failed: {}", reason))
264        }
265    }
266}
267
268/// What a launcher that lost the single-instance race does: open a window in
269/// the running instance, unless it was started by a login entry, which only
270/// asks that AgentMux be running (SPEC_START_WITH_OS_2026_09_25.md §3.6).
271/// Not used by the macOS reopen handler, which is a user asking for a window.
272/// Returns the forward's error, if any, for [`await_running_instance`].
273#[cfg(not(target_os = "windows"))]
274fn forward_for_second_instance(data_dir: &std::path::Path, dir_hash: &str) -> Option<ForwardError> {
275    if !crate::autostart::second_instance_opens_window(&std::env::args().collect::<Vec<_>>()) {
276        log("login start: AgentMux is already running — exiting without opening a window");
277        return None;
278    }
279    match forward_open_new_window(data_dir, dir_hash) {
280        Ok(()) => {
281            log("forwarded open_new_window to existing instance");
282            None
283        }
284        Err(e) => Some(e),
285    }
286}
287
288/// The socket, once the launcher that held it has exited: unlink its file
289/// and bind. `None` while it still answers. Called under the recovery lock.
290#[cfg(not(target_os = "windows"))]
291fn claim_after_quit(socket_path: &str) -> Option<tokio::net::UnixListener> {
292    if std::os::unix::net::UnixStream::connect(socket_path).is_ok() {
293        return None;
294    }
295    let _ = std::fs::remove_file(socket_path);
296    crate::ipc::server::bind_first_unix_socket(socket_path).ok()
297}
298
299/// Bind the launcher's IPC socket with single-instance enforcement +
300/// crash-safe stale-socket recovery, serialized across concurrent
301/// launchers via `flock(2)`.
302///
303/// Returns a bound `UnixListener` on success. Calls `std::process::exit`
304/// on:
305///   * second-instance detection (exit code 0)
306///   * a hard bind failure that isn't `EADDRINUSE` (exit code 2)
307///   * unable to acquire the recovery lock (exit code 2)
308///
309/// Why the lockfile (codex P1 + reagent P1 on PR #1288): see the call-
310/// site comment. Two-launcher concurrent stale-cleanup would otherwise
311/// produce two live launchers for one data dir.
312#[cfg(not(target_os = "windows"))]
313pub(crate) fn bind_socket_with_recovery(
314    socket_path: &str,
315    data_dir: &std::path::Path,
316    dir_hash: &str,
317    channel: &str,
318) -> tokio::net::UnixListener {
319    use std::os::unix::io::AsRawFd as _;
320
321    // Fast path: bind without contention.
322    match crate::ipc::server::bind_first_unix_socket(socket_path) {
323        Ok(l) => return l,
324        Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => { /* slow path below */ }
325        Err(e) => {
326            log(&format!("FATAL: bind {} failed: {}", socket_path, e));
327            eprintln!(
328                "AgentMux failed to start: could not bind IPC socket.\n\nSocket: {}\nError: {}",
329                socket_path, e
330            );
331            std::process::exit(2);
332        }
333    }
334
335    // Slow path: contention. Acquire the recovery lock so only one
336    // launcher at a time does the connect-probe + unlink + rebind.
337    let lock_path = format!("{}.lock", socket_path);
338    let lock_file = match std::fs::OpenOptions::new()
339        .create(true)
340        .read(true)
341        .write(true)
342        .truncate(false)
343        .open(&lock_path)
344    {
345        Ok(f) => f,
346        Err(e) => {
347            log(&format!(
348                "FATAL: could not open recovery lockfile {}: {}",
349                lock_path, e
350            ));
351            eprintln!(
352                "AgentMux failed to start: could not open IPC recovery lockfile.\n\nLockfile: {}\nError: {}",
353                lock_path, e
354            );
355            std::process::exit(2);
356        }
357    };
358    // Block until we have the lock — another launcher's recovery
359    // window is bounded by a single bind + a single connect-probe;
360    // we won't wait long. flock(2) is auto-released on close (when
361    // the OS reaps the launcher process), so a SIGKILL'd holder
362    // doesn't leak the lock.
363    if unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX) } != 0 {
364        let errno = std::io::Error::last_os_error();
365        log(&format!(
366            "FATAL: flock({}, LOCK_EX) failed: {}",
367            lock_path, errno
368        ));
369        eprintln!(
370            "AgentMux failed to start: could not acquire IPC recovery lock.\n\nLockfile: {}\nError: {}",
371            lock_path, errno
372        );
373        std::process::exit(2);
374    }
375
376    // Retry the bind under the lock — another launcher may have
377    // already cleaned up the stale file while we were waiting on
378    // flock, leaving us free to bind directly.
379    match crate::ipc::server::bind_first_unix_socket(socket_path) {
380        Ok(l) => return l,
381        Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => { /* probe below */ }
382        Err(e) => {
383            log(&format!(
384                "FATAL: post-lock bind {} failed: {}",
385                socket_path, e
386            ));
387            eprintln!(
388                "AgentMux failed to start: post-lock IPC bind failed.\n\nSocket: {}\nError: {}",
389                socket_path, e
390            );
391            std::process::exit(2);
392        }
393    }
394
395    // Disambiguate: is the existing socket a real running launcher,
396    // or a stale file?
397    match std::os::unix::net::UnixStream::connect(socket_path) {
398        Ok(_) => {
399            // Real second-instance. Forward an `open_new_window` request to the
400            // already-running launcher's host (Windows-parity: `run_windows`'s
401            // `already_running` branch in supervisor/windows.rs), then exit
402            // cleanly. SPEC_MACOS_LAUNCH_COHERENCE_2026_06_18.md.
403            if channel.starts_with("dev-") {
404                print_dev_instance_collision(channel, data_dir, socket_path);
405            } else {
406                eprintln!(
407                    "AgentMux is already running for this data directory.\n\nSocket: {}",
408                    socket_path
409                );
410            }
411            if let Some(first) = forward_for_second_instance(data_dir, dir_hash) {
412                log(&format!(
413                    "[ipc] open_new_window not taken ({}) — waiting for the instance to start or finish quitting",
414                    first.reason()
415                ));
416                match await_running_instance(
417                    || claim_after_quit(socket_path),
418                    || forward_open_new_window(data_dir, dir_hash),
419                    first,
420                ) {
421                    Handoff::Claimed(listener) => {
422                        log("[ipc] the previous instance has quit — starting");
423                        return listener;
424                    }
425                    Handoff::Forwarded => log("forwarded open_new_window to existing instance"),
426                    Handoff::GaveUp(e) => log(&format!("open_new_window forward failed: {}", e.reason())),
427                }
428            }
429            log(&format!(
430                "[ipc] second-instance detected — existing launcher owns {}",
431                socket_path
432            ));
433            std::process::exit(0);
434        }
435        Err(connect_err)
436            if connect_err.kind() == std::io::ErrorKind::ConnectionRefused
437                || connect_err.raw_os_error() == Some(libc::ENOENT) =>
438        {
439            // Stale socket file from a crashed launcher. Unlink and
440            // rebind. The lock serializes us against other launchers
441            // ALSO doing recovery, but it does NOT block a fresh
442            // launcher taking the fast-path bind() above — that
443            // launcher is lock-free and can win the socket in the
444            // microsecond window between our `remove_file` and our
445            // `bind`. If that happens, AddrInUse means a real
446            // launcher just claimed the socket and WE are now the
447            // losing second instance, not a failed start.
448            // (Reagent P2 on PR #1288.)
449            log(&format!(
450                "[ipc] stale socket file at {} — unlinking and rebinding (under recovery lock)",
451                socket_path
452            ));
453            let _ = std::fs::remove_file(socket_path);
454            match crate::ipc::server::bind_first_unix_socket(socket_path) {
455                Ok(l) => l,
456                Err(retry_e) if retry_e.kind() == std::io::ErrorKind::AddrInUse => {
457                    if channel.starts_with("dev-") {
458                        print_dev_instance_collision(channel, data_dir, socket_path);
459                    } else {
460                        eprintln!(
461                            "AgentMux is already running for this data directory.\n\nSocket: {}",
462                            socket_path
463                        );
464                    }
465                    let _ = forward_for_second_instance(data_dir, dir_hash);
466                    log(&format!(
467                        "[ipc] post-recovery bind lost the race to a fresh launcher on {} — exiting as second instance",
468                        socket_path
469                    ));
470                    std::process::exit(0);
471                }
472                Err(retry_e) => {
473                    log(&format!(
474                        "FATAL: bind retry after stale-socket unlink failed: {}",
475                        retry_e
476                    ));
477                    eprintln!(
478                        "AgentMux failed to start: IPC rebind after stale cleanup failed.\n\nSocket: {}\nError: {}",
479                        socket_path, retry_e
480                    );
481                    std::process::exit(2);
482                }
483            }
484        }
485        Err(other) => {
486            log(&format!(
487                "[ipc] AddrInUse but connect probe failed in an unexpected way: {} — \
488                 treating as second instance and exiting cleanly",
489                other
490            ));
491            std::process::exit(0);
492        }
493    }
494    // `lock_file` drops here; flock auto-released on close.
495}
496
497#[cfg(test)]
498mod tests {
499    use super::{await_running_instance, ForwardError, Handoff};
500
501    /// A relaunch during a quit takes the socket/pipe as soon as the old
502    /// instance lets go of it, not after the whole wait.
503    #[test]
504    fn a_relaunch_claims_as_soon_as_the_old_instance_lets_go() {
505        let mut tries = 0;
506        let started = std::time::Instant::now();
507        let out = await_running_instance(
508            || {
509                tries += 1;
510                (tries == 3).then_some("socket")
511            },
512            || Err(ForwardError::Transient("port file gone".into())),
513            ForwardError::Transient("port file gone".into()),
514        );
515        assert!(matches!(out, Handoff::Claimed("socket")));
516        assert!(started.elapsed() < std::time::Duration::from_secs(2));
517    }
518
519    /// A relaunch while the instance is still starting forwards once its
520    /// host answers.
521    #[test]
522    fn a_relaunch_forwards_once_the_host_answers() {
523        let mut forwards = 0;
524        let out = await_running_instance(
525            || None::<()>,
526            || {
527                forwards += 1;
528                if forwards == 2 { Ok(()) } else { Err(ForwardError::Transient("not yet".into())) }
529            },
530            ForwardError::Transient("not yet".into()),
531        );
532        assert!(matches!(out, Handoff::Forwarded));
533        assert_eq!(forwards, 2);
534    }
535}