Skip to main content

agentmux_srv/backend/
lan_discovery.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! LAN instance discovery via mDNS/DNS-SD.
5//!
6//! Each AgentMux backend advertises itself as `_agentmux._tcp.local.` and
7//! continuously browses for peers. Discovered instances are tracked in memory
8//! and broadcast to frontend clients via EventBus.
9
10use std::collections::{BTreeMap, BTreeSet, HashMap};
11use std::net::{IpAddr, Ipv4Addr};
12use std::sync::Arc;
13
14use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
15use parking_lot::{Mutex, RwLock};
16
17use crate::backend::agent_state::AgentState;
18use crate::backend::lan_mdns_diag;
19use crate::backend::lan_mdns_health;
20use serde::{Deserialize, Serialize};
21use serde_json::json;
22use tokio::net::UdpSocket;
23use tokio::sync::oneshot;
24
25use super::eventbus::EventBus;
26
27mod agent_names;
28mod events;
29mod lan_instances;
30mod udp_peers;
31
32pub use events::{LanDiscoverability, LanDiscoveryError, LanInstanceList};
33pub(crate) use agent_names::{parse_agent_names_body, PeerAgents};
34pub(crate) use udp_peers::DESKTOP_DISCOVERY_PORT;
35
36const SERVICE_TYPE: &str = "_agentmux._tcp.local.";
37const LAN_AGENT_CACHE_TTL_SECS: u64 = 60;
38const LAN_PEER_QUERY_TIMEOUT_SECS: u64 = 2;
39/// How long a peer is kept after its most recent `ServiceResolved` before
40/// `get_instances()` treats it as gone. mdns-sd's own `ServiceRemoved` event
41/// is NOT used for this (see the `ServiceRemoved` arm of `handle_event`
42/// below) — live logs showed it firing on ordinary TTL churn seconds before
43/// the same peer re-resolved, and every re-resolution after a `remove()` came
44/// back with a blank TXT record, so deleting on `ServiceRemoved` permanently
45/// wiped a live peer's hostname/instance_id/version/auth_key for no real
46/// disappearance.
47///
48/// This is a FLOOR, not the actual timeout — see `peer_staleness_window_secs`.
49/// A fixed 300s (this constant's original, only value — see
50/// docs/retro/retro-lan-peer-stale-timeout-too-short-for-slow-reannounce-peers-2026-09-17.md)
51/// was tuned against one fast-reannouncing peer seen in live logs (1-2 minute
52/// re-fire gaps) and wrongly pruned peers whose own advertised record TTL is
53/// much longer: RFC 6762 §10's recommended (and mdns-sd 0.12's hardcoded,
54/// unconfigurable-from-this-crate-version) default `other_ttl` for PTR/TXT
55/// records is 4500s/75min, and real LAN peers were observed re-announcing on
56/// gaps up to ~46 minutes — comfortably inside that TTL, but 9x past the old
57/// fixed cutoff. 300s remains only as a safety floor against a peer that
58/// somehow advertises an implausibly small TTL.
59const LAN_PEER_STALE_TIMEOUT_FLOOR_SECS: u64 = 300;
60/// Ceiling on `other_ttl_secs`-driven staleness (ReAgent P1, PR #3301):
61/// `other_ttl_secs` is taken directly from a peer's own mDNS advertisement,
62/// which this file already treats as adversarial elsewhere (see
63/// `LAN_AGENT_NAMES_MAX_*` above — "anything that can fake an mDNS
64/// advertisement... makes it into `self.instances`"). Without a cap, a
65/// spoofed peer advertising `other_ttl` near `u32::MAX` (~136 years) would be
66/// honored by both `get_instances()` and the periodic GC, permanently pinning
67/// it as "alive" — defeating the entire self-pruning design this exists for.
68/// 2x the RFC 6762 §10 / mdns-sd 0.12 default (4500s) comfortably covers every
69/// legitimate cadence actually observed on this LAN (narko/starpower's ~46min
70/// gaps) with margin, while bounding attacker-controlled input the same way
71/// the byte/count/length caps above already do.
72const LAN_PEER_STALE_TIMEOUT_CEIL_SECS: u64 = 9000;
73
74/// A peer is stale once `last_seen` exceeds ITS OWN advertised `other_ttl` —
75/// the standards-defined lifetime of the PTR/TXT records that make up an mDNS
76/// announcement — not one fixed cutoff shared by every peer regardless of how
77/// often it actually re-announces. Clamped between
78/// `LAN_PEER_STALE_TIMEOUT_FLOOR_SECS` (guards an implausibly small
79/// advertised TTL) and `LAN_PEER_STALE_TIMEOUT_CEIL_SECS` (guards an
80/// adversarially large one — `other_ttl_secs` is untrusted peer input).
81fn peer_staleness_window_secs(other_ttl_secs: u32) -> u64 {
82    (other_ttl_secs as u64).clamp(LAN_PEER_STALE_TIMEOUT_FLOOR_SECS, LAN_PEER_STALE_TIMEOUT_CEIL_SECS)
83}
84/// How often each known LAN peer is asked for its agent-name list. Peers are
85/// few (one per machine) and the request is a single small GET, so this is
86/// cheap; it's deliberately slower than `LAN_AGENT_CACHE_TTL_SECS` because
87/// this list is for *display* ("who lives on that host"), not for delivery
88/// routing — `find_agent` still does its own authoritative lookup.
89const LAN_AGENT_NAMES_REFRESH_SECS: u64 = 30;
90/// Hard cap on a single peer's `/agentmux/reactive/agent-names` response body
91/// (Codex P1 on PR #3102): the `lan_key` a peer advertised authenticates
92/// THIS client TO that peer, not the peer to us — anything that can fake an
93/// mDNS advertisement for `_agentmux._tcp.local` makes it into
94/// `self.instances` and gets polled by this loop. Enforced while reading, not
95/// via `Content-Length` (a malicious peer can omit or lie about that while
96/// still streaming an unbounded chunked body). 64 KiB is generous for a
97/// plain name list — real fleets are agents-per-machine, not per-thousand.
98pub(crate) const LAN_AGENT_NAMES_MAX_RESPONSE_BYTES: usize = 64 * 1024;
99/// Defense in depth past the byte cap above: even a response that fits in
100/// 64 KiB could be an adversarially compact array of many short strings.
101const LAN_AGENT_NAMES_MAX_COUNT: usize = 500;
102/// A legitimate agent name is short; anything absurdly long is more likely
103/// an attempt to waste memory than a real name, so it's dropped rather than
104/// truncated (truncating could quietly merge two distinct long names).
105const LAN_AGENT_NAMES_MAX_NAME_LEN: usize = 256;
106
107/// UDP broadcast discovery fallback (Layer 2), for LANs where mDNS multicast
108/// is filtered (common on corporate/guest WiFi). Mobile clients broadcast a
109/// small JSON probe to this port; any listening desktop instance unicasts a
110/// response straight back to the sender's source address.
111///
112/// Port 47891 is picked from the "ephemeral-safe-but-memorable" range: above
113/// both the well-known (0-1023) and IANA-registered (1024-49151) ranges, so
114/// it never collides with a registered service, while still being a fixed,
115/// easy-to-grep value in logs and firewall rules (unlike a random ephemeral
116/// port, which a fixed-port broadcast probe cannot target).
117pub(crate) const UDP_DISCOVERY_PORT: u16 = 47891;
118
119/// Wire-protocol `type` value a probe datagram must carry.
120pub(crate) const UDP_PROBE_TYPE: &str = "agentmux_discover";
121/// Wire-protocol `type` value this responder replies with.
122const UDP_RESPONSE_TYPE: &str = "agentmux_discover_response";
123/// Wire-protocol version. Bump alongside the mobile client if the schema
124/// changes; `is_valid_probe` rejects anything else.
125pub(crate) const UDP_PROTOCOL_VERSION: u64 = 1;
126/// How often an instance that could not bind `UDP_DISCOVERY_PORT` tries
127/// again, so another LAN-enabled channel on this host takes over the probe
128/// port when its holder stops (SPEC_LAN_FLEET_FEED_2026_10_03.md §3).
129const UDP_RESPONDER_RETRY: std::time::Duration = std::time::Duration::from_secs(15);
130
131/// Build the JSON response payload for a valid probe. Pure/free function
132/// (no `&self`) so it is trivially testable without spinning up a full
133/// `LanDiscovery` (which owns a real mDNS `ServiceDaemon`). `LanDiscovery`'s
134/// production loop calls this via `build_probe_response`.
135fn probe_response_json(
136    instance_id: &str,
137    hostname: &str,
138    version: &str,
139    port: u16,
140    auth_key: &str,
141) -> serde_json::Value {
142    json!({
143        "type": UDP_RESPONSE_TYPE,
144        "v": UDP_PROTOCOL_VERSION,
145        "instance_id": instance_id,
146        "hostname": hostname,
147        "version": version,
148        "port": port,
149        "auth_key": auth_key,
150    })
151}
152
153/// Validate a received UDP datagram as an in-protocol discovery probe.
154/// Anything that fails to parse as JSON, or does not carry the exact
155/// `type`/`v` fields, is treated as noise (unrelated LAN traffic or a
156/// future/older protocol version) and silently ignored by the caller.
157fn is_valid_probe(bytes: &[u8]) -> bool {
158    let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
159        return false;
160    };
161    value.get("type").and_then(|t| t.as_str()) == Some(UDP_PROBE_TYPE) && is_probe_version_match(&value)
162}
163
164/// Match the probe's `v` field against `UDP_PROTOCOL_VERSION`, accepting
165/// either JSON integer or float encoding. Some JSON serializers (e.g. a
166/// client whose numeric type is inferred as `double`) emit a whole number
167/// like `1` as `1.0`; `Value::as_u64()` alone returns `None` for that
168/// representation, which would silently drop an otherwise-conformant probe.
169fn is_probe_version_match(value: &serde_json::Value) -> bool {
170    let Some(v) = value.get("v") else {
171        return false;
172    };
173    v.as_u64() == Some(UDP_PROTOCOL_VERSION) || v.as_f64() == Some(UDP_PROTOCOL_VERSION as f64)
174}
175
176/// Trust-boundary check for the UDP responder: is `addr` reachable only from
177/// a private/link-local network, i.e. plausibly on this LAN? Unlike mDNS
178/// (link-local multicast, structurally non-routable off-subnet), this socket
179/// binds `0.0.0.0` and would otherwise answer *any* routable unicast probe
180/// with `auth_key` — an internet-facing disclosure oracle / UDP reflection
181/// vector if the port is ever reachable externally (port forward, hairpin
182/// NAT). Mirrors the private/loopback/link-local classification used for the
183/// opposite purpose (SSRF egress checks) in
184/// `drone/executor/blocks/api.rs::is_reserved_v4`/`is_reserved_v6`, minus the
185/// broadcast/unspecified/multicast cases that don't apply to a source addr.
186fn is_lan_source(addr: &std::net::SocketAddr) -> bool {
187    match addr.ip() {
188        IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(),
189        IpAddr::V6(v6) => {
190            if v6.is_loopback()
191                // unique-local fc00::/7
192                || (v6.segments()[0] & 0xfe00) == 0xfc00
193                // link-local fe80::/10
194                || (v6.segments()[0] & 0xffc0) == 0xfe80
195            {
196                return true;
197            }
198            // IPv4-mapped/-compatible v6 literals route to the embedded v4
199            // address on most kernels — delegate so a private v4 source
200            // wearing a v6 wrapper isn't misclassified as public.
201            if let Some(v4) = v6.to_ipv4_mapped() {
202                return v4.is_loopback() || v4.is_private() || v4.is_link_local();
203            }
204            #[allow(deprecated)]
205            if let Some(v4) = v6.to_ipv4() {
206                return v4.is_loopback() || v4.is_private() || v4.is_link_local();
207            }
208            false
209        }
210    }
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
214#[ts(export, export_to = "../../../frontend/types/rpc/")]
215pub struct LanInstance {
216    pub instance_id: String,
217    pub hostname: String,
218    pub version: String,
219    /// The advertising instance's channel (`AGENTMUX_CHANNEL`, default
220    /// "stable"), so a host running two channels can be told apart in the
221    /// Swarm (SPEC_SWARM_OTHER_HOSTS_AND_CHANNELS_2026_10_02.md Phase 2). Empty
222    /// for a peer on a build from before it was advertised.
223    #[serde(default)]
224    pub channel: String,
225    /// The peer's operating system (`windows`, `macos`, `linux`), for the
226    /// platform tag in the Swarm. Display only; empty for a peer that does not
227    /// advertise it (SPEC_SWARM_REMOTE_AGENTS_PLATFORM_TAG_AND_SELECTION_2026_10_03.md §3).
228    #[serde(default)]
229    pub os: String,
230    pub address: String,
231    pub port: u16,
232    /// The peer's `lan_key`; never serialized, as no reader of the JSON needs it.
233    #[serde(skip_serializing, default)]
234    #[ts(skip)]
235    pub auth_key: String,
236    pub agents: Vec<String>,
237    /// Each listed agent's state and summary line as the peer reported them, by
238    /// lower-cased name (`lan_discovery/agent_names.rs`). Self-reported, for the Swarm.
239    #[serde(default)]
240    pub agent_status: BTreeMap<String, AgentState>,
241    #[serde(default)]
242    pub agent_lines: BTreeMap<String, String>,
243    #[ts(type = "number")]
244    pub first_seen: u64,
245    #[ts(type = "number")]
246    pub last_seen: u64,
247    /// Unix seconds of the last answered agent-name poll, 0 for never. The Swarm
248    /// judges liveness by the later of this and `last_seen` (mDNS re-resolves only).
249    #[serde(default)]
250    #[ts(type = "number")]
251    pub last_polled_ok: u64,
252    /// This peer's advertised PTR/TXT record TTL (seconds) at its latest
253    /// `ServiceResolved` (`get_other_ttl()`); drives `peer_staleness_window_secs`.
254    pub other_ttl_secs: u32,
255}
256
257struct LanCacheEntry {
258    /// `None` = negative cache entry: agent is not on any LAN peer.
259    peer_url: Option<String>,
260    auth_key: String,
261    expires: std::time::Instant,
262}
263
264/// One in-flight `GET /agentmux/reactive/agent?id=…` against a single peer.
265type PeerQueryOutcome = (String, String, Result<reqwest::Response, reqwest::Error>);
266
267/// Query EVERY eligible LAN peer concurrently for `agent_id`, yielding results
268/// as they arrive.
269///
270/// Replaces the sequential `for peer in &peers { … .await }` both lookups used
271/// to run. That loop cost up to `LAN_PEER_QUERY_TIMEOUT_SECS × peer_count`
272/// before a message could be delivered — 10s across five peers — and the
273/// pathological ordering (a dead or slow peer ahead of the right one) is the
274/// common case on a laptop network where stale peers linger until their mDNS
275/// TTL expires. Fanning out makes the worst case ~one timeout regardless of
276/// peer count.
277///
278/// Returns a stream so each caller keeps its OWN acceptance rule: `find_agent`
279/// accepts any 2xx, while `find_agent_lan_pubkey` must keep trying after a 2xx
280/// whose body carries no `lan_public_key` (that peer hosts the agent but hasn't
281/// minted a key). A shared "first success wins" helper could not express both.
282///
283/// **Dropping the returned stream cancels every still-in-flight request** —
284/// so a caller that `break`s on the first acceptable answer stops paying for
285/// the rest, rather than merely ignoring them.
286fn query_peers_concurrently<'a>(
287    peers: &'a [LanInstance],
288    agent_id: &'a str,
289    http: &'a reqwest::Client,
290) -> futures_util::stream::FuturesUnordered<impl std::future::Future<Output = PeerQueryOutcome> + 'a> {
291    let futures = futures_util::stream::FuturesUnordered::new();
292    for peer in peers {
293        // Same eligibility rule the sequential loops used: a peer with no
294        // address or no scoped lan_key can't be queried at all.
295        if peer.address.is_empty() || peer.auth_key.is_empty() {
296            continue;
297        }
298        let peer_url = format!("http://{}:{}", peer.address, peer.port);
299        let auth_key = peer.auth_key.clone();
300        futures.push(async move {
301            let result = http
302                .get(format!("{peer_url}/agentmux/reactive/agent"))
303                .query(&[("id", agent_id)])
304                .header(agentmux_common::AUTH_KEY_HEADER, &auth_key)
305                .timeout(std::time::Duration::from_secs(LAN_PEER_QUERY_TIMEOUT_SECS))
306                .send()
307                .await;
308            (peer_url, auth_key, result)
309        });
310    }
311    futures
312}
313
314/// Pure predicate behind `LanDiscovery::resolves_to_this_instance`, split out
315/// so it can be tested without standing up an mDNS `ServiceDaemon`.
316///
317/// `any` rather than `all` on the address match: a resolution for our own
318/// service can legitimately carry an address that interface enumeration missed
319/// (they race), and requiring every address to be ours would let that one
320/// stray entry resurrect the phantom. `any` cannot produce a false positive —
321/// combined with the port equality, a genuine remote peer would have to be
322/// listening on our port *at one of our own interface addresses*, which it
323/// cannot be. Another AgentMux instance on this same host does share our
324/// addresses, but never our port.
325fn is_self_resolution(
326    resolved_port: u16,
327    resolved_addrs: &[IpAddr],
328    own_port: u16,
329    own_addrs: &std::collections::HashSet<IpAddr>,
330) -> bool {
331    if resolved_port != own_port || own_addrs.is_empty() {
332        return false;
333    }
334    resolved_addrs.iter().any(|a| own_addrs.contains(a))
335}
336
337pub struct LanDiscovery {
338    daemon: ServiceDaemon,
339    instances: Arc<RwLock<HashMap<String, LanInstance>>>,
340    instance_id: String,
341    event_bus: Arc<EventBus>,
342    service_fullname: String,
343    auth_key: String,
344    hostname: String,
345    version: String,
346    port: u16,
347    /// Cancellation half for the UDP responder task spawned in `start()`.
348    /// `None` once `shutdown()` has fired. Set even if the UDP socket never
349    /// binds (`udp_responder_loop` then just exits, and `shutdown()`'s send
350    /// is a harmless no-op). Guarded by a sync mutex since
351    /// `shutdown()` is `&self` and called from both an explicit live-toggle
352    /// path and `Drop`.
353    udp_cancel: Mutex<Option<oneshot::Sender<()>>>,
354    /// Cancellation half for the desktop-to-desktop UDP discovery task
355    /// (`udp_peers`). Same contract as `udp_cancel`: the task holds its own
356    /// `Arc<LanDiscovery>`, so dropping the controller's Arc does not stop it.
357    udp_peer_cancel: Mutex<Option<oneshot::Sender<()>>>,
358    /// Cancels `agent_names_refresh_loop`. Needed for the same reason
359    /// `udp_cancel` is: that task holds its own `Arc<LanDiscovery>` clone, so
360    /// dropping the controller's Arc on `apply(false)` never reaches refcount
361    /// zero and the loop would otherwise keep issuing authenticated requests
362    /// to peers forever — leaking one orphaned task per disable/enable cycle.
363    agent_names_cancel: Mutex<Option<oneshot::Sender<()>>>,
364    /// Cancels `instance_record_loop`. Same contract as `udp_cancel`.
365    instance_record_cancel: Mutex<Option<oneshot::Sender<()>>>,
366    /// Where this instance's `lan_instances` record is written; `None` when
367    /// there is nowhere to write it (see `lan_instances::instances_dir`).
368    instances_dir: Option<std::path::PathBuf>,
369    /// `true` while the record may be (re)written; cleared for good by
370    /// `withdraw_instance_record`.
371    instance_record_live: Mutex<bool>,
372    /// IPv4 addresses `mdns-sd` reported announcing our service on, from its
373    /// monitor channel. An interface whose socket failed to bind never appears
374    /// here (see `lan_mdns_health`). Filled by `monitor_loop`.
375    announced_v4: Arc<Mutex<BTreeSet<Ipv4Addr>>>,
376    /// Whether the monitor channel was obtained. Without it `announced_v4` never
377    /// fills, so `health()` has no basis for a verdict and stays `Pending`.
378    monitored: bool,
379    /// When this daemon was built, for the start-up grace in `health()`.
380    started_at: std::time::Instant,
381}
382
383/// Normalize an OS hostname into a valid mDNS host name by appending the
384/// `.local.` suffix that mdns-sd's `ServiceInfo::new` requires. Idempotent
385/// — already-normalized inputs pass through unchanged. We also strip any
386/// trailing dot first so `"foo.local"` doesn't end up as `"foo.local..local."`.
387fn mdns_hostname(os_hostname: &str) -> String {
388    let trimmed = os_hostname.trim_end_matches('.');
389    if trimmed.ends_with(".local") {
390        format!("{trimmed}.")
391    } else {
392        format!("{trimmed}.local.")
393    }
394}
395
396/// Build this instance's DNS-SD *instance name* — the first label of the
397/// service fullname. Must be BOTH unique per running instance AND free of
398/// dots.
399///
400/// Both properties were violated before 2026-09-06, and together they broke
401/// LAN discovery outright between two hosts on the same build:
402///
403/// - **Not unique.** The name was `format!("agentmux-{instance_id}")`, and
404///   `instance_id` is the *version* (`config.rs`'s `args.instance`, which the
405///   launcher sets to e.g. `v0.55.37`). Every host on the same release
406///   therefore registered the identical name, and `handle_event`'s self-skip —
407///   which compares incoming fullnames against our own — discarded genuine
408///   peers as if they were us.
409/// - **Not a single label.** `v0.55.37` contains dots. In DNS-SD the instance
410///   name is ONE label and dots must be escaped; nothing escaped them, so
411///   mdns-sd split at the first dot, treated `agentmux-v0` as the label and
412///   `.55.37` as part of the service type. Observed verbatim in a peer's log
413///   as `agentmux-v0 (2).55.37._agentmux._tcp.local.` — note the conflict
414///   suffix landing mid-version-string.
415///
416/// The two compounded: the collision triggered mdns-sd's conflict-renaming,
417/// and because `service_fullname` was captured *before* `register()` (fixed
418/// alongside this), the renamed host kept a stale self-name that exactly
419/// matched the OTHER host's real name — so the renamed host silently dropped
420/// every announcement from its peer, permanently, with no self-heal. That is
421/// why discovery was observed to work in one direction only.
422///
423/// `hostname` + `port` is unique per instance (hostname per machine, port per
424/// srv on that machine) and carries no version, which is deliberate: a version
425/// does not belong in an identity key, and it was the version being in there
426/// that created the cross-host collision. Any character that isn't
427/// alphanumeric, `-` or `_` is replaced, so a FQDN hostname
428/// (`box.corp.example`) or an exotic one can't reintroduce the dot bug.
429fn mdns_instance_label(hostname: &str, port: u16) -> String {
430    let sanitized: String = hostname
431        .trim_end_matches('.')
432        .chars()
433        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
434        .collect();
435    // An empty/all-punctuation hostname would otherwise yield `agentmux--<port>`;
436    // the port alone still disambiguates instances on this host, and remote
437    // peers are disambiguated by address.
438    let host_part = if sanitized.is_empty() { "unknown".to_string() } else { sanitized };
439    // A DNS label is capped at 63 bytes. "agentmux-" (9) + "-" + a 5-digit port
440    // (6) leaves 48; truncate to 45 for headroom. Windows caps hostnames at 15
441    // but Linux allows 64, so this is reachable in practice, and an
442    // over-length label would be rejected or silently mangled rather than
443    // failing loudly [Manpo review, PR #3048].
444    let host_part: String = host_part.chars().take(45).collect();
445    // Residual collision: two hosts sharing BOTH hostname and port — e.g. two
446    // fresh VM clones that are each "ubuntu" — still produce the same label and
447    // will be conflict-renamed. That no longer blinds either host (the
448    // self-check requires port+address agreement, see `handle_event`), but the
449    // two do collapse into one peer-map entry with a flapping address. A
450    // persisted per-install random suffix would close it for good; not done
451    // here because it needs somewhere durable to live.
452    format!("agentmux-{host_part}-{port}")
453}
454
455impl LanDiscovery {
456    /// Start LAN discovery: register this instance and browse for peers.
457    pub fn start(
458        instance_id: String,
459        hostname: String,
460        version: String,
461        port: u16,
462        auth_key: String,
463        event_bus: Arc<EventBus>,
464    ) -> Result<Arc<Self>, String> {
465        let daemon = ServiceDaemon::new().map_err(|e| format!("mDNS daemon failed: {e}"))?;
466        // Taken before `register` so the registration's own announcement is not
467        // missed. Not fatal if unavailable: health then stays `Pending` and the
468        // watchdog never acts, which is the pre-existing behaviour.
469        let monitor = match daemon.monitor() {
470            Ok(rx) => Some(rx),
471            Err(e) => {
472                tracing::warn!("mDNS monitor unavailable, discoverability cannot be checked: {e}");
473                None
474            }
475        };
476        let announced_v4: Arc<Mutex<BTreeSet<Ipv4Addr>>> = Arc::new(Mutex::new(BTreeSet::new()));
477
478        // Register this instance. mdns-sd requires the host name passed to
479        // `ServiceInfo::new` to end with `.local.` — we always normalize so
480        // a raw OS hostname like "narko" becomes "narko.local.".
481        // Unique per INSTANCE and dot-free — see `mdns_instance_label`. The
482        // version still travels in the TXT record (`instance_id` below), which
483        // is where peers actually read it from; it just must not be the
484        // identity key.
485        let service_name = mdns_instance_label(&hostname, port);
486        let host_name_mdns = mdns_hostname(&hostname);
487        let channel = crate::backend::reactive::registry::local_channel_id();
488        let os = crate::backend::host_os::local_os();
489        // Not `channels_running`: it changes, and a TXT update is costly; a
490        // peer reads it from the fleet feed instead.
491        let install_id = crate::backend::fleet_source::install_id();
492        let mut properties = vec![
493            ("version", version.as_str()),
494            ("hostname", hostname.as_str()),
495            ("instance_id", instance_id.as_str()),
496            ("channel", channel.as_str()),
497            ("os", os.as_str()),
498            ("auth_key", auth_key.as_str()),
499        ];
500        if let Some(id) = install_id.as_deref() {
501            properties.push(("install_id", id));
502        }
503        // `""` alone does NOT mean "auto-detect" despite how that reads —
504        // `AsIpAddrs for &str`'s own doc comment: "If the string is empty,
505        // will return an empty set." Auto-detection is a SEPARATE opt-in:
506        // `.enable_addr_auto()` tells the daemon to fill in (and keep
507        // updated) this service's addresses from the host's own interfaces.
508        // Without it, a service registered via `ServiceInfo::new(..., "",
509        // ...)` has zero addresses and `addr_auto: false` (the struct's
510        // hardcoded default) — `register()` still returns `Ok(())` (it just
511        // enqueues a `Command::Register`, no synchronous validation), so
512        // this silently produced a service that was never actually
513        // discoverable by ANY peer, not even another mDNS client on the same
514        // host. Caught by `a_registered_instance_is_discoverable_by_an_
515        // independent_mdns_client` below.
516        let service_info = ServiceInfo::new(
517            SERVICE_TYPE,
518            &service_name,
519            &host_name_mdns,
520            "",
521            port,
522            &properties[..],
523        )
524        .map_err(|e| format!("ServiceInfo creation failed: {e}"))?
525        .enable_addr_auto();
526
527        let service_fullname = service_info.get_fullname().to_string();
528
529        daemon
530            .register(service_info)
531            .map_err(|e| format!("mDNS register failed: {e}"))?;
532
533        // Log the name we registered under. Before this, an affected host was
534        // mute: the ONLY site that logged a fullname was the `ServiceRemoved`
535        // branch, so a host that discovered nothing (the exact failure this
536        // commit fixes) could not self-report its own service name at all, and
537        // the bug had to be diagnosed from the *peer's* log. `service_fullname`
538        // is the pre-register name — if mdns-sd ever conflict-renames us it
539        // will differ from what is actually on the wire, which is itself worth
540        // seeing, and is why the self-check below no longer trusts it alone.
541        tracing::info!(
542            service_fullname = %service_fullname,
543            instance_id = %instance_id,
544            port,
545            "LAN discovery registered mDNS service"
546        );
547
548        // Browse for peers — keep the receiver for the event loop
549        let browse_receiver = daemon
550            .browse(SERVICE_TYPE)
551            .map_err(|e| format!("mDNS browse failed: {e}"))?;
552
553        let instances = Arc::new(RwLock::new(HashMap::new()));
554
555        let discovery = Arc::new(Self {
556            daemon,
557            instances: instances.clone(),
558            instance_id: instance_id.clone(),
559            event_bus: event_bus.clone(),
560            service_fullname,
561            auth_key,
562            hostname,
563            version,
564            port,
565            udp_cancel: Mutex::new(None),
566            udp_peer_cancel: Mutex::new(None),
567            agent_names_cancel: Mutex::new(None),
568            instance_record_cancel: Mutex::new(None),
569            instances_dir: lan_instances::instances_dir(),
570            instance_record_live: Mutex::new(true),
571            announced_v4: announced_v4.clone(),
572            monitored: monitor.is_some(),
573            started_at: std::time::Instant::now(),
574        });
575
576        if let Some(rx) = monitor {
577            tokio::task::spawn_blocking(move || monitor_loop(rx, announced_v4));
578        }
579
580        // Spawn event receiver on a blocking thread to avoid starving the tokio runtime
581        let disc = discovery.clone();
582        tokio::task::spawn_blocking(move || {
583            disc.event_loop(browse_receiver);
584        });
585
586        // Spawn the UDP broadcast-probe responder (Layer 2 fallback for
587        // filtered mDNS). `UdpSocket::recv_from` is natively async, so this
588        // uses `tokio::spawn` (not `spawn_blocking`, unlike the mdns-sd event
589        // loop above whose receiver is a sync channel).
590        let (cancel_tx, cancel_rx) = oneshot::channel();
591        *discovery.udp_cancel.lock() = Some(cancel_tx);
592        let disc_udp = discovery.clone();
593        tokio::spawn(async move {
594            disc_udp.udp_responder_loop(cancel_rx).await;
595        });
596
597        // Desktop-to-desktop discovery that does not depend on mDNS: probes the
598        // LAN by broadcast and records who answers (`udp_peers`). Started
599        // whenever LAN discovery is, so a host whose mDNS is blocked or
600        // contested still finds, and is found by, its peers.
601        let (peer_cancel_tx, peer_cancel_rx) = oneshot::channel();
602        *discovery.udp_peer_cancel.lock() = Some(peer_cancel_tx);
603        tokio::spawn(discovery.clone().udp_peer_loop(peer_cancel_rx));
604
605        // Keeps each peer's `agents` list populated (see
606        // `agent_names_refresh_loop`). Cancelled explicitly by `shutdown()`
607        // via its own oneshot — NOT by Arc drop, since this task holds its
608        // own clone and would otherwise outlive `apply(false)` forever.
609        let (names_cancel_tx, names_cancel_rx) = oneshot::channel();
610        *discovery.agent_names_cancel.lock() = Some(names_cancel_tx);
611        tokio::spawn(
612            discovery
613                .clone()
614                .agent_names_refresh_loop(names_cancel_rx),
615        );
616
617        // Lists this instance as a sibling for whichever channel on this host
618        // holds the UDP probe port (`lan_instances`). Written now, then kept
619        // fresh; `shutdown()` withdraws it.
620        discovery.publish_instance_record();
621        let (record_cancel_tx, record_cancel_rx) = oneshot::channel();
622        *discovery.instance_record_cancel.lock() = Some(record_cancel_tx);
623        tokio::spawn(discovery.clone().instance_record_loop(record_cancel_rx));
624
625        tracing::info!(
626            instance_id = %instance_id,
627            port = port,
628            "LAN discovery started (mDNS)"
629        );
630
631        Ok(discovery)
632    }
633
634    /// Periodically ask every known peer which agents it hosts, so
635    /// `LanInstance::agents` reflects reality instead of staying the empty
636    /// vec it's created with. Without this, a peer shows up with a correct
637    /// hostname/version/address but `agents: []`, so there is no way to
638    /// discover *which* agents live on another machine — you can only
639    /// address one blind by name and hope. See
640    /// `handle_reactive_agent_names`' doc comment for the security scoping
641    /// of the endpoint this calls (names only, `lan_key`-readable).
642    ///
643    /// Failures are deliberately non-destructive: a peer that's briefly
644    /// unreachable keeps its last-known list rather than being blanked, since
645    /// staleness is already tracked by `last_seen` and a flapping list is
646    /// worse than a slightly-old one.
647    async fn agent_names_refresh_loop(self: Arc<Self>, mut cancel: oneshot::Receiver<()>) {
648        let http = match reqwest::Client::builder()
649            .timeout(std::time::Duration::from_secs(LAN_PEER_QUERY_TIMEOUT_SECS))
650            .build()
651        {
652            Ok(c) => c,
653            Err(e) => {
654                tracing::warn!(error = %e, "LAN agent-name refresh disabled: HTTP client build failed");
655                return;
656            }
657        };
658
659        loop {
660            // Cancellation is checked at the sleep, not just between
661            // iterations: this task holds its own `Arc<LanDiscovery>`, so
662            // without an explicit signal `apply(false)` cannot stop it and it
663            // would keep hitting peers indefinitely (one leaked task per
664            // toggle cycle).
665            tokio::select! {
666                _ = tokio::time::sleep(std::time::Duration::from_secs(
667                    LAN_AGENT_NAMES_REFRESH_SECS,
668                )) => {}
669                _ = &mut cancel => {
670                    tracing::debug!("LAN agent-name refresh loop cancelled");
671                    return;
672                }
673            }
674
675            // Garbage-collect entries `get_instances()` would already hide as
676            // stale. This is the only place `self.instances` actually shrinks
677            // now that `ServiceRemoved` no longer deletes (see
678            // `peer_staleness_window_secs`) — without it a genuinely departed
679            // peer would sit in the map forever and this very loop would keep
680            // polling it every cycle.
681            let any_pruned = {
682                let now = agentmux_common::time::now_secs_u64();
683                let mut instances = self.instances.write();
684                let before = instances.len();
685                instances.retain(|_, inst| {
686                    now.saturating_sub(inst.last_seen) <= peer_staleness_window_secs(inst.other_ttl_secs)
687                });
688                instances.len() != before
689            };
690            // A connected frontend only updates `lanInstancesAtom` on a
691            // `laninstances` WS event — without this, a peer pruned here
692            // would keep showing in an already-open client until some
693            // unrelated ServiceResolved happened to fire a broadcast
694            // [Codex P1 on PR #3245].
695            if any_pruned {
696                self.broadcast_instances();
697            }
698
699            // Snapshot (key, url, lan_key) so the lock is not held across any
700            // await — the map is read by every inject/discovery call.
701            let targets: Vec<(String, String, String)> = {
702                let instances = self.instances.read();
703                instances
704                    .iter()
705                    .map(|(key, inst)| {
706                        (
707                            key.clone(),
708                            format!("http://{}:{}", inst.address, inst.port),
709                            inst.auth_key.clone(),
710                        )
711                    })
712                    .collect()
713            };
714
715            // Fan out concurrently, same pattern as `query_peers_concurrently`
716            // — sequential awaits here would let N stale/unreachable peers
717            // each burn their full LAN_PEER_QUERY_TIMEOUT_SECS in turn
718            // (Codex P2 on PR #3102), stretching the nominal 30s refresh
719            // interval by however many peers are currently slow to answer.
720            let outcomes: Vec<(String, Option<PeerAgents>)> = {
721                use futures_util::StreamExt as _;
722                let http_ref = &http;
723                let mut inflight = futures_util::stream::FuturesUnordered::new();
724                for (key, base_url, lan_key) in &targets {
725                    inflight.push(async move {
726                        (
727                            key.clone(),
728                            Self::fetch_peer_agent_names(http_ref, base_url, lan_key).await,
729                        )
730                    });
731                }
732                let mut out = Vec::with_capacity(targets.len());
733                while let Some(item) = inflight.next().await {
734                    out.push(item);
735                }
736                out
737            };
738
739            // Only broadcast `laninstances` when something actually changed
740            // (Codex P2 on PR #3102) — otherwise the display-oriented point
741            // of this loop is defeated: without this, a refresh that DOES
742            // find new names still leaves connected clients showing the
743            // stale `agents: []` from first mDNS resolution until some
744            // unrelated, unsynchronized mDNS event happens to fire.
745            let mut changed = false;
746            {
747                let mut instances = self.instances.write();
748                for (key, peer) in outcomes {
749                    if let Some(entry) = instances.get_mut(&key) {
750                        let now = agentmux_common::time::now_secs_u64();
751                        changed |= agent_names::apply_agent_names_poll(entry, peer, now);
752                    }
753                }
754            }
755            if changed {
756                self.broadcast_instances();
757            }
758        }
759    }
760
761    /// One peer's contribution to `agent_names_refresh_loop`. Returns `None`
762    /// on any failure (unreachable, non-2xx, oversized, or malformed body) —
763    /// this list is display-only, so a bad peer response should leave the
764    /// last-known list in place rather than blank it (see the loop's own
765    /// "non-destructive" framing).
766    async fn fetch_peer_agent_names(
767        http: &reqwest::Client,
768        base_url: &str,
769        lan_key: &str,
770    ) -> Option<PeerAgents> {
771        let url = format!("{base_url}/agentmux/reactive/agent-names");
772        let resp = match http.get(&url).header(agentmux_common::AUTH_KEY_HEADER, lan_key).send().await {
773            Ok(r) => r,
774            Err(e) => {
775                tracing::debug!(peer = %base_url, error = %e, "LAN agent-name refresh: unreachable");
776                return None;
777            }
778        };
779        if !resp.status().is_success() {
780            // A peer running a build without this route answers 404 —
781            // expected during a rolling upgrade, not an error.
782            tracing::debug!(peer = %base_url, status = %resp.status(), "LAN agent-name refresh: non-2xx");
783            return None;
784        }
785
786        let body = match Self::read_body_capped(resp, LAN_AGENT_NAMES_MAX_RESPONSE_BYTES).await {
787            Ok(b) => b,
788            Err(e) => {
789                tracing::warn!(peer = %base_url, error = %e, "LAN agent-name refresh: response too large, discarded");
790                return None;
791            }
792        };
793
794        let value: serde_json::Value = match serde_json::from_slice(&body) {
795            Ok(v) => v,
796            Err(e) => {
797                tracing::debug!(peer = %base_url, error = %e, "LAN agent-name refresh: bad JSON");
798                return None;
799            }
800        };
801
802        Some(parse_agent_names_body(&value))
803    }
804
805    /// Reads an HTTP response body up to `max_bytes`, erroring out as soon as
806    /// the cap is exceeded rather than buffering the full body first —
807    /// `Response::bytes()`/`.json()` have no size limit of their own, so a
808    /// peer that responds successfully but with an enormous body would
809    /// otherwise be read into memory in full before this code ever gets a
810    /// chance to reject it.
811    pub(crate) async fn read_body_capped(
812        mut resp: reqwest::Response,
813        max_bytes: usize,
814    ) -> Result<Vec<u8>, String> {
815        let mut buf = Vec::new();
816        while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
817            buf.extend_from_slice(&chunk);
818            if buf.len() > max_bytes {
819                return Err(format!("response exceeded {max_bytes}-byte cap"));
820            }
821        }
822        Ok(buf)
823    }
824
825    /// Build the JSON identity payload for this instance — see
826    /// `probe_response_json` for the pure field-assembly logic shared with
827    /// tests. Desktop peers get this (`udp_peers`); phones get
828    /// [`Self::build_probe_response`].
829    fn build_identity_response(&self) -> serde_json::Value {
830        self.identity_response_with(
831            crate::backend::fleet_source::install_id().as_deref(),
832            crate::backend::fleet_source::latest_channels_running(),
833            crate::backend::viewer::advertised_viewer_port(),
834        )
835    }
836
837    /// [`Self::build_identity_response`] with the install id, channel count
838    /// and viewer port given, so a test needs no WAN identity or fleet feed.
839    fn identity_response_with(
840        &self,
841        install_id: Option<&str>,
842        channels_running: Option<u32>,
843        viewer_port: Option<u16>,
844    ) -> serde_json::Value {
845        let mut response = probe_response_json(
846            &self.instance_id,
847            &self.hostname,
848            &self.version,
849            self.port,
850            &self.auth_key,
851        );
852        // The channel, as the TXT record carries it, so a desktop that finds
853        // this instance over UDP can tell two channels on one host apart. An
854        // extra field: older parsers (mobile included) ignore it.
855        response["channel"] = json!(crate::backend::reactive::registry::local_channel_id());
856        response["os"] = json!(crate::backend::host_os::local_os());
857        // What a phone merges this install with its cloud record by, and how
858        // many channels this machine runs, shared on the LAN or not
859        // (agentmux-mobile's SPEC_FLEET_HOST_TAGS_AND_CLOUD_HOSTS_2026_10_06
860        // §5.3). Both only when known.
861        if let Some(id) = install_id {
862            response["install_id"] = json!(id);
863        }
864        if let Some(n) = channels_running {
865            response["channels_running"] = json!(n);
866        }
867        // The viewer listener's port while it is up, so a paired device finds
868        // it again after an address change (agentmux-mobile's
869        // SPEC_AGENT_STATUS_AND_LIVE_PANE_FEED_2026_10_07 §13.2).
870        if let Some(port) = viewer_port {
871            response["viewer_port"] = json!(port);
872        }
873        response
874    }
875
876    /// The 47891 reply: the identity payload plus `siblings`, the other
877    /// LAN-enabled channels on this host (`lan_instances`), kept within one
878    /// datagram (`lan_instances::MAX_REPLY_BYTES`). Each sibling carries its
879    /// own `lan_key`, which it already broadcasts over mDNS.
880    fn build_probe_response(&self) -> serde_json::Value {
881        self.probe_response_with(
882            crate::backend::fleet_source::install_id().as_deref(),
883            crate::backend::fleet_source::latest_channels_running(),
884            crate::backend::viewer::advertised_viewer_port(),
885        )
886    }
887
888    fn probe_response_with(
889        &self,
890        install_id: Option<&str>,
891        channels_running: Option<u32>,
892        viewer_port: Option<u16>,
893    ) -> serde_json::Value {
894        let mut response = self.identity_response_with(install_id, channels_running, viewer_port);
895        lan_instances::attach_siblings(&mut response, &self.sibling_entries(), lan_instances::MAX_REPLY_BYTES);
896        response
897    }
898
899    /// UDP broadcast-probe responder loop (Layer 2 discovery fallback).
900    ///
901    /// Binds `0.0.0.0:UDP_DISCOVERY_PORT` and answers valid probes from
902    /// private/link-local source addresses (see `is_lan_source`) with a
903    /// unicast response back to the sender's `recv_from` source address.
904    /// Deliberately does NOT set `SO_REUSEADDR`: this codebase supports
905    /// running multiple AgentMux instances on one host simultaneously, and
906    /// on Windows `SO_REUSEADDR` lets a second process silently steal a UDP
907    /// port already owned by another — an inappropriate risk for a socket
908    /// that hands out `auth_key`. If the bind fails (most likely because
909    /// another local instance already holds the port), this task retries
910    /// every `UDP_RESPONDER_RETRY` until it gets the port or is cancelled, so
911    /// the next LAN-enabled channel takes over when the holder stops. mDNS
912    /// discovery (already running via `event_loop`) is unaffected either way,
913    /// matching the "never fail `start()` over this" contract.
914    ///
915    /// We do not call `set_broadcast(true)`: that flag is only required to
916    /// *send* to a broadcast address, and this socket only receives (probes
917    /// arrive as broadcast/subnet-broadcast datagrams addressed to us, which
918    /// requires no special socket option on the receiving end) and replies
919    /// with a plain unicast send back to the probe's source address.
920    async fn udp_responder_loop(self: Arc<Self>, cancel_rx: oneshot::Receiver<()>) {
921        self.udp_responder_loop_on(UDP_DISCOVERY_PORT, UDP_RESPONDER_RETRY, cancel_rx)
922            .await;
923    }
924
925    /// [`Self::udp_responder_loop`] on a given port and retry interval, so a
926    /// test can drive the takeover without the production port.
927    async fn udp_responder_loop_on(
928        self: Arc<Self>,
929        port: u16,
930        retry_every: std::time::Duration,
931        mut cancel_rx: oneshot::Receiver<()>,
932    ) {
933        let mut retrying = false;
934        let socket = loop {
935            match UdpSocket::bind(("0.0.0.0", port)).await {
936                Ok(s) => break s,
937                Err(e) => {
938                    // Logged once, not per attempt: a second channel on this
939                    // host retries for as long as the holder runs.
940                    if !retrying {
941                        tracing::debug!(
942                            port,
943                            "UDP discovery responder waiting for the port (bind failed, likely \
944                             another local instance holds it); retrying every {}s: {e}",
945                            retry_every.as_secs()
946                        );
947                        retrying = true;
948                    }
949                    tokio::select! {
950                        _ = tokio::time::sleep(retry_every) => {}
951                        _ = &mut cancel_rx => {
952                            tracing::debug!("UDP discovery responder stopping before it got the port");
953                            return;
954                        }
955                    }
956                }
957            }
958        };
959
960        if retrying {
961            tracing::info!(port, "UDP discovery responder took over the probe port");
962        } else {
963            tracing::debug!(port, "UDP discovery responder listening");
964        }
965
966        let mut buf = [0u8; 1024];
967        loop {
968            tokio::select! {
969                _ = &mut cancel_rx => {
970                    tracing::debug!("UDP discovery responder stopping");
971                    break;
972                }
973                result = socket.recv_from(&mut buf) => {
974                    match result {
975                        Ok((len, src)) => {
976                            // Noise (unrelated LAN UDP traffic, malformed
977                            // packets) is expected on a live network — do
978                            // not log per-packet above debug.
979                            if !is_valid_probe(&buf[..len]) {
980                                continue;
981                            }
982                            // Trust boundary: never hand out auth_key to a
983                            // source address outside the private/link-local
984                            // ranges, even if the packet is a well-formed
985                            // probe (see is_lan_source doc comment).
986                            if !is_lan_source(&src) {
987                                tracing::debug!(
988                                    src = %src,
989                                    "UDP discovery probe from non-LAN source, ignoring"
990                                );
991                                continue;
992                            }
993                            tracing::debug!(src = %src, "UDP discovery probe received");
994                            let response = self.build_probe_response();
995                            match serde_json::to_vec(&response) {
996                                Ok(payload) => {
997                                    if let Err(e) = socket.send_to(&payload, src).await {
998                                        tracing::debug!("UDP discovery response send failed: {e}");
999                                    } else {
1000                                        tracing::debug!(src = %src, "UDP discovery probe answered");
1001                                    }
1002                                }
1003                                Err(e) => {
1004                                    tracing::debug!("UDP discovery response serialize failed: {e}");
1005                                }
1006                            }
1007                        }
1008                        Err(e) => {
1009                            // A persistent OS-level error (e.g. Windows
1010                            // WSAECONNRESET/10054 from an ICMP
1011                            // port-unreachable triggered by our own prior
1012                            // send_to) would otherwise spin this loop at
1013                            // full CPU with no delay between retries.
1014                            tracing::debug!("UDP discovery recv_from error: {e}");
1015                            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1016                        }
1017                    }
1018                }
1019            }
1020        }
1021    }
1022
1023    fn event_loop(&self, receiver: mdns_sd::Receiver<ServiceEvent>) {
1024        loop {
1025            match receiver.recv() {
1026                Ok(event) => self.handle_event(event),
1027                Err(_) => {
1028                    tracing::warn!("mDNS event receiver closed");
1029                    break;
1030                }
1031            }
1032        }
1033    }
1034
1035    /// True when a resolved service is really this instance, reached over one
1036    /// of this host's own addresses.
1037    ///
1038    /// Both conditions are required. The port alone is far too weak (two hosts
1039    /// can land on the same ephemeral port), and an address alone is too weak
1040    /// while several AgentMux instances share a machine — but no remote peer
1041    /// can be listening on OUR port at an address that is OURS.
1042    fn resolves_to_this_instance(&self, info: &ServiceInfo) -> bool {
1043        // Port first, and BEFORE touching the address set. A resolution on a
1044        // different port cannot be us, and that is the overwhelmingly common
1045        // case (every genuine remote peer), so it must not pay for an address
1046        // lookup. Passing the set as an argument would defeat this — arguments
1047        // are evaluated eagerly, so the cheap check inside `is_self_resolution`
1048        // would come too late [reagent #3025 P2].
1049        if info.get_port() != self.port {
1050            return false;
1051        }
1052        let addrs: Vec<IpAddr> = info.get_addresses().iter().copied().collect();
1053        is_self_resolution(
1054            info.get_port(),
1055            &addrs,
1056            self.port,
1057            &crate::backend::lan_listeners::cached_local_addresses(),
1058        )
1059    }
1060
1061    fn handle_event(&self, event: ServiceEvent) {
1062        match event {
1063            ServiceEvent::ServiceResolved(info) => {
1064                let fullname = info.get_fullname().to_string();
1065
1066                // Skip self. Compared by `fullname` (deterministic from
1067                // `service_name`, set once at registration — see `start()`)
1068                // rather than the TXT-record `instance_id` property: on this
1069                // machine's own virtual/link-local interfaces (WSL/Hyper-V/
1070                // VPN adapters), `enable_addr_auto()` re-fires
1071                // `ServiceResolved` far more often than fresh TXT data
1072                // arrives — most of those events resolve with an empty TXT
1073                // record (confirmed via `LAN peer discovered` log: ~96% of
1074                // events for this instance's own addresses logged
1075                // `peer_id=""`). Comparing on `instance_id` alone meant a
1076                // blank-TXT self-resolution was never recognized as self and
1077                // was inserted as a phantom peer that could never self-heal
1078                // (see the instance_id-preservation fix below for why).
1079                // THE self-check. One test, and it is the only one: a service
1080                // on OUR port at an address belonging to THIS machine is us. A
1081                // genuine remote peer cannot satisfy both — it would have to be
1082                // reachable at one of our own interface addresses.
1083                //
1084                // There used to be a second, independent check above this one
1085                // comparing the resolved `fullname` against our own. It is gone
1086                // as of 2026-09-06, because it was actively harmful and,
1087                // once this check existed, entirely redundant (any event a
1088                // fullname match could legitimately skip is one this check
1089                // already skips — reagent P2 on PR #3048 caught that keeping it
1090                // as a conjunct left dead code that also bypassed the debug log
1091                // below).
1092                //
1093                // Why it was harmful, and the answer to a question the previous
1094                // revision of this comment recorded as unexplained ("Why the
1095                // fullname check misses them is not established — plausibly
1096                // mdns-sd conflict-renaming"): it WAS conflict-renaming. The
1097                // mDNS instance label used to be `agentmux-{instance_id}` where
1098                // `instance_id` is the VERSION, so every host on a release
1099                // registered the same name AND that name contained dots, which
1100                // is malformed for a single DNS-SD label. mdns-sd split it at
1101                // the first dot and conflict-renamed the loser
1102                // ("agentmux-v0 (2).55.37._agentmux._tcp.local."). Two
1103                // consequences: a genuine peer's fullname could equal ours
1104                // exactly, and on a renamed host `service_fullname` — captured
1105                // before `register()` — went stale and matched the peer that
1106                // WON the name. Either way that host silently dropped every
1107                // announcement from its peer, forever, reporting `lan: []`
1108                // while the peer saw it fine. `mdns_instance_label` now makes
1109                // the collision impossible; deleting the check makes the
1110                // consequence impossible even if a future naming scheme
1111                // regresses.
1112                //
1113                // The blank-TXT self-resolution storm the fullname check was
1114                // originally added for is still caught here: live logs showed
1115                // ~96% of resolution events for this instance's own
1116                // virtual/link-local addresses carrying an empty TXT record
1117                // (`peer_id=""`), all on this instance's own port at its own
1118                // addresses — so this check catches them on its own, which is
1119                // what made the fullname comparison redundant rather than
1120                // load-bearing.
1121                //
1122                // Cost of getting it wrong, and why it is worth being careful:
1123                // `find_agent` fans out concurrently to every known peer, so a
1124                // phantom self-entry spent a request per lookup asking
1125                // ourselves a question we had already answered locally.
1126                if self.resolves_to_this_instance(&info) {
1127                    tracing::debug!(
1128                        address = %info.get_addresses().iter().next().map(|a| a.to_string()).unwrap_or_default(),
1129                        port = info.get_port(),
1130                        "skipping mDNS resolution of this instance's own service"
1131                    );
1132                    return;
1133                }
1134
1135                let peer_id = info
1136                    .get_property_val_str("instance_id")
1137                    .unwrap_or_default()
1138                    .to_string();
1139
1140                let now = agentmux_common::time::now_secs_u64();
1141
1142                let address = info
1143                    .get_addresses()
1144                    .iter()
1145                    .find(|a| matches!(a, IpAddr::V4(_)))
1146                    .or_else(|| info.get_addresses().iter().next())
1147                    .map(|a| a.to_string())
1148                    .unwrap_or_default();
1149
1150                let hostname = info
1151                    .get_property_val_str("hostname")
1152                    .unwrap_or_default()
1153                    .to_string();
1154                let version = info
1155                    .get_property_val_str("version")
1156                    .unwrap_or_default()
1157                    .to_string();
1158                let auth_key = info
1159                    .get_property_val_str("auth_key")
1160                    .unwrap_or_default()
1161                    .to_string();
1162                let channel = info
1163                    .get_property_val_str("channel")
1164                    .unwrap_or_default()
1165                    .to_string();
1166                // Self-reported by the peer, so only a plain token is kept.
1167                let os = info
1168                    .get_property_val_str("os")
1169                    .and_then(crate::backend::host_os::sanitize_os)
1170                    .unwrap_or_default();
1171                let other_ttl_secs = info.get_other_ttl();
1172
1173                let mut instances = self.instances.write();
1174                // Cloned for the log line below: `entry()` takes ownership of
1175                // the key, and the peer's service name is the field that makes
1176                // that line correlatable with the sender's own registration.
1177                let fullname_for_log = fullname.clone();
1178                let entry = instances.entry(fullname).or_insert_with(|| LanInstance {
1179                    instance_id: peer_id.clone(),
1180                    hostname: hostname.clone(),
1181                    version: version.clone(),
1182                    channel: channel.clone(),
1183                    os: os.clone(),
1184                    address: address.clone(),
1185                    port: info.get_port(),
1186                    auth_key: auth_key.clone(),
1187                    agents: Vec::new(),
1188                    agent_status: Default::default(),
1189                    agent_lines: Default::default(),
1190                    first_seen: now,
1191                    last_seen: now,
1192                    last_polled_ok: 0,
1193                    other_ttl_secs,
1194                });
1195                entry.last_seen = now;
1196                entry.address = address;
1197                entry.port = info.get_port();
1198                // A record TTL, not TXT content — always present on a real
1199                // resolution (unlike hostname/version/auth_key below), so it's
1200                // safe to overwrite unconditionally the same as address/port.
1201                entry.other_ttl_secs = other_ttl_secs;
1202                // TXT-derived fields only: `enable_addr_auto()` re-fires
1203                // `ServiceResolved` on every interface/address change, and
1204                // most of those re-fires resolve with an empty TXT record
1205                // (see the self-skip comment above) — apply a value only
1206                // when this event actually carried one, so a stale re-fire
1207                // never erases previously-known-good peer identity. Address/
1208                // port come from the SRV/A record, not TXT, and are always
1209                // populated, so they're safe to overwrite unconditionally.
1210                if !peer_id.is_empty() {
1211                    entry.instance_id = peer_id.clone();
1212                }
1213                if !hostname.is_empty() {
1214                    entry.hostname = hostname;
1215                }
1216                if !version.is_empty() {
1217                    entry.version = version;
1218                }
1219                if !auth_key.is_empty() {
1220                    entry.auth_key = auth_key;
1221                }
1222                if !channel.is_empty() {
1223                    entry.channel = channel;
1224                }
1225                if !os.is_empty() {
1226                    entry.os = os;
1227                }
1228                // The UDP route may have found this peer first; one entry per
1229                // peer, the mDNS one (udp_peers::merge_udp_peer is the converse).
1230                udp_peers::drop_udp_duplicates(&mut instances);
1231                drop(instances);
1232
1233                tracing::info!(
1234                    peer_id = %peer_id,
1235                    // The peer's own service name. Without it this line could
1236                    // not be correlated with the sender's registration, which
1237                    // is what made the 2026-09-06 collision take two hosts and
1238                    // an ssh session to pin down [Manpo review, PR #3048].
1239                    fullname = %fullname_for_log,
1240                    address = %info.get_addresses().iter().next().map(|a| a.to_string()).unwrap_or_default(),
1241                    port = info.get_port(),
1242                    "LAN peer discovered"
1243                );
1244
1245                self.broadcast_instances();
1246            }
1247            ServiceEvent::ServiceRemoved(_, fullname) => {
1248                // Deliberately NOT deleted from `self.instances` here (see
1249                // `peer_staleness_window_secs`): a real departure and ordinary
1250                // TTL/interface churn look identical on the wire, and removing
1251                // throws away last-known-good TXT fields that a post-removal
1252                // re-resolution (almost always blank-TXT) can never restore.
1253                // `get_instances()` prunes on `last_seen` age instead, which
1254                // only advances on an actual `ServiceResolved`.
1255                tracing::debug!(
1256                    fullname = %fullname,
1257                    "mDNS ServiceRemoved received (not acted on; peer expires via last_seen staleness instead)"
1258                );
1259            }
1260            _ => {}
1261        }
1262    }
1263
1264    fn broadcast_instances(&self) {
1265        // Via `get_instances()`, not a raw map read, so a stale entry never
1266        // reaches the frontend over the live `laninstances` event when the
1267        // REST/RPC path (which calls the same method) would already hide it.
1268        let instances: Vec<LanInstance> = self.get_instances();
1269        self.event_bus.broadcast_event(&EventBus::typed_ws_event::<crate::backend::mps_events::LanInstances>(&LanInstanceList(instances), String::new()));
1270    }
1271
1272    /// Is this instance really announced on an IPv4 interface other machines
1273    /// can reach? `Pending` until the start-up grace has passed.
1274    pub fn health(&self) -> lan_mdns_health::MdnsHealth {
1275        // Cheap exits first: no monitor, or still inside the grace.
1276        if !self.monitored || self.started_at.elapsed() < lan_mdns_health::GRACE {
1277            return lan_mdns_health::MdnsHealth::Pending;
1278        }
1279        // Grouped by interface: `mdns-sd` announces on one address per
1280        // interface and IP version, so an interface with two IPv4 addresses is
1281        // covered by either one (ReAgent P2 on #4148).
1282        let local = if_addrs::get_if_addrs()
1283            .unwrap_or_default()
1284            .into_iter()
1285            .map(|i| (i.name.clone(), i.ip()));
1286        let expected = lan_mdns_health::expected_by_interface(local);
1287        let announced = self.announced_v4.lock().clone();
1288        lan_mdns_health::verdict(self.monitored, self.started_at.elapsed(), &expected, &announced)
1289    }
1290
1291    /// Get current list of discovered LAN peers (excludes self).
1292    pub fn get_instances(&self) -> Vec<LanInstance> {
1293        let now = agentmux_common::time::now_secs_u64();
1294        self.instances
1295            .read()
1296            .values()
1297            .filter(|inst| now.saturating_sub(inst.last_seen) <= peer_staleness_window_secs(inst.other_ttl_secs))
1298            .cloned()
1299            .collect()
1300    }
1301
1302    /// Get peer count (excludes self).
1303    #[allow(dead_code)]
1304    pub fn peer_count(&self) -> usize {
1305        self.instances.read().len()
1306    }
1307
1308    /// Stop the mDNS daemon and the UDP broadcast-probe responder.
1309    ///
1310    /// The mDNS side synchronously closes the daemon socket (UDP:5353),
1311    /// causing the `browse_receiver` to return Err and the event-loop thread
1312    /// spawned by `start()` to exit. The UDP responder is stopped by firing
1313    /// its cancellation channel, which the `tokio::select!` in
1314    /// `udp_responder_loop` is racing against `recv_from` — this is what
1315    /// makes a live setting toggle actually stop responding to probes, not
1316    /// just stop the mDNS advertisement.
1317    ///
1318    /// Required for live-disable to actually stop discovery: both background
1319    /// tasks hold their own `Arc<Self>` clone, so simply dropping the
1320    /// controller's Arc never reaches refcount zero and `Drop` does not
1321    /// run. Callers must invoke `shutdown()` before clearing their Arc.
1322    /// Idempotent — safe to call from both the explicit path and `Drop`.
1323    pub fn shutdown(&self) {
1324        if let Some(tx) = self.udp_cancel.lock().take() {
1325            // Ignore send errors: an `Err` here just means the responder
1326            // task already exited on its own (e.g. the bind failed), which
1327            // is a no-op we're happy with.
1328            let _ = tx.send(());
1329        }
1330        if let Some(tx) = self.udp_peer_cancel.lock().take() {
1331            let _ = tx.send(());
1332        }
1333        // Same contract as `udp_cancel` above — and load-bearing rather than
1334        // tidy-up: the refresh task owns an `Arc<LanDiscovery>` clone, so
1335        // without this signal it survives `apply(false)` and keeps issuing
1336        // authenticated requests to LAN peers indefinitely.
1337        if let Some(tx) = self.agent_names_cancel.lock().take() {
1338            let _ = tx.send(());
1339        }
1340        if let Some(tx) = self.instance_record_cancel.lock().take() {
1341            let _ = tx.send(());
1342        }
1343        self.withdraw_instance_record();
1344        if let Err(e) = self.daemon.unregister(&self.service_fullname) {
1345            // Likely already unregistered; do not warn loudly.
1346            tracing::debug!("mDNS unregister returned: {e}");
1347        }
1348        if let Err(e) = self.daemon.shutdown() {
1349            tracing::debug!("mDNS daemon shutdown returned: {e}");
1350        }
1351    }
1352}
1353
1354impl Drop for LanDiscovery {
1355    fn drop(&mut self) {
1356        // Fallback path. Under normal live-toggle flow the controller calls
1357        // `shutdown()` explicitly; this only fires for process exit, when
1358        // the event-loop thread has already terminated and the final Arc
1359        // is being released.
1360        self.shutdown();
1361    }
1362}
1363
1364/// How often the watchdog looks at discoverability. Two consecutive
1365/// undiscoverable ticks are needed before a rebuild
1366/// (`lan_mdns_health::STRIKES_BEFORE_REBUILD`).
1367const HEALTH_TICK: std::time::Duration = std::time::Duration::from_secs(15);
1368
1369/// Reads the daemon's monitor channel and records the IPv4 addresses it
1370/// announced our service on. Ends when the daemon shuts down (the channel
1371/// disconnects). Runs on a blocking thread, like the browse loop.
1372fn monitor_loop(
1373    rx: mdns_sd::Receiver<mdns_sd::DaemonEvent>,
1374    announced: Arc<Mutex<BTreeSet<Ipv4Addr>>>,
1375) {
1376    while let Ok(event) = rx.recv() {
1377        record_daemon_event(event, &announced);
1378    }
1379}
1380
1381/// Folds one daemon event into the set of IPv4 addresses we announced on.
1382fn record_daemon_event(event: mdns_sd::DaemonEvent, announced: &Mutex<BTreeSet<Ipv4Addr>>) {
1383    use mdns_sd::DaemonEvent;
1384    match event {
1385        DaemonEvent::Announce(_, addrs) => {
1386            let found = lan_mdns_health::ipv4_in(&addrs);
1387            if !found.is_empty() {
1388                announced.lock().extend(found);
1389            }
1390        }
1391        DaemonEvent::IpDel(IpAddr::V4(gone)) => {
1392            announced.lock().remove(&gone);
1393        }
1394        DaemonEvent::Error(e) => {
1395            tracing::warn!("mDNS daemon error: {e}");
1396        }
1397        _ => {}
1398    }
1399}
1400
1401/// Controller for live start/stop of `LanDiscovery` in response to setting changes.
1402///
1403/// Owns the daemon slot plus the start arguments, so toggling
1404/// `network:lan_discovery` from the UI (or from an external edit of
1405/// `settings.json`) can start or stop the daemon without restarting the
1406/// process.
1407///
1408/// Spec: docs/specs/lan-discovery-toggle.md
1409pub struct LanDiscoveryController {
1410    slot: Arc<RwLock<Option<Arc<LanDiscovery>>>>,
1411    /// What the last `apply` asked for: LAN discovery should be running. Set
1412    /// under the slot's write lock, so it never disagrees with the slot for
1413    /// longer than one `apply`. It stays `true` when a start fails, which is how
1414    /// the watchdog tells "wanted but down" (retry) from "off" (leave alone).
1415    desired: std::sync::atomic::AtomicBool,
1416    instance_id: String,
1417    hostname: String,
1418    version: String,
1419    port: u16,
1420    auth_key: String,
1421    event_bus: Arc<EventBus>,
1422    /// Short-lived cache mapping agent_id → (peer_url, auth_key). Entries expire
1423    /// after LAN_AGENT_CACHE_TTL_SECS to handle agent migration between peers.
1424    agent_cache: std::sync::RwLock<HashMap<String, LanCacheEntry>>,
1425    /// Separate cache mapping agent_id → LAN Ed25519 public key bytes.
1426    /// SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md §2.2 — deliberately its own
1427    /// map rather than folded into `agent_cache`: the two are keyed by the
1428    /// same agent_id but answer different questions ("which peer currently
1429    /// hosts this agent" churns on process restarts/migration; "what is
1430    /// this agent's public key" is effectively static for the agent's
1431    /// lifetime), and a lookup for one is not always paired with a lookup
1432    /// for the other (a message TO agent X needs `agent_cache`'s entry for
1433    /// X; verifying a message FROM agent Y needs `pubkey_cache`'s entry for
1434    /// Y — different agent_ids on the same inbound request).
1435    pubkey_cache: std::sync::RwLock<HashMap<String, LanPubkeyCacheEntry>>,
1436    /// reagentx P1 on the LAN signing PR: `find_agent_lan_pubkey`'s negative
1437    /// cache is keyed by agent_id, so a caller holding only the `lan_key`
1438    /// can force a fresh multi-peer fan-out on every single request just by
1439    /// sending a novel random `source_agent` each time — the cache never
1440    /// hits, and this expensive walk runs (in `verify_lan_signature`)
1441    /// BEFORE `Handler::inject_message`'s own rate limiter is ever reached.
1442    /// A simple global token bucket on the fan-out ITSELF (not per-agent_id
1443    /// — the abuse pattern is specifically about varying the id to dodge a
1444    /// per-id cache) bounds the damage regardless of how many distinct
1445    /// identities are probed.
1446    pubkey_lookup_limiter: std::sync::Mutex<LookupRateLimiter>,
1447}
1448
1449struct LanPubkeyCacheEntry {
1450    /// `None` = negative cache entry: no peer has a LAN public key on file
1451    /// for this agent_id (never minted one, or genuinely unknown).
1452    public_key: Option<Vec<u8>>,
1453    /// Identity M4d-6: the UID and UID key from the same peer's answer, when
1454    /// it gave both.
1455    uid_key: Option<(String, Vec<u8>)>,
1456    expires: std::time::Instant,
1457}
1458
1459/// Outcome of `find_agent_lan_pubkey`. Three states, not two — reagentx P0
1460/// follow-up on the LAN signing PR: collapsing "the lookup was skipped
1461/// (rate-limited)" into the same `None`/"not found" outcome as "genuinely
1462/// no peer has this key" let an attacker exhaust
1463/// `LAN_PUBKEY_LOOKUP_RATE_LIMIT` with junk lookups, then slip a forged
1464/// signature for a REAL agent's identity through as unverified (benign)
1465/// instead of a verification failure (forced sensitive). By the time this
1466/// function is ever called, `verify_lan_signature` has already confirmed a
1467/// `lan_sig` was actually presented — so "we didn't check" must be
1468/// distinguishable from "there was nothing to check."
1469pub(crate) enum LanPubkeyLookup {
1470    /// A peer has a public key on file for this agent_id.
1471    Found(Vec<u8>),
1472    /// No peer (that answered) has ever minted a LAN key for this agent_id
1473    /// — genuinely unknown sender, not a red flag on its own (same
1474    /// "unverified, not escalated" treatment self-declared senders already
1475    /// get elsewhere in this system).
1476    NotFound,
1477    /// The lookup was skipped by the rate limiter — unknown, NOT the same
1478    /// as `NotFound`. Callers verifying an actual signature attempt must
1479    /// treat this conservatively (as a failure), never as "nothing to
1480    /// check."
1481    RateLimited,
1482}
1483
1484/// Minimal token bucket, refilled once per second — same shape as
1485/// `backend::reactive::handler::RateLimiter`, duplicated locally rather
1486/// than widening that one's visibility across modules for a single caller.
1487struct LookupRateLimiter {
1488    tokens: u32,
1489    max_tokens: u32,
1490    last_refill: std::time::Instant,
1491}
1492
1493impl LookupRateLimiter {
1494    fn new(max_tokens: u32) -> Self {
1495        Self {
1496            tokens: max_tokens,
1497            max_tokens,
1498            last_refill: std::time::Instant::now(),
1499        }
1500    }
1501
1502    fn check(&mut self) -> bool {
1503        let now = std::time::Instant::now();
1504        if now.duration_since(self.last_refill) >= std::time::Duration::from_secs(1) {
1505            self.tokens = self.max_tokens;
1506            self.last_refill = now;
1507        }
1508        if self.tokens > 0 {
1509            self.tokens -= 1;
1510            true
1511        } else {
1512            false
1513        }
1514    }
1515}
1516
1517/// Fan-outs per second `find_agent_lan_pubkey` will perform before failing
1518/// closed (returning `None` without querying any peer). Generous for
1519/// legitimate traffic — LAN jekts to previously-unseen senders are rare
1520/// once agents have been talking a while, and the 60s positive/negative
1521/// cache absorbs repeat lookups for the SAME agent_id — while still
1522/// bounding the worst case to a small, fixed number of outbound requests
1523/// per second regardless of how many distinct agent_ids are probed.
1524const LAN_PUBKEY_LOOKUP_RATE_LIMIT: u32 = 10;
1525
1526impl LanDiscoveryController {
1527    pub fn new(
1528        instance_id: String,
1529        hostname: String,
1530        version: String,
1531        port: u16,
1532        event_bus: Arc<EventBus>,
1533        auth_key: String,
1534    ) -> Self {
1535        Self {
1536            slot: Arc::new(RwLock::new(None)),
1537            desired: std::sync::atomic::AtomicBool::new(false),
1538            instance_id,
1539            hostname,
1540            version,
1541            port,
1542            auth_key,
1543            event_bus,
1544            agent_cache: std::sync::RwLock::new(HashMap::new()),
1545            pubkey_cache: std::sync::RwLock::new(HashMap::new()),
1546            pubkey_lookup_limiter: std::sync::Mutex::new(LookupRateLimiter::new(LAN_PUBKEY_LOOKUP_RATE_LIMIT)),
1547        }
1548    }
1549
1550    /// Query LAN peers for which one hosts `agent_id`. Returns `(peer_url,
1551    /// lan_key)` for the first peer that responds 2xx to the agent-lookup
1552    /// endpoint. Results — both positive and negative — are cached for
1553    /// `LAN_AGENT_CACHE_TTL_SECS` seconds to avoid a blocking peer fan-out on
1554    /// every inject for cloud-only agents.
1555    ///
1556    /// Security:
1557    /// the value broadcast in the mDNS TXT record / UDP probe response
1558    /// (`self.auth_key` field name kept for wire back-compat with older
1559    /// peer versions — see `Config::lan_key`'s doc comment) is now a
1560    /// separate, narrowly-scoped credential, NOT the instance's full-access
1561    /// `auth_key`. A passive LAN listener who captures it gets standing
1562    /// access to only the three LAN-forwarding routes
1563    /// (`lan_or_full_auth_middleware` in `server/mod.rs`) — not the full
1564    /// `/agentmux/service` surface this used to expose. Broadcasting
1565    /// *something* in cleartext to the LAN is still an accepted trade-off
1566    /// of this opt-in feature (mDNS/UDP have no confidentiality of their
1567    /// own); this change is about shrinking what a captured value is worth,
1568    /// not about hiding it in transit — see the spec's LAN P1-1 for the
1569    /// separate, not-yet-implemented "encrypt/authenticate the transport
1570    /// too" hardening.
1571    pub async fn find_agent(
1572        &self,
1573        agent_id: &str,
1574        http: &reqwest::Client,
1575    ) -> Option<(String, String)> {
1576        // Fast path: valid cache hit (positive or negative)
1577        if let Ok(cache) = self.agent_cache.read() {
1578            if let Some(e) = cache.get(agent_id) {
1579                if e.expires > std::time::Instant::now() {
1580                    return e.peer_url.as_ref().map(|url| (url.clone(), e.auth_key.clone()));
1581                }
1582            }
1583        }
1584
1585        // Slow path: fan out to every peer CONCURRENTLY and take the first
1586        // 2xx. Use reqwest's .query() for safe percent-encoding of the
1587        // agent_id (handles spaces, &, =, #, etc.) — see
1588        // `query_peers_concurrently`, which also explains why this is a stream
1589        // rather than a first-success-wins helper.
1590        let peers = self.get_instances();
1591        {
1592            use futures_util::StreamExt as _;
1593            let mut inflight = query_peers_concurrently(&peers, agent_id, http);
1594            while let Some((peer_url, auth_key, result)) = inflight.next().await {
1595                if matches!(result, Ok(ref r) if r.status().is_success()) {
1596                    tracing::debug!(agent_id, peer_url = %peer_url, "LAN agent found on peer");
1597                    if let Ok(mut cache) = self.agent_cache.write() {
1598                        cache.insert(
1599                            agent_id.to_string(),
1600                            LanCacheEntry {
1601                                peer_url: Some(peer_url.clone()),
1602                                auth_key: auth_key.clone(),
1603                                expires: std::time::Instant::now()
1604                                    + std::time::Duration::from_secs(LAN_AGENT_CACHE_TTL_SECS),
1605                            },
1606                        );
1607                    }
1608                    // Dropping `inflight` here cancels the remaining requests.
1609                    return Some((peer_url, auth_key));
1610                }
1611            }
1612        }
1613
1614        // No peer has this agent — write a negative cache entry so future
1615        // injects for cloud-only agents skip the full peer fan-out.
1616        if let Ok(mut cache) = self.agent_cache.write() {
1617            cache.insert(
1618                agent_id.to_string(),
1619                LanCacheEntry {
1620                    peer_url: None,
1621                    auth_key: String::new(),
1622                    expires: std::time::Instant::now()
1623                        + std::time::Duration::from_secs(LAN_AGENT_CACHE_TTL_SECS),
1624                },
1625            );
1626        }
1627        None
1628    }
1629
1630    /// Look up a claimed LAN sender's Ed25519 public key by fanning out to
1631    /// every discovered LAN peer, same query the target-agent lookup above
1632    /// makes (`GET /agentmux/reactive/agent?id=<agent_id>`, now extended —
1633    /// see `handle_reactive_agent` — to embed `lan_public_key` when the
1634    /// responding peer has minted one). A SEPARATE lookup from `find_agent`
1635    /// even though it hits the same endpoint: verifying an inbound message
1636    /// FROM agent Y needs Y's key, which is almost never the same agent_id
1637    /// as whatever THIS request's own target-agent lookup (if any) was for.
1638    ///
1639    /// Returns the raw decoded public key bytes, or `None` if no peer has
1640    /// one on file for this agent_id (cached negatively, same as
1641    /// `find_agent`'s "not found" case) — callers (`verify_lan_signature`)
1642    /// treat that identically to "no signature attempted": nothing to check
1643    /// against, not a red flag on its own.
1644    pub async fn find_agent_lan_pubkey(&self, agent_id: &str, http: &reqwest::Client) -> LanPubkeyLookup {
1645        if let Ok(cache) = self.pubkey_cache.read() {
1646            if let Some(e) = cache.get(agent_id) {
1647                if e.expires > std::time::Instant::now() {
1648                    return match &e.public_key {
1649                        Some(k) => LanPubkeyLookup::Found(k.clone()),
1650                        None => LanPubkeyLookup::NotFound,
1651                    };
1652                }
1653            }
1654        }
1655
1656        // reagentx P1: fail closed on the FAN-OUT (no outbound peer
1657        // queries) rather than let an attacker force unbounded network
1658        // traffic by varying agent_id on every request — see
1659        // LAN_PUBKEY_LOOKUP_RATE_LIMIT's doc comment. Returning
1660        // `RateLimited` here (reagentx P0 follow-up, NOT the same as
1661        // `NotFound`) is the point: by the time this function is called at
1662        // all, `verify_lan_signature` has already confirmed a `lan_sig` WAS
1663        // presented, so "we didn't check" must never collapse into the same
1664        // outcome as "genuinely nothing to check" — a caller could
1665        // otherwise exhaust this limiter with junk lookups, then slip a
1666        // forged signature for a REAL agent's identity through unverified
1667        // instead of forced-sensitive.
1668        let allowed = self
1669            .pubkey_lookup_limiter
1670            .lock()
1671            .map(|mut limiter| limiter.check())
1672            .unwrap_or(true);
1673        if !allowed {
1674            tracing::debug!(agent_id, "LAN pubkey lookup rate-limited — skipping peer fan-out");
1675            return LanPubkeyLookup::RateLimited;
1676        }
1677
1678        // Concurrent fan-out, same as `find_agent` — but note the acceptance
1679        // rule differs: a 2xx whose body has no `lan_public_key` means that
1680        // peer hosts the agent but hasn't minted a key, so we must keep
1681        // consuming the stream rather than stopping at the first 2xx.
1682        let peers = self.get_instances();
1683        {
1684            use futures_util::StreamExt as _;
1685            let mut inflight = query_peers_concurrently(&peers, agent_id, http);
1686            while let Some((peer_url, _auth_key, result)) = inflight.next().await {
1687                let Ok(resp) = result else { continue };
1688                if !resp.status().is_success() {
1689                    continue;
1690                }
1691                let Ok(body) = resp.json::<serde_json::Value>().await else { continue };
1692                let Some(pubkey_b64) = body.get("lan_public_key").and_then(|v| v.as_str()) else {
1693                    continue; // this peer has the agent but no LAN key minted for it yet
1694                };
1695                use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
1696                let Ok(pubkey_bytes) = BASE64.decode(pubkey_b64) else { continue };
1697                tracing::debug!(agent_id, peer_url = %peer_url, "LAN agent pubkey found on peer");
1698                let uid = body.get("uid").and_then(|v| v.as_str()).filter(|u| !u.is_empty());
1699                let uid_key = body.get("uid_public_key").and_then(|v| v.as_str()).and_then(|k| BASE64.decode(k).ok());
1700                if let Ok(mut cache) = self.pubkey_cache.write() {
1701                    cache.insert(
1702                        agent_id.to_string(),
1703                        LanPubkeyCacheEntry {
1704                            public_key: Some(pubkey_bytes.clone()),
1705                            uid_key: uid.zip(uid_key).map(|(u, k)| (u.to_string(), k)),
1706                            expires: std::time::Instant::now()
1707                                + std::time::Duration::from_secs(LAN_AGENT_CACHE_TTL_SECS),
1708                        },
1709                    );
1710                }
1711                // Dropping `inflight` here cancels the remaining requests.
1712                return LanPubkeyLookup::Found(pubkey_bytes);
1713            }
1714        }
1715
1716        if let Ok(mut cache) = self.pubkey_cache.write() {
1717            cache.insert(
1718                agent_id.to_string(),
1719                LanPubkeyCacheEntry {
1720                    public_key: None,
1721                    uid_key: None,
1722                    expires: std::time::Instant::now()
1723                        + std::time::Duration::from_secs(LAN_AGENT_CACHE_TTL_SECS),
1724                },
1725            );
1726        }
1727        LanPubkeyLookup::NotFound
1728    }
1729
1730    /// The UID and UID key the peer that answered [`Self::find_agent_lan_pubkey`]
1731    /// for `agent_id` gave beside its name key, while that answer is cached
1732    /// (identity M4d-6).
1733    pub fn cached_lan_uid_key(&self, agent_id: &str) -> Option<(String, Vec<u8>)> {
1734        let cache = self.pubkey_cache.read().ok()?;
1735        let e = cache.get(agent_id).filter(|e| e.expires > std::time::Instant::now())?;
1736        e.uid_key.clone()
1737    }
1738
1739    /// Cache a peer's answer for `agent_id` as `find_agent_lan_pubkey` would.
1740    #[cfg(test)]
1741    pub fn seed_lan_pubkey_for_test(&self, agent_id: &str, public_key: Vec<u8>, uid_key: Option<(String, Vec<u8>)>) {
1742        self.pubkey_cache.write().unwrap().insert(
1743            agent_id.to_string(),
1744            LanPubkeyCacheEntry {
1745                public_key: Some(public_key),
1746                uid_key,
1747                expires: std::time::Instant::now() + std::time::Duration::from_secs(LAN_AGENT_CACHE_TTL_SECS),
1748            },
1749        );
1750    }
1751
1752    /// Evict a stale cache entry (e.g. after a forward to that peer failed).
1753    pub fn evict_agent(&self, agent_id: &str) {
1754        if let Ok(mut cache) = self.agent_cache.write() {
1755            cache.remove(agent_id);
1756        }
1757    }
1758
1759    /// Idempotent: starts the daemon when `enabled` and not running, stops it
1760    /// when `!enabled` and running. Re-entrant safe.
1761    ///
1762    /// Holds the slot's write lock for the entire check-and-modify transaction
1763    /// to avoid a TOCTOU race between the `is_running` read and the slot
1764    /// mutation. `apply()` is called from toggle clicks and setting writes —
1765    /// low frequency — so briefly blocking concurrent peer-list reads is
1766    /// acceptable. `LanDiscovery::start()` and `Drop` are both fast (mDNS
1767    /// daemon construction + service register/unregister are local socket ops).
1768    pub fn apply(&self, enabled: bool) {
1769        let mut slot = self.slot.write();
1770        self.desired.store(enabled, std::sync::atomic::Ordering::SeqCst);
1771        let is_running = slot.is_some();
1772        match (enabled, is_running) {
1773            (true, false) => {
1774                match self.start_daemon() {
1775                    Ok(d) => {
1776                        *slot = Some(d);
1777                        tracing::info!("LAN discovery enabled via setting");
1778                    }
1779                    Err(e) => {
1780                        tracing::warn!("LAN discovery start failed: {e}");
1781                        // Surface to the UI so the user sees why the toggle
1782                        // didn't take effect (e.g. Windows Firewall blocked).
1783                        // `e` is already a String, but `.to_string()` is the
1784                        // documented contract for the wire payload (frontend
1785                        // reads `event.data.error` as a string).
1786                        self.event_bus.broadcast_event(&EventBus::typed_ws_event::<crate::backend::mps_events::LanInstancesError>(&LanDiscoveryError { error: e.to_string() }, String::new()));
1787                    }
1788                }
1789            }
1790            (false, true) => {
1791                // Explicitly shut down before dropping our Arc. The
1792                // spawn_blocking event-loop thread holds an `Arc<LanDiscovery>`
1793                // clone (see `start()` line ~94), so dropping the slot's Arc
1794                // alone does not reach refcount zero — `Drop` would never run
1795                // and the daemon would keep advertising/browsing. `shutdown()`
1796                // closes the mDNS socket synchronously, the receiver returns
1797                // Err, the event-loop exits, and the spawned thread releases
1798                // its Arc. `Drop`'s subsequent call to `shutdown()` is a
1799                // no-op (idempotent).
1800                if let Some(d) = slot.as_ref() {
1801                    d.shutdown();
1802                }
1803                *slot = None;
1804                tracing::info!("LAN discovery disabled via setting");
1805                self.event_bus.broadcast_event(&EventBus::typed_ws_event::<crate::backend::mps_events::LanInstances>(&LanInstanceList(Vec::new()), String::new()));
1806            }
1807            _ => {}
1808        }
1809    }
1810
1811    fn start_daemon(&self) -> Result<Arc<LanDiscovery>, String> {
1812        LanDiscovery::start(
1813            self.instance_id.clone(),
1814            self.hostname.clone(),
1815            self.version.clone(),
1816            self.port,
1817            self.auth_key.clone(),
1818            self.event_bus.clone(),
1819        )
1820    }
1821
1822    /// Remove this instance's `lan_instances` record on clean process exit,
1823    /// when `LanDiscovery`'s own `Drop` may never run (its tasks hold `Arc`s).
1824    /// Leaves mDNS and the sockets alone; the process is going away.
1825    pub fn withdraw_instance_record(&self) {
1826        if let Some(d) = self.slot.read().as_ref() {
1827            d.withdraw_instance_record();
1828        }
1829    }
1830
1831    /// Should a daemon be running right now, per the last `apply`?
1832    pub fn is_wanted(&self) -> bool {
1833        self.desired.load(std::sync::atomic::Ordering::SeqCst)
1834    }
1835
1836    /// Start a daemon when one is wanted but none is running: the recovery for a
1837    /// rebuild whose new daemon failed to start (ReAgent P2 on #4148). Does
1838    /// nothing, and returns `false`, when LAN is not wanted or a daemon is
1839    /// already up, checked under the same write lock `apply` uses, so it cannot
1840    /// race a switch-off.
1841    pub fn restart_if_wanted(&self) -> bool {
1842        let mut slot = self.slot.write();
1843        if slot.is_some() || !self.is_wanted() {
1844            return false;
1845        }
1846        match self.start_daemon() {
1847            Ok(fresh) => {
1848                *slot = Some(fresh);
1849                // Clears the error a failed rebuild showed, and resets the peer list.
1850                self.event_bus.broadcast_event(&EventBus::typed_ws_event::<crate::backend::mps_events::LanInstances>(&LanInstanceList(Vec::new()), String::new()));
1851                tracing::info!("mDNS daemon restarted after a failed rebuild");
1852                true
1853            }
1854            Err(e) => {
1855                tracing::warn!("mDNS daemon restart failed: {e}");
1856                false
1857            }
1858        }
1859    }
1860
1861    /// Replace a RUNNING mDNS daemon with a fresh one, atomically.
1862    ///
1863    /// Only ever replaces: if no daemon is running (LAN was switched off, or the
1864    /// supervisor's `enabled && reachable` gate in `bootstrap/network.rs` stopped
1865    /// it) this does nothing and returns `false`, so a rebuild can never start
1866    /// advertising on its own. The whole swap happens under one write lock, so
1867    /// `apply(false)` from the supervisor cannot interleave between the stop and
1868    /// the start (ReAgent P1 on #4148). The first draft called `apply(false)` then
1869    /// `apply(true)`, which released the lock in between and would have restarted
1870    /// a daemon the setting had just turned off.
1871    pub fn rebuild(&self) -> bool {
1872        let mut slot = self.slot.write();
1873        let Some(old) = slot.take() else {
1874            return false;
1875        };
1876        old.shutdown();
1877        match self.start_daemon() {
1878            Ok(fresh) => {
1879                *slot = Some(fresh);
1880                // The peer list starts over with the new daemon.
1881                self.event_bus.broadcast_event(&EventBus::typed_ws_event::<crate::backend::mps_events::LanInstances>(&LanInstanceList(Vec::new()), String::new()));
1882                tracing::info!("mDNS daemon rebuilt");
1883                true
1884            }
1885            Err(e) => {
1886                tracing::warn!("mDNS daemon rebuild failed: {e}");
1887                self.event_bus.broadcast_event(&EventBus::typed_ws_event::<crate::backend::mps_events::LanInstancesError>(&LanDiscoveryError { error: e.to_string() }, String::new()));
1888                false
1889            }
1890        }
1891    }
1892
1893    /// The running daemon's discoverability, or `None` when LAN discovery is
1894    /// off or failed to start.
1895    pub fn health(&self) -> Option<lan_mdns_health::MdnsHealth> {
1896        self.slot.read().as_ref().map(|d| d.health())
1897    }
1898
1899    /// Start the watchdog that notices an instance nobody can find and
1900    /// rebuilds its mDNS daemon, the same path as switching LAN off and on
1901    /// (which cleared the fault on Area54, SPEC_LAN_FIREWALL_SETUP 8.1). It
1902    /// also tells the frontend, via `laninstances:health`, so the indicator can
1903    /// say so: a host can see peers and still be invisible to them.
1904    pub fn spawn_health_watchdog(self: Arc<Self>) {
1905        tokio::spawn(async move {
1906            let mut watchdog = lan_mdns_health::Watchdog::default();
1907            let mut last_sent: Option<(String, Vec<Ipv4Addr>, u32)> = None;
1908            let mut tick = tokio::time::interval(HEALTH_TICK);
1909            tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1910            loop {
1911                tick.tick().await;
1912                let Some(health) = self.health() else {
1913                    if self.is_wanted() {
1914                        // Wanted but no daemon: a rebuild whose new daemon failed to
1915                        // start. Retry, and keep the rebuild budget (no `reset`).
1916                        if watchdog.observe_down() {
1917                            self.restart_if_wanted();
1918                        }
1919                        continue;
1920                    }
1921                    watchdog.reset();
1922                    // LAN went off: clear whatever the indicator was showing.
1923                    if last_sent.as_ref().is_some_and(|(state, _, _)| state != "off") {
1924                        self.publish_health("off", &[], 0);
1925                        last_sent = Some(("off".to_string(), Vec::new(), 0));
1926                    }
1927                    continue;
1928                };
1929                let rebuilds_before = watchdog.rebuilds();
1930                match watchdog.observe(&health) {
1931                    lan_mdns_health::Action::Rebuild => {
1932                        let missing = health.missing().to_vec();
1933                        // Say WHY before rebuilding: replay the daemon's own socket set-up
1934                        // on each address it failed to announce on, and name whoever holds
1935                        // UDP 5353. `mdns-sd` knows, but only tells the log what it can, and
1936                        // on Area54 this took several cloud round trips to find by hand.
1937                        let probe_these = missing.clone();
1938                        let diagnosis = tokio::task::spawn_blocking(move || lan_mdns_diag::diagnose(&probe_these))
1939                            .await
1940                            .unwrap_or_else(|e| format!("the diagnosis did not run ({e})"));
1941                        tracing::warn!(
1942                            missing = ?missing,
1943                            attempt = watchdog.rebuilds(),
1944                            next_retry_secs = u64::from(watchdog.retry_in_ticks()) * HEALTH_TICK.as_secs(),
1945                            %diagnosis,
1946                            "mDNS announced on none of this host's IPv4 addresses, so other machines cannot find it; rebuilding the mDNS daemon"
1947                        );
1948                        self.rebuild();
1949                    }
1950                    lan_mdns_health::Action::Nothing => {
1951                        if matches!(health, lan_mdns_health::MdnsHealth::Healthy) && rebuilds_before > 0 {
1952                            tracing::info!(rebuilds = rebuilds_before, "mDNS is announced on this host's IPv4 addresses again");
1953                        }
1954                        if let lan_mdns_health::MdnsHealth::Degraded { missing } = &health {
1955                            // Logged once per change below, not per tick.
1956                            if last_sent.as_ref().map(|(s, _, _)| s.as_str()) != Some("degraded") {
1957                                tracing::warn!(
1958                                    ?missing,
1959                                    "mDNS announced on some of this host's IPv4 addresses but not these (often a virtual adapter)"
1960                                );
1961                            }
1962                        }
1963                    }
1964                }
1965                let sent = (
1966                    health.wire_state().to_string(),
1967                    health.missing().to_vec(),
1968                    watchdog.rebuilds(),
1969                );
1970                // A standing problem is re-sent every tick so a window opened later
1971                // (or a reconnected WebSocket) learns of it; see `should_publish`.
1972                let changed = last_sent.as_ref() != Some(&sent);
1973                if lan_mdns_health::should_publish(&health, changed) {
1974                    self.publish_health(&sent.0, &sent.1, sent.2);
1975                    last_sent = Some(sent);
1976                }
1977            }
1978        });
1979    }
1980
1981    fn publish_health(&self, state: &str, missing: &[Ipv4Addr], rebuilds: u32) {
1982        let health = LanDiscoverability {
1983            state: state.to_string(),
1984            missing: missing.iter().map(|a| a.to_string()).collect(),
1985            rebuilds,
1986        };
1987        self.event_bus.broadcast_event(&EventBus::typed_ws_event::<crate::backend::mps_events::LanInstancesHealth>(&health, String::new()));
1988    }
1989
1990    /// Read the current peer list. Returns empty when the daemon is not
1991    /// running (discovery disabled or start failed).
1992    pub fn get_instances(&self) -> Vec<LanInstance> {
1993        self.slot
1994            .read()
1995            .as_ref()
1996            .map(|d| d.get_instances())
1997            .unwrap_or_default()
1998    }
1999}
2000
2001#[cfg(test)]
2002mod tests {
2003    use super::{
2004        is_lan_source, is_valid_probe, mdns_hostname, probe_response_json, record_daemon_event,
2005        LanDiscovery, SERVICE_TYPE, UDP_PROBE_TYPE, UDP_PROTOCOL_VERSION, UDP_RESPONSE_TYPE,
2006    };
2007    use crate::backend::lan_mdns_health;
2008    use mdns_sd::ServiceEvent;
2009    use parking_lot::Mutex;
2010    use serde_json::json;
2011    use std::collections::BTreeSet;
2012    use std::net::{Ipv4Addr, SocketAddr};
2013    use std::sync::Arc;
2014    use tokio::net::UdpSocket;
2015
2016    #[test]
2017    fn is_lan_source_accepts_private_v4_ranges() {
2018        for addr in [
2019            "127.0.0.1:1234",
2020            "10.0.0.5:1234",
2021            "172.16.4.4:1234",
2022            "192.168.1.50:1234",
2023            "169.254.1.1:1234", // link-local / APIPA
2024        ] {
2025            let addr: SocketAddr = addr.parse().unwrap();
2026            assert!(is_lan_source(&addr), "{addr} should be treated as LAN");
2027        }
2028    }
2029
2030    #[test]
2031    fn is_lan_source_rejects_public_v4() {
2032        for addr in ["8.8.8.8:1234", "1.1.1.1:1234", "203.0.113.7:1234"] {
2033            let addr: SocketAddr = addr.parse().unwrap();
2034            assert!(!is_lan_source(&addr), "{addr} should NOT be treated as LAN");
2035        }
2036    }
2037
2038    #[test]
2039    fn is_lan_source_accepts_v6_loopback_and_local_ranges() {
2040        for addr in ["[::1]:1234", "[fe80::1]:1234", "[fc00::1]:1234"] {
2041            let addr: SocketAddr = addr.parse().unwrap();
2042            assert!(is_lan_source(&addr), "{addr} should be treated as LAN");
2043        }
2044    }
2045
2046    #[test]
2047    fn is_lan_source_rejects_public_v6() {
2048        let addr: SocketAddr = "[2001:4860:4860::8888]:1234".parse().unwrap();
2049        assert!(!is_lan_source(&addr));
2050    }
2051
2052    #[test]
2053    fn is_lan_source_unwraps_v4_mapped_v6() {
2054        // A private v4 address wearing a v6 mapped-address wrapper must
2055        // still be classified by its embedded v4 range, not treated as an
2056        // opaque public v6 address.
2057        let addr: SocketAddr = "[::ffff:10.0.0.5]:1234".parse().unwrap();
2058        assert!(is_lan_source(&addr));
2059        let addr: SocketAddr = "[::ffff:8.8.8.8]:1234".parse().unwrap();
2060        assert!(!is_lan_source(&addr));
2061    }
2062
2063    #[test]
2064    fn appends_local_dot_to_bare_hostname() {
2065        assert_eq!(mdns_hostname("narko"), "narko.local.");
2066    }
2067
2068    #[test]
2069    fn preserves_already_fully_qualified_name() {
2070        assert_eq!(mdns_hostname("narko.local."), "narko.local.");
2071    }
2072
2073    #[test]
2074    fn appends_trailing_dot_to_local_suffix() {
2075        // mdns-sd needs the trailing dot; we add it without doubling .local.
2076        assert_eq!(mdns_hostname("narko.local"), "narko.local.");
2077    }
2078
2079    #[test]
2080    fn handles_trailing_dot_on_bare_hostname() {
2081        assert_eq!(mdns_hostname("narko."), "narko.local.");
2082    }
2083
2084    #[test]
2085    fn does_not_double_suffix() {
2086        // Two passes through the normalizer produce the same result.
2087        let once = mdns_hostname("narko");
2088        let twice = mdns_hostname(&once);
2089        assert_eq!(twice, once);
2090    }
2091
2092    // -- UDP broadcast-probe wire protocol (Layer 2 discovery fallback) --
2093
2094    #[test]
2095    fn is_valid_probe_accepts_well_formed_probe() {
2096        let bytes = serde_json::to_vec(&json!({"type": UDP_PROBE_TYPE, "v": 1})).unwrap();
2097        assert!(is_valid_probe(&bytes));
2098    }
2099
2100    #[test]
2101    fn is_valid_probe_rejects_non_json() {
2102        assert!(!is_valid_probe(b"not json at all"));
2103    }
2104
2105    #[test]
2106    fn is_valid_probe_rejects_wrong_type() {
2107        let bytes = serde_json::to_vec(&json!({"type": "something_else", "v": 1})).unwrap();
2108        assert!(!is_valid_probe(&bytes));
2109    }
2110
2111    #[test]
2112    fn is_valid_probe_rejects_wrong_version() {
2113        let bytes = serde_json::to_vec(&json!({"type": UDP_PROBE_TYPE, "v": 2})).unwrap();
2114        assert!(!is_valid_probe(&bytes));
2115    }
2116
2117    #[test]
2118    fn is_valid_probe_accepts_version_encoded_as_float() {
2119        // A client whose JSON serializer infers a `double` type for the
2120        // version field emits `1.0` rather than `1` — both must validate.
2121        let bytes = serde_json::to_vec(&json!({"type": UDP_PROBE_TYPE, "v": 1.0})).unwrap();
2122        assert!(is_valid_probe(&bytes));
2123    }
2124
2125    #[test]
2126    fn is_valid_probe_rejects_version_encoded_as_non_integral_float() {
2127        let bytes = serde_json::to_vec(&json!({"type": UDP_PROBE_TYPE, "v": 1.5})).unwrap();
2128        assert!(!is_valid_probe(&bytes));
2129    }
2130
2131    #[test]
2132    fn is_valid_probe_rejects_unrelated_json() {
2133        // Simulates non-AgentMux JSON noise landing on the port.
2134        let bytes = serde_json::to_vec(&json!({"hello": "world"})).unwrap();
2135        assert!(!is_valid_probe(&bytes));
2136    }
2137
2138    #[test]
2139    fn is_valid_probe_rejects_missing_version_field() {
2140        let bytes = serde_json::to_vec(&json!({"type": UDP_PROBE_TYPE})).unwrap();
2141        assert!(!is_valid_probe(&bytes));
2142    }
2143
2144    #[test]
2145    fn is_valid_probe_rejects_missing_type_field() {
2146        let bytes = serde_json::to_vec(&json!({"v": UDP_PROTOCOL_VERSION})).unwrap();
2147        assert!(!is_valid_probe(&bytes));
2148    }
2149
2150    #[test]
2151    fn probe_response_json_populates_all_fields() {
2152        let response = probe_response_json("inst-1", "myhost", "1.2.3", 9999, "secret-key");
2153        assert_eq!(response["type"], UDP_RESPONSE_TYPE);
2154        assert_eq!(response["v"], UDP_PROTOCOL_VERSION);
2155        assert_eq!(response["instance_id"], "inst-1");
2156        assert_eq!(response["hostname"], "myhost");
2157        assert_eq!(response["version"], "1.2.3");
2158        assert_eq!(response["port"], 9999);
2159        assert_eq!(response["auth_key"], "secret-key");
2160    }
2161
2162    // -- Real mDNS registration round-trip (reagent-style investigation:
2163    //    LAN peer discovery was silently broken end-to-end — see below) --
2164
2165    /// Registers a real LanDiscovery instance (exactly as `start()` does in
2166    /// production) and browses for it with a SEPARATE, independent
2167    /// `mdns_sd::ServiceDaemon` — mirroring what a real peer's browse-side
2168    /// would see. This is the one test in this module that touches the real
2169    /// network stack (multicast on 5353) — it's slow (~3s) but it's the only
2170    /// way to catch a registration bug that a browse-only or probe-only unit
2171    /// test can't reach.
2172    ///
2173    /// This test is the reason the `""` (empty string) IP passed to
2174    /// `ServiceInfo::new` in `start()` was found to be broken: mdns-sd's own
2175    /// doc comment on `AsIpAddrs for &str` says plainly "If the string is
2176    /// empty, will return an empty set" — NOT "auto-detect". Combined with
2177    /// `ServiceInfo::new` hardcoding `addr_auto: false` and `start()` never
2178    /// calling the builder's `.enable_addr_auto()`, every registered service
2179    /// had zero addresses and no instruction to ever fill them in — it was
2180    /// never actually discoverable, not by a real peer, not even by another
2181    /// mDNS client on the SAME machine. `register()` itself has no
2182    /// synchronous validation for this (it just enqueues a `Command::
2183    /// Register` and returns `Ok`), so `start()` logged "LAN discovery
2184    /// started" successfully every time despite never actually working.
2185    ///
2186    /// IGNORED by default (opt in with `cargo test -- --ignored`) —
2187    /// confirmed environment-fragile in two independent, unrelated ways, not
2188    /// just "flaky CI networking":
2189    ///
2190    /// 1. GitHub Actions' `macos-latest` runner doesn't support UDP
2191    ///    multicast at all: `mDNSResponder` is disabled there
2192    ///    (actions/runner-images#9628) and macOS 15+ runners separately lack
2193    ///    the "Local Network" permission sandboxed processes need for
2194    ///    multicast (actions/runner-images discussion #170669). Confirmed
2195    ///    deterministic (3/3 nightly runs), not intermittent.
2196    /// 2. Fails on real macOS hardware too, outside any CI — reproduced with
2197    ///    a minimal from-scratch `mdns-sd` program (no AgentMux code at all)
2198    ///    on a dev machine with several active VPN/tunnel interfaces: two
2199    ///    `ServiceDaemon`s in the *same process*, both sharing port 5353
2200    ///    with the OS's own `mDNSResponder`, and the browse side received
2201    ///    zero packets — not even `ServiceFound`. The OS-level `dns-sd`
2202    ///    CLI (two separate *processes*) discovers the identical
2203    ///    registration instantly on the same machine, so this isn't a
2204    ///    broken network or firewall; it's specific to same-process
2205    ///    multi-`ServiceDaemon` port-5353 sharing in this crate.
2206    ///
2207    /// Both failure modes are outside this codebase's control (GitHub's
2208    /// runner sandboxing; mdns-sd's own same-process daemon behavior), so
2209    /// this stays a real, valuable, manually-run integration check —
2210    /// exactly how the bug above was actually caught — rather than
2211    /// something CI can gate on. See
2212    /// docs/retro/retro-macos-ci-mdns-multicast-unsupported-2026-08-12.md.
2213    #[tokio::test]
2214    #[ignore = "environment-fragile: real multicast, not gateable in GH Actions macOS CI \
2215                (no multicast support there) or reliably on multi-interface dev machines \
2216                (same-process ServiceDaemon port-5353 sharing) — see retro doc"]
2217    async fn a_registered_instance_is_discoverable_by_an_independent_mdns_client() {
2218        let event_bus = Arc::new(crate::backend::eventbus::EventBus::new());
2219        let instance_id = format!("test-{}", std::process::id());
2220        let discovery = LanDiscovery::start(
2221            instance_id.clone(),
2222            "test-host".to_string(),
2223            "0.0.0-test".to_string(),
2224            54321,
2225            "test-auth-key".to_string(),
2226            event_bus,
2227        )
2228        .expect("LanDiscovery::start should succeed");
2229
2230        // Independent daemon — a stand-in for a real peer's browse side.
2231        // Deliberately NOT the same ServiceDaemon `discovery` uses; a
2232        // same-process shortcut (e.g. reading `discovery`'s own in-memory
2233        // `instances` map) wouldn't exercise the actual wire announcement.
2234        let browser_daemon = mdns_sd::ServiceDaemon::new().expect("browser daemon");
2235        let receiver = browser_daemon.browse(SERVICE_TYPE).expect("browse");
2236
2237        let mut found = false;
2238        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2239        while std::time::Instant::now() < deadline {
2240            if let Ok(event) = receiver.recv_timeout(std::time::Duration::from_millis(200)) {
2241                if let ServiceEvent::ServiceResolved(info) = event {
2242                    if info.get_property_val_str("instance_id") == Some(instance_id.as_str()) {
2243                        found = true;
2244                        break;
2245                    }
2246                }
2247            }
2248        }
2249
2250        let _ = browser_daemon.shutdown();
2251        discovery.shutdown();
2252
2253        assert!(
2254            found,
2255            "an independent mDNS client must be able to discover a freshly-registered LanDiscovery instance within 5s"
2256        );
2257    }
2258
2259    /// ReAgent P1 on #4148: a rebuild may only REPLACE a running daemon. With none
2260    /// running (LAN switched off, or the supervisor's `enabled && reachable` gate
2261    /// stopped it) it must do nothing, never start advertising on its own.
2262    #[tokio::test]
2263    async fn a_rebuild_never_starts_a_daemon_that_is_not_running() {
2264        let controller = super::LanDiscoveryController::new(
2265            "test-instance".to_string(),
2266            "test-host".to_string(),
2267            "0.0.0-test".to_string(),
2268            54323,
2269            Arc::new(crate::backend::eventbus::EventBus::new()),
2270            "test-key".to_string(),
2271        );
2272        assert!(controller.health().is_none());
2273        assert!(!controller.rebuild(), "nothing to rebuild");
2274        assert!(controller.health().is_none(), "and it must not have started one");
2275        assert!(controller.get_instances().is_empty());
2276
2277        // The same after LAN was on and has been switched off again.
2278        controller.apply(true);
2279        controller.apply(false);
2280        assert!(!controller.rebuild());
2281        assert!(controller.health().is_none());
2282    }
2283
2284    /// ReAgent P2 on #4148: the controller remembers that LAN is wanted even when
2285    /// the start failed, so the watchdog can retry; and it never restarts a
2286    /// daemon the setting has switched off.
2287    #[tokio::test]
2288    async fn a_restart_only_happens_while_lan_is_wanted() {
2289        let controller = super::LanDiscoveryController::new(
2290            "test-instance".to_string(),
2291            "test-host".to_string(),
2292            "0.0.0-test".to_string(),
2293            54324,
2294            Arc::new(crate::backend::eventbus::EventBus::new()),
2295            "test-key".to_string(),
2296        );
2297        assert!(!controller.is_wanted());
2298        assert!(!controller.restart_if_wanted(), "not wanted: nothing to restart");
2299        assert!(controller.health().is_none());
2300
2301        controller.apply(true); // wanted whether or not this host can start mDNS
2302        assert!(controller.is_wanted());
2303        controller.apply(false);
2304        assert!(!controller.is_wanted());
2305        assert!(!controller.restart_if_wanted(), "switched off again: must not restart");
2306        assert!(controller.health().is_none());
2307    }
2308
2309    /// The daemon's announcements, in the two payload shapes `mdns-sd` 0.12 uses,
2310    /// accumulate into the announced set, and an address that goes away leaves it.
2311    #[test]
2312    fn daemon_events_fold_into_the_announced_set() {
2313        use mdns_sd::DaemonEvent;
2314        let announced = Mutex::new(BTreeSet::new());
2315        record_daemon_event(
2316            DaemonEvent::Announce("a._agentmux._tcp.local.".into(), "[192.168.1.26, fe80::1]".into()),
2317            &announced,
2318        );
2319        record_daemon_event(
2320            DaemonEvent::Announce("a._agentmux._tcp.local.".into(), "Area54.local.:172.17.16.1".into()),
2321            &announced,
2322        );
2323        // Unrelated events change nothing.
2324        record_daemon_event(DaemonEvent::IpAdd("10.0.0.9".parse().unwrap()), &announced);
2325        record_daemon_event(DaemonEvent::Respond("192.168.1.26".parse().unwrap()), &announced);
2326        let want: BTreeSet<Ipv4Addr> = ["192.168.1.26", "172.17.16.1"].iter().map(|s| s.parse().unwrap()).collect();
2327        assert_eq!(*announced.lock(), want);
2328
2329        record_daemon_event(DaemonEvent::IpDel("172.17.16.1".parse().unwrap()), &announced);
2330        assert!(!announced.lock().contains(&"172.17.16.1".parse::<Ipv4Addr>().unwrap()));
2331        assert!(announced.lock().contains(&"192.168.1.26".parse::<Ipv4Addr>().unwrap()));
2332    }
2333
2334    /// Real daemon, real multicast: a freshly started instance must report itself
2335    /// announced on this host's IPv4 addresses. This is what proves the payload
2336    /// shape `ipv4_in` parses is the one the crate actually emits, and it is the
2337    /// check that would have failed on Area54. Manual, like the neighbouring
2338    /// test, for the same reason (CI has no reliable multicast).
2339    #[tokio::test]
2340    #[ignore = "real multicast: run by hand, e.g. `cargo test -p agentmux-srv -- --ignored a_started_instance_reports_itself_healthy`"]
2341    async fn a_started_instance_reports_itself_healthy() {
2342        let event_bus = Arc::new(crate::backend::eventbus::EventBus::new());
2343        let discovery = LanDiscovery::start(
2344            format!("health-{}", std::process::id()),
2345            "health-host".to_string(),
2346            "0.0.0-test".to_string(),
2347            54322,
2348            "test-auth-key".to_string(),
2349            event_bus,
2350        )
2351        .expect("LanDiscovery::start should succeed");
2352
2353        tokio::time::sleep(lan_mdns_health::GRACE + std::time::Duration::from_secs(1)).await;
2354        let health = discovery.health();
2355        let announced = discovery.announced_v4.lock().clone();
2356        discovery.shutdown();
2357
2358        assert!(
2359            matches!(health, lan_mdns_health::MdnsHealth::Healthy | lan_mdns_health::MdnsHealth::Degraded { .. }),
2360            "a started instance must be announced on at least one IPv4 address; health={health:?} announced={announced:?}"
2361        );
2362    }
2363
2364    /// End-to-end probe/response round trip over real loopback sockets
2365    /// (ephemeral ports, NOT the fixed UDP_DISCOVERY_PORT — avoids CI port
2366    /// collisions and avoids needing a full `LanDiscovery` + real mDNS
2367    /// `ServiceDaemon` just to exercise the wire format). Exercises the same
2368    /// `is_valid_probe` / `probe_response_json` functions the production
2369    /// `udp_responder_loop` calls.
2370    #[tokio::test]
2371    async fn udp_probe_round_trip_returns_valid_response() {
2372        let responder = UdpSocket::bind("127.0.0.1:0").await.unwrap();
2373        let responder_addr = responder.local_addr().unwrap();
2374        let prober = UdpSocket::bind("127.0.0.1:0").await.unwrap();
2375
2376        let probe = json!({"type": UDP_PROBE_TYPE, "v": UDP_PROTOCOL_VERSION});
2377        prober
2378            .send_to(&serde_json::to_vec(&probe).unwrap(), responder_addr)
2379            .await
2380            .unwrap();
2381
2382        let mut buf = [0u8; 1024];
2383        let (len, src) = responder.recv_from(&mut buf).await.unwrap();
2384        assert!(is_valid_probe(&buf[..len]));
2385
2386        let response = probe_response_json("inst-1", "myhost", "1.2.3", 9999, "secret-key");
2387        responder
2388            .send_to(&serde_json::to_vec(&response).unwrap(), src)
2389            .await
2390            .unwrap();
2391
2392        let (len, _) = prober.recv_from(&mut buf).await.unwrap();
2393        let received: serde_json::Value = serde_json::from_slice(&buf[..len]).unwrap();
2394        assert_eq!(received["type"], UDP_RESPONSE_TYPE);
2395        assert_eq!(received["v"], UDP_PROTOCOL_VERSION);
2396        assert_eq!(received["instance_id"], "inst-1");
2397        assert_eq!(received["hostname"], "myhost");
2398        assert_eq!(received["version"], "1.2.3");
2399        assert_eq!(received["port"], 9999);
2400        assert_eq!(received["auth_key"], "secret-key");
2401    }
2402}
2403
2404// -- `handle_event` self-skip + TXT-clobber regression tests --
2405//
2406// See docs/specs/SPEC_LAN_DISCOVERY_TXT_CLOBBER_FIX_2026_08_16.md. Root
2407// cause: `enable_addr_auto()` makes mdns-sd re-fire `ServiceResolved` for
2408// the SAME service far more often than fresh TXT data actually arrives —
2409// live logs showed ~96% of resolution events for this instance's own
2410// virtual/link-local addresses carrying an empty TXT record while
2411// address/port were always populated. These tests exercise
2412// `LanDiscovery::handle_event` directly against hand-built `ServiceInfo`
2413// values (no real daemon register/browse — construction of a
2414// `ServiceDaemon` is required only to satisfy the struct field, exactly
2415// as the existing ignored round-trip test above already relies on being
2416// safe) so they run fast and are not subject to that test's documented
2417// multicast flakiness.
2418#[cfg(test)]
2419mod handle_event_tests {
2420    use super::{LanDiscovery, LanInstance, SERVICE_TYPE};
2421    use mdns_sd::{ServiceEvent, ServiceInfo};
2422    use parking_lot::{Mutex, RwLock};
2423    use std::collections::{BTreeSet, HashMap};
2424    use std::sync::Arc;
2425    use tokio::sync::oneshot;
2426
2427    /// Build a `ServiceInfo` for `instance_id`, mirroring the exact
2428    /// `service_name`/host-name shape `LanDiscovery::start()` registers
2429    /// with, so `get_fullname()` matches what production code would
2430    /// compute for the same `instance_id`.
2431    fn test_service_info(host_seed: &str, port: u16, properties: &[(&str, &str)]) -> ServiceInfo {
2432        test_service_info_at(host_seed, port, "127.0.0.1", properties)
2433    }
2434
2435    /// As [`test_service_info`], but with an explicit address. Self-fixtures
2436    /// need a genuinely local one, because the self-check now requires the
2437    /// port AND address to say "this is us" — a fullname match alone no
2438    /// longer skips. See `handle_event`.
2439    fn test_service_info_at(
2440        host_seed: &str,
2441        port: u16,
2442        addr: &str,
2443        properties: &[(&str, &str)],
2444    ) -> ServiceInfo {
2445        // Mirror production's naming exactly (`mdns_instance_label`), so
2446        // `get_fullname()` is what `start()` would really register.
2447        let service_name = super::mdns_instance_label(host_seed, port);
2448        test_service_info_with_label(&service_name, port, addr, properties)
2449    }
2450
2451    /// Build a `ServiceInfo` with an explicit instance label, decoupled from
2452    /// the SRV port. Needed because the peer map is keyed by fullname and the
2453    /// label now embeds the port: a test that models "the SAME service moved
2454    /// to a new port" must hold the label fixed while the SRV port changes,
2455    /// which the label-derives-from-port helpers cannot express.
2456    fn test_service_info_with_label(
2457        label: &str,
2458        port: u16,
2459        addr: &str,
2460        properties: &[(&str, &str)],
2461    ) -> ServiceInfo {
2462        ServiceInfo::new(SERVICE_TYPE, label, "test-host.local.", addr, port, properties)
2463            .expect("ServiceInfo::new should succeed for a well-formed test fixture")
2464    }
2465
2466    /// One address that genuinely belongs to this host, so a self-fixture
2467    /// satisfies `resolves_to_this_instance`. Falls back to loopback on the
2468    /// (practically impossible) empty-set machine.
2469    fn own_addr() -> String {
2470        crate::backend::lan_listeners::cached_local_addresses()
2471            .iter()
2472            .next()
2473            .map(|a| a.to_string())
2474            .unwrap_or_else(|| "127.0.0.1".to_string())
2475    }
2476
2477    /// A `LanDiscovery` whose `service_fullname` is derived the same way
2478    /// production's `start()` derives it (from a real `ServiceInfo` for
2479    /// `self_host_seed`), so the self-skip comparison in `handle_event`
2480    /// is exercised exactly as it runs in production — not asserted
2481    /// against a hand-typed guess at mdns-sd's fullname format.
2482    /// `self_port` is now load-bearing: it is BOTH the port baked into our
2483    /// own `service_fullname` and the `port` the self-check compares against,
2484    /// exactly as in production where both come from the same bind. The old
2485    /// fixture set `port: 0` while minting self-events on a different port —
2486    /// harmless when a bare fullname match was enough to skip, but it did not
2487    /// represent a real self-resolution.
2488    fn test_discovery(self_host_seed: &str, self_port: u16) -> LanDiscovery {
2489        let self_info = test_service_info(self_host_seed, self_port, &[]);
2490        LanDiscovery {
2491            daemon: mdns_sd::ServiceDaemon::new().expect("daemon construction (no register/browse)"),
2492            instances: Arc::new(RwLock::new(HashMap::new())),
2493            instance_id: self_host_seed.to_string(),
2494            event_bus: Arc::new(crate::backend::eventbus::EventBus::new()),
2495            service_fullname: self_info.get_fullname().to_string(),
2496            auth_key: String::new(),
2497            hostname: String::new(),
2498            version: String::new(),
2499            port: self_port,
2500            udp_cancel: Mutex::new(None),
2501            udp_peer_cancel: Mutex::new(None),
2502            agent_names_cancel: Mutex::new(None),
2503            instance_record_cancel: Mutex::new(None),
2504            instances_dir: None,
2505            instance_record_live: Mutex::new(false),
2506            announced_v4: Arc::new(Mutex::new(BTreeSet::new())),
2507            monitored: true,
2508            started_at: std::time::Instant::now(),
2509        }
2510    }
2511
2512    fn get(instances: &[LanInstance], instance_id: &str) -> Option<LanInstance> {
2513        instances.iter().find(|i| i.instance_id == instance_id).cloned()
2514    }
2515
2516    // -- cross-host discovery regression (2026-09-06) --
2517    //
2518    // Two hosts on the same build stopped discovering each other in one
2519    // direction. `service_name` was `agentmux-{instance_id}` and `instance_id`
2520    // is the VERSION, so both hosts registered the identical fullname; the
2521    // self-skip then dropped the genuine peer as "us". The dots in the version
2522    // also made the label malformed, triggering mdns-sd conflict-renaming,
2523    // which left the renamed host with a stale `service_fullname` that matched
2524    // its peer's real name — so the renamed host went blind permanently.
2525    // Found jointly with Manpo@gamerlove against two live hosts.
2526
2527    #[test]
2528    fn a_peer_whose_fullname_equals_ours_is_still_discovered() {
2529        // THE regression. A remote peer announcing under a fullname identical
2530        // to our own (same-version collision, or a stale self-name after a
2531        // conflict-rename) must NOT be mistaken for us: it is on a different
2532        // port, which is proof it is somebody else.
2533        let discovery = test_discovery("collide", 59859);
2534
2535        // The peer must carry OUR EXACT fullname while arriving from a foreign
2536        // address on a foreign port — that is the shape the old
2537        // `fullname == self.service_fullname` guard silently dropped. Pin the
2538        // label explicitly: deriving it from the peer's own (foreign) port
2539        // would produce a DIFFERENT fullname, the old guard would never fire
2540        // on it, and this test would pass against the unfixed code — vacuous.
2541        // (Caught by Manpo@gamerlove reviewing this PR; verified by reverting
2542        // the guard locally and watching this test go red.)
2543        let self_label = super::mdns_instance_label("collide", 59859);
2544        let remote_peer = test_service_info_with_label(
2545            &self_label,
2546            60237,
2547            "192.168.1.68",
2548            &[("instance_id", "v0.55.37"), ("hostname", "gamerlove")],
2549        );
2550        assert_eq!(
2551            remote_peer.get_fullname(),
2552            discovery.service_fullname,
2553            "fixture must actually reproduce the fullname collision, or this test proves nothing"
2554        );
2555
2556        discovery.handle_event(ServiceEvent::ServiceResolved(remote_peer));
2557
2558        assert_eq!(
2559            discovery.get_instances().len(),
2560            1,
2561            "a remote peer must be discovered even when its fullname collides with ours"
2562        );
2563    }
2564
2565    #[test]
2566    fn instance_label_is_free_of_dots_and_unique_per_instance() {
2567        // Dots made the label malformed (mdns-sd split it and appended its
2568        // conflict suffix mid-string, observed as
2569        // "agentmux-v0 (2).55.37._agentmux._tcp.local.").
2570        let fqdn = super::mdns_instance_label("box.corp.example", 59859);
2571        assert!(!fqdn.contains('.'), "label must contain no dots, got {fqdn}");
2572
2573        // Same host, two instances -> different labels (differing ports).
2574        assert_ne!(
2575            super::mdns_instance_label("narko", 59859),
2576            super::mdns_instance_label("narko", 60237),
2577        );
2578        // Two hosts on the SAME version -> different labels. This is the
2579        // property the old version-derived name did not have.
2580        assert_ne!(
2581            super::mdns_instance_label("narko", 59859),
2582            super::mdns_instance_label("gamerlove", 59859),
2583        );
2584        // No version anywhere in it, deliberately.
2585        assert!(!super::mdns_instance_label("narko", 59859).contains("0.55"));
2586    }
2587
2588    #[test]
2589    fn instance_label_survives_a_hostname_with_no_usable_characters() {
2590        let label = super::mdns_instance_label("...", 59859);
2591        assert!(!label.contains('.'));
2592        assert_eq!(label, "agentmux-unknown-59859");
2593    }
2594
2595    #[test]
2596    fn self_resolution_with_blank_txt_is_never_inserted_as_a_peer() {
2597        // This is the exact failure mode from the live logs: mdns-sd
2598        // re-resolves this instance's own service on a virtual/link-local
2599        // interface and the TXT record (instance_id/hostname/version/
2600        // auth_key) comes back empty. Skipping by `fullname` (SRV-record
2601        // derived, always present) rather than the TXT `instance_id`
2602        // property must catch this even though the property itself is
2603        // blank on this event.
2604        let discovery = test_discovery("self-id", 56023);
2605        let blank_self_event = test_service_info_at("self-id", 56023, &own_addr(), &[]);
2606
2607        discovery.handle_event(ServiceEvent::ServiceResolved(blank_self_event));
2608
2609        assert!(
2610            discovery.get_instances().is_empty(),
2611            "a blank-TXT resolution of this instance's own service must never appear as a peer"
2612        );
2613    }
2614
2615    #[test]
2616    fn self_resolution_with_full_txt_is_also_never_inserted_as_a_peer() {
2617        // Sanity check that the fullname-based skip doesn't regress the
2618        // straightforward case (this was already correct before the fix).
2619        let discovery = test_discovery("self-id", 56023);
2620        let full_self_event = test_service_info_at(
2621            "self-id",
2622            56023,
2623            &own_addr(),
2624            &[("instance_id", "self-id"), ("hostname", "myhost")],
2625        );
2626
2627        discovery.handle_event(ServiceEvent::ServiceResolved(full_self_event));
2628
2629        assert!(discovery.get_instances().is_empty());
2630    }
2631
2632    #[test]
2633    fn blank_txt_refire_does_not_clobber_previously_known_good_peer_fields() {
2634        let discovery = test_discovery("self-id", 56023);
2635
2636        let full_event = test_service_info(
2637            "peer-1",
2638            9999,
2639            &[
2640                ("instance_id", "peer-1"),
2641                ("hostname", "realhost"),
2642                ("version", "1.2.3"),
2643                ("auth_key", "secret"),
2644            ],
2645        );
2646        discovery.handle_event(ServiceEvent::ServiceResolved(full_event));
2647
2648        let after_full = get(&discovery.get_instances(), "peer-1").expect("peer-1 should be discovered");
2649        assert_eq!(after_full.hostname, "realhost");
2650        assert_eq!(after_full.version, "1.2.3");
2651        assert_eq!(after_full.auth_key, "secret");
2652
2653        // Same service (same fullname), a re-resolution with an empty TXT
2654        // record — exactly what mdns-sd's `enable_addr_auto()` produces on
2655        // most re-fires per the live-log evidence.
2656        let blank_refire = test_service_info("peer-1", 9999, &[]);
2657        discovery.handle_event(ServiceEvent::ServiceResolved(blank_refire));
2658
2659        let instances = discovery.get_instances();
2660        assert_eq!(instances.len(), 1, "the blank re-fire must update the existing entry, not create a duplicate");
2661        let after_blank = get(&instances, "peer-1").expect("peer-1 must still be present");
2662        assert_eq!(after_blank.hostname, "realhost", "hostname must survive a blank-TXT re-fire");
2663        assert_eq!(after_blank.version, "1.2.3", "version must survive a blank-TXT re-fire");
2664        assert_eq!(after_blank.auth_key, "secret", "auth_key must survive a blank-TXT re-fire");
2665        assert_eq!(after_blank.instance_id, "peer-1", "instance_id must survive a blank-TXT re-fire");
2666    }
2667
2668    #[test]
2669    fn service_removed_does_not_delete_a_known_peer() {
2670        // The live-log failure mode this guards against: mdns-sd fires
2671        // `ServiceRemoved` on ordinary TTL/interface churn, not just on a
2672        // genuine peer departure. The old handler deleted the map entry on
2673        // that event, so the peer's identity (hostname/version/auth_key) was
2674        // gone — and every subsequent re-resolution came back blank-TXT (see
2675        // the refire test above), so it could never recover. `ServiceRemoved`
2676        // must be inert; only staleness (`get_instances()`'s `last_seen`
2677        // check) may make a peer disappear.
2678        let discovery = test_discovery("self-id", 56023);
2679
2680        let full_event = test_service_info(
2681            "peer-1",
2682            9999,
2683            &[
2684                ("instance_id", "peer-1"),
2685                ("hostname", "realhost"),
2686                ("version", "1.2.3"),
2687                ("auth_key", "secret"),
2688            ],
2689        );
2690        let fullname = full_event.get_fullname().to_string();
2691        discovery.handle_event(ServiceEvent::ServiceResolved(full_event));
2692        assert_eq!(discovery.get_instances().len(), 1, "peer-1 should be discovered");
2693
2694        discovery.handle_event(ServiceEvent::ServiceRemoved(SERVICE_TYPE.to_string(), fullname.clone()));
2695
2696        let after_removed = get(&discovery.get_instances(), "peer-1")
2697            .expect("ServiceRemoved must not delete the peer");
2698        assert_eq!(after_removed.hostname, "realhost", "hostname must survive a ServiceRemoved event");
2699        assert_eq!(after_removed.version, "1.2.3", "version must survive a ServiceRemoved event");
2700        assert_eq!(after_removed.auth_key, "secret", "auth_key must survive a ServiceRemoved event");
2701
2702        // The failure mode in full: Removed, then a blank-TXT re-resolution
2703        // (the realistic post-removal shape per live logs) must still not
2704        // lose the identity established by the original full resolution.
2705        let blank_refire = test_service_info("peer-1", 9999, &[]);
2706        discovery.handle_event(ServiceEvent::ServiceResolved(blank_refire));
2707        let after_refire = get(&discovery.get_instances(), "peer-1")
2708            .expect("peer-1 must still be present after a post-removal blank refire");
2709        assert_eq!(after_refire.hostname, "realhost");
2710        assert_eq!(after_refire.version, "1.2.3");
2711        assert_eq!(after_refire.auth_key, "secret");
2712    }
2713
2714    #[test]
2715    fn get_instances_hides_a_peer_stale_past_its_own_ttl() {
2716        let discovery = test_discovery("self-id", 56023);
2717
2718        let full_event = test_service_info(
2719            "peer-1",
2720            9999,
2721            &[("instance_id", "peer-1"), ("hostname", "realhost")],
2722        );
2723        discovery.handle_event(ServiceEvent::ServiceResolved(full_event));
2724        assert_eq!(discovery.get_instances().len(), 1);
2725
2726        // mdns-sd 0.12 exposes no way to construct a ServiceInfo with a
2727        // custom TTL, so the fixture above always resolves with the crate's
2728        // real default (`other_ttl_secs: 4500`) — set it directly to a value
2729        // ABOVE `LAN_PEER_STALE_TIMEOUT_FLOOR_SECS` (300) so the boundary
2730        // being tested is the peer's own TTL, not the floor underneath it.
2731        {
2732            let mut instances = discovery.instances.write();
2733            for inst in instances.values_mut() {
2734                inst.other_ttl_secs = 1000;
2735            }
2736        }
2737
2738        // Backdate past the floor (300s) but still within this peer's own
2739        // 1000s TTL: must still be reported — proves the peer's own TTL, not
2740        // the floor, is what's controlling visibility here.
2741        {
2742            let mut instances = discovery.instances.write();
2743            for inst in instances.values_mut() {
2744                inst.last_seen = inst.last_seen.saturating_sub(600);
2745            }
2746        }
2747        assert_eq!(
2748            discovery.get_instances().len(),
2749            1,
2750            "600s of silence is past the old 300s floor but within this peer's own 1000s TTL"
2751        );
2752
2753        // Now push past the peer's own TTL too.
2754        {
2755            let mut instances = discovery.instances.write();
2756            for inst in instances.values_mut() {
2757                inst.last_seen = inst.last_seen.saturating_sub(401);
2758            }
2759        }
2760        assert!(
2761            discovery.get_instances().is_empty(),
2762            "a peer with no ServiceResolved in over its own advertised TTL must not be reported"
2763        );
2764    }
2765
2766    #[test]
2767    fn peer_staleness_window_secs_ceils_an_adversarially_large_advertised_ttl() {
2768        // ReAgent P1, PR #3301: other_ttl_secs comes straight from a peer's own
2769        // (spoofable) mDNS advertisement — this file already treats that input
2770        // as adversarial elsewhere (LAN_AGENT_NAMES_MAX_*). Without a ceiling, a
2771        // peer claiming an other_ttl near u32::MAX (~136 years) would be
2772        // honored forever, permanently pinning it as "alive" and defeating the
2773        // entire staleness design.
2774        assert_eq!(
2775            super::peer_staleness_window_secs(u32::MAX),
2776            super::LAN_PEER_STALE_TIMEOUT_CEIL_SECS,
2777            "an implausibly large advertised TTL must be capped, not honored as-is"
2778        );
2779    }
2780
2781    #[test]
2782    fn get_instances_hides_a_peer_with_an_adversarial_ttl_once_the_ceiling_elapses() {
2783        let discovery = test_discovery("self-id", 56023);
2784
2785        let full_event = test_service_info(
2786            "peer-1",
2787            9999,
2788            &[("instance_id", "peer-1"), ("hostname", "realhost")],
2789        );
2790        discovery.handle_event(ServiceEvent::ServiceResolved(full_event));
2791        assert_eq!(discovery.get_instances().len(), 1);
2792
2793        {
2794            let mut instances = discovery.instances.write();
2795            for inst in instances.values_mut() {
2796                inst.other_ttl_secs = u32::MAX;
2797                inst.last_seen = inst.last_seen.saturating_sub(super::LAN_PEER_STALE_TIMEOUT_CEIL_SECS + 1);
2798            }
2799        }
2800
2801        assert!(
2802            discovery.get_instances().is_empty(),
2803            "a peer claiming an implausibly large TTL must still expire at the ceiling, \
2804             not be pinned as permanently alive"
2805        );
2806    }
2807
2808    #[test]
2809    fn get_instances_does_not_prune_a_slow_reannouncing_peer_within_its_ttl() {
2810        // The real bug this guards against (2026-09-17): narko and starpower
2811        // re-announce roughly every 40-46 minutes (their actual mDNS `other_ttl`
2812        // record cycle), well past the old fixed 300s cutoff but comfortably
2813        // inside the real advertised TTL (4500s/75min default — RFC 6762 §10,
2814        // mdns-sd 0.12's hardcoded `DNS_OTHER_TTL`). A peer silent for 46
2815        // minutes is still alive and must still be reported.
2816        let discovery = test_discovery("self-id", 56023);
2817
2818        let full_event = test_service_info(
2819            "peer-1",
2820            9999,
2821            &[("instance_id", "peer-1"), ("hostname", "realhost")],
2822        );
2823        discovery.handle_event(ServiceEvent::ServiceResolved(full_event));
2824        assert_eq!(discovery.get_instances().len(), 1);
2825
2826        const FORTY_SIX_MINUTES_SECS: u64 = 46 * 60;
2827        {
2828            let mut instances = discovery.instances.write();
2829            for inst in instances.values_mut() {
2830                inst.last_seen = inst.last_seen.saturating_sub(FORTY_SIX_MINUTES_SECS);
2831            }
2832        }
2833
2834        assert_eq!(
2835            discovery.get_instances().len(),
2836            1,
2837            "a peer silent for less than its own advertised TTL must still be reported, \
2838             even though that gap is far past the old fixed 300s cutoff"
2839        );
2840    }
2841
2842    #[test]
2843    fn address_and_port_still_update_unconditionally_on_a_blank_txt_refire() {
2844        // address/port come from the SRV/A record, not TXT, and are always
2845        // populated by mdns-sd — a genuine interface/address change must
2846        // still be reflected even when the TXT-derived fields are absent
2847        // on that same event.
2848        let discovery = test_discovery("self-id", 56023);
2849
2850        // One stable label across both events: this models the same mDNS
2851        // service re-resolving on a new port, not a different service. (The
2852        // label embeds a port in production, so it is pinned here explicitly
2853        // rather than derived from the changing SRV port.)
2854        let label = super::mdns_instance_label("peer-1", 9999);
2855        let first = test_service_info_with_label(
2856            &label,
2857            9999,
2858            "127.0.0.1",
2859            &[("instance_id", "peer-1"), ("hostname", "realhost")],
2860        );
2861        discovery.handle_event(ServiceEvent::ServiceResolved(first));
2862        assert_eq!(get(&discovery.get_instances(), "peer-1").unwrap().port, 9999);
2863
2864        let moved = test_service_info_with_label(&label, 8888, "127.0.0.1", &[]);
2865        discovery.handle_event(ServiceEvent::ServiceResolved(moved));
2866
2867        let instances = discovery.get_instances();
2868        let entry = get(&instances, "peer-1").expect("peer-1 must still be present");
2869        assert_eq!(entry.port, 8888, "port must update even on a blank-TXT event");
2870        assert_eq!(entry.hostname, "realhost", "hostname must still survive despite the address/port change");
2871    }
2872
2873    #[test]
2874    fn a_new_peer_first_seen_via_blank_txt_has_no_data_to_preserve() {
2875        // Not a regression this fix is responsible for solving — merely
2876        // documents the expected (acceptable) behavior for a genuinely
2877        // first-seen blank-TXT event: nothing was ever known, so nothing
2878        // can be preserved. hostname stays empty until a TXT-bearing event
2879        // eventually arrives.
2880        let discovery = test_discovery("self-id", 56023);
2881        let blank_first = test_service_info("peer-2", 7777, &[]);
2882
2883        discovery.handle_event(ServiceEvent::ServiceResolved(blank_first));
2884
2885        let instances = discovery.get_instances();
2886        assert_eq!(instances.len(), 1);
2887        assert_eq!(instances[0].hostname, "");
2888        assert_eq!(instances[0].instance_id, "");
2889    }
2890
2891    /// The refresh loop holds its own `Arc<LanDiscovery>`, so dropping the
2892    /// controller's Arc on `apply(false)` can never stop it — only an explicit
2893    /// signal can. Without this, disabling LAN discovery left the task issuing
2894    /// authenticated requests to peers forever, leaking one per toggle cycle
2895    /// (ReAgent P1 on PR #3102 — the first version of this code shipped a doc
2896    /// comment claiming cancellation it did not actually implement, which is
2897    /// exactly why this is asserted here rather than described).
2898    #[tokio::test]
2899    async fn shutdown_cancels_the_agent_names_refresh_loop() {
2900        let discovery = Arc::new(test_discovery("cancel-host", 55123));
2901
2902        let (tx, rx) = oneshot::channel();
2903        *discovery.agent_names_cancel.lock() = Some(tx);
2904        let handle = tokio::spawn(discovery.clone().agent_names_refresh_loop(rx));
2905
2906        // Still parked on its sleep — nothing has cancelled it yet.
2907        assert!(!handle.is_finished());
2908
2909        discovery.shutdown();
2910
2911        // Must return promptly on the signal rather than after the full
2912        // LAN_AGENT_NAMES_REFRESH_SECS sleep, which is the whole point of
2913        // selecting on the cancel inside the sleep.
2914        let stopped = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await;
2915        assert!(
2916            stopped.is_ok(),
2917            "refresh loop did not exit within 5s of shutdown() - it would outlive apply(false)"
2918        );
2919    }
2920}
2921
2922#[cfg(test)]
2923mod lookup_rate_limiter_tests {
2924    use super::LookupRateLimiter;
2925
2926    #[test]
2927    fn allows_up_to_max_tokens_per_window() {
2928        let mut limiter = LookupRateLimiter::new(3);
2929        assert!(limiter.check());
2930        assert!(limiter.check());
2931        assert!(limiter.check());
2932        assert!(!limiter.check(), "a 4th check within the same second must be denied");
2933    }
2934
2935    #[test]
2936    fn refills_after_the_window_elapses() {
2937        let mut limiter = LookupRateLimiter::new(1);
2938        assert!(limiter.check());
2939        assert!(!limiter.check());
2940        // Simulate the refill window having passed rather than sleeping in
2941        // a test — same approach as the existing RATE_LIMIT_MAX tests use
2942        // conceptually, just directly on the struct field since this type
2943        // has no injectable clock.
2944        limiter.last_refill -= std::time::Duration::from_secs(2);
2945        assert!(limiter.check(), "must refill once a full second has elapsed");
2946    }
2947}
2948
2949#[cfg(test)]
2950mod peer_fanout_tests {
2951    use super::*;
2952    use tokio::io::{AsyncReadExt, AsyncWriteExt};
2953
2954    /// Spawn a minimal HTTP peer that waits `delay` before answering `status`.
2955    /// Returns its `127.0.0.1:port`. Same raw-TCP shape as the mock servers in
2956    /// `muxbus::pkce`'s tests — no extra dev-dependency needed.
2957    async fn mock_peer(delay: std::time::Duration, status: &'static str, body: &'static str) -> String {
2958        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2959        let addr = listener.local_addr().unwrap();
2960        tokio::spawn(async move {
2961            loop {
2962                let Ok((mut stream, _)) = listener.accept().await else { return };
2963                tokio::spawn(async move {
2964                    let mut buf = [0u8; 2048];
2965                    let _ = stream.read(&mut buf).await;
2966                    tokio::time::sleep(delay).await;
2967                    let resp = format!(
2968                        "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
2969                        body.len()
2970                    );
2971                    let _ = stream.write_all(resp.as_bytes()).await;
2972                });
2973            }
2974        });
2975        format!("127.0.0.1:{}", addr.port())
2976    }
2977
2978    fn peer_at(addr: &str) -> LanInstance {
2979        let (host, port) = addr.rsplit_once(':').unwrap();
2980        LanInstance {
2981            instance_id: format!("peer-{port}"),
2982            hostname: "test".into(),
2983            version: "0".into(),
2984            channel: String::new(),
2985            os: String::new(),
2986            address: host.to_string(),
2987            port: port.parse().unwrap(),
2988            auth_key: "k".into(),
2989            agents: vec![],
2990            agent_status: Default::default(),
2991            agent_lines: Default::default(),
2992            first_seen: 0,
2993            last_seen: 0,
2994            last_polled_ok: 0,
2995            other_ttl_secs: 4500,
2996        }
2997    }
2998
2999    /// The regression this fan-out exists to prevent: a slow peer listed BEFORE
3000    /// the peer that actually has the agent must not delay the answer.
3001    ///
3002    /// Sequentially this took `LAN_PEER_QUERY_TIMEOUT_SECS` (2s) to time the
3003    /// slow peer out before even trying the second one. Concurrently the fast
3004    /// peer answers immediately. The 1500ms bound sits well clear of both the
3005    /// ~0ms concurrent path and the ≥2000ms sequential one, so it discriminates
3006    /// without being timing-fragile.
3007    #[tokio::test]
3008    async fn a_slow_peer_does_not_delay_a_fast_one() {
3009        let slow = mock_peer(
3010            std::time::Duration::from_secs(LAN_PEER_QUERY_TIMEOUT_SECS + 5),
3011            "200 OK",
3012            "{}",
3013        )
3014        .await;
3015        let fast = mock_peer(std::time::Duration::from_millis(0), "200 OK", "{}").await;
3016        // Slow first — the ordering that defeated the sequential loop.
3017        let peers = vec![peer_at(&slow), peer_at(&fast)];
3018        let http = reqwest::Client::new();
3019
3020        let started = std::time::Instant::now();
3021        let mut inflight = query_peers_concurrently(&peers, "agent-x", &http);
3022        let mut winner = None;
3023        {
3024            use futures_util::StreamExt as _;
3025            while let Some((peer_url, _key, result)) = inflight.next().await {
3026                if matches!(result, Ok(ref r) if r.status().is_success()) {
3027                    winner = Some(peer_url);
3028                    break;
3029                }
3030            }
3031        }
3032        let elapsed = started.elapsed();
3033
3034        assert_eq!(
3035            winner.as_deref(),
3036            Some(format!("http://{fast}").as_str()),
3037            "the fast peer should win even though the slow peer was queried first"
3038        );
3039        assert!(
3040            elapsed < std::time::Duration::from_millis(1500),
3041            "fan-out should not wait on the slow peer; took {elapsed:?}"
3042        );
3043    }
3044
3045    /// Peers with no address or no scoped lan_key are not queryable and must be
3046    /// skipped — same eligibility rule the sequential loops applied.
3047    #[tokio::test]
3048    async fn ineligible_peers_are_skipped() {
3049        let mut no_addr = peer_at("127.0.0.1:1");
3050        no_addr.address = String::new();
3051        let mut no_key = peer_at("127.0.0.1:2");
3052        no_key.auth_key = String::new();
3053        let peers = vec![no_addr, no_key];
3054        let http = reqwest::Client::new();
3055        let inflight = query_peers_concurrently(&peers, "agent-x", &http);
3056        assert_eq!(inflight.len(), 0, "neither peer is eligible to be queried");
3057    }
3058}
3059
3060/// `is_self_resolution` — the guard that stops this instance inserting itself
3061/// as a LAN peer.
3062///
3063/// The scenario these encode was measured on 2026-09-06, not imagined: four
3064/// `LAN peer discovered` events, all `peer_id=""`, all on this instance's own
3065/// port 55019, at 192.168.1.230 / 172.23.176.1 / fe80::5c1e:c2bb:b5bc:9655 —
3066/// every one an address of this host. The pre-existing fullname-based self-skip
3067/// did not catch them.
3068#[cfg(test)]
3069mod self_resolution_tests {
3070    use super::is_self_resolution;
3071    use std::collections::HashSet;
3072    use std::net::IpAddr;
3073
3074    fn own() -> HashSet<IpAddr> {
3075        ["192.168.1.230", "172.23.176.1", "fe80::5c1e:c2bb:b5bc:9655", "127.0.0.1"]
3076            .iter()
3077            .map(|s| s.parse().unwrap())
3078            .collect()
3079    }
3080
3081    fn ip(s: &str) -> IpAddr {
3082        s.parse().unwrap()
3083    }
3084
3085    /// The exact observed case, one per address the log showed.
3086    #[test]
3087    fn our_own_addresses_on_our_own_port_are_self() {
3088        for addr in ["192.168.1.230", "172.23.176.1", "fe80::5c1e:c2bb:b5bc:9655"] {
3089            assert!(
3090                is_self_resolution(55019, &[ip(addr)], 55019, &own()),
3091                "{addr}:55019 is this host on this instance's port — must be skipped"
3092            );
3093        }
3094    }
3095
3096    /// A real peer: different machine, so its addresses are not ours. Sharing
3097    /// our ephemeral port by coincidence must NOT be enough to discard it —
3098    /// that would silently drop a genuine peer, which is far worse than the
3099    /// phantom this guard exists to remove.
3100    #[test]
3101    fn a_remote_peer_on_a_coincidentally_equal_port_is_not_self() {
3102        assert!(!is_self_resolution(55019, &[ip("192.168.1.77")], 55019, &own()));
3103    }
3104
3105    /// Another AgentMux instance on THIS host does share our addresses — it is
3106    /// the port that distinguishes it. Two instances never hold the same port.
3107    #[test]
3108    fn a_sibling_instance_on_this_host_is_not_self() {
3109        assert!(
3110            !is_self_resolution(51095, &[ip("192.168.1.230")], 55019, &own()),
3111            "same host, different port — a real sibling instance, not us"
3112        );
3113    }
3114
3115    /// `any`, not `all`: interface enumeration and mDNS resolution race, so a
3116    /// resolution of our own service can carry one address we didn't enumerate.
3117    /// Requiring every address to match would let that stray entry resurrect
3118    /// the phantom.
3119    #[test]
3120    fn one_matching_address_is_enough_even_beside_an_unknown_one() {
3121        assert!(is_self_resolution(
3122            55019,
3123            &[ip("10.99.99.99"), ip("172.23.176.1")],
3124            55019,
3125            &own()
3126        ));
3127    }
3128
3129    /// Enumeration failing means "we cannot prove any address is ours". Degrade
3130    /// to the old behaviour (a phantom peer) rather than to something worse —
3131    /// an empty set must never make everything look like self.
3132    #[test]
3133    fn an_empty_own_set_never_claims_self() {
3134        assert!(!is_self_resolution(55019, &[ip("192.168.1.230")], 55019, &HashSet::new()));
3135    }
3136
3137    #[test]
3138    fn a_resolution_with_no_addresses_is_not_self() {
3139        assert!(!is_self_resolution(55019, &[], 55019, &own()));
3140    }
3141}
3142
3143/// `agent_names_refresh_loop`'s peer-response handling — real HTTP round
3144/// trips against a raw fake server, same style as
3145/// `server::tests::spawn_fake_browser_api`, because the behavior under test
3146/// (bailing out mid-body, not just rejecting on `Content-Length`) can't be
3147/// exercised through an in-process `tower::ServiceExt::oneshot` call.
3148#[cfg(test)]
3149mod agent_names_refresh_tests {
3150    use super::{LanDiscovery, LAN_AGENT_NAMES_MAX_COUNT, LAN_AGENT_NAMES_MAX_NAME_LEN};
3151    use tokio::io::{AsyncReadExt, AsyncWriteExt};
3152
3153    /// Starts a raw TCP server that always answers
3154    /// `/agentmux/reactive/agent-names` with `body` verbatim (caller supplies
3155    /// correct headers/framing), and returns its `http://127.0.0.1:<port>`
3156    /// base URL. One-shot: serves exactly one connection then stops, which is
3157    /// all a single `fetch_peer_agent_names` call makes.
3158    async fn spawn_fake_agent_names_server(response: &'static str) -> String {
3159        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3160        let port = listener.local_addr().unwrap().port();
3161        tokio::spawn(async move {
3162            let Ok((mut stream, _)) = listener.accept().await else { return };
3163            let mut buf = [0u8; 4096];
3164            let _ = stream.read(&mut buf).await;
3165            let _ = stream.write_all(response.as_bytes()).await;
3166        });
3167        format!("http://127.0.0.1:{port}")
3168    }
3169
3170    /// Codex P1 on PR #3102: the `lan_key` a peer advertises authenticates
3171    /// THIS client to that peer, not the other way round — anything able to
3172    /// fake an mDNS advertisement gets into `self.instances` and is polled.
3173    /// A response larger than the cap must be rejected outright, not
3174    /// buffered in full and then discarded.
3175    #[tokio::test]
3176    async fn oversized_response_is_rejected_not_buffered() {
3177        // 4x the cap, filled with a repeating byte inside a JSON string so a
3178        // parser that ignored the cap would otherwise succeed.
3179        let oversized_len = 4 * super::LAN_AGENT_NAMES_MAX_RESPONSE_BYTES;
3180        let filler = "a".repeat(oversized_len);
3181        let json_body = format!(r#"{{"agents":["{filler}"]}}"#);
3182        let response = format!(
3183            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
3184            json_body.len(),
3185            json_body
3186        );
3187        let leaked: &'static str = Box::leak(response.into_boxed_str());
3188        let base_url = spawn_fake_agent_names_server(leaked).await;
3189        let http = reqwest::Client::new();
3190
3191        let names = LanDiscovery::fetch_peer_agent_names(&http, &base_url, "irrelevant").await;
3192        assert!(
3193            names.is_none(),
3194            "an oversized response must be discarded entirely, not truncated into a result"
3195        );
3196    }
3197
3198    /// The happy path, plus defense-in-depth entry capping: a valid response
3199    /// with a name that's too long and more names than the count cap allows
3200    /// should keep the good names and drop the rest, not fail outright.
3201    #[tokio::test]
3202    async fn valid_response_is_parsed_and_excess_entries_are_capped() {
3203        let too_long_name = "x".repeat(LAN_AGENT_NAMES_MAX_NAME_LEN + 1);
3204        let mut names_json: Vec<String> = (0..LAN_AGENT_NAMES_MAX_COUNT + 10)
3205            .map(|i| format!("\"agent-{i}\""))
3206            .collect();
3207        names_json.push(format!("\"{too_long_name}\""));
3208        let json_body = format!(r#"{{"agents":[{}]}}"#, names_json.join(","));
3209        let response = format!(
3210            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
3211            json_body.len(),
3212            json_body
3213        );
3214        let leaked: &'static str = Box::leak(response.into_boxed_str());
3215        let base_url = spawn_fake_agent_names_server(leaked).await;
3216        let http = reqwest::Client::new();
3217
3218        let names = LanDiscovery::fetch_peer_agent_names(&http, &base_url, "irrelevant")
3219            .await
3220            .expect("a well-formed, appropriately-sized response must parse")
3221            .names;
3222
3223        assert_eq!(names.len(), LAN_AGENT_NAMES_MAX_COUNT, "must truncate to the count cap");
3224        assert!(names.contains(&"agent-0".to_string()));
3225        assert!(
3226            !names.iter().any(|n| n.len() > LAN_AGENT_NAMES_MAX_NAME_LEN),
3227            "an over-length name must never survive into the result"
3228        );
3229    }
3230
3231    /// Codex P2 on PR #3102: sequential polling lets N stale/unreachable
3232    /// peers each burn their full timeout in turn. Two peers that each take
3233    /// ~300ms to answer must complete in ~300ms total, not ~600ms — proving
3234    /// the fan-out is real concurrency, not just non-blocking syntax that
3235    /// still executes one after another.
3236    #[tokio::test]
3237    async fn peers_are_polled_concurrently_not_sequentially() {
3238        async fn spawn_slow_agent_names_server(delay_ms: u64) -> String {
3239            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3240            let port = listener.local_addr().unwrap().port();
3241            tokio::spawn(async move {
3242                let Ok((mut stream, _)) = listener.accept().await else { return };
3243                let mut buf = [0u8; 4096];
3244                let _ = stream.read(&mut buf).await;
3245                tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
3246                let body = r#"{"agents":["slow-agent"]}"#;
3247                let resp = format!(
3248                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
3249                    body.len(),
3250                    body
3251                );
3252                let _ = stream.write_all(resp.as_bytes()).await;
3253            });
3254            format!("http://127.0.0.1:{port}")
3255        }
3256
3257        let delay_ms = 300;
3258        let url_a = spawn_slow_agent_names_server(delay_ms).await;
3259        let url_b = spawn_slow_agent_names_server(delay_ms).await;
3260        let http = reqwest::Client::new();
3261
3262        let started = std::time::Instant::now();
3263        use futures_util::StreamExt as _;
3264        let mut inflight = futures_util::stream::FuturesUnordered::new();
3265        inflight.push(LanDiscovery::fetch_peer_agent_names(&http, &url_a, "k"));
3266        inflight.push(LanDiscovery::fetch_peer_agent_names(&http, &url_b, "k"));
3267        while inflight.next().await.is_some() {}
3268        let elapsed = started.elapsed();
3269
3270        assert!(
3271            elapsed < std::time::Duration::from_millis(delay_ms * 2 - 100),
3272            "two {delay_ms}ms peers took {elapsed:?} — looks sequential, not concurrent"
3273        );
3274    }
3275}