Skip to main content

openrtc/client/
auto_connect_impl.rs

1//! Delegated `Client` auto-connect and transport-health internals.
2//!
3//! This module owns retry/backoff/tie-break policy and snapshot/event reconciliation.
4
5#[cfg(test)]
6use super::state_signaling_impl::should_present_session_token_on_connected_transport;
7use super::*;
8
9#[cfg(test)]
10pub(crate) fn should_auto_present_session_token(
11    has_extracted_token: bool,
12    has_current_remote_admission_proof: bool,
13) -> bool {
14    should_present_session_token_on_connected_transport(
15        has_extracted_token,
16        has_current_remote_admission_proof,
17    )
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[cfg(not(target_arch = "wasm32"))]
22pub(crate) struct SessionAdmissionPresentationDecision {
23    pub(crate) should_present: bool,
24    pub(crate) request_reciprocal: bool,
25}
26
27/// Decide how the sole auto-connect actor repairs directional admission.
28///
29/// A current outbound proof normally suppresses duplicate presentation. The
30/// exception is a current-generation route missing its inbound proof: this side
31/// sends a valid typed request and asks the peer to present its own token in the
32/// same pass. A validated peer request likewise forces one bounded presentation
33/// through this same actor.
34#[cfg(not(target_arch = "wasm32"))]
35pub(crate) fn session_admission_presentation_decision(
36    has_extracted_token: bool,
37    has_current_remote_admission_proof: bool,
38    has_current_inbound_admission_proof: bool,
39    peer_requested_reciprocal: bool,
40    is_designated_initiator: bool,
41) -> SessionAdmissionPresentationDecision {
42    if !has_extracted_token {
43        return SessionAdmissionPresentationDecision {
44            should_present: false,
45            request_reciprocal: false,
46        };
47    }
48
49    // A replacement route commonly starts with neither directional proof. Ask
50    // for the reverse presentation on the first valid request instead of
51    // waiting for a second health-loop pass after remote approval. A validated
52    // peer request already arrived on the peer's presentation stream, so echoing
53    // another reciprocal request before its ACK-bound proof becomes visible
54    // creates a request ping-pong. Present once to satisfy that request without
55    // bouncing it back.
56    let bilateral_proof_is_current =
57        has_current_remote_admission_proof && has_current_inbound_admission_proof;
58    if bilateral_proof_is_current {
59        return SessionAdmissionPresentationDecision {
60            should_present: false,
61            request_reciprocal: false,
62        };
63    }
64
65    // A fresh physical leg commonly becomes visible to both auto-connect
66    // actors before either directional proof exists. Only the deterministic
67    // initiator may open the first persistent admission stream; otherwise both
68    // peers install different canonical control streams and each closes the
69    // route selected by the other. The observing peer answers that one stream
70    // inline. If it could not do so, the validated request below authorizes one
71    // bounded follow-up presentation without another reciprocal request.
72    if !has_current_remote_admission_proof && !has_current_inbound_admission_proof {
73        return SessionAdmissionPresentationDecision {
74            should_present: is_designated_initiator,
75            request_reciprocal: is_designated_initiator,
76        };
77    }
78
79    if peer_requested_reciprocal {
80        return SessionAdmissionPresentationDecision {
81            should_present: true,
82            request_reciprocal: false,
83        };
84    }
85
86    // An inbound proof means the peer's presentation and ACK exchange has
87    // already completed, so a one-way follow-up cannot collide with that
88    // stream. A presenter that is still missing its inbound proof owns the
89    // reciprocal request only when it is the deterministic initiator.
90    let request_reciprocal = has_current_remote_admission_proof && is_designated_initiator;
91    SessionAdmissionPresentationDecision {
92        should_present: !has_current_remote_admission_proof || request_reciprocal,
93        request_reciprocal,
94    }
95}
96
97/// Return whether the persistent `user-device` actor may inspect or repair the
98/// current physical transport.
99///
100/// A deterministic node-pair connection can be reused by an explicit
101/// capability such as `drive-grant:*`, `room:*`, or `space:*`. That capability
102/// owns its admission/security epoch while active. The background user-device
103/// actor must yield before probing, presenting its cached ticket, or retiring
104/// that transport; otherwise it can replace the explicit capability's
105/// semantic security epoch and strand product traffic.
106///
107/// An in-flight presentation is also exclusive. The registry presentation
108/// guard resolves the narrow race where an explicit actor starts admission
109/// between discovery snapshots.
110#[cfg(not(target_arch = "wasm32"))]
111pub(crate) fn persistent_managed_actor_may_own_transport(
112    current_scopes: &[String],
113    admitted_scope: Option<&str>,
114    presentation_in_flight: bool,
115) -> bool {
116    if presentation_in_flight {
117        return false;
118    }
119
120    current_scopes
121        .iter()
122        .map(String::as_str)
123        .chain(admitted_scope)
124        .map(str::trim)
125        .filter(|scope| !scope.is_empty())
126        .all(|scope| scope == super::core_impl::PERSISTENT_MANAGED_ADMISSION_SCOPE)
127}
128
129#[cfg(not(target_arch = "wasm32"))]
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub(crate) enum ActiveConnectionProbeDecision {
132    Healthy,
133    PreserveLiveTransport,
134    Failed,
135}
136
137#[cfg(not(target_arch = "wasm32"))]
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139enum FailedProbeRetirementOutcome {
140    Retired,
141    DeferredAdmissionInFlight,
142    CancelledCurrentGenerationActivity,
143    GenerationChanged,
144}
145
146const ACTIVE_CONNECTION_PROBE_TIMEOUT_MS: i64 = 4_000;
147
148/// Retire shared-endpoint connections from the superseded remote process while
149/// preserving the exact Iroh connection that owns OpenRTC control traffic.
150///
151/// OpenRTC's `transport_generation` is a process-lifetime logical fence. Iroh's
152/// `Connection::stable_id()` is a separate runtime identifier used by the
153/// endpoint's connection registry. Keeping this adapter typed to a concrete
154/// connection prevents those two identifiers from being mixed.
155#[cfg(all(
156    not(target_arch = "wasm32"),
157    openrtc_iroh_retire_remote_connections_api
158))]
159pub(crate) async fn retire_stale_endpoint_connections(
160    endpoint: &iroh::Endpoint,
161    endpoint_id: iroh::EndpointId,
162    current_transport: &iroh::endpoint::Connection,
163    reason: &[u8],
164) -> usize {
165    endpoint
166        .retire_remote_connections_except(endpoint_id, current_transport.stable_id(), reason)
167        .await
168}
169
170pub(crate) fn generation_activity_is_recent(
171    last_inbound_activity_age: Option<std::time::Duration>,
172    probe_interval_ms: i64,
173) -> bool {
174    let activity_window_ms = probe_interval_ms
175        .max(0)
176        .saturating_add(ACTIVE_CONNECTION_PROBE_TIMEOUT_MS) as u64;
177    last_inbound_activity_age
178        .is_some_and(|age| age <= std::time::Duration::from_millis(activity_window_ms))
179}
180
181#[cfg(not(target_arch = "wasm32"))]
182impl Client {
183    /// Wake whichever native desired-peer adapter currently feeds the one
184    /// Rust auto-connect owner. Gateway-integrated hosts use the provider
185    /// subscription loop; IPC-only hosts use the external revisioned mailbox.
186    pub(crate) async fn wake_native_auto_connect_owner(&self) -> bool {
187        if self.external_auto_connect_is_active().await {
188            return self.wake_native_external_auto_connect().await;
189        }
190
191        let provider_actor_active = self
192            .auto_connect_loop_key
193            .lock()
194            .unwrap_or_else(|poisoned| poisoned.into_inner())
195            .is_some();
196        if provider_actor_active {
197            self.native_auto_connect_wake.notify_one();
198        }
199        provider_actor_active
200    }
201
202    /// Record valid peer protocol activity for the exact physical generation
203    /// that carried it. Callers must only invoke this after parsing a complete
204    /// OpenRTC control frame or after the Rust admission router has classified
205    /// a current, admitted application stream. Unclassified raw bytes and
206    /// failed authentication are not liveness evidence.
207    pub(crate) fn record_native_transport_protocol_activity(
208        &self,
209        connection_id: &str,
210        transport_stable_id: u64,
211    ) {
212        if let Ok(mut activity) = self.native_transport_protocol_activity.write() {
213            activity.insert(
214                connection_id.to_string(),
215                super::NativeTransportProtocolActivity {
216                    transport_stable_id,
217                    observed_at: std::time::Instant::now(),
218                },
219            );
220        }
221    }
222
223    pub(crate) fn native_transport_protocol_activity_age(
224        &self,
225        connection_id: &str,
226        expected_transport_stable_id: u64,
227    ) -> Option<std::time::Duration> {
228        let observed_at = self
229            .native_transport_protocol_activity
230            .read()
231            .ok()
232            .and_then(|activity| activity.get(connection_id).copied())
233            .filter(|activity| activity.transport_stable_id == expected_transport_stable_id)?
234            .observed_at;
235        Some(std::time::Instant::now().saturating_duration_since(observed_at))
236    }
237
238    pub(crate) fn forget_native_transport_protocol_activity_for_transport(
239        &self,
240        connection_id: &str,
241        expected_transport_stable_id: u64,
242    ) -> bool {
243        self.native_transport_protocol_activity
244            .write()
245            .ok()
246            .is_some_and(|mut activity| {
247                if activity
248                    .get(connection_id)
249                    .is_some_and(|entry| entry.transport_stable_id == expected_transport_stable_id)
250                {
251                    activity.remove(connection_id);
252                    true
253                } else {
254                    false
255                }
256            })
257    }
258
259    pub(crate) fn forget_native_transport_protocol_activity(&self, connection_id: &str) {
260        if let Ok(mut activity) = self.native_transport_protocol_activity.write() {
261            activity.remove(connection_id);
262        }
263    }
264
265    /// Retire a physical transport after a failed liveness probe without
266    /// racing a valid admission exchange on that same generation.
267    ///
268    /// Every native failed-probe caller must converge here. The retirement
269    /// guard serializes teardown with response writers and outbound token
270    /// presenters, then the exact generation and its protocol activity are
271    /// re-read after the guard is acquired.
272    async fn retire_transport_generation_after_failed_probe(
273        &self,
274        connection_id: &str,
275        endpoint_id: iroh::EndpointId,
276        expected_transport_stable_id: u64,
277        activity_window_ms: i64,
278        reason: &str,
279    ) -> FailedProbeRetirementOutcome {
280        let Some(retirement_guard) = self
281            .session_token_registry
282            .try_begin_transport_retirement(connection_id)
283        else {
284            return FailedProbeRetirementOutcome::DeferredAdmissionInFlight;
285        };
286
287        let current_transport_stable_id = self
288            .get_connection(endpoint_id)
289            .await
290            .map(|connection| crate::transport_generation::for_connection(&connection));
291        if current_transport_stable_id != Some(expected_transport_stable_id) {
292            drop(retirement_guard);
293            return FailedProbeRetirementOutcome::GenerationChanged;
294        }
295
296        if generation_activity_is_recent(
297            self.native_transport_protocol_activity_age(
298                connection_id,
299                expected_transport_stable_id,
300            ),
301            activity_window_ms,
302        ) {
303            drop(retirement_guard);
304            return FailedProbeRetirementOutcome::CancelledCurrentGenerationActivity;
305        }
306
307        let retired = self
308            .disconnect_transport_generation_with_reason(
309                endpoint_id,
310                expected_transport_stable_id,
311                reason,
312            )
313            .await
314            .unwrap_or(false);
315        drop(retirement_guard);
316
317        if retired {
318            FailedProbeRetirementOutcome::Retired
319        } else {
320            FailedProbeRetirementOutcome::GenerationChanged
321        }
322    }
323}
324
325#[cfg(target_arch = "wasm32")]
326impl Client {
327    pub(crate) fn native_transport_protocol_activity_age(
328        &self,
329        _connection_id: &str,
330        _expected_transport_stable_id: u64,
331    ) -> Option<std::time::Duration> {
332        None
333    }
334}
335
336#[cfg(not(target_arch = "wasm32"))]
337pub(crate) fn active_connection_probe_decision(
338    active_probe_healthy: bool,
339    passive_transport_alive: bool,
340) -> ActiveConnectionProbeDecision {
341    if active_probe_healthy {
342        ActiveConnectionProbeDecision::Healthy
343    } else if passive_transport_alive {
344        ActiveConnectionProbeDecision::PreserveLiveTransport
345    } else {
346        ActiveConnectionProbeDecision::Failed
347    }
348}
349
350#[cfg(not(target_arch = "wasm32"))]
351pub(crate) fn active_connection_probe_decision_with_generation_activity(
352    active_probe_healthy: bool,
353    passive_transport_alive: bool,
354    recent_generation_activity: bool,
355) -> ActiveConnectionProbeDecision {
356    if recent_generation_activity {
357        ActiveConnectionProbeDecision::Healthy
358    } else {
359        active_connection_probe_decision(active_probe_healthy, passive_transport_alive)
360    }
361}
362
363#[cfg(not(target_arch = "wasm32"))]
364pub(crate) fn next_active_connection_probe_failure_count(
365    decision: ActiveConnectionProbeDecision,
366    current_failures: u8,
367) -> u8 {
368    if matches!(decision, ActiveConnectionProbeDecision::Healthy) {
369        0
370    } else {
371        current_failures.saturating_add(1).min(10)
372    }
373}
374
375#[cfg(not(target_arch = "wasm32"))]
376pub(crate) fn active_connection_probe_is_healthy(
377    decision: ActiveConnectionProbeDecision,
378    failure_count: u8,
379    failure_threshold: u8,
380) -> bool {
381    matches!(decision, ActiveConnectionProbeDecision::Healthy)
382        || (matches!(
383            decision,
384            ActiveConnectionProbeDecision::PreserveLiveTransport
385        ) && failure_count < failure_threshold)
386}
387
388/// A session-token response proves authorization only when it is received and
389/// parsed. Losing the physical Iroh leg before that response is transport
390/// churn, not an authorization denial. The owner of auto-connect must preserve
391/// the logical peer session in that case so the canonical replacement leg can
392/// retry admission after backoff.
393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394pub(crate) enum SessionTokenPresentationFailureAction {
395    RetryOnNextTransport,
396    RejectAndDisconnect,
397}
398
399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400#[cfg(not(target_arch = "wasm32"))]
401pub(crate) enum RetryableSessionAdmissionFailureDecision {
402    PreserveCurrentTransport,
403    RetireCurrentTransport,
404    ReplacementAlreadyWon,
405}
406
407/// A superseded desired-peer pass may retire a connection only when the same
408/// actor accepted a newer authoritative roster that actually withdrew that
409/// durable device. A revision bump caused by another peer must not close the
410/// still-desired peer's shared physical transport.
411pub(crate) fn superseded_desired_peer_was_withdrawn(
412    actor_is_current: bool,
413    current_revision: u64,
414    attempt_revision: u64,
415    peer_is_still_desired: bool,
416) -> bool {
417    actor_is_current && current_revision > attempt_revision && !peer_is_still_desired
418}
419
420/// Bound admission repair to the physical generation that failed.
421///
422/// A single response loss is normal during crossed reconnects, so the current
423/// transport gets one retry. Repeated failures mean the passive Iroh handle is
424/// no longer useful for application admission even when QUIC has not reported
425/// closed. Only the generation that produced the failure may be retired; a
426/// replacement that won while the request was in flight remains authoritative.
427#[cfg(not(target_arch = "wasm32"))]
428pub(crate) fn retryable_session_admission_failure_decision(
429    failure_count: u8,
430    failure_threshold: u8,
431    attempted_transport_stable_id: Option<u64>,
432    current_transport_stable_id: Option<u64>,
433    remote_runtime_instance_changed: bool,
434) -> RetryableSessionAdmissionFailureDecision {
435    if attempted_transport_stable_id != current_transport_stable_id
436        || attempted_transport_stable_id.is_none()
437    {
438        return RetryableSessionAdmissionFailureDecision::ReplacementAlreadyWon;
439    }
440
441    // A new remote runtime instance with the same durable Iroh identity can
442    // leave the old process's QUIC handle looking passively alive. If that
443    // exact generation cannot obtain an admission response from the newly
444    // advertised runtime, preserving it for the ordinary second attempt only
445    // sends the retry to the retired process. The runtime-instance observation
446    // is coordination evidence, while the failed action-bound response is the
447    // lifecycle input that makes this generation unusable.
448    if !remote_runtime_instance_changed && failure_count < failure_threshold.max(1) {
449        RetryableSessionAdmissionFailureDecision::PreserveCurrentTransport
450    } else {
451        RetryableSessionAdmissionFailureDecision::RetireCurrentTransport
452    }
453}
454
455pub(crate) fn session_token_presentation_failure_action(
456    error: &str,
457) -> SessionTokenPresentationFailureAction {
458    let error = error.to_ascii_lowercase();
459    if error.contains("connection lost")
460        || error.contains("[session-token-response:timeout]")
461        || error.contains("stream finished early")
462        || error.contains("peer stream ended before")
463        || error.contains("connection closed")
464        || error.contains("stream reset")
465        || error.contains("stream stopped")
466        || error.contains("failed to open bi-stream")
467        || error.contains("failed to write session-token presentation")
468        || error.contains("missing connection for session-token presentation")
469        || error.contains("[session-token-presentation:in-flight]")
470        || error.contains("authoritative-device-binding-pending")
471        || error.contains(crate::session_token::PEER_ADMISSION_LEASE_EXPIRED)
472    {
473        SessionTokenPresentationFailureAction::RetryOnNextTransport
474    } else {
475        SessionTokenPresentationFailureAction::RejectAndDisconnect
476    }
477}
478
479#[cfg(not(target_arch = "wasm32"))]
480pub(crate) fn session_token_presentation_was_duplicate(error: &str) -> bool {
481    error
482        .to_ascii_lowercase()
483        .contains("[session-token-presentation:in-flight]")
484}
485
486fn remote_excludes_local_device(excluded_peers: &[String], local_device_id: &str) -> bool {
487    let local_device_id = local_device_id.trim();
488    !local_device_id.is_empty()
489        && excluded_peers
490            .iter()
491            .any(|peer| peer.trim().eq_ignore_ascii_case(local_device_id))
492}
493
494#[cfg(not(target_arch = "wasm32"))]
495fn observe_remote_runtime_instance(
496    last_known: &mut std::collections::HashMap<String, String>,
497    device_id: &str,
498    runtime_instance_id: Option<&str>,
499) -> bool {
500    let Some(runtime_instance_id) = runtime_instance_id
501        .map(str::trim)
502        .filter(|value| !value.is_empty() && *value != device_id)
503    else {
504        return false;
505    };
506    let key = format!("{device_id}::runtime-instance");
507    last_known
508        .insert(key, runtime_instance_id.to_string())
509        .is_some_and(|previous| previous != runtime_instance_id)
510}
511
512#[cfg(not(target_arch = "wasm32"))]
513fn runtime_instance_change_requires_replacement(
514    runtime_instance_changed: bool,
515    active_transport_responsive: bool,
516) -> bool {
517    runtime_instance_changed && !active_transport_responsive
518}
519
520#[cfg(not(target_arch = "wasm32"))]
521fn observe_remote_ticket(
522    last_known: &mut std::collections::HashMap<String, String>,
523    device_id: &str,
524    ticket: &str,
525) -> bool {
526    let ticket = ticket.trim();
527    if ticket.is_empty() {
528        return false;
529    }
530    let key = format!("{device_id}::ticket");
531    let fingerprint = crate::session_token::token_fingerprint(ticket);
532    last_known
533        .insert(key, fingerprint.clone())
534        .is_some_and(|previous| previous != fingerprint)
535}
536
537#[cfg(not(target_arch = "wasm32"))]
538fn reconcile_remote_ticket_retry_state(
539    last_known: &mut std::collections::HashMap<String, String>,
540    device_id: &str,
541    ticket: &str,
542    last_attempt_at: &mut std::collections::HashMap<String, i64>,
543    failure_count: &mut std::collections::HashMap<String, u8>,
544    failure_backoff_until: &mut std::collections::HashMap<String, i64>,
545) -> bool {
546    if !observe_remote_ticket(last_known, device_id, ticket) {
547        return false;
548    }
549    last_attempt_at.remove(device_id);
550    clear_auto_connect_failure_state(device_id, failure_count, failure_backoff_until);
551    true
552}
553
554#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
555#[serde(rename_all = "camelCase")]
556pub(crate) struct BrowserDesiredPeer {
557    #[serde(default)]
558    pub(crate) connection_intent: DesiredPeerConnectionIntent,
559    pub(crate) device_id: String,
560    #[serde(default)]
561    pub(crate) node_id: Option<String>,
562    #[serde(default)]
563    pub(crate) ticket: Option<String>,
564    #[serde(default)]
565    pub(crate) online: bool,
566    #[serde(default)]
567    pub(crate) session_id: Option<String>,
568    #[serde(default)]
569    pub(crate) excluded_peers: Vec<String>,
570    #[serde(default)]
571    pub(crate) topology_role: Option<SparseTopologyRole>,
572    #[serde(default)]
573    pub(crate) topology_revision: Option<u64>,
574}
575
576/// Membership observation does not grant automatic dialing. Explicit commands
577/// still pass through the normal scoped connect/admission owner.
578#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize)]
579#[serde(rename_all = "lowercase")]
580pub(crate) enum DesiredPeerConnectionIntent {
581    #[default]
582    Automatic,
583    Observe,
584}
585
586/// Fleet observations and legacy dial candidates have independent bounds. One
587/// observed record cannot exceed the gateway's 64 KiB page bound. Keep the
588/// existing 100-candidate/256 KiB automatic bound, including in mixed inputs.
589fn parse_desired_peer_intents(peers_json: &str) -> anyhow::Result<Vec<BrowserDesiredPeer>> {
590    const AUTOMATIC_BYTES: usize = 256 * 1024;
591    const OBSERVATION_BYTES: usize = 64 * 1024;
592    const OBSERVATIONS: usize = 500;
593    if peers_json.len() > OBSERVATIONS * OBSERVATION_BYTES + AUTOMATIC_BYTES + 602 {
594        return Err(anyhow::anyhow!(
595            "desired-peer intent payload exceeds its bounded fleet envelope"
596        ));
597    }
598    let values: Vec<serde_json::Value> = serde_json::from_str(peers_json)
599        .map_err(|error| anyhow::anyhow!("invalid desired-peer intent payload: {error}"))?;
600    if values.len() > OBSERVATIONS + 100 {
601        return Err(anyhow::anyhow!(
602            "desired-peer intent payload exceeds 600 references"
603        ));
604    }
605    let mut observations = 0;
606    let mut automatic = 0;
607    let mut automatic_bytes = 2;
608    let mut peers = Vec::with_capacity(values.len());
609    for value in values {
610        let bytes = serde_json::to_vec(&value)?.len();
611        let peer: BrowserDesiredPeer = serde_json::from_value(value)
612            .map_err(|error| anyhow::anyhow!("invalid desired-peer intent: {error}"))?;
613        match peer.connection_intent {
614            DesiredPeerConnectionIntent::Observe => {
615                observations += 1;
616                if observations > OBSERVATIONS || bytes > OBSERVATION_BYTES {
617                    return Err(anyhow::anyhow!(
618                        "desired-peer observations exceed 500 records or 64 KiB per record"
619                    ));
620                }
621            }
622            DesiredPeerConnectionIntent::Automatic => {
623                automatic += 1;
624                automatic_bytes += bytes + usize::from(automatic > 1);
625                if automatic > 100 || automatic_bytes > AUTOMATIC_BYTES {
626                    return Err(anyhow::anyhow!(
627                        "automatic desired peers exceed 100 candidates or 256 KiB"
628                    ));
629                }
630            }
631        }
632        peers.push(peer);
633    }
634    if observations == 0 && peers_json.len() > AUTOMATIC_BYTES {
635        return Err(anyhow::anyhow!(
636            "automatic desired-peer payload exceeds 256 KiB"
637        ));
638    }
639    Ok(peers)
640}
641
642#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
643#[serde(rename_all = "lowercase")]
644pub(crate) enum SparseTopologyRole {
645    Active,
646    Backup,
647}
648
649#[derive(Debug, Default)]
650struct BrowserDesiredPeerMailbox {
651    revision: u64,
652    transfer_demands: super::transfer_peer_demand::TransferPeerDemands,
653    peers: std::collections::BTreeMap<String, BrowserDesiredPeer>,
654    pending_topology_revision: Option<u64>,
655    pending_ready_active_peers: std::collections::HashSet<String>,
656    settled_active: std::collections::BTreeMap<String, BrowserDesiredPeer>,
657    /// Devices whose authoritative live-route desire was explicitly withdrawn.
658    ///
659    /// Retiring the current connection is not sufficient: a revoked peer can
660    /// still race back in over a cached endpoint ticket before its own gateway
661    /// lifecycle observes the fatal close.  Keep the withdrawal beside the
662    /// revisioned roster authority so incoming transport admission can reject
663    /// that replacement generation.  A later online roster revision is the
664    /// only event that clears the fence.
665    withdrawn: std::collections::BTreeMap<String, Option<String>>,
666}
667
668impl BrowserDesiredPeerMailbox {
669    #[cfg(test)]
670    fn accept(&mut self, revision: u64, peers: Vec<BrowserDesiredPeer>) -> bool {
671        self.accept_with_withdrawn(revision, peers).is_some()
672    }
673
674    /// Accepts a full authoritative roster and returns peers whose live-route
675    /// desire was withdrawn by this revision. Persistent user avenues retain
676    /// offline devices in the roster, so both removal and an `online ->
677    /// offline` transition must retire any managed route for the old live
678    /// generation.
679    fn accept_with_withdrawn(
680        &mut self,
681        revision: u64,
682        peers: Vec<BrowserDesiredPeer>,
683    ) -> Option<Vec<BrowserDesiredPeer>> {
684        self.accept_update(revision, peers, None)
685    }
686
687    /// Apply one candidate-only reachability expiry. The exact missing peer is
688    /// removed from dial eligibility without becoming authoritatively
689    /// withdrawn; any other removal keeps the normal withdrawal semantics.
690    fn accept_with_observation_expired(
691        &mut self,
692        revision: u64,
693        peers: Vec<BrowserDesiredPeer>,
694        expired_device_id: &str,
695    ) -> Option<Vec<BrowserDesiredPeer>> {
696        let expired_device_id = expired_device_id.trim();
697        if expired_device_id.is_empty() {
698            return None;
699        }
700        self.accept_update(revision, peers, Some(expired_device_id))
701    }
702
703    fn accept_update(
704        &mut self,
705        revision: u64,
706        peers: Vec<BrowserDesiredPeer>,
707        observation_expired_device_id: Option<&str>,
708    ) -> Option<Vec<BrowserDesiredPeer>> {
709        if revision <= self.revision {
710            return None;
711        }
712        let mut peers = peers
713            .into_iter()
714            .filter_map(|mut peer| {
715                peer.device_id = peer.device_id.trim().to_string();
716                if peer.device_id.is_empty() {
717                    return None;
718                }
719                peer.node_id = peer
720                    .node_id
721                    .take()
722                    .map(|value| value.trim().to_string())
723                    .filter(|value| !value.is_empty());
724                peer.ticket = peer
725                    .ticket
726                    .take()
727                    .map(|value| value.trim().to_string())
728                    .filter(|value| !value.is_empty());
729                peer.session_id = peer
730                    .session_id
731                    .take()
732                    .map(|value| value.trim().to_string())
733                    .filter(|value| !value.is_empty());
734                peer.excluded_peers = peer
735                    .excluded_peers
736                    .into_iter()
737                    .map(|value| value.trim().to_ascii_lowercase())
738                    .filter(|value| !value.is_empty())
739                    .collect();
740                Some((peer.device_id.clone(), peer))
741            })
742            .collect::<std::collections::BTreeMap<_, _>>();
743        let topology_revision = peers
744            .values()
745            .filter_map(|peer| peer.topology_revision)
746            .max();
747        if topology_revision == self.pending_topology_revision {
748            self.pending_ready_active_peers.retain(|device_id| {
749                let Some(previous) = self.peers.get(device_id) else {
750                    return false;
751                };
752                peers.get(device_id).is_some_and(|current| {
753                    current.topology_role == Some(SparseTopologyRole::Active)
754                        && browser_peer_evidence(current) == browser_peer_evidence(previous)
755                })
756            });
757        } else {
758            self.pending_ready_active_peers.clear();
759            if topology_revision.is_none() {
760                self.settled_active.clear();
761            } else if !self.settled_active.is_empty() {
762                // A gateway lease overlaps only its immediately prior active
763                // set. Rapid revisions can make that set speculative. Keep
764                // exactly one bounded, Rust-proven settled generation instead
765                // and never accumulate intermediate generations.
766                peers.retain(|_, peer| peer.topology_role != Some(SparseTopologyRole::Backup));
767                for (device_id, settled) in self.settled_active.iter().take(4) {
768                    let still_authorized =
769                        peers.get(device_id).is_some_and(|current| current.online);
770                    let is_current_active = peers.get(device_id).is_some_and(|current| {
771                        current.topology_role == Some(SparseTopologyRole::Active)
772                    });
773                    if still_authorized && !is_current_active {
774                        let mut overlap = settled.clone();
775                        overlap.topology_role = Some(SparseTopologyRole::Backup);
776                        overlap.topology_revision = topology_revision;
777                        peers.insert(device_id.clone(), overlap);
778                    }
779                }
780            }
781        }
782        let active_ids = peers
783            .values()
784            .filter(|peer| peer.topology_role == Some(SparseTopologyRole::Active))
785            .map(|peer| peer.device_id.clone())
786            .collect::<std::collections::HashSet<_>>();
787        if !active_ids.is_empty() && active_ids.is_subset(&self.pending_ready_active_peers) {
788            peers.retain(|_, peer| peer.topology_role != Some(SparseTopologyRole::Backup));
789        }
790        let withdrawn = self
791            .peers
792            .values()
793            .filter(|peer| {
794                peer.online
795                    && observation_expired_device_id != Some(peer.device_id.as_str())
796                    && peers
797                        .get(&peer.device_id)
798                        .is_none_or(|current| !current.online)
799            })
800            .cloned()
801            .collect::<Vec<_>>();
802        for peer in &withdrawn {
803            self.withdrawn
804                .insert(peer.device_id.clone(), peer.node_id.clone());
805        }
806        for peer in peers.values() {
807            if peer.online {
808                self.withdrawn.remove(&peer.device_id);
809            }
810        }
811        self.revision = revision;
812        self.pending_topology_revision = topology_revision;
813        self.peers = peers;
814        Some(withdrawn)
815    }
816
817    /// Record the existing desired-peer owner's settled transport+admission
818    /// result. Once every active edge for this exact topology is ready, remove
819    /// overlap backups from the same authoritative mailbox and return them to
820    /// the caller for physical retirement.
821    fn acknowledge_topology_peer_ready(
822        &mut self,
823        device_id: &str,
824        topology_revision: Option<u64>,
825    ) -> Vec<BrowserDesiredPeer> {
826        let Some(topology_revision) = topology_revision else {
827            return Vec::new();
828        };
829        if self.pending_topology_revision != Some(topology_revision)
830            || !self.peers.get(device_id).is_some_and(|peer| {
831                peer.topology_revision == Some(topology_revision)
832                    && peer.topology_role == Some(SparseTopologyRole::Active)
833            })
834        {
835            return Vec::new();
836        }
837        self.pending_ready_active_peers
838            .insert(device_id.to_string());
839        let active_ids = self
840            .peers
841            .values()
842            .filter(|peer| peer.topology_role == Some(SparseTopologyRole::Active))
843            .map(|peer| peer.device_id.clone())
844            .collect::<std::collections::HashSet<_>>();
845        if active_ids.is_empty() || !active_ids.is_subset(&self.pending_ready_active_peers) {
846            return Vec::new();
847        }
848        self.settled_active = self
849            .peers
850            .iter()
851            .filter(|(_, peer)| peer.topology_role == Some(SparseTopologyRole::Active))
852            .map(|(device_id, peer)| (device_id.clone(), peer.clone()))
853            .take(4)
854            .collect();
855        let backups = self
856            .peers
857            .values()
858            .filter(|peer| peer.topology_role == Some(SparseTopologyRole::Backup))
859            .cloned()
860            .collect::<Vec<_>>();
861        for peer in &backups {
862            self.peers.remove(&peer.device_id);
863            self.withdrawn
864                .insert(peer.device_id.clone(), peer.node_id.clone());
865        }
866        backups
867    }
868
869    fn observe_topology_peer_not_ready(&mut self, device_id: &str, topology_revision: Option<u64>) {
870        if self.pending_topology_revision == topology_revision
871            && self.peers.get(device_id).is_some_and(|peer| {
872                peer.topology_revision == topology_revision
873                    && peer.topology_role == Some(SparseTopologyRole::Active)
874            })
875        {
876            self.pending_ready_active_peers.remove(device_id);
877        }
878    }
879
880    fn device_is_withdrawn(&self, device_id: &str) -> bool {
881        self.withdrawn.contains_key(device_id.trim())
882    }
883
884    fn withdrawn_device_for_node(&self, node_id: &str) -> Option<String> {
885        let node_id = node_id.trim();
886        if node_id.is_empty() {
887            return None;
888        }
889        self.withdrawn
890            .iter()
891            .find_map(|(device_id, withdrawn_node_id)| {
892                (withdrawn_node_id.as_deref() == Some(node_id)).then(|| device_id.clone())
893            })
894    }
895
896    fn current_online_device_for_node(&self, node_id: &str) -> Option<String> {
897        let node_id = node_id.trim();
898        if node_id.is_empty() {
899            return None;
900        }
901        self.peers.values().find_map(|peer| {
902            if !peer.online {
903                return None;
904            }
905            let ticket_node_id = peer.ticket.as_deref().and_then(|ticket| {
906                let (iroh_ticket, token_suffix) = crate::session_token::split_ticket(ticket);
907                let payload = token_suffix
908                    .and_then(|suffix| crate::session_token::decode_payload(iroh_ticket, suffix))?;
909                if payload.scope.as_str() != "user-device" {
910                    return None;
911                }
912                parse_endpoint_ticket(iroh_ticket)
913                    .ok()
914                    .map(|address| address.id.to_string())
915            });
916            let ticket_node_id = ticket_node_id.as_deref()?;
917            let resolved_node_id = match peer.node_id.as_deref() {
918                Some(directory) if directory != ticket_node_id => return None,
919                Some(directory) => directory,
920                None => ticket_node_id,
921            };
922            (resolved_node_id == node_id).then(|| peer.device_id.clone())
923        })
924    }
925
926    fn current_online_assignment_matches(
927        &self,
928        device_id: &str,
929        node_id: &str,
930        ticket: &str,
931        evidence: &str,
932    ) -> bool {
933        self.peers.get(device_id.trim()).is_some_and(|peer| {
934            peer.online
935                && (peer.connection_intent != DesiredPeerConnectionIntent::Observe
936                    || self.transfer_demands.active(&peer.device_id))
937                && peer.ticket.as_deref() == Some(ticket.trim())
938                && browser_peer_evidence(peer) == evidence
939                && self.current_online_device_for_node(node_id).as_deref()
940                    == Some(peer.device_id.as_str())
941        })
942    }
943}
944
945#[cfg(not(target_arch = "wasm32"))]
946#[derive(Debug, Default)]
947pub(crate) struct NativeExternalAutoConnectActorState {
948    active: bool,
949    generation: u64,
950    local_device_id: String,
951    mailbox: BrowserDesiredPeerMailbox,
952    peer_evidence: std::collections::HashMap<String, String>,
953    failure_count: std::collections::HashMap<String, u8>,
954    retry_not_before_ms: std::collections::HashMap<String, i64>,
955    non_initiator_not_before_ms: std::collections::HashMap<String, i64>,
956    scheduled_deadline_ms: Option<i64>,
957    schedule_epoch: u64,
958    reconcile_running: bool,
959    wake_pending: bool,
960    /// Recoverable transport closes reconcile only the affected desired peer.
961    /// A provider revision clears this scope and performs the normal full pass.
962    recovery_device_ids: std::collections::HashSet<String>,
963    /// Deduplicates close/accept/path-watcher observations for one physical
964    /// loss. A new provider revision clears this fence immediately.
965    recovery_suppressed_until_ms: std::collections::HashMap<String, i64>,
966}
967
968#[cfg(target_arch = "wasm32")]
969struct BrowserAutoConnectActorState {
970    /// Serializes demand mutations and idle retirement without borrowing the
971    /// browser mailbox across an await. Roster/lifecycle callbacks stay live.
972    demand_control: std::rc::Rc<tokio::sync::Mutex<()>>,
973    active: bool,
974    client: std::sync::Weak<Client>,
975    generation: u64,
976    local_device_id: String,
977    mailbox: BrowserDesiredPeerMailbox,
978    peer_evidence: std::collections::HashMap<String, String>,
979    failure_count: std::collections::HashMap<String, u8>,
980    retry_not_before_ms: std::collections::HashMap<String, i64>,
981    non_initiator_not_before_ms: std::collections::HashMap<String, i64>,
982    scheduled_deadline_ms: Option<i64>,
983    schedule_epoch: u64,
984    reconcile_running: bool,
985    wake_pending: bool,
986}
987
988#[cfg(target_arch = "wasm32")]
989thread_local! {
990    static BROWSER_AUTO_CONNECT_ACTORS: std::cell::RefCell<
991        std::collections::HashMap<usize, std::rc::Rc<std::cell::RefCell<BrowserAutoConnectActorState>>>
992    > = std::cell::RefCell::new(std::collections::HashMap::new());
993}
994
995fn browser_auto_connect_failure_backoff_ms(failure_count: u8) -> i64 {
996    500_i64
997        .saturating_mul(1_i64 << (failure_count as u32).min(4))
998        .min(8_000)
999}
1000
1001fn browser_peer_evidence(peer: &BrowserDesiredPeer) -> String {
1002    format!(
1003        "{}|{}|{}{}",
1004        peer.node_id.as_deref().unwrap_or_default(),
1005        peer.ticket
1006            .as_deref()
1007            .map(crate::session_token::token_fingerprint)
1008            .unwrap_or_default(),
1009        peer.session_id.as_deref().unwrap_or_default(),
1010        if peer.connection_intent == DesiredPeerConnectionIntent::Observe {
1011            "|observe"
1012        } else {
1013            ""
1014        },
1015    )
1016}
1017
1018fn desired_peer_evidence_is_current(
1019    mailbox: &BrowserDesiredPeerMailbox,
1020    peer: &BrowserDesiredPeer,
1021) -> bool {
1022    // Mailbox revisions describe the complete desired-peer set. An unrelated
1023    // peer update must not cancel an in-flight connection for this peer after
1024    // it has already established transport or admission. The peer's own
1025    // evidence is the typed cancellation input.
1026    mailbox
1027        .peers
1028        .get(&peer.device_id)
1029        .is_some_and(|current| browser_peer_evidence(current) == browser_peer_evidence(peer))
1030}
1031
1032#[cfg(target_arch = "wasm32")]
1033fn browser_actor_key(client: &Arc<Client>) -> usize {
1034    Arc::as_ptr(client) as usize
1035}
1036
1037impl Client {
1038    async fn with_transfer_peer_demands<T>(
1039        self: &Arc<Self>,
1040        expected_generation: Option<u64>,
1041        mutate: bool,
1042        operation: impl FnOnce(
1043            &mut super::transfer_peer_demand::TransferPeerDemands,
1044            u64,
1045        ) -> anyhow::Result<T>,
1046    ) -> anyhow::Result<T> {
1047        #[cfg(not(target_arch = "wasm32"))]
1048        let result = {
1049            let mut state = self.external_desired_peer_actor.lock().await;
1050            if !state.active
1051                || self
1052                    .auto_connect_generation
1053                    .load(std::sync::atomic::Ordering::SeqCst)
1054                    != state.generation
1055                || expected_generation.is_some_and(|generation| generation != state.generation)
1056            {
1057                anyhow::bail!("execution_suspended: transfer peer demand requires its current desired-peer session");
1058            }
1059            let generation = state.generation;
1060            let previously_requested = state.mailbox.transfer_demands.devices_with_requests();
1061            let result = operation(&mut state.mailbox.transfer_demands, generation)?;
1062            if mutate {
1063                let cooldown = state.retry_not_before_ms.clone();
1064                let local = state.local_device_id.clone();
1065                let mailbox = &mut state.mailbox;
1066                let newly_admitted = mailbox.transfer_demands.reconcile(
1067                    &mailbox.peers,
1068                    &cooldown,
1069                    &local,
1070                    now_millis_i64(),
1071                    |device| self.is_auto_connect_excluded(device),
1072                );
1073                if !state.recovery_device_ids.is_empty() {
1074                    state.recovery_device_ids.extend(newly_admitted);
1075                }
1076            }
1077            let retired: Vec<_> = previously_requested
1078                .into_iter()
1079                .filter(|device| !state.mailbox.transfer_demands.has_requests_for(device))
1080                .collect();
1081            (result, generation, retired)
1082        };
1083        #[cfg(not(target_arch = "wasm32"))]
1084        let result = {
1085            let (result, generation, retired) = result;
1086            for device in retired {
1087                self.retire_unused_native_transfer_peer(&device, generation)
1088                    .await;
1089            }
1090            result
1091        };
1092        #[cfg(target_arch = "wasm32")]
1093        let result = {
1094            let actor = BROWSER_AUTO_CONNECT_ACTORS
1095                .with(|actors| actors.borrow().get(&browser_actor_key(self)).cloned())
1096                .ok_or_else(|| {
1097                    anyhow::anyhow!(
1098                        "execution_suspended: transfer peer demand requires a desired-peer session"
1099                    )
1100                })?;
1101            let control = actor.borrow().demand_control.clone();
1102            let _control = if mutate {
1103                Some(control.lock().await)
1104            } else {
1105                None
1106            };
1107            let (result, generation, retired) = {
1108                let mut state = actor.borrow_mut();
1109                if !browser_actor_is_current(&state)
1110                    || expected_generation.is_some_and(|generation| generation != state.generation)
1111                {
1112                    anyhow::bail!("execution_suspended: transfer peer demand belongs to a retired desired-peer session");
1113                }
1114                let generation = state.generation;
1115                let previously_requested = state.mailbox.transfer_demands.devices_with_requests();
1116                let result = operation(&mut state.mailbox.transfer_demands, generation)?;
1117                if mutate {
1118                    let cooldown = state.retry_not_before_ms.clone();
1119                    let local = state.local_device_id.clone();
1120                    let mailbox = &mut state.mailbox;
1121                    let _ = mailbox.transfer_demands.reconcile(
1122                        &mailbox.peers,
1123                        &cooldown,
1124                        &local,
1125                        now_millis_i64(),
1126                        |device| self.is_auto_connect_excluded(device),
1127                    );
1128                }
1129                let retired: Vec<_> = previously_requested
1130                    .into_iter()
1131                    .filter(|device| !state.mailbox.transfer_demands.has_requests_for(device))
1132                    .collect();
1133                (result, generation, retired)
1134            };
1135            for device in retired {
1136                self.retire_unused_browser_transfer_peer(&actor, &device, generation)
1137                    .await;
1138            }
1139            result
1140        };
1141        if mutate {
1142            #[cfg(not(target_arch = "wasm32"))]
1143            {
1144                wake_native_external_auto_connect_boxed(self.clone()).await;
1145            }
1146            #[cfg(target_arch = "wasm32")]
1147            {
1148                self.wake_browser_auto_connect();
1149            }
1150        }
1151        Ok(result)
1152    }
1153
1154    pub async fn request_transfer_peer(
1155        self: &Arc<Self>,
1156        request: super::TransferPeerDemandRequest,
1157    ) -> anyhow::Result<super::TransferPeerDemandStatus> {
1158        let id = request.id.clone();
1159        let generation = self
1160            .with_transfer_peer_demands(None, true, |demands, generation| {
1161                demands.request(request)?;
1162                Ok(generation)
1163            })
1164            .await?;
1165        self.transfer_peer_demand_status(&id, generation)
1166            .await?
1167            .ok_or_else(|| {
1168                anyhow::anyhow!(
1169                    "execution_suspended: transfer peer demand was released during acceptance"
1170                )
1171            })
1172    }
1173
1174    pub async fn transfer_peer_demand_status(
1175        self: &Arc<Self>,
1176        id: &str,
1177        generation: u64,
1178    ) -> anyhow::Result<Option<super::TransferPeerDemandStatus>> {
1179        super::TransferPeerDemandRequest::validate_identity(id)?;
1180        self.with_transfer_peer_demands(Some(generation), false, |demands, generation| {
1181            Ok(demands.status(id, generation))
1182        })
1183        .await
1184    }
1185
1186    pub async fn release_transfer_peer(
1187        self: &Arc<Self>,
1188        id: &str,
1189        generation: u64,
1190    ) -> anyhow::Result<bool> {
1191        super::TransferPeerDemandRequest::validate_identity(id)?;
1192        self.with_transfer_peer_demands(
1193            Some(generation),
1194            true,
1195            |demands, _| Ok(demands.release(id)),
1196        )
1197        .await
1198    }
1199
1200    pub async fn set_transfer_peer_budget(
1201        self: &Arc<Self>,
1202        budget: usize,
1203        generation: u64,
1204    ) -> anyhow::Result<()> {
1205        self.with_transfer_peer_demands(Some(generation), true, |demands, _| {
1206            demands.set_budget(budget)
1207        })
1208        .await
1209    }
1210
1211    #[cfg(not(target_arch = "wasm32"))]
1212    async fn retire_unused_native_transfer_peer(self: &Arc<Self>, device: &str, generation: u64) {
1213        let records = self.resolve_peer_connection_records(device).await;
1214        for record in &records {
1215            let gate = self.managed_connect_gate(&record.connection_id).await;
1216            let _connect_guard = gate.lock().await;
1217            // Match the existing connect ordering: connect gate, then desired
1218            // owner. A new demand cannot be accepted during physical retirement.
1219            let state = self.external_desired_peer_actor.lock().await;
1220            if !state.active
1221                || state.generation != generation
1222                || self
1223                    .auto_connect_generation
1224                    .load(std::sync::atomic::Ordering::SeqCst)
1225                    != generation
1226                || state.mailbox.transfer_demands.has_requests_for(device)
1227                || !state.mailbox.peers.get(device).is_some_and(|peer| {
1228                    peer.connection_intent == DesiredPeerConnectionIntent::Observe
1229                })
1230            {
1231                return;
1232            }
1233            self.retire_unreferenced_transfer_physical_route(record, device, || {
1234                self.auto_connect_generation
1235                    .load(std::sync::atomic::Ordering::SeqCst)
1236                    == generation
1237            })
1238            .await;
1239        }
1240    }
1241
1242    #[cfg(target_arch = "wasm32")]
1243    async fn retire_unused_browser_transfer_peer(
1244        self: &Arc<Self>,
1245        actor: &std::rc::Rc<std::cell::RefCell<BrowserAutoConnectActorState>>,
1246        device: &str,
1247        generation: u64,
1248    ) {
1249        let allowed = || {
1250            actor.try_borrow().is_ok_and(|state| {
1251                browser_actor_is_current(&state)
1252                    && state.generation == generation
1253                    && !state.mailbox.transfer_demands.has_requests_for(device)
1254                    && state.mailbox.peers.get(device).is_some_and(|peer| {
1255                        peer.connection_intent == DesiredPeerConnectionIntent::Observe
1256                    })
1257            })
1258        };
1259        for record in self.resolve_peer_connection_records(device).await {
1260            let gate = self.managed_connect_gate(&record.connection_id).await;
1261            let _connect_guard = gate.lock().await;
1262            if !allowed() {
1263                return;
1264            }
1265            self.retire_unreferenced_transfer_physical_route(&record, device, allowed)
1266                .await;
1267        }
1268    }
1269
1270    async fn retire_unreferenced_transfer_physical_route(
1271        &self,
1272        record: &crate::connection_manager::ConnectionRecord,
1273        device: &str,
1274        allowed: impl Fn() -> bool,
1275    ) {
1276        let Some(_references) = self
1277            .connection_manager
1278            .guard_transfer_retirement(device)
1279            .await
1280        else {
1281            return;
1282        };
1283        let Some(endpoint) = record
1284            .endpoint_id
1285            .as_deref()
1286            .or(record.node_id.as_deref())
1287            .and_then(|id| id.parse::<iroh::EndpointId>().ok())
1288        else {
1289            return;
1290        };
1291        let Some(transport) = self.get_connection(endpoint).await else {
1292            return;
1293        };
1294        let stable_id = crate::transport_generation::for_connection(&transport);
1295        let node = self.iroh_node.read().await.as_ref().cloned();
1296        if let Some(node) = node {
1297            // The owner verdict is synchronous under the physical registry
1298            // lock, after every await. The existing close bridge owns cleanup.
1299            let _ = node
1300                .disconnect_with_reason_if_current_when(
1301                    endpoint,
1302                    stable_id,
1303                    crate::lifecycle_reason::REASON_TRANSFER_IDLE,
1304                    allowed,
1305                )
1306                .await;
1307        }
1308    }
1309
1310    async fn transfer_peer_demand_is_admitted(self: &Arc<Self>, device: &str) -> bool {
1311        self.with_transfer_peer_demands(None, false, |demands, _| Ok(demands.active(device)))
1312            .await
1313            .unwrap_or(false)
1314    }
1315}
1316
1317impl Client {
1318    #[cfg(not(target_arch = "wasm32"))]
1319    pub(crate) async fn native_external_auto_connect_owner_is_current(
1320        &self,
1321        user_id: &str,
1322        local_device_id: &str,
1323    ) -> bool {
1324        let expected = (
1325            user_id.trim().to_string(),
1326            local_device_id.trim().to_string(),
1327        );
1328        let state = self.external_desired_peer_actor.lock().await;
1329        let key = self
1330            .auto_connect_loop_key
1331            .lock()
1332            .unwrap_or_else(|poisoned| poisoned.into_inner());
1333        key.as_ref() == Some(&expected)
1334            && state.active
1335            && state.local_device_id == expected.1
1336            && self
1337                .auto_connect_generation
1338                .load(std::sync::atomic::Ordering::SeqCst)
1339                == state.generation
1340    }
1341
1342    /// Returns the explicitly withdrawn device bound to `remote_node_id`, if
1343    /// the active managed-session roster has retired that route.
1344    ///
1345    /// This is a read of the existing desired-peer owner, not a second
1346    /// admission registry.  Accepted transport handlers use it before
1347    /// projecting a replacement so cached peer tickets cannot resurrect a
1348    /// device after gateway revocation.
1349    pub(crate) async fn withdrawn_desired_device_for_node(
1350        &self,
1351        remote_node_id: &str,
1352    ) -> Option<String> {
1353        #[cfg(not(target_arch = "wasm32"))]
1354        {
1355            let state = self.external_desired_peer_actor.lock().await;
1356            if !state.active
1357                || self
1358                    .auto_connect_generation
1359                    .load(std::sync::atomic::Ordering::SeqCst)
1360                    != state.generation
1361            {
1362                return None;
1363            }
1364            state.mailbox.withdrawn_device_for_node(remote_node_id)
1365        }
1366        #[cfg(target_arch = "wasm32")]
1367        {
1368            let actor_key = self as *const Client as usize;
1369            BROWSER_AUTO_CONNECT_ACTORS.with(|actors| {
1370                let actor = actors.borrow().get(&actor_key).cloned()?;
1371                let state = actor.borrow();
1372                browser_actor_is_current(&state)
1373                    .then(|| state.mailbox.withdrawn_device_for_node(remote_node_id))
1374                    .flatten()
1375            })
1376        }
1377    }
1378
1379    /// Fences a claimed same-user device identity after its authoritative
1380    /// roster entry has been withdrawn.  Room/ticket peers are unaffected
1381    /// unless that exact device previously belonged to this managed device
1382    /// roster and was explicitly retired.
1383    pub(crate) async fn desired_device_is_withdrawn(&self, device_id: &str) -> bool {
1384        #[cfg(not(target_arch = "wasm32"))]
1385        {
1386            let state = self.external_desired_peer_actor.lock().await;
1387            state.active
1388                && self
1389                    .auto_connect_generation
1390                    .load(std::sync::atomic::Ordering::SeqCst)
1391                    == state.generation
1392                && state.mailbox.device_is_withdrawn(device_id)
1393        }
1394        #[cfg(target_arch = "wasm32")]
1395        {
1396            let actor_key = self as *const Client as usize;
1397            BROWSER_AUTO_CONNECT_ACTORS.with(|actors| {
1398                actors
1399                    .borrow()
1400                    .get(&actor_key)
1401                    .cloned()
1402                    .is_some_and(|actor| {
1403                        let state = actor.borrow();
1404                        browser_actor_is_current(&state)
1405                            && state.mailbox.device_is_withdrawn(device_id)
1406                    })
1407            })
1408        }
1409    }
1410
1411    #[cfg(target_arch = "wasm32")]
1412    pub(crate) fn current_desired_user_device_for_node_now(
1413        &self,
1414        remote_node_id: &str,
1415    ) -> Option<String> {
1416        let actor_key = self as *const Client as usize;
1417        BROWSER_AUTO_CONNECT_ACTORS.with(|actors| {
1418            let actor = actors.borrow().get(&actor_key).cloned()?;
1419            let state = actor.borrow();
1420            browser_actor_is_current(&state)
1421                .then(|| state.mailbox.current_online_device_for_node(remote_node_id))
1422                .flatten()
1423        })
1424    }
1425
1426    #[cfg(target_arch = "wasm32")]
1427    pub(crate) async fn current_desired_user_device_for_node(
1428        &self,
1429        remote_node_id: &str,
1430    ) -> Option<String> {
1431        self.current_desired_user_device_for_node_now(remote_node_id)
1432    }
1433
1434    #[cfg(not(target_arch = "wasm32"))]
1435    pub(super) async fn current_directory_reciprocal_credential(
1436        &self,
1437        remote_node_id: &str,
1438    ) -> Option<super::NativeRouteRepairCredential> {
1439        // Passive observed peers do not dial and therefore have no dial cache.
1440        // Read their reciprocal credential from the existing committed mailbox;
1441        // an offline, withdrawn or replaced directory entry cannot supply it.
1442        let state = self.external_desired_peer_actor.lock().await;
1443        if !state.active
1444            || self
1445                .auto_connect_generation
1446                .load(std::sync::atomic::Ordering::SeqCst)
1447                != state.generation
1448        {
1449            return None;
1450        }
1451        let device_id = state
1452            .mailbox
1453            .current_online_device_for_node(remote_node_id)?;
1454        let peer = state.mailbox.peers.get(&device_id)?;
1455        let (ticket, suffix) = crate::session_token::split_ticket(peer.ticket.as_deref()?);
1456        if parse_endpoint_ticket(ticket).ok()?.id.to_string() != remote_node_id {
1457            return None;
1458        }
1459        let suffix = suffix?;
1460        let payload = crate::session_token::decode_payload(ticket, suffix)?;
1461        if payload.scope.as_str() != "user-device"
1462            || payload.token.trim().is_empty()
1463            || payload
1464                .expires_at_ms
1465                .is_some_and(|expiry| expiry <= crate::session_token::now_unix_ms())
1466        {
1467            return None;
1468        }
1469        Some(super::NativeRouteRepairCredential {
1470            token: payload.token,
1471            token_payload: suffix.to_string(),
1472            scope: payload.scope,
1473            authoritative_device_id: device_id,
1474        })
1475    }
1476
1477    #[cfg(not(target_arch = "wasm32"))]
1478    pub(super) async fn current_demanded_user_device_assignment_matches(
1479        &self,
1480        device_id: &str,
1481        remote_node_id: &str,
1482        ticket: &str,
1483        evidence: &str,
1484    ) -> bool {
1485        // Read intent and directory under the same existing owner lock. A
1486        // paused/released demand or retired generation cannot elect a presenter.
1487        let state = self.external_desired_peer_actor.lock().await;
1488        state.active
1489            && self
1490                .auto_connect_generation
1491                .load(std::sync::atomic::Ordering::SeqCst)
1492                == state.generation
1493            && state.mailbox.transfer_demands.active(device_id)
1494            && state.mailbox.current_online_assignment_matches(
1495                device_id,
1496                remote_node_id,
1497                ticket,
1498                evidence,
1499            )
1500    }
1501
1502    pub(crate) async fn current_desired_user_device_assignment_matches(
1503        &self,
1504        device_id: &str,
1505        remote_node_id: &str,
1506        ticket: &str,
1507        evidence: &str,
1508    ) -> bool {
1509        #[cfg(not(target_arch = "wasm32"))]
1510        {
1511            let state = self.external_desired_peer_actor.lock().await;
1512            state.active
1513                && self
1514                    .auto_connect_generation
1515                    .load(std::sync::atomic::Ordering::SeqCst)
1516                    == state.generation
1517                && state.mailbox.current_online_assignment_matches(
1518                    device_id,
1519                    remote_node_id,
1520                    ticket,
1521                    evidence,
1522                )
1523        }
1524        #[cfg(target_arch = "wasm32")]
1525        {
1526            let actor_key = self as *const Client as usize;
1527            BROWSER_AUTO_CONNECT_ACTORS.with(|actors| {
1528                actors
1529                    .borrow()
1530                    .get(&actor_key)
1531                    .cloned()
1532                    .is_some_and(|actor| {
1533                        let state = actor.borrow();
1534                        browser_actor_is_current(&state)
1535                            && state.mailbox.current_online_assignment_matches(
1536                                device_id,
1537                                remote_node_id,
1538                                ticket,
1539                                evidence,
1540                            )
1541                    })
1542            })
1543        }
1544    }
1545}
1546
1547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1548enum BrowserDesiredPeerDecision {
1549    ObserveExisting,
1550    WaitUntil(i64),
1551    Dial,
1552}
1553
1554fn browser_desired_peer_decision(
1555    local_node_id: &str,
1556    remote_node_id: &str,
1557    transport_alive: bool,
1558    now_ms: i64,
1559    non_initiator_not_before_ms: Option<i64>,
1560) -> BrowserDesiredPeerDecision {
1561    if transport_alive {
1562        return BrowserDesiredPeerDecision::ObserveExisting;
1563    }
1564    if local_node_id > remote_node_id {
1565        return BrowserDesiredPeerDecision::Dial;
1566    }
1567
1568    let not_before = non_initiator_not_before_ms.unwrap_or_else(|| now_ms.saturating_add(3_000));
1569    if now_ms >= not_before {
1570        BrowserDesiredPeerDecision::Dial
1571    } else {
1572        BrowserDesiredPeerDecision::WaitUntil(not_before)
1573    }
1574}
1575
1576fn should_replace_browser_retry_deadline(current: Option<i64>, next: Option<i64>) -> bool {
1577    match (current, next) {
1578        (None, None) => false,
1579        (Some(_), None) | (None, Some(_)) => true,
1580        (Some(current), Some(next)) => next < current,
1581    }
1582}
1583
1584fn browser_reconcile_deadlines(
1585    outcome: BrowserPeerReconcileOutcome,
1586    failure_retry_deadline: Option<i64>,
1587) -> (Option<i64>, Option<i64>) {
1588    match outcome {
1589        BrowserPeerReconcileOutcome::Retry => (failure_retry_deadline, None),
1590        BrowserPeerReconcileOutcome::WakeAt(deadline) => (Some(deadline), None),
1591        BrowserPeerReconcileOutcome::WaitUntil(deadline) => (None, Some(deadline)),
1592        BrowserPeerReconcileOutcome::Stable | BrowserPeerReconcileOutcome::Ineligible => {
1593            (None, None)
1594        }
1595    }
1596}
1597
1598#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1599enum BrowserPeerReconcileOutcome {
1600    Stable,
1601    Ineligible,
1602    Retry,
1603    WakeAt(i64),
1604    WaitUntil(i64),
1605}
1606
1607#[cfg(target_arch = "wasm32")]
1608fn browser_actor_is_current(actor: &BrowserAutoConnectActorState) -> bool {
1609    actor.active
1610        && actor.client.upgrade().is_some_and(|client| {
1611            client
1612                .auto_connect_generation
1613                .load(std::sync::atomic::Ordering::SeqCst)
1614                == actor.generation
1615        })
1616}
1617
1618#[cfg(target_arch = "wasm32")]
1619fn browser_attempt_is_current(
1620    actor: &BrowserAutoConnectActorState,
1621    _revision: u64,
1622    peer: &BrowserDesiredPeer,
1623) -> bool {
1624    browser_actor_is_current(actor) && desired_peer_evidence_is_current(&actor.mailbox, peer)
1625}
1626
1627#[cfg(target_arch = "wasm32")]
1628fn deactivate_browser_actor(actor: &mut BrowserAutoConnectActorState) {
1629    actor.active = false;
1630    actor.mailbox.peers.clear();
1631    actor.mailbox.transfer_demands = Default::default();
1632    actor.peer_evidence.clear();
1633    actor.failure_count.clear();
1634    actor.retry_not_before_ms.clear();
1635    actor.non_initiator_not_before_ms.clear();
1636    actor.scheduled_deadline_ms = None;
1637    actor.schedule_epoch = actor.schedule_epoch.saturating_add(1);
1638    actor.wake_pending = false;
1639}
1640
1641#[cfg(target_arch = "wasm32")]
1642fn schedule_browser_auto_connect_retry(
1643    actor: &std::rc::Rc<std::cell::RefCell<BrowserAutoConnectActorState>>,
1644) {
1645    let (next_deadline, generation) = {
1646        let state = actor.borrow();
1647        if !browser_actor_is_current(&state) {
1648            return;
1649        }
1650        let next = state
1651            .retry_not_before_ms
1652            .values()
1653            .chain(state.non_initiator_not_before_ms.values())
1654            .copied()
1655            .min();
1656        (next, state.generation)
1657    };
1658
1659    let schedule = {
1660        let mut state = actor.borrow_mut();
1661        if !should_replace_browser_retry_deadline(state.scheduled_deadline_ms, next_deadline) {
1662            return;
1663        }
1664        state.schedule_epoch = state.schedule_epoch.saturating_add(1);
1665        state.scheduled_deadline_ms = next_deadline;
1666        next_deadline.map(|deadline| (deadline, state.schedule_epoch))
1667    };
1668
1669    let Some((deadline, epoch)) = schedule else {
1670        return;
1671    };
1672    let actor = actor.clone();
1673    wasm_bindgen_futures::spawn_local(async move {
1674        let delay_ms = deadline.saturating_sub(now_millis_i64()).max(0) as u64;
1675        gloo_timers::future::sleep(std::time::Duration::from_millis(delay_ms)).await;
1676        let should_wake = {
1677            let mut state = actor.borrow_mut();
1678            if !state.active
1679                || state.generation != generation
1680                || state.schedule_epoch != epoch
1681                || !browser_actor_is_current(&state)
1682            {
1683                false
1684            } else {
1685                state.scheduled_deadline_ms = None;
1686                true
1687            }
1688        };
1689        if should_wake {
1690            wake_browser_auto_connect_actor(actor);
1691        }
1692    });
1693}
1694
1695#[cfg(target_arch = "wasm32")]
1696fn wake_browser_auto_connect_actor(
1697    actor: std::rc::Rc<std::cell::RefCell<BrowserAutoConnectActorState>>,
1698) {
1699    let should_spawn = {
1700        let mut state = actor.borrow_mut();
1701        if !browser_actor_is_current(&state) {
1702            return;
1703        }
1704        if state.reconcile_running {
1705            state.wake_pending = true;
1706            false
1707        } else {
1708            state.reconcile_running = true;
1709            true
1710        }
1711    };
1712    if !should_spawn {
1713        return;
1714    }
1715
1716    wasm_bindgen_futures::spawn_local(async move {
1717        loop {
1718            run_browser_auto_connect_pass(actor.clone()).await;
1719            let run_again = {
1720                let mut state = actor.borrow_mut();
1721                if !browser_actor_is_current(&state) {
1722                    state.reconcile_running = false;
1723                    false
1724                } else if state.wake_pending {
1725                    state.wake_pending = false;
1726                    true
1727                } else {
1728                    state.reconcile_running = false;
1729                    false
1730                }
1731            };
1732            if !run_again {
1733                schedule_browser_auto_connect_retry(&actor);
1734                break;
1735            }
1736        }
1737    });
1738}
1739
1740#[cfg(target_arch = "wasm32")]
1741async fn reconcile_browser_desired_peer(
1742    actor: &std::rc::Rc<std::cell::RefCell<BrowserAutoConnectActorState>>,
1743    client: &Arc<Client>,
1744    generation: u64,
1745    revision: u64,
1746    local_device_id: &str,
1747    peer: &BrowserDesiredPeer,
1748    non_initiator_not_before_ms: Option<i64>,
1749) -> BrowserPeerReconcileOutcome {
1750    if client
1751        .auto_connect_generation
1752        .load(std::sync::atomic::Ordering::SeqCst)
1753        != generation
1754    {
1755        return BrowserPeerReconcileOutcome::Ineligible;
1756    }
1757    if client.is_app_execution_suspended() {
1758        return BrowserPeerReconcileOutcome::Ineligible;
1759    }
1760
1761    let parsed_ticket = peer.ticket.as_deref().and_then(|ticket| {
1762        let (iroh_ticket, _) = crate::session_token::split_ticket(ticket);
1763        parse_endpoint_ticket(iroh_ticket).ok()
1764    });
1765    let parsed_node_id = parsed_ticket.as_ref().map(|address| address.id.to_string());
1766    let binding_node_id = peer.node_id.as_deref().or(parsed_node_id.as_deref());
1767    if let Some(node_id) = binding_node_id {
1768        client.bind_node_device_id(node_id, &peer.device_id).await;
1769    }
1770
1771    if !actor
1772        .try_borrow()
1773        .is_ok_and(|state| browser_attempt_is_current(&state, revision, peer))
1774    {
1775        return BrowserPeerReconcileOutcome::Ineligible;
1776    }
1777    if !peer.online || peer.device_id == local_device_id {
1778        return BrowserPeerReconcileOutcome::Ineligible;
1779    }
1780    if remote_excludes_local_device(&peer.excluded_peers, local_device_id) {
1781        // `excludedPeers` is a durable auto-connect policy, not a transport
1782        // teardown command. A product may keep auto-connect disabled while the
1783        // user explicitly opens a session. Only the authenticated native
1784        // manual-disconnect notice (or an actual roster withdrawal) may retire
1785        // an already-admitted generation.
1786        return BrowserPeerReconcileOutcome::Ineligible;
1787    }
1788    if client.is_auto_connect_excluded(&peer.device_id) {
1789        return BrowserPeerReconcileOutcome::Ineligible;
1790    }
1791
1792    let Some(ticket) = peer.ticket.as_deref() else {
1793        return BrowserPeerReconcileOutcome::Ineligible;
1794    };
1795    let Some(endpoint_addr) = parsed_ticket else {
1796        return BrowserPeerReconcileOutcome::Ineligible;
1797    };
1798    let endpoint_id = endpoint_addr.id;
1799    let remote_node_id = endpoint_id.to_string();
1800    if peer
1801        .node_id
1802        .as_deref()
1803        .is_some_and(|node_id| node_id != remote_node_id)
1804    {
1805        return BrowserPeerReconcileOutcome::Ineligible;
1806    }
1807
1808    let Some(local_node_id) = client.current_node_id().await else {
1809        return BrowserPeerReconcileOutcome::WakeAt(now_millis_i64().saturating_add(250));
1810    };
1811    if local_node_id == remote_node_id {
1812        return BrowserPeerReconcileOutcome::Ineligible;
1813    }
1814
1815    client
1816        .reconcile_authoritative_device_node(&peer.device_id, &remote_node_id)
1817        .await;
1818    if !actor
1819        .try_borrow()
1820        .is_ok_and(|state| browser_attempt_is_current(&state, revision, peer))
1821    {
1822        return BrowserPeerReconcileOutcome::Ineligible;
1823    }
1824
1825    if peer.connection_intent == DesiredPeerConnectionIntent::Observe
1826        && !client
1827            .transfer_peer_demand_is_admitted(&peer.device_id)
1828            .await
1829    {
1830        return BrowserPeerReconcileOutcome::Ineligible;
1831    }
1832
1833    let transport_alive = client.is_connection_transport_alive(endpoint_id).await;
1834    match browser_desired_peer_decision(
1835        &local_node_id,
1836        &remote_node_id,
1837        transport_alive,
1838        now_millis_i64(),
1839        non_initiator_not_before_ms,
1840    ) {
1841        BrowserDesiredPeerDecision::WaitUntil(deadline) => {
1842            return BrowserPeerReconcileOutcome::WaitUntil(deadline);
1843        }
1844        BrowserDesiredPeerDecision::ObserveExisting | BrowserDesiredPeerDecision::Dial => {}
1845    }
1846
1847    let desired_peer_evidence = browser_peer_evidence(peer);
1848    let result = client
1849        .connect_desired_device(&peer.device_id, ticket, &desired_peer_evidence)
1850        .await;
1851    let (attempt_is_current, peer_was_withdrawn) = actor
1852        .try_borrow()
1853        .map(|state| {
1854            let actor_is_current = browser_actor_is_current(&state);
1855            (
1856                actor_is_current && browser_attempt_is_current(&state, revision, peer),
1857                superseded_desired_peer_was_withdrawn(
1858                    actor_is_current,
1859                    state.mailbox.revision,
1860                    revision,
1861                    state.mailbox.peers.contains_key(&peer.device_id),
1862                ),
1863            )
1864        })
1865        .unwrap_or((false, false));
1866    if !attempt_is_current {
1867        if peer_was_withdrawn {
1868            client
1869                .retire_withdrawn_desired_peer_connections(&peer.device_id, peer.node_id.as_deref())
1870                .await;
1871        }
1872        return BrowserPeerReconcileOutcome::Ineligible;
1873    }
1874    if client.is_auto_connect_excluded(&peer.device_id) {
1875        // This is a dialing preference, not a manual-disconnect command.
1876        // A concurrent explicit connect may share the completed transport.
1877        // Stop this automatic attempt without retiring that shared generation.
1878        return BrowserPeerReconcileOutcome::Ineligible;
1879    }
1880
1881    let Ok(result) = result else {
1882        return BrowserPeerReconcileOutcome::Retry;
1883    };
1884    if matches!(result.state.as_str(), "failed" | "closed")
1885        || !client.is_connection_transport_alive(endpoint_id).await
1886    {
1887        return BrowserPeerReconcileOutcome::Retry;
1888    }
1889
1890    let (iroh_ticket, token_suffix) = crate::session_token::split_ticket(ticket);
1891    if let Some(token) = token_suffix.and_then(|suffix| {
1892        crate::session_token::decode_payload(iroh_ticket, suffix).map(|payload| payload.token)
1893    }) {
1894        let has_proof = client
1895            .has_current_remote_session_admission_proof(&result.connection_id, endpoint_id, &token)
1896            .await;
1897        // An explicit product scope supersedes the provider user-device token
1898        // on this shared physical route. `connect_desired_device` substitutes
1899        // Rust's cached scoped intent and reports the host-approved scope.
1900        if !has_proof && result.approved_scope.as_deref().is_none_or(str::is_empty) {
1901            return BrowserPeerReconcileOutcome::Retry;
1902        }
1903    }
1904
1905    BrowserPeerReconcileOutcome::Stable
1906}
1907
1908#[cfg(target_arch = "wasm32")]
1909async fn run_browser_auto_connect_pass(
1910    actor: std::rc::Rc<std::cell::RefCell<BrowserAutoConnectActorState>>,
1911) {
1912    let (client, generation, revision, local_device_id, peers) = {
1913        let mut state = actor.borrow_mut();
1914        if !browser_actor_is_current(&state) {
1915            return;
1916        }
1917        let Some(client) = state.client.upgrade() else {
1918            return;
1919        };
1920        let cooldown = state.retry_not_before_ms.clone();
1921        let local = state.local_device_id.clone();
1922        let mailbox = &mut state.mailbox;
1923        mailbox.transfer_demands.reconcile(
1924            &mailbox.peers,
1925            &cooldown,
1926            &local,
1927            now_millis_i64(),
1928            |device| client.is_auto_connect_excluded(device),
1929        );
1930        (
1931            client,
1932            state.generation,
1933            state.mailbox.revision,
1934            state.local_device_id.clone(),
1935            state.mailbox.peers.values().cloned().collect::<Vec<_>>(),
1936        )
1937    };
1938
1939    for peer in peers {
1940        let (retry_not_before, non_initiator_not_before) = {
1941            let state = actor.borrow();
1942            if !browser_attempt_is_current(&state, revision, &peer) {
1943                continue;
1944            }
1945            (
1946                state.retry_not_before_ms.get(&peer.device_id).copied(),
1947                state
1948                    .non_initiator_not_before_ms
1949                    .get(&peer.device_id)
1950                    .copied(),
1951            )
1952        };
1953        let now = now_millis_i64();
1954        if retry_not_before.is_some_and(|deadline| now < deadline) {
1955            continue;
1956        }
1957
1958        let outcome = reconcile_browser_desired_peer(
1959            &actor,
1960            &client,
1961            generation,
1962            revision,
1963            &local_device_id,
1964            &peer,
1965            non_initiator_not_before,
1966        )
1967        .await;
1968        let backups_to_retire = {
1969            let mut state = actor.borrow_mut();
1970            if !browser_attempt_is_current(&state, revision, &peer) {
1971                continue;
1972            }
1973            let backups = if outcome == BrowserPeerReconcileOutcome::Stable {
1974                state
1975                    .mailbox
1976                    .acknowledge_topology_peer_ready(&peer.device_id, peer.topology_revision)
1977            } else {
1978                state
1979                    .mailbox
1980                    .observe_topology_peer_not_ready(&peer.device_id, peer.topology_revision);
1981                Vec::new()
1982            };
1983            match outcome {
1984                BrowserPeerReconcileOutcome::Stable | BrowserPeerReconcileOutcome::Ineligible => {
1985                    state.failure_count.remove(&peer.device_id);
1986                    state.retry_not_before_ms.remove(&peer.device_id);
1987                    state.non_initiator_not_before_ms.remove(&peer.device_id);
1988                }
1989                BrowserPeerReconcileOutcome::Retry => {
1990                    if state.mailbox.transfer_demands.active(&peer.device_id) {
1991                        state.mailbox.transfer_demands.yield_failed(&peer.device_id);
1992                        state.wake_pending = true;
1993                    }
1994                    let count = state
1995                        .failure_count
1996                        .entry(peer.device_id.clone())
1997                        .or_insert(0);
1998                    *count = count.saturating_add(1).min(10);
1999                    let deadline = now_millis_i64()
2000                        .saturating_add(browser_auto_connect_failure_backoff_ms(*count));
2001                    let (retry_deadline, non_initiator_deadline) =
2002                        browser_reconcile_deadlines(outcome, Some(deadline));
2003                    match retry_deadline {
2004                        Some(deadline) => {
2005                            state
2006                                .retry_not_before_ms
2007                                .insert(peer.device_id.clone(), deadline);
2008                        }
2009                        None => {
2010                            state.retry_not_before_ms.remove(&peer.device_id);
2011                        }
2012                    }
2013                    match non_initiator_deadline {
2014                        Some(deadline) => {
2015                            state
2016                                .non_initiator_not_before_ms
2017                                .insert(peer.device_id.clone(), deadline);
2018                        }
2019                        None => {
2020                            state.non_initiator_not_before_ms.remove(&peer.device_id);
2021                        }
2022                    }
2023                }
2024                BrowserPeerReconcileOutcome::WakeAt(deadline) => {
2025                    state
2026                        .retry_not_before_ms
2027                        .insert(peer.device_id.clone(), deadline);
2028                    state.non_initiator_not_before_ms.remove(&peer.device_id);
2029                }
2030                BrowserPeerReconcileOutcome::WaitUntil(deadline) => {
2031                    state.retry_not_before_ms.remove(&peer.device_id);
2032                    state
2033                        .non_initiator_not_before_ms
2034                        .insert(peer.device_id.clone(), deadline);
2035                }
2036            }
2037            backups
2038        };
2039        for backup in backups_to_retire {
2040            client
2041                .retire_withdrawn_desired_peer_connections(
2042                    &backup.device_id,
2043                    backup.node_id.as_deref(),
2044                )
2045                .await;
2046        }
2047    }
2048}
2049
2050#[cfg(not(target_arch = "wasm32"))]
2051async fn native_external_attempt_is_current(
2052    client: &Arc<Client>,
2053    generation: u64,
2054    _revision: u64,
2055    peer: &BrowserDesiredPeer,
2056) -> bool {
2057    if client
2058        .auto_connect_generation
2059        .load(std::sync::atomic::Ordering::SeqCst)
2060        != generation
2061    {
2062        return false;
2063    }
2064    let state = client.external_desired_peer_actor.lock().await;
2065    state.active
2066        && state.generation == generation
2067        && desired_peer_evidence_is_current(&state.mailbox, peer)
2068}
2069
2070#[cfg(not(target_arch = "wasm32"))]
2071async fn reconcile_native_external_desired_peer(
2072    client: &Arc<Client>,
2073    generation: u64,
2074    revision: u64,
2075    local_device_id: &str,
2076    peer: &BrowserDesiredPeer,
2077    non_initiator_not_before_ms: Option<i64>,
2078) -> BrowserPeerReconcileOutcome {
2079    if !native_external_attempt_is_current(client, generation, revision, peer).await
2080        || client.is_app_execution_suspended()
2081    {
2082        return BrowserPeerReconcileOutcome::Ineligible;
2083    }
2084
2085    let parsed_ticket = peer.ticket.as_deref().and_then(|ticket| {
2086        let (iroh_ticket, _) = crate::session_token::split_ticket(ticket);
2087        parse_endpoint_ticket(iroh_ticket).ok()
2088    });
2089    let parsed_node_id = parsed_ticket.as_ref().map(|address| address.id.to_string());
2090    let binding_node_id = peer.node_id.as_deref().or(parsed_node_id.as_deref());
2091    if let Some(node_id) = binding_node_id {
2092        client.bind_node_device_id(node_id, &peer.device_id).await;
2093    }
2094
2095    if !peer.online || peer.device_id == local_device_id {
2096        return BrowserPeerReconcileOutcome::Ineligible;
2097    }
2098    if remote_excludes_local_device(&peer.excluded_peers, local_device_id) {
2099        // Roster exclusion suppresses automatic dialing only. It cannot
2100        // invalidate an explicit, authenticated session that shares the same
2101        // physical generation.
2102        return BrowserPeerReconcileOutcome::Ineligible;
2103    }
2104    if client.is_auto_connect_excluded(&peer.device_id) {
2105        return BrowserPeerReconcileOutcome::Ineligible;
2106    }
2107
2108    let Some(ticket) = peer.ticket.as_deref() else {
2109        return BrowserPeerReconcileOutcome::Ineligible;
2110    };
2111    let Some(endpoint_addr) = parsed_ticket else {
2112        return BrowserPeerReconcileOutcome::Ineligible;
2113    };
2114    let endpoint_id = endpoint_addr.id;
2115    let remote_node_id = endpoint_id.to_string();
2116    if peer
2117        .node_id
2118        .as_deref()
2119        .is_some_and(|node_id| node_id != remote_node_id)
2120    {
2121        return BrowserPeerReconcileOutcome::Ineligible;
2122    }
2123
2124    let Some(local_node_id) = client.current_node_id().await else {
2125        return BrowserPeerReconcileOutcome::WakeAt(now_millis_i64().saturating_add(250));
2126    };
2127    if local_node_id == remote_node_id {
2128        return BrowserPeerReconcileOutcome::Ineligible;
2129    }
2130
2131    client
2132        .reconcile_authoritative_device_node(&peer.device_id, &remote_node_id)
2133        .await;
2134    if !native_external_attempt_is_current(client, generation, revision, peer).await {
2135        return BrowserPeerReconcileOutcome::Ineligible;
2136    }
2137
2138    if peer.connection_intent == DesiredPeerConnectionIntent::Observe
2139        && !client
2140            .transfer_peer_demand_is_admitted(&peer.device_id)
2141            .await
2142    {
2143        return BrowserPeerReconcileOutcome::Ineligible;
2144    }
2145
2146    let transport_alive = client.is_connection_transport_alive(endpoint_id).await;
2147    match browser_desired_peer_decision(
2148        &local_node_id,
2149        &remote_node_id,
2150        transport_alive,
2151        now_millis_i64(),
2152        non_initiator_not_before_ms,
2153    ) {
2154        BrowserDesiredPeerDecision::WaitUntil(deadline) => {
2155            return BrowserPeerReconcileOutcome::WaitUntil(deadline);
2156        }
2157        BrowserDesiredPeerDecision::ObserveExisting | BrowserDesiredPeerDecision::Dial => {}
2158    }
2159
2160    let desired_peer_evidence = browser_peer_evidence(peer);
2161    let result = client
2162        .connect_desired_device(&peer.device_id, ticket, &desired_peer_evidence)
2163        .await;
2164    let transport_stable_id = client
2165        .get_connection(endpoint_id)
2166        .await
2167        .map(|connection| crate::transport_generation::for_connection(&connection));
2168    if let Some(transport_stable_id) = transport_stable_id {
2169        let connection_id = Client::deterministic_connection_id(&local_node_id, &remote_node_id);
2170        let _ = client
2171            .maybe_start_offline_proof_for_transport(
2172                &connection_id,
2173                endpoint_id,
2174                transport_stable_id,
2175            )
2176            .await;
2177    }
2178    if !native_external_attempt_is_current(client, generation, revision, peer).await {
2179        let peer_was_withdrawn = {
2180            let state = client.external_desired_peer_actor.lock().await;
2181            let actor_is_current = state.active && state.generation == generation;
2182            superseded_desired_peer_was_withdrawn(
2183                actor_is_current,
2184                state.mailbox.revision,
2185                revision,
2186                state.mailbox.peers.contains_key(&peer.device_id),
2187            )
2188        };
2189        if peer_was_withdrawn {
2190            client
2191                .retire_withdrawn_desired_peer_connections(&peer.device_id, peer.node_id.as_deref())
2192                .await;
2193        }
2194        return BrowserPeerReconcileOutcome::Ineligible;
2195    }
2196    if client.is_auto_connect_excluded(&peer.device_id) {
2197        // This is a dialing preference, not a manual-disconnect command.
2198        // A concurrent explicit connect may share the completed transport.
2199        // Stop this automatic attempt without retiring that shared generation.
2200        return BrowserPeerReconcileOutcome::Ineligible;
2201    }
2202
2203    let Ok(result) = result else {
2204        return BrowserPeerReconcileOutcome::Retry;
2205    };
2206    if matches!(result.state.as_str(), "failed" | "closed")
2207        || !client.is_connection_transport_alive(endpoint_id).await
2208    {
2209        return BrowserPeerReconcileOutcome::Retry;
2210    }
2211
2212    let (iroh_ticket, token_suffix) = crate::session_token::split_ticket(ticket);
2213    if let Some(token) = token_suffix.and_then(|suffix| {
2214        crate::session_token::decode_payload(iroh_ticket, suffix).map(|payload| payload.token)
2215    }) {
2216        let has_proof = client
2217            .has_current_remote_session_admission_proof(&result.connection_id, endpoint_id, &token)
2218            .await;
2219        if !has_proof && result.approved_scope.as_deref().is_none_or(str::is_empty) {
2220            return BrowserPeerReconcileOutcome::Retry;
2221        }
2222    }
2223
2224    BrowserPeerReconcileOutcome::Stable
2225}
2226
2227#[cfg(not(target_arch = "wasm32"))]
2228async fn run_native_external_auto_connect_pass(client: Arc<Client>) {
2229    let (generation, revision, local_device_id, peers, recovery_scoped) = {
2230        let mut state = client.external_desired_peer_actor.lock().await;
2231        if !state.active
2232            || client
2233                .auto_connect_generation
2234                .load(std::sync::atomic::Ordering::SeqCst)
2235                != state.generation
2236        {
2237            return;
2238        }
2239        let cooldown = state.retry_not_before_ms.clone();
2240        let local = state.local_device_id.clone();
2241        let mailbox = &mut state.mailbox;
2242        let newly_admitted = mailbox.transfer_demands.reconcile(
2243            &mailbox.peers,
2244            &cooldown,
2245            &local,
2246            now_millis_i64(),
2247            |device| client.is_auto_connect_excluded(device),
2248        );
2249        let recovery_device_ids = std::mem::take(&mut state.recovery_device_ids);
2250        let recovery_scoped = !recovery_device_ids.is_empty();
2251        let peers = state
2252            .mailbox
2253            .peers
2254            .values()
2255            .filter(|peer| {
2256                recovery_device_ids.is_empty()
2257                    || recovery_device_ids.contains(&peer.device_id)
2258                    || newly_admitted.contains(&peer.device_id)
2259            })
2260            .cloned()
2261            .collect::<Vec<_>>();
2262        (
2263            state.generation,
2264            state.mailbox.revision,
2265            state.local_device_id.clone(),
2266            peers,
2267            recovery_scoped,
2268        )
2269    };
2270
2271    for peer in peers {
2272        let (retry_not_before, non_initiator_not_before) = {
2273            let state = client.external_desired_peer_actor.lock().await;
2274            if !state.active
2275                || state.generation != generation
2276                || state.mailbox.revision != revision
2277                || !state
2278                    .mailbox
2279                    .peers
2280                    .get(&peer.device_id)
2281                    .is_some_and(|current| {
2282                        browser_peer_evidence(current) == browser_peer_evidence(&peer)
2283                    })
2284            {
2285                continue;
2286            }
2287            (
2288                state.retry_not_before_ms.get(&peer.device_id).copied(),
2289                state
2290                    .non_initiator_not_before_ms
2291                    .get(&peer.device_id)
2292                    .copied(),
2293            )
2294        };
2295        let now = now_millis_i64();
2296        if retry_not_before.is_some_and(|deadline| now < deadline) {
2297            continue;
2298        }
2299
2300        let outcome = reconcile_native_external_desired_peer(
2301            &client,
2302            generation,
2303            revision,
2304            &local_device_id,
2305            &peer,
2306            non_initiator_not_before,
2307        )
2308        .await;
2309        if recovery_scoped {
2310            eprintln!(
2311                "[pluto-rtc][auto-connect][recovery-pass] device_id={} outcome={:?}",
2312                peer.device_id, outcome,
2313            );
2314        }
2315        let backups_to_retire = {
2316            let mut state = client.external_desired_peer_actor.lock().await;
2317            if !state.active
2318                || state.generation != generation
2319                || state.mailbox.revision != revision
2320                || !state
2321                    .mailbox
2322                    .peers
2323                    .get(&peer.device_id)
2324                    .is_some_and(|current| {
2325                        browser_peer_evidence(current) == browser_peer_evidence(&peer)
2326                    })
2327            {
2328                continue;
2329            }
2330            let backups = if outcome == BrowserPeerReconcileOutcome::Stable {
2331                state
2332                    .mailbox
2333                    .acknowledge_topology_peer_ready(&peer.device_id, peer.topology_revision)
2334            } else {
2335                state
2336                    .mailbox
2337                    .observe_topology_peer_not_ready(&peer.device_id, peer.topology_revision);
2338                Vec::new()
2339            };
2340            match outcome {
2341                BrowserPeerReconcileOutcome::Stable | BrowserPeerReconcileOutcome::Ineligible => {
2342                    state.failure_count.remove(&peer.device_id);
2343                    state.retry_not_before_ms.remove(&peer.device_id);
2344                    state.non_initiator_not_before_ms.remove(&peer.device_id);
2345                }
2346                BrowserPeerReconcileOutcome::Retry => {
2347                    if state.mailbox.transfer_demands.active(&peer.device_id) {
2348                        state.mailbox.transfer_demands.yield_failed(&peer.device_id);
2349                        state.wake_pending = true;
2350                    }
2351                    let count = state
2352                        .failure_count
2353                        .entry(peer.device_id.clone())
2354                        .or_insert(0);
2355                    *count = count.saturating_add(1).min(10);
2356                    let deadline = now_millis_i64()
2357                        .saturating_add(browser_auto_connect_failure_backoff_ms(*count));
2358                    let (retry_deadline, non_initiator_deadline) =
2359                        browser_reconcile_deadlines(outcome, Some(deadline));
2360                    match retry_deadline {
2361                        Some(deadline) => {
2362                            state
2363                                .retry_not_before_ms
2364                                .insert(peer.device_id.clone(), deadline);
2365                        }
2366                        None => {
2367                            state.retry_not_before_ms.remove(&peer.device_id);
2368                        }
2369                    }
2370                    match non_initiator_deadline {
2371                        Some(deadline) => {
2372                            state
2373                                .non_initiator_not_before_ms
2374                                .insert(peer.device_id.clone(), deadline);
2375                        }
2376                        None => {
2377                            state.non_initiator_not_before_ms.remove(&peer.device_id);
2378                        }
2379                    }
2380                    // A close-triggered pass is deliberately scoped to the
2381                    // affected durable device. Preserve that scope across its
2382                    // bounded retry: otherwise the timer below wakes an empty
2383                    // recovery set and re-runs every unrelated desired peer,
2384                    // turning one physical loss into mesh-wide transport churn.
2385                    if recovery_scoped {
2386                        state.recovery_device_ids.insert(peer.device_id.clone());
2387                    }
2388                }
2389                BrowserPeerReconcileOutcome::WakeAt(deadline) => {
2390                    state
2391                        .retry_not_before_ms
2392                        .insert(peer.device_id.clone(), deadline);
2393                    state.non_initiator_not_before_ms.remove(&peer.device_id);
2394                    if recovery_scoped {
2395                        state.recovery_device_ids.insert(peer.device_id.clone());
2396                    }
2397                }
2398                BrowserPeerReconcileOutcome::WaitUntil(deadline) => {
2399                    state.retry_not_before_ms.remove(&peer.device_id);
2400                    state
2401                        .non_initiator_not_before_ms
2402                        .insert(peer.device_id.clone(), deadline);
2403                    if recovery_scoped {
2404                        state.recovery_device_ids.insert(peer.device_id.clone());
2405                    }
2406                }
2407            }
2408            backups
2409        };
2410        for backup in backups_to_retire {
2411            client
2412                .retire_withdrawn_desired_peer_connections(
2413                    &backup.device_id,
2414                    backup.node_id.as_deref(),
2415                )
2416                .await;
2417        }
2418    }
2419}
2420
2421#[cfg(not(target_arch = "wasm32"))]
2422async fn schedule_native_external_auto_connect_retry(client: Arc<Client>) {
2423    let schedule = {
2424        let mut state = client.external_desired_peer_actor.lock().await;
2425        if !state.active
2426            || client
2427                .auto_connect_generation
2428                .load(std::sync::atomic::Ordering::SeqCst)
2429                != state.generation
2430        {
2431            return;
2432        }
2433        let next = state
2434            .retry_not_before_ms
2435            .values()
2436            .chain(state.non_initiator_not_before_ms.values())
2437            .copied()
2438            .min();
2439        if !should_replace_browser_retry_deadline(state.scheduled_deadline_ms, next) {
2440            return;
2441        }
2442        state.schedule_epoch = state.schedule_epoch.saturating_add(1);
2443        state.scheduled_deadline_ms = next;
2444        next.map(|deadline| (deadline, state.schedule_epoch, state.generation))
2445    };
2446    let Some((deadline, epoch, generation)) = schedule else {
2447        return;
2448    };
2449    tokio::spawn(async move {
2450        let delay_ms = deadline.saturating_sub(now_millis_i64()).max(0) as u64;
2451        tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
2452        let should_wake = {
2453            let mut state = client.external_desired_peer_actor.lock().await;
2454            if !state.active
2455                || state.generation != generation
2456                || state.schedule_epoch != epoch
2457                || client
2458                    .auto_connect_generation
2459                    .load(std::sync::atomic::Ordering::SeqCst)
2460                    != generation
2461            {
2462                false
2463            } else {
2464                state.scheduled_deadline_ms = None;
2465                true
2466            }
2467        };
2468        if should_wake {
2469            let _ = wake_native_external_auto_connect_boxed(client).await;
2470        }
2471    });
2472}
2473
2474#[cfg(not(target_arch = "wasm32"))]
2475fn wake_native_external_auto_connect_boxed(
2476    client: Arc<Client>,
2477) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>> {
2478    Box::pin(async move {
2479        let should_spawn = {
2480            let mut state = client.external_desired_peer_actor.lock().await;
2481            if !state.active
2482                || client
2483                    .auto_connect_generation
2484                    .load(std::sync::atomic::Ordering::SeqCst)
2485                    != state.generation
2486            {
2487                return false;
2488            }
2489            if state.reconcile_running {
2490                state.wake_pending = true;
2491                false
2492            } else {
2493                state.reconcile_running = true;
2494                true
2495            }
2496        };
2497        if !should_spawn {
2498            return true;
2499        }
2500        tokio::spawn(async move {
2501            loop {
2502                run_native_external_auto_connect_pass(client.clone()).await;
2503                let run_again = {
2504                    let mut state = client.external_desired_peer_actor.lock().await;
2505                    if !state.active
2506                        || client
2507                            .auto_connect_generation
2508                            .load(std::sync::atomic::Ordering::SeqCst)
2509                            != state.generation
2510                    {
2511                        state.reconcile_running = false;
2512                        false
2513                    } else if state.wake_pending {
2514                        state.wake_pending = false;
2515                        true
2516                    } else {
2517                        state.reconcile_running = false;
2518                        false
2519                    }
2520                };
2521                if !run_again {
2522                    schedule_native_external_auto_connect_retry(client.clone()).await;
2523                    break;
2524                }
2525            }
2526        });
2527        true
2528    })
2529}
2530
2531#[cfg(not(target_arch = "wasm32"))]
2532impl Client {
2533    /// Test-harness diagnostic for the provider-neutral desired-peer mailbox.
2534    ///
2535    /// Tickets are never returned. Only their endpoint identity and a one-way
2536    /// fingerprint are exposed so a production-shaped local test can prove
2537    /// that a gateway replacement revision crossed the host boundary.
2538    #[doc(hidden)]
2539    pub async fn external_desired_peer_debug_snapshot(&self) -> serde_json::Value {
2540        let state = self.external_desired_peer_actor.lock().await;
2541        let mut ready_active_peers = state
2542            .mailbox
2543            .pending_ready_active_peers
2544            .iter()
2545            .cloned()
2546            .collect::<Vec<_>>();
2547        ready_active_peers.sort();
2548        let peers = state
2549            .mailbox
2550            .peers
2551            .values()
2552            .map(|peer| {
2553                let ticket_node_id = peer.ticket.as_deref().and_then(|ticket| {
2554                    let (iroh_ticket, _) = crate::session_token::split_ticket(ticket);
2555                    parse_endpoint_ticket(iroh_ticket)
2556                        .ok()
2557                        .map(|address| address.id.to_string())
2558                });
2559                serde_json::json!({
2560                    "deviceId": peer.device_id,
2561                    "nodeId": peer.node_id,
2562                    "ticketNodeId": ticket_node_id,
2563                    "ticketFingerprint": peer.ticket.as_deref()
2564                        .map(crate::session_token::token_fingerprint),
2565                    "online": peer.online,
2566                    "sessionId": peer.session_id,
2567                })
2568            })
2569            .collect::<Vec<_>>();
2570        serde_json::json!({
2571            "active": state.active,
2572            "generation": state.generation,
2573            "revision": state.mailbox.revision,
2574            "reconcileRunning": state.reconcile_running,
2575            "wakePending": state.wake_pending,
2576            "scheduledDeadlineMs": state.scheduled_deadline_ms,
2577            "lastFailureCounts": state.failure_count,
2578            "retryNotBeforeMs": state.retry_not_before_ms,
2579            "topologyRevision": state.mailbox.pending_topology_revision,
2580            "readyActivePeers": ready_active_peers,
2581            "backupPeerCount": state.mailbox.peers.values()
2582                .filter(|peer| peer.topology_role == Some(SparseTopologyRole::Backup))
2583                .count(),
2584            "peers": peers,
2585        })
2586    }
2587
2588    pub async fn start_external_auto_connect(
2589        self: &Arc<Self>,
2590        user_id: String,
2591        local_device_id: String,
2592    ) -> anyhow::Result<()> {
2593        let user_id = user_id.trim().to_string();
2594        let local_device_id = local_device_id.trim().to_string();
2595        if user_id.is_empty() || local_device_id.is_empty() {
2596            return Err(anyhow::anyhow!(
2597                "external auto-connect requires non-empty user and local device ids"
2598            ));
2599        }
2600        let key = (user_id.clone(), local_device_id.clone());
2601        let duplicate_start = {
2602            let state = self.external_desired_peer_actor.lock().await;
2603            let guard = self
2604                .auto_connect_loop_key
2605                .lock()
2606                .unwrap_or_else(|poisoned| poisoned.into_inner());
2607            guard.as_ref() == Some(&key)
2608                && state.active
2609                && state.local_device_id == local_device_id
2610                && self
2611                    .auto_connect_generation
2612                    .load(std::sync::atomic::Ordering::SeqCst)
2613                    == state.generation
2614        };
2615        if duplicate_start {
2616            self.wake_native_external_auto_connect().await;
2617            return Ok(());
2618        }
2619        let generation = {
2620            let mut guard = self
2621                .auto_connect_loop_key
2622                .lock()
2623                .unwrap_or_else(|poisoned| poisoned.into_inner());
2624            self.known_device_ids_by_node
2625                .write()
2626                .unwrap_or_else(|poisoned| poisoned.into_inner())
2627                .clear();
2628            self.native_route_repair_credentials
2629                .write()
2630                .unwrap_or_else(|poisoned| poisoned.into_inner())
2631                .clear();
2632            self.scoped_route_repair_credentials
2633                .write()
2634                .unwrap_or_else(|poisoned| poisoned.into_inner())
2635                .clear();
2636            *guard = Some(key);
2637            self.auto_connect_generation
2638                .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
2639                + 1
2640        };
2641        let mut state = self.external_desired_peer_actor.lock().await;
2642        *state = NativeExternalAutoConnectActorState {
2643            active: true,
2644            generation,
2645            local_device_id,
2646            ..NativeExternalAutoConnectActorState::default()
2647        };
2648        Ok(())
2649    }
2650
2651    pub(crate) async fn start_external_auto_connect_with_peer_budget(
2652        self: &Arc<Self>,
2653        user_id: String,
2654        local_device_id: String,
2655        budget: usize,
2656    ) -> anyhow::Result<()> {
2657        self.start_external_auto_connect(user_id, local_device_id)
2658            .await?;
2659        let generation = self.external_desired_peer_actor.lock().await.generation;
2660        self.set_transfer_peer_budget(budget, generation).await
2661    }
2662
2663    /// Additive intent-aware host ABI. Hosts using observed membership must
2664    /// compile against this API rather than send ignored fields to an older core.
2665    pub async fn submit_external_peer_intents(
2666        self: &Arc<Self>,
2667        revision: u64,
2668        peers_json: &str,
2669    ) -> anyhow::Result<bool> {
2670        self.submit_external_desired_peers(revision, peers_json)
2671            .await
2672    }
2673
2674    pub async fn submit_external_desired_peers(
2675        self: &Arc<Self>,
2676        revision: u64,
2677        peers_json: &str,
2678    ) -> anyhow::Result<bool> {
2679        self.submit_external_desired_peer_update(revision, peers_json, None, false)
2680            .await
2681    }
2682
2683    /// Publish a capability withdrawal only if the root actor is still running.
2684    /// A stopped actor has already discarded its desired peers; retiring an old
2685    /// identity must not restart it or fail a subsequent identity startup.
2686    /// Ordinary admission/startup submissions remain strict.
2687    pub async fn withdraw_external_desired_peers(
2688        self: &Arc<Self>,
2689        revision: u64,
2690        peers_json: &str,
2691    ) -> anyhow::Result<bool> {
2692        self.submit_external_desired_peer_update(revision, peers_json, None, true)
2693            .await
2694    }
2695
2696    #[cfg(feature = "transport-lan")]
2697    pub(crate) async fn submit_external_desired_peer_observation_expired(
2698        self: &Arc<Self>,
2699        revision: u64,
2700        peers_json: &str,
2701        expired_device_id: &str,
2702    ) -> anyhow::Result<bool> {
2703        let expired_device_id = expired_device_id.trim();
2704        if expired_device_id.is_empty() {
2705            return Err(anyhow::anyhow!("expired offline device id is required"));
2706        }
2707        self.submit_external_desired_peer_update(
2708            revision,
2709            peers_json,
2710            Some(expired_device_id),
2711            false,
2712        )
2713        .await
2714    }
2715
2716    async fn submit_external_desired_peer_update(
2717        self: &Arc<Self>,
2718        revision: u64,
2719        peers_json: &str,
2720        observation_expired_device_id: Option<&str>,
2721        withdrawing: bool,
2722    ) -> anyhow::Result<bool> {
2723        let peers = parse_desired_peer_intents(peers_json)?;
2724        if observation_expired_device_id
2725            .is_some_and(|expired| peers.iter().any(|peer| peer.device_id.trim() == expired))
2726        {
2727            return Err(anyhow::anyhow!(
2728                "expired offline device remains in desired-peer input"
2729            ));
2730        }
2731
2732        let (bindings, withdrawn) = {
2733            let mut state = self.external_desired_peer_actor.lock().await;
2734            if !state.active {
2735                if withdrawing {
2736                    return Ok(false);
2737                }
2738                return Err(anyhow::anyhow!("external auto-connect is not started"));
2739            }
2740            let accepted = match observation_expired_device_id {
2741                Some(expired) => state
2742                    .mailbox
2743                    .accept_with_observation_expired(revision, peers, expired),
2744                None => state.mailbox.accept_with_withdrawn(revision, peers),
2745            };
2746            let Some(withdrawn) = accepted else {
2747                return Ok(false);
2748            };
2749            let desired_ids = state
2750                .mailbox
2751                .peers
2752                .keys()
2753                .cloned()
2754                .collect::<std::collections::HashSet<_>>();
2755            state.peer_evidence.retain(|id, _| desired_ids.contains(id));
2756            state.failure_count.retain(|id, _| desired_ids.contains(id));
2757            state
2758                .retry_not_before_ms
2759                .retain(|id, _| desired_ids.contains(id));
2760            state
2761                .non_initiator_not_before_ms
2762                .retain(|id, _| desired_ids.contains(id));
2763
2764            let evidence = state
2765                .mailbox
2766                .peers
2767                .iter()
2768                .map(|(id, peer)| (id.clone(), browser_peer_evidence(peer)))
2769                .collect::<Vec<_>>();
2770            for (id, evidence) in evidence {
2771                let changed = state
2772                    .peer_evidence
2773                    .insert(id.clone(), evidence.clone())
2774                    .is_some_and(|previous| previous != evidence);
2775                if changed {
2776                    state.failure_count.remove(&id);
2777                    state.retry_not_before_ms.remove(&id);
2778                    state.non_initiator_not_before_ms.remove(&id);
2779                }
2780            }
2781            state.schedule_epoch = state.schedule_epoch.saturating_add(1);
2782            state.scheduled_deadline_ms = None;
2783            let bindings = state
2784                .mailbox
2785                .peers
2786                .values()
2787                .filter(|peer| peer.online)
2788                .filter_map(|peer| {
2789                    let ticket_node_id = peer.ticket.as_deref().and_then(|ticket| {
2790                        let (iroh_ticket, _) = crate::session_token::split_ticket(ticket);
2791                        parse_endpoint_ticket(iroh_ticket)
2792                            .ok()
2793                            .map(|address| address.id.to_string())
2794                    });
2795                    let node_id = match (peer.node_id.as_deref(), ticket_node_id.as_deref()) {
2796                        (Some(directory), Some(ticket)) if directory != ticket => return None,
2797                        (Some(directory), _) => directory.to_string(),
2798                        (None, Some(ticket)) => ticket.to_string(),
2799                        (None, None) => return None,
2800                    };
2801                    Some((peer.device_id.clone(), node_id))
2802                })
2803                .collect::<Vec<_>>();
2804            (bindings, withdrawn)
2805        };
2806        for peer in withdrawn {
2807            let still_withdrawn = {
2808                let state = self.external_desired_peer_actor.lock().await;
2809                state.active
2810                    && state.mailbox.revision == revision
2811                    && state
2812                        .mailbox
2813                        .peers
2814                        .get(&peer.device_id)
2815                        .is_none_or(|current| !current.online)
2816            };
2817            if still_withdrawn {
2818                self.retire_withdrawn_desired_peer_connections(
2819                    &peer.device_id,
2820                    peer.node_id.as_deref(),
2821                )
2822                .await;
2823            }
2824        }
2825        for (device_id, node_id) in bindings {
2826            self.observe_authoritative_device_node(&device_id, &node_id);
2827        }
2828        self.wake_native_external_auto_connect().await;
2829        Ok(true)
2830    }
2831
2832    pub async fn wake_native_external_auto_connect(&self) -> bool {
2833        // Explicit desired-peer input is authoritative and may affect the full
2834        // roster. It supersedes any narrow close-recovery timer/pass.
2835        {
2836            let mut state = self.external_desired_peer_actor.lock().await;
2837            state.recovery_device_ids.clear();
2838            state.recovery_suppressed_until_ms.clear();
2839        }
2840        wake_native_external_auto_connect_boxed(Arc::new(self.clone())).await
2841    }
2842
2843    /// Reconcile one already-installed replacement generation immediately.
2844    ///
2845    /// A close observer can schedule (and temporarily suppress duplicate)
2846    /// recovery before Iroh finishes selecting the successor connection. Once
2847    /// the incoming owner has atomically installed that successor and retired
2848    /// the preceding generation's native-main proofs, the old suppression no
2849    /// longer describes duplicate work: this is new typed lifecycle evidence
2850    /// that the current route itself needs admission repair. Feed that evidence
2851    /// to the existing desired-peer actor instead of waiting for another
2852    /// provider revision or starting a second retry owner.
2853    pub(crate) async fn wake_native_external_auto_connect_for_replaced_route(
2854        &self,
2855        remote_node_id: &str,
2856    ) -> bool {
2857        let remote_node_id = remote_node_id.trim();
2858        let Some(remote_device_id) = self
2859            .known_device_ids_by_node
2860            .read()
2861            .unwrap_or_else(|poisoned| poisoned.into_inner())
2862            .get(remote_node_id)
2863            .cloned()
2864        else {
2865            eprintln!(
2866                "[pluto-rtc][auto-connect][replacement-route] remote_node_id={} action=skip reason=device-binding-missing",
2867                remote_node_id,
2868            );
2869            return false;
2870        };
2871
2872        {
2873            let mut state = self.external_desired_peer_actor.lock().await;
2874            if !state.active
2875                || self
2876                    .auto_connect_generation
2877                    .load(std::sync::atomic::Ordering::SeqCst)
2878                    != state.generation
2879                || !state.mailbox.peers.contains_key(&remote_device_id)
2880            {
2881                eprintln!(
2882                    "[pluto-rtc][auto-connect][replacement-route] remote_node_id={} device_id={} action=skip reason=actor-or-peer-inactive",
2883                    remote_node_id, remote_device_id,
2884                );
2885                return false;
2886            }
2887
2888            state.recovery_device_ids.insert(remote_device_id.clone());
2889            state.recovery_suppressed_until_ms.remove(&remote_device_id);
2890            state.retry_not_before_ms.remove(&remote_device_id);
2891            state.non_initiator_not_before_ms.remove(&remote_device_id);
2892            state.schedule_epoch = state.schedule_epoch.saturating_add(1);
2893            state.scheduled_deadline_ms = None;
2894        }
2895
2896        eprintln!(
2897            "[pluto-rtc][auto-connect][replacement-route] remote_node_id={} device_id={} action=wake",
2898            remote_node_id, remote_device_id,
2899        );
2900        wake_native_external_auto_connect_boxed(Arc::new(self.clone())).await
2901    }
2902
2903    /// Coalesce a burst of recoverable native transport-loss notices before
2904    /// waking the one external desired-peer actor. A local interface can close
2905    /// several routes in one network transition; eagerly reconciling after
2906    /// each close would redial routes that the same transition is still
2907    /// retiring. A newer provider revision still wakes immediately and fences
2908    /// this scheduled notice through `schedule_epoch`.
2909    pub(crate) async fn schedule_native_external_auto_connect_recovery(
2910        &self,
2911        remote_node_id: &str,
2912    ) -> bool {
2913        // One physical QUIC loss produces close, accept, path-watcher, and
2914        // admission-stream notifications on independent tasks. Give the
2915        // successor transport time to win before the sole desired-peer actor
2916        // re-examines it; 75ms allowed those observers to redial against an
2917        // already-arriving replacement and created generation churn.
2918        const RECOVERY_COALESCE_MS: i64 = 1_000;
2919        // A single close is reported by the Iroh connection, the accept loop,
2920        // and sometimes the path watcher. Once the actor has been asked to
2921        // repair that loss, suppress duplicate reports long enough for the
2922        // tie-break winner and session admission to settle. This is a
2923        // per-device fence, not a second retry owner.
2924        const RECOVERY_DUPLICATE_SUPPRESSION_MS: i64 = MANAGED_SETTLE_DEADLINE_MS;
2925        let remote_node_id = remote_node_id.trim();
2926        let Some(remote_device_id) = self
2927            .known_device_ids_by_node
2928            .read()
2929            .unwrap_or_else(|poisoned| poisoned.into_inner())
2930            .get(remote_node_id)
2931            .cloned()
2932        else {
2933            // Do not fan a route-level loss into a full desired-peer scan when
2934            // the route has not been bound to a durable device yet. The next
2935            // provider revision remains the authority for that startup race.
2936            return false;
2937        };
2938        let schedule = {
2939            let mut state = self.external_desired_peer_actor.lock().await;
2940            if !state.active
2941                || self
2942                    .auto_connect_generation
2943                    .load(std::sync::atomic::Ordering::SeqCst)
2944                    != state.generation
2945            {
2946                return false;
2947            }
2948
2949            let now = now_millis_i64();
2950            if state
2951                .recovery_suppressed_until_ms
2952                .get(&remote_device_id)
2953                .is_some_and(|deadline| *deadline > now)
2954            {
2955                return true;
2956            }
2957            state.recovery_device_ids.insert(remote_device_id.clone());
2958            state.recovery_suppressed_until_ms.insert(
2959                remote_device_id,
2960                now.saturating_add(RECOVERY_DUPLICATE_SUPPRESSION_MS),
2961            );
2962
2963            let deadline = now.saturating_add(RECOVERY_COALESCE_MS);
2964            if state
2965                .scheduled_deadline_ms
2966                .is_some_and(|scheduled| scheduled <= deadline)
2967            {
2968                return true;
2969            }
2970            state.schedule_epoch = state.schedule_epoch.saturating_add(1);
2971            state.scheduled_deadline_ms = Some(deadline);
2972            (deadline, state.schedule_epoch, state.generation)
2973        };
2974
2975        let client = Arc::new(self.clone());
2976        tokio::spawn(async move {
2977            let (deadline, epoch, generation) = schedule;
2978            let delay_ms = deadline.saturating_sub(now_millis_i64()).max(0) as u64;
2979            tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
2980            let should_wake = {
2981                let mut state = client.external_desired_peer_actor.lock().await;
2982                if !state.active
2983                    || state.generation != generation
2984                    || state.schedule_epoch != epoch
2985                    || client
2986                        .auto_connect_generation
2987                        .load(std::sync::atomic::Ordering::SeqCst)
2988                        != generation
2989                {
2990                    false
2991                } else {
2992                    state.scheduled_deadline_ms = None;
2993                    true
2994                }
2995            };
2996            if should_wake {
2997                let _ = wake_native_external_auto_connect_boxed(client).await;
2998            }
2999        });
3000        true
3001    }
3002
3003    pub(crate) async fn external_auto_connect_is_active(&self) -> bool {
3004        self.external_desired_peer_actor.lock().await.active
3005    }
3006
3007    pub async fn stop_external_auto_connect(&self) {
3008        let mut state = self.external_desired_peer_actor.lock().await;
3009        state.active = false;
3010        state.mailbox.peers.clear();
3011        state.mailbox.transfer_demands = Default::default();
3012        state.peer_evidence.clear();
3013        state.failure_count.clear();
3014        state.retry_not_before_ms.clear();
3015        state.non_initiator_not_before_ms.clear();
3016        state.scheduled_deadline_ms = None;
3017        state.schedule_epoch = state.schedule_epoch.saturating_add(1);
3018        state.wake_pending = false;
3019        state.recovery_device_ids.clear();
3020        state.recovery_suppressed_until_ms.clear();
3021    }
3022}
3023
3024#[cfg(target_arch = "wasm32")]
3025impl Client {
3026    pub(crate) fn start_browser_auto_connect(
3027        self: &Arc<Self>,
3028        user_id: String,
3029        local_device_id: String,
3030    ) -> anyhow::Result<()> {
3031        let user_id = user_id.trim().to_string();
3032        let local_device_id = local_device_id.trim().to_string();
3033        if user_id.is_empty() || local_device_id.is_empty() {
3034            return Err(anyhow::anyhow!(
3035                "browser auto-connect requires non-empty user and local device ids"
3036            ));
3037        }
3038
3039        let key = (user_id, local_device_id.clone());
3040        let actor_key = browser_actor_key(self);
3041        let duplicate_actor = BROWSER_AUTO_CONNECT_ACTORS.with(|actors| {
3042            actors.borrow().get(&actor_key).cloned().filter(|actor| {
3043                let state = actor.borrow();
3044                browser_actor_is_current(&state) && state.local_device_id == local_device_id
3045            })
3046        });
3047        {
3048            let guard = self
3049                .auto_connect_loop_key
3050                .lock()
3051                .unwrap_or_else(|poisoned| poisoned.into_inner());
3052            if guard.as_ref() == Some(&key) {
3053                if let Some(actor) = duplicate_actor {
3054                    wake_browser_auto_connect_actor(actor);
3055                    return Ok(());
3056                }
3057            }
3058        }
3059
3060        let generation = {
3061            let mut guard = self
3062                .auto_connect_loop_key
3063                .lock()
3064                .unwrap_or_else(|poisoned| poisoned.into_inner());
3065            self.known_device_ids_by_node
3066                .write()
3067                .unwrap_or_else(|poisoned| poisoned.into_inner())
3068                .clear();
3069            *guard = Some(key);
3070            self.auto_connect_generation
3071                .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
3072                + 1
3073        };
3074
3075        let actor = std::rc::Rc::new(std::cell::RefCell::new(BrowserAutoConnectActorState {
3076            demand_control: std::rc::Rc::new(tokio::sync::Mutex::new(())),
3077            active: true,
3078            client: Arc::downgrade(self),
3079            generation,
3080            local_device_id,
3081            mailbox: BrowserDesiredPeerMailbox::default(),
3082            peer_evidence: std::collections::HashMap::new(),
3083            failure_count: std::collections::HashMap::new(),
3084            retry_not_before_ms: std::collections::HashMap::new(),
3085            non_initiator_not_before_ms: std::collections::HashMap::new(),
3086            scheduled_deadline_ms: None,
3087            schedule_epoch: 0,
3088            reconcile_running: false,
3089            wake_pending: false,
3090        }));
3091        BROWSER_AUTO_CONNECT_ACTORS.with(|actors| {
3092            if let Some(previous) = actors.borrow_mut().insert(actor_key, actor) {
3093                deactivate_browser_actor(&mut previous.borrow_mut());
3094            }
3095        });
3096        Ok(())
3097    }
3098
3099    pub(crate) fn submit_browser_peer_intents(
3100        self: &Arc<Self>,
3101        revision: u64,
3102        peers_json: &str,
3103    ) -> anyhow::Result<bool> {
3104        let peers = parse_desired_peer_intents(peers_json)?;
3105        self.submit_browser_desired_peer_update(revision, peers)
3106    }
3107
3108    pub(crate) fn submit_browser_desired_peers(
3109        self: &Arc<Self>,
3110        revision: u64,
3111        peers_json: &str,
3112    ) -> anyhow::Result<bool> {
3113        let peers: Vec<BrowserDesiredPeer> = serde_json::from_str(peers_json)
3114            .map_err(|error| anyhow::anyhow!("invalid browser desired-peer payload: {error}"))?;
3115        self.submit_browser_desired_peer_update(revision, peers)
3116    }
3117
3118    fn submit_browser_desired_peer_update(
3119        self: &Arc<Self>,
3120        revision: u64,
3121        peers: Vec<BrowserDesiredPeer>,
3122    ) -> anyhow::Result<bool> {
3123        let actor = BROWSER_AUTO_CONNECT_ACTORS
3124            .with(|actors| actors.borrow().get(&browser_actor_key(self)).cloned());
3125        let Some(actor) = actor else {
3126            return Err(anyhow::anyhow!("browser auto-connect is not started"));
3127        };
3128
3129        let (accepted, current_bindings, withdrawn) = {
3130            let mut state = actor.borrow_mut();
3131            if !browser_actor_is_current(&state) {
3132                (false, Vec::new(), Vec::new())
3133            } else if let Some(withdrawn) = state.mailbox.accept_with_withdrawn(revision, peers) {
3134                let desired_ids = state
3135                    .mailbox
3136                    .peers
3137                    .keys()
3138                    .cloned()
3139                    .collect::<std::collections::HashSet<_>>();
3140                state
3141                    .peer_evidence
3142                    .retain(|device_id, _| desired_ids.contains(device_id));
3143                state
3144                    .failure_count
3145                    .retain(|device_id, _| desired_ids.contains(device_id));
3146                state
3147                    .retry_not_before_ms
3148                    .retain(|device_id, _| desired_ids.contains(device_id));
3149                state
3150                    .non_initiator_not_before_ms
3151                    .retain(|device_id, _| desired_ids.contains(device_id));
3152
3153                let evidence = state
3154                    .mailbox
3155                    .peers
3156                    .iter()
3157                    .map(|(device_id, peer)| (device_id.clone(), browser_peer_evidence(peer)))
3158                    .collect::<Vec<_>>();
3159                for (device_id, evidence) in evidence {
3160                    let changed = state
3161                        .peer_evidence
3162                        .insert(device_id.clone(), evidence.clone())
3163                        .is_some_and(|previous| previous != evidence);
3164                    if changed {
3165                        state.failure_count.remove(&device_id);
3166                        state.retry_not_before_ms.remove(&device_id);
3167                        state.non_initiator_not_before_ms.remove(&device_id);
3168                    }
3169                }
3170                state.schedule_epoch = state.schedule_epoch.saturating_add(1);
3171                state.scheduled_deadline_ms = None;
3172                let bindings = state
3173                    .mailbox
3174                    .peers
3175                    .values()
3176                    .filter(|peer| peer.online)
3177                    .filter_map(|peer| {
3178                        let ticket_node_id = peer.ticket.as_deref().and_then(|ticket| {
3179                            let (iroh_ticket, _) = crate::session_token::split_ticket(ticket);
3180                            parse_endpoint_ticket(iroh_ticket)
3181                                .ok()
3182                                .map(|address| address.id.to_string())
3183                        });
3184                        let node_id = match (peer.node_id.as_deref(), ticket_node_id.as_deref()) {
3185                            (Some(directory_node_id), Some(ticket_node_id))
3186                                if directory_node_id != ticket_node_id =>
3187                            {
3188                                return None;
3189                            }
3190                            (Some(directory_node_id), _) => directory_node_id.to_string(),
3191                            (None, Some(ticket_node_id)) => ticket_node_id.to_string(),
3192                            (None, None) => return None,
3193                        };
3194                        Some((peer.device_id.clone(), node_id))
3195                    })
3196                    .collect::<Vec<_>>();
3197                (true, bindings, withdrawn)
3198            } else {
3199                (false, Vec::new(), Vec::new())
3200            }
3201        };
3202        if accepted {
3203            // This is the cancellation token for any older desired-peer pass.
3204            // It must be visible before waking the serial actor, because an old
3205            // connect may currently be suspended inside Iroh or admission.
3206            for (device_id, node_id) in current_bindings {
3207                self.observe_authoritative_device_node(&device_id, &node_id);
3208            }
3209            if !withdrawn.is_empty() {
3210                let client = Arc::clone(self);
3211                let actor_for_retirement = actor.clone();
3212                wasm_bindgen_futures::spawn_local(async move {
3213                    for peer in withdrawn {
3214                        let still_withdrawn = {
3215                            let state = actor_for_retirement.borrow();
3216                            browser_actor_is_current(&state)
3217                                && state.mailbox.revision == revision
3218                                && state
3219                                    .mailbox
3220                                    .peers
3221                                    .get(&peer.device_id)
3222                                    .is_none_or(|current| !current.online)
3223                        };
3224                        if still_withdrawn {
3225                            client
3226                                .retire_withdrawn_desired_peer_connections(
3227                                    &peer.device_id,
3228                                    peer.node_id.as_deref(),
3229                                )
3230                                .await;
3231                        }
3232                    }
3233                });
3234            }
3235            wake_browser_auto_connect_actor(actor);
3236        }
3237        Ok(accepted)
3238    }
3239
3240    pub(crate) fn wake_browser_auto_connect(self: &Arc<Self>) -> bool {
3241        let actor = BROWSER_AUTO_CONNECT_ACTORS
3242            .with(|actors| actors.borrow().get(&browser_actor_key(self)).cloned());
3243        let Some(actor) = actor else {
3244            return false;
3245        };
3246        if !browser_actor_is_current(&actor.borrow()) {
3247            return false;
3248        }
3249        wake_browser_auto_connect_actor(actor);
3250        true
3251    }
3252
3253    pub(crate) fn stop_browser_auto_connect(self: &Arc<Self>) {
3254        let actor = BROWSER_AUTO_CONNECT_ACTORS
3255            .with(|actors| actors.borrow_mut().remove(&browser_actor_key(self)));
3256        if let Some(actor) = actor {
3257            deactivate_browser_actor(&mut actor.borrow_mut());
3258        }
3259    }
3260}
3261
3262#[cfg(test)]
3263mod tests {
3264    use super::*;
3265
3266    #[cfg(not(target_arch = "wasm32"))]
3267    #[tokio::test]
3268    async fn auto_connect_disabled_during_reconcile_preserves_manual_transport() {
3269        let client = Arc::new(Client::new_with_app_tag(
3270            crate::test_constants::TEST_PROJECT_ID.to_string(),
3271            "exclusion-race".to_string(),
3272            Box::new(|| None),
3273        ));
3274        let remote = Client::new_with_app_tag(
3275            crate::test_constants::TEST_PROJECT_ID.to_string(),
3276            "exclusion-race".to_string(),
3277            Box::new(|| None),
3278        );
3279        for owner in [&*client, &remote] {
3280            let endpoint = iroh::Endpoint::builder(iroh::endpoint::presets::N0)
3281                .alpns(vec![crate::native_node::PlutoniumProtocol::ALPN.to_vec()])
3282                .relay_mode(iroh::RelayMode::Disabled)
3283                .bind_addr("127.0.0.1:0".parse::<std::net::SocketAddr>().unwrap())
3284                .unwrap()
3285                .bind()
3286                .await
3287                .unwrap();
3288            owner.adopt_endpoint(endpoint).await;
3289        }
3290        let ticket = remote.endpoint_ticket().await.unwrap();
3291        let remote_node_id = remote.current_node_id().await.unwrap();
3292        let endpoint_id = remote_node_id.parse().unwrap();
3293        let connected = client
3294            .connect_device(Some("remote"), &ticket)
3295            .await
3296            .unwrap();
3297        let original = client.get_connection(endpoint_id).await.unwrap();
3298        let original_stable_id = crate::transport_generation::for_connection(&original);
3299        let peer = BrowserDesiredPeer {
3300            connection_intent: DesiredPeerConnectionIntent::Automatic,
3301            device_id: "remote".to_string(),
3302            node_id: Some(remote_node_id),
3303            ticket: Some(ticket),
3304            online: true,
3305            session_id: Some("runtime-1".to_string()),
3306            excluded_peers: Vec::new(),
3307            topology_role: None,
3308            topology_revision: None,
3309        };
3310        let generation = client
3311            .auto_connect_generation
3312            .load(std::sync::atomic::Ordering::SeqCst);
3313        {
3314            let mut actor = client.external_desired_peer_actor.lock().await;
3315            actor.active = true;
3316            actor.generation = generation;
3317            actor.local_device_id = "local".to_string();
3318            actor
3319                .mailbox
3320                .accept_with_withdrawn(1, vec![peer.clone()])
3321                .unwrap();
3322        }
3323        // Pause the production actor at the same connect gate shared by a
3324        // manual request. Restore the durable preference while it is awaiting
3325        // that gate, as a relaunched WebView can do during admission.
3326        let gate = client.managed_connect_gate(&connected.connection_id).await;
3327        let guard = gate.lock().await;
3328        let reconcile =
3329            reconcile_native_external_desired_peer(&client, generation, 1, "local", &peer, None);
3330        tokio::pin!(reconcile);
3331        assert!(
3332            tokio::time::timeout(std::time::Duration::from_millis(100), &mut reconcile)
3333                .await
3334                .is_err()
3335        );
3336        client.set_auto_connect_excluded("remote", true);
3337        drop(guard);
3338        assert_eq!(
3339            tokio::time::timeout(std::time::Duration::from_secs(5), reconcile)
3340                .await
3341                .unwrap(),
3342            BrowserPeerReconcileOutcome::Ineligible,
3343        );
3344        assert!(client.is_auto_connect_excluded("remote"));
3345        assert!(
3346            original.close_reason().is_none(),
3347            "auto-connect policy must not close a manual transport"
3348        );
3349        let current = client
3350            .get_connection(endpoint_id)
3351            .await
3352            .expect("manual connection survives");
3353        assert_eq!(
3354            crate::transport_generation::for_connection(&current),
3355            original_stable_id
3356        );
3357        assert!(client.is_connection_transport_alive(endpoint_id).await);
3358    }
3359
3360    fn replacement_snapshot(last_reconnect_reason: Option<&str>) -> ConnectionHealthView {
3361        ConnectionHealthView {
3362            connection_id: "conn-1".to_string(),
3363            device_id: Some("device-1".to_string()),
3364            device_id_hint: Some("device-1".to_string()),
3365            node_id: Some("node-1".to_string()),
3366            active_transport_stable_id: None,
3367            transport_generation: 2,
3368            route_generation: 0,
3369            status: ManagedHealth::AwaitingReplacement,
3370            settled_ready: false,
3371            readiness_state: ReadinessState::AwaitingReplacement,
3372            replacement_pending: true,
3373            last_lifecycle_transition_at_ms: 1_000,
3374            readiness_reason: "missing-active-transport".to_string(),
3375            transition_count: 1,
3376            connecting_transition_count: 0,
3377            replacement_count: 1,
3378            retire_count: 0,
3379            last_disconnect_reason: None,
3380            last_reconnect_reason: last_reconnect_reason.map(str::to_string),
3381        }
3382    }
3383
3384    #[test]
3385    fn runtime_instance_change_is_observed_only_after_the_initial_generation() {
3386        let mut last_known = std::collections::HashMap::new();
3387
3388        assert!(!observe_remote_runtime_instance(
3389            &mut last_known,
3390            "device-1",
3391            Some("runtime-1"),
3392        ));
3393        assert!(!observe_remote_runtime_instance(
3394            &mut last_known,
3395            "device-1",
3396            Some("runtime-1"),
3397        ));
3398        assert!(observe_remote_runtime_instance(
3399            &mut last_known,
3400            "device-1",
3401            Some("runtime-2"),
3402        ));
3403    }
3404
3405    #[cfg(not(target_arch = "wasm32"))]
3406    #[test]
3407    fn delayed_runtime_instance_update_preserves_a_responsive_replacement() {
3408        assert!(!runtime_instance_change_requires_replacement(true, true));
3409    }
3410
3411    #[cfg(not(target_arch = "wasm32"))]
3412    #[test]
3413    fn runtime_instance_update_replaces_an_unresponsive_transport() {
3414        assert!(runtime_instance_change_requires_replacement(true, false));
3415        assert!(!runtime_instance_change_requires_replacement(false, false));
3416    }
3417
3418    #[test]
3419    fn ticket_change_clears_transient_retry_without_retaining_ticket_material() {
3420        let mut last_known = std::collections::HashMap::new();
3421        let mut last_attempt_at = std::collections::HashMap::new();
3422        let mut failure_count = std::collections::HashMap::new();
3423        let mut failure_backoff_until = std::collections::HashMap::new();
3424
3425        assert!(!reconcile_remote_ticket_retry_state(
3426            &mut last_known,
3427            "device-1",
3428            "ticket-1.token-1",
3429            &mut last_attempt_at,
3430            &mut failure_count,
3431            &mut failure_backoff_until,
3432        ));
3433        last_attempt_at.insert("device-1".to_string(), 100);
3434        failure_count.insert("device-1".to_string(), 4);
3435        failure_backoff_until.insert("device-1".to_string(), 8_100);
3436
3437        assert!(!reconcile_remote_ticket_retry_state(
3438            &mut last_known,
3439            "device-1",
3440            "ticket-1.token-1",
3441            &mut last_attempt_at,
3442            &mut failure_count,
3443            &mut failure_backoff_until,
3444        ));
3445        assert_eq!(last_attempt_at.get("device-1"), Some(&100));
3446        assert_eq!(failure_count.get("device-1"), Some(&4));
3447        assert_eq!(failure_backoff_until.get("device-1"), Some(&8_100));
3448
3449        assert!(reconcile_remote_ticket_retry_state(
3450            &mut last_known,
3451            "device-1",
3452            "ticket-1.token-2",
3453            &mut last_attempt_at,
3454            &mut failure_count,
3455            &mut failure_backoff_until,
3456        ));
3457        assert!(!last_attempt_at.contains_key("device-1"));
3458        assert!(!failure_count.contains_key("device-1"));
3459        assert!(!failure_backoff_until.contains_key("device-1"));
3460
3461        let stored = last_known
3462            .get("device-1::ticket")
3463            .expect("ticket fingerprint");
3464        assert_eq!(stored.len(), 16);
3465        assert!(!stored.contains("token-2"));
3466    }
3467
3468    #[test]
3469    fn replacement_window_suppresses_auto_connect_for_active_replacement_churn() {
3470        let snapshot = replacement_snapshot(Some("incoming-replacement-churn"));
3471
3472        assert!(should_suppress_auto_connect_for_replacement_window(
3473            &snapshot,
3474            snapshot.last_lifecycle_transition_at_ms + 1_000,
3475            false,
3476        ));
3477        assert!(
3478            !should_suppress_auto_connect_for_replacement_window(
3479                &snapshot,
3480                snapshot.last_lifecycle_transition_at_ms + 1_000,
3481                true,
3482            ),
3483            "replacement suppression must not bypass positive liveness checks for an owned transport"
3484        );
3485    }
3486
3487    #[test]
3488    fn transient_disconnect_replacement_window_allows_auto_connect_redial() {
3489        let snapshot = replacement_snapshot(Some("transient-disconnect"));
3490
3491        assert!(!should_suppress_auto_connect_for_replacement_window(
3492            &snapshot,
3493            snapshot.last_lifecycle_transition_at_ms + 1_000,
3494            false,
3495        ));
3496    }
3497
3498    #[test]
3499    fn expired_replacement_window_allows_auto_connect_redial() {
3500        let snapshot = replacement_snapshot(Some("incoming-replacement-churn"));
3501
3502        assert!(!should_suppress_auto_connect_for_replacement_window(
3503            &snapshot,
3504            snapshot.last_lifecycle_transition_at_ms + MANAGED_SETTLE_DEADLINE_MS,
3505            false,
3506        ));
3507    }
3508
3509    #[test]
3510    fn remote_exclusion_matches_canonical_device_id_case_insensitively() {
3511        let excluded_peers = vec!["  DEVICE-LOCAL  ".to_string()];
3512
3513        assert!(remote_excludes_local_device(
3514            &excluded_peers,
3515            "device-local"
3516        ));
3517        assert!(!remote_excludes_local_device(
3518            &excluded_peers,
3519            "device-other"
3520        ));
3521        assert!(!remote_excludes_local_device(&excluded_peers, ""));
3522    }
3523
3524    #[test]
3525    fn browser_desired_peer_mailbox_fences_stale_revisions_and_replaces_state() {
3526        let mut mailbox = BrowserDesiredPeerMailbox::default();
3527        let peer = BrowserDesiredPeer {
3528            connection_intent: DesiredPeerConnectionIntent::Automatic,
3529            device_id: " device-1 ".to_string(),
3530            node_id: Some(" node-1 ".to_string()),
3531            ticket: Some(" ticket-1 ".to_string()),
3532            online: true,
3533            session_id: Some(" runtime-1 ".to_string()),
3534            excluded_peers: vec![" DEVICE-LOCAL ".to_string()],
3535            topology_role: None,
3536            topology_revision: None,
3537        };
3538
3539        assert!(mailbox.accept_with_withdrawn(2, vec![peer]).is_some());
3540        assert!(mailbox.accept_with_withdrawn(1, Vec::new()).is_none());
3541        assert!(mailbox.accept_with_withdrawn(2, Vec::new()).is_none());
3542        assert_eq!(mailbox.revision, 2);
3543        let stored = mailbox.peers.get("device-1").expect("desired peer");
3544        assert_eq!(stored.node_id.as_deref(), Some("node-1"));
3545        assert_eq!(stored.excluded_peers, vec!["device-local"]);
3546
3547        let withdrawn = mailbox
3548            .accept_with_withdrawn(3, Vec::new())
3549            .expect("newer revision is accepted");
3550        assert_eq!(withdrawn.len(), 1);
3551        assert_eq!(withdrawn[0].device_id, "device-1");
3552        assert!(mailbox.peers.is_empty());
3553        assert!(mailbox.device_is_withdrawn("device-1"));
3554        assert_eq!(
3555            mailbox.withdrawn_device_for_node("node-1").as_deref(),
3556            Some("device-1")
3557        );
3558
3559        let mut restored = withdrawn[0].clone();
3560        restored.online = true;
3561        assert!(mailbox
3562            .accept_with_withdrawn(4, vec![restored])
3563            .expect("restored revision is accepted")
3564            .is_empty());
3565        assert!(!mailbox.device_is_withdrawn("device-1"));
3566        assert_eq!(mailbox.withdrawn_device_for_node("node-1"), None);
3567    }
3568
3569    #[test]
3570    fn current_user_device_assignment_requires_exact_online_runtime_evidence() {
3571        let endpoint_id = iroh::SecretKey::generate().public();
3572        let node_id = endpoint_id.to_string();
3573        let iroh_ticket =
3574            iroh_tickets::endpoint::EndpointTicket::new(iroh::EndpointAddr::new(endpoint_id))
3575                .to_string();
3576        let ticket =
3577            crate::session_token::build_ticket(&iroh_ticket, "user-device-token", "user-device", 0);
3578        let peer = BrowserDesiredPeer {
3579            connection_intent: DesiredPeerConnectionIntent::Automatic,
3580            device_id: "device-1".to_string(),
3581            node_id: Some(node_id.clone()),
3582            ticket: Some(ticket.clone()),
3583            online: true,
3584            session_id: Some("runtime-1".to_string()),
3585            excluded_peers: Vec::new(),
3586            topology_role: None,
3587            topology_revision: None,
3588        };
3589        let evidence = browser_peer_evidence(&peer);
3590        let mut mailbox = BrowserDesiredPeerMailbox::default();
3591        assert!(mailbox.accept(1, vec![peer.clone()]));
3592        assert_eq!(
3593            mailbox.current_online_device_for_node(&node_id).as_deref(),
3594            Some("device-1")
3595        );
3596        assert!(
3597            mailbox.current_online_assignment_matches("device-1", &node_id, &ticket, &evidence,)
3598        );
3599        assert!(!mailbox.current_online_assignment_matches(
3600            "device-1",
3601            &node_id,
3602            "stale-ticket",
3603            &evidence,
3604        ));
3605
3606        let mut replacement = peer.clone();
3607        replacement.session_id = Some("runtime-2".to_string());
3608        assert!(mailbox.accept(2, vec![replacement.clone()]));
3609        assert!(
3610            !mailbox.current_online_assignment_matches("device-1", &node_id, &ticket, &evidence,)
3611        );
3612        let replacement_evidence = browser_peer_evidence(&replacement);
3613        assert!(mailbox.current_online_assignment_matches(
3614            "device-1",
3615            &node_id,
3616            &ticket,
3617            &replacement_evidence,
3618        ));
3619
3620        replacement.online = false;
3621        assert!(mailbox.accept(3, vec![replacement]));
3622        assert_eq!(mailbox.current_online_device_for_node(&node_id), None);
3623        assert!(!mailbox.current_online_assignment_matches(
3624            "device-1",
3625            &node_id,
3626            &ticket,
3627            &replacement_evidence,
3628        ));
3629    }
3630
3631    #[test]
3632    fn browser_desired_peer_mailbox_withdraws_live_route_when_device_goes_offline() {
3633        let mut mailbox = BrowserDesiredPeerMailbox::default();
3634        let online = BrowserDesiredPeer {
3635            connection_intent: DesiredPeerConnectionIntent::Automatic,
3636            device_id: "device-1".to_string(),
3637            node_id: Some("node-1".to_string()),
3638            ticket: Some("ticket-1".to_string()),
3639            online: true,
3640            session_id: Some("runtime-1".to_string()),
3641            excluded_peers: Vec::new(),
3642            topology_role: None,
3643            topology_revision: None,
3644        };
3645        assert!(mailbox
3646            .accept_with_withdrawn(1, vec![online.clone()])
3647            .expect("initial revision is accepted")
3648            .is_empty());
3649
3650        let mut offline = online.clone();
3651        offline.online = false;
3652        let withdrawn = mailbox
3653            .accept_with_withdrawn(2, vec![offline])
3654            .expect("offline revision is accepted");
3655        assert_eq!(withdrawn, vec![online]);
3656        assert!(mailbox.peers.contains_key("device-1"));
3657        assert!(!mailbox.peers["device-1"].online);
3658        assert!(mailbox.device_is_withdrawn("device-1"));
3659        assert_eq!(
3660            mailbox.withdrawn_device_for_node("node-1").as_deref(),
3661            Some("device-1")
3662        );
3663
3664        assert!(mailbox
3665            .accept_with_withdrawn(3, vec![mailbox.peers["device-1"].clone()])
3666            .expect("repeated offline revision is accepted")
3667            .is_empty());
3668    }
3669
3670    #[test]
3671    fn offline_boot_is_open() {
3672        let mut mailbox = BrowserDesiredPeerMailbox::default();
3673        let offline = BrowserDesiredPeer {
3674            connection_intent: DesiredPeerConnectionIntent::Automatic,
3675            device_id: "device-1".to_string(),
3676            node_id: Some("node-1".to_string()),
3677            ticket: Some("ticket-1".to_string()),
3678            online: false,
3679            session_id: None,
3680            excluded_peers: Vec::new(),
3681            topology_role: None,
3682            topology_revision: None,
3683        };
3684
3685        assert!(mailbox
3686            .accept_with_withdrawn(1, vec![offline])
3687            .expect("initial offline roster is accepted")
3688            .is_empty());
3689        assert!(!mailbox.device_is_withdrawn("device-1"));
3690        assert_eq!(mailbox.withdrawn_device_for_node("node-1"), None);
3691    }
3692
3693    #[test]
3694    fn candidate_observation_expiry_removes_dial_intent_without_withdrawal() {
3695        let mut mailbox = BrowserDesiredPeerMailbox::default();
3696        let peer = BrowserDesiredPeer {
3697            connection_intent: DesiredPeerConnectionIntent::Automatic,
3698            device_id: "device-1".to_string(),
3699            node_id: Some("node-1".to_string()),
3700            ticket: Some("ticket-1".to_string()),
3701            online: true,
3702            session_id: None,
3703            excluded_peers: Vec::new(),
3704            topology_role: None,
3705            topology_revision: None,
3706        };
3707        assert!(mailbox
3708            .accept_with_withdrawn(1, vec![peer.clone()])
3709            .unwrap()
3710            .is_empty());
3711        assert!(mailbox
3712            .accept_with_observation_expired(2, Vec::new(), "device-1")
3713            .expect("newer observation revision")
3714            .is_empty());
3715        assert!(mailbox.peers.is_empty());
3716        assert!(!mailbox.device_is_withdrawn("device-1"));
3717        assert_eq!(mailbox.withdrawn_device_for_node("node-1"), None);
3718        assert!(mailbox
3719            .accept_with_observation_expired(2, Vec::new(), "device-1")
3720            .is_none());
3721
3722        assert!(mailbox
3723            .accept_with_withdrawn(3, vec![peer])
3724            .expect("rediscovery revision")
3725            .is_empty());
3726        assert!(mailbox.peers.contains_key("device-1"));
3727        assert!(!mailbox.device_is_withdrawn("device-1"));
3728    }
3729
3730    #[test]
3731    fn topology_backups_retire_only_after_current_active_edges_are_ready() {
3732        let peer = |device_id: &str, role: SparseTopologyRole, topology_revision: u64| {
3733            BrowserDesiredPeer {
3734                connection_intent: DesiredPeerConnectionIntent::Automatic,
3735                device_id: device_id.to_string(),
3736                node_id: Some(format!("node-{device_id}")),
3737                ticket: Some(format!("ticket-{device_id}")),
3738                online: true,
3739                session_id: Some(format!("runtime-{device_id}")),
3740                excluded_peers: Vec::new(),
3741                topology_role: Some(role),
3742                topology_revision: Some(topology_revision),
3743            }
3744        };
3745        let active_a = peer("active-a", SparseTopologyRole::Active, 7);
3746        let active_b = peer("active-b", SparseTopologyRole::Active, 7);
3747        let backup = peer("backup", SparseTopologyRole::Backup, 7);
3748        let membership_only = |mut peer: BrowserDesiredPeer| {
3749            peer.node_id = None;
3750            peer.ticket = None;
3751            peer.topology_role = None;
3752            peer.topology_revision = None;
3753            peer
3754        };
3755        let mut mailbox = BrowserDesiredPeerMailbox::default();
3756        assert!(mailbox
3757            .accept_with_withdrawn(10, vec![active_a.clone(), active_b.clone(), backup.clone()],)
3758            .expect("initial topology")
3759            .is_empty());
3760
3761        assert!(mailbox
3762            .acknowledge_topology_peer_ready("active-a", Some(7))
3763            .is_empty());
3764        assert!(mailbox.peers.contains_key("backup"));
3765        mailbox.observe_topology_peer_not_ready("active-a", Some(7));
3766        assert!(mailbox
3767            .acknowledge_topology_peer_ready("active-b", Some(7))
3768            .is_empty());
3769        assert!(mailbox.peers.contains_key("backup"));
3770        assert!(mailbox
3771            .acknowledge_topology_peer_ready("active-b", Some(6))
3772            .is_empty());
3773        assert!(mailbox.peers.contains_key("backup"));
3774
3775        assert_eq!(
3776            mailbox.acknowledge_topology_peer_ready("active-a", Some(7)),
3777            vec![backup.clone()],
3778        );
3779        assert!(!mailbox.peers.contains_key("backup"));
3780        assert!(mailbox.device_is_withdrawn("backup"));
3781
3782        // An unrelated public-membership revision cannot revive a retired
3783        // backup while the gateway topology generation is unchanged.
3784        assert!(mailbox
3785            .accept_with_withdrawn(11, vec![active_a.clone(), active_b.clone(), backup.clone()],)
3786            .expect("same topology revision")
3787            .is_empty());
3788        assert!(!mailbox.peers.contains_key("backup"));
3789
3790        // A new topology clears readiness. A late acknowledgement from the
3791        // prior generation cannot retire its replacement overlap.
3792        let next_active = peer("active-next", SparseTopologyRole::Active, 8);
3793        let next_backup = peer("backup-next", SparseTopologyRole::Backup, 8);
3794        mailbox
3795            .accept_with_withdrawn(
3796                12,
3797                vec![
3798                    next_active.clone(),
3799                    next_backup.clone(),
3800                    membership_only(active_a.clone()),
3801                    membership_only(active_b.clone()),
3802                ],
3803            )
3804            .expect("next topology revision");
3805        assert!(mailbox
3806            .acknowledge_topology_peer_ready("active-a", Some(7))
3807            .is_empty());
3808        assert!(!mailbox.peers.contains_key("backup-next"));
3809        assert_eq!(
3810            mailbox.peers["active-a"].topology_role,
3811            Some(SparseTopologyRole::Backup),
3812        );
3813
3814        // A third lease arriving before revision 8 settles retains only the
3815        // last Rust-proven revision 7 active set. It does not accumulate the
3816        // speculative revision 8 active or backup set.
3817        let latest_active = peer("active-latest", SparseTopologyRole::Active, 9);
3818        let speculative_overlap = peer("active-next", SparseTopologyRole::Backup, 9);
3819        mailbox
3820            .accept_with_withdrawn(
3821                13,
3822                vec![
3823                    latest_active.clone(),
3824                    speculative_overlap,
3825                    membership_only(active_a.clone()),
3826                    membership_only(active_b.clone()),
3827                    membership_only(next_active.clone()),
3828                ],
3829            )
3830            .expect("rapid topology revision");
3831        assert_eq!(
3832            mailbox
3833                .peers
3834                .values()
3835                .filter(|peer| peer.topology_role == Some(SparseTopologyRole::Backup))
3836                .map(|peer| peer.device_id.as_str())
3837                .collect::<std::collections::BTreeSet<_>>(),
3838            std::collections::BTreeSet::from(["active-a", "active-b"]),
3839        );
3840        assert_eq!(
3841            mailbox.acknowledge_topology_peer_ready("active-latest", Some(9)),
3842            vec![active_a.clone(), active_b.clone()]
3843                .into_iter()
3844                .map(|mut peer| {
3845                    peer.topology_role = Some(SparseTopologyRole::Backup);
3846                    peer.topology_revision = Some(9);
3847                    peer
3848                })
3849                .collect::<Vec<_>>(),
3850        );
3851
3852        // Restarting the actor starts with no inherited readiness proof.
3853        let mut restarted = BrowserDesiredPeerMailbox::default();
3854        restarted
3855            .accept_with_withdrawn(1, vec![next_active, next_backup])
3856            .expect("restarted topology");
3857        assert!(restarted.peers.contains_key("backup-next"));
3858    }
3859
3860    #[test]
3861    fn browser_peer_evidence_fingerprints_ticket_instead_of_retaining_it() {
3862        let peer = BrowserDesiredPeer {
3863            connection_intent: DesiredPeerConnectionIntent::Automatic,
3864            device_id: "device-1".to_string(),
3865            node_id: Some("node-1".to_string()),
3866            ticket: Some("iroh-ticket.sensitive-token-material".to_string()),
3867            online: true,
3868            session_id: Some("runtime-1".to_string()),
3869            excluded_peers: Vec::new(),
3870            topology_role: None,
3871            topology_revision: None,
3872        };
3873
3874        let evidence = browser_peer_evidence(&peer);
3875        assert!(evidence.starts_with("node-1|"));
3876        assert!(!evidence.contains("sensitive-token-material"));
3877    }
3878
3879    #[test]
3880    fn desired_peer_attempt_survives_unrelated_mailbox_revision() {
3881        let mut mailbox = BrowserDesiredPeerMailbox::default();
3882        let tracked = BrowserDesiredPeer {
3883            connection_intent: DesiredPeerConnectionIntent::Automatic,
3884            device_id: "device-tracked".to_string(),
3885            node_id: Some("node-tracked".to_string()),
3886            ticket: Some("ticket-tracked".to_string()),
3887            online: true,
3888            session_id: Some("runtime-tracked".to_string()),
3889            excluded_peers: Vec::new(),
3890            topology_role: None,
3891            topology_revision: None,
3892        };
3893        let unrelated = BrowserDesiredPeer {
3894            connection_intent: DesiredPeerConnectionIntent::Automatic,
3895            device_id: "device-unrelated".to_string(),
3896            node_id: Some("node-unrelated".to_string()),
3897            ticket: Some("ticket-unrelated".to_string()),
3898            online: true,
3899            session_id: Some("runtime-unrelated".to_string()),
3900            excluded_peers: Vec::new(),
3901            topology_role: None,
3902            topology_revision: None,
3903        };
3904
3905        assert!(mailbox.accept(1, vec![tracked.clone()]));
3906        assert!(desired_peer_evidence_is_current(&mailbox, &tracked));
3907
3908        assert!(mailbox.accept(2, vec![tracked.clone(), unrelated.clone()]));
3909        assert!(desired_peer_evidence_is_current(&mailbox, &tracked));
3910
3911        let mut changed = tracked.clone();
3912        changed.session_id = Some("runtime-replacement".to_string());
3913        assert!(mailbox.accept(3, vec![changed, unrelated.clone()]));
3914        assert!(!desired_peer_evidence_is_current(&mailbox, &tracked));
3915
3916        assert!(mailbox.accept(4, vec![unrelated]));
3917        assert!(!desired_peer_evidence_is_current(&mailbox, &tracked));
3918    }
3919
3920    #[test]
3921    fn browser_desired_peer_tie_break_has_one_initial_dialer_and_bounded_takeover() {
3922        assert_eq!(
3923            browser_desired_peer_decision("node-z", "node-a", false, 1_000, None),
3924            BrowserDesiredPeerDecision::Dial
3925        );
3926        assert_eq!(
3927            browser_desired_peer_decision("node-a", "node-z", false, 1_000, None),
3928            BrowserDesiredPeerDecision::WaitUntil(4_000)
3929        );
3930        assert_eq!(
3931            browser_desired_peer_decision("node-a", "node-z", false, 4_000, Some(4_000)),
3932            BrowserDesiredPeerDecision::Dial
3933        );
3934        assert_eq!(
3935            browser_desired_peer_decision("node-a", "node-z", true, 1_000, None),
3936            BrowserDesiredPeerDecision::ObserveExisting
3937        );
3938    }
3939
3940    #[test]
3941    fn browser_retry_deadline_only_replaces_with_earlier_work_or_cancellation() {
3942        assert!(!should_replace_browser_retry_deadline(None, None));
3943        assert!(should_replace_browser_retry_deadline(None, Some(5_000)));
3944        assert!(!should_replace_browser_retry_deadline(
3945            Some(5_000),
3946            Some(6_000)
3947        ));
3948        assert!(should_replace_browser_retry_deadline(
3949            Some(5_000),
3950            Some(4_000)
3951        ));
3952        assert!(should_replace_browser_retry_deadline(Some(5_000), None));
3953    }
3954
3955    #[test]
3956    fn browser_failure_backoff_is_bounded_for_recovery() {
3957        assert_eq!(browser_auto_connect_failure_backoff_ms(1), 1_000);
3958        assert_eq!(browser_auto_connect_failure_backoff_ms(2), 2_000);
3959        assert_eq!(browser_auto_connect_failure_backoff_ms(10), 8_000);
3960    }
3961
3962    #[test]
3963    fn reconcile_deadlines_replace_expired_non_initiator_wait_with_failure_backoff() {
3964        let (retry_deadline, non_initiator_deadline) =
3965            browser_reconcile_deadlines(BrowserPeerReconcileOutcome::Retry, Some(9_000));
3966        assert_eq!(retry_deadline, Some(9_000));
3967        assert_eq!(non_initiator_deadline, None);
3968
3969        let (retry_deadline, non_initiator_deadline) =
3970            browser_reconcile_deadlines(BrowserPeerReconcileOutcome::WaitUntil(4_000), None);
3971        assert_eq!(retry_deadline, None);
3972        assert_eq!(non_initiator_deadline, Some(4_000));
3973    }
3974
3975    #[cfg(not(target_arch = "wasm32"))]
3976    #[tokio::test]
3977    async fn observed_fleet_directory_is_retained_without_expanding_automatic_peer_admission() {
3978        let client = Arc::new(Client::new_with_app_tag(
3979            crate::test_constants::TEST_PROJECT_ID.to_string(),
3980            "observed-fleet".to_string(),
3981            Box::new(|| None),
3982        ));
3983        client
3984            .start_external_auto_connect("user".into(), "local".into())
3985            .await
3986            .unwrap();
3987        let peers = (0..500)
3988            .map(|index| {
3989                serde_json::json!({
3990                    "deviceId": format!("device-{index}"), "online": index % 2 == 0,
3991                    "connectionIntent": "observe",
3992                })
3993            })
3994            .collect::<Vec<_>>();
3995        assert!(client
3996            .submit_external_desired_peers(1, &serde_json::to_string(&peers).unwrap())
3997            .await
3998            .expect("500 observed devices must be retained"));
3999        let snapshot = client.external_desired_peer_debug_snapshot().await;
4000        assert_eq!(snapshot["peers"].as_array().unwrap().len(), 500);
4001        assert_eq!(
4002            snapshot["peers"]
4003                .as_array()
4004                .unwrap()
4005                .iter()
4006                .filter(|peer| peer["online"] == true)
4007                .count(),
4008            250
4009        );
4010        let mut overflow = peers.clone();
4011        overflow.push(serde_json::json!({"deviceId":"overflow", "connectionIntent":"observe"}));
4012        assert!(client
4013            .submit_external_desired_peers(2, &serde_json::to_string(&overflow).unwrap())
4014            .await
4015            .is_err());
4016        let automatic = (0..101)
4017            .map(|index| serde_json::json!({"deviceId":format!("automatic-{index}")}))
4018            .collect::<Vec<_>>();
4019        assert!(client
4020            .submit_external_desired_peers(3, &serde_json::to_string(&automatic).unwrap())
4021            .await
4022            .is_err());
4023        assert_eq!(
4024            client.external_desired_peer_debug_snapshot().await["revision"],
4025            1
4026        );
4027        client.stop_external_auto_connect().await;
4028    }
4029
4030    #[cfg(not(target_arch = "wasm32"))]
4031    #[tokio::test]
4032    async fn observed_online_peer_does_not_dial_and_preserves_an_explicit_transport() {
4033        let client = Arc::new(Client::new_with_app_tag(
4034            crate::test_constants::TEST_PROJECT_ID.to_string(),
4035            "observe-route".into(),
4036            Box::new(|| None),
4037        ));
4038        let remote = Client::new_with_app_tag(
4039            crate::test_constants::TEST_PROJECT_ID.to_string(),
4040            "observe-route".into(),
4041            Box::new(|| None),
4042        );
4043        for owner in [&*client, &remote] {
4044            let endpoint = iroh::Endpoint::builder(iroh::endpoint::presets::N0)
4045                .alpns(vec![crate::native_node::PlutoniumProtocol::ALPN.to_vec()])
4046                .relay_mode(iroh::RelayMode::Disabled)
4047                .bind_addr("127.0.0.1:0".parse::<std::net::SocketAddr>().unwrap())
4048                .unwrap()
4049                .bind()
4050                .await
4051                .unwrap();
4052            owner.adopt_endpoint(endpoint).await;
4053        }
4054        let ticket = remote.endpoint_ticket().await.unwrap();
4055        let node = remote.current_node_id().await.unwrap();
4056        let endpoint_id = node.parse().unwrap();
4057        client
4058            .start_external_auto_connect("user".into(), "local".into())
4059            .await
4060            .unwrap();
4061        let json = serde_json::to_string(&serde_json::json!([{
4062            "deviceId":"remote", "nodeId":node, "ticket":ticket, "online":true,
4063            "sessionId":"runtime-1", "connectionIntent":"observe",
4064        }]))
4065        .unwrap();
4066        assert!(client
4067            .submit_external_desired_peers(1, &json)
4068            .await
4069            .unwrap());
4070        tokio::time::timeout(std::time::Duration::from_secs(5), async {
4071            while client.external_desired_peer_debug_snapshot().await["reconcileRunning"] == true {
4072                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4073            }
4074        })
4075        .await
4076        .unwrap();
4077        assert!(
4078            client.get_connection(endpoint_id).await.is_none(),
4079            "observing an online routable peer must not dial it"
4080        );
4081        client
4082            .connect_device(Some("remote"), &ticket)
4083            .await
4084            .unwrap();
4085        let transport = client.get_connection(endpoint_id).await.unwrap();
4086        let stable_id = crate::transport_generation::for_connection(&transport);
4087        assert!(client
4088            .submit_external_desired_peers(2, &json)
4089            .await
4090            .unwrap());
4091        tokio::time::timeout(std::time::Duration::from_secs(5), async {
4092            while client.external_desired_peer_debug_snapshot().await["reconcileRunning"] == true {
4093                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4094            }
4095        })
4096        .await
4097        .unwrap();
4098        assert!(transport.close_reason().is_none());
4099        assert_eq!(
4100            crate::transport_generation::for_connection(
4101                &client.get_connection(endpoint_id).await.unwrap()
4102            ),
4103            stable_id
4104        );
4105        // Observation does not pin a superseded endpoint. Directory authority
4106        // still retires the old route, without dialing the new observed endpoint.
4107        let replacement = iroh::SecretKey::generate().public();
4108        let replacement_ticket =
4109            iroh_tickets::endpoint::EndpointTicket::new(iroh::EndpointAddr::new(replacement))
4110                .to_string();
4111        let replacement_json = serde_json::to_string(&serde_json::json!([{
4112            "deviceId":"remote", "nodeId":replacement.to_string(), "ticket":replacement_ticket,
4113            "online":true, "sessionId":"runtime-2", "connectionIntent":"observe",
4114        }]))
4115        .unwrap();
4116        assert!(client
4117            .submit_external_desired_peers(3, &replacement_json)
4118            .await
4119            .unwrap());
4120        tokio::time::timeout(std::time::Duration::from_secs(5), async {
4121            while client.external_desired_peer_debug_snapshot().await["reconcileRunning"] == true {
4122                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4123            }
4124        })
4125        .await
4126        .unwrap();
4127        assert!(
4128            transport.close_reason().is_some(),
4129            "a current observed endpoint must retire its superseded route"
4130        );
4131        assert!(
4132            client.get_connection(replacement).await.is_none(),
4133            "replacement observation must not dial"
4134        );
4135        client.stop_external_auto_connect().await;
4136    }
4137
4138    #[cfg(not(target_arch = "wasm32"))]
4139    #[tokio::test]
4140    async fn transfer_peer_demand_drives_only_the_admitted_observed_peer_and_fences_restart() {
4141        let mut owners = Vec::new();
4142        for _ in 0..3 {
4143            let owner = Arc::new(Client::new_with_app_tag(
4144                crate::test_constants::TEST_PROJECT_ID.into(),
4145                "transfer-demand".into(),
4146                Box::new(|| None),
4147            ));
4148            let endpoint = iroh::Endpoint::builder(iroh::endpoint::presets::N0)
4149                .alpns(vec![crate::native_node::PlutoniumProtocol::ALPN.to_vec()])
4150                .relay_mode(iroh::RelayMode::Disabled)
4151                .bind_addr("127.0.0.1:0".parse::<std::net::SocketAddr>().unwrap())
4152                .unwrap()
4153                .bind()
4154                .await
4155                .unwrap();
4156            owner.adopt_endpoint(endpoint).await;
4157            owners.push(owner);
4158        }
4159        let client = &owners[0];
4160        client
4161            .start_external_auto_connect("user".into(), "local".into())
4162            .await
4163            .unwrap();
4164        let bootstrap = client
4165            .request_transfer_peer(super::super::TransferPeerDemandRequest {
4166                id: "offline-job".into(),
4167                device_id: "offline".into(),
4168                purpose: super::super::TransferPeerDemandPurpose::FileTransfer,
4169            })
4170            .await
4171            .unwrap();
4172        assert_eq!(
4173            bootstrap.state,
4174            super::super::TransferPeerDemandState::WaitingForPeer
4175        );
4176        client
4177            .set_transfer_peer_budget(1, bootstrap.generation)
4178            .await
4179            .unwrap();
4180        let mut peers = Vec::new();
4181        let mut nodes = Vec::new();
4182        for (index, remote) in owners.iter().enumerate().skip(1) {
4183            let node = remote.current_node_id().await.unwrap();
4184            nodes.push(node.parse::<iroh::EndpointId>().unwrap());
4185            peers.push(serde_json::json!({"deviceId":format!("remote-{index}"), "nodeId":node,
4186                "ticket":remote.endpoint_ticket().await.unwrap(), "online":true, "connectionIntent":"observe"}));
4187        }
4188        client
4189            .submit_external_peer_intents(1, &serde_json::to_string(&peers).unwrap())
4190            .await
4191            .unwrap();
4192        let first = client
4193            .request_transfer_peer(super::super::TransferPeerDemandRequest {
4194                id: "first-job".into(),
4195                device_id: "remote-1".into(),
4196                purpose: super::super::TransferPeerDemandPurpose::FileTransfer,
4197            })
4198            .await
4199            .unwrap();
4200        assert_eq!(first.state, super::super::TransferPeerDemandState::Admitted);
4201        let second = client
4202            .request_transfer_peer(super::super::TransferPeerDemandRequest {
4203                id: "second-job".into(),
4204                device_id: "remote-2".into(),
4205                purpose: super::super::TransferPeerDemandPurpose::DriveAccess,
4206            })
4207            .await
4208            .unwrap();
4209        assert_eq!(second.state, super::super::TransferPeerDemandState::Queued);
4210        tokio::time::timeout(std::time::Duration::from_secs(10), async {
4211            while client.get_connection(nodes[0]).await.is_none() {
4212                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4213            }
4214        })
4215        .await
4216        .expect("the admitted demand must reach the existing Iroh dial owner");
4217        assert!(
4218            client.get_connection(nodes[1]).await.is_none(),
4219            "queued work must not dial its observed peer"
4220        );
4221        let first_transport = client.get_connection(nodes[0]).await.unwrap();
4222        let shared = client
4223            .request_transfer_peer(super::super::TransferPeerDemandRequest {
4224                id: "same-peer-job".into(),
4225                device_id: "remote-1".into(),
4226                purpose: super::super::TransferPeerDemandPurpose::ContentSync,
4227            })
4228            .await
4229            .unwrap();
4230        client
4231            .release_transfer_peer(&shared.id, shared.generation)
4232            .await
4233            .unwrap();
4234        assert!(
4235            first_transport.close_reason().is_none(),
4236            "another job must retain the shared physical route"
4237        );
4238        let connection_id = Client::deterministic_connection_id(
4239            &client.current_node_id().await.unwrap(),
4240            &nodes[0].to_string(),
4241        );
4242        let gate = client.managed_connect_gate(&connection_id).await;
4243        let held = gate.lock().await;
4244        let releasing_client = client.clone();
4245        let first_id = first.id.clone();
4246        let releasing = tokio::spawn(async move {
4247            releasing_client
4248                .release_transfer_peer(&first_id, first.generation)
4249                .await
4250                .unwrap()
4251        });
4252        tokio::time::timeout(std::time::Duration::from_secs(5), async {
4253            while client
4254                .transfer_peer_demand_status(&first.id, first.generation)
4255                .await
4256                .unwrap()
4257                .is_some()
4258            {
4259                tokio::task::yield_now().await;
4260            }
4261        })
4262        .await
4263        .expect("the old reference must be removed before waiting at the connect gate");
4264        let requesting_client = client.clone();
4265        let requesting = tokio::spawn(async move {
4266            requesting_client
4267                .request_transfer_peer(super::super::TransferPeerDemandRequest {
4268                    id: "replacement-job".into(),
4269                    device_id: "remote-1".into(),
4270                    purpose: super::super::TransferPeerDemandPurpose::FileTransfer,
4271                })
4272                .await
4273                .unwrap()
4274        });
4275        tokio::time::timeout(std::time::Duration::from_secs(5), async {
4276            while client
4277                .transfer_peer_demand_status("replacement-job", first.generation)
4278                .await
4279                .unwrap()
4280                .is_none()
4281            {
4282                tokio::task::yield_now().await;
4283            }
4284        })
4285        .await
4286        .expect("a new reference must enter the existing owner while retirement waits");
4287        drop(held);
4288        assert!(releasing.await.unwrap());
4289        let replacement = requesting.await.unwrap();
4290        assert!(
4291            first_transport.close_reason().is_none(),
4292            "retirement must recheck a new reference after waiting at the connect gate"
4293        );
4294        client
4295            .release_transfer_peer(&replacement.id, replacement.generation)
4296            .await
4297            .unwrap();
4298        assert!(
4299            first_transport.close_reason().is_some(),
4300            "releasing the last demand must retire its unreferenced physical route"
4301        );
4302        tokio::time::timeout(std::time::Duration::from_secs(10), async {
4303            while client.get_connection(nodes[1]).await.is_none() {
4304                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4305            }
4306        })
4307        .await
4308        .expect("releasing the last demand must admit the next peer");
4309        let second_transport = client.get_connection(nodes[1]).await.unwrap();
4310        let mut automatic = peers.clone();
4311        automatic[1]["connectionIntent"] = serde_json::json!("automatic");
4312        client
4313            .submit_external_peer_intents(2, &serde_json::to_string(&automatic).unwrap())
4314            .await
4315            .unwrap();
4316        client
4317            .release_transfer_peer(&second.id, second.generation)
4318            .await
4319            .unwrap();
4320        assert!(
4321            second_transport.close_reason().is_none(),
4322            "an automatic avenue must retain its shared route"
4323        );
4324        client
4325            .connect_device(
4326                Some("remote-2"),
4327                &owners[2].endpoint_ticket().await.unwrap(),
4328            )
4329            .await
4330            .unwrap();
4331        client
4332            .submit_external_peer_intents(3, &serde_json::to_string(&peers).unwrap())
4333            .await
4334            .unwrap();
4335        let manual_shared = client
4336            .request_transfer_peer(super::super::TransferPeerDemandRequest {
4337                id: "manual-shared-job".into(),
4338                device_id: "remote-2".into(),
4339                purpose: super::super::TransferPeerDemandPurpose::DriveAccess,
4340            })
4341            .await
4342            .unwrap();
4343        client
4344            .release_transfer_peer(&manual_shared.id, manual_shared.generation)
4345            .await
4346            .unwrap();
4347        assert!(
4348            second_transport.close_reason().is_none(),
4349            "an explicit connection must survive final bulk release"
4350        );
4351        client.stop_external_auto_connect().await;
4352        client
4353            .start_external_auto_connect("user".into(), "local".into())
4354            .await
4355            .unwrap();
4356        assert!(
4357            client
4358                .release_transfer_peer(&second.id, second.generation)
4359                .await
4360                .is_err(),
4361            "an old job release cannot mutate a restarted same-identity owner"
4362        );
4363        client.stop_external_auto_connect().await;
4364    }
4365    #[cfg(not(target_arch = "wasm32"))]
4366    #[tokio::test]
4367    async fn duplicate_external_auto_connect_preserves_gateway_mailbox_generation() {
4368        let client = std::sync::Arc::new(Client::new_with_app_tag(
4369            crate::test_constants::TEST_PROJECT_ID.to_string(),
4370            "test-app".to_string(),
4371            Box::new(|| None),
4372        ));
4373        client
4374            .start_external_auto_connect("test-user".to_string(), "local-device".to_string())
4375            .await
4376            .expect("external actor starts");
4377        assert!(client
4378            .submit_external_desired_peers(7, "[]")
4379            .await
4380            .expect("gateway revision is accepted"));
4381
4382        let before = client.external_desired_peer_debug_snapshot().await;
4383        client
4384            .start_external_auto_connect("test-user".to_string(), "local-device".to_string())
4385            .await
4386            .expect("duplicate start is idempotent");
4387        let after = client.external_desired_peer_debug_snapshot().await;
4388
4389        assert_eq!(after["generation"], before["generation"]);
4390        assert_eq!(after["revision"], serde_json::json!(7));
4391        assert_eq!(after["active"], serde_json::json!(true));
4392    }
4393}
4394
4395#[cfg(not(target_arch = "wasm32"))]
4396fn should_suppress_auto_connect_for_replacement_window(
4397    snapshot: &ConnectionHealthView,
4398    now_ms: i64,
4399    transport_connected: bool,
4400) -> bool {
4401    if !snapshot.replacement_pending || transport_connected {
4402        return false;
4403    }
4404
4405    if now_ms.saturating_sub(snapshot.last_lifecycle_transition_at_ms) >= MANAGED_SETTLE_DEADLINE_MS
4406    {
4407        return false;
4408    }
4409
4410    // A transient network drop clears the active transport but does not itself
4411    // create a successor transport. Auto-connect must redial immediately;
4412    // otherwise native->browser edges remain stuck in transport-only readiness.
4413    !matches!(
4414        snapshot.last_reconnect_reason.as_deref(),
4415        Some("transient-disconnect")
4416    )
4417}
4418
4419impl Client {
4420    async fn retire_desired_peer_connections(
4421        &self,
4422        remote_device_id: &str,
4423        remote_node_id: Option<&str>,
4424        reason: &str,
4425    ) {
4426        let mut records = self.resolve_peer_connection_records(remote_device_id).await;
4427        if records.is_empty() {
4428            if let Some(node_id) = remote_node_id
4429                .map(str::trim)
4430                .filter(|value| !value.is_empty())
4431            {
4432                records = self.resolve_peer_connection_records(node_id).await;
4433            }
4434        }
4435
4436        for record in records {
4437            let endpoint_id = record
4438                .endpoint_id
4439                .as_deref()
4440                .or(record.node_id.as_deref())
4441                .and_then(|value| value.trim().parse::<iroh::EndpointId>().ok());
4442            let disconnected = if let Some(endpoint_id) = endpoint_id {
4443                self.disconnect_with_reason(endpoint_id, reason)
4444                    .await
4445                    .is_ok()
4446            } else {
4447                false
4448            };
4449            if !disconnected {
4450                self.retire_managed_connection(&record.connection_id, Some(reason.to_string()))
4451                    .await;
4452            }
4453            eprintln!(
4454                "[pluto-rtc][auto-connect] retired undesired connection remote_device_id={} connection_id={} reason={} transport_closed={}",
4455                remote_device_id, record.connection_id, reason, disconnected
4456            );
4457        }
4458    }
4459
4460    async fn retire_withdrawn_desired_peer_connections(
4461        &self,
4462        remote_device_id: &str,
4463        remote_node_id: Option<&str>,
4464    ) {
4465        self.retire_desired_peer_connections(
4466            remote_device_id,
4467            remote_node_id,
4468            crate::lifecycle_reason::REASON_DESIRED_PEER_WITHDRAWN,
4469        )
4470        .await;
4471    }
4472
4473    #[cfg(not(target_arch = "wasm32"))]
4474    pub(crate) async fn retire_offline_desired_route(
4475        &self,
4476        remote_device_id: &str,
4477        remote_node_id: Option<&str>,
4478    ) {
4479        self.retire_withdrawn_desired_peer_connections(remote_device_id, remote_node_id)
4480            .await;
4481    }
4482
4483    pub async fn is_connection_transport_alive(&self, endpoint_id: iroh::EndpointId) -> bool {
4484        if !self.is_connected(endpoint_id).await {
4485            return false;
4486        }
4487
4488        #[cfg(not(target_arch = "wasm32"))]
4489        {
4490            let Some(connection) = self.get_connection(endpoint_id).await else {
4491                return false;
4492            };
4493
4494            return connection.close_reason().is_none();
4495        }
4496
4497        #[cfg(target_arch = "wasm32")]
4498        {
4499            let Some(connection) = self.get_connection(endpoint_id).await else {
4500                return false;
4501            };
4502
4503            connection.close_reason().is_none()
4504        }
4505    }
4506
4507    #[cfg(not(target_arch = "wasm32"))]
4508    pub(crate) async fn validate_active_connection(
4509        &self,
4510        endpoint_id: iroh::EndpointId,
4511    ) -> Option<crate::heartbeat::IrohConnectionProbe> {
4512        if !self.is_connection_transport_alive(endpoint_id).await {
4513            return None;
4514        }
4515        let node = self.iroh_node.read().await.as_ref().cloned()?;
4516        node.probe_connection(endpoint_id, std::time::Duration::from_secs(4))
4517            .await
4518    }
4519
4520    #[cfg(not(target_arch = "wasm32"))]
4521    pub(crate) async fn should_force_network_change_after_connect_failures(
4522        &self,
4523        remote_device_id: &str,
4524    ) -> bool {
4525        for record in self.connection_manager.list_active().await {
4526            if !matches!(
4527                record.state,
4528                crate::connection_manager::ConnectionState::Connected
4529            ) {
4530                continue;
4531            }
4532
4533            let Some(record_device_id) = record
4534                .device_id
4535                .as_deref()
4536                .or(record.device_id_hint.as_deref())
4537                .map(str::trim)
4538                .filter(|value| !value.is_empty())
4539            else {
4540                continue;
4541            };
4542
4543            if record_device_id == remote_device_id {
4544                continue;
4545            }
4546
4547            eprintln!(
4548                "[pluto-rtc][auto-connect] suppress network-change recovery remote_device_id={} because healthy peer remains connected peer_device_id={} connection_id={}",
4549                remote_device_id,
4550                record_device_id,
4551                record.connection_id
4552            );
4553            return false;
4554        }
4555
4556        true
4557    }
4558
4559    #[cfg(not(target_arch = "wasm32"))]
4560    pub(crate) async fn try_auto_connect_device(
4561        self: &Arc<Self>,
4562        user_id: &str,
4563        device: crate::signaling::Device,
4564        local_device_id: &str,
4565        last_attempt_at: &mut std::collections::HashMap<String, i64>,
4566        failure_count: &mut std::collections::HashMap<String, u8>,
4567        failure_backoff_until: &mut std::collections::HashMap<String, i64>,
4568        retry_throttle_ms: i64,
4569        non_initiator_wait_started_at: &mut std::collections::HashMap<String, i64>,
4570        non_initiator_escalation_count: &mut std::collections::HashMap<String, u8>,
4571        initiator_grace_ms: i64,
4572        skip_log_at: &mut std::collections::HashMap<String, i64>,
4573        connected_health_checked_at: &mut std::collections::HashMap<String, i64>,
4574        connected_health_state: &mut std::collections::HashMap<String, bool>,
4575        connected_health_failures: &mut std::collections::HashMap<String, u8>,
4576        connected_health_probe_ms: i64,
4577        connected_health_failure_threshold: u8,
4578        last_presence_republish_at_ms: &mut i64,
4579        presence_republish_interval_ms: i64,
4580        last_network_change_at_ms: &mut i64,
4581        network_change_recovery_interval_ms: i64,
4582        network_change_failure_threshold: u8,
4583        last_known_node_id: &mut std::collections::HashMap<String, String>,
4584    ) {
4585        if self.is_app_execution_suspended() {
4586            return;
4587        }
4588
4589        #[cfg(target_arch = "wasm32")]
4590        let _ = (
4591            user_id,
4592            last_presence_republish_at_ms,
4593            presence_republish_interval_ms,
4594            last_network_change_at_ms,
4595            network_change_recovery_interval_ms,
4596            network_change_failure_threshold,
4597        );
4598
4599        let remote_device_id = device.device_id.clone();
4600        let now = now_millis_i64();
4601        let skip_log_interval_ms = 2_000;
4602
4603        let mut log_skip = |reason: &str| {
4604            let is_noise_reason = matches!(
4605                reason,
4606                "already-connected"
4607                    | "tie-break-not-initiator-waiting"
4608                    | "retry-throttle"
4609                    | "self-ticket"
4610                    | "auto-connect-excluded"
4611                    | "remote-excluded-local-device"
4612            );
4613            if is_noise_reason && !auto_connect_verbose() {
4614                return;
4615            }
4616
4617            let skip_key = format!("{}::{}", remote_device_id, reason);
4618            let should_log = match skip_log_at.get(&skip_key) {
4619                Some(last_at) => now.saturating_sub(*last_at) >= skip_log_interval_ms,
4620                None => true,
4621            };
4622            if !should_log {
4623                return;
4624            }
4625            skip_log_at.insert(skip_key, now);
4626
4627            #[cfg(not(target_arch = "wasm32"))]
4628            eprintln!(
4629                "[pluto-rtc][auto-connect] skip remote_device_id={} local_device_id={} reason={}",
4630                remote_device_id, local_device_id, reason
4631            );
4632            #[cfg(target_arch = "wasm32")]
4633            web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
4634                "[pluto-rtc][auto-connect] skip remote_device_id={} local_device_id={} reason={}",
4635                remote_device_id, local_device_id, reason
4636            )));
4637        };
4638
4639        if !device.online {
4640            last_attempt_at.remove(&remote_device_id);
4641            non_initiator_wait_started_at.remove(&remote_device_id);
4642            non_initiator_escalation_count.remove(&remote_device_id);
4643            connected_health_checked_at.remove(&remote_device_id);
4644            connected_health_state.remove(&remote_device_id);
4645            connected_health_failures.remove(&remote_device_id);
4646            clear_auto_connect_failure_state(
4647                &remote_device_id,
4648                failure_count,
4649                failure_backoff_until,
4650            );
4651            return;
4652        }
4653
4654        if remote_device_id == local_device_id {
4655            last_attempt_at.remove(&remote_device_id);
4656            non_initiator_wait_started_at.remove(&remote_device_id);
4657            non_initiator_escalation_count.remove(&remote_device_id);
4658            connected_health_checked_at.remove(&remote_device_id);
4659            connected_health_state.remove(&remote_device_id);
4660            connected_health_failures.remove(&remote_device_id);
4661            clear_auto_connect_failure_state(
4662                &remote_device_id,
4663                failure_count,
4664                failure_backoff_until,
4665            );
4666            return;
4667        }
4668
4669        if remote_excludes_local_device(&device.excluded_peers, local_device_id) {
4670            log_skip("remote-excluded-local-device");
4671            return;
4672        }
4673
4674        if self.is_auto_connect_peer_excluded(&remote_device_id, device.node_id.as_deref()) {
4675            log_skip("auto-connect-excluded");
4676            return;
4677        }
4678
4679        let provider_ticket = match device.ticket.as_deref() {
4680            Some(ticket) if !ticket.trim().is_empty() => ticket,
4681            _ => {
4682                log_skip("missing-ticket");
4683                return;
4684            }
4685        };
4686
4687        let (provider_iroh_ticket, _) = crate::session_token::split_ticket(provider_ticket.trim());
4688        let provider_endpoint_addr = match parse_endpoint_ticket(provider_iroh_ticket) {
4689            Ok(addr) => addr,
4690            Err(error) => {
4691                log_skip("invalid-ticket");
4692                eprintln!(
4693                    "[pluto-rtc][auto-connect] invalid ticket remote_device_id={} error={}",
4694                    remote_device_id, error
4695                );
4696                return;
4697            }
4698        };
4699        let preferred_ticket = self.preferred_scoped_route_ticket(
4700            provider_endpoint_addr.id.to_string().as_str(),
4701            provider_iroh_ticket,
4702        );
4703        let ticket_str = preferred_ticket.as_deref().unwrap_or(provider_ticket);
4704
4705        // Strip compound ticket suffix for iroh parsing.  The token is
4706        // extracted and will be auto-presented to the host after connection.
4707        let (iroh_ticket, token_suffix) = crate::session_token::split_ticket(ticket_str.trim());
4708        let extracted_token_payload = token_suffix
4709            .and_then(|suffix| crate::session_token::decode_payload(iroh_ticket, suffix));
4710        let extracted_token = extracted_token_payload
4711            .as_ref()
4712            .map(|payload| payload.token.clone());
4713        let extracted_token_suffix = token_suffix
4714            .filter(|_| extracted_token_payload.is_some())
4715            .map(ToOwned::to_owned);
4716
4717        let endpoint_addr = provider_endpoint_addr;
4718        #[cfg(feature = "transport-lan")]
4719        if self.transport_config.read().await.network_policy
4720            == crate::client::NetworkPolicy::LocalOnly
4721            && !crate::local_discovery::endpoint_addr_is_local_only(&endpoint_addr, &[])
4722        {
4723            log_skip("local-only-address-rejected");
4724            return;
4725        }
4726        let endpoint_id = endpoint_addr.id;
4727        let node_id_str = endpoint_id.to_string();
4728        #[cfg(not(target_arch = "wasm32"))]
4729        if let Some(payload) = extracted_token_payload
4730            .as_ref()
4731            .filter(|payload| payload.scope.as_str() == "user-device")
4732        {
4733            self.update_native_route_repair_credential(
4734                &node_id_str,
4735                Some(&remote_device_id),
4736                extracted_token.as_deref(),
4737                extracted_token_suffix.as_deref(),
4738                &payload.scope,
4739            );
4740        }
4741        let runtime_instance_changed = observe_remote_runtime_instance(
4742            last_known_node_id,
4743            &remote_device_id,
4744            device.session_id.as_deref(),
4745        );
4746        let ticket_changed = reconcile_remote_ticket_retry_state(
4747            last_known_node_id,
4748            &remote_device_id,
4749            provider_ticket,
4750            last_attempt_at,
4751            failure_count,
4752            failure_backoff_until,
4753        );
4754
4755        if ticket_changed {
4756            eprintln!(
4757                "[pluto-rtc][auto-connect] peer ticket changed remote_device_id={} node_id={} - clearing stale retry deadline",
4758                remote_device_id, node_id_str
4759            );
4760        }
4761
4762        // Detect peer identity change (e.g. web refresh → new node_id).
4763        // When detected, clear all retry/health/backoff state so reconnection
4764        // proceeds immediately without being blocked by stale throttles.
4765        {
4766            let identity_changed = last_known_node_id
4767                .get(&remote_device_id)
4768                .map(|prev| prev.as_str() != node_id_str)
4769                .unwrap_or(false);
4770            last_known_node_id.insert(remote_device_id.clone(), node_id_str.clone());
4771            if identity_changed {
4772                #[cfg(not(target_arch = "wasm32"))]
4773                eprintln!(
4774                    "[pluto-rtc][auto-connect] peer identity changed remote_device_id={} new_node_id={} — resetting state for fast reconnect",
4775                    remote_device_id, node_id_str
4776                );
4777                #[cfg(target_arch = "wasm32")]
4778                web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
4779                    "[pluto-rtc][auto-connect] peer identity changed remote_device_id={} new_node_id={} — resetting state for fast reconnect",
4780                    remote_device_id, node_id_str
4781                )));
4782                last_attempt_at.remove(&remote_device_id);
4783                non_initiator_wait_started_at.remove(&remote_device_id);
4784                non_initiator_escalation_count.remove(&remote_device_id);
4785                connected_health_checked_at.remove(&remote_device_id);
4786                connected_health_state.remove(&remote_device_id);
4787                connected_health_failures.remove(&remote_device_id);
4788                clear_auto_connect_failure_state(
4789                    &remote_device_id,
4790                    failure_count,
4791                    failure_backoff_until,
4792                );
4793            }
4794        }
4795
4796        let local_node_id = match self.current_node_id().await {
4797            Some(id) => id,
4798            None => {
4799                log_skip("local-node-not-ready");
4800                return;
4801            }
4802        };
4803
4804        if local_node_id == node_id_str {
4805            log_skip("self-ticket");
4806            return;
4807        }
4808
4809        if runtime_instance_changed {
4810            // Presence is coordination evidence, not physical-lifecycle
4811            // authority. A newly launched peer can accept a replacement route
4812            // before its new gateway lease wins aggregation. Retiring that
4813            // already-responsive route when the delayed instance arrives
4814            // creates needless generation churn. Probe the exact current
4815            // generation and retire it only when the peer no longer responds.
4816            let current_transport = self.get_connection(endpoint_id).await;
4817            let current_transport_stable_id = current_transport
4818                .as_ref()
4819                .map(crate::transport_generation::for_connection);
4820            #[cfg(openrtc_iroh_retire_remote_connections_api)]
4821            if let Some(current_transport) = current_transport.as_ref() {
4822                let transport_stable_id =
4823                    crate::transport_generation::for_connection(current_transport);
4824                // One Iroh endpoint can carry OpenRTC control traffic and
4825                // independent application protocols. A durable node identity
4826                // survives process restart, so those other protocol
4827                // connections can otherwise remain attached to the retired
4828                // process generation even after the new OpenRTC connection is
4829                // healthy. Runtime-instance replacement is the typed lifecycle
4830                // evidence: fence every other connection for this peer while
4831                // preserving the exact admitted OpenRTC generation.
4832                let endpoint = { self.iroh_endpoint.read().await.clone() };
4833                if let Some(endpoint) = endpoint {
4834                    let retired = retire_stale_endpoint_connections(
4835                        &endpoint,
4836                        endpoint_id,
4837                        current_transport,
4838                        crate::lifecycle_reason::REASON_STALE_ACTIVE_CONNECTION_RECONNECT
4839                            .as_bytes(),
4840                    )
4841                    .await;
4842                    if retired > 0 {
4843                        eprintln!(
4844                            "[pluto-rtc][auto-connect] peer runtime instance changed remote_device_id={} node_id={} - retired {} stale shared-endpoint protocol connection(s), preserved transport_stable_id={}",
4845                            remote_device_id, node_id_str, retired, transport_stable_id
4846                        );
4847                    }
4848                }
4849            }
4850            let active_transport_responsive = if current_transport_stable_id.is_some() {
4851                self.validate_active_connection(endpoint_id)
4852                    .await
4853                    .is_some_and(|probe| probe.responsive)
4854            } else {
4855                false
4856            };
4857            if runtime_instance_change_requires_replacement(
4858                runtime_instance_changed,
4859                active_transport_responsive,
4860            ) {
4861                if let Some(transport_stable_id) = current_transport_stable_id {
4862                    match self
4863                        .retire_transport_generation_after_failed_probe(
4864                            &Self::deterministic_connection_id(&local_node_id, &node_id_str),
4865                            endpoint_id,
4866                            transport_stable_id,
4867                            connected_health_probe_ms,
4868                            crate::lifecycle_reason::REASON_STALE_ACTIVE_CONNECTION_RECONNECT,
4869                        )
4870                        .await
4871                    {
4872                        FailedProbeRetirementOutcome::Retired => {
4873                            eprintln!(
4874                                "[pluto-rtc][auto-connect] peer runtime instance changed remote_device_id={} node_id={} - replaced unresponsive physical transport",
4875                                remote_device_id, node_id_str
4876                            );
4877                        }
4878                        FailedProbeRetirementOutcome::DeferredAdmissionInFlight => {
4879                            eprintln!(
4880                                "[pluto-rtc][auto-connect] peer runtime instance changed remote_device_id={} node_id={} - deferred replacement while admission is in flight",
4881                                remote_device_id, node_id_str
4882                            );
4883                        }
4884                        FailedProbeRetirementOutcome::CancelledCurrentGenerationActivity => {
4885                            eprintln!(
4886                                "[pluto-rtc][auto-connect] peer runtime instance changed remote_device_id={} node_id={} - preserved generation with recent protocol activity",
4887                                remote_device_id, node_id_str
4888                            );
4889                        }
4890                        FailedProbeRetirementOutcome::GenerationChanged => {
4891                            eprintln!(
4892                                "[pluto-rtc][auto-connect] peer runtime instance changed remote_device_id={} node_id={} - ignored stale generation",
4893                                remote_device_id, node_id_str
4894                            );
4895                        }
4896                    }
4897                }
4898            } else {
4899                eprintln!(
4900                    "[pluto-rtc][auto-connect] peer runtime instance changed remote_device_id={} node_id={} - preserving responsive physical transport",
4901                    remote_device_id, node_id_str
4902                );
4903            }
4904            last_attempt_at.remove(&remote_device_id);
4905            non_initiator_wait_started_at.remove(&remote_device_id);
4906            non_initiator_escalation_count.remove(&remote_device_id);
4907            connected_health_checked_at.remove(&remote_device_id);
4908            connected_health_state.remove(&remote_device_id);
4909            connected_health_failures.remove(&remote_device_id);
4910            clear_auto_connect_failure_state(
4911                &remote_device_id,
4912                failure_count,
4913                failure_backoff_until,
4914            );
4915        }
4916
4917        if let Some(discovered_node_id) = device.node_id.as_deref() {
4918            if discovered_node_id != node_id_str {
4919                log_skip("ticket-node-mismatch-discovery");
4920                eprintln!(
4921                    "[pluto-rtc][auto-connect] rejected inconsistent discovery identity remote_device_id={} discovered_node_id={} ticket_node_id={}",
4922                    remote_device_id, discovered_node_id, node_id_str
4923                );
4924                return;
4925            }
4926        }
4927
4928        // The discovery ticket/node pair is the current coordination authority
4929        // for this durable device. A transport to an older node can remain open
4930        // until QUIC idle timeout after a browser/app restart; it must not keep
4931        // owning the logical device session or receive application payloads.
4932        // Run this even when the loop did not observe the previous node itself
4933        // (for example, subscription replacement between snapshots).
4934        self.reconcile_authoritative_device_node(&remote_device_id, &node_id_str)
4935            .await;
4936
4937        #[cfg(feature = "transport-lan")]
4938        if self.is_peer_locally_reachable(&node_id_str).await {
4939            eprintln!(
4940                "[pluto-rtc][auto-connect] LAN peer reachable remote_device_id={} node_id={}",
4941                remote_device_id, node_id_str
4942            );
4943        }
4944
4945        let connection_id = Self::deterministic_connection_id(&local_node_id, &node_id_str);
4946
4947        let managed_health_snapshot = self.managed_connection_health(&connection_id).await;
4948
4949        #[cfg(not(target_arch = "wasm32"))]
4950        {
4951            if let Some(existing) = self
4952                .connection_manager
4953                .get_by_connection_id(&connection_id)
4954                .await
4955            {
4956                let existing_device_id = existing.device_id.as_deref().map(str::trim);
4957                if existing_device_id != Some(remote_device_id.as_str()) {
4958                    self.connection_manager
4959                        .set_device_id(&connection_id, remote_device_id.clone())
4960                        .await;
4961                    eprintln!(
4962                        "[pluto-rtc][auto-connect] backfilled device_id hint connection_id={} node_id={} device_id={}",
4963                        connection_id, node_id_str, remote_device_id
4964                    );
4965                }
4966            }
4967        }
4968
4969        // The native managed actor owns the durable logical Iroh connection.
4970        // A carrier mechanism cannot repair a missing committed connection on
4971        // its own, so only generation-current Iroh liveness may suppress the
4972        // bounded redial after a network drop.
4973        #[cfg(not(target_arch = "wasm32"))]
4974        let transport_connected = self.is_connection_transport_alive(endpoint_id).await;
4975        #[cfg(target_arch = "wasm32")]
4976        let transport_connected = self.is_connected(endpoint_id).await;
4977        if let Some(snapshot) = managed_health_snapshot.as_ref() {
4978            // The Rust core owns replacement recovery. Suppress a competing
4979            // dial while no transport owns the replacement window, but never
4980            // let that suppression bypass positive liveness probes for an
4981            // already-owned generation.
4982            if should_suppress_auto_connect_for_replacement_window(
4983                &snapshot,
4984                now,
4985                transport_connected,
4986            ) {
4987                log_skip("replacement-pending");
4988                return;
4989            }
4990        }
4991        #[cfg(target_arch = "wasm32")]
4992        if !transport_connected {
4993            if let Some(existing) = self
4994                .connection_manager
4995                .get_by_connection_id(&connection_id)
4996                .await
4997            {
4998                if matches!(
4999                    existing.state,
5000                    crate::connection_manager::ConnectionState::Connected
5001                ) {
5002                    web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
5003                        "[pluto-rtc][auto-connect][wasm] transport mismatch remote_device_id={} remote_node_id={} connection_id={} record_stable_id={:?} record_generation={} state={:?} status_reason={:?}",
5004                        remote_device_id,
5005                        node_id_str,
5006                        connection_id,
5007                        existing.transport_stable_id,
5008                        existing.transport_generation,
5009                        existing.state,
5010                        existing.status_reason
5011                    )));
5012                }
5013            }
5014        }
5015
5016        // Tie breaker only controls who should dial.
5017        // If an incoming transport is already connected, the non-initiator must
5018        // observe and promote that transport instead of waiting forever for the
5019        // initiator path to "win".
5020        //
5021        // Use exponential backoff with jitter so repeated escalations don't
5022        // produce duplicate-storm collisions (2s→4s→8s→16s cap, ±20% jitter).
5023        let esc_count = non_initiator_escalation_count
5024            .get(&remote_device_id)
5025            .copied()
5026            .unwrap_or(0);
5027        let effective_grace_ms = if local_node_id <= node_id_str {
5028            non_initiator_escalation_grace_ms(esc_count)
5029        } else {
5030            initiator_grace_ms
5031        };
5032        let waited_ms = if local_node_id <= node_id_str {
5033            let wait_started_at = non_initiator_wait_started_at
5034                .entry(remote_device_id.clone())
5035                .or_insert(now);
5036            now.saturating_sub(*wait_started_at)
5037        } else {
5038            0
5039        };
5040        match auto_connect_tie_break_decision(
5041            &local_node_id,
5042            &node_id_str,
5043            transport_connected,
5044            waited_ms,
5045            effective_grace_ms,
5046        ) {
5047            AutoConnectTieBreakDecision::ObserveConnectedTransport => {
5048                non_initiator_wait_started_at.remove(&remote_device_id);
5049                non_initiator_escalation_count.remove(&remote_device_id);
5050            }
5051            AutoConnectTieBreakDecision::WaitForInitiator => {
5052                if waited_ms < 1_000 {
5053                    log_skip("tie-break-not-initiator-waiting");
5054                }
5055                return;
5056            }
5057            AutoConnectTieBreakDecision::ActAsInitiator => {
5058                if local_node_id <= node_id_str {
5059                    // Non-initiator escalated after grace period expired.
5060                    let new_esc_count = esc_count.saturating_add(1);
5061                    non_initiator_escalation_count.insert(remote_device_id.clone(), new_esc_count);
5062                    #[cfg(not(target_arch = "wasm32"))]
5063                    eprintln!(
5064                        "[pluto-rtc][auto-connect] non-initiator escalated after {}ms grace remote_device_id={} local_device_id={} escalation_count={}",
5065                        waited_ms, remote_device_id, local_device_id, new_esc_count
5066                    );
5067                    #[cfg(target_arch = "wasm32")]
5068                    web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
5069                        "[pluto-rtc][auto-connect] non-initiator escalated after {}ms grace remote_device_id={} local_device_id={} escalation_count={}",
5070                        waited_ms, remote_device_id, local_device_id, new_esc_count
5071                    )));
5072                }
5073                non_initiator_wait_started_at.remove(&remote_device_id);
5074            }
5075        }
5076
5077        self.reconcile_authoritative_device_node(&remote_device_id, &node_id_str)
5078            .await;
5079
5080        if transport_connected {
5081            let current_scopes = self.connection_manager.get_scopes(&connection_id).await;
5082            let admitted_scope = match self.session_admission(&connection_id) {
5083                crate::session_token::SessionAdmission::Accepted {
5084                    scope: Some(scope), ..
5085                } => Some(scope.into_inner()),
5086                _ => None,
5087            };
5088            let presentation_in_flight = self
5089                .session_token_registry
5090                .is_session_token_presentation_in_flight(&connection_id);
5091            if !persistent_managed_actor_may_own_transport(
5092                &current_scopes,
5093                admitted_scope.as_deref(),
5094                presentation_in_flight,
5095            ) {
5096                log_skip(if presentation_in_flight {
5097                    "explicit-admission-presentation-owns-current-transport"
5098                } else {
5099                    "non-managed-scope-owns-current-transport"
5100                });
5101                return;
5102            }
5103
5104            connected_health_checked_at
5105                .entry(remote_device_id.clone())
5106                .or_insert(now);
5107            connected_health_state
5108                .entry(remote_device_id.clone())
5109                .or_insert(true);
5110
5111            let should_probe = match connected_health_checked_at.get(&remote_device_id) {
5112                Some(last_probe_at) => {
5113                    now.saturating_sub(*last_probe_at) >= connected_health_probe_ms
5114                }
5115                None => false,
5116            };
5117            let mut healthy = connected_health_state
5118                .get(&remote_device_id)
5119                .copied()
5120                .unwrap_or(true);
5121            let mut probed_transport_stable_id = None;
5122
5123            if should_probe {
5124                let active_probe = self.validate_active_connection(endpoint_id).await;
5125                probed_transport_stable_id =
5126                    active_probe.as_ref().map(|probe| probe.transport_stable_id);
5127                let active_probe_healthy =
5128                    active_probe.as_ref().is_some_and(|probe| probe.responsive);
5129                let passive_transport_alive = self.is_connection_transport_alive(endpoint_id).await;
5130                let recent_generation_activity = active_probe.as_ref().is_some_and(|probe| {
5131                    generation_activity_is_recent(
5132                        probe.last_inbound_activity_age,
5133                        connected_health_probe_ms,
5134                    ) || generation_activity_is_recent(
5135                        self.native_transport_protocol_activity_age(
5136                            &connection_id,
5137                            probe.transport_stable_id,
5138                        ),
5139                        connected_health_probe_ms,
5140                    )
5141                });
5142                let probe_decision = active_connection_probe_decision_with_generation_activity(
5143                    active_probe_healthy,
5144                    passive_transport_alive,
5145                    recent_generation_activity,
5146                );
5147                let current_failures = connected_health_failures
5148                    .get(&remote_device_id)
5149                    .copied()
5150                    .unwrap_or(0);
5151                let failures =
5152                    next_active_connection_probe_failure_count(probe_decision, current_failures);
5153                healthy = active_connection_probe_is_healthy(
5154                    probe_decision,
5155                    failures,
5156                    connected_health_failure_threshold,
5157                );
5158                connected_health_checked_at.insert(remote_device_id.clone(), now);
5159                connected_health_state.insert(remote_device_id.clone(), healthy);
5160                if matches!(probe_decision, ActiveConnectionProbeDecision::Healthy) {
5161                    connected_health_failures.remove(&remote_device_id);
5162                    // Keep the canonical peer snapshot aligned with the transport
5163                    // liveness decision. This is especially important after an
5164                    // in-place app relaunch: the old generation may have marked the
5165                    // peer stale while the replacement transport is already live.
5166                    // A matching pong is current-generation readiness evidence.
5167                    let _ = self
5168                        .confirm_managed_connection_readiness(&connection_id)
5169                        .await;
5170                } else {
5171                    connected_health_failures.insert(remote_device_id.clone(), failures);
5172                    if matches!(
5173                        probe_decision,
5174                        ActiveConnectionProbeDecision::PreserveLiveTransport
5175                    ) && healthy
5176                    {
5177                        log_skip("active-probe-missed-preserving-live-transport");
5178                    }
5179                }
5180            }
5181
5182            if !healthy {
5183                if !should_probe {
5184                    log_skip("awaiting-current-generation-health-probe");
5185                    return;
5186                }
5187                if self.is_app_execution_suspended() {
5188                    log_skip("backgrounded-after-health-probe");
5189                    return;
5190                }
5191                #[cfg(not(target_arch = "wasm32"))]
5192                {
5193                    let failures = connected_health_failures
5194                        .get(&remote_device_id)
5195                        .copied()
5196                        .unwrap_or(1);
5197                    if failures < connected_health_failure_threshold {
5198                        log_skip("active-connection-health-probe-failed");
5199                        return;
5200                    }
5201                    let Some(transport_stable_id) = probed_transport_stable_id else {
5202                        log_skip("active-probe-generation-disappeared");
5203                        connected_health_checked_at.remove(&remote_device_id);
5204                        connected_health_state.remove(&remote_device_id);
5205                        connected_health_failures.remove(&remote_device_id);
5206                        return;
5207                    };
5208                    match self
5209                        .retire_transport_generation_after_failed_probe(
5210                            &connection_id,
5211                            endpoint_id,
5212                            transport_stable_id,
5213                            connected_health_probe_ms,
5214                            crate::lifecycle_reason::REASON_STALE_ACTIVE_CONNECTION_RECONNECT,
5215                        )
5216                        .await
5217                    {
5218                        FailedProbeRetirementOutcome::Retired => {
5219                            log_skip("stale-active-connection-reconnect");
5220                        }
5221                        FailedProbeRetirementOutcome::DeferredAdmissionInFlight => {
5222                            log_skip("active-probe-retirement-deferred-admission-in-flight");
5223                            connected_health_checked_at.insert(remote_device_id.clone(), now);
5224                            connected_health_state.insert(remote_device_id.clone(), true);
5225                            connected_health_failures.remove(&remote_device_id);
5226                            return;
5227                        }
5228                        FailedProbeRetirementOutcome::CancelledCurrentGenerationActivity => {
5229                            log_skip(
5230                                "active-probe-retirement-cancelled-by-current-generation-activity",
5231                            );
5232                            connected_health_checked_at.insert(remote_device_id.clone(), now);
5233                            connected_health_state.insert(remote_device_id.clone(), true);
5234                            connected_health_failures.remove(&remote_device_id);
5235                            let _ = self
5236                                .confirm_managed_connection_readiness(&connection_id)
5237                                .await;
5238                            return;
5239                        }
5240                        FailedProbeRetirementOutcome::GenerationChanged => {
5241                            log_skip("active-probe-generation-disappeared");
5242                            connected_health_checked_at.remove(&remote_device_id);
5243                            connected_health_state.remove(&remote_device_id);
5244                            connected_health_failures.remove(&remote_device_id);
5245                            return;
5246                        }
5247                    }
5248                    self.maybe_refresh_gateway_presence_for_auto_connect(
5249                        user_id,
5250                        local_device_id,
5251                        "stale-active-connection-reconnect",
5252                        last_presence_republish_at_ms,
5253                        presence_republish_interval_ms,
5254                    )
5255                    .await;
5256                    self.reconcile_authoritative_device_node(&remote_device_id, &node_id_str)
5257                        .await;
5258                    connected_health_checked_at.remove(&remote_device_id);
5259                    connected_health_state.remove(&remote_device_id);
5260                    connected_health_failures.remove(&remote_device_id);
5261                    if failures >= network_change_failure_threshold {
5262                        self.maybe_force_network_change_for_auto_connect(
5263                            user_id,
5264                            local_device_id,
5265                            "stale-active-connection",
5266                            last_network_change_at_ms,
5267                            network_change_recovery_interval_ms,
5268                            last_presence_republish_at_ms,
5269                            presence_republish_interval_ms,
5270                        )
5271                        .await;
5272                    }
5273                }
5274                #[cfg(target_arch = "wasm32")]
5275                {
5276                    let failures = connected_health_failures
5277                        .get(&remote_device_id)
5278                        .copied()
5279                        .unwrap_or(1);
5280                    if failures < connected_health_failure_threshold {
5281                        eprintln!(
5282                            "[pluto-rtc][auto-connect][health] remote_device_id={} local_device_id={} remote_node_id={} healthy=false failures={} threshold={} action=wait",
5283                            remote_device_id,
5284                            local_device_id,
5285                            node_id_str,
5286                            failures,
5287                            connected_health_failure_threshold
5288                        );
5289                        log_skip("active-connection-health-probe-failed");
5290                        return;
5291                    }
5292                    eprintln!(
5293                        "[pluto-rtc][auto-connect][health] remote_device_id={} local_device_id={} remote_node_id={} healthy=false failures={} threshold={} action=disconnect-and-reconnect",
5294                        remote_device_id,
5295                        local_device_id,
5296                        node_id_str,
5297                        failures,
5298                        connected_health_failure_threshold
5299                    );
5300                    log_skip("stale-active-connection-reconnect");
5301                    let _ = self
5302                        .disconnect_with_reason(
5303                            endpoint_id,
5304                            crate::lifecycle_reason::REASON_STALE_ACTIVE_CONNECTION_RECONNECT,
5305                        )
5306                        .await;
5307                    connected_health_checked_at.remove(&remote_device_id);
5308                    connected_health_state.remove(&remote_device_id);
5309                    connected_health_failures.remove(&remote_device_id);
5310                }
5311            } else {
5312                // Do not clear a bounded passive-open miss here. A transport
5313                // handle can remain locally open after the remote process has
5314                // disappeared, so only the matching positive probe above may
5315                // reset `connected_health_failures`. Clearing on the
5316                // temporarily-healthy PreserveLiveTransport decision would
5317                // keep every pass at failure 1 and make the retirement
5318                // threshold unreachable.
5319                if self.is_auto_connect_peer_excluded(&remote_device_id, Some(&node_id_str)) {
5320                    log_skip("auto-connect-excluded-after-health-probe");
5321                    let _ = self
5322                        .disconnect_with_reason(
5323                            endpoint_id,
5324                            crate::lifecycle_reason::REASON_MANUAL_DISCONNECT,
5325                        )
5326                        .await;
5327                    return;
5328                }
5329                self.connection_manager
5330                    .upsert_pending(
5331                        connection_id.clone(),
5332                        Some(node_id_str.clone()),
5333                        Some(remote_device_id.clone()),
5334                        Some(node_id_str.clone()),
5335                    )
5336                    .await;
5337                let current_transport_stable_id = self
5338                    .get_connection(endpoint_id)
5339                    .await
5340                    .map(|connection| crate::transport_generation::for_connection(&connection));
5341                self.commit_current_native_transport_record(
5342                    &connection_id,
5343                    &node_id_str,
5344                    Some(remote_device_id.clone()),
5345                    current_transport_stable_id,
5346                    Some("health-check".to_string()),
5347                )
5348                .await;
5349                #[cfg(target_arch = "wasm32")]
5350                {
5351                    let active_transport =
5352                        self.iroh_path_kind(&node_id_str).await.transport_label();
5353                    let _ = self
5354                        .report_transport_status_for_current_generation(
5355                            &connection_id,
5356                            active_transport,
5357                            None,
5358                        )
5359                        .await;
5360                }
5361
5362                // Admission is bound to the physical transport generation, not
5363                // to whichever fresh ticket happens to be in the latest presence
5364                // row. Native peers routinely rotate advertised ticket tokens
5365                // while an already admitted connection remains live. Re-presenting
5366                // the replacement token creates a needless control stream and can
5367                // disturb an otherwise settled carrier generation.
5368                #[cfg(not(target_arch = "wasm32"))]
5369                let has_remote_admission_proof =
5370                    current_transport_stable_id.is_some_and(|transport_stable_id| {
5371                        self.remote_session_token_admitted_for_transport(
5372                            &connection_id,
5373                            transport_stable_id,
5374                        )
5375                    });
5376                #[cfg(target_arch = "wasm32")]
5377                let has_remote_admission_proof = if let Some(token) = extracted_token.as_deref() {
5378                    self.has_current_remote_session_admission_proof(
5379                        &connection_id,
5380                        endpoint_id,
5381                        token,
5382                    )
5383                    .await
5384                } else {
5385                    false
5386                };
5387                #[cfg(not(target_arch = "wasm32"))]
5388                let has_current_inbound_admission_proof =
5389                    current_transport_stable_id.is_some_and(|transport_stable_id| {
5390                        self.inbound_session_token_admitted_for_transport(
5391                            &connection_id,
5392                            transport_stable_id,
5393                        )
5394                    });
5395                #[cfg(not(target_arch = "wasm32"))]
5396                let peer_requested_reciprocal =
5397                    self.has_pending_reciprocal_session_admission_request(&connection_id);
5398                #[cfg(not(target_arch = "wasm32"))]
5399                if has_remote_admission_proof
5400                    && has_current_inbound_admission_proof
5401                    && peer_requested_reciprocal
5402                {
5403                    self.clear_reciprocal_session_admission_request(&connection_id);
5404                }
5405                #[cfg(not(target_arch = "wasm32"))]
5406                let presentation_decision = session_admission_presentation_decision(
5407                    extracted_token.is_some(),
5408                    has_remote_admission_proof,
5409                    has_current_inbound_admission_proof,
5410                    peer_requested_reciprocal,
5411                    local_node_id > node_id_str,
5412                );
5413                #[cfg(target_arch = "wasm32")]
5414                let presentation_decision = session_admission_presentation_decision(
5415                    extracted_token.is_some(),
5416                    has_remote_admission_proof,
5417                    true,
5418                    false,
5419                    true,
5420                );
5421                if presentation_decision.should_present {
5422                    if let Some(backoff_until) =
5423                        failure_backoff_until.get(&remote_device_id).copied()
5424                    {
5425                        if now < backoff_until {
5426                            log_skip("session-admission-backoff");
5427                            return;
5428                        }
5429                        failure_backoff_until.remove(&remote_device_id);
5430                    }
5431
5432                    let token = extracted_token
5433                        .as_ref()
5434                        .expect("representation requires an extracted token");
5435                    eprintln!(
5436                        "[pluto-rtc][auto-connect][session-admission] re-presenting token on live transport connection_id={} remote_device_id={} remote_node_id={} token_fp={}",
5437                        connection_id,
5438                        remote_device_id,
5439                        node_id_str,
5440                        super::core_impl::log_fingerprint(token.as_str())
5441                    );
5442                    #[cfg(not(target_arch = "wasm32"))]
5443                    let presentation_result = self
5444                        .present_and_accept_session_token_for_route_repair(
5445                            endpoint_id,
5446                            &connection_id,
5447                            token,
5448                            extracted_token_suffix.as_deref(),
5449                            Some(remote_device_id.clone()),
5450                            Some(local_device_id.to_string()),
5451                            has_remote_admission_proof,
5452                            presentation_decision.request_reciprocal,
5453                        )
5454                        .await;
5455                    #[cfg(target_arch = "wasm32")]
5456                    let presentation_result = self
5457                        .present_and_accept_session_token_with_local_claim(
5458                            endpoint_id,
5459                            &connection_id,
5460                            token,
5461                            extracted_token_suffix.as_deref(),
5462                            Some(remote_device_id.clone()),
5463                            Some(local_device_id.to_string()),
5464                        )
5465                        .await;
5466                    match presentation_result {
5467                        Ok(approval_scope) => {
5468                            clear_auto_connect_failure_state(
5469                                &remote_device_id,
5470                                failure_count,
5471                                failure_backoff_until,
5472                            );
5473                            eprintln!(
5474                                "[pluto-rtc][auto-connect][session-admission] live transport admitted connection_id={} remote_device_id={} remote_node_id={} scope={} token_fp={}",
5475                                connection_id,
5476                                remote_device_id,
5477                                node_id_str,
5478                                approval_scope,
5479                                super::core_impl::log_fingerprint(token.as_str())
5480                            );
5481                        }
5482                        Err(error) => {
5483                            let active_transport_stable_id =
5484                                self.get_connection(endpoint_id).await.map(|connection| {
5485                                    crate::transport_generation::for_connection(&connection)
5486                                });
5487                            if current_transport_stable_id != active_transport_stable_id {
5488                                eprintln!(
5489                                    "[pluto-rtc][auto-connect][session-admission] stale presentation failure ignored because replacement already owns endpoint connection_id={} remote_device_id={} attempted_transport_stable_id={:?} active_transport_stable_id={:?}",
5490                                    connection_id,
5491                                    remote_device_id,
5492                                    current_transport_stable_id,
5493                                    active_transport_stable_id
5494                                );
5495                                return;
5496                            }
5497
5498                            #[cfg(not(target_arch = "wasm32"))]
5499                            let current_generation_is_routable = active_transport_stable_id
5500                                .is_some_and(|transport_stable_id| {
5501                                    self.native_application_stream_admitted_for_transport(
5502                                        &connection_id,
5503                                        transport_stable_id,
5504                                    )
5505                                });
5506                            #[cfg(target_arch = "wasm32")]
5507                            let current_generation_is_routable = false;
5508
5509                            if current_generation_is_routable {
5510                                clear_auto_connect_failure_state(
5511                                    &remote_device_id,
5512                                    failure_count,
5513                                    failure_backoff_until,
5514                                );
5515                                connected_health_failures.remove(&remote_device_id);
5516                                let _ = self
5517                                    .confirm_managed_connection_readiness(&connection_id)
5518                                    .await;
5519                                eprintln!(
5520                                    "[pluto-rtc][auto-connect][session-admission] ignored losing presentation failure because another exchange made the generation routable connection_id={} remote_device_id={} transport_stable_id={:?} error={}",
5521                                    connection_id,
5522                                    remote_device_id,
5523                                    active_transport_stable_id,
5524                                    error,
5525                                );
5526                                return;
5527                            }
5528
5529                            // A duplicate never owns admission outcome. Ignore
5530                            // it even if the canonical exchange completed
5531                            // between returning this tagged error and handling
5532                            // it here; only the canonical presenter may count
5533                            // or retire its physical generation.
5534                            if session_token_presentation_was_duplicate(&error) {
5535                                eprintln!(
5536                                    "[pluto-rtc][auto-connect][session-admission] ignored duplicate presentation owned by canonical exchange connection_id={} remote_device_id={} transport_stable_id={:?}",
5537                                    connection_id,
5538                                    remote_device_id,
5539                                    active_transport_stable_id,
5540                                );
5541                                return;
5542                            }
5543
5544                            if self
5545                                .session_token_registry
5546                                .is_session_token_presentation_in_flight(&connection_id)
5547                            {
5548                                eprintln!(
5549                                    "[pluto-rtc][auto-connect][session-admission] ignored duplicate presentation while canonical exchange remains in flight connection_id={} remote_device_id={} transport_stable_id={:?}",
5550                                    connection_id,
5551                                    remote_device_id,
5552                                    active_transport_stable_id,
5553                                );
5554                                return;
5555                            }
5556
5557                            if session_token_presentation_failure_action(&error)
5558                                == SessionTokenPresentationFailureAction::RejectAndDisconnect
5559                            {
5560                                let rejection_reason =
5561                                    format!("session-token-presentation-failed: {}", error);
5562                                self.reject_session_connection(
5563                                    &connection_id,
5564                                    rejection_reason.as_str(),
5565                                );
5566                                self.connection_manager
5567                                    .set_failed(&connection_id, Some(rejection_reason.clone()))
5568                                    .await;
5569                                let _ = self
5570                                    .disconnect_with_reason(
5571                                        endpoint_id,
5572                                        crate::lifecycle_reason::REASON_SESSION_ADMISSION_REJECTED,
5573                                    )
5574                                    .await;
5575                                self.forget_session_connection(&connection_id);
5576                                register_auto_connect_admission_rejection(
5577                                    &remote_device_id,
5578                                    now_millis_i64(),
5579                                    failure_count,
5580                                    failure_backoff_until,
5581                                );
5582                                eprintln!(
5583                                    "[pluto-rtc][auto-connect][session-admission] live transport admission rejected connection_id={} remote_device_id={} remote_node_id={} token_fp={} error={}",
5584                                    connection_id,
5585                                    remote_device_id,
5586                                    node_id_str,
5587                                    super::core_impl::log_fingerprint(token.as_str()),
5588                                    error
5589                                );
5590                                return;
5591                            }
5592
5593                            register_auto_connect_failure(
5594                                &remote_device_id,
5595                                now_millis_i64(),
5596                                failure_count,
5597                                failure_backoff_until,
5598                            );
5599                            let failures =
5600                                failure_count.get(&remote_device_id).copied().unwrap_or(1);
5601                            match retryable_session_admission_failure_decision(
5602                                failures,
5603                                connected_health_failure_threshold,
5604                                current_transport_stable_id,
5605                                active_transport_stable_id,
5606                                runtime_instance_changed,
5607                            ) {
5608                                RetryableSessionAdmissionFailureDecision::PreserveCurrentTransport => {
5609                                    eprintln!(
5610                                        "[pluto-rtc][auto-connect][session-admission] transient presentation failure; preserving current generation for one bounded retry connection_id={} remote_device_id={} remote_node_id={} transport_stable_id={:?} failures={} threshold={} token_fp={} error={}",
5611                                        connection_id,
5612                                        remote_device_id,
5613                                        node_id_str,
5614                                        current_transport_stable_id,
5615                                        failures,
5616                                        connected_health_failure_threshold,
5617                                        super::core_impl::log_fingerprint(token.as_str()),
5618                                        error
5619                                    );
5620                                }
5621                                RetryableSessionAdmissionFailureDecision::RetireCurrentTransport => {
5622                                    eprintln!(
5623                                        "[pluto-rtc][auto-connect][session-admission] repeated transient presentation failure; retiring unresponsive generation for canonical reconnect connection_id={} remote_device_id={} remote_node_id={} transport_stable_id={:?} failures={} threshold={} token_fp={} error={}",
5624                                        connection_id,
5625                                        remote_device_id,
5626                                        node_id_str,
5627                                        current_transport_stable_id,
5628                                        failures,
5629                                        connected_health_failure_threshold,
5630                                        super::core_impl::log_fingerprint(token.as_str()),
5631                                        error
5632                                    );
5633                                    let _ = self
5634                                        .disconnect_with_reason(
5635                                            endpoint_id,
5636                                            crate::lifecycle_reason::REASON_STALE_ACTIVE_CONNECTION_RECONNECT,
5637                                        )
5638                                        .await;
5639                                    connected_health_checked_at.remove(&remote_device_id);
5640                                    connected_health_state.remove(&remote_device_id);
5641                                    connected_health_failures.remove(&remote_device_id);
5642                                }
5643                                RetryableSessionAdmissionFailureDecision::ReplacementAlreadyWon => {
5644                                    eprintln!(
5645                                        "[pluto-rtc][auto-connect][session-admission] transient failure belongs to retired generation; replacement preserved connection_id={} remote_device_id={} attempted_transport_stable_id={:?} active_transport_stable_id={:?}",
5646                                        connection_id,
5647                                        remote_device_id,
5648                                        current_transport_stable_id,
5649                                        active_transport_stable_id
5650                                    );
5651                                }
5652                            }
5653                            return;
5654                        }
5655                    }
5656                }
5657
5658                #[cfg(not(target_arch = "wasm32"))]
5659                {
5660                    if self
5661                        .promote_known_native_user_device_connection(&connection_id, &node_id_str)
5662                        .await
5663                        .is_none()
5664                    {
5665                        let _ = self
5666                            .confirm_managed_connection_readiness(&connection_id)
5667                            .await;
5668                    }
5669                    // Health polling observes the current committed connection;
5670                    // it does not own carrier recovery. Initial connect, path
5671                    // changes, foreground resume, inbound signaling, and the
5672                    // Rust retry scheduler already feed the connection owner.
5673                    // Starting WebRTC here can transiently clear settlement on
5674                    // an otherwise healthy direct Iroh route.
5675                }
5676                #[cfg(target_arch = "wasm32")]
5677                {
5678                    let _ = self
5679                        .confirm_managed_connection_readiness(&connection_id)
5680                        .await;
5681                    self.emit_current_wasm_connection_state(&connection_id)
5682                        .await;
5683                }
5684                clear_auto_connect_failure_state(
5685                    &remote_device_id,
5686                    failure_count,
5687                    failure_backoff_until,
5688                );
5689                non_initiator_escalation_count.remove(&remote_device_id);
5690                log_skip("already-connected");
5691                return;
5692            }
5693        } else {
5694            connected_health_checked_at.remove(&remote_device_id);
5695            connected_health_state.remove(&remote_device_id);
5696            connected_health_failures.remove(&remote_device_id);
5697        }
5698
5699        if let Some(backoff_until) = failure_backoff_until.get(&remote_device_id).copied() {
5700            if now < backoff_until {
5701                log_skip("failure-backoff");
5702                return;
5703            }
5704            failure_backoff_until.remove(&remote_device_id);
5705        }
5706
5707        let last = last_attempt_at.get(&remote_device_id).cloned().unwrap_or(0);
5708        if now - last < retry_throttle_ms {
5709            log_skip("retry-throttle");
5710            return;
5711        }
5712        last_attempt_at.insert(remote_device_id.clone(), now);
5713        let connect_mode = "ticket-endpoint-addr";
5714
5715        #[cfg(not(target_arch = "wasm32"))]
5716        {
5717            self.connection_manager
5718                .upsert_pending(
5719                    connection_id.clone(),
5720                    Some(node_id_str.clone()),
5721                    Some(remote_device_id.clone()),
5722                    Some(node_id_str.clone()),
5723                )
5724                .await;
5725            self.connection_manager.set_connecting(&connection_id).await;
5726            let dial_attempt_fence = self
5727                .connection_manager
5728                .get_by_connection_id(&connection_id)
5729                .await
5730                .map(|record| (record.transport_generation, record.transport_stable_id));
5731
5732            eprintln!(
5733                "[pluto-rtc][auto-connect] attempt remote_device_id={} local_device_id={} local_node_id={} remote_node_id={} mode={}",
5734                remote_device_id,
5735                local_device_id,
5736                local_node_id,
5737                node_id_str,
5738                connect_mode
5739            );
5740
5741            eprintln!("[pluto-rtc] auto-connecting to {}", remote_device_id);
5742            let result = self.ensure_connected_addr(endpoint_id, endpoint_addr).await;
5743
5744            match result {
5745                Ok(()) => {
5746                    if self.is_auto_connect_peer_excluded(&remote_device_id, Some(&node_id_str)) {
5747                        log_skip("auto-connect-excluded-after-dial");
5748                        let _ = self
5749                            .disconnect_with_reason(
5750                                endpoint_id,
5751                                crate::lifecycle_reason::REASON_MANUAL_DISCONNECT,
5752                            )
5753                            .await;
5754                        self.connection_manager
5755                            .set_closed(
5756                                &connection_id,
5757                                Some(crate::lifecycle_reason::REASON_MANUAL_DISCONNECT.to_string()),
5758                            )
5759                            .await;
5760                        return;
5761                    }
5762                    let active_probe = self.validate_active_connection(endpoint_id).await;
5763                    let active_probe_stable =
5764                        active_probe.as_ref().is_some_and(|probe| probe.responsive);
5765                    let transport_alive = self.is_connection_transport_alive(endpoint_id).await;
5766                    let probe_decision =
5767                        active_connection_probe_decision(active_probe_stable, transport_alive);
5768                    let stable = matches!(
5769                        probe_decision,
5770                        ActiveConnectionProbeDecision::Healthy
5771                            | ActiveConnectionProbeDecision::PreserveLiveTransport
5772                    );
5773                    if !stable {
5774                        if self.is_app_execution_suspended() {
5775                            log_skip("backgrounded-after-stability-probe");
5776                            return;
5777                        }
5778                        // A probe miss is physical-generation evidence, not
5779                        // authority to fail the whole logical peer. An inbound
5780                        // winner may be admitting while this dial loses arbitration.
5781                        let Some(transport_stable_id) =
5782                            active_probe.as_ref().map(|probe| probe.transport_stable_id)
5783                        else {
5784                            log_skip("stability-probe-generation-disappeared");
5785                            return;
5786                        };
5787                        if !matches!(
5788                            self.retire_transport_generation_after_failed_probe(
5789                                &connection_id,
5790                                endpoint_id,
5791                                transport_stable_id,
5792                                connected_health_probe_ms,
5793                                crate::lifecycle_reason::REASON_CONNECTION_FAILED_STABILITY,
5794                            )
5795                            .await,
5796                            FailedProbeRetirementOutcome::Retired
5797                        ) {
5798                            log_skip("stability-probe-retirement-preserved-current-generation");
5799                            return;
5800                        }
5801                        self.reconcile_authoritative_device_node(&remote_device_id, &node_id_str)
5802                            .await;
5803                        register_auto_connect_failure(
5804                            &remote_device_id,
5805                            now_millis_i64(),
5806                            failure_count,
5807                            failure_backoff_until,
5808                        );
5809                        let current_failures =
5810                            failure_count.get(&remote_device_id).copied().unwrap_or(0);
5811                        if current_failures >= network_change_failure_threshold
5812                            && self
5813                                .should_force_network_change_after_connect_failures(
5814                                    &remote_device_id,
5815                                )
5816                                .await
5817                        {
5818                            self.maybe_force_network_change_for_auto_connect(
5819                                user_id,
5820                                local_device_id,
5821                                "unstable-connection",
5822                                last_network_change_at_ms,
5823                                network_change_recovery_interval_ms,
5824                                last_presence_republish_at_ms,
5825                                presence_republish_interval_ms,
5826                            )
5827                            .await;
5828                        }
5829                        eprintln!(
5830                            "[pluto-rtc][auto-connect] unstable connection suppressed remote_device_id={} local_device_id={} local_node_id={} remote_node_id={} connection_id={}",
5831                            remote_device_id,
5832                            local_device_id,
5833                            local_node_id,
5834                            node_id_str,
5835                            connection_id
5836                        );
5837                        return;
5838                    }
5839                    if matches!(
5840                        probe_decision,
5841                        ActiveConnectionProbeDecision::PreserveLiveTransport
5842                    ) {
5843                        log_skip("stability-probe-missed-preserving-live-transport");
5844                    }
5845                    let current_transport_stable_id = self
5846                        .get_connection(endpoint_id)
5847                        .await
5848                        .map(|connection| crate::transport_generation::for_connection(&connection));
5849                    self.commit_current_native_transport_record(
5850                        &connection_id,
5851                        &node_id_str,
5852                        Some(remote_device_id.clone()),
5853                        current_transport_stable_id,
5854                        Some(connect_mode.to_string()),
5855                    )
5856                    .await;
5857
5858                    if let Some(ref token) = extracted_token {
5859                        let bilateral_user_device = extracted_token_payload
5860                            .as_ref()
5861                            .is_some_and(|payload| payload.scope.as_str() == "user-device");
5862                        let has_remote_admission_proof =
5863                            current_transport_stable_id.is_some_and(|transport_stable_id| {
5864                                self.remote_session_token_admitted_for_transport(
5865                                    &connection_id,
5866                                    transport_stable_id,
5867                                )
5868                            });
5869                        let has_current_inbound_admission_proof = current_transport_stable_id
5870                            .is_some_and(|transport_stable_id| {
5871                                self.inbound_session_token_admitted_for_transport(
5872                                    &connection_id,
5873                                    transport_stable_id,
5874                                )
5875                            });
5876                        let peer_requested_reciprocal =
5877                            self.has_pending_reciprocal_session_admission_request(&connection_id);
5878                        let presentation_decision = if bilateral_user_device {
5879                            session_admission_presentation_decision(
5880                                true,
5881                                has_remote_admission_proof,
5882                                has_current_inbound_admission_proof,
5883                                peer_requested_reciprocal,
5884                                local_node_id > node_id_str,
5885                            )
5886                        } else {
5887                            SessionAdmissionPresentationDecision {
5888                                should_present: !has_remote_admission_proof,
5889                                request_reciprocal: false,
5890                            }
5891                        };
5892                        if !presentation_decision.should_present {
5893                            eprintln!(
5894                                "[pluto-rtc][auto-connect][session-admission] awaiting elected presenter on fresh transport connection_id={} remote_device_id={} remote_node_id={} local_node_id={}",
5895                                connection_id,
5896                                remote_device_id,
5897                                node_id_str,
5898                                local_node_id,
5899                            );
5900                            return;
5901                        }
5902                        match self
5903                            .present_and_accept_session_token_for_route_repair(
5904                                endpoint_id,
5905                                &connection_id,
5906                                token,
5907                                extracted_token_suffix.as_deref(),
5908                                Some(remote_device_id.clone()),
5909                                Some(local_device_id.to_string()),
5910                                has_remote_admission_proof,
5911                                presentation_decision.request_reciprocal,
5912                            )
5913                            .await
5914                        {
5915                            Ok(approval_scope) => {
5916                                eprintln!(
5917                                    "[pluto-rtc][auto-connect] session-token presented and approved connection_id={} scope={} device_id={} token_fp={} local_admission=accepted",
5918                                    connection_id,
5919                                    approval_scope,
5920                                    remote_device_id,
5921                                    super::core_impl::log_fingerprint(token.as_str())
5922                                );
5923                            }
5924                            Err(error) => {
5925                                if session_token_presentation_failure_action(&error)
5926                                    == SessionTokenPresentationFailureAction::RetryOnNextTransport
5927                                {
5928                                    register_auto_connect_failure(
5929                                        &remote_device_id,
5930                                        now_millis_i64(),
5931                                        failure_count,
5932                                        failure_backoff_until,
5933                                    );
5934                                    eprintln!(
5935                                        "[pluto-rtc][auto-connect] transient session-token presentation failure; preserving peer session for canonical transport retry connection_id={} device_id={} token_fp={} error={}",
5936                                        connection_id,
5937                                        remote_device_id,
5938                                        super::core_impl::log_fingerprint(token.as_str()),
5939                                        error
5940                                    );
5941                                    return;
5942                                }
5943                                let rejection_reason =
5944                                    format!("session-token-presentation-failed: {}", error);
5945                                self.reject_session_connection(
5946                                    &connection_id,
5947                                    rejection_reason.as_str(),
5948                                );
5949                                self.connection_manager
5950                                    .set_failed(&connection_id, Some(rejection_reason.clone()))
5951                                    .await;
5952                                let _ = self
5953                                    .disconnect_with_reason(
5954                                        endpoint_id,
5955                                        crate::lifecycle_reason::REASON_SESSION_ADMISSION_REJECTED,
5956                                    )
5957                                    .await;
5958                                self.reconcile_authoritative_device_node(
5959                                    &remote_device_id,
5960                                    &node_id_str,
5961                                )
5962                                .await;
5963                                // Clear the rejection state so the next auto-connect
5964                                // attempt can re-present a (potentially refreshed) token.
5965                                // Without this the session stays in Rejected and no
5966                                // further token presentation occurs for this peer.
5967                                self.forget_session_connection(&connection_id);
5968                                register_auto_connect_admission_rejection(
5969                                    &remote_device_id,
5970                                    now_millis_i64(),
5971                                    failure_count,
5972                                    failure_backoff_until,
5973                                );
5974                                eprintln!(
5975                                    "[pluto-rtc][auto-connect] session-token presentation failed connection_id={} device_id={} token_fp={} error={}",
5976                                    connection_id,
5977                                    remote_device_id,
5978                                    super::core_impl::log_fingerprint(token.as_str()),
5979                                    error
5980                                );
5981                                return;
5982                            }
5983                        }
5984                    }
5985
5986                    let _ = self
5987                        .confirm_managed_connection_readiness(&connection_id)
5988                        .await;
5989                    // Spawn a path-watcher for this outgoing connection so that
5990                    // relay → direct-QUIC (and back) transitions retrigger or
5991                    // suspend transport upgrades reactively — matching the
5992                    // behaviour we already have for incoming connections.
5993                    if let Some(outgoing_conn) = self.get_connection(endpoint_id).await {
5994                        // force_restart=false: the path-watcher fires on relay↔direct
5995                        // transitions after connection. A Connecting session started just
5996                        // above should not be replaced by path-change retriggers.
5997                        self.spawn_iroh_path_watcher(
5998                            &connection_id,
5999                            &node_id_str,
6000                            &outgoing_conn,
6001                            false,
6002                        );
6003                    }
6004                    clear_auto_connect_failure_state(
6005                        &remote_device_id,
6006                        failure_count,
6007                        failure_backoff_until,
6008                    );
6009                    non_initiator_escalation_count.remove(&remote_device_id);
6010                    eprintln!(
6011                        "[pluto-rtc][auto-connect] connected remote_device_id={} local_device_id={} local_node_id={} remote_node_id={} connection_id={}",
6012                        remote_device_id,
6013                        local_device_id,
6014                        local_node_id,
6015                        node_id_str,
6016                        connection_id
6017                    );
6018                }
6019                Err(e) => {
6020                    if let Some((transport_generation, transport_stable_id)) = dial_attempt_fence {
6021                        let failed = self
6022                            .connection_manager
6023                            .set_failed_if_dial_attempt_current(
6024                                &connection_id,
6025                                transport_generation,
6026                                transport_stable_id,
6027                                Some(
6028                                    crate::lifecycle_reason::REASON_AUTO_CONNECT_FAILURE
6029                                        .to_string(),
6030                                ),
6031                            )
6032                            .await;
6033                        if failed.is_none() {
6034                            eprintln!(
6035                                "[pluto-rtc][auto-connect] ignored stale dial failure connection_id={} remote_device_id={} remote_node_id={} expected_transport_generation={} expected_transport_stable_id={:?}",
6036                                connection_id,
6037                                remote_device_id,
6038                                node_id_str,
6039                                transport_generation,
6040                                transport_stable_id,
6041                            );
6042                            // The successor's accept/admission path owns all
6043                            // remaining settlement. Do not charge this stale
6044                            // attempt to backoff or let its failure threshold
6045                            // disconnect the current physical generation.
6046                            return;
6047                        }
6048                    }
6049                    register_auto_connect_failure(
6050                        &remote_device_id,
6051                        now_millis_i64(),
6052                        failure_count,
6053                        failure_backoff_until,
6054                    );
6055                    let current_failures =
6056                        failure_count.get(&remote_device_id).copied().unwrap_or(0);
6057                    if current_failures >= network_change_failure_threshold
6058                        && self
6059                            .should_force_network_change_after_connect_failures(&remote_device_id)
6060                            .await
6061                    {
6062                        let _ = self
6063                            .disconnect_with_reason(
6064                                endpoint_id,
6065                                crate::lifecycle_reason::REASON_NETWORK_CHANGE_RECONNECT,
6066                            )
6067                            .await;
6068                        self.reconcile_authoritative_device_node(&remote_device_id, &node_id_str)
6069                            .await;
6070                        self.maybe_force_network_change_for_auto_connect(
6071                            user_id,
6072                            local_device_id,
6073                            "repeated-connect-failures",
6074                            last_network_change_at_ms,
6075                            network_change_recovery_interval_ms,
6076                            last_presence_republish_at_ms,
6077                            presence_republish_interval_ms,
6078                        )
6079                        .await;
6080                    }
6081                    self.maybe_refresh_gateway_presence_for_auto_connect(
6082                        user_id,
6083                        local_device_id,
6084                        "connect-failed",
6085                        last_presence_republish_at_ms,
6086                        presence_republish_interval_ms,
6087                    )
6088                    .await;
6089                    eprintln!(
6090                        "[pluto-rtc] auto-connect failed to {}: {}",
6091                        remote_device_id, e
6092                    );
6093                }
6094            }
6095        }
6096
6097        #[cfg(target_arch = "wasm32")]
6098        {
6099            self.connection_manager
6100                .upsert_pending(
6101                    connection_id.clone(),
6102                    Some(node_id_str.clone()),
6103                    Some(remote_device_id.clone()),
6104                    Some(node_id_str.clone()),
6105                )
6106                .await;
6107            // Pre-populate device_id from the signaling record so that peer
6108            // lookups by device_id succeed immediately instead of waiting
6109            // for the accept probe to backfill it (~18 s in worst case).
6110            self.connection_manager
6111                .set_device_id(&connection_id, remote_device_id.clone())
6112                .await;
6113            self.connection_manager.set_connecting(&connection_id).await;
6114            self.emit_current_wasm_connection_state(&connection_id)
6115                .await;
6116            web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
6117                "[pluto-rtc][auto-connect] attempt remote_device_id={} local_device_id={} local_node_id={} remote_node_id={} mode={}",
6118                remote_device_id,
6119                local_device_id,
6120                local_node_id,
6121                node_id_str,
6122                connect_mode
6123            )));
6124            web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
6125                "[pluto-rtc] auto-connecting to {}",
6126                remote_device_id
6127            )));
6128            let result = self.ensure_connected_addr(endpoint_id, endpoint_addr).await;
6129
6130            match result {
6131                Ok(()) => {
6132                    let active_probe_stable = self.validate_active_connection(endpoint_id).await;
6133                    let transport_alive = self.is_connection_transport_alive(endpoint_id).await;
6134                    let probe_decision =
6135                        active_connection_probe_decision(active_probe_stable, transport_alive);
6136                    let stable = matches!(
6137                        probe_decision,
6138                        ActiveConnectionProbeDecision::Healthy
6139                            | ActiveConnectionProbeDecision::PreserveLiveTransport
6140                    );
6141                    if !stable {
6142                        if self.is_app_execution_suspended() {
6143                            log_skip("backgrounded-after-stability-probe");
6144                            return;
6145                        }
6146                        self.connection_manager
6147                            .upsert_pending(
6148                                connection_id.clone(),
6149                                Some(node_id_str.clone()),
6150                                Some(remote_device_id.clone()),
6151                                Some(node_id_str.clone()),
6152                            )
6153                            .await;
6154                        self.connection_manager
6155                            .set_failed(
6156                                &connection_id,
6157                                Some("connection failed stability verification".to_string()),
6158                            )
6159                            .await;
6160                        self.emit_current_wasm_connection_state(&connection_id)
6161                            .await;
6162                        register_auto_connect_failure(
6163                            &remote_device_id,
6164                            now_millis_i64(),
6165                            failure_count,
6166                            failure_backoff_until,
6167                        );
6168                        let _ = self
6169                            .disconnect_with_reason(
6170                                endpoint_id,
6171                                crate::lifecycle_reason::REASON_AUTO_CONNECT_FAILURE,
6172                            )
6173                            .await;
6174                        web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
6175                            "[pluto-rtc][auto-connect] unstable connection suppressed remote_device_id={} local_device_id={} local_node_id={} remote_node_id={}",
6176                            remote_device_id,
6177                            local_device_id,
6178                            local_node_id,
6179                            node_id_str
6180                        )));
6181                        return;
6182                    }
6183                    if matches!(
6184                        probe_decision,
6185                        ActiveConnectionProbeDecision::PreserveLiveTransport
6186                    ) {
6187                        web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
6188                            "[pluto-rtc][auto-connect] stability probe missed but transport remains alive; preserving connection remote_device_id={} local_device_id={} local_node_id={} remote_node_id={} connection_id={}",
6189                            remote_device_id,
6190                            local_device_id,
6191                            local_node_id,
6192                            node_id_str,
6193                            connection_id
6194                        )));
6195                    }
6196
6197                    self.connection_manager
6198                        .upsert_pending(
6199                            connection_id.clone(),
6200                            Some(node_id_str.clone()),
6201                            Some(remote_device_id.clone()),
6202                            Some(node_id_str.clone()),
6203                        )
6204                        .await;
6205                    let pending_record = self
6206                        .connection_manager
6207                        .get_by_connection_id(&connection_id)
6208                        .await;
6209                    web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
6210                        "[pluto-rtc][auto-connect][wasm] upsert_pending remote_device_id={} remote_node_id={} connection_id={} pending_record_exists={} record={:?}",
6211                        remote_device_id,
6212                        node_id_str,
6213                        connection_id,
6214                        pending_record.is_some(),
6215                        pending_record,
6216                    )));
6217                    let transport_stable_id = self
6218                        .get_connection(endpoint_id)
6219                        .await
6220                        .map(|connection| crate::transport_generation::for_connection(&connection));
6221                    self.commit_current_native_transport_record(
6222                        &connection_id,
6223                        &node_id_str,
6224                        Some(remote_device_id.clone()),
6225                        transport_stable_id,
6226                        Some(connect_mode.to_string()),
6227                    )
6228                    .await;
6229                    let active_transport =
6230                        self.iroh_path_kind(&node_id_str).await.transport_label();
6231                    let _ = self
6232                        .report_transport_status_for_current_generation(
6233                            &connection_id,
6234                            active_transport,
6235                            None,
6236                        )
6237                        .await;
6238
6239                    if let Some(ref token) = extracted_token {
6240                        match self
6241                            .present_and_accept_session_token(
6242                                endpoint_id,
6243                                &connection_id,
6244                                token,
6245                                extracted_token_suffix.as_deref(),
6246                                Some(remote_device_id.clone()),
6247                            )
6248                            .await
6249                        {
6250                            Ok(approval_scope) => {
6251                                web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
6252                                    "[pluto-rtc][auto-connect][wasm] session-token presented and approved connection_id={} scope={} device_id={} token_fp={} local_admission=accepted",
6253                                    connection_id,
6254                                    approval_scope,
6255                                    remote_device_id,
6256                                    super::core_impl::log_fingerprint(token.as_str())
6257                                )));
6258                            }
6259                            Err(error) => {
6260                                if session_token_presentation_failure_action(&error)
6261                                    == SessionTokenPresentationFailureAction::RetryOnNextTransport
6262                                {
6263                                    register_auto_connect_failure(
6264                                        &remote_device_id,
6265                                        now_millis_i64(),
6266                                        failure_count,
6267                                        failure_backoff_until,
6268                                    );
6269                                    web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
6270                                        "[pluto-rtc][auto-connect][wasm] transient session-token presentation failure; preserving peer session for canonical transport retry connection_id={} device_id={} token_fp={} error={}",
6271                                        connection_id,
6272                                        remote_device_id,
6273                                        super::core_impl::log_fingerprint(token.as_str()),
6274                                        error
6275                                    )));
6276                                    return;
6277                                }
6278                                let rejection_reason =
6279                                    format!("session-token-presentation-failed: {}", error);
6280                                self.reject_session_connection(
6281                                    &connection_id,
6282                                    rejection_reason.as_str(),
6283                                );
6284                                self.connection_manager
6285                                    .set_failed(&connection_id, Some(rejection_reason))
6286                                    .await;
6287                                self.emit_current_wasm_connection_state(&connection_id)
6288                                    .await;
6289                                let _ = self
6290                                    .disconnect_with_reason(
6291                                        endpoint_id,
6292                                        crate::lifecycle_reason::REASON_SESSION_ADMISSION_REJECTED,
6293                                    )
6294                                    .await;
6295                                // Token presentation failure belongs to this
6296                                // transport attempt. Let a later deterministic
6297                                // reconnect authenticate from a clean window.
6298                                self.forget_session_connection(&connection_id);
6299                                register_auto_connect_admission_rejection(
6300                                    &remote_device_id,
6301                                    now_millis_i64(),
6302                                    failure_count,
6303                                    failure_backoff_until,
6304                                );
6305                                web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
6306                                    "[pluto-rtc][auto-connect][wasm] session-token presentation failed connection_id={} device_id={} token_fp={} error={}",
6307                                    connection_id,
6308                                    remote_device_id,
6309                                    super::core_impl::log_fingerprint(token.as_str()),
6310                                    error
6311                                )));
6312                                return;
6313                            }
6314                        }
6315                    }
6316
6317                    let _ = self
6318                        .confirm_managed_connection_readiness(&connection_id)
6319                        .await;
6320                    let connected_record = self
6321                        .connection_manager
6322                        .get_by_connection_id(&connection_id)
6323                        .await;
6324                    let record_count = self.connection_manager.list_all().await.len();
6325                    web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
6326                        "[pluto-rtc][auto-connect][wasm] set_connected remote_device_id={} remote_node_id={} connection_id={} connected_record_exists={} total_records={} record={:?}",
6327                        remote_device_id,
6328                        node_id_str,
6329                        connection_id,
6330                        connected_record.is_some(),
6331                        record_count,
6332                        connected_record,
6333                    )));
6334                    self.emit_current_wasm_connection_state(&connection_id)
6335                        .await;
6336                    clear_auto_connect_failure_state(
6337                        &remote_device_id,
6338                        failure_count,
6339                        failure_backoff_until,
6340                    );
6341                    non_initiator_escalation_count.remove(&remote_device_id);
6342                    web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
6343                        "[pluto-rtc][auto-connect] connected remote_device_id={} local_device_id={} local_node_id={} remote_node_id={}",
6344                        remote_device_id,
6345                        local_device_id,
6346                        local_node_id,
6347                        node_id_str
6348                    )));
6349                }
6350                Err(e) => {
6351                    let err = e.to_string();
6352                    self.connection_manager
6353                        .upsert_pending(
6354                            connection_id.clone(),
6355                            Some(node_id_str.clone()),
6356                            Some(remote_device_id.clone()),
6357                            Some(node_id_str.clone()),
6358                        )
6359                        .await;
6360                    self.connection_manager
6361                        .set_failed(&connection_id, Some(err.clone()))
6362                        .await;
6363                    self.emit_current_wasm_connection_state(&connection_id)
6364                        .await;
6365                    register_auto_connect_failure(
6366                        &remote_device_id,
6367                        now_millis_i64(),
6368                        failure_count,
6369                        failure_backoff_until,
6370                    );
6371                    web_sys::console::error_1(&wasm_bindgen::JsValue::from_str(&format!(
6372                        "[pluto-rtc] auto-connect failed to {}: {}",
6373                        remote_device_id, err
6374                    )));
6375                }
6376            }
6377        }
6378    }
6379
6380    #[cfg(not(target_arch = "wasm32"))]
6381    async fn run_auto_connect_snapshot(
6382        self: &Arc<Self>,
6383        user_id: &str,
6384        local_device_id: &str,
6385        last_attempt_at: &mut std::collections::HashMap<String, i64>,
6386        failure_count: &mut std::collections::HashMap<String, u8>,
6387        failure_backoff_until: &mut std::collections::HashMap<String, i64>,
6388        retry_throttle_ms: i64,
6389        non_initiator_wait_started_at: &mut std::collections::HashMap<String, i64>,
6390        non_initiator_escalation_count: &mut std::collections::HashMap<String, u8>,
6391        initiator_grace_ms: i64,
6392        skip_log_at: &mut std::collections::HashMap<String, i64>,
6393        connected_health_checked_at: &mut std::collections::HashMap<String, i64>,
6394        connected_health_state: &mut std::collections::HashMap<String, bool>,
6395        connected_health_failures: &mut std::collections::HashMap<String, u8>,
6396        connected_health_probe_ms: i64,
6397        connected_health_failure_threshold: u8,
6398        last_presence_republish_at_ms: &mut i64,
6399        presence_republish_interval_ms: i64,
6400        last_network_change_at_ms: &mut i64,
6401        network_change_recovery_interval_ms: i64,
6402        network_change_failure_threshold: u8,
6403        last_snapshot_signature: &mut Option<(usize, usize, usize)>,
6404        last_snapshot_log_at: &mut i64,
6405        last_known_node_id: &mut std::collections::HashMap<String, String>,
6406        known_devices: &mut std::collections::HashMap<String, crate::signaling::Device>,
6407    ) {
6408        if self.is_app_execution_suspended() {
6409            return;
6410        }
6411
6412        let now = now_millis_i64();
6413        match self.search_devices(user_id).await {
6414            Ok(devices) => {
6415                let total = devices.len();
6416                let online = devices.iter().filter(|d| d.online).count();
6417                let remote_online = devices
6418                    .iter()
6419                    .filter(|d| d.online && d.device_id != local_device_id)
6420                    .count();
6421                let signature = (total, online, remote_online);
6422                let changed = last_snapshot_signature
6423                    .map(|prev| prev != signature)
6424                    .unwrap_or(true);
6425                let should_log = if auto_connect_verbose() {
6426                    now.saturating_sub(*last_snapshot_log_at) >= 2_000
6427                } else {
6428                    changed || now.saturating_sub(*last_snapshot_log_at) >= 30_000
6429                };
6430                if should_log {
6431                    *last_snapshot_log_at = now;
6432                    *last_snapshot_signature = Some(signature);
6433                    #[cfg(not(target_arch = "wasm32"))]
6434                    eprintln!(
6435                        "[pluto-rtc][auto-connect][snapshot] user_id={} local_device_id={} total_devices={} online_devices={} remote_online_devices={}",
6436                        user_id, local_device_id, total, online, remote_online
6437                    );
6438                    #[cfg(target_arch = "wasm32")]
6439                    web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
6440                        "[pluto-rtc][auto-connect][snapshot] user_id={} local_device_id={} total_devices={} online_devices={} remote_online_devices={}",
6441                        user_id, local_device_id, total, online, remote_online
6442                    )));
6443                }
6444
6445                let known_device_ids: std::collections::HashSet<String> =
6446                    devices.iter().map(|d| d.device_id.clone()).collect();
6447
6448                for device in devices {
6449                    known_devices.insert(device.device_id.clone(), device.clone());
6450                    self.try_auto_connect_device(
6451                        user_id,
6452                        device,
6453                        local_device_id,
6454                        last_attempt_at,
6455                        failure_count,
6456                        failure_backoff_until,
6457                        retry_throttle_ms,
6458                        non_initiator_wait_started_at,
6459                        non_initiator_escalation_count,
6460                        initiator_grace_ms,
6461                        skip_log_at,
6462                        connected_health_checked_at,
6463                        connected_health_state,
6464                        connected_health_failures,
6465                        connected_health_probe_ms,
6466                        connected_health_failure_threshold,
6467                        last_presence_republish_at_ms,
6468                        presence_republish_interval_ms,
6469                        last_network_change_at_ms,
6470                        network_change_recovery_interval_ms,
6471                        network_change_failure_threshold,
6472                        last_known_node_id,
6473                    )
6474                    .await;
6475                }
6476
6477                // Prune tracking state for device IDs no longer returned by
6478                // the snapshot. Prevents unbounded growth as peers come and go
6479                // without always firing a DeviceEvent::Removed.
6480                last_attempt_at.retain(|k, _| known_device_ids.contains(k));
6481                failure_count.retain(|k, _| known_device_ids.contains(k));
6482                failure_backoff_until.retain(|k, _| known_device_ids.contains(k));
6483                non_initiator_wait_started_at.retain(|k, _| known_device_ids.contains(k));
6484                non_initiator_escalation_count.retain(|k, _| known_device_ids.contains(k));
6485                connected_health_checked_at.retain(|k, _| known_device_ids.contains(k));
6486                connected_health_state.retain(|k, _| known_device_ids.contains(k));
6487                connected_health_failures.retain(|k, _| known_device_ids.contains(k));
6488                last_known_node_id.retain(|key, _| {
6489                    let device_id = key
6490                        .strip_suffix("::runtime-instance")
6491                        .or_else(|| key.strip_suffix("::ticket"))
6492                        .unwrap_or(key.as_str());
6493                    known_device_ids.contains(device_id)
6494                });
6495                // skip_log_at keys are "device_id::reason" — prune by prefix
6496                skip_log_at.retain(|k, _| {
6497                    k.split_once("::")
6498                        .map_or(false, |(id, _)| known_device_ids.contains(id))
6499                });
6500            }
6501            Err(error) => {
6502                let min_interval = if auto_connect_verbose() {
6503                    2_000
6504                } else {
6505                    30_000
6506                };
6507                if now.saturating_sub(*last_snapshot_log_at) >= min_interval {
6508                    *last_snapshot_log_at = now;
6509                    #[cfg(not(target_arch = "wasm32"))]
6510                    eprintln!(
6511                        "[pluto-rtc][auto-connect][snapshot] user_id={} local_device_id={} search_devices_error={}",
6512                        user_id, local_device_id, error
6513                    );
6514                    #[cfg(target_arch = "wasm32")]
6515                    web_sys::console::error_1(&wasm_bindgen::JsValue::from_str(&format!(
6516                        "[pluto-rtc][auto-connect][snapshot] user_id={} local_device_id={} search_devices_error={}",
6517                        user_id, local_device_id, error
6518                    )));
6519                }
6520            }
6521        }
6522    }
6523
6524    #[cfg(not(target_arch = "wasm32"))]
6525    pub(crate) async fn auto_connect_loop(
6526        self: Arc<Self>,
6527        user_id: String,
6528        local_device_id: String,
6529        generation: u64,
6530    ) {
6531        use futures::StreamExt;
6532        let mut last_attempt_at: std::collections::HashMap<String, i64> =
6533            std::collections::HashMap::new();
6534        let mut non_initiator_wait_started_at: std::collections::HashMap<String, i64> =
6535            std::collections::HashMap::new();
6536        let mut non_initiator_escalation_count: std::collections::HashMap<String, u8> =
6537            std::collections::HashMap::new();
6538        let mut skip_log_at: std::collections::HashMap<String, i64> =
6539            std::collections::HashMap::new();
6540        let mut connected_health_checked_at: std::collections::HashMap<String, i64> =
6541            std::collections::HashMap::new();
6542        let mut connected_health_state: std::collections::HashMap<String, bool> =
6543            std::collections::HashMap::new();
6544        let mut connected_health_failures: std::collections::HashMap<String, u8> =
6545            std::collections::HashMap::new();
6546        let mut failure_count: std::collections::HashMap<String, u8> =
6547            std::collections::HashMap::new();
6548        let mut failure_backoff_until: std::collections::HashMap<String, i64> =
6549            std::collections::HashMap::new();
6550        let mut last_known_node_id: std::collections::HashMap<String, String> =
6551            std::collections::HashMap::new();
6552        // The provider subscription owns discovery updates. Keep its latest
6553        // device values in memory so transport health can run on its own
6554        // cadence without turning every ping into a Firestore, RTDB, gateway,
6555        // or future-provider roster read.
6556        let mut known_devices: std::collections::HashMap<String, crate::signaling::Device> =
6557            std::collections::HashMap::new();
6558        let mut last_snapshot_signature: Option<(usize, usize, usize)> = None;
6559        let mut last_snapshot_log_at = 0_i64;
6560        let mut last_presence_republish_at_ms = 0_i64;
6561        let retry_throttle_ms = 1_000;
6562        // Gateway events deliver roster and live-presence changes.
6563        // This is only a low-frequency safety reconciliation; it must never be
6564        // used as a five-second full-scope polling loop.
6565        let rescan_interval_ms_fg: u64 = 300_000;
6566        let rescan_interval_ms_bg: u64 = 300_000;
6567        let initiator_grace_ms = 3_000;
6568        // Foreground: probe every 5s, fail after 2 misses (~10s total).
6569        // Background: skip health probing entirely (i64::MAX disables the probe).
6570        let connected_health_probe_ms_fg = 5_000_i64;
6571        let connected_health_probe_ms_bg = i64::MAX;
6572        let connected_health_failure_threshold = 2;
6573        // Failure-triggered presence repair is throttled to once per 10s.
6574        // The native presence actor owns periodic gateway lease refreshes.
6575        let presence_republish_interval_ms_fg = 10_000_i64;
6576        let presence_republish_interval_ms_bg = i64::MAX;
6577        let mut last_network_change_at_ms = 0_i64;
6578        // Foreground: network change recovery every 15s. Background: skip.
6579        let network_change_recovery_interval_ms_fg = 15_000_i64;
6580        let network_change_recovery_interval_ms_bg = i64::MAX;
6581        let network_change_failure_threshold = auto_connect_network_change_failure_threshold();
6582
6583        loop {
6584            if !self.is_auto_connect_generation_current(generation) {
6585                #[cfg(not(target_arch = "wasm32"))]
6586                eprintln!(
6587                    "[pluto-rtc][auto-connect] stopping stale loop user_id={} local_device_id={} generation={}",
6588                    user_id, local_device_id, generation
6589                );
6590                #[cfg(target_arch = "wasm32")]
6591                web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
6592                    "[pluto-rtc][auto-connect] stopping stale loop user_id={} local_device_id={} generation={}",
6593                    user_id, local_device_id, generation
6594                )));
6595                break;
6596            }
6597
6598            // Snapshot pass so auto-connect does not depend on receiving a specific
6599            // event type from the subscription backend.
6600            if !self.is_app_execution_suspended() {
6601                self.run_auto_connect_snapshot(
6602                    &user_id,
6603                    &local_device_id,
6604                    &mut last_attempt_at,
6605                    &mut failure_count,
6606                    &mut failure_backoff_until,
6607                    retry_throttle_ms,
6608                    &mut non_initiator_wait_started_at,
6609                    &mut non_initiator_escalation_count,
6610                    initiator_grace_ms,
6611                    &mut skip_log_at,
6612                    &mut connected_health_checked_at,
6613                    &mut connected_health_state,
6614                    &mut connected_health_failures,
6615                    connected_health_probe_ms_fg,
6616                    connected_health_failure_threshold,
6617                    &mut last_presence_republish_at_ms,
6618                    presence_republish_interval_ms_fg,
6619                    &mut last_network_change_at_ms,
6620                    network_change_recovery_interval_ms_fg,
6621                    network_change_failure_threshold,
6622                    &mut last_snapshot_signature,
6623                    &mut last_snapshot_log_at,
6624                    &mut last_known_node_id,
6625                    &mut known_devices,
6626                )
6627                .await;
6628            }
6629
6630            #[allow(unused_mut)]
6631            let mut stream = match self.subscribe_devices(&user_id).await {
6632                Ok(s) => {
6633                    #[cfg(not(target_arch = "wasm32"))]
6634                    eprintln!(
6635                        "[pluto-rtc][auto-connect] subscribed user_id={} local_device_id={}",
6636                        user_id, local_device_id
6637                    );
6638                    #[cfg(target_arch = "wasm32")]
6639                    web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
6640                        "[pluto-rtc][auto-connect] subscribed user_id={} local_device_id={}",
6641                        user_id, local_device_id
6642                    )));
6643                    s
6644                }
6645                Err(e) => {
6646                    #[cfg(not(target_arch = "wasm32"))]
6647                    eprintln!(
6648                        "[pluto-rtc][auto-connect] subscribe failed user_id={} local_device_id={} error={}",
6649                        user_id, local_device_id, e
6650                    );
6651                    #[cfg(target_arch = "wasm32")]
6652                    web_sys::console::error_1(&wasm_bindgen::JsValue::from_str(&format!(
6653                        "[pluto-rtc][auto-connect] subscribe failed user_id={} local_device_id={} error={}",
6654                        user_id, local_device_id, e
6655                    )));
6656
6657                    #[cfg(not(target_arch = "wasm32"))]
6658                    tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
6659                    #[cfg(target_arch = "wasm32")]
6660                    gloo_timers::future::sleep(std::time::Duration::from_millis(1500)).await;
6661                    continue;
6662                }
6663            };
6664
6665            #[cfg(not(target_arch = "wasm32"))]
6666            {
6667                let mut last_bg_state = self.is_app_execution_suspended();
6668                let mut rescan_interval =
6669                    tokio::time::interval(std::time::Duration::from_millis(if last_bg_state {
6670                        rescan_interval_ms_bg
6671                    } else {
6672                        rescan_interval_ms_fg
6673                    }));
6674                let mut health_interval = tokio::time::interval(std::time::Duration::from_millis(
6675                    connected_health_probe_ms_fg as u64,
6676                ));
6677                health_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
6678                loop {
6679                    if !self.is_auto_connect_generation_current(generation) {
6680                        break;
6681                    }
6682                    // Recreate the interval when background state changes so the
6683                    // new period takes effect immediately rather than after the
6684                    // current (possibly 30s) tick expires.
6685                    let bg = self.is_app_execution_suspended();
6686                    if bg != last_bg_state {
6687                        last_bg_state = bg;
6688                        rescan_interval =
6689                            tokio::time::interval(std::time::Duration::from_millis(if bg {
6690                                rescan_interval_ms_bg
6691                            } else {
6692                                rescan_interval_ms_fg
6693                            }));
6694                    }
6695                    let connected_health_probe_ms = if bg {
6696                        connected_health_probe_ms_bg
6697                    } else {
6698                        connected_health_probe_ms_fg
6699                    };
6700                    let presence_republish_interval_ms = if bg {
6701                        presence_republish_interval_ms_bg
6702                    } else {
6703                        presence_republish_interval_ms_fg
6704                    };
6705                    let network_change_recovery_interval_ms = if bg {
6706                        network_change_recovery_interval_ms_bg
6707                    } else {
6708                        network_change_recovery_interval_ms_fg
6709                    };
6710                    // A discovery event can put this peer into the deterministic
6711                    // non-initiator grace window before the incoming transport
6712                    // arrives. Wake at that exact policy deadline so reciprocal
6713                    // admission is not accidentally coupled to a presence lease
6714                    // refresh or the low-frequency safety rescan.
6715                    let grace_wakeup_delay_ms = next_non_initiator_wakeup_delay_ms(
6716                        &non_initiator_wait_started_at,
6717                        &non_initiator_escalation_count,
6718                        now_millis_i64(),
6719                    );
6720                    let grace_wakeup = tokio::time::sleep(std::time::Duration::from_millis(
6721                        grace_wakeup_delay_ms.unwrap_or(rescan_interval_ms_fg),
6722                    ));
6723                    tokio::pin!(grace_wakeup);
6724                    tokio::select! {
6725                        _ = self.native_auto_connect_wake.notified() => {
6726                            if self.is_app_execution_suspended() {
6727                                continue;
6728                            }
6729                            // Admission proof changes are typed lifecycle
6730                            // input even when the provider roster and physical
6731                            // transport are unchanged. Reconcile through this
6732                            // actor's normal admission/tie-break path now.
6733                            self.run_auto_connect_snapshot(
6734                                &user_id,
6735                                &local_device_id,
6736                                &mut last_attempt_at,
6737                                &mut failure_count,
6738                                &mut failure_backoff_until,
6739                                retry_throttle_ms,
6740                                &mut non_initiator_wait_started_at,
6741                                &mut non_initiator_escalation_count,
6742                                initiator_grace_ms,
6743                                &mut skip_log_at,
6744                                &mut connected_health_checked_at,
6745                                &mut connected_health_state,
6746                                &mut connected_health_failures,
6747                                connected_health_probe_ms,
6748                                connected_health_failure_threshold,
6749                                &mut last_presence_republish_at_ms,
6750                                presence_republish_interval_ms,
6751                                &mut last_network_change_at_ms,
6752                                network_change_recovery_interval_ms,
6753                                network_change_failure_threshold,
6754                                &mut last_snapshot_signature,
6755                                &mut last_snapshot_log_at,
6756                                &mut last_known_node_id,
6757                                &mut known_devices,
6758                            ).await;
6759                        }
6760                        _ = rescan_interval.tick() => {
6761                            if self.is_app_execution_suspended() {
6762                                continue;
6763                            }
6764                            self.run_auto_connect_snapshot(
6765                                &user_id,
6766                                &local_device_id,
6767                                &mut last_attempt_at,
6768                                &mut failure_count,
6769                                &mut failure_backoff_until,
6770                                retry_throttle_ms,
6771                                &mut non_initiator_wait_started_at,
6772                                &mut non_initiator_escalation_count,
6773                                initiator_grace_ms,
6774                                &mut skip_log_at,
6775                                &mut connected_health_checked_at,
6776                                &mut connected_health_state,
6777                                &mut connected_health_failures,
6778                                connected_health_probe_ms,
6779                                connected_health_failure_threshold,
6780                                &mut last_presence_republish_at_ms,
6781                                presence_republish_interval_ms,
6782                                &mut last_network_change_at_ms,
6783                                network_change_recovery_interval_ms,
6784                                network_change_failure_threshold,
6785                                &mut last_snapshot_signature,
6786                                &mut last_snapshot_log_at,
6787                                &mut last_known_node_id,
6788                                &mut known_devices,
6789                            ).await;
6790                        }
6791                        _ = health_interval.tick() => {
6792                            if self.is_app_execution_suspended() {
6793                                continue;
6794                            }
6795
6796                            // Probe cached provider records, not the provider
6797                            // itself. Presence is coordination evidence; when a
6798                            // cached record has gone offline, an already-owned
6799                            // physical transport still receives positive
6800                            // liveness probes until that exact generation is
6801                            // retired or proves responsive.
6802                            let cached_devices: Vec<crate::signaling::Device> =
6803                                known_devices.values().cloned().collect();
6804                            for mut device in cached_devices {
6805                                if !device.online {
6806                                    let Some(ticket) = device.ticket.as_deref() else {
6807                                        continue;
6808                                    };
6809                                    let (iroh_ticket, _) =
6810                                        crate::session_token::split_ticket(ticket.trim());
6811                                    let Ok(endpoint_addr) = parse_endpoint_ticket(iroh_ticket) else {
6812                                        continue;
6813                                    };
6814                                    if !self.is_connected(endpoint_addr.id).await {
6815                                        continue;
6816                                    }
6817                                    device.online = true;
6818                                }
6819                                self.try_auto_connect_device(
6820                                    &user_id,
6821                                    device,
6822                                    &local_device_id,
6823                                    &mut last_attempt_at,
6824                                    &mut failure_count,
6825                                    &mut failure_backoff_until,
6826                                    retry_throttle_ms,
6827                                    &mut non_initiator_wait_started_at,
6828                                    &mut non_initiator_escalation_count,
6829                                    initiator_grace_ms,
6830                                    &mut skip_log_at,
6831                                    &mut connected_health_checked_at,
6832                                    &mut connected_health_state,
6833                                    &mut connected_health_failures,
6834                                    connected_health_probe_ms_fg,
6835                                    connected_health_failure_threshold,
6836                                    &mut last_presence_republish_at_ms,
6837                                    presence_republish_interval_ms_fg,
6838                                    &mut last_network_change_at_ms,
6839                                    network_change_recovery_interval_ms_fg,
6840                                    network_change_failure_threshold,
6841                                    &mut last_known_node_id,
6842                                )
6843                                .await;
6844                            }
6845                        }
6846                        _ = &mut grace_wakeup, if grace_wakeup_delay_ms.is_some() => {
6847                            if self.is_app_execution_suspended() {
6848                                continue;
6849                            }
6850                            self.run_auto_connect_snapshot(
6851                                &user_id,
6852                                &local_device_id,
6853                                &mut last_attempt_at,
6854                                &mut failure_count,
6855                                &mut failure_backoff_until,
6856                                retry_throttle_ms,
6857                                &mut non_initiator_wait_started_at,
6858                                &mut non_initiator_escalation_count,
6859                                initiator_grace_ms,
6860                                &mut skip_log_at,
6861                                &mut connected_health_checked_at,
6862                                &mut connected_health_state,
6863                                &mut connected_health_failures,
6864                                connected_health_probe_ms,
6865                                connected_health_failure_threshold,
6866                                &mut last_presence_republish_at_ms,
6867                                presence_republish_interval_ms,
6868                                &mut last_network_change_at_ms,
6869                                network_change_recovery_interval_ms,
6870                                network_change_failure_threshold,
6871                                &mut last_snapshot_signature,
6872                                &mut last_snapshot_log_at,
6873                                &mut last_known_node_id,
6874                                &mut known_devices,
6875                            ).await;
6876                        }
6877                        result = stream.next() => {
6878                            let Some(result) = result else { break; };
6879                            let events = match result {
6880                                Ok(e) => e,
6881                                Err(error) => {
6882                                    eprintln!(
6883                                        "[pluto-rtc][auto-connect] discovery subscription ended; recreating owner-managed stream: {}",
6884                                        error
6885                                    );
6886                                    break;
6887                                }
6888                            };
6889
6890                            for event in events {
6891                                match event {
6892                                    crate::signaling::DeviceEvent::Added { device }
6893                                    | crate::signaling::DeviceEvent::Modified { device } => {
6894                                        known_devices
6895                                            .insert(device.device_id.clone(), device.clone());
6896                                        self.try_auto_connect_device(
6897                                            &user_id,
6898                                            device,
6899                                            &local_device_id,
6900                                            &mut last_attempt_at,
6901                                            &mut failure_count,
6902                                            &mut failure_backoff_until,
6903                                            retry_throttle_ms,
6904                                            &mut non_initiator_wait_started_at,
6905                                            &mut non_initiator_escalation_count,
6906                                            initiator_grace_ms,
6907                                            &mut skip_log_at,
6908                                            &mut connected_health_checked_at,
6909                                            &mut connected_health_state,
6910                                            &mut connected_health_failures,
6911                                            connected_health_probe_ms_fg,
6912                                            connected_health_failure_threshold,
6913                                            &mut last_presence_republish_at_ms,
6914                                            presence_republish_interval_ms_fg,
6915                                            &mut last_network_change_at_ms,
6916                                            network_change_recovery_interval_ms_fg,
6917                                            network_change_failure_threshold,
6918                                            &mut last_known_node_id,
6919                                        )
6920                                        .await;
6921                                    }
6922                                    crate::signaling::DeviceEvent::Removed { device_id } => {
6923                                        known_devices.remove(&device_id);
6924                                        last_attempt_at.remove(&device_id);
6925                                        non_initiator_wait_started_at.remove(&device_id);
6926                                        non_initiator_escalation_count.remove(&device_id);
6927                                        skip_log_at.retain(|key, _| !key.starts_with(&format!("{}::", device_id)));
6928                                        connected_health_checked_at.remove(&device_id);
6929                                        connected_health_state.remove(&device_id);
6930                                        connected_health_failures.remove(&device_id);
6931                                        last_known_node_id
6932                                            .remove(&format!("{device_id}::runtime-instance"));
6933                                        last_known_node_id
6934                                            .remove(&format!("{device_id}::ticket"));
6935                                        clear_auto_connect_failure_state(
6936                                            &device_id,
6937                                            &mut failure_count,
6938                                            &mut failure_backoff_until,
6939                                        );
6940
6941                                        for record in self.connection_manager.get_by_device_id(&device_id).await {
6942                                            if matches!(
6943                                                record.state,
6944                                                crate::connection_manager::ConnectionState::Pending
6945                                                    | crate::connection_manager::ConnectionState::Connecting
6946                                            ) {
6947                                                self.connection_manager
6948                                                    .set_failed(
6949                                                        &record.connection_id,
6950                                                        Some("device removed from signaling snapshot".to_string()),
6951                                                    )
6952                                                    .await;
6953                                            }
6954                                        }
6955                                    }
6956                                }
6957                            }
6958                        }
6959                    }
6960                }
6961            }
6962
6963            #[cfg(target_arch = "wasm32")]
6964            {
6965                let mut stream = stream.fuse();
6966
6967                loop {
6968                    if !self.is_auto_connect_generation_current(generation) {
6969                        break;
6970                    }
6971                    let bg = self.is_app_execution_suspended();
6972                    let connected_health_probe_ms = if bg {
6973                        connected_health_probe_ms_bg
6974                    } else {
6975                        connected_health_probe_ms_fg
6976                    };
6977                    let presence_republish_interval_ms = if bg {
6978                        presence_republish_interval_ms_bg
6979                    } else {
6980                        presence_republish_interval_ms_fg
6981                    };
6982                    let network_change_recovery_interval_ms = if bg {
6983                        network_change_recovery_interval_ms_bg
6984                    } else {
6985                        network_change_recovery_interval_ms_fg
6986                    };
6987                    let rescan_ms = if bg {
6988                        rescan_interval_ms_bg
6989                    } else {
6990                        rescan_interval_ms_fg
6991                    };
6992                    let sleep =
6993                        gloo_timers::future::sleep(std::time::Duration::from_millis(rescan_ms))
6994                            .fuse();
6995                    futures::pin_mut!(sleep);
6996                    futures::select! {
6997                        _ = sleep => {
6998                            if self.is_app_execution_suspended() {
6999                                continue;
7000                            }
7001                            self.run_auto_connect_snapshot(
7002                                &user_id,
7003                                &local_device_id,
7004                                &mut last_attempt_at,
7005                                &mut failure_count,
7006                                &mut failure_backoff_until,
7007                                retry_throttle_ms,
7008                                &mut non_initiator_wait_started_at,
7009                                &mut non_initiator_escalation_count,
7010                                initiator_grace_ms,
7011                                &mut skip_log_at,
7012                                &mut connected_health_checked_at,
7013                                &mut connected_health_state,
7014                                &mut connected_health_failures,
7015                                connected_health_probe_ms,
7016                                connected_health_failure_threshold,
7017                                &mut last_presence_republish_at_ms,
7018                                presence_republish_interval_ms,
7019                                &mut last_network_change_at_ms,
7020                                network_change_recovery_interval_ms,
7021                                network_change_failure_threshold,
7022                                &mut last_snapshot_signature,
7023                                &mut last_snapshot_log_at,
7024                                &mut last_known_node_id,
7025                                &mut known_devices,
7026                            ).await;
7027                        }
7028                        result = stream.next() => {
7029                            let Some(result) = result else { break; };
7030                            let events = match result {
7031                                Ok(e) => e,
7032                                Err(error) => {
7033                                    web_sys::console::error_1(&wasm_bindgen::JsValue::from_str(
7034                                        &format!(
7035                                            "[pluto-rtc][auto-connect] discovery subscription ended; recreating owner-managed stream: {}",
7036                                            error
7037                                        ),
7038                                    ));
7039                                    break;
7040                                }
7041                            };
7042
7043                            for event in events {
7044                                match event {
7045                                    crate::signaling::DeviceEvent::Added { device }
7046                                    | crate::signaling::DeviceEvent::Modified { device } => {
7047                                        // Event-driven connects always use foreground parameters.
7048                                        self.try_auto_connect_device(
7049                                            &user_id,
7050                                            device,
7051                                            &local_device_id,
7052                                            &mut last_attempt_at,
7053                                            &mut failure_count,
7054                                            &mut failure_backoff_until,
7055                                            retry_throttle_ms,
7056                                            &mut non_initiator_wait_started_at,
7057                                            &mut non_initiator_escalation_count,
7058                                            initiator_grace_ms,
7059                                            &mut skip_log_at,
7060                                            &mut connected_health_checked_at,
7061                                            &mut connected_health_state,
7062                                            &mut connected_health_failures,
7063                                            connected_health_probe_ms_fg,
7064                                            connected_health_failure_threshold,
7065                                            &mut last_presence_republish_at_ms,
7066                                            presence_republish_interval_ms_fg,
7067                                            &mut last_network_change_at_ms,
7068                                            network_change_recovery_interval_ms_fg,
7069                                            network_change_failure_threshold,
7070                                            &mut last_known_node_id,
7071                                        )
7072                                        .await;
7073                                    }
7074                                    crate::signaling::DeviceEvent::Removed { device_id } => {
7075                                        last_attempt_at.remove(&device_id);
7076                                        non_initiator_wait_started_at.remove(&device_id);
7077                                        non_initiator_escalation_count.remove(&device_id);
7078                                        skip_log_at.retain(|key, _| !key.starts_with(&format!("{}::", device_id)));
7079                                        connected_health_checked_at.remove(&device_id);
7080                                        connected_health_state.remove(&device_id);
7081                                        connected_health_failures.remove(&device_id);
7082                                        last_known_node_id
7083                                            .remove(&format!("{device_id}::runtime-instance"));
7084                                        last_known_node_id
7085                                            .remove(&format!("{device_id}::ticket"));
7086                                        clear_auto_connect_failure_state(
7087                                            &device_id,
7088                                            &mut failure_count,
7089                                            &mut failure_backoff_until,
7090                                        );
7091
7092                                        for record in self.connection_manager.get_by_device_id(&device_id).await {
7093                                            if matches!(
7094                                                record.state,
7095                                                crate::connection_manager::ConnectionState::Pending
7096                                                    | crate::connection_manager::ConnectionState::Connecting
7097                                            ) {
7098                                                self.connection_manager
7099                                                    .set_failed(
7100                                                        &record.connection_id,
7101                                                        Some("device removed from signaling snapshot".to_string()),
7102                                                    )
7103                                                    .await;
7104                                            }
7105                                        }
7106                                    }
7107                                }
7108                            }
7109                        }
7110                    }
7111                }
7112            }
7113
7114            #[cfg(not(target_arch = "wasm32"))]
7115            eprintln!(
7116                "[pluto-rtc][auto-connect] device stream ended user_id={} local_device_id={}, resubscribing",
7117                user_id, local_device_id
7118            );
7119            #[cfg(target_arch = "wasm32")]
7120            web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
7121                "[pluto-rtc][auto-connect] device stream ended user_id={} local_device_id={}, resubscribing",
7122                user_id, local_device_id
7123            )));
7124
7125            #[cfg(not(target_arch = "wasm32"))]
7126            tokio::time::sleep(std::time::Duration::from_millis(400)).await;
7127            #[cfg(target_arch = "wasm32")]
7128            gloo_timers::future::sleep(std::time::Duration::from_millis(400)).await;
7129        }
7130    }
7131}