1#[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#[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 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 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 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#[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#[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 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 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 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#[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
407pub(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#[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 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#[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
586fn 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 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 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 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 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 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 recovery_device_ids: std::collections::HashSet<String>,
963 recovery_suppressed_until_ms: std::collections::HashMap<String, i64>,
966}
967
968#[cfg(target_arch = "wasm32")]
969struct BrowserAutoConnectActorState {
970 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
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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 {
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 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 pub(crate) async fn schedule_native_external_auto_connect_recovery(
2910 &self,
2911 remote_node_id: &str,
2912 ) -> bool {
2913 const RECOVERY_COALESCE_MS: i64 = 1_000;
2919 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 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 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 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(¤t),
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 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 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 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 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 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 !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 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 {
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 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 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 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 #[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 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 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 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 ¤t_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 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 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 #[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 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 }
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 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 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 if let Some(outgoing_conn) = self.get_connection(endpoint_id).await {
5994 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 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 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 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 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.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 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 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 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 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 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 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 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 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 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 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 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}