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}