Skip to main content

agentmux_srv/bootstrap/
network.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Split out of the single-file bootstrap module unchanged (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md §4.3).
5
6use super::*;
7
8/// Output of [`bind_listeners_and_network`].
9pub struct NetworkBundle {
10    pub web_listener: TcpListener,
11    pub ws_listener: TcpListener,
12    pub web_addr: std::net::SocketAddr,
13    pub ws_addr: std::net::SocketAddr,
14    pub local_web_url: String,
15    /// OS hostname, resolved once here because LAN discovery needs it for its
16    /// mDNS TXT records. Carried on the bundle so `build_app_state` can put it
17    /// on `AppState` too, rather than resolving it a second time.
18    pub hostname: String,
19    pub lan_discovery: Arc<backend::lan_discovery::LanDiscoveryController>,
20    /// Owns the LAN-facing listeners so `network:lan_discovery` takes effect
21    /// live, instead of only at the next startup — see
22    /// `backend::lan_listeners`. Needs its router handed to it by `main.rs`
23    /// after `build_router`.
24    pub lan_listeners: Arc<backend::lan_listeners::LanListenerSupervisor>,
25    pub lsp_supervisor: Arc<backend::lsp::LspSupervisor>,
26    pub process_tracker: Arc<backend::process_tracker::registry::AgentProcessRegistry>,
27    pub process_broker: Arc<crate::broker::ProcessBroker>,
28}
29
30/// Step 5: Bind 2 TCP listeners (web + ws — separate ports matching Go), then
31/// bring up LAN discovery (mDNS), the LSP supervisor, stale registry cleanup,
32/// and the process tracker/broker.
33pub async fn bind_listeners_and_network(
34    config: &config::Config,
35    event_bus: &Arc<EventBus>,
36    broker: &Arc<Broker>,
37    version: &str,
38) -> NetworkBundle {
39    // Startup listeners are ALWAYS loopback-only, regardless of the
40    // `network:lan_discovery` setting. LAN reachability is owned entirely by
41    // `backend::lan_listeners::LanListenerSupervisor`, which binds one extra
42    // socket per non-loopback interface address on these same ports and tears
43    // them down when the setting flips off. `main.rs` calls `apply()` with the
44    // current setting immediately after the router exists, so a user who
45    // already had LAN enabled is reachable from boot — just via the
46    // supervisor's sockets rather than a wildcard one here.
47    //
48    // Do NOT reintroduce a conditional `0.0.0.0:0` bind. A wildcard socket
49    // holding the port makes the supervisor's per-address binds fail with
50    // EADDRINUSE on Linux/macOS (they succeed on Windows, which lacks
51    // SO_EXCLUSIVEADDRUSE by default — so the bug reproduces on only two of
52    // three platforms). On Linux/macOS that leaves `active` empty, which drives
53    // `sync_advertising` to `discovery.apply(false)` and silently switches off
54    // the mDNS advertising the user had enabled — an ON→OFF regression on every
55    // restart for exactly the users who already opted in. On Windows it instead
56    // leaves two sockets on one port with ambiguous accept. Keeping startup
57    // strictly loopback makes the supervisor the single owner of every LAN
58    // socket on both counts. [reagent #3021 P0]
59    //
60    // The scoped `lan_key` (X-AuthKey header, broadcast in the mDNS TXT record)
61    // gates only the three LAN-forwarding routes (`lan_or_full_auth_middleware`)
62    // — not the full auth_key previously broadcast here, which gated the entire
63    // API surface (see Config::lan_key's doc comment).
64    // Loopback, OS-chosen ports — or, headless only, the fixed ports it was
65    // given (`--web-port` / `--ws-port`, SPEC_SRV_HEADLESS_MODE_2026_09_26.md).
66    //
67    // Not headless: the next two free ports of `lan_ports::LAN_PORT_RANGE`, so
68    // one firewall rule on that range covers every build and update
69    // (SPEC_LAN_FIREWALL_SETUP_2026_10_01.md §4.1); OS-chosen only if the range
70    // is exhausted. Headless keeps the fixed ports it was given.
71    let (web_listener, ws_listener) = if crate::headless::active() {
72        let web_bind = crate::headless::startup_bind_addr(crate::headless::Listener::Web);
73        let ws_bind = crate::headless::startup_bind_addr(crate::headless::Listener::Ws);
74        let web = TcpListener::bind(&web_bind)
75            .await
76            .unwrap_or_else(|e| panic!("failed to bind web listener on {web_bind}: {e}"));
77        let ws = TcpListener::bind(&ws_bind)
78            .await
79            .unwrap_or_else(|e| panic!("failed to bind ws listener on {ws_bind}: {e}"));
80        (web, ws)
81    } else {
82        let bound = backend::lan_ports::bind_startup_listeners().await;
83        if !bound.in_range {
84            tracing::warn!(
85                range = ?backend::lan_ports::LAN_PORT_RANGE,
86                "LAN port range exhausted: listening on OS-chosen ports, which a firewall rule on the range does not cover"
87            );
88        }
89        (bound.web, bound.ws)
90    };
91
92    let web_addr = web_listener.local_addr().unwrap();
93    let ws_addr = ws_listener.local_addr().unwrap();
94    // Always use 127.0.0.1 for the local URL regardless of bind address.
95    // When bound to 0.0.0.0, local_addr() returns 0.0.0.0:PORT which is not a
96    // valid connect destination (fails on Windows and some Linux configs).
97    let local_web_url = format!("http://127.0.0.1:{}", web_addr.port());
98
99    // Make local backend URL available to child processes (PTY shells).
100    // the muxbus client (agentbus-client package) reads AGENTMUX_LOCAL_URL for local PTY delivery
101    // instead of routing through the cloud muxbus relay.
102    std::env::set_var("AGENTMUX_LOCAL_URL", &local_web_url);
103
104    // LAN discovery via mDNS — opt-in to avoid Windows Firewall prompt.
105    // mDNS binds 0.0.0.0:5353 UDP which triggers the firewall dialog.
106    // The setting defaults to false; users opt in via the HostPopover toggle
107    // (or by editing settings.json). The controller supports live start/stop
108    // so flipping the setting does not require an app restart.
109    // See docs/specs/lan-discovery-toggle.md.
110    let hostname = whoami::fallible::hostname().unwrap_or_else(|_| "unknown".to_string());
111    let lan_discovery = Arc::new(backend::lan_discovery::LanDiscoveryController::new(
112        config.instance_id.clone(),
113        hostname.clone(),
114        version.to_string(),
115        web_addr.port(),
116        event_bus.clone(),
117        config.lan_key.clone(),
118    ));
119    // The LAN tier of one-live-instance-per-agent asks these peers.
120    backend::agent_admission::set_lan_discovery(lan_discovery.clone());
121    // Notices an instance that hears peers but cannot be heard (no IPv4 mDNS
122    // socket: `lan_mdns_health`), rebuilds the daemon, and tells the indicator.
123    // Idle while LAN discovery is off.
124    lan_discovery.clone().spawn_health_watchdog();
125    // Deliberately NOT applied here. The supervisor below is the single driver
126    // of `lan_discovery.apply`, so mDNS can never advertise an endpoint before
127    // (or without) a socket actually listening on it. `main.rs` reads the
128    // setting and calls `lan_listeners.apply(..)` as soon as the router exists,
129    // which reconciles listeners and then gates advertising on the result.
130
131    // LAN listeners. Startup bound loopback only (always — see the bind comment
132    // above); this supervisor adds/removes the LAN-facing listeners on the SAME
133    // ports so the setting takes effect live rather than at the next restart.
134    // `apply` is deferred to `main.rs`, which is where the router it needs to
135    // serve finally exists.
136    let lan_listeners = Arc::new(backend::lan_listeners::LanListenerSupervisor::new(
137        web_addr.port(),
138        ws_addr.port(),
139    ));
140    // Headless srv is loopback-only, whatever the channel's LAN setting says.
141    if crate::headless::active() {
142        lan_listeners.forbid_lan();
143    }
144    // The supervisor owns the advertise/reachable pairing: it re-gates mDNS on
145    // every reconcile so we never advertise an address nothing is listening on,
146    // and it performs the boot-time setting read too (via `main.rs`).
147    lan_listeners.set_discovery(lan_discovery.clone());
148    // Tells the status bar whether the OS firewall would let LAN peers in
149    // (Windows only for now). Read-only.
150    backend::lan_firewall::spawn_watcher(
151        lan_discovery.clone(),
152        web_addr.port(),
153        ws_addr.port(),
154        event_bus.clone(),
155    );
156
157    // LSP supervisor — owns LSP server child processes. Nothing spawned
158    // until the editor pane calls `lspstart`. Spec:
159    // docs/specs/SPEC_EDITOR_LSP_AND_THEMES_2026-05-26.md
160    let lsp_supervisor = Arc::new(backend::lsp::LspSupervisor::new(event_bus.clone()));
161
162    // Clean up stale cross-instance agent registry entries (entries older than 4h).
163    backend::reactive::registry::cleanup_stale(
164        &base::get_mux_data_dir(),
165        4 * 60 * 60 * 1000,
166    );
167
168    // Same sweep for the host-global shared registry (Tier 2b, issue #1916)
169    // — additionally drops entries whose owning PID no longer exists on this
170    // host, since a channel that crashed without a clean unregister can
171    // leave an entry behind indefinitely otherwise (no other channel's
172    // startup would ever revisit it).
173    if let Some(shared_dir) = registry::resolve_shared_reactive_dir() {
174        backend::reactive::registry::cleanup_stale_shared(&shared_dir, 4 * 60 * 60 * 1000);
175    }
176
177    // Periodic re-registration heartbeat for every LOCALLY-registered agent's
178    // Tier 2 (per-channel) and Tier 2b (host-global shared) registry
179    // entries. Without this, an entry's `updated_at` is stamped once at
180    // registration and never refreshed — so `should_evict_on_forward_failure`
181    // (registry.rs)'s grace-period check is only ever true in the ~60s right
182    // after an agent registers, useless for any steady-state agent (i.e.
183    // most of them). reagent P1 round 3 on PR #2640: the PID+age eviction
184    // guard from the earlier commits on that PR doesn't actually protect a
185    // live, long-running agent from a single transient forward failure,
186    // reproducing the exact bug the PR claims to fix. This heartbeat closes
187    // that gap: `reactive::get_global_handler().list_agents()` is the
188    // in-memory Tier-1 registry, updated synchronously on every
189    // register/unregister — so it's always accurate, with no staleness
190    // window of its own. Re-writing from it keeps a live agent's on-disk
191    // entries fresh indefinitely; a genuinely-dead agent simply stops
192    // appearing in `list_agents()` (its own register/unregister lifecycle
193    // already handles that), so its entries age past the grace period and
194    // become evictable again within one heartbeat interval. The interval is
195    // well under `FORWARD_FAILURE_GRACE_MS` so a live entry's age never
196    // approaches the grace threshold between heartbeats. See
197    // docs/retro/retro-cross-channel-jekt-eviction-2026-08-17.md.
198    {
199        let local_web_url = local_web_url.clone();
200        tokio::spawn(async move {
201            const HEARTBEAT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(20);
202            let mut interval = tokio::time::interval(HEARTBEAT_INTERVAL);
203            loop {
204                interval.tick().await;
205                let data_dir = base::get_mux_data_dir();
206                for reg in reactive::get_global_handler().list_agents() {
207                    // _with_nonce, not the plain write/write_shared_from_env
208                    // — those hardcode registration_nonce: 0, which would
209                    // silently break remove_if_nonce/
210                    // remove_shared_from_env_if_nonce's compare-and-remove
211                    // on this agent's own clean-exit cleanup (reagent P1
212                    // round 4 on PR #2640: every 20s heartbeat tick would
213                    // zero out the real nonce already recorded in
214                    // `reg.registration_nonce`).
215                    backend::reactive::registry::write_with_nonce(
216                        &data_dir,
217                        &reg.agent_id,
218                        &local_web_url,
219                        &reg.block_id,
220                        reg.registration_nonce,
221                    );
222                    backend::reactive::registry::write_shared_from_env_with_nonce(
223                        &reg.agent_id,
224                        &local_web_url,
225                        &reg.block_id,
226                        reg.registration_nonce,
227                    );
228                }
229            }
230        });
231    }
232
233    // Linux: join a delegated systemd user scope first, so each agent gets a
234    // cgroup holding everything it starts (process_tracker::cgroup_linux).
235    // Before the registry exists, so no agent can spawn outside it.
236    #[cfg(target_os = "linux")]
237    backend::process_tracker::cgroup_linux::init().await;
238
239    // Tracks agent-spawned OS processes per block. Registered trackers
240    // live as long as their agent pane; the background poller emits
241    // delta events (`agent:process-added`/`-exited`) to the frontend.
242    let process_tracker = std::sync::Arc::new(
243        backend::process_tracker::registry::AgentProcessRegistry::new(Some(broker.clone())),
244    );
245    backend::process_tracker::registry::set_global(process_tracker.clone());
246    backend::process_tracker::registry::spawn_poller(process_tracker.clone());
247
248    // Process Broker (Phase A) — unified read path over blockcontroller +
249    // process_tracker. See docs/specs/REPORT_PROCESS_ARCHITECTURE_STATE_AND_RETHINK_2026_07_22.md.
250    let process_broker = std::sync::Arc::new(crate::broker::ProcessBroker::new(Some(broker.clone())));
251    crate::broker::process::set_global(process_broker.clone());
252
253    NetworkBundle {
254        web_listener,
255        ws_listener,
256        web_addr,
257        ws_addr,
258        local_web_url,
259        hostname,
260        lan_discovery,
261        lan_listeners,
262        lsp_supervisor,
263        process_tracker,
264        process_broker,
265    }
266}