1use super::*;
6
7pub(crate) fn should_preserve_proven_iroh_carrier_route(
8 current_transport: &str,
9 observed_transport: &str,
10) -> bool {
11 crate::transport_label::is_generation_bound_iroh_carrier(current_transport)
12 && crate::transport_label::is_iroh_base(observed_transport)
13}
14
15pub(crate) fn should_present_session_token_on_connected_transport(
16 has_extracted_token: bool,
17 has_current_remote_admission_proof: bool,
18) -> bool {
19 has_extracted_token && !has_current_remote_admission_proof
20}
21
22fn selected_iroh_latency_label<'a>(
23 path_kind: crate::client::IrohPathKind,
24 transport_labels: impl IntoIterator<Item = &'a str>,
25) -> Option<&'a str> {
26 let labels = transport_labels
27 .into_iter()
28 .filter_map(|label| crate::transport_label::normalize(label).map(|_| label))
29 .collect::<Vec<_>>();
30 let matches_kind = |label: &&str| {
31 let normalized = crate::transport_label::normalize(label);
32 match path_kind {
33 crate::client::IrohPathKind::DirectQuic => {
34 normalized == Some(crate::transport_label::IROH)
35 || normalized == Some(crate::transport_label::IROH_QUIC)
36 }
37 crate::client::IrohPathKind::DirectLan => {
38 normalized == Some(crate::transport_label::IROH_LAN)
39 }
40 crate::client::IrohPathKind::Relay => {
41 normalized == Some(crate::transport_label::IROH_RELAY)
42 }
43 crate::client::IrohPathKind::Ble => normalized == Some(crate::transport_label::BLE),
44 crate::client::IrohPathKind::WebRtc => {
45 normalized == Some(crate::transport_label::WEBRTC)
46 }
47 crate::client::IrohPathKind::Moq => normalized == Some(crate::transport_label::MOQ),
48 crate::client::IrohPathKind::Unknown => {
49 normalized.is_some_and(crate::transport_label::is_iroh_base)
50 }
51 }
52 };
53 labels.into_iter().find(matches_kind)
54}
55
56#[cfg(not(target_arch = "wasm32"))]
57fn native_presence_retry_targets(durable_registered: bool, live_registered: bool) -> (bool, bool) {
58 (!durable_registered, !live_registered)
59}
60
61#[cfg(not(target_arch = "wasm32"))]
62fn native_presence_is_ready(durable_registered: bool, live_registered: bool) -> bool {
63 durable_registered && live_registered
64}
65
66#[cfg(not(target_arch = "wasm32"))]
67fn native_presence_retry_delay(consecutive_failures: u32) -> std::time::Duration {
68 const BASE_MS: u64 = 2_000;
69 const MAX_MS: u64 = 60_000;
70 let exponent = consecutive_failures.min(5);
71 std::time::Duration::from_millis(BASE_MS.saturating_mul(1_u64 << exponent).min(MAX_MS))
72}
73
74#[cfg(not(target_arch = "wasm32"))]
75#[derive(Clone, Debug)]
76enum NativePresenceTicketPolicy {
77 Fixed(String),
78 ManagedUserDevice,
79}
80
81#[cfg(not(target_arch = "wasm32"))]
82#[derive(Default)]
83pub(super) struct RetiredNativeCarrierAttempts {
84 #[cfg(feature = "transport-webrtc")]
85 webrtc: Option<Arc<crate::client::NativeWebRtcCarrierAttempt>>,
86 #[cfg(feature = "transport-moq")]
87 moq: Option<Arc<crate::client::NativeMoqCarrierAttempt>>,
88}
89
90#[cfg(not(target_arch = "wasm32"))]
91fn native_carrier_generation_matches_retirement(
92 generation: crate::client::NativePeerDataGeneration,
93 retiring_generation: Option<(Option<u64>, u64, u64)>,
94) -> bool {
95 retiring_generation.is_some_and(
96 |(transport_stable_id, transport_generation, route_generation)| {
97 transport_stable_id.is_none_or(|stable_id| stable_id == generation.transport_stable_id)
98 && transport_generation == generation.transport_generation
99 && route_generation == generation.route_generation
100 },
101 )
102}
103
104impl Client {
105 pub(crate) async fn managed_connect_gate(
106 &self,
107 connection_id: &str,
108 ) -> Arc<tokio::sync::Mutex<()>> {
109 let mut gates = self.managed_connect_gates.lock().await;
110 gates
111 .entry(connection_id.to_string())
112 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
113 .clone()
114 }
115
116 async fn close_cancelled_connect(
117 &self,
118 endpoint_id: iroh::EndpointId,
119 transport_stable_id: Option<u64>,
120 ) {
121 let (Some(node), Some(transport_stable_id)) = (
122 self.iroh_node.read().await.as_ref().cloned(),
123 transport_stable_id,
124 ) else {
125 return;
126 };
127 let _ = node
128 .disconnect_with_reason_if_current(
129 endpoint_id,
130 transport_stable_id,
131 crate::lifecycle_reason::REASON_MANUAL_DISCONNECT,
132 )
133 .await;
134 }
135
136 #[cfg(not(target_arch = "wasm32"))]
137 pub(super) async fn retire_native_carrier_upgrade_state_if_current(
138 &self,
139 connection_id: &str,
140 retiring_generation: Option<(Option<u64>, u64, u64)>,
141 retiring_peer_policy_epoch: Option<u64>,
142 ) -> RetiredNativeCarrierAttempts {
143 #[cfg(not(feature = "iroh-carrier-core"))]
144 let _ = retiring_peer_policy_epoch;
145 let mut gates = self.native_transport_upgrade_gates.lock().await;
146
147 #[cfg(feature = "transport-webrtc")]
148 let webrtc = {
149 let mut attempts = self.native_webrtc_carrier_attempts.write().await;
150 if attempts.get(connection_id).is_some_and(|attempt| {
151 native_carrier_generation_matches_retirement(
152 attempt.generation,
153 retiring_generation,
154 )
155 }) {
156 attempts.remove(connection_id)
157 } else {
158 None
159 }
160 };
161
162 #[cfg(feature = "transport-moq")]
163 let moq = {
164 let mut attempts = self.native_moq_carrier_attempts.write().await;
165 if attempts.get(connection_id).is_some_and(|attempt| {
166 native_carrier_generation_matches_retirement(
167 attempt.generation,
168 retiring_generation,
169 )
170 }) {
171 attempts.remove(connection_id)
172 } else {
173 None
174 }
175 };
176
177 let mut attempts = self.native_ble_upgrade_attempts.lock().await;
178 if attempts.get(connection_id).is_some_and(|attempt| {
179 native_carrier_generation_matches_retirement(attempt.generation, retiring_generation)
180 }) {
181 attempts.remove(connection_id);
182 }
183 drop(attempts);
184
185 gates.retain(|(candidate_connection_id, _), gate| {
186 candidate_connection_id != connection_id
187 || !native_carrier_generation_matches_retirement(
188 gate.generation,
189 retiring_generation,
190 )
191 });
192
193 #[cfg(feature = "iroh-carrier-core")]
194 if !gates
195 .keys()
196 .any(|(candidate_connection_id, _)| candidate_connection_id == connection_id)
197 {
198 if retiring_peer_policy_epoch.is_some_and(|expected_epoch| {
199 self.current_iroh_carrier_peer_policy_epoch(connection_id) == expected_epoch
200 }) {
201 self.forget_iroh_carrier_peer_policy_epoch(connection_id);
202 }
203 }
204
205 RetiredNativeCarrierAttempts {
206 #[cfg(feature = "transport-webrtc")]
207 webrtc,
208 #[cfg(feature = "transport-moq")]
209 moq,
210 }
211 }
212
213 pub(crate) async fn retire_managed_connection_now(
214 &self,
215 connection_id: &str,
216 normalized_reason: Option<String>,
217 ) {
218 #[cfg(all(
219 not(target_arch = "wasm32"),
220 any(feature = "transport-webrtc", feature = "transport-moq")
221 ))]
222 let retirement_reason = normalized_reason.clone();
223 #[cfg(not(target_arch = "wasm32"))]
224 let retiring_record = self
225 .connection_manager
226 .get_by_connection_id(connection_id)
227 .await;
228 #[cfg(not(target_arch = "wasm32"))]
229 let retiring_generation = retiring_record.as_ref().map(|record| {
230 (
231 record.transport_stable_id,
232 record.transport_generation,
233 record.route_generation,
234 )
235 });
236 #[cfg(all(not(target_arch = "wasm32"), feature = "iroh-carrier-core"))]
237 let retiring_peer_policy_epoch =
238 Some(self.current_iroh_carrier_peer_policy_epoch(connection_id));
239 #[cfg(all(not(target_arch = "wasm32"), not(feature = "iroh-carrier-core")))]
240 let retiring_peer_policy_epoch = None;
241 let terminal_disconnect =
242 crate::lifecycle_reason::is_terminal(normalized_reason.as_deref());
243 #[cfg(not(target_arch = "wasm32"))]
244 let already_terminal = retiring_record.as_ref().is_some_and(|record| {
245 matches!(
246 record.state,
247 crate::connection_manager::ConnectionState::Closed
248 | crate::connection_manager::ConnectionState::Failed
249 )
250 });
251 #[cfg(target_arch = "wasm32")]
252 let already_terminal = self
253 .connection_manager
254 .get_by_connection_id(connection_id)
255 .await
256 .is_some_and(|record| {
257 matches!(
258 record.state,
259 crate::connection_manager::ConnectionState::Closed
260 | crate::connection_manager::ConnectionState::Failed
261 )
262 });
263 if !already_terminal {
264 self.connection_manager
265 .set_closing(connection_id, normalized_reason.clone())
266 .await;
267 self.connection_manager
268 .set_closed(connection_id, normalized_reason)
269 .await;
270 }
271 #[cfg(not(target_arch = "wasm32"))]
272 self.emit_current_native_connection_state(connection_id)
273 .await;
274 #[cfg(target_arch = "wasm32")]
275 self.emit_current_wasm_connection_state(connection_id).await;
276 if terminal_disconnect {
277 self.forget_session_connection(connection_id);
278 }
279 let _ = self.connection_manager.remove(connection_id).await;
280 self.managed_connect_gates
281 .lock()
282 .await
283 .remove(connection_id);
284 #[cfg(not(target_arch = "wasm32"))]
285 self.native_peer_transport_capabilities
286 .write()
287 .await
288 .remove(connection_id);
289 #[cfg(all(target_arch = "wasm32", feature = "iroh-carrier-core"))]
290 self.forget_iroh_carrier_peer_policy_epoch(connection_id);
291 #[cfg(not(target_arch = "wasm32"))]
292 self.native_control_streams
293 .lock()
294 .await
295 .remove(connection_id);
296 #[cfg(not(target_arch = "wasm32"))]
297 let retired_native_carrier_attempts = self
298 .retire_native_carrier_upgrade_state_if_current(
299 connection_id,
300 retiring_generation,
301 retiring_peer_policy_epoch,
302 )
303 .await;
304 #[cfg(all(
305 not(target_arch = "wasm32"),
306 not(any(feature = "transport-webrtc", feature = "transport-moq"))
307 ))]
308 let _ = retired_native_carrier_attempts;
309 #[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
310 if let Some(attempt) = retired_native_carrier_attempts.webrtc {
311 if retirement_reason.as_deref()
312 == Some(crate::lifecycle_reason::REASON_SESSION_TOKEN_REVOKED)
313 {
314 if let Some(pump) = attempt.pump.lock().await.as_ref() {
315 let _ = pump
316 .send_terminal(crate::lifecycle_reason::REASON_SESSION_TOKEN_REVOKED)
317 .await;
318 }
319 }
320 self.record_native_iroh_carrier_debug_event(
321 connection_id,
322 &attempt.upgrade_id,
323 "close-requested",
324 retirement_reason
325 .as_deref()
326 .or(Some("logical-connection-retired")),
327 );
328 attempt.channel.close();
329 let _ = attempt.pump.lock().await.take();
330 }
331
332 #[cfg(all(not(target_arch = "wasm32"), feature = "transport-moq"))]
333 if let Some(attempt) = retired_native_carrier_attempts.moq {
334 if retirement_reason.as_deref()
335 == Some(crate::lifecycle_reason::REASON_SESSION_TOKEN_REVOKED)
336 {
337 if let Some(pump) = attempt.pump.lock().await.as_ref() {
338 let _ = pump
339 .send_terminal(crate::lifecycle_reason::REASON_SESSION_TOKEN_REVOKED)
340 .await;
341 }
342 }
343 attempt.session.close();
344 let _ = attempt.pump.lock().await.take();
345 }
346 }
347
348 #[cfg(not(target_arch = "wasm32"))]
357 const SETTLED_PEER_STABILITY_WINDOW_MS: u64 = 3_000;
358 #[cfg(target_arch = "wasm32")]
359 const SETTLED_PEER_STABILITY_WINDOW_MS: u64 = 3_000;
360 #[cfg(target_arch = "wasm32")]
361 const EMPTY_PEER_SESSIONS_WARNING_INTERVAL_MS: u64 = 5_000;
362
363 fn normalize_transport_name(value: &str) -> Option<String> {
364 crate::transport_label::normalize(value).map(str::to_string)
365 }
366
367 #[cfg(test)]
370 pub async fn report_transport_status(
371 &self,
372 connection_id: &str,
373 active_transport: &str,
374 parallel_transport: Option<&str>,
375 ) -> Option<PeerSessionSnapshot> {
376 self.report_transport_status_for_current_generation(
377 connection_id,
378 active_transport,
379 parallel_transport,
380 )
381 .await
382 }
383
384 #[cfg(any(test, target_arch = "wasm32", feature = "iroh-carrier-core"))]
388 pub(crate) async fn report_transport_status_for_current_generation(
389 &self,
390 connection_id: &str,
391 active_transport: &str,
392 parallel_transport: Option<&str>,
393 ) -> Option<PeerSessionSnapshot> {
394 let record = self
395 .connection_manager
396 .get_by_connection_id(connection_id)
397 .await?;
398 let stable_id = record.transport_stable_id?;
399 self.report_transport_status_for_generation(
400 connection_id,
401 active_transport,
402 parallel_transport,
403 stable_id,
404 record.transport_generation,
405 record.route_generation,
406 )
407 .await
408 }
409
410 pub(crate) async fn report_transport_status_for_generation(
411 &self,
412 connection_id: &str,
413 active_transport: &str,
414 parallel_transport: Option<&str>,
415 expected_transport_stable_id: u64,
416 expected_transport_generation: u64,
417 expected_route_generation: u64,
418 ) -> Option<PeerSessionSnapshot> {
419 self.report_transport_status_observation(
420 connection_id,
421 active_transport,
422 parallel_transport,
423 (
424 expected_transport_stable_id,
425 expected_transport_generation,
426 expected_route_generation,
427 ),
428 )
429 .await
430 }
431
432 async fn report_transport_status_observation(
433 &self,
434 connection_id: &str,
435 active_transport: &str,
436 parallel_transport: Option<&str>,
437 expected_generation: (u64, u64, u64),
438 ) -> Option<PeerSessionSnapshot> {
439 let normalized_active_transport = Self::normalize_transport_name(active_transport)?;
440 let normalized_parallel_transport = parallel_transport
441 .and_then(Self::normalize_transport_name)
442 .filter(|value| value != &normalized_active_transport);
443 let current_record = self
444 .connection_manager
445 .get_by_connection_id(connection_id)
446 .await;
447 let (normalized_active_transport, normalized_parallel_transport) = {
448 let mut active_transport = normalized_active_transport;
449 let mut parallel_transport = normalized_parallel_transport;
450 if let Some(record) = current_record.as_ref() {
451 if should_preserve_proven_iroh_carrier_route(
452 record.active_transport.as_str(),
453 active_transport.as_str(),
454 ) {
455 active_transport = record.active_transport.clone();
462 parallel_transport = record.parallel_transport.clone();
463 }
464 }
465 (active_transport, parallel_transport)
466 };
467
468 let (active_transport, parallel_transport) =
469 (normalized_active_transport, normalized_parallel_transport);
470
471 let transport_changed = current_record
472 .as_ref()
473 .map(|record| {
474 record.active_transport != active_transport
475 || record.parallel_transport != parallel_transport
476 })
477 .unwrap_or(true);
478 let (stable_id, transport_generation, route_generation) = expected_generation;
479 let snapshot = self
480 .connection_manager
481 .report_transport_status_if_current(
482 connection_id,
483 stable_id,
484 transport_generation,
485 route_generation,
486 active_transport,
487 parallel_transport,
488 )
489 .await
490 .map(peer_session_snapshot_from_peer)
491 .map(|snapshot| self.apply_peer_session_admission_guard(snapshot));
492 if snapshot.is_some() && transport_changed {
493 #[cfg(not(target_arch = "wasm32"))]
494 self.emit_current_native_connection_state(connection_id)
495 .await;
496 #[cfg(target_arch = "wasm32")]
497 self.emit_current_wasm_connection_state(connection_id).await;
498 }
499 snapshot
500 }
501
502 #[cfg(not(target_arch = "wasm32"))]
503 pub(crate) async fn current_native_peer_data_generation(
504 &self,
505 connection_id: &str,
506 expected_transport_stable_id: Option<u64>,
507 ) -> Option<crate::client::NativePeerDataGeneration> {
508 let record = self
509 .connection_manager
510 .get_by_connection_id(connection_id)
511 .await?;
512 if !matches!(
513 record.state,
514 crate::connection_manager::ConnectionState::Connecting
515 | crate::connection_manager::ConnectionState::Connected
516 ) {
517 return None;
518 }
519 let transport_stable_id = record.transport_stable_id?;
520 if expected_transport_stable_id.is_some_and(|expected| expected != transport_stable_id) {
521 return None;
522 }
523 Some(crate::client::NativePeerDataGeneration {
524 transport_stable_id,
525 transport_generation: record.transport_generation,
526 route_generation: record.route_generation,
527 })
528 }
529
530 #[cfg(all(
531 target_arch = "wasm32",
532 any(feature = "transport-webrtc", feature = "transport-moq")
533 ))]
534 pub(crate) async fn current_wasm_peer_data_generation(
535 &self,
536 connection_id: &str,
537 expected_transport_stable_id: Option<u64>,
538 ) -> Option<crate::client::WasmPeerDataGeneration> {
539 let record = self
540 .connection_manager
541 .get_by_connection_id(connection_id)
542 .await?;
543 if !matches!(
544 record.state,
545 crate::connection_manager::ConnectionState::Connecting
546 | crate::connection_manager::ConnectionState::Connected
547 ) {
548 return None;
549 }
550 let transport_stable_id = record.transport_stable_id?;
551 if expected_transport_stable_id.is_some_and(|expected| expected != transport_stable_id) {
552 return None;
553 }
554 Some(crate::client::WasmPeerDataGeneration {
555 transport_stable_id,
556 transport_generation: record.transport_generation,
557 route_generation: record.route_generation,
558 })
559 }
560
561 async fn best_peer_snapshot_for_lookup(
562 &self,
563 id: &str,
564 seed: Option<&crate::connection_manager::PeerSnapshot>,
565 ) -> Option<crate::connection_manager::PeerSnapshot> {
566 let mut aliases = std::collections::HashSet::new();
567 if let Some(alias) = normalize_lookup_id(Some(id)) {
568 aliases.insert(alias);
569 }
570 if let Some(seed) = seed {
571 aliases.extend(peer_snapshot_lookup_aliases(seed));
572 }
573 if aliases.is_empty() {
574 return None;
575 }
576
577 self.connection_manager
578 .list_peer_snapshots()
579 .await
580 .into_iter()
581 .filter(|snapshot| peer_snapshot_matches_any_alias(snapshot, &aliases))
582 .fold(None, |current, candidate| {
583 Some(preferred_peer_snapshot(current, candidate))
584 })
585 }
586
587 async fn resolved_peer_snapshot_for_lookup(
588 &self,
589 id: &str,
590 ) -> Option<crate::connection_manager::PeerSnapshot> {
591 let snapshot = self.connection_manager.peer_snapshot(id).await;
592 self.best_peer_snapshot_for_lookup(id, snapshot.as_ref())
593 .await
594 .or(snapshot)
595 }
596
597 #[cfg(test)]
598 pub(crate) fn session_admission_block_reason(
599 &self,
600 connection_id: &str,
601 ) -> Option<(bool, String)> {
602 let block = self.session_admission_block_reason_for_transport(connection_id, None);
603 if matches!(
604 block.as_ref(),
605 Some((false, reason)) if reason == "application-crypto-confirmation-pending"
606 ) && self.connection_application_crypto_has_bound_confirmation(connection_id)
607 {
608 None
609 } else {
610 block
611 }
612 }
613
614 pub(crate) fn session_admission_block_reason_for_transport(
615 &self,
616 connection_id: &str,
617 transport_stable_id: Option<u64>,
618 ) -> Option<(bool, String)> {
619 #[cfg(target_arch = "wasm32")]
620 let _ = transport_stable_id;
621
622 if self.session_registry_active() {
623 match self.session_admission(connection_id) {
624 crate::session_token::SessionAdmission::Accepted { .. } => {
625 #[cfg(not(target_arch = "wasm32"))]
626 if !self.native_admission_route_is_ready_for_transport(
627 connection_id,
628 transport_stable_id,
629 ) {
630 if Self::admission_trace_enabled() {
631 let diagnostic = transport_stable_id
632 .map(|stable_id| {
633 self.native_application_stream_pending_diagnostic(
634 connection_id,
635 stable_id,
636 )
637 })
638 .unwrap_or_else(|| "transport_stable_id=missing".to_string());
639 eprintln!(
640 "[OpenRTC][admission-readiness] connection_id={} reason=native-main-route-pending {}",
641 connection_id, diagnostic,
642 );
643 }
644 return Some((false, "native-main-route-pending".to_string()));
645 }
646 }
647 crate::session_token::SessionAdmission::Pending => {
648 return Some((false, "session-admission-pending".to_string()));
649 }
650 crate::session_token::SessionAdmission::Rejected { reason } => {
651 return Some((true, reason));
652 }
653 }
654 }
655
656 if self.connection_requires_application_crypto(connection_id)
657 && self
658 .application_crypto_key_for_connection(Some(connection_id))
659 .is_none()
660 {
661 return Some((false, "application-crypto-key-pending".to_string()));
662 }
663
664 if self.connection_requires_application_crypto_confirmation(connection_id)
665 && !self.connection_application_crypto_is_confirmed(connection_id, transport_stable_id)
666 {
667 return Some((false, "application-crypto-confirmation-pending".to_string()));
668 }
669
670 None
671 }
672
673 fn apply_peer_session_admission_guard(
674 &self,
675 mut snapshot: PeerSessionSnapshot,
676 ) -> PeerSessionSnapshot {
677 if let Some(connection_id) = snapshot.active_connection_id.as_deref() {
683 if let crate::session_token::SessionAdmission::Accepted {
684 scope: Some(scope), ..
685 } = self.session_admission(connection_id)
686 {
687 let scope = scope.into_inner();
688 if !scope.trim().is_empty() && !snapshot.scopes.contains(&scope) {
689 snapshot.scopes.push(scope);
690 snapshot.scopes.sort();
691 snapshot.scopes.dedup();
692 }
693 }
694 }
695
696 let connection_id = snapshot
697 .active_connection_id
698 .clone()
699 .or_else(|| snapshot.candidate_connection_ids.first().cloned());
700 let Some(connection_id) = connection_id else {
701 return snapshot;
702 };
703 let Some((rejected, reason)) = self.session_admission_block_reason_for_transport(
704 &connection_id,
705 snapshot.active_transport_stable_id,
706 ) else {
707 return snapshot;
708 };
709
710 snapshot.settled_ready = false;
711 snapshot.replacement_pending = false;
712 snapshot.readiness_reason = reason.clone();
713
714 if rejected {
715 snapshot.status = crate::connection_manager::ConnectionState::Failed;
716 snapshot.readiness_state = ReadinessState::Failed;
717 snapshot.health = crate::connection_manager::ConnectionHealth::Stale;
718 snapshot.error = Some(reason);
719 } else {
720 if matches!(
721 snapshot.status,
722 crate::connection_manager::ConnectionState::Connected
723 ) {
724 snapshot.status = crate::connection_manager::ConnectionState::Connecting;
725 }
726 snapshot.readiness_state = ReadinessState::Settling;
727 }
728
729 snapshot
730 }
731
732 fn apply_connection_state_admission_guard(&self, mut snapshot: StateSnapshot) -> StateSnapshot {
733 if matches!(
734 snapshot.state.as_str(),
735 "closed" | "failed" | "disconnected"
736 ) {
737 return snapshot;
742 }
743 let Some((rejected, reason)) = self.session_admission_block_reason_for_transport(
744 &snapshot.connection_id,
745 snapshot.active_transport_stable_id,
746 ) else {
747 return snapshot;
748 };
749
750 snapshot.routable = false;
751 snapshot.replacement_in_progress = false;
752 snapshot.readiness_reason = reason.clone();
753
754 if rejected {
755 snapshot.state = "failed".to_string();
756 snapshot.protocol_state = "admission-rejected".to_string();
757 snapshot.readiness_state = ReadinessState::Failed;
758 snapshot.error = Some(reason);
759 } else {
760 snapshot.state = "connecting".to_string();
761 snapshot.protocol_state = "awaiting-admission".to_string();
762 snapshot.readiness_state = ReadinessState::Settling;
763 }
764
765 snapshot
766 }
767
768 fn apply_device_status_admission_guard(
769 &self,
770 mut snapshot: DeviceStatusSnapshot,
771 ) -> DeviceStatusSnapshot {
772 if matches!(
773 snapshot.connection_status,
774 ConnectionStatus::Disconnected | ConnectionStatus::Failed | ConnectionStatus::Closed
775 ) {
776 return snapshot;
780 }
781 let Some(connection_id) = snapshot.connection_id.clone() else {
782 return snapshot;
783 };
784 let Some((rejected, reason)) = self.session_admission_block_reason_for_transport(
785 &connection_id,
786 snapshot.active_transport_stable_id,
787 ) else {
788 return snapshot;
789 };
790
791 snapshot.settled_ready = false;
792 snapshot.readiness_reason = reason.clone();
793
794 if rejected {
795 snapshot.connection_status = ConnectionStatus::Failed;
796 snapshot.readiness_state = ReadinessState::Failed;
797 snapshot.peer_health = crate::connection_manager::ConnectionHealth::Stale;
798 } else {
799 snapshot.connection_status = ConnectionStatus::Connecting;
800 snapshot.readiness_state = ReadinessState::Settling;
801 }
802
803 snapshot
804 }
805
806 pub(crate) async fn resolve_settled_peer_endpoint(
807 &self,
808 id: &str,
809 timeout_ms: Option<u64>,
810 ) -> anyhow::Result<(PeerSessionSnapshot, iroh::EndpointId)> {
811 let timeout_ms = timeout_ms.unwrap_or(10_000);
812
813 loop {
814 let snapshot = self
815 .wait_for_peer(id, Some(timeout_ms))
816 .await
817 .ok_or_else(|| anyhow::anyhow!("peer {} not found", id))?;
818
819 if snapshot.settled_ready && !Self::peer_session_is_stably_settled(&snapshot) {
820 anyhow::bail!(
821 "peer-stream-route-pending: peer {id} generation has not remained settled long enough for protocol traffic"
822 );
823 }
824
825 if !snapshot.settled_ready {
826 #[cfg(not(target_arch = "wasm32"))]
827 let native_route_diagnostic = snapshot
828 .active_connection_id
829 .as_deref()
830 .zip(snapshot.active_transport_stable_id)
831 .map(|(connection_id, transport_stable_id)| {
832 self.native_application_stream_pending_diagnostic(
833 connection_id,
834 transport_stable_id,
835 )
836 })
837 .unwrap_or_else(|| "unavailable".to_string());
838 #[cfg(target_arch = "wasm32")]
839 let native_route_diagnostic = "wasm-runtime".to_string();
840 return Err(anyhow::anyhow!(
841 "peer {} is not settled for protocol traffic: status={:?} readiness={:?} reason={} transport_stable_id={:?} route=[{}]",
842 id,
843 snapshot.status,
844 snapshot.readiness_state,
845 snapshot.readiness_reason,
846 snapshot.active_transport_stable_id,
847 native_route_diagnostic,
848 ));
849 }
850
851 let node_id = snapshot
852 .node_id
853 .as_deref()
854 .map(str::trim)
855 .filter(|value| !value.is_empty())
856 .ok_or_else(|| anyhow::anyhow!("peer {} is missing a remote node id", id))?;
857
858 let endpoint_id = node_id.parse::<iroh::EndpointId>().map_err(|error| {
859 anyhow::anyhow!("peer {} has invalid remote node id: {}", id, error)
860 })?;
861
862 if !self.is_connection_transport_alive(endpoint_id).await {
863 return Err(anyhow::anyhow!(
864 "peer {} has no active transport for protocol traffic",
865 id
866 ));
867 }
868
869 return Ok((snapshot, endpoint_id));
870 }
871 }
872
873 pub(crate) async fn wait_for_peer_transport_replacement(
874 &self,
875 id: &str,
876 prior_connection_id: &str,
877 prior_endpoint_id: iroh::EndpointId,
878 prior_transport_stable_id: u64,
879 timeout_ms: u64,
880 ) -> Option<(PeerSessionSnapshot, iroh::EndpointId)> {
881 #[cfg(not(target_arch = "wasm32"))]
882 let started = std::time::Instant::now();
883 #[cfg(target_arch = "wasm32")]
884 let started_ms = js_sys::Date::now();
885
886 loop {
887 if let Some(snapshot) = self.peer_session(id).await {
888 let endpoint_id = snapshot
889 .node_id
890 .as_deref()
891 .map(str::trim)
892 .filter(|value| !value.is_empty())
893 .and_then(|value| value.parse::<iroh::EndpointId>().ok());
894 let generation_changed = snapshot.active_connection_id.as_deref()
895 != Some(prior_connection_id)
896 || snapshot.active_transport_stable_id != Some(prior_transport_stable_id)
897 || endpoint_id.is_some_and(|value| value != prior_endpoint_id);
898
899 if generation_changed && Self::peer_session_is_stably_settled(&snapshot) {
900 if let Some(endpoint_id) = endpoint_id {
901 if self.is_connection_transport_alive(endpoint_id).await {
902 return Some((snapshot, endpoint_id));
903 }
904 }
905 }
906 }
907
908 #[cfg(not(target_arch = "wasm32"))]
909 let timed_out = started.elapsed().as_millis() >= timeout_ms as u128;
910 #[cfg(target_arch = "wasm32")]
911 let timed_out = (js_sys::Date::now() - started_ms) >= timeout_ms as f64;
912 if timed_out {
913 return None;
914 }
915
916 #[cfg(not(target_arch = "wasm32"))]
917 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
918 #[cfg(target_arch = "wasm32")]
919 gloo_timers::future::sleep(std::time::Duration::from_millis(50)).await;
920 }
921 }
922
923 async fn resolve_diagnostic_peer_endpoint(
924 &self,
925 id: &str,
926 timeout_ms: Option<u64>,
927 ) -> anyhow::Result<(PeerSessionSnapshot, iroh::EndpointId)> {
928 let timeout_ms = timeout_ms.unwrap_or(10_000);
929 #[cfg(not(target_arch = "wasm32"))]
930 let started = std::time::Instant::now();
931 #[cfg(target_arch = "wasm32")]
932 let started_ms = js_sys::Date::now();
933
934 loop {
935 let snapshot = self
936 .peer_session(id)
937 .await
938 .ok_or_else(|| anyhow::anyhow!("peer {} not found", id))?;
939 let connection_id = snapshot
940 .active_connection_id
941 .as_deref()
942 .map(str::trim)
943 .filter(|value| !value.is_empty())
944 .ok_or_else(|| anyhow::anyhow!("peer {} is missing an active connection", id))?;
945
946 let readiness_block = self.session_admission_block_reason_for_transport(
947 connection_id,
948 snapshot.active_transport_stable_id,
949 );
950 if let Some((true, reason)) = readiness_block.as_ref() {
951 return Err(anyhow::anyhow!(
952 "peer {} is not admitted for diagnostic traffic: {}",
953 id,
954 reason
955 ));
956 }
957
958 let node_id = snapshot
959 .node_id
960 .as_deref()
961 .map(str::trim)
962 .filter(|value| !value.is_empty())
963 .ok_or_else(|| anyhow::anyhow!("peer {} is missing a remote node id", id))?;
964 let endpoint_id = node_id.parse::<iroh::EndpointId>().map_err(|error| {
965 anyhow::anyhow!("peer {} has invalid remote node id: {}", id, error)
966 })?;
967
968 if readiness_block.is_none() && self.is_connection_transport_alive(endpoint_id).await {
969 return Ok((snapshot, endpoint_id));
970 }
971
972 #[cfg(not(target_arch = "wasm32"))]
973 let timed_out = started.elapsed().as_millis() >= timeout_ms as u128;
974 #[cfg(target_arch = "wasm32")]
975 let timed_out = (js_sys::Date::now() - started_ms) >= timeout_ms as f64;
976 if timed_out {
977 if let Some((_rejected, reason)) = readiness_block {
978 return Err(anyhow::anyhow!(
979 "peer {} did not become ready for diagnostic traffic: {}",
980 id,
981 reason
982 ));
983 }
984 return Err(anyhow::anyhow!(
985 "peer {} has no active transport for diagnostic traffic",
986 id
987 ));
988 }
989
990 #[cfg(not(target_arch = "wasm32"))]
991 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
992 #[cfg(target_arch = "wasm32")]
993 gloo_timers::future::sleep(std::time::Duration::from_millis(50)).await;
994 }
995 }
996
997 pub async fn add_peer_scope(&self, id: &str, scope: &str) -> Vec<String> {
998 self.connection_manager.add_scope(id, scope).await
999 }
1000
1001 pub async fn release_peer_scope(&self, id: &str, scope: Option<&str>) -> Vec<String> {
1002 self.connection_manager.release_scope(id, scope).await
1003 }
1004
1005 pub async fn peer_scopes(&self, id: &str) -> Vec<String> {
1006 self.connection_manager.get_scopes(id).await
1007 }
1008
1009 pub async fn same_peer(&self, left: &str, right: &str) -> bool {
1010 self.connection_manager.are_same_peer(left, right).await
1011 }
1012
1013 pub async fn peer_snapshot(&self, id: &str) -> Option<crate::connection_manager::PeerSnapshot> {
1014 self.resolved_peer_snapshot_for_lookup(id).await
1015 }
1016
1017 pub async fn peer_session(&self, id: &str) -> Option<PeerSessionSnapshot> {
1030 self.resolved_peer_snapshot_for_lookup(id)
1031 .await
1032 .map(peer_session_snapshot_from_peer)
1033 .map(|snapshot| self.apply_peer_session_admission_guard(snapshot))
1034 }
1035
1036 pub async fn peer_sessions(&self) -> Vec<PeerSessionSnapshot> {
1037 let snapshots: Vec<PeerSessionSnapshot> = self
1038 .connection_manager
1039 .list_peer_snapshots()
1040 .await
1041 .into_iter()
1042 .map(peer_session_snapshot_from_peer)
1043 .map(|snapshot| self.apply_peer_session_admission_guard(snapshot))
1044 .collect();
1045
1046 #[cfg(target_arch = "wasm32")]
1047 if snapshots.is_empty() {
1048 let active_endpoints = {
1049 let node_guard = self.iroh_node.read().await;
1050 if let Some(node) = node_guard.as_ref() {
1051 node.active_endpoint_ids().await.len()
1052 } else {
1053 0
1054 }
1055 };
1056 if active_endpoints > 0 {
1057 let now_ms = js_sys::Date::now() as u64;
1058 let previous_ms = self
1059 .last_empty_peer_sessions_warning_ms
1060 .load(std::sync::atomic::Ordering::Relaxed);
1061 if now_ms.saturating_sub(previous_ms)
1062 >= Self::EMPTY_PEER_SESSIONS_WARNING_INTERVAL_MS
1063 {
1064 self.last_empty_peer_sessions_warning_ms
1065 .store(now_ms, std::sync::atomic::Ordering::Relaxed);
1066 let record_count = self.connection_manager.list_all().await.len();
1067 web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
1068 "[pluto-rtc][peer-sessions][wasm] empty snapshot list active_endpoints={} record_count={} note=expected briefly during token-first connects before the main stream is registered; live-transports-may-belong-to-another-client-instance-until-adopted",
1069 active_endpoints, record_count
1070 )));
1071 }
1072 }
1073 }
1074
1075 snapshots
1076 }
1077
1078 pub async fn connection_state(&self, connection_id: &str) -> Option<StateSnapshot> {
1079 let record = self
1080 .connection_manager
1081 .get_by_connection_id(connection_id)
1082 .await?;
1083 #[allow(unused_mut)]
1084 let mut peer_snapshot = self.connection_manager.peer_snapshot(connection_id).await;
1085 Some(
1086 self.apply_connection_state_admission_guard(connection_state_snapshot_from_parts(
1087 &record,
1088 peer_snapshot.as_ref(),
1089 )),
1090 )
1091 }
1092
1093 pub async fn connection_states(&self) -> Vec<StateSnapshot> {
1094 let records = self.connection_manager.list_active().await;
1095 let mut snapshots = Vec::with_capacity(records.len());
1096 for record in records {
1097 let peer_snapshot = self
1098 .connection_manager
1099 .peer_snapshot(&record.connection_id)
1100 .await;
1101 snapshots.push(self.apply_connection_state_admission_guard(
1102 connection_state_snapshot_from_parts(&record, peer_snapshot.as_ref()),
1103 ));
1104 }
1105 snapshots
1106 }
1107
1108 #[cfg(target_arch = "wasm32")]
1109 pub async fn emit_current_wasm_connection_state(&self, connection_id: &str) {
1110 if let Some(snapshot) = self.connection_state(connection_id).await {
1111 let fingerprint = super::WasmConnectionStateFingerprint::from(&snapshot);
1112 let terminal = matches!(
1113 snapshot.state.as_str(),
1114 "closed" | "failed" | "disconnected"
1115 );
1116 let should_emit = {
1117 let mut emitted = self
1118 .last_emitted_connection_states
1119 .lock()
1120 .expect("wasm connection state cache poisoned");
1121 if terminal {
1127 emitted.insert(connection_id.to_string(), fingerprint);
1128 true
1129 } else {
1130 match emitted.get(connection_id) {
1131 Some(previous) if *previous == fingerprint => false,
1132 _ => {
1133 emitted.insert(connection_id.to_string(), fingerprint);
1134 true
1135 }
1136 }
1137 }
1138 };
1139 if !should_emit {
1140 return;
1141 }
1142 emit_wasm_connection_state_event(&snapshot);
1143 }
1144 }
1145
1146 pub async fn wait_for_peer(
1147 &self,
1148 id: &str,
1149 timeout_ms: Option<u64>,
1150 ) -> Option<PeerSessionSnapshot> {
1151 let timeout_ms = timeout_ms.unwrap_or(10_000);
1152 #[cfg(not(target_arch = "wasm32"))]
1153 let started = std::time::Instant::now();
1154 #[cfg(target_arch = "wasm32")]
1155 let started_ms = js_sys::Date::now();
1156 loop {
1157 let snapshot = self.peer_session(id).await;
1158 if let Some(snapshot_ref) = snapshot.as_ref() {
1159 if Self::peer_session_is_stably_settled(snapshot_ref) {
1160 return snapshot;
1161 }
1162 }
1163 #[cfg(not(target_arch = "wasm32"))]
1164 let timed_out = started.elapsed().as_millis() >= timeout_ms as u128;
1165 #[cfg(target_arch = "wasm32")]
1166 let timed_out = (js_sys::Date::now() - started_ms) >= timeout_ms as f64;
1167 if timed_out {
1168 return snapshot;
1169 }
1170 #[cfg(not(target_arch = "wasm32"))]
1171 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1172 #[cfg(target_arch = "wasm32")]
1173 gloo_timers::future::sleep(std::time::Duration::from_millis(50)).await;
1174 }
1175 }
1176
1177 pub async fn wait_for_settled_scope(
1186 &self,
1187 scope: &str,
1188 timeout_ms: Option<u64>,
1189 ) -> Option<PeerSessionSnapshot> {
1190 let scope = scope.trim();
1191 if scope.is_empty() {
1192 return None;
1193 }
1194
1195 let timeout_ms = timeout_ms.unwrap_or(10_000);
1196 #[cfg(not(target_arch = "wasm32"))]
1197 let started = std::time::Instant::now();
1198 #[cfg(target_arch = "wasm32")]
1199 let started_ms = js_sys::Date::now();
1200 loop {
1201 let mut matches = self
1202 .peer_sessions()
1203 .await
1204 .into_iter()
1205 .filter(|snapshot| snapshot.scopes.iter().any(|candidate| candidate == scope));
1206 let snapshot = matches.next();
1207 let snapshot = if matches.next().is_none() {
1208 snapshot
1209 } else {
1210 None
1211 };
1212
1213 if let Some(snapshot_ref) = snapshot.as_ref() {
1214 if Self::peer_session_is_stably_settled(snapshot_ref) {
1215 return snapshot;
1216 }
1217 }
1218
1219 #[cfg(not(target_arch = "wasm32"))]
1220 let timed_out = started.elapsed().as_millis() >= timeout_ms as u128;
1221 #[cfg(target_arch = "wasm32")]
1222 let timed_out = (js_sys::Date::now() - started_ms) >= timeout_ms as f64;
1223 if timed_out {
1224 return snapshot.filter(Self::peer_session_is_stably_settled);
1225 }
1226 #[cfg(not(target_arch = "wasm32"))]
1227 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1228 #[cfg(target_arch = "wasm32")]
1229 gloo_timers::future::sleep(std::time::Duration::from_millis(50)).await;
1230 }
1231 }
1232
1233 fn peer_session_is_stably_settled(snapshot: &PeerSessionSnapshot) -> bool {
1234 if !snapshot.settled_ready {
1235 return false;
1236 }
1237 if snapshot.candidate_connection_ids.is_empty()
1238 && snapshot.transport_generation <= 1
1239 && snapshot.route_generation <= 1
1240 {
1241 return true;
1242 }
1243
1244 let stable_deadline_ms = snapshot
1245 .last_lifecycle_transition_at_ms
1246 .saturating_add(Self::SETTLED_PEER_STABILITY_WINDOW_MS as i64);
1247 #[cfg(not(target_arch = "wasm32"))]
1248 {
1249 now_millis_i64() >= stable_deadline_ms
1250 }
1251 #[cfg(target_arch = "wasm32")]
1252 {
1253 js_sys::Date::now() as i64 >= stable_deadline_ms
1254 }
1255 }
1256
1257 pub(crate) fn peer_stream_generation_matches(
1258 expected: &PeerSessionSnapshot,
1259 current: &PeerSessionSnapshot,
1260 opened_transport_stable_id: u64,
1261 ) -> bool {
1262 Self::peer_session_is_stably_settled(current)
1263 && current.active_connection_id == expected.active_connection_id
1264 && current.node_id == expected.node_id
1265 && current.active_transport_stable_id == Some(opened_transport_stable_id)
1266 && current.transport_generation == expected.transport_generation
1267 && current.route_generation == expected.route_generation
1268 }
1269
1270 pub(crate) async fn confirm_managed_connection_readiness(&self, connection_id: &str) -> bool {
1271 let healthy = self.probe_peer_health(connection_id).await;
1272 #[cfg(not(target_arch = "wasm32"))]
1273 self.emit_current_native_connection_state(connection_id)
1274 .await;
1275 #[cfg(target_arch = "wasm32")]
1276 self.emit_current_wasm_connection_state(connection_id).await;
1277 healthy
1278 }
1279
1280 pub(crate) async fn confirm_managed_connection_readiness_from_transport_proof(
1281 &self,
1282 connection_id: &str,
1283 expected_transport_stable_id: u64,
1284 ) -> bool {
1285 let confirmed = self
1286 .connection_manager
1287 .set_health_if_current(
1288 connection_id,
1289 expected_transport_stable_id,
1290 crate::connection_manager::ConnectionHealth::Healthy,
1291 )
1292 .await
1293 .is_some();
1294 #[cfg(not(target_arch = "wasm32"))]
1295 self.emit_current_native_connection_state(connection_id)
1296 .await;
1297 #[cfg(target_arch = "wasm32")]
1298 self.emit_current_wasm_connection_state(connection_id).await;
1299 confirmed
1300 }
1301
1302 pub async fn open_peer_bi(
1303 &self,
1304 id: &str,
1305 timeout_ms: Option<u64>,
1306 ) -> anyhow::Result<(
1307 Option<String>,
1308 String,
1309 crate::application_crypto_streams::PeerSendStream,
1310 crate::application_crypto_streams::PeerRecvStream,
1311 )> {
1312 let (snapshot, endpoint_id, send, recv) = self.open_peer_bi_current(id, timeout_ms).await?;
1313 Ok((
1314 snapshot.active_connection_id,
1315 endpoint_id.to_string(),
1316 send,
1317 recv,
1318 ))
1319 }
1320
1321 pub(crate) async fn open_peer_bi_current(
1322 &self,
1323 id: &str,
1324 timeout_ms: Option<u64>,
1325 ) -> anyhow::Result<(
1326 PeerSessionSnapshot,
1327 iroh::EndpointId,
1328 crate::application_crypto_streams::PeerSendStream,
1329 crate::application_crypto_streams::PeerRecvStream,
1330 )> {
1331 let (snapshot, endpoint_id) = self.resolve_settled_peer_endpoint(id, timeout_ms).await?;
1332 self.ensure_connection_manager_record_before_peer_stream(&endpoint_id)
1333 .await?;
1334 let (send, recv) = self
1335 .open_generation_bound_peer_bi(
1336 id,
1337 &snapshot,
1338 endpoint_id,
1339 timeout_ms.map(std::time::Duration::from_millis),
1340 "outgoing bidirectional application stream",
1341 )
1342 .await?;
1343 Ok((snapshot, endpoint_id, send, recv))
1344 }
1345
1346 async fn open_generation_bound_peer_bi(
1347 &self,
1348 id: &str,
1349 expected: &PeerSessionSnapshot,
1350 endpoint_id: iroh::EndpointId,
1351 timeout: Option<std::time::Duration>,
1352 operation: &str,
1353 ) -> anyhow::Result<(
1354 crate::application_crypto_streams::PeerSendStream,
1355 crate::application_crypto_streams::PeerRecvStream,
1356 )> {
1357 let expected_connection_id = expected.active_connection_id.as_deref().ok_or_else(|| {
1358 anyhow::anyhow!("peer-stream-route-pending: peer {id} has no active connection")
1359 })?;
1360 let expected_transport_stable_id =
1361 expected.active_transport_stable_id.ok_or_else(|| {
1362 anyhow::anyhow!(
1363 "peer-stream-route-pending: peer {id} has no active transport generation"
1364 )
1365 })?;
1366
1367 #[cfg(not(target_arch = "wasm32"))]
1368 let opened = if let Some(timeout) = timeout {
1369 tokio::time::timeout(
1370 timeout,
1371 self.open_current_bi_internal_with_transport_stable_id(endpoint_id),
1372 )
1373 .await
1374 .map_err(|_| {
1375 anyhow::anyhow!(
1376 "peer-stream-route-pending: stream open timed out after {}ms for peer {id}",
1377 timeout.as_millis(),
1378 )
1379 })?
1380 } else {
1381 self.open_current_bi_internal_with_transport_stable_id(endpoint_id)
1382 .await
1383 };
1384 #[cfg(target_arch = "wasm32")]
1385 let opened = {
1386 let _ = timeout;
1387 self.open_current_bi_internal_with_transport_stable_id(endpoint_id)
1388 .await
1389 };
1390 let (opened_transport_stable_id, send, recv) = opened.map_err(|error| {
1391 anyhow::anyhow!("peer-stream-route-pending: peer {id} has no current route: {error}")
1392 })?;
1393 if opened_transport_stable_id != expected_transport_stable_id {
1394 anyhow::bail!(
1395 "peer-stream-generation-stale: peer {id} physical generation changed while opening a protected stream"
1396 );
1397 }
1398
1399 let key = self
1400 .required_crypto_key(Some(expected_connection_id), &endpoint_id, operation)
1401 .await?;
1402 let wrapped =
1403 self.wrap_application_streams(expected_connection_id, key, send, recv, &[])?;
1404
1405 let current = self.peer_session(id).await;
1406 let current_physical_stable_id = self
1407 .get_connection(endpoint_id)
1408 .await
1409 .map(|connection| crate::transport_generation::for_connection(&connection));
1410 let generation_is_current = current.as_ref().is_some_and(|current| {
1411 Self::peer_stream_generation_matches(expected, current, opened_transport_stable_id)
1412 });
1413 if current_physical_stable_id != Some(opened_transport_stable_id) || !generation_is_current
1414 {
1415 anyhow::bail!(
1416 "peer-stream-generation-stale: peer {id} changed generation while opening a protected stream"
1417 );
1418 }
1419
1420 Ok(wrapped)
1421 }
1422
1423 #[cfg(not(target_arch = "wasm32"))]
1431 pub async fn open_peer_protected_bi(
1432 &self,
1433 id: &str,
1434 timeout_ms: Option<u64>,
1435 ) -> anyhow::Result<(
1436 Option<String>,
1437 String,
1438 crate::application_crypto_streams::PeerSendStream,
1439 crate::application_crypto_streams::PeerRecvStream,
1440 )> {
1441 let timeout_ms = timeout_ms.unwrap_or(10_000);
1442 let (snapshot, endpoint_id) = self
1443 .resolve_settled_peer_endpoint(id, Some(timeout_ms))
1444 .await?;
1445 let connection_id = snapshot
1446 .active_connection_id
1447 .as_deref()
1448 .map(str::trim)
1449 .filter(|value| !value.is_empty())
1450 .ok_or_else(|| anyhow::anyhow!("peer {} is missing an active connection", id))?;
1451 let transport_stable_id = snapshot.active_transport_stable_id;
1452
1453 self.ensure_connection_manager_record_before_peer_stream(&endpoint_id)
1454 .await?;
1455 self.set_connection_application_crypto_required(connection_id);
1456 if self
1457 .application_crypto_key_for_connection(Some(connection_id))
1458 .is_none()
1459 || !self.connection_application_crypto_is_confirmed(connection_id, transport_stable_id)
1460 {
1461 let public_key = self
1462 .get_or_create_connection_key_agreement(connection_id)
1463 .map_err(|error| {
1464 anyhow::anyhow!(
1465 "application crypto key agreement initialization failed for peer {}: {:?}",
1466 id,
1467 error
1468 )
1469 })?
1470 .public_key_bytes();
1471 self.send_typescript_capability_update(
1472 connection_id,
1473 "protected-application-stream",
1474 Some(public_key),
1475 )
1476 .await?;
1477 }
1478
1479 let started = std::time::Instant::now();
1480 let endpoint_id_text = endpoint_id.to_string();
1481 loop {
1482 let current = self
1483 .peer_session(id)
1484 .await
1485 .ok_or_else(|| anyhow::anyhow!("peer {} disappeared during key agreement", id))?;
1486 let generation_is_current = current.active_connection_id.as_deref()
1487 == Some(connection_id)
1488 && current.node_id.as_deref() == Some(endpoint_id_text.as_str());
1489 if !generation_is_current {
1490 anyhow::bail!(
1491 "peer {} connection generation changed during application crypto key agreement",
1492 id
1493 );
1494 }
1495 if self
1496 .application_crypto_key_for_connection(Some(connection_id))
1497 .is_some()
1498 && self.connection_application_crypto_is_confirmed(
1499 connection_id,
1500 current.active_transport_stable_id,
1501 )
1502 {
1503 break;
1504 }
1505 if started.elapsed().as_millis() >= timeout_ms as u128 {
1506 anyhow::bail!(
1507 "peer {} application crypto key agreement timed out after {}ms",
1508 id,
1509 timeout_ms
1510 );
1511 }
1512 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1513 }
1514
1515 let (send, recv) = self
1516 .open_generation_bound_peer_bi(
1517 id,
1518 &snapshot,
1519 endpoint_id,
1520 Some(std::time::Duration::from_millis(
1521 timeout_ms
1522 .saturating_sub(
1523 started
1524 .elapsed()
1525 .as_millis()
1526 .try_into()
1527 .unwrap_or(timeout_ms),
1528 )
1529 .max(1),
1530 )),
1531 "outgoing protected bidirectional application stream",
1532 )
1533 .await?;
1534 Ok((
1535 Some(connection_id.to_string()),
1536 endpoint_id.to_string(),
1537 send,
1538 recv,
1539 ))
1540 }
1541
1542 pub async fn send_peer_application_frame(
1547 &self,
1548 id: &str,
1549 frame: &[u8],
1550 timeout_ms: Option<u64>,
1551 ) -> anyhow::Result<()> {
1552 let (_connection_id, _remote_node_id, mut send, _recv) =
1553 self.open_peer_bi(id, timeout_ms).await?;
1554 let envelope = crate::stream_metadata::encode_envelope(
1555 crate::stream_metadata::DEFAULT_PEER_CHANNEL_ID,
1556 None,
1557 )?;
1558 send.write_all(&envelope).await?;
1559 send.write_all(frame).await?;
1560 send.finish_and_wait_for_peer(std::time::Duration::from_secs(2))
1561 .await?;
1562 Ok(())
1563 }
1564
1565 pub async fn open_peer_bi_explicit_file_sender(
1572 &self,
1573 id: &str,
1574 timeout_ms: Option<u64>,
1575 ) -> anyhow::Result<(
1576 Option<String>,
1577 String,
1578 crate::application_crypto_streams::PeerSendStream,
1579 )> {
1580 let (connection_id, remote_node_id, send, _recv) = self
1581 .open_peer_bi_explicit_file_streams(id, timeout_ms)
1582 .await?;
1583 Ok((connection_id, remote_node_id, send))
1584 }
1585
1586 #[cfg(not(target_arch = "wasm32"))]
1590 pub async fn open_peer_bi_explicit_file(
1591 &self,
1592 id: &str,
1593 timeout_ms: Option<u64>,
1594 ) -> anyhow::Result<(
1595 Option<String>,
1596 String,
1597 crate::application_crypto_streams::PeerSendStream,
1598 crate::application_crypto_streams::PeerRecvStream,
1599 )> {
1600 self.open_peer_bi_explicit_file_streams(id, timeout_ms)
1601 .await
1602 }
1603
1604 async fn open_peer_bi_explicit_file_streams(
1605 &self,
1606 id: &str,
1607 timeout_ms: Option<u64>,
1608 ) -> anyhow::Result<(
1609 Option<String>,
1610 String,
1611 crate::application_crypto_streams::PeerSendStream,
1612 crate::application_crypto_streams::PeerRecvStream,
1613 )> {
1614 let (snapshot, endpoint_id) = self.resolve_settled_peer_endpoint(id, timeout_ms).await?;
1615 let remote_node_id = endpoint_id.to_string();
1616 self.ensure_connection_manager_record_before_peer_stream(&endpoint_id)
1617 .await?;
1618 let (mut send, recv) = self
1619 .open_generation_bound_peer_bi(
1620 id,
1621 &snapshot,
1622 endpoint_id,
1623 timeout_ms.map(std::time::Duration::from_millis),
1624 "outgoing explicit-file application stream",
1625 )
1626 .await?;
1627 let connection_id = snapshot.active_connection_id.clone();
1628 send.write_all(&[crate::explicit_transfer_crypto::EXPLICIT_FILE_PROTOCOL_BYTE])
1629 .await?;
1630 Ok((connection_id, remote_node_id, send, recv))
1631 }
1632
1633 pub async fn open_peer_bi_diagnostic(
1639 &self,
1640 id: &str,
1641 timeout_ms: Option<u64>,
1642 ) -> anyhow::Result<(
1643 Option<String>,
1644 String,
1645 crate::application_crypto_streams::PeerSendStream,
1646 crate::application_crypto_streams::PeerRecvStream,
1647 )> {
1648 let (snapshot, endpoint_id) = self
1649 .resolve_diagnostic_peer_endpoint(id, timeout_ms)
1650 .await?;
1651 let remote_node_id = endpoint_id.to_string();
1652 self.ensure_connection_manager_record_before_peer_stream(&endpoint_id)
1653 .await?;
1654 let (send, recv) = self
1655 .open_bi_internal_with_timeout(
1656 endpoint_id,
1657 timeout_ms.map(std::time::Duration::from_millis),
1658 )
1659 .await?;
1660 let key = self
1661 .required_crypto_key(
1662 snapshot.active_connection_id.as_deref(),
1663 &endpoint_id,
1664 "outgoing diagnostic application stream",
1665 )
1666 .await?;
1667 let connection_id = snapshot
1668 .active_connection_id
1669 .as_deref()
1670 .ok_or_else(|| anyhow::anyhow!("diagnostic peer has no active connection"))?;
1671 self.wrap_application_streams(connection_id, key, send, recv, &[])
1672 .map(|wrapped| {
1673 (
1674 snapshot.active_connection_id,
1675 remote_node_id,
1676 wrapped.0,
1677 wrapped.1,
1678 )
1679 })
1680 }
1681
1682 pub async fn open_peer_bi_transport_only(
1686 &self,
1687 id: &str,
1688 timeout_ms: Option<u64>,
1689 ) -> anyhow::Result<(
1690 Option<String>,
1691 String,
1692 iroh::endpoint::SendStream,
1693 iroh::endpoint::RecvStream,
1694 )> {
1695 let timeout_ms = timeout_ms.unwrap_or(10_000);
1696 #[cfg(not(target_arch = "wasm32"))]
1697 let started = std::time::Instant::now();
1698 #[cfg(target_arch = "wasm32")]
1699 let started_ms = js_sys::Date::now();
1700
1701 loop {
1702 let snapshot = self
1703 .peer_session(id)
1704 .await
1705 .ok_or_else(|| anyhow::anyhow!("peer {} not found", id))?;
1706 let connection_id = snapshot
1707 .active_connection_id
1708 .as_deref()
1709 .map(str::trim)
1710 .filter(|value| !value.is_empty())
1711 .ok_or_else(|| anyhow::anyhow!("peer {} is missing an active connection", id))?;
1712
1713 if let Some((_rejected, reason)) = self.session_admission_block_reason_for_transport(
1714 connection_id,
1715 snapshot.active_transport_stable_id,
1716 ) {
1717 return Err(anyhow::anyhow!(
1718 "peer {} is not admitted for transport-only traffic: {}",
1719 id,
1720 reason
1721 ));
1722 }
1723
1724 let node_id = snapshot
1725 .node_id
1726 .as_deref()
1727 .map(str::trim)
1728 .filter(|value| !value.is_empty())
1729 .ok_or_else(|| anyhow::anyhow!("peer {} is missing a remote node id", id))?;
1730
1731 let endpoint_id = node_id.parse::<iroh::EndpointId>().map_err(|error| {
1732 anyhow::anyhow!("peer {} has invalid remote node id: {}", id, error)
1733 })?;
1734
1735 if self
1736 .application_crypto_key_for_endpoint(&endpoint_id)
1737 .await
1738 .is_some()
1739 {
1740 anyhow::bail!(
1741 "open_peer_bi_transport_only is not allowed while application crypto is active; use open_peer_bi instead"
1742 );
1743 }
1744
1745 self.ensure_connection_manager_record_before_peer_stream(&endpoint_id)
1746 .await?;
1747 match self.open_bi_internal(endpoint_id).await {
1748 Ok((send, recv)) => {
1749 return Ok((
1750 snapshot.active_connection_id,
1751 endpoint_id.to_string(),
1752 send,
1753 recv,
1754 ));
1755 }
1756 Err(error) => {
1757 #[cfg(not(target_arch = "wasm32"))]
1758 let timed_out = started.elapsed().as_millis() >= timeout_ms as u128;
1759 #[cfg(target_arch = "wasm32")]
1760 let timed_out = (js_sys::Date::now() - started_ms) >= timeout_ms as f64;
1761 if timed_out {
1762 return Err(anyhow::anyhow!(
1763 "peer {} transport-only open timed out after redial attempts: {}",
1764 id,
1765 error
1766 ));
1767 }
1768 }
1769 }
1770
1771 #[cfg(not(target_arch = "wasm32"))]
1772 let timed_out = started.elapsed().as_millis() >= timeout_ms as u128;
1773 #[cfg(target_arch = "wasm32")]
1774 let timed_out = (js_sys::Date::now() - started_ms) >= timeout_ms as f64;
1775 if timed_out {
1776 return Err(anyhow::anyhow!(
1777 "peer {} transport not alive (transport-only open timed out)",
1778 id
1779 ));
1780 }
1781
1782 #[cfg(not(target_arch = "wasm32"))]
1783 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1784 #[cfg(target_arch = "wasm32")]
1785 gloo_timers::future::sleep(std::time::Duration::from_millis(50)).await;
1786 }
1787 }
1788
1789 pub async fn open_peer_uni(
1790 &self,
1791 id: &str,
1792 timeout_ms: Option<u64>,
1793 ) -> anyhow::Result<(
1794 Option<String>,
1795 String,
1796 crate::application_crypto_streams::PeerSendStream,
1797 )> {
1798 let (snapshot, endpoint_id) = self.resolve_settled_peer_endpoint(id, timeout_ms).await?;
1799 let remote_node_id = endpoint_id.to_string();
1800 let node_guard = self.iroh_node.read().await;
1801 let node = node_guard
1802 .as_ref()
1803 .ok_or_else(|| anyhow::anyhow!("Iroh node not initialized"))?;
1804 let send = node.open_uni(endpoint_id).await?;
1805 let connection_id = snapshot.active_connection_id.clone();
1806 let key = self
1807 .required_crypto_key(
1808 snapshot.active_connection_id.as_deref(),
1809 &endpoint_id,
1810 "outgoing unidirectional application stream",
1811 )
1812 .await?;
1813 let send = match key {
1814 Some(key) => {
1815 let id = connection_id
1816 .as_deref()
1817 .ok_or_else(|| anyhow::anyhow!("peer has no active connection"))?;
1818 let access = self.application_stream_access(id, key)?;
1819 crate::application_crypto_streams::PeerSendStream::encrypted(send, key)
1820 .with_access(access)
1821 }
1822 None => crate::application_crypto_streams::PeerSendStream::plain(send),
1823 };
1824 Ok((connection_id, remote_node_id, send))
1825 }
1826
1827 pub const MAX_PEER_DATAGRAM_BYTES: usize = 1_024;
1830 const PEER_DATAGRAM_MAGIC: [u8; 4] = *b"ORD1";
1831
1832 pub async fn send_peer_datagram(&self, id: &str, payload: &[u8]) -> anyhow::Result<()> {
1835 if payload.len() > Self::MAX_PEER_DATAGRAM_BYTES {
1836 anyhow::bail!(
1837 "peer datagram payload exceeds {} bytes",
1838 Self::MAX_PEER_DATAGRAM_BYTES
1839 );
1840 }
1841 loop {
1842 let (snapshot, endpoint_id) =
1843 self.resolve_settled_peer_endpoint(id, Some(10_000)).await?;
1844 let connection_id = snapshot
1845 .active_connection_id
1846 .as_deref()
1847 .ok_or_else(|| anyhow::anyhow!("peer {id} has no active connection"))?;
1848 let expected_stable_id = snapshot
1849 .active_transport_stable_id
1850 .ok_or_else(|| anyhow::anyhow!("peer {id} has no active transport generation"))?;
1851 if self
1852 .application_crypto_key_for_connection(Some(connection_id))
1853 .is_none()
1854 {
1855 anyhow::bail!("protected peer datagrams require confirmed application crypto");
1856 }
1857 if self.connection_requires_application_crypto_confirmation(connection_id)
1858 && !self.connection_application_crypto_is_confirmed(
1859 connection_id,
1860 Some(expected_stable_id),
1861 )
1862 {
1863 anyhow::bail!("protected peer datagrams require confirmed application crypto");
1864 }
1865 let protected = self.protect_outbound_application_payload(connection_id, payload)?;
1866 let mut frame = Vec::with_capacity(Self::PEER_DATAGRAM_MAGIC.len() + protected.len());
1867 frame.extend_from_slice(&Self::PEER_DATAGRAM_MAGIC);
1868 frame.extend_from_slice(&protected);
1869 let Some(connection) = self.get_connection(endpoint_id).await else {
1870 if self
1871 .wait_for_peer_transport_replacement(
1872 id,
1873 connection_id,
1874 endpoint_id,
1875 expected_stable_id,
1876 10_000,
1877 )
1878 .await
1879 .is_some()
1880 {
1881 continue;
1882 }
1883 anyhow::bail!("peer {id} has no live logical Iroh connection");
1884 };
1885 let stable_id = crate::transport_generation::for_connection(&connection);
1886 if stable_id != expected_stable_id {
1887 continue;
1888 }
1889 match connection
1890 .send_datagram_wait(bytes::Bytes::from(frame))
1891 .await
1892 {
1893 Ok(()) => return Ok(()),
1894 Err(error) => {
1895 if self
1896 .wait_for_peer_transport_replacement(
1897 id,
1898 connection_id,
1899 endpoint_id,
1900 stable_id,
1901 10_000,
1902 )
1903 .await
1904 .is_some()
1905 {
1906 continue;
1907 }
1908 return Err(anyhow::anyhow!("peer datagram send failed: {error}"));
1909 }
1910 }
1911 }
1912 }
1913
1914 pub async fn send_peer_datagram_with_max_age(
1918 &self,
1919 id: &str,
1920 payload: &[u8],
1921 max_age_ms: u64,
1922 ) -> anyhow::Result<bool> {
1923 if max_age_ms == 0 {
1924 return Ok(false);
1925 }
1926 use futures::FutureExt;
1927 let send = self.send_peer_datagram(id, payload).fuse();
1928 #[cfg(target_arch = "wasm32")]
1929 let deadline =
1930 gloo_timers::future::sleep(std::time::Duration::from_millis(max_age_ms)).fuse();
1931 #[cfg(not(target_arch = "wasm32"))]
1932 let deadline = tokio::time::sleep(std::time::Duration::from_millis(max_age_ms)).fuse();
1933 futures::pin_mut!(send, deadline);
1934 futures::select! {
1935 result = send => result.map(|()| true),
1936 _ = deadline => Ok(false),
1937 }
1938 }
1939
1940 pub async fn receive_peer_datagram(&self, id: &str) -> anyhow::Result<Vec<u8>> {
1944 loop {
1945 let (snapshot, endpoint_id) =
1946 self.resolve_settled_peer_endpoint(id, Some(10_000)).await?;
1947 let connection_id = snapshot
1948 .active_connection_id
1949 .as_deref()
1950 .ok_or_else(|| anyhow::anyhow!("peer {id} has no active connection"))?;
1951 let expected_stable_id = snapshot
1952 .active_transport_stable_id
1953 .ok_or_else(|| anyhow::anyhow!("peer {id} has no active transport generation"))?;
1954 if self
1955 .application_crypto_key_for_connection(Some(connection_id))
1956 .is_none()
1957 || (self.connection_requires_application_crypto_confirmation(connection_id)
1958 && !self.connection_application_crypto_is_confirmed(
1959 connection_id,
1960 Some(expected_stable_id),
1961 ))
1962 {
1963 anyhow::bail!("protected peer datagrams require confirmed application crypto");
1964 }
1965 let Some(connection) = self.get_connection(endpoint_id).await else {
1966 if self
1967 .wait_for_peer_transport_replacement(
1968 id,
1969 connection_id,
1970 endpoint_id,
1971 expected_stable_id,
1972 10_000,
1973 )
1974 .await
1975 .is_some()
1976 {
1977 continue;
1978 }
1979 anyhow::bail!("peer {id} has no live logical Iroh connection");
1980 };
1981 let stable_id = crate::transport_generation::for_connection(&connection);
1982 if stable_id != expected_stable_id {
1983 continue;
1984 }
1985 let frame = match connection.read_datagram().await {
1986 Ok(frame) => frame,
1987 Err(error) => {
1988 let replacement = self
1989 .wait_for_peer_transport_replacement(
1990 id,
1991 connection_id,
1992 endpoint_id,
1993 stable_id,
1994 10_000,
1995 )
1996 .await
1997 .is_some();
1998 if replacement {
1999 continue;
2000 }
2001 return Err(anyhow::anyhow!("peer datagram receive failed: {error}"));
2002 }
2003 };
2004 let current_transport = self.get_connection(endpoint_id).await.filter(|current| {
2005 crate::transport_generation::for_connection(current) == stable_id
2006 });
2007 let Ok((current_snapshot, current_endpoint_id)) =
2008 self.resolve_settled_peer_endpoint(id, Some(0)).await
2009 else {
2010 continue;
2011 };
2012 if current_transport.is_none()
2013 || current_endpoint_id != endpoint_id
2014 || current_snapshot.active_connection_id.as_deref() != Some(connection_id)
2015 || current_snapshot.active_transport_stable_id != Some(stable_id)
2016 {
2017 continue;
2018 }
2019 let Some(protected) = frame.strip_prefix(&Self::PEER_DATAGRAM_MAGIC) else {
2020 continue;
2021 };
2022 if let Ok(payload) = self.open_inbound_application_payload(connection_id, protected) {
2023 return Ok(payload);
2024 }
2025 }
2026 }
2027
2028 pub async fn resolve_peer_connection_ids(&self, id: &str) -> Vec<String> {
2035 let mut connection_ids = Vec::new();
2036
2037 if let Some(snapshot) = self.connection_manager.peer_snapshot(id).await {
2038 connection_ids.extend(snapshot.connection_ids);
2039 }
2040
2041 if let Some(record) = self.connection_manager.get_by_connection_id(id).await {
2042 connection_ids.push(record.connection_id);
2043 }
2044
2045 if let Some((left, right)) = id.split_once('-') {
2046 for node_id in [left.trim(), right.trim()] {
2047 if node_id.is_empty() {
2048 continue;
2049 }
2050 for record in self.connection_manager.get_by_node_id(node_id).await {
2051 if let Some(snapshot) = self
2052 .connection_manager
2053 .peer_snapshot(&record.connection_id)
2054 .await
2055 {
2056 connection_ids.extend(snapshot.connection_ids);
2057 } else {
2058 connection_ids.push(record.connection_id);
2059 }
2060 }
2061 }
2062
2063 let all_records = self.connection_manager.list_all().await;
2064 for node_id in [left.trim(), right.trim()] {
2065 if node_id.is_empty() {
2066 continue;
2067 }
2068 for record in all_records.iter().filter(|record| {
2069 record
2070 .connection_id
2071 .split('-')
2072 .any(|part| part.trim() == node_id)
2073 }) {
2074 if let Some(snapshot) = self
2075 .connection_manager
2076 .peer_snapshot(&record.connection_id)
2077 .await
2078 {
2079 connection_ids.extend(snapshot.connection_ids);
2080 } else {
2081 connection_ids.push(record.connection_id.clone());
2082 }
2083 }
2084 }
2085 }
2086
2087 if connection_ids.len() > 1 {
2088 let mut unique = std::collections::HashSet::with_capacity(connection_ids.len());
2089 connection_ids.retain(|connection_id| unique.insert(connection_id.clone()));
2090 }
2091
2092 connection_ids
2093 }
2094
2095 pub async fn resolve_peer_connection_records(
2096 &self,
2097 id: &str,
2098 ) -> Vec<crate::connection_manager::ConnectionRecord> {
2099 let mut records = Vec::new();
2100 for connection_id in self.resolve_peer_connection_ids(id).await {
2101 if let Some(record) = self
2102 .connection_manager
2103 .get_by_connection_id(&connection_id)
2104 .await
2105 {
2106 records.push(record);
2107 }
2108 }
2109 records.sort_by(|left, right| right.updated_at_ms.cmp(&left.updated_at_ms));
2110 records
2111 }
2112
2113 pub async fn best_connection_record_for_peer(
2114 &self,
2115 id: &str,
2116 ) -> Option<crate::connection_manager::ConnectionRecord> {
2117 self.connection_manager.best_connection_for_peer(id).await
2118 }
2119
2120 pub async fn list_managed_connections(
2121 &self,
2122 ) -> Vec<crate::connection_manager::ConnectionRecord> {
2123 self.connection_manager.list_all().await
2124 }
2125
2126 pub async fn managed_connection_device_hint(&self, id: &str) -> Option<String> {
2134 if let Some(record) = self.connection_manager.get_by_connection_id(id).await {
2138 if let Some(device_id) = record
2139 .device_id
2140 .as_deref()
2141 .or(record.device_id_hint.as_deref())
2142 .map(str::trim)
2143 .filter(|value| !value.is_empty())
2144 {
2145 return Some(device_id.to_string());
2146 }
2147
2148 return None;
2154 }
2155
2156 self.resolve_peer_connection_records(id)
2157 .await
2158 .into_iter()
2159 .find_map(|record| {
2160 record
2161 .device_id
2162 .as_deref()
2163 .map(str::trim)
2164 .filter(|value| !value.is_empty())
2165 .map(ToOwned::to_owned)
2166 .or_else(|| {
2167 record
2168 .device_id_hint
2169 .as_deref()
2170 .map(str::trim)
2171 .filter(|value| !value.is_empty())
2172 .map(ToOwned::to_owned)
2173 })
2174 })
2175 }
2176
2177 #[cfg(not(target_arch = "wasm32"))]
2178 pub async fn managed_connection_adoption(
2179 &self,
2180 record: &crate::connection_manager::ConnectionRecord,
2181 ) -> Option<crate::client::ConnectionAdoption> {
2182 if !matches!(
2183 record.state,
2184 crate::connection_manager::ConnectionState::Connected
2185 ) {
2186 return None;
2187 }
2188
2189 let admitted_scope = if self.session_registry_active() {
2194 let admission = self.session_token_registry.admission(&record.connection_id);
2195 match &admission {
2196 crate::session_token::SessionAdmission::Accepted { scope, .. } => scope.clone(),
2197 crate::session_token::SessionAdmission::Pending => return None,
2198 crate::session_token::SessionAdmission::Rejected { reason } => {
2199 eprintln!(
2200 "[PlutoRTC][managed-adoption][blocked] connection_id={} reason={} node_id={} device_id_hint={}",
2201 record.connection_id,
2202 reason,
2203 record.node_id.as_deref().unwrap_or("pending"),
2204 record
2205 .device_id
2206 .as_deref()
2207 .or(record.device_id_hint.as_deref())
2208 .unwrap_or("pending")
2209 );
2210 return None;
2211 }
2212 }
2213 } else {
2214 None
2215 };
2216
2217 let node_id = record
2218 .node_id
2219 .as_deref()
2220 .map(str::trim)
2221 .filter(|value| !value.is_empty())?
2222 .to_string();
2223 let device_id = self
2224 .managed_connection_device_hint(&record.connection_id)
2225 .await;
2226
2227 let uses_transient_identity = admitted_scope.as_ref().is_some_and(|scope| {
2232 super::admission_impl::session_scope_uses_transient_peer_identity(scope.as_str())
2233 });
2234 let resolved_device_id = if uses_transient_identity {
2235 Some(node_id.clone())
2240 } else {
2241 device_id
2242 .as_deref()
2243 .map(str::trim)
2244 .filter(|value| !value.is_empty())
2245 .map(ToOwned::to_owned)
2246 .or_else(|| {
2247 if matches!(
2248 admitted_scope.as_ref().map(|scope| scope.as_str()),
2249 Some("user-device")
2250 ) {
2251 record
2252 .device_id
2253 .as_deref()
2254 .or(record.device_id_hint.as_deref())
2255 .map(str::trim)
2256 .filter(|value| !value.is_empty())
2257 .map(ToOwned::to_owned)
2258 } else {
2259 None
2260 }
2261 })
2262 };
2263
2264 if resolved_device_id.is_none()
2265 && matches!(
2266 admitted_scope.as_ref().map(|scope| scope.as_str()),
2267 Some("user-device")
2268 )
2269 {
2270 eprintln!(
2271 "[PlutoRTC][managed-adoption][wait] connection_id={} scope=user-device reason=missing-authoritative-device-id node_id={} device_id_hint={}",
2272 record.connection_id,
2273 node_id,
2274 record
2275 .device_id
2276 .as_deref()
2277 .or(record.device_id_hint.as_deref())
2278 .unwrap_or("pending")
2279 );
2280 }
2281
2282 Some(crate::client::ConnectionAdoption {
2283 connection_id: record.connection_id.clone(),
2284 node_id,
2285 device_id: resolved_device_id.clone(),
2286 transport_generation: record.transport_generation,
2287 status_reason: record.status_reason.clone(),
2288 main_stream_ready: resolved_device_id.is_some()
2289 && self.native_admission_route_is_ready_for_transport(
2290 &record.connection_id,
2291 record.transport_stable_id,
2292 ),
2293 })
2294 }
2295
2296 pub async fn retire_managed_connection(&self, connection_id: &str, reason: Option<String>) {
2297 let normalized_reason = reason.and_then(|value| {
2298 let trimmed = value.trim();
2299 if trimmed.is_empty() {
2300 None
2301 } else {
2302 Some(trimmed.to_string())
2303 }
2304 });
2305
2306 #[cfg(target_arch = "wasm32")]
2307 {
2308 web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
2309 "[pluto-rtc][managed-retire] connection_id={} reason={:?}",
2310 connection_id, normalized_reason
2311 )));
2312 }
2313 #[cfg(not(target_arch = "wasm32"))]
2314 {
2315 eprintln!(
2316 "[pluto-rtc][managed-retire] connection_id={} reason={:?}",
2317 connection_id, normalized_reason
2318 );
2319 }
2320
2321 self.retire_managed_connection_now(connection_id, normalized_reason)
2322 .await;
2323 }
2324
2325 pub async fn bind_connection_device_id(
2333 &self,
2334 connection_id: &str,
2335 device_id: &str,
2336 ) -> Option<crate::connection_manager::PeerSnapshot> {
2337 let trimmed = device_id.trim();
2338 if !trimmed.is_empty() {
2339 let admission = self.session_admission(connection_id);
2340 let durable_admission = matches!(
2341 &admission,
2342 crate::session_token::SessionAdmission::Accepted {
2343 scope: Some(scope),
2344 ..
2345 } if super::admission_impl::session_scope_allows_device_binding(scope.as_str())
2346 );
2347 let legacy_trusted_binding = !self.session_registry_active()
2348 && matches!(admission, crate::session_token::SessionAdmission::Pending);
2349
2350 if durable_admission || legacy_trusted_binding {
2351 let admitted = self
2352 .mark_trusted_user_device_connection_admitted(connection_id, trimmed)
2353 .await;
2354 if admitted {
2357 if let Some(record) = self
2358 .connection_manager
2359 .get_by_connection_id(connection_id)
2360 .await
2361 {
2362 if let Some(node_id) = record.node_id.as_deref() {
2363 self.reconcile_authoritative_device_node(trimmed, node_id)
2364 .await;
2365 }
2366 }
2367 }
2368 } else if matches!(admission, crate::session_token::SessionAdmission::Pending) {
2369 self.connection_manager
2373 .set_device_id(connection_id, trimmed.to_string())
2374 .await;
2375 }
2376 }
2377 self.connection_manager.peer_snapshot(connection_id).await
2378 }
2379
2380 pub async fn bind_node_device_id(&self, node_id: &str, device_id: &str) {
2388 let node_id = node_id.trim();
2389 let device_id = device_id.trim();
2390 if node_id.is_empty() || device_id.is_empty() {
2391 return;
2392 }
2393
2394 self.reconcile_authoritative_device_node(device_id, node_id)
2398 .await;
2399 let records = self.connection_manager.get_by_node_id(node_id).await;
2400 for record in records {
2401 let _ = self
2402 .bind_connection_device_id(&record.connection_id, device_id)
2403 .await;
2404 }
2405 }
2406
2407 pub async fn report_managed_connection_settled(
2408 &self,
2409 connection_id: &str,
2410 settled: bool,
2411 device_id: Option<&str>,
2412 ) -> Option<crate::connection_manager::PeerSnapshot> {
2413 if let Some(device_id) = device_id.map(str::trim).filter(|value| !value.is_empty()) {
2414 let _ = self
2415 .connection_manager
2416 .set_device_id(connection_id, device_id.to_string())
2417 .await;
2418 }
2419
2420 if settled {
2421 let _ = self
2422 .connection_manager
2423 .clear_status_reason(
2424 connection_id,
2425 Some(crate::lifecycle_reason::REASON_REPLACEMENT_IN_PROGRESS),
2426 )
2427 .await;
2428 }
2429
2430 self.connection_manager
2431 .set_health(
2432 connection_id,
2433 if settled {
2434 crate::connection_manager::ConnectionHealth::Healthy
2435 } else {
2436 crate::connection_manager::ConnectionHealth::Unknown
2437 },
2438 )
2439 .await
2440 }
2441
2442 #[cfg(target_arch = "wasm32")]
2443 pub(crate) async fn report_managed_connection_settled_for_transport(
2444 &self,
2445 connection_id: &str,
2446 settled: bool,
2447 expected_transport_stable_id: u64,
2448 expected_transport_generation: u64,
2449 expected_route_generation: u64,
2450 ) -> Option<crate::connection_manager::PeerSnapshot> {
2451 self.connection_manager
2452 .set_settled_if_current(
2453 connection_id,
2454 expected_transport_stable_id,
2455 expected_transport_generation,
2456 expected_route_generation,
2457 settled,
2458 )
2459 .await
2460 }
2461
2462 pub async fn probe_peer_health(&self, id: &str) -> bool {
2463 let snapshot = self.connection_manager.peer_snapshot(id).await;
2464 let Some(snapshot) = snapshot else {
2465 return false;
2466 };
2467
2468 let Some(node_id) = snapshot.node_id.clone() else {
2469 let _ = self
2470 .connection_manager
2471 .set_health(id, crate::connection_manager::ConnectionHealth::Stale)
2472 .await;
2473 return false;
2474 };
2475
2476 let Ok(endpoint_id) = node_id.parse::<iroh::EndpointId>() else {
2477 let _ = self
2478 .connection_manager
2479 .set_health(id, crate::connection_manager::ConnectionHealth::Stale)
2480 .await;
2481 return false;
2482 };
2483
2484 let Some(active_connection_id) = snapshot.connection_ids.first().cloned() else {
2485 return false;
2486 };
2487 let Some(expected_transport_stable_id) = snapshot.active_transport_stable_id else {
2488 return false;
2489 };
2490
2491 let node = self.iroh_node.read().await.as_ref().cloned();
2492 let probe = match node {
2493 Some(node) => {
2494 node.probe_connection(endpoint_id, std::time::Duration::from_secs(4))
2495 .await
2496 }
2497 None => None,
2498 };
2499 let recent_protocol_activity = super::auto_connect_impl::generation_activity_is_recent(
2500 self.native_transport_protocol_activity_age(
2501 &active_connection_id,
2502 expected_transport_stable_id,
2503 ),
2504 5_000,
2505 );
2506
2507 let (health, healthy) = match probe {
2508 _ if recent_protocol_activity => {
2514 (crate::connection_manager::ConnectionHealth::Healthy, true)
2515 }
2516 Some(probe)
2517 if probe.transport_stable_id == expected_transport_stable_id
2518 && (probe.responsive
2519 || super::auto_connect_impl::generation_activity_is_recent(
2520 probe.last_inbound_activity_age,
2521 5_000,
2522 )) =>
2523 {
2524 (crate::connection_manager::ConnectionHealth::Healthy, true)
2525 }
2526 Some(probe) if probe.transport_stable_id != expected_transport_stable_id => {
2527 (crate::connection_manager::ConnectionHealth::Unknown, false)
2530 }
2531 _ => (crate::connection_manager::ConnectionHealth::Stale, false),
2532 };
2533 let updated = self
2534 .connection_manager
2535 .set_health_if_current(&active_connection_id, expected_transport_stable_id, health)
2536 .await;
2537 healthy && updated.is_some()
2538 }
2539
2540 pub async fn update_presence(
2542 &self,
2543 user_id: &str,
2544 device_name: &str,
2545 ticket: &str,
2546 metadata: Option<&str>,
2547 ) -> anyhow::Result<()> {
2548 self.update_presence_with_ttl(user_id, device_name, ticket, 300_000, metadata)
2549 .await
2550 }
2551
2552 pub async fn update_presence_with_ttl(
2553 &self,
2554 user_id: &str,
2555 device_name: &str,
2556 ticket: &str,
2557 ttl_ms: u64,
2558 metadata: Option<&str>,
2559 ) -> anyhow::Result<()> {
2560 self.publish_presence_record_with_ttl(user_id, device_name, ticket, true, ttl_ms, metadata)
2561 .await
2562 }
2563
2564 pub async fn update_durable_device_record_with_ttl(
2565 &self,
2566 user_id: &str,
2567 device_name: &str,
2568 ticket: &str,
2569 ttl_ms: u64,
2570 metadata: Option<&str>,
2571 ) -> anyhow::Result<()> {
2572 self.publish_presence_record_with_ttl(user_id, device_name, ticket, false, ttl_ms, metadata)
2573 .await
2574 }
2575
2576 pub async fn update_live_presence_record(
2577 &self,
2578 user_id: &str,
2579 device_name: &str,
2580 ticket: &str,
2581 metadata: Option<&str>,
2582 ) -> anyhow::Result<()> {
2583 let effective_ticket = self.refresh_presence_ticket_for_publication(ticket).await;
2584 let (iroh_ticket, _) = crate::session_token::split_ticket(&effective_ticket);
2585 let ticket_node_id = parse_endpoint_ticket(iroh_ticket)?.id.to_string();
2586
2587 #[cfg(not(target_arch = "wasm32"))]
2588 let metadata_owned = self.metadata_with_local_device_id(metadata).await;
2589
2590 #[cfg(not(target_arch = "wasm32"))]
2591 let metadata = metadata_owned.as_deref();
2592
2593 self.signaling
2594 .update_live_presence(
2595 user_id,
2596 &ticket_node_id,
2597 &effective_ticket,
2598 device_name,
2599 metadata,
2600 )
2601 .await
2602 }
2603
2604 #[cfg(not(target_arch = "wasm32"))]
2605 async fn publish_native_user_device_presence_once(
2606 &self,
2607 reason: &str,
2608 user_id: &str,
2609 device_name: &str,
2610 ticket: &str,
2611 durable_ttl_ms: u64,
2612 metadata: Option<&str>,
2613 publish_durable: bool,
2614 publish_live: bool,
2615 ) -> (bool, bool) {
2616 if self.is_app_execution_suspended() {
2617 return (false, false);
2618 }
2619
2620 let effective_ticket = self.refresh_presence_ticket_for_publication(ticket).await;
2621 let (iroh_fingerprint, scope, token_fingerprint) =
2622 super::core_impl::summarize_compound_ticket_for_logs(effective_ticket.as_str());
2623 let durable_publish = async {
2627 if !publish_durable {
2628 return false;
2629 }
2630 eprintln!(
2631 "[openrtc][presence][device-register] reason={} liveness_source=gateway-lease scope={} token_fp={} iroh_fp={}",
2632 reason,
2633 scope.unwrap_or_else(|| "unrestricted".to_string()),
2634 token_fingerprint.unwrap_or_else(|| "none".to_string()),
2635 iroh_fingerprint
2636 );
2637 match self
2638 .update_durable_device_record_with_ttl(
2639 user_id,
2640 device_name,
2641 &effective_ticket,
2642 durable_ttl_ms,
2643 metadata,
2644 )
2645 .await
2646 {
2647 Ok(()) => {
2648 eprintln!(
2649 "[openrtc][presence][device-register][ok] reason={} liveness_source=gateway-lease",
2650 reason
2651 );
2652 true
2653 }
2654 Err(e) => {
2655 eprintln!(
2656 "[openrtc][presence][device-register][retry] reason={} error={}",
2657 reason, e
2658 );
2659 false
2660 }
2661 }
2662 };
2663 let live_publish = async {
2664 if !publish_live {
2665 return false;
2666 }
2667 match self
2668 .update_live_presence_record(user_id, device_name, &effective_ticket, metadata)
2669 .await
2670 {
2671 Ok(()) => {
2672 eprintln!("[openrtc][presence][lease][ok] reason={}", reason);
2673 true
2674 }
2675 Err(e) => {
2676 eprintln!(
2677 "[openrtc][presence][lease][retry] reason={} error={}",
2678 reason, e
2679 );
2680 false
2681 }
2682 }
2683 };
2684 let (durable_registered, live_registered) = tokio::join!(durable_publish, live_publish);
2685
2686 (durable_registered, live_registered)
2687 }
2688
2689 #[cfg(not(target_arch = "wasm32"))]
2690 async fn resolve_native_presence_ticket(
2691 &self,
2692 policy: &NativePresenceTicketPolicy,
2693 ) -> anyhow::Result<String> {
2694 match policy {
2695 NativePresenceTicketPolicy::Fixed(ticket) => {
2696 Ok(self.refresh_presence_ticket_for_publication(ticket).await)
2697 }
2698 NativePresenceTicketPolicy::ManagedUserDevice => {
2699 let ticket = self.endpoint_ticket_with_token("user-device", 0).await?;
2700 let (iroh_ticket, suffix) = crate::session_token::split_ticket(&ticket);
2701 let payload = suffix
2702 .and_then(|value| crate::session_token::decode_payload(iroh_ticket, value))
2703 .ok_or_else(|| {
2704 anyhow::anyhow!(
2705 "managed user-device presence requires a compound admission ticket"
2706 )
2707 })?;
2708 if payload.scope.as_str() != "user-device" {
2709 return Err(anyhow::anyhow!(
2710 "managed user-device presence minted unexpected scope {}",
2711 payload.scope
2712 ));
2713 }
2714 Ok(ticket)
2715 }
2716 }
2717 }
2718
2719 #[cfg(not(target_arch = "wasm32"))]
2720 async fn publish_native_presence_with_policy_once(
2721 &self,
2722 reason: &str,
2723 user_id: &str,
2724 device_name: &str,
2725 policy: &NativePresenceTicketPolicy,
2726 durable_ttl_ms: u64,
2727 metadata: Option<&str>,
2728 publish_durable: bool,
2729 publish_live: bool,
2730 ) -> (bool, bool) {
2731 let ticket = match self.resolve_native_presence_ticket(policy).await {
2732 Ok(ticket) => ticket,
2733 Err(error) => {
2734 eprintln!(
2735 "[pluto-rtc][presence][ticket-unavailable] reason={} policy={} error={}",
2736 reason,
2737 match policy {
2738 NativePresenceTicketPolicy::Fixed(_) => "fixed",
2739 NativePresenceTicketPolicy::ManagedUserDevice => "managed-user-device",
2740 },
2741 error
2742 );
2743 return (false, false);
2744 }
2745 };
2746
2747 self.publish_native_user_device_presence_once(
2748 reason,
2749 user_id,
2750 device_name,
2751 &ticket,
2752 durable_ttl_ms,
2753 metadata,
2754 publish_durable,
2755 publish_live,
2756 )
2757 .await
2758 }
2759
2760 async fn publish_presence_record_with_ttl(
2761 &self,
2762 user_id: &str,
2763 device_name: &str,
2764 ticket: &str,
2765 is_online: bool,
2766 ttl_ms: u64,
2767 metadata: Option<&str>,
2768 ) -> anyhow::Result<()> {
2769 let effective_ticket = self.refresh_presence_ticket_for_publication(ticket).await;
2770 let (iroh_ticket, _) = crate::session_token::split_ticket(&effective_ticket);
2773 let ticket_node_id = parse_endpoint_ticket(iroh_ticket)?.id.to_string();
2774
2775 #[cfg(not(target_arch = "wasm32"))]
2776 let metadata_owned = self.metadata_with_local_device_id(metadata).await;
2777
2778 #[cfg(not(target_arch = "wasm32"))]
2779 let metadata = metadata_owned.as_deref();
2780
2781 self.signaling
2782 .update_presence(
2783 user_id,
2784 &ticket_node_id,
2785 &effective_ticket,
2786 is_online,
2787 device_name,
2788 ttl_ms,
2789 metadata,
2790 )
2791 .await
2792 }
2793
2794 async fn refresh_presence_ticket_for_publication(&self, ticket: &str) -> String {
2795 let trimmed = ticket.trim();
2796 if trimmed.is_empty() {
2797 return ticket.to_string();
2798 }
2799
2800 let (iroh_ticket, suffix) = crate::session_token::split_ticket(trimmed);
2801 let Some(payload) =
2802 suffix.and_then(|value| crate::session_token::decode_payload(iroh_ticket, value))
2803 else {
2804 return trimmed.to_string();
2805 };
2806
2807 let latest_iroh_ticket = match self.endpoint_ticket().await {
2808 Ok(value) => value,
2809 Err(_) => return trimmed.to_string(),
2810 };
2811
2812 if latest_iroh_ticket == iroh_ticket {
2813 return trimmed.to_string();
2814 }
2815
2816 let rebuilt = crate::session_token::build_ticket(
2817 &latest_iroh_ticket,
2818 &payload.token,
2819 payload.scope.clone(),
2820 payload.max_connections,
2821 );
2822
2823 #[cfg(not(target_arch = "wasm32"))]
2824 {
2825 let (iroh_fingerprint, scope, token_fingerprint) =
2826 super::core_impl::summarize_compound_ticket_for_logs(rebuilt.as_str());
2827 eprintln!(
2828 "[pluto-rtc][presence][ticket-refresh] rebuilt compound ticket for publish scope={} endpoint_changed=true token_fp={} iroh_fp={}",
2829 scope.unwrap_or_else(|| payload.scope.to_string()),
2830 token_fingerprint.unwrap_or_else(|| "unknown".to_string()),
2831 iroh_fingerprint
2832 );
2833 }
2834
2835 #[cfg(target_arch = "wasm32")]
2836 web_sys::console::info_1(&wasm_bindgen::JsValue::from_str(&format!(
2837 "[pluto-rtc][presence] rebuilt compound ticket for publish scope={} endpoint_changed=true",
2838 payload.scope
2839 )));
2840
2841 rebuilt
2842 }
2843
2844 pub async fn set_offline(&self, user_id: &str) -> anyhow::Result<()> {
2845 self.stop_presence_loop();
2848 if let Some(node_id) = self.current_node_id().await {
2849 self.signaling
2853 .set_live_presence_offline(user_id, &node_id)
2854 .await?;
2855 }
2856 Ok(())
2857 }
2858
2859 pub async fn update_device(
2860 &self,
2861 user_id: &str,
2862 device_id: &str,
2863 device_name: Option<&str>,
2864 capabilities: Option<crate::signaling::DeviceCapabilities>,
2865 metadata: Option<&str>,
2866 ) -> anyhow::Result<()> {
2867 self.signaling
2868 .update_device(user_id, device_id, device_name, capabilities, metadata)
2869 .await
2870 }
2871
2872 pub async fn delete_device(&self, user_id: &str, device_id: &str) -> anyhow::Result<()> {
2873 let active_session_identity = self.active_session_identity();
2874 #[cfg(not(target_arch = "wasm32"))]
2875 let native_device_id = self
2876 .native_device_identity
2877 .read()
2878 .await
2879 .as_ref()
2880 .map(|identity| identity.device_id.clone());
2881 #[cfg(target_arch = "wasm32")]
2882 let native_device_id: Option<String> = None;
2883 let deleting_local_device = device_id_is_local(
2884 device_id,
2885 active_session_identity.as_ref(),
2886 native_device_id.as_deref(),
2887 );
2888 if deleting_local_device {
2889 self.stop_presence_loop();
2895 }
2896 self.signaling.delete_device(user_id, device_id).await
2897 }
2898
2899 pub fn active_session_identity(&self) -> Option<(String, String)> {
2902 match self.auto_connect_loop_key.lock() {
2903 Ok(guard) => guard.clone(),
2904 Err(p) => p.into_inner().clone(),
2905 }
2906 }
2907
2908 pub async fn update_signaling_excluded_peers(
2915 &self,
2916 user_id: &str,
2917 excluded_peers: &[String],
2918 ) -> anyhow::Result<()> {
2919 if let Some(node_id) = self.current_node_id().await {
2920 self.signaling
2921 .set_excluded_peers(user_id, &node_id, excluded_peers)
2922 .await?;
2923 }
2924 Ok(())
2925 }
2926
2927 pub fn current_excluded_peers_snapshot(&self) -> Vec<String> {
2933 let guard = match self.auto_connect_excluded.lock() {
2934 Ok(g) => g,
2935 Err(p) => p.into_inner(),
2936 };
2937 let mut peers: Vec<String> = guard.iter().cloned().collect();
2938 peers.sort();
2939 peers
2940 }
2941
2942 fn normalize_auto_connect_exclusion_key(value: &str) -> Option<String> {
2943 let value = value.trim().to_ascii_lowercase();
2944 if value.is_empty() {
2945 None
2946 } else {
2947 Some(value)
2948 }
2949 }
2950
2951 #[cfg(not(target_arch = "wasm32"))]
2952 pub(crate) fn should_publish_excluded_peers_to_signaling(
2953 external_coordination_active: bool,
2954 ) -> bool {
2955 !external_coordination_active
2956 }
2957
2958 pub async fn exclude_peer_and_publish(&self, remote_device_id: &str) {
2961 self.set_auto_connect_excluded_peer(remote_device_id, None, true);
2962
2963 #[cfg(target_arch = "wasm32")]
2964 return;
2965
2966 #[cfg(not(target_arch = "wasm32"))]
2967 if !Self::should_publish_excluded_peers_to_signaling(
2968 self.external_auto_connect_is_active().await,
2969 ) {
2970 return;
2971 }
2972
2973 #[cfg(not(target_arch = "wasm32"))]
2974 let Some((user_id, _)) = self.active_session_identity() else {
2975 return;
2976 };
2977 #[cfg(not(target_arch = "wasm32"))]
2978 let excluded = self.current_excluded_peers_snapshot();
2979 #[cfg(not(target_arch = "wasm32"))]
2980 if let Err(error) = self
2981 .update_signaling_excluded_peers(&user_id, &excluded)
2982 .await
2983 {
2984 eprintln!(
2985 "[PlutoRTC] exclude_peer_and_publish failed to publish excluded_peers remote_device_id={} error={}",
2986 remote_device_id,
2987 error
2988 );
2989 }
2990 }
2991
2992 pub async fn unexclude_peer_and_publish(&self, remote_device_id: &str) {
2995 self.set_auto_connect_excluded_peer(remote_device_id, None, false);
2996
2997 #[cfg(target_arch = "wasm32")]
2998 return;
2999
3000 #[cfg(not(target_arch = "wasm32"))]
3001 if !Self::should_publish_excluded_peers_to_signaling(
3002 self.external_auto_connect_is_active().await,
3003 ) {
3004 return;
3005 }
3006
3007 #[cfg(not(target_arch = "wasm32"))]
3008 let Some((user_id, _)) = self.active_session_identity() else {
3009 return;
3010 };
3011 #[cfg(not(target_arch = "wasm32"))]
3012 let excluded = self.current_excluded_peers_snapshot();
3013 #[cfg(not(target_arch = "wasm32"))]
3014 if let Err(error) = self
3015 .update_signaling_excluded_peers(&user_id, &excluded)
3016 .await
3017 {
3018 eprintln!(
3019 "[PlutoRTC] unexclude_peer_and_publish failed to publish excluded_peers remote_device_id={} error={}",
3020 remote_device_id,
3021 error
3022 );
3023 }
3024 }
3025
3026 pub async fn search_devices(
3027 &self,
3028 user_id: &str,
3029 ) -> anyhow::Result<Vec<crate::signaling::Device>> {
3030 let node_id = self.current_node_id().await;
3031 self.signaling
3032 .search_devices(user_id, node_id.as_deref())
3033 .await
3034 }
3035
3036 pub async fn devices_with_status(
3037 &self,
3038 user_id: &str,
3039 ) -> anyhow::Result<Vec<DeviceStatusSnapshot>> {
3040 let node_id = self.current_node_id().await;
3041 let devices = self
3042 .signaling
3043 .list_devices(user_id, node_id.as_deref())
3044 .await?;
3045 let peers = self.connection_manager.list_peer_snapshots().await;
3046 self.backfill_peer_device_ids_from_devices(&devices, &peers)
3047 .await;
3048 let peers = self.connection_manager.list_peer_snapshots().await;
3049 let _discovered_count = devices.len();
3050 let _peer_count = peers.len();
3051 let merged = merge_device_status_snapshots(devices, peers);
3052 let mut snapshots = Vec::with_capacity(merged.len());
3061 for snapshot in merged {
3062 let mut snapshot = self.apply_local_manual_disconnect_status(snapshot);
3063 snapshot = self.apply_device_status_admission_guard(snapshot);
3064 self.enrich_device_status_latency(&mut snapshot).await;
3065 snapshots.push(snapshot);
3066 }
3067 Ok(snapshots)
3068 }
3069
3070 pub(crate) fn apply_local_manual_disconnect_status(
3071 &self,
3072 mut snapshot: DeviceStatusSnapshot,
3073 ) -> DeviceStatusSnapshot {
3074 if !self.is_locally_auto_connect_excluded(&snapshot.device.device_id) {
3075 return snapshot;
3076 }
3077
3078 snapshot.connection_status = ConnectionStatus::Closed;
3085 snapshot.settled_ready = false;
3086 snapshot.readiness_state = ReadinessState::Closed;
3087 snapshot.readiness_reason = crate::lifecycle_reason::REASON_MANUAL_DISCONNECT.to_string();
3088 snapshot.peer_health = crate::connection_manager::ConnectionHealth::Stale;
3089 snapshot.connection_id = None;
3090 snapshot.active_transport_stable_id = None;
3091 snapshot.parallel_transport = None;
3092 snapshot.latency_ms = None;
3093 snapshot.latency_by_transport = crate::client::LatencySnapshot::default();
3094 snapshot
3095 }
3096
3097 async fn enrich_device_status_latency(&self, snapshot: &mut DeviceStatusSnapshot) {
3098 snapshot.latency_ms = None;
3099 snapshot.latency_by_transport = crate::client::LatencySnapshot::default();
3100 if !snapshot.settled_ready {
3101 return;
3102 }
3103
3104 let expected_generation = match snapshot.connection_id.as_deref() {
3105 Some(connection_id) => {
3106 self.connection_manager
3107 .get_by_connection_id(connection_id)
3108 .await
3109 }
3110 None => None,
3111 };
3112 if expected_generation.as_ref().is_some_and(|record| {
3113 record.transport_stable_id != snapshot.active_transport_stable_id
3114 || record.transport_generation != snapshot.transport_generation
3115 || record.route_generation != snapshot.route_generation
3116 }) {
3117 return;
3118 }
3119
3120 let transport_labels = [
3121 Some(snapshot.active_transport.as_str()),
3122 snapshot.parallel_transport.as_deref(),
3123 ];
3124 let lookup_id = snapshot
3125 .connection_id
3126 .as_deref()
3127 .or(snapshot.peer_id.as_deref())
3128 .or(snapshot.device.node_id.as_deref())
3129 .unwrap_or(snapshot.device.device_id.as_str());
3130 let mut latency_by_transport = crate::client::LatencySnapshot::default();
3131
3132 if transport_labels.iter().flatten().any(|transport| {
3133 crate::transport_label::normalize(transport).is_some_and(|label| {
3134 crate::transport_label::is_iroh_base(label)
3135 || crate::transport_label::is_iroh_packet_carrier(label)
3136 }) || crate::transport_label::normalize(transport) == Some(crate::transport_label::BLE)
3137 }) {
3138 if let Some(latency_ms) = self.iroh_transport_rtt_ms(lookup_id).await {
3139 let path_kind = self.iroh_path_kind(lookup_id).await;
3140 if let Some(transport) = selected_iroh_latency_label(
3141 path_kind,
3142 transport_labels.iter().flatten().copied(),
3143 ) {
3144 latency_by_transport.set(transport, latency_ms);
3145 }
3146 }
3147 }
3148
3149 if let Some(expected) = expected_generation {
3150 let current = self
3151 .connection_manager
3152 .get_by_connection_id(&expected.connection_id)
3153 .await;
3154 if current.as_ref().is_none_or(|record| {
3155 record.transport_stable_id != expected.transport_stable_id
3156 || record.transport_generation != expected.transport_generation
3157 || record.route_generation != expected.route_generation
3158 }) {
3159 return;
3160 }
3161 }
3162 snapshot.latency_by_transport = latency_by_transport;
3163 snapshot.latency_ms = snapshot
3164 .latency_by_transport
3165 .get(&snapshot.active_transport)
3166 .or_else(|| {
3167 snapshot
3168 .parallel_transport
3169 .as_deref()
3170 .and_then(|transport| snapshot.latency_by_transport.get(transport))
3171 });
3172 }
3173
3174 pub(super) async fn backfill_peer_device_ids_from_devices(
3175 &self,
3176 devices: &[crate::signaling::Device],
3177 peers: &[crate::connection_manager::PeerSnapshot],
3178 ) {
3179 let device_ids_by_node: std::collections::HashMap<String, String> = devices
3180 .iter()
3181 .filter_map(|device| {
3182 let node_id = device
3183 .node_id
3184 .as_deref()
3185 .map(str::trim)
3186 .filter(|value| !value.is_empty())?;
3187 let device_id = device.device_id.trim();
3188 if device_id.is_empty() {
3189 return None;
3190 }
3191 Some((node_id.to_string(), device_id.to_string()))
3192 })
3193 .collect();
3194
3195 for peer in peers {
3196 let has_device_id = peer
3197 .device_id
3198 .as_deref()
3199 .map(str::trim)
3200 .filter(|value| !value.is_empty())
3201 .is_some();
3202 if has_device_id {
3203 continue;
3204 }
3205
3206 let Some(node_id) = peer
3207 .node_id
3208 .as_deref()
3209 .map(str::trim)
3210 .filter(|value| !value.is_empty())
3211 else {
3212 continue;
3213 };
3214
3215 let Some(device_id) = device_ids_by_node.get(node_id).cloned() else {
3216 continue;
3217 };
3218
3219 for connection_id in &peer.connection_ids {
3220 let current_device_id = self
3221 .connection_manager
3222 .get_by_connection_id(connection_id)
3223 .await
3224 .and_then(|record| {
3225 record
3226 .device_id
3227 .as_deref()
3228 .map(str::trim)
3229 .filter(|value| !value.is_empty())
3230 .map(ToOwned::to_owned)
3231 });
3232 if current_device_id.as_deref() == Some(device_id.as_str()) {
3233 continue;
3234 }
3235
3236 self.connection_manager
3237 .set_device_id(connection_id, device_id.clone())
3238 .await;
3239
3240 #[cfg(target_arch = "wasm32")]
3241 web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
3242 "[pluto-rtc][devices-with-status] backfilled peer device_id connection_id={} node_id={} device_id={}",
3243 connection_id, node_id, device_id
3244 )));
3245 }
3246 }
3247 }
3248
3249 pub async fn runtime_status(&self) -> RuntimeStatus {
3250 let node_id = self.node_id.read().await.clone();
3251 let transport_config = self.transport_config.read().await;
3252 #[cfg(not(target_arch = "wasm32"))]
3253 let ble_available = self
3254 .native_custom_transport_kinds
3255 .read()
3256 .await
3257 .values()
3258 .any(|kind| matches!(kind, IrohPathKind::Ble));
3259 #[cfg(target_arch = "wasm32")]
3260 let ble_available = false;
3261 let transport = TransportStatus::from_config_with_ble_available(
3262 &transport_config,
3263 ble_available,
3264 current_iroh_relay_provider(),
3265 );
3266 RuntimeStatus {
3267 ready: true,
3268 node_id,
3269 transport,
3270 }
3271 }
3272
3273 pub fn product_capability_maturity(&self) -> ProductCapabilityMaturity {
3279 ProductCapabilityMaturity::for_current_target()
3280 }
3281
3282 #[cfg(not(target_arch = "wasm32"))]
3283 pub async fn notify_network_change(&self) -> anyhow::Result<usize> {
3284 let endpoint = {
3285 let endpoint_guard = self.iroh_endpoint.read().await;
3286 endpoint_guard
3287 .clone()
3288 .ok_or_else(|| anyhow::anyhow!("Iroh endpoint not initialized"))?
3289 };
3290
3291 tokio::time::timeout(
3292 std::time::Duration::from_secs(10),
3293 endpoint.network_change(),
3294 )
3295 .await
3296 .map_err(|_| anyhow::anyhow!("Iroh endpoint network-change refresh timed out after 10s"))?;
3297
3298 let records = self.connection_manager.list_all().await;
3299 let mut retired = 0usize;
3300 for record in records {
3301 let Some(endpoint_id) = record
3302 .endpoint_id
3303 .as_deref()
3304 .or(record.node_id.as_deref())
3305 .and_then(|value| value.parse::<iroh::EndpointId>().ok())
3306 else {
3307 continue;
3308 };
3309
3310 let transport_alive = tokio::time::timeout(
3311 std::time::Duration::from_secs(5),
3312 self.is_connection_transport_alive(endpoint_id),
3313 )
3314 .await
3315 .map_err(|_| {
3316 anyhow::anyhow!(
3317 "Iroh transport health check timed out after 5s endpoint_id={endpoint_id}"
3318 )
3319 })?;
3320
3321 if transport_alive {
3322 continue;
3323 }
3324
3325 self.connection_manager
3326 .set_closing(
3327 &record.connection_id,
3328 Some("network-change-retired-stale-record".to_string()),
3329 )
3330 .await;
3331 self.connection_manager
3332 .set_closed(
3333 &record.connection_id,
3334 Some("network-change-retired-stale-record".to_string()),
3335 )
3336 .await;
3337 let _ = self.connection_manager.remove(&record.connection_id).await;
3338 retired = retired.saturating_add(1);
3339 }
3340
3341 self.wake_native_ble_recovery("network-change").await;
3342
3343 Ok(retired)
3344 }
3345
3346 pub async fn search_devices_raw(
3348 &self,
3349 user_id: &str,
3350 ) -> anyhow::Result<Vec<crate::signaling::Device>> {
3351 self.signaling.search_devices(user_id, None).await
3352 }
3353
3354 pub async fn connect_device(
3355 &self,
3356 device_id: Option<&str>,
3357 endpoint_ticket: &str,
3358 ) -> anyhow::Result<ManagedConnectResult> {
3359 self.connect_device_with_intent(device_id, endpoint_ticket, true, None)
3360 .await
3361 }
3362
3363 pub async fn connect_known_device_with_token(
3367 &self,
3368 device_id: &str,
3369 token: &str,
3370 scope: &str,
3371 max_connections: u32,
3372 expires_at_ms: Option<u64>,
3373 lookup_timeout_ms: Option<u64>,
3374 ) -> anyhow::Result<ManagedConnectResult> {
3375 let device_id = device_id.trim();
3376 let token = token.trim();
3377 let scope = scope.trim();
3378 if device_id.is_empty() {
3379 return Err(anyhow::anyhow!(
3380 "known-device admission requires a device id"
3381 ));
3382 }
3383 if token.is_empty() {
3384 return Err(anyhow::anyhow!("known-device admission requires a token"));
3385 }
3386 if scope.is_empty() {
3387 return Err(anyhow::anyhow!("known-device admission requires a scope"));
3388 }
3389 if expires_at_ms.is_some_and(|expiry| expiry <= crate::session_token::now_unix_ms()) {
3390 return Err(anyhow::anyhow!(
3391 "known-device admission expiry must be in the future"
3392 ));
3393 }
3394
3395 let mut endpoint_revisions = self.known_device_endpoint_revision.subscribe();
3396 let lookup = async {
3397 loop {
3398 if let Some(remote_node_id) = self.authoritative_node_for_device(device_id) {
3399 let endpoint_id =
3400 remote_node_id
3401 .parse::<iroh::EndpointId>()
3402 .map_err(|error| {
3403 anyhow::anyhow!(
3404 "known device {device_id} has an invalid authoritative endpoint: {error}"
3405 )
3406 })?;
3407 if let Some(endpoint_addr) = self.cached_endpoint_addr(endpoint_id).await {
3408 return Ok::<_, anyhow::Error>(endpoint_addr);
3409 }
3410 }
3411 endpoint_revisions
3412 .changed()
3413 .await
3414 .map_err(|_| anyhow::anyhow!("known-device endpoint directory closed"))?;
3415 }
3416 };
3417 let lookup_timeout_ms = lookup_timeout_ms.unwrap_or(10_000).max(1);
3418 #[cfg(not(target_arch = "wasm32"))]
3419 let endpoint_addr = tokio::time::timeout(
3420 std::time::Duration::from_millis(lookup_timeout_ms),
3421 lookup,
3422 )
3423 .await
3424 .map_err(|_| {
3425 anyhow::anyhow!(
3426 "timed out after {lookup_timeout_ms}ms waiting for Rust-owned endpoint state for known device {device_id}"
3427 )
3428 })??;
3429 #[cfg(target_arch = "wasm32")]
3430 let endpoint_addr = {
3431 use futures::FutureExt;
3432
3433 let lookup = lookup.fuse();
3434 let timeout =
3435 gloo_timers::future::sleep(std::time::Duration::from_millis(lookup_timeout_ms))
3436 .fuse();
3437 futures::pin_mut!(lookup, timeout);
3438 futures::select! {
3439 endpoint_addr = lookup => endpoint_addr?,
3440 _ = timeout => {
3441 return Err(anyhow::anyhow!(
3442 "timed out after {lookup_timeout_ms}ms waiting for Rust-owned endpoint state for known device {device_id}"
3443 ));
3444 }
3445 }
3446 };
3447 let remote_node_id = endpoint_addr.id.to_string();
3448 let endpoint_ticket =
3449 iroh_tickets::endpoint::EndpointTicket::new(endpoint_addr.clone()).to_string();
3450 let compound_ticket = crate::session_token::expiring_ticket(
3451 endpoint_ticket.as_str(),
3452 token,
3453 scope,
3454 max_connections,
3455 expires_at_ms,
3456 );
3457 let previous_repair_credential = self.cache_scoped_route_repair_credential(
3458 &remote_node_id,
3459 Some(crate::client::ScopedRouteRepairCredential {
3460 token: token.to_string(),
3461 scope: scope.to_string(),
3462 max_connections,
3463 expires_at_ms,
3464 authoritative_device_id: device_id.to_string(),
3465 }),
3466 );
3467 let first_connect = self.connect_device(None, compound_ticket.as_str()).await;
3472 let mut first_error = None;
3473 let mut result = match first_connect {
3474 Ok(result) => Some(result),
3475 Err(error) => {
3476 first_error = Some(error);
3477 None
3478 }
3479 };
3480 if result
3481 .as_ref()
3482 .is_none_or(|result| result.approved_scope.as_deref() != Some(scope))
3483 {
3484 let stale_transport_stable_id = self
3492 .get_connection(endpoint_addr.id)
3493 .await
3494 .map(|connection| crate::transport_generation::for_connection(&connection));
3495 let retired_stale_transport = match stale_transport_stable_id {
3496 Some(stable_id) => self
3497 .disconnect_transport_generation_with_reason(
3498 endpoint_addr.id,
3499 stable_id,
3500 crate::lifecycle_reason::REASON_SCOPED_ADMISSION_FRESH_REDIAL,
3501 )
3502 .await
3503 .unwrap_or(false),
3504 None => false,
3505 };
3506 eprintln!(
3507 "[pluto-rtc][known-device-admission] retrying replacement scope on fresh transport device_id={} remote_node_id={} scope={} stale_transport_stable_id={:?} retired_stale_transport={} first_error={}",
3508 device_id,
3509 remote_node_id,
3510 scope,
3511 stale_transport_stable_id,
3512 retired_stale_transport,
3513 first_error
3514 .as_ref()
3515 .map(ToString::to_string)
3516 .unwrap_or_else(|| "scope-not-approved".to_string()),
3517 );
3518 result = match self.connect_device(None, compound_ticket.as_str()).await {
3519 Ok(result) => Some(result),
3520 Err(error) => {
3521 self.cache_scoped_route_repair_credential(
3522 &remote_node_id,
3523 previous_repair_credential,
3524 );
3525 return Err(match first_error {
3526 Some(first_error) => anyhow::anyhow!(
3527 "known-device admission for {device_id} failed before and after one fresh transport retry: first={first_error}; retry={error}"
3528 ),
3529 None => error,
3530 });
3531 }
3532 };
3533 }
3534 let mut result = result.expect("known-device admission retry produced a result");
3535 if result.approved_scope.as_deref() != Some(scope) {
3536 self.cache_scoped_route_repair_credential(&remote_node_id, previous_repair_credential);
3537 return Err(anyhow::anyhow!(
3538 "known-device admission for {device_id} did not receive approval for scope {scope}"
3539 ));
3540 }
3541 self.connection_manager
3542 .set_device_id(&result.connection_id, device_id.to_string())
3543 .await;
3544 result.device_id = Some(device_id.to_string());
3545 result.device_id_hint = Some(device_id.to_string());
3546 Ok(result)
3547 }
3548
3549 pub async fn observe_known_device_endpoint(
3553 &self,
3554 device_id: &str,
3555 endpoint_ticket: &str,
3556 ) -> anyhow::Result<()> {
3557 let device_id = device_id.trim();
3558 if device_id.is_empty() {
3559 return Err(anyhow::anyhow!(
3560 "known-device endpoint observation requires a device id"
3561 ));
3562 }
3563 let endpoint_ticket = endpoint_ticket.trim();
3564 let (iroh_ticket, _) = crate::session_token::split_ticket(endpoint_ticket);
3565 let endpoint_addr = parse_endpoint_ticket(iroh_ticket)?;
3566 let endpoint_id = endpoint_addr.id;
3567 self.remember_endpoint_addr(&endpoint_addr).await;
3568 self.reconcile_authoritative_device_node(device_id, &endpoint_id.to_string())
3569 .await;
3570 Ok(())
3571 }
3572
3573 pub(crate) async fn connect_desired_device(
3574 &self,
3575 device_id: &str,
3576 endpoint_ticket: &str,
3577 desired_peer_evidence: &str,
3578 ) -> anyhow::Result<ManagedConnectResult> {
3579 let (iroh_ticket, _) = crate::session_token::split_ticket(endpoint_ticket.trim());
3580 let endpoint_addr = parse_endpoint_ticket(iroh_ticket)?;
3581 let preferred_ticket =
3582 self.preferred_scoped_route_ticket(endpoint_addr.id.to_string().as_str(), iroh_ticket);
3583 self.connect_device_with_intent(
3584 Some(device_id),
3585 preferred_ticket.as_deref().unwrap_or(endpoint_ticket),
3586 false,
3587 Some(desired_peer_evidence),
3588 )
3589 .await
3590 }
3591
3592 async fn connect_device_with_intent(
3593 &self,
3594 device_id: Option<&str>,
3595 endpoint_ticket: &str,
3596 clear_auto_connect_exclusion: bool,
3597 desired_peer_evidence: Option<&str>,
3598 ) -> anyhow::Result<ManagedConnectResult> {
3599 let (iroh_ticket, token_suffix) =
3602 crate::session_token::split_ticket(endpoint_ticket.trim());
3603 let extracted_token_payload = token_suffix
3604 .and_then(|suffix| crate::session_token::decode_payload(iroh_ticket, suffix));
3605 let extracted_token = extracted_token_payload
3606 .as_ref()
3607 .map(|payload| payload.token.clone());
3608 let extracted_token_suffix = token_suffix
3609 .filter(|_| extracted_token_payload.is_some())
3610 .map(ToOwned::to_owned);
3611 if token_suffix.is_some() && extracted_token_payload.is_none() {
3612 return Err(anyhow::anyhow!(
3613 "invalid compound ticket payload for endpoint ticket"
3614 ));
3615 }
3616 let endpoint_addr = parse_endpoint_ticket(iroh_ticket)?;
3617 self.remember_endpoint_addr(&endpoint_addr).await;
3618 let remote_node_id = endpoint_addr.id.to_string();
3619 let local_node_id = self
3620 .current_node_id()
3621 .await
3622 .ok_or_else(|| anyhow::anyhow!("Iroh node not initialized"))?;
3623 let connection_id = Self::deterministic_connection_id(&local_node_id, &remote_node_id);
3624 #[cfg(not(target_arch = "wasm32"))]
3625 let bilateral_user_device = extracted_token_payload
3626 .as_ref()
3627 .is_some_and(|payload| payload.scope.as_str() == "user-device");
3628 #[cfg(not(target_arch = "wasm32"))]
3629 let claimed_local_device_id = self
3630 .auto_connect_loop_key
3631 .lock()
3632 .unwrap_or_else(|poisoned| poisoned.into_inner())
3633 .as_ref()
3634 .map(|(_, device_id)| device_id.clone());
3635 #[cfg(target_arch = "wasm32")]
3636 self.set_connection_application_crypto_required(&connection_id);
3637 let trimmed_device_id = device_id
3638 .map(str::trim)
3639 .filter(|value| !value.is_empty())
3640 .map(ToOwned::to_owned);
3641 #[cfg(not(target_arch = "wasm32"))]
3642 if extracted_token_payload
3643 .as_ref()
3644 .is_some_and(|payload| payload.scope.as_str() == "user-device")
3645 {
3646 self.update_native_route_repair_credential(
3647 &remote_node_id,
3648 trimmed_device_id.as_deref(),
3649 extracted_token.as_deref(),
3650 extracted_token_suffix.as_deref(),
3651 );
3652 }
3653 let ensure_connect_intent_is_current = || -> anyhow::Result<()> {
3654 if clear_auto_connect_exclusion {
3655 if trimmed_device_id
3656 .as_deref()
3657 .is_some_and(|device_id| self.is_auto_connect_excluded(device_id))
3658 {
3659 return Err(anyhow::anyhow!(
3660 "explicit device connect was cancelled by manual disconnect"
3661 ));
3662 }
3663 return Ok(());
3664 }
3665 let Some(device_id) = trimmed_device_id.as_deref() else {
3666 return Ok(());
3667 };
3668 let Some(authoritative_node_id) = self.authoritative_node_for_device(device_id) else {
3669 return Ok(());
3670 };
3671 if authoritative_node_id == remote_node_id {
3672 return Ok(());
3673 }
3674 Err(anyhow::anyhow!(
3675 "browser desired-peer generation is stale: device {device_id} now belongs to node {authoritative_node_id}, not {remote_node_id}"
3676 ))
3677 };
3678 if clear_auto_connect_exclusion {
3679 if let Some(device_id) = trimmed_device_id.as_deref() {
3680 self.unexclude_peer_and_publish(device_id).await;
3684 }
3685 }
3686 let connect_gate = self.managed_connect_gate(&connection_id).await;
3687 let _connect_guard = connect_gate.lock().await;
3688 ensure_connect_intent_is_current()?;
3689 let mut approved_scope: Option<String> = None;
3690 let log_phase = |phase: &str| {
3691 let message = format!(
3692 "[pluto-rtc][connect-device][phase] phase={} connection_id={} remote_node_id={} device_id={}",
3693 phase,
3694 connection_id,
3695 remote_node_id,
3696 trimmed_device_id.as_deref().unwrap_or("")
3697 );
3698 #[cfg(not(target_arch = "wasm32"))]
3699 eprintln!("{}", message);
3700 #[cfg(target_arch = "wasm32")]
3701 web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&message));
3702 };
3703 log_phase("gate-acquired");
3704
3705 if let Some(existing) = self
3708 .connection_manager
3709 .get_by_connection_id(&connection_id)
3710 .await
3711 {
3712 if matches!(
3713 existing.state,
3714 crate::connection_manager::ConnectionState::Connected
3715 ) && self.is_connection_transport_alive(endpoint_addr.id).await
3716 {
3717 let live_transport_stable_id = self
3724 .get_connection(endpoint_addr.id)
3725 .await
3726 .map(|connection| crate::transport_generation::for_connection(&connection));
3727 if let Some(live_transport_stable_id) = live_transport_stable_id {
3728 self.commit_current_native_transport_record(
3737 &connection_id,
3738 &remote_node_id,
3739 trimmed_device_id.clone(),
3740 Some(live_transport_stable_id),
3741 Some("connect-device-existing-live-transport".to_string()),
3742 )
3743 .await;
3744 }
3745 if let Some(device_id) = trimmed_device_id.clone() {
3746 self.mark_trusted_user_device_connection_admitted(&connection_id, &device_id)
3747 .await;
3748 self.reconcile_authoritative_device_node(&device_id, &remote_node_id)
3749 .await;
3750 }
3751
3752 let current_remote_admission_scope = if let Some(token) = extracted_token.as_deref()
3753 {
3754 self.current_remote_session_admission_scope(
3755 &connection_id,
3756 endpoint_addr.id,
3757 token,
3758 )
3759 .await
3760 } else {
3761 None
3762 };
3763 let has_remote_admission_proof = current_remote_admission_scope.is_some();
3764 if let Some(scope) = current_remote_admission_scope {
3765 approved_scope = Some(scope);
3771 };
3772 let needs_token_representation =
3776 should_present_session_token_on_connected_transport(
3777 extracted_token.is_some(),
3778 has_remote_admission_proof,
3779 );
3780 #[cfg(not(target_arch = "wasm32"))]
3781 let desired_presentation_decision = bilateral_user_device.then(|| {
3782 let has_current_inbound_admission_proof =
3783 live_transport_stable_id.is_some_and(|transport_stable_id| {
3784 self.inbound_session_token_admitted_for_transport(
3785 &connection_id,
3786 transport_stable_id,
3787 )
3788 });
3789 let peer_requested_reciprocal =
3790 self.has_pending_reciprocal_session_admission_request(&connection_id);
3791 super::auto_connect_impl::session_admission_presentation_decision(
3792 extracted_token.is_some(),
3793 has_remote_admission_proof,
3794 has_current_inbound_admission_proof,
3795 peer_requested_reciprocal,
3796 clear_auto_connect_exclusion || local_node_id > remote_node_id,
3797 )
3798 });
3799 #[cfg(not(target_arch = "wasm32"))]
3800 let needs_token_representation = desired_presentation_decision
3801 .map(|decision| decision.should_present)
3802 .unwrap_or(needs_token_representation);
3803
3804 if needs_token_representation {
3805 if let Some(token) = extracted_token.as_ref() {
3806 eprintln!(
3807 "[PlutoRTC] Existing managed transport still awaiting session admission; re-presenting token connection_id={} remote_node_id={} token_fp={}",
3808 connection_id,
3809 remote_node_id,
3810 super::core_impl::log_fingerprint(token.as_str())
3811 );
3812 #[cfg(not(target_arch = "wasm32"))]
3813 let presentation_result =
3814 if let Some(decision) = desired_presentation_decision {
3815 self.present_and_accept_session_token_for_route_repair(
3816 endpoint_addr.id,
3817 &connection_id,
3818 token,
3819 extracted_token_suffix.as_deref(),
3820 trimmed_device_id.clone(),
3821 claimed_local_device_id.clone(),
3822 has_remote_admission_proof,
3823 decision.request_reciprocal,
3824 )
3825 .await
3826 } else {
3827 self.present_and_accept_session_token(
3828 endpoint_addr.id,
3829 &connection_id,
3830 token,
3831 extracted_token_suffix.as_deref(),
3832 trimmed_device_id.clone(),
3833 )
3834 .await
3835 };
3836 #[cfg(target_arch = "wasm32")]
3837 let presentation_result = self
3838 .present_and_accept_session_token(
3839 endpoint_addr.id,
3840 &connection_id,
3841 token,
3842 extracted_token_suffix.as_deref(),
3843 trimmed_device_id.clone(),
3844 )
3845 .await;
3846 match presentation_result {
3847 Ok(approval_scope) => {
3848 approved_scope = Some(approval_scope.clone());
3849 eprintln!(
3850 "[PlutoRTC] Re-presented session-token on existing managed transport connection_id={} scope={} token_fp={} local_admission=accepted",
3851 connection_id,
3852 approval_scope,
3853 super::core_impl::log_fingerprint(token.as_str())
3854 );
3855 }
3856 Err(error) => {
3857 eprintln!(
3858 "[PlutoRTC] Failed to re-present session-token on existing managed transport connection_id={} error={}",
3859 connection_id,
3860 error
3861 );
3862 }
3863 }
3864 ensure_connect_intent_is_current()?;
3865 }
3866 }
3867
3868 ensure_connect_intent_is_current()?;
3869 let _ = self
3870 .confirm_managed_connection_readiness(&connection_id)
3871 .await;
3872 ensure_connect_intent_is_current()?;
3873
3874 let current = self
3875 .connection_manager
3876 .get_by_connection_id(&connection_id)
3877 .await
3878 .ok_or_else(|| {
3879 anyhow::anyhow!(
3880 "managed connection was retired while existing transport settled"
3881 )
3882 })?;
3883 if !matches!(
3884 current.state,
3885 crate::connection_manager::ConnectionState::Connected
3886 ) || current.transport_stable_id != live_transport_stable_id
3887 || !self.is_connection_transport_alive(endpoint_addr.id).await
3888 {
3889 return Err(anyhow::anyhow!(
3890 "managed connection was superseded while existing transport settled"
3891 ));
3892 }
3893
3894 let snapshot = self.connection_manager.peer_snapshot(&connection_id).await;
3895 return Ok(ManagedConnectResult {
3896 connection_id,
3897 device_id: snapshot
3898 .as_ref()
3899 .and_then(|value| value.device_id.clone())
3900 .or(trimmed_device_id.clone()),
3901 device_id_hint: snapshot
3902 .as_ref()
3903 .and_then(|value| value.device_id_hint.clone())
3904 .or(trimmed_device_id),
3905 remote_node_id,
3906 state: "connected".to_string(),
3907 approved_scope,
3908 });
3909 }
3910 }
3911
3912 if clear_auto_connect_exclusion {
3913 self.connection_manager
3914 .upsert_pending(
3915 connection_id.clone(),
3916 Some(remote_node_id.clone()),
3917 trimmed_device_id.clone(),
3918 Some(remote_node_id.clone()),
3919 )
3920 .await;
3921 self.connection_manager.set_connecting(&connection_id).await;
3922 } else {
3923 let automatic_device_id = trimmed_device_id.clone();
3924 let current_user_device_assignment = if extracted_token_payload
3925 .as_ref()
3926 .is_some_and(|payload| payload.scope.as_str() == "user-device")
3927 {
3928 match (automatic_device_id.as_deref(), desired_peer_evidence) {
3929 (Some(device_id), Some(evidence)) => {
3930 self.current_desired_user_device_assignment_matches(
3931 device_id,
3932 &remote_node_id,
3933 endpoint_ticket,
3934 evidence,
3935 )
3936 .await
3937 }
3938 _ => false,
3939 }
3940 } else {
3941 false
3942 };
3943 let began = self
3944 .connection_manager
3945 .begin_automatic_connect(
3946 connection_id.clone(),
3947 Some(remote_node_id.clone()),
3948 trimmed_device_id.clone(),
3949 Some(remote_node_id.clone()),
3950 |requires_fresh_assignment| {
3951 automatic_device_id
3952 .as_deref()
3953 .is_none_or(|device_id| !self.is_auto_connect_excluded(device_id))
3954 && (!requires_fresh_assignment
3955 || extracted_token_payload.as_ref().is_some_and(|payload| {
3956 crate::session_token::matches_ticket(iroh_ticket, payload)
3957 && if payload.scope.as_str() == "user-device" {
3958 current_user_device_assignment
3959 } else {
3960 self.session_token_registry.is_peer_assigned_to_scope(
3961 payload.scope.as_str(),
3962 &remote_node_id,
3963 )
3964 }
3965 }))
3966 },
3967 )
3968 .await;
3969 if began.is_none() {
3970 return Err(anyhow::anyhow!(
3971 "automatic managed connect was cancelled by terminal peer intent"
3972 ));
3973 }
3974 if current_user_device_assignment {
3975 let Some(device_id) = automatic_device_id.as_deref() else {
3976 unreachable!("current user-device assignment requires a device id")
3977 };
3978 let Some(evidence) = desired_peer_evidence else {
3979 unreachable!("current user-device assignment requires peer evidence")
3980 };
3981 if !self
3982 .current_desired_user_device_assignment_matches(
3983 device_id,
3984 &remote_node_id,
3985 endpoint_ticket,
3986 evidence,
3987 )
3988 .await
3989 {
3990 return Err(anyhow::anyhow!(
3991 "automatic managed connect was superseded by newer desired-peer evidence"
3992 ));
3993 }
3994 }
3995 }
3996 if let Some(device_id) = trimmed_device_id.as_deref() {
3997 self.reconcile_authoritative_device_node(device_id, &remote_node_id)
3998 .await;
3999 }
4000 log_phase("transport-dial-start");
4001
4002 self.ensure_connected_addr(endpoint_addr.id, endpoint_addr.clone())
4003 .await?;
4004 let transport_stable_id = self
4005 .get_connection(endpoint_addr.id)
4006 .await
4007 .map(|connection| crate::transport_generation::for_connection(&connection));
4008 if let Err(error) = ensure_connect_intent_is_current() {
4009 self.close_cancelled_connect(endpoint_addr.id, transport_stable_id)
4014 .await;
4015 return Err(error);
4016 }
4017 log_phase("transport-dial-complete");
4018
4019 log_phase("transport-record-start");
4020 let transport_committed = self
4021 .commit_current_native_transport_record(
4022 &connection_id,
4023 &remote_node_id,
4024 trimmed_device_id.clone(),
4025 transport_stable_id,
4026 None,
4027 )
4028 .await;
4029 if !transport_committed {
4030 if let Err(error) = ensure_connect_intent_is_current() {
4031 self.close_cancelled_connect(endpoint_addr.id, transport_stable_id)
4032 .await;
4033 return Err(error);
4034 }
4035 }
4036 log_phase("transport-record-complete");
4037 log_phase("transport-health-start");
4038 let _ = self
4039 .confirm_managed_connection_readiness(&connection_id)
4040 .await;
4041 ensure_connect_intent_is_current()?;
4042 log_phase("transport-health-complete");
4043 if let Some(device_id) = trimmed_device_id.clone() {
4044 log_phase("trusted-admission-start");
4045 self.mark_trusted_user_device_connection_admitted(&connection_id, &device_id)
4046 .await;
4047 ensure_connect_intent_is_current()?;
4048 log_phase("trusted-admission-complete");
4049 log_phase("stale-record-prune-start");
4050 self.reconcile_authoritative_device_node(&device_id, &remote_node_id)
4051 .await;
4052 log_phase("stale-record-prune-complete");
4053 }
4054
4055 if let Some(ref token) = extracted_token {
4056 #[cfg(not(target_arch = "wasm32"))]
4057 let desired_presentation_decision = bilateral_user_device.then(|| {
4058 let has_remote_admission_proof =
4059 transport_stable_id.is_some_and(|transport_stable_id| {
4060 self.remote_session_token_admitted_for_transport(
4061 &connection_id,
4062 transport_stable_id,
4063 )
4064 });
4065 let has_current_inbound_admission_proof =
4066 transport_stable_id.is_some_and(|transport_stable_id| {
4067 self.inbound_session_token_admitted_for_transport(
4068 &connection_id,
4069 transport_stable_id,
4070 )
4071 });
4072 let peer_requested_reciprocal =
4073 self.has_pending_reciprocal_session_admission_request(&connection_id);
4074 super::auto_connect_impl::session_admission_presentation_decision(
4075 true,
4076 has_remote_admission_proof,
4077 has_current_inbound_admission_proof,
4078 peer_requested_reciprocal,
4079 clear_auto_connect_exclusion || local_node_id > remote_node_id,
4080 )
4081 });
4082 #[cfg(not(target_arch = "wasm32"))]
4083 let should_present = desired_presentation_decision
4084 .map(|decision| decision.should_present)
4085 .unwrap_or(true);
4086 #[cfg(target_arch = "wasm32")]
4087 let should_present = true;
4088
4089 if should_present {
4090 #[cfg(not(target_arch = "wasm32"))]
4091 let presentation_result = if let Some(decision) = desired_presentation_decision {
4092 self.present_and_accept_session_token_for_route_repair(
4093 endpoint_addr.id,
4094 &connection_id,
4095 token,
4096 extracted_token_suffix.as_deref(),
4097 trimmed_device_id.clone(),
4098 claimed_local_device_id.clone(),
4099 false,
4100 decision.request_reciprocal,
4101 )
4102 .await
4103 } else {
4104 self.present_and_accept_session_token(
4105 endpoint_addr.id,
4106 &connection_id,
4107 token,
4108 extracted_token_suffix.as_deref(),
4109 trimmed_device_id.clone(),
4110 )
4111 .await
4112 };
4113 #[cfg(target_arch = "wasm32")]
4114 let presentation_result = self
4115 .present_and_accept_session_token(
4116 endpoint_addr.id,
4117 &connection_id,
4118 token,
4119 extracted_token_suffix.as_deref(),
4120 trimmed_device_id.clone(),
4121 )
4122 .await;
4123 match presentation_result {
4124 Ok(approval_scope) => {
4125 approved_scope = Some(approval_scope.clone());
4126 eprintln!(
4127 "[PlutoRTC] Sent session-token presentation to host and received approval connection_id={} scope={} token_fp={} local_admission=accepted",
4128 connection_id,
4129 approval_scope,
4130 super::core_impl::log_fingerprint(token.as_str())
4131 );
4132 }
4133 Err(error) => {
4134 eprintln!(
4135 "[PlutoRTC] Failed to send session-token presentation connection_id={} token_fp={} error={}",
4136 connection_id,
4137 super::core_impl::log_fingerprint(token.as_str()),
4138 error
4139 );
4140 }
4141 }
4142 } else {
4143 eprintln!(
4144 "[pluto-rtc][connect-device][session-admission] awaiting elected presenter on desired transport connection_id={} remote_node_id={} local_node_id={}",
4145 connection_id,
4146 remote_node_id,
4147 local_node_id,
4148 );
4149 }
4150 let _ = self
4151 .confirm_managed_connection_readiness(&connection_id)
4152 .await;
4153 ensure_connect_intent_is_current()?;
4154 }
4155
4156 ensure_connect_intent_is_current()?;
4157 let snapshot = self.connection_manager.peer_snapshot(&connection_id).await;
4158 let state = match snapshot.as_ref().map(|value| &value.status) {
4159 Some(crate::connection_manager::ConnectionState::Pending)
4160 | Some(crate::connection_manager::ConnectionState::Connecting) => "connecting",
4161 Some(crate::connection_manager::ConnectionState::Connected) => "connected",
4162 Some(crate::connection_manager::ConnectionState::Failed) => "failed",
4163 Some(crate::connection_manager::ConnectionState::Closed)
4164 | Some(crate::connection_manager::ConnectionState::Closing) => "closed",
4165 None => "connected",
4166 }
4167 .to_string();
4168 log_phase("return");
4169
4170 Ok(ManagedConnectResult {
4171 connection_id,
4172 device_id: snapshot
4173 .as_ref()
4174 .and_then(|value| value.device_id.clone())
4175 .or(trimmed_device_id.clone()),
4176 device_id_hint: snapshot
4177 .as_ref()
4178 .and_then(|value| value.device_id_hint.clone())
4179 .or(trimmed_device_id),
4180 remote_node_id,
4181 state,
4182 approved_scope,
4183 })
4184 }
4185
4186 pub async fn managed_connection_health(
4187 &self,
4188 connection_id: &str,
4189 ) -> Option<ConnectionHealthView> {
4190 let record = self
4191 .connection_manager
4192 .get_by_connection_id(connection_id)
4193 .await?;
4194 let peer_snapshot = self.connection_manager.peer_snapshot(connection_id).await;
4195
4196 let settled_ready = peer_snapshot
4197 .as_ref()
4198 .map(peer_snapshot_settled_ready)
4199 .unwrap_or(false);
4200 let admission_block = self.session_admission_block_reason_for_transport(
4201 connection_id,
4202 record.transport_stable_id,
4203 );
4204 let admitted_settled_ready = settled_ready && admission_block.is_none();
4205 let settle_deadline_elapsed = matches!(
4206 record.state,
4207 crate::connection_manager::ConnectionState::Connected
4208 ) && !admitted_settled_ready
4209 && now_millis_i64().saturating_sub(record.last_transport_change_at_ms)
4210 >= MANAGED_SETTLE_DEADLINE_MS;
4211
4212 let status = match record.state {
4213 crate::connection_manager::ConnectionState::Pending
4214 | crate::connection_manager::ConnectionState::Connecting => {
4215 ManagedHealth::AwaitingReplacement
4216 }
4217 crate::connection_manager::ConnectionState::Connected => {
4218 #[cfg(not(target_arch = "wasm32"))]
4219 {
4220 if admitted_settled_ready {
4221 ManagedHealth::Healthy
4222 } else if settle_deadline_elapsed {
4223 ManagedHealth::Dead
4224 } else {
4225 ManagedHealth::AwaitingReplacement
4226 }
4227 }
4228 #[cfg(target_arch = "wasm32")]
4229 {
4230 let peer_health = peer_snapshot
4231 .as_ref()
4232 .map(|snapshot| snapshot.health.clone());
4233 match peer_health
4234 .unwrap_or(crate::connection_manager::ConnectionHealth::Unknown)
4235 {
4236 crate::connection_manager::ConnectionHealth::Healthy
4237 if admitted_settled_ready =>
4238 {
4239 ManagedHealth::Healthy
4240 }
4241 _ if settle_deadline_elapsed => ManagedHealth::Dead,
4242 crate::connection_manager::ConnectionHealth::Unknown
4243 | crate::connection_manager::ConnectionHealth::Suspect
4244 | crate::connection_manager::ConnectionHealth::Stale
4245 | crate::connection_manager::ConnectionHealth::Healthy => {
4246 ManagedHealth::AwaitingReplacement
4247 }
4248 }
4249 }
4250 }
4251 crate::connection_manager::ConnectionState::Closing
4252 | crate::connection_manager::ConnectionState::Closed
4253 | crate::connection_manager::ConnectionState::Failed => ManagedHealth::Dead,
4254 };
4255
4256 let readiness_state = connection_readiness_state(&record, peer_snapshot.as_ref());
4257 let readiness_reason = connection_readiness_reason(&record, peer_snapshot.as_ref());
4258 let replacement_pending = matches!(readiness_state, ReadinessState::AwaitingReplacement);
4259 let last_lifecycle_transition_at_ms = record
4260 .last_state_change_at_ms
4261 .max(record.last_transport_change_at_ms)
4262 .max(record.last_route_change_at_ms);
4263
4264 let mut snapshot = ConnectionHealthView {
4265 connection_id: record.connection_id,
4266 device_id: record.device_id,
4267 device_id_hint: record.device_id_hint,
4268 node_id: record.node_id,
4269 active_transport_stable_id: record.transport_stable_id,
4270 transport_generation: record.transport_generation,
4271 route_generation: record.route_generation,
4272 status,
4273 settled_ready: admitted_settled_ready,
4274 readiness_state,
4275 replacement_pending,
4276 last_lifecycle_transition_at_ms,
4277 readiness_reason,
4278 transition_count: record.transition_count,
4279 connecting_transition_count: record.connecting_transition_count,
4280 replacement_count: record.replacement_count,
4281 retire_count: record.retire_count,
4282 last_disconnect_reason: record.last_disconnect_reason,
4283 last_reconnect_reason: record.last_reconnect_reason,
4284 };
4285
4286 if let Some((rejected, reason)) = admission_block {
4287 snapshot.settled_ready = false;
4288 snapshot.replacement_pending = false;
4289 snapshot.readiness_reason = reason.clone();
4290 if rejected {
4291 snapshot.status = ManagedHealth::Dead;
4292 snapshot.readiness_state = ReadinessState::Failed;
4293 } else {
4294 snapshot.status = ManagedHealth::AwaitingReplacement;
4295 snapshot.readiness_state = ReadinessState::Settling;
4296 }
4297 }
4298
4299 Some(snapshot)
4300 }
4301
4302 #[cfg(not(target_arch = "wasm32"))]
4303 pub async fn managed_connection_bridge_action(
4304 &self,
4305 connection_id: &str,
4306 ) -> Option<BridgeAction> {
4307 let snapshot = self.managed_connection_health(connection_id).await?;
4308 let record = self
4309 .connection_manager
4310 .get_by_connection_id(connection_id)
4311 .await?;
4312 let settle_deadline_elapsed = matches!(
4313 record.state,
4314 crate::connection_manager::ConnectionState::Connected
4315 ) && !snapshot.settled_ready
4316 && now_millis_i64().saturating_sub(record.last_transport_change_at_ms)
4317 >= MANAGED_SETTLE_DEADLINE_MS;
4318 let action = match snapshot.status {
4319 ManagedHealth::Healthy => BridgeAction::Healthy,
4320 ManagedHealth::AwaitingReplacement => {
4321 if settle_deadline_elapsed {
4322 let timeout_reason = if matches!(
4323 self.session_admission(connection_id),
4324 crate::session_token::SessionAdmission::Pending
4325 ) {
4326 "session-admission-timeout"
4327 } else {
4328 "settle-timeout"
4329 };
4330 BridgeAction::Retire {
4331 reason: timeout_reason.to_string(),
4332 }
4333 } else if snapshot
4334 .node_id
4335 .as_deref()
4336 .and_then(|value| value.parse::<iroh::EndpointId>().ok())
4337 .is_some()
4338 {
4339 BridgeAction::Rebind {
4340 remote_node_id: snapshot.node_id,
4341 transport_generation: snapshot.transport_generation,
4342 }
4343 } else {
4344 BridgeAction::AwaitReplacement {
4345 reason: snapshot.readiness_reason.clone(),
4346 }
4347 }
4348 }
4349 ManagedHealth::Dead => {
4350 if settle_deadline_elapsed {
4351 BridgeAction::Retire {
4352 reason: "settle-timeout".to_string(),
4353 }
4354 } else {
4355 BridgeAction::Retire {
4356 reason: "managed transport no longer alive".to_string(),
4357 }
4358 }
4359 }
4360 };
4361 Some(action)
4362 }
4363
4364 #[cfg(not(target_arch = "wasm32"))]
4365 pub async fn send_message(
4366 &self,
4367 target_id: &str,
4368 payload: &str,
4369 state: Option<&str>,
4370 reply_payload: Option<&str>,
4371 ) -> anyhow::Result<String> {
4372 let node_guard = self.node_id.read().await;
4373 if let Some(node_id) = node_guard.as_deref() {
4374 self.signaling
4375 .send_message(node_id, target_id, payload, state, reply_payload)
4376 .await
4377 } else {
4378 Err(anyhow::anyhow!("Node ID not set"))
4379 }
4380 }
4381
4382 #[cfg(target_arch = "wasm32")]
4383 pub async fn send_message(
4384 &self,
4385 _target_id: &str,
4386 _payload: &str,
4387 _state: Option<&str>,
4388 _reply_payload: Option<&str>,
4389 ) -> anyhow::Result<String> {
4390 Err(Self::deprecated_browser_signaling_error("send_message"))
4391 }
4392
4393 #[cfg(not(target_arch = "wasm32"))]
4394 pub async fn subscribe_devices(
4395 &self,
4396 user_id: &str,
4397 ) -> anyhow::Result<
4398 futures::stream::BoxStream<'static, anyhow::Result<Vec<crate::signaling::DeviceEvent>>>,
4399 > {
4400 self.signaling.subscribe_devices(user_id).await
4401 }
4402
4403 #[cfg(target_arch = "wasm32")]
4404 pub async fn subscribe_devices(
4405 &self,
4406 _user_id: &str,
4407 ) -> anyhow::Result<
4408 futures::stream::BoxStream<'static, anyhow::Result<Vec<crate::signaling::DeviceEvent>>>,
4409 > {
4410 Err(Self::deprecated_browser_signaling_error(
4411 "subscribe_devices",
4412 ))
4413 }
4414
4415 #[cfg(not(target_arch = "wasm32"))]
4416 pub async fn create_session(
4417 &self,
4418 mut session: crate::signaling::SignalingSession,
4419 ) -> anyhow::Result<()> {
4420 if session.app_tag.is_none() {
4421 session.app_tag = Some(self.app_tag.clone());
4422 }
4423 self.signaling.create_session(session).await
4424 }
4425
4426 #[cfg(target_arch = "wasm32")]
4427 pub async fn create_session(
4428 &self,
4429 _session: crate::signaling::SignalingSession,
4430 ) -> anyhow::Result<()> {
4431 Err(Self::deprecated_browser_signaling_error("create_session"))
4432 }
4433
4434 #[cfg(not(target_arch = "wasm32"))]
4435 pub async fn update_session(
4436 &self,
4437 session_id: &str,
4438 update: serde_json::Value,
4439 ) -> anyhow::Result<()> {
4440 self.signaling.update_session(session_id, update).await
4441 }
4442
4443 #[cfg(target_arch = "wasm32")]
4444 pub async fn update_session(
4445 &self,
4446 _session_id: &str,
4447 _update: serde_json::Value,
4448 ) -> anyhow::Result<()> {
4449 Err(Self::deprecated_browser_signaling_error("update_session"))
4450 }
4451
4452 #[cfg(not(target_arch = "wasm32"))]
4453 pub async fn subscribe_sessions(
4454 &self,
4455 local_device_id: &str,
4456 ) -> anyhow::Result<
4457 futures::stream::BoxStream<'static, anyhow::Result<Vec<crate::signaling::SessionEvent>>>,
4458 > {
4459 self.signaling.subscribe_sessions(local_device_id).await
4460 }
4461
4462 #[cfg(target_arch = "wasm32")]
4463 pub async fn subscribe_sessions(
4464 &self,
4465 _local_device_id: &str,
4466 ) -> anyhow::Result<
4467 futures::stream::BoxStream<'static, anyhow::Result<Vec<crate::signaling::SessionEvent>>>,
4468 > {
4469 Err(Self::deprecated_browser_signaling_error(
4470 "subscribe_sessions",
4471 ))
4472 }
4473
4474 #[cfg(not(target_arch = "wasm32"))]
4475 pub fn start_signaling_loop(
4476 self: std::sync::Arc<Self>,
4477 user_id: String,
4478 device_name: String,
4479 ticket: String,
4480 metadata: Option<String>,
4481 ) -> tokio::task::JoinHandle<()> {
4482 let (handle, _readiness) = self.start_native_presence_loop(
4483 user_id,
4484 device_name,
4485 NativePresenceTicketPolicy::Fixed(ticket),
4486 metadata,
4487 );
4488 handle
4489 }
4490
4491 #[cfg(not(target_arch = "wasm32"))]
4492 pub async fn start_user_presence(
4493 self: std::sync::Arc<Self>,
4494 user_id: String,
4495 device_name: String,
4496 metadata: Option<String>,
4497 ) -> anyhow::Result<tokio::task::JoinHandle<()>> {
4498 const INITIAL_PRESENCE_READY_TIMEOUT_SECS: u64 = 15;
4499
4500 let owner = self.clone();
4501 let (handle, readiness) = self.start_native_presence_loop(
4502 user_id,
4503 device_name,
4504 NativePresenceTicketPolicy::ManagedUserDevice,
4505 metadata,
4506 );
4507 match tokio::time::timeout(
4508 std::time::Duration::from_secs(INITIAL_PRESENCE_READY_TIMEOUT_SECS),
4509 readiness,
4510 )
4511 .await
4512 {
4513 Ok(Ok(())) => Ok(handle),
4514 Ok(Err(_)) => {
4515 owner.stop_presence_loop();
4516 handle.abort();
4517 Err(anyhow::anyhow!(
4518 "native presence actor exited before initial publication completed"
4519 ))
4520 }
4521 Err(_) => {
4522 owner.stop_presence_loop();
4523 handle.abort();
4524 Err(anyhow::anyhow!(
4525 "native presence did not publish device and lease records within {} seconds",
4526 INITIAL_PRESENCE_READY_TIMEOUT_SECS
4527 ))
4528 }
4529 }
4530 }
4531
4532 #[cfg(not(target_arch = "wasm32"))]
4533 fn start_native_presence_loop(
4534 self: std::sync::Arc<Self>,
4535 user_id: String,
4536 device_name: String,
4537 ticket_policy: NativePresenceTicketPolicy,
4538 metadata: Option<String>,
4539 ) -> (
4540 tokio::task::JoinHandle<()>,
4541 tokio::sync::oneshot::Receiver<()>,
4542 ) {
4543 let (tx, mut rx) = tokio::sync::mpsc::channel(8);
4544 let actor_tx = tx.clone();
4545 let (readiness_tx, readiness_rx) = tokio::sync::oneshot::channel();
4546
4547 let replaced_sender = {
4548 let mut guard = self.presence_loop_tx.lock().unwrap();
4549 guard.replace(tx)
4550 };
4551 let replaced_existing_actor = replaced_sender.is_some();
4552 if let Some(replaced_sender) = replaced_sender {
4553 let _ = replaced_sender.try_send(crate::presence::PresenceCommand::Stop);
4554 }
4555
4556 let handle = tokio::spawn(async move {
4557 const DURABLE_DEVICE_RECORD_TTL_MS: u64 =
4558 crate::presence_policy::DEVICE_OFFLINE_RETENTION_MS as u64;
4559 let mut durable_registered = false;
4560 let mut live_registered = false;
4561 let mut consecutive_publish_failures = 0_u32;
4562 let mut readiness_tx = Some(readiness_tx);
4563 let policy_label = match &ticket_policy {
4564 NativePresenceTicketPolicy::Fixed(_) => "fixed",
4565 NativePresenceTicketPolicy::ManagedUserDevice => "managed-user-device",
4566 };
4567 eprintln!(
4568 "[pluto-rtc][presence][actor-start] user_id={} device_name={} policy={} replaced_existing_actor={}",
4569 user_id, device_name, policy_label, replaced_existing_actor
4570 );
4571 let mut retry_interval =
4572 tokio::time::interval(native_presence_retry_delay(consecutive_publish_failures));
4573 retry_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
4574 loop {
4575 tokio::select! {
4576 _ = retry_interval.tick(), if !durable_registered || !live_registered => {
4577 let (publish_durable, publish_live) =
4578 native_presence_retry_targets(durable_registered, live_registered);
4579 let (durable_ok, live_ok) = self
4580 .publish_native_presence_with_policy_once(
4581 if durable_registered || live_registered {
4582 "initial-retry"
4583 } else {
4584 "initial"
4585 },
4586 &user_id,
4587 &device_name,
4588 &ticket_policy,
4589 DURABLE_DEVICE_RECORD_TTL_MS,
4590 metadata.as_deref(),
4591 publish_durable,
4592 publish_live,
4593 )
4594 .await;
4595 durable_registered = durable_registered || durable_ok;
4596 live_registered = live_registered || live_ok;
4597 if native_presence_is_ready(durable_registered, live_registered) {
4598 consecutive_publish_failures = 0;
4599 if let Some(readiness_tx) = readiness_tx.take() {
4600 let _ = readiness_tx.send(());
4601 eprintln!(
4602 "[pluto-rtc][presence][actor-ready] user_id={} device_name={} policy={}",
4603 user_id, device_name, policy_label
4604 );
4605 }
4606 } else {
4607 let delay = native_presence_retry_delay(consecutive_publish_failures);
4608 consecutive_publish_failures =
4609 consecutive_publish_failures.saturating_add(1);
4610 retry_interval.reset_after(delay);
4611 }
4612 }
4613 cmd = rx.recv() => {
4614 match cmd {
4615 Some(crate::presence::PresenceCommand::RefreshLiveNow) => {
4616 let (_, live_ok) = self
4617 .publish_native_presence_with_policy_once(
4618 "live-refresh-now",
4619 &user_id,
4620 &device_name,
4621 &ticket_policy,
4622 DURABLE_DEVICE_RECORD_TTL_MS,
4623 metadata.as_deref(),
4624 false,
4625 true,
4626 )
4627 .await;
4628 live_registered = live_ok;
4629 if !live_registered {
4630 consecutive_publish_failures = 0;
4631 retry_interval.reset_after(native_presence_retry_delay(0));
4632 }
4633 }
4634 Some(crate::presence::PresenceCommand::RepublishDurableNow) => {
4635 let (durable_ok, live_ok) = self
4636 .publish_native_presence_with_policy_once(
4637 "durable-republish-now",
4638 &user_id,
4639 &device_name,
4640 &ticket_policy,
4641 DURABLE_DEVICE_RECORD_TTL_MS,
4642 metadata.as_deref(),
4643 true,
4644 true,
4645 )
4646 .await;
4647 durable_registered = durable_ok;
4648 live_registered = live_ok;
4649 if native_presence_is_ready(durable_registered, live_registered) {
4650 consecutive_publish_failures = 0;
4651 } else {
4652 consecutive_publish_failures = 0;
4653 retry_interval.reset_after(native_presence_retry_delay(0));
4654 }
4655 }
4656 Some(crate::presence::PresenceCommand::Stop) => {
4657 eprintln!(
4658 "[pluto-rtc][presence][actor-stop] user_id={} device_name={} reason=command",
4659 user_id, device_name
4660 );
4661 break;
4662 }
4663 None => {
4664 eprintln!(
4665 "[pluto-rtc][presence][actor-stop] user_id={} device_name={} reason=sender-replaced-or-dropped",
4666 user_id, device_name
4667 );
4668 break;
4669 }
4670 }
4671 }
4672 }
4673 }
4674
4675 let mut guard = self.presence_loop_tx.lock().unwrap();
4676 if guard
4677 .as_ref()
4678 .is_some_and(|active_tx| active_tx.same_channel(&actor_tx))
4679 {
4680 guard.take();
4681 }
4682 });
4683
4684 (handle, readiness_rx)
4685 }
4686
4687 #[cfg(target_arch = "wasm32")]
4688 pub fn start_signaling_loop(
4689 self: std::sync::Arc<Self>,
4690 _user_id: String,
4691 _device_name: String,
4692 _ticket: String,
4693 _metadata: Option<String>,
4694 ) {
4695 let (tx, mut rx) = tokio::sync::mpsc::channel(8);
4696 {
4697 let mut guard = self.presence_loop_tx.lock().unwrap();
4698 *guard = Some(tx);
4699 }
4700
4701 wasm_bindgen_futures::spawn_local(async move {
4702 loop {
4703 tokio::select! {
4704 cmd = rx.recv() => {
4705 match cmd {
4706 Some(crate::presence::PresenceCommand::RefreshLiveNow)
4707 | Some(crate::presence::PresenceCommand::RepublishDurableNow) => {
4708 web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(
4709 "[OpenRTC] Rust browser presence is disabled; the TypeScript coordination gateway owns browser leases and device projection.",
4710 ));
4711 }
4712 Some(crate::presence::PresenceCommand::Stop) | None => {
4713 break;
4714 }
4715 }
4716 }
4717 }
4718 }
4719 });
4720 }
4721
4722 #[cfg(not(target_arch = "wasm32"))]
4723 pub fn start_auto_connect(self: Arc<Self>, user_id: String, local_device_id: String) {
4724 let key = (user_id.clone(), local_device_id.clone());
4725 let generation = {
4726 let mut guard = match self.auto_connect_loop_key.lock() {
4727 Ok(guard) => guard,
4728 Err(poisoned) => poisoned.into_inner(),
4729 };
4730
4731 if guard.as_ref() == Some(&key) {
4732 #[cfg(not(target_arch = "wasm32"))]
4733 eprintln!(
4734 "[pluto-rtc][auto-connect] duplicate start ignored user_id={} local_device_id={}",
4735 user_id, local_device_id
4736 );
4737 #[cfg(target_arch = "wasm32")]
4738 web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
4739 "[pluto-rtc][auto-connect] duplicate start ignored user_id={} local_device_id={}",
4740 user_id, local_device_id
4741 )));
4742 return;
4743 }
4744
4745 self.known_device_ids_by_node
4746 .write()
4747 .unwrap_or_else(|poisoned| poisoned.into_inner())
4748 .clear();
4749 self.native_route_repair_credentials
4750 .write()
4751 .unwrap_or_else(|poisoned| poisoned.into_inner())
4752 .clear();
4753 self.scoped_route_repair_credentials
4754 .write()
4755 .unwrap_or_else(|poisoned| poisoned.into_inner())
4756 .clear();
4757 *guard = Some(key);
4758 self.auto_connect_generation.fetch_add(1, Ordering::SeqCst) + 1
4759 };
4760
4761 let client_clone = self.clone();
4762
4763 #[cfg(not(target_arch = "wasm32"))]
4764 tokio::spawn(async move {
4765 client_clone
4766 .auto_connect_loop(user_id, local_device_id, generation)
4767 .await;
4768 });
4769
4770 #[cfg(target_arch = "wasm32")]
4771 wasm_bindgen_futures::spawn_local(async move {
4772 client_clone
4773 .auto_connect_loop(user_id, local_device_id, generation)
4774 .await;
4775 });
4776 }
4777
4778 #[cfg(target_arch = "wasm32")]
4779 pub fn start_auto_connect(self: Arc<Self>, user_id: String, local_device_id: String) {
4780 let key = (user_id, local_device_id);
4788 let mut guard = match self.auto_connect_loop_key.lock() {
4789 Ok(guard) => guard,
4790 Err(poisoned) => poisoned.into_inner(),
4791 };
4792 if guard.as_ref() != Some(&key) {
4793 self.known_device_ids_by_node
4794 .write()
4795 .unwrap_or_else(|poisoned| poisoned.into_inner())
4796 .clear();
4797 self.scoped_route_repair_credentials
4798 .write()
4799 .unwrap_or_else(|poisoned| poisoned.into_inner())
4800 .clear();
4801 }
4802 *guard = Some(key);
4803 self.auto_connect_generation
4804 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4805 }
4806
4807 #[cfg(target_arch = "wasm32")]
4808 fn deprecated_browser_signaling_error(operation: &str) -> anyhow::Error {
4809 Self::warn_deprecated_browser_signaling(operation);
4810 anyhow::anyhow!(
4811 "The wasm {} direct signaling path has been retired. Use the TypeScript coordination gateway adapter.",
4812 operation
4813 )
4814 }
4815
4816 #[cfg(target_arch = "wasm32")]
4817 fn warn_deprecated_browser_signaling(operation: &str) {
4818 web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
4819 "[OpenRTC] {} is deprecated in browsers. Use the TypeScript coordination gateway path.",
4820 operation
4821 )));
4822 }
4823
4824 pub fn force_reconnect_snapshot(self: Arc<Self>) {
4825 if self.is_app_execution_suspended() {
4826 #[cfg(target_arch = "wasm32")]
4827 web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(
4828 "[Client] force_reconnect_snapshot skipped while app is backgrounded",
4829 ));
4830 #[cfg(not(target_arch = "wasm32"))]
4831 eprintln!("[Client] force_reconnect_snapshot skipped while app is backgrounded");
4832 return;
4833 }
4834
4835 if let Some(tx) = self.presence_loop_tx.lock().unwrap().clone() {
4836 let _ = tx.try_send(crate::presence::PresenceCommand::RepublishDurableNow);
4837 #[cfg(target_arch = "wasm32")]
4838 web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(
4839 "[Client] Forced presence update triggered",
4840 ));
4841 #[cfg(not(target_arch = "wasm32"))]
4842 eprintln!("[Client] Forced presence update triggered");
4843 }
4844
4845 #[cfg(target_arch = "wasm32")]
4846 {
4847 web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(
4848 "[Client] force_reconnect_snapshot skipped browser auto-connect loop; the TypeScript coordination gateway owns device snapshots",
4849 ));
4850 return;
4851 }
4852
4853 #[cfg(not(target_arch = "wasm32"))]
4854 {
4855 let (user_id, local_device_id) = {
4856 let guard = match self.auto_connect_loop_key.lock() {
4857 Ok(guard) => guard,
4858 Err(poisoned) => poisoned.into_inner(),
4859 };
4860 match guard.as_ref() {
4861 Some((uid, did)) => (uid.clone(), did.clone()),
4862 None => return, }
4864 };
4865
4866 let generation = self
4867 .auto_connect_generation
4868 .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
4869 + 1;
4870 let client_clone = self.clone();
4871
4872 tokio::spawn(async move {
4873 client_clone
4874 .auto_connect_loop(user_id, local_device_id, generation)
4875 .await;
4876 });
4877 }
4878 }
4879
4880 pub fn stop_presence_loop(&self) {
4881 let tx = {
4882 let mut guard = self.presence_loop_tx.lock().unwrap();
4883 guard.take()
4884 };
4885
4886 if let Some(tx) = tx {
4887 let _ = tx.try_send(crate::presence::PresenceCommand::Stop);
4888 }
4889 }
4890
4891 pub fn request_presence_update(&self) -> bool {
4892 self.presence_loop_tx
4893 .lock()
4894 .unwrap()
4895 .as_ref()
4896 .is_some_and(|tx| {
4897 tx.try_send(crate::presence::PresenceCommand::RefreshLiveNow)
4898 .is_ok()
4899 })
4900 }
4901
4902 pub fn stop_auto_connect(&self) {
4903 let mut guard = match self.auto_connect_loop_key.lock() {
4904 Ok(guard) => guard,
4905 Err(poisoned) => poisoned.into_inner(),
4906 };
4907 *guard = None;
4908
4909 self.auto_connect_generation
4910 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4911 #[cfg(not(target_arch = "wasm32"))]
4912 self.native_auto_connect_wake.notify_waiters();
4913 #[cfg(not(target_arch = "wasm32"))]
4914 self.native_route_repair_credentials
4915 .write()
4916 .unwrap_or_else(|poisoned| poisoned.into_inner())
4917 .clear();
4918 self.scoped_route_repair_credentials
4919 .write()
4920 .unwrap_or_else(|poisoned| poisoned.into_inner())
4921 .clear();
4922 }
4923
4924 pub fn stop_auth_scoped_activity(&self) {
4925 self.stop_presence_loop();
4926 self.stop_auto_connect();
4927 }
4928
4929 #[cfg(not(target_arch = "wasm32"))]
4930 pub(crate) fn is_auto_connect_generation_current(&self, generation: u64) -> bool {
4931 self.auto_connect_generation.load(Ordering::SeqCst) == generation
4932 }
4933
4934 pub fn set_auto_connect_excluded(&self, device_id: &str, excluded: bool) {
4939 self.set_auto_connect_excluded_peer(device_id, None, excluded);
4940 }
4941
4942 pub(crate) fn set_auto_connect_excluded_peer(
4949 &self,
4950 device_id: &str,
4951 node_id: Option<&str>,
4952 excluded: bool,
4953 ) {
4954 let _owner = match self.auto_connect_exclusion_owner.lock() {
4955 Ok(owner) => owner,
4956 Err(poisoned) => poisoned.into_inner(),
4957 };
4958 let Some(device_id) = Self::normalize_auto_connect_exclusion_key(device_id) else {
4959 return;
4960 };
4961
4962 let normalized_node_id = node_id.and_then(Self::normalize_auto_connect_exclusion_key);
4963
4964 let mut guard = match self.auto_connect_excluded.lock() {
4965 Ok(g) => g,
4966 Err(p) => p.into_inner(),
4967 };
4968 let mut aliases = match self.auto_connect_excluded_node_aliases.lock() {
4969 Ok(g) => g,
4970 Err(p) => p.into_inner(),
4971 };
4972
4973 if excluded {
4974 guard.insert(device_id.clone());
4975 match self.auto_connect_peer_requested_excluded.lock() {
4976 Ok(mut peer_requested) => {
4977 peer_requested.remove(&device_id);
4978 }
4979 Err(poisoned) => {
4980 poisoned.into_inner().remove(&device_id);
4981 }
4982 }
4983 if let Some(node_id) = normalized_node_id {
4984 aliases.entry(device_id).or_default().insert(node_id);
4985 }
4986 } else {
4987 guard.remove(&device_id);
4988 aliases.remove(&device_id);
4989 match self.auto_connect_peer_requested_excluded.lock() {
4990 Ok(mut peer_requested) => {
4991 peer_requested.remove(&device_id);
4992 }
4993 Err(poisoned) => {
4994 poisoned.into_inner().remove(&device_id);
4995 }
4996 }
4997 }
4998 }
4999
5000 pub(crate) fn set_peer_requested_auto_connect_excluded(
5001 &self,
5002 device_id: &str,
5003 node_id: Option<&str>,
5004 ) {
5005 let _owner = match self.auto_connect_exclusion_owner.lock() {
5006 Ok(owner) => owner,
5007 Err(poisoned) => poisoned.into_inner(),
5008 };
5009 let Some(device_id) = Self::normalize_auto_connect_exclusion_key(device_id) else {
5010 return;
5011 };
5012
5013 let normalized_node_id = node_id.and_then(Self::normalize_auto_connect_exclusion_key);
5014 let mut excluded = match self.auto_connect_excluded.lock() {
5015 Ok(guard) => guard,
5016 Err(poisoned) => poisoned.into_inner(),
5017 };
5018 let mut aliases = match self.auto_connect_excluded_node_aliases.lock() {
5019 Ok(guard) => guard,
5020 Err(poisoned) => poisoned.into_inner(),
5021 };
5022 let mut peer_requested = match self.auto_connect_peer_requested_excluded.lock() {
5023 Ok(guard) => guard,
5024 Err(poisoned) => poisoned.into_inner(),
5025 };
5026
5027 let locally_excluded =
5033 excluded.contains(&device_id) && !peer_requested.contains(&device_id);
5034 if let Some(node_id) = normalized_node_id {
5035 aliases
5036 .entry(device_id.clone())
5037 .or_default()
5038 .insert(node_id);
5039 }
5040 if locally_excluded {
5041 return;
5042 }
5043
5044 excluded.insert(device_id.clone());
5045 peer_requested.insert(device_id);
5046 }
5047
5048 #[cfg(test)]
5049 pub(crate) fn resume_peer_requested_auto_connect_if_authenticated(
5050 &self,
5051 device_id: &str,
5052 ) -> bool {
5053 self.commit_peer_requested_reconnect_if_authenticated(device_id, || true)
5054 }
5055
5056 pub(crate) fn commit_peer_requested_reconnect_if_authenticated(
5064 &self,
5065 device_id: &str,
5066 commit_application_security: impl FnOnce() -> bool,
5067 ) -> bool {
5068 let _owner = match self.auto_connect_exclusion_owner.lock() {
5069 Ok(owner) => owner,
5070 Err(poisoned) => poisoned.into_inner(),
5071 };
5072 let Some(device_id) = Self::normalize_auto_connect_exclusion_key(device_id) else {
5073 return false;
5074 };
5075 let mut excluded = match self.auto_connect_excluded.lock() {
5076 Ok(excluded) => excluded,
5077 Err(poisoned) => poisoned.into_inner(),
5078 };
5079 let mut aliases = match self.auto_connect_excluded_node_aliases.lock() {
5080 Ok(aliases) => aliases,
5081 Err(poisoned) => poisoned.into_inner(),
5082 };
5083 let mut peer_requested = match self.auto_connect_peer_requested_excluded.lock() {
5084 Ok(peer_requested) => peer_requested,
5085 Err(poisoned) => poisoned.into_inner(),
5086 };
5087 if !excluded.contains(&device_id) || !peer_requested.contains(&device_id) {
5088 return false;
5089 }
5090 if !commit_application_security() {
5091 return false;
5092 }
5093 peer_requested.remove(&device_id);
5094 excluded.remove(&device_id);
5095 aliases.remove(&device_id);
5096 true
5097 }
5098
5099 pub(crate) fn auto_connect_exclusion_for_peer(
5100 &self,
5101 device_id: Option<&str>,
5102 node_id: Option<&str>,
5103 ) -> Option<bool> {
5104 let _owner = match self.auto_connect_exclusion_owner.lock() {
5105 Ok(owner) => owner,
5106 Err(poisoned) => poisoned.into_inner(),
5107 };
5108 let device_id = device_id.and_then(Self::normalize_auto_connect_exclusion_key);
5109 let node_id = node_id.and_then(Self::normalize_auto_connect_exclusion_key);
5110 let excluded = match self.auto_connect_excluded.lock() {
5111 Ok(excluded) => excluded,
5112 Err(poisoned) => poisoned.into_inner(),
5113 };
5114 let aliases = match self.auto_connect_excluded_node_aliases.lock() {
5115 Ok(aliases) => aliases,
5116 Err(poisoned) => poisoned.into_inner(),
5117 };
5118 let peer_requested = match self.auto_connect_peer_requested_excluded.lock() {
5119 Ok(peer_requested) => peer_requested,
5120 Err(poisoned) => poisoned.into_inner(),
5121 };
5122 let excluded_device_id = device_id
5123 .filter(|device_id| excluded.contains(device_id))
5124 .or_else(|| {
5125 node_id.as_deref().and_then(|node_id| {
5126 aliases.iter().find_map(|(device_id, node_ids)| {
5127 (excluded.contains(device_id) && node_ids.contains(node_id))
5128 .then(|| device_id.clone())
5129 })
5130 })
5131 })?;
5132 Some(peer_requested.contains(&excluded_device_id))
5133 }
5134
5135 pub fn is_auto_connect_excluded(&self, device_id: &str) -> bool {
5136 let Some(device_id) = Self::normalize_auto_connect_exclusion_key(device_id) else {
5137 return false;
5138 };
5139 match self.auto_connect_excluded.lock() {
5140 Ok(g) => g.contains(&device_id),
5141 Err(p) => p.into_inner().contains(&device_id),
5142 }
5143 }
5144
5145 fn is_locally_auto_connect_excluded(&self, device_id: &str) -> bool {
5146 let Some(device_id) = Self::normalize_auto_connect_exclusion_key(device_id) else {
5147 return false;
5148 };
5149 let excluded = match self.auto_connect_excluded.lock() {
5150 Ok(guard) => guard.contains(&device_id),
5151 Err(poisoned) => poisoned.into_inner().contains(&device_id),
5152 };
5153 if !excluded {
5154 return false;
5155 }
5156 match self.auto_connect_peer_requested_excluded.lock() {
5157 Ok(guard) => !guard.contains(&device_id),
5158 Err(poisoned) => !poisoned.into_inner().contains(&device_id),
5159 }
5160 }
5161
5162 #[cfg(not(target_arch = "wasm32"))]
5163 pub(crate) fn is_auto_connect_peer_excluded(
5164 &self,
5165 device_id: &str,
5166 node_id: Option<&str>,
5167 ) -> bool {
5168 if self.is_auto_connect_excluded(device_id) {
5169 return true;
5170 }
5171
5172 let Some(node_id) = node_id.and_then(Self::normalize_auto_connect_exclusion_key) else {
5173 return false;
5174 };
5175 let aliases = match self.auto_connect_excluded_node_aliases.lock() {
5176 Ok(g) => g,
5177 Err(p) => p.into_inner(),
5178 };
5179 aliases.values().any(|values| values.contains(&node_id))
5180 }
5181
5182 pub fn set_app_backgrounded(self: &Arc<Self>, backgrounded: bool) {
5188 let was_backgrounded = self
5189 .app_backgrounded
5190 .swap(backgrounded, std::sync::atomic::Ordering::Relaxed);
5191
5192 if was_backgrounded != backgrounded {
5193 #[cfg(not(target_arch = "wasm32"))]
5194 eprintln!(
5195 "[AppState] set_app_backgrounded transition={} previous={} current={}",
5196 if backgrounded {
5197 "foreground->background"
5198 } else {
5199 "background->foreground"
5200 },
5201 was_backgrounded,
5202 backgrounded
5203 );
5204 #[cfg(target_arch = "wasm32")]
5205 web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!(
5206 "[AppState] set_app_backgrounded transition={} previous={} current={}",
5207 if backgrounded {
5208 "foreground->background"
5209 } else {
5210 "background->foreground"
5211 },
5212 was_backgrounded,
5213 backgrounded
5214 )));
5215 }
5216
5217 #[cfg(not(target_arch = "wasm32"))]
5218 if was_backgrounded && !backgrounded {
5219 self.native_auto_connect_wake.notify_one();
5220 }
5221
5222 #[cfg(not(target_arch = "wasm32"))]
5225 if was_backgrounded && !backgrounded {
5226 let client = self.clone();
5227 if let Ok(handle) = tokio::runtime::Handle::try_current() {
5232 handle.spawn(async move {
5233 eprintln!("[AppState] Returned to foreground — revisiting packet carriers for relay-connected peers");
5234 for record in client.connection_manager.list_active().await {
5235 let Some(ref endpoint_id) = record.endpoint_id else {
5236 continue;
5237 };
5238 if client.iroh_path_kind(endpoint_id).await
5239 == crate::client::IrohPathKind::Relay
5240 {
5241 let capabilities = client
5242 .native_peer_transport_capabilities
5243 .read()
5244 .await
5245 .get(&record.connection_id)
5246 .cloned()
5247 .unwrap_or_default();
5248 let _ = client
5249 .maybe_start_preferred_native_iroh_carrier(
5250 &record.connection_id,
5251 Some(endpoint_id.as_str()),
5252 capabilities.contains(
5253 &crate::client::NativePeerTransportCapability::WebRtc,
5254 ),
5255 capabilities.contains(
5256 &crate::client::NativePeerTransportCapability::Moq,
5257 ),
5258 )
5259 .await;
5260 }
5261 }
5262 });
5263 }
5264 }
5265 }
5266
5267 pub fn set_background_execution_allowed(&self, allowed: bool) {
5271 self.background_execution_allowed.store(allowed, std::sync::atomic::Ordering::Release);
5272 #[cfg(not(target_arch = "wasm32"))]
5273 self.native_auto_connect_wake.notify_one();
5274 }
5275
5276 pub fn is_app_execution_suspended(&self) -> bool {
5278 self.is_app_backgrounded()
5279 && !self.background_execution_allowed.load(std::sync::atomic::Ordering::Acquire)
5280 }
5281
5282 pub fn is_app_backgrounded(&self) -> bool {
5283 self.app_backgrounded
5284 .load(std::sync::atomic::Ordering::Relaxed)
5285 }
5286}
5287
5288fn device_id_is_local(
5289 device_id: &str,
5290 active_session_identity: Option<&(String, String)>,
5291 native_device_id: Option<&str>,
5292) -> bool {
5293 active_session_identity.is_some_and(|(_, local_device_id)| local_device_id == device_id)
5294 || native_device_id.is_some_and(|local_device_id| local_device_id == device_id)
5295}
5296
5297#[cfg(test)]
5298mod tests {
5299 use super::{
5300 device_id_is_local, native_presence_is_ready, native_presence_retry_delay,
5301 native_presence_retry_targets, selected_iroh_latency_label, Client,
5302 NativePresenceTicketPolicy,
5303 };
5304
5305 #[test]
5306 fn host_execution_grant_preserves_repair_until_os_revokes_it() {
5307 let client = std::sync::Arc::new(Client::new_with_app_tag(
5308 crate::test_constants::TEST_PROJECT_ID.to_string(),
5309 "execution-test".to_string(), Box::new(|| None),
5310 ));
5311 assert!(!client.is_app_execution_suspended());
5312 client.set_app_backgrounded(true);
5313 assert!(client.is_app_execution_suspended());
5314 client.set_background_execution_allowed(true);
5315 assert!(client.is_app_backgrounded());
5316 assert!(!client.is_app_execution_suspended());
5317 client.set_background_execution_allowed(false);
5318 assert!(client.is_app_execution_suspended());
5319 client.set_app_backgrounded(false);
5320 assert!(!client.is_app_execution_suspended());
5321 client.set_background_execution_allowed(true);
5322 client.set_app_backgrounded(true);
5323 assert!(!client.is_app_execution_suspended());
5324 client.set_background_execution_allowed(false);
5325 assert!(client.is_app_execution_suspended());
5326 }
5327
5328 #[tokio::test]
5329 async fn pending_admission_cannot_project_terminal_connection_as_connecting() {
5330 let client = Client::new_with_app_tag(
5331 crate::test_constants::TEST_PROJECT_ID.to_string(),
5332 "test-app".to_string(),
5333 Box::new(|| None),
5334 );
5335 let connection_id = "terminal-admission-projection";
5336 client
5337 .connection_manager
5338 .upsert_pending(
5339 connection_id.to_string(),
5340 Some("terminal-node".to_string()),
5341 Some("terminal-device".to_string()),
5342 Some("terminal-node".to_string()),
5343 )
5344 .await;
5345 client
5346 .connection_manager
5347 .commit_current_transport(
5348 connection_id,
5349 Some("terminal-node".to_string()),
5350 71,
5351 Some("initial".to_string()),
5352 |_| {},
5353 )
5354 .await
5355 .expect("connected transport");
5356 client.set_connection_application_crypto_required(connection_id);
5357 client
5358 .connection_manager
5359 .set_closed_if_current(
5360 connection_id,
5361 71,
5362 Some(crate::lifecycle_reason::REASON_MANUAL_DISCONNECT.to_string()),
5363 )
5364 .await
5365 .expect("terminal close");
5366
5367 let snapshot = client
5368 .connection_state(connection_id)
5369 .await
5370 .expect("terminal snapshot");
5371 assert_eq!(snapshot.state, "closed");
5372 assert_eq!(snapshot.transport_state, "closed");
5373 assert_eq!(snapshot.protocol_state, "closed");
5374 assert_eq!(
5375 snapshot.readiness_state,
5376 crate::client::ReadinessState::Closed
5377 );
5378 assert!(!snapshot.routable);
5379 }
5380
5381 #[test]
5382 fn local_device_delete_detection_survives_missing_auto_connect_identity() {
5383 let active = ("user".to_string(), "active-device".to_string());
5384 assert!(device_id_is_local(
5385 "active-device",
5386 Some(&active),
5387 Some("native-device"),
5388 ));
5389 assert!(device_id_is_local(
5390 "native-device",
5391 None,
5392 Some("native-device"),
5393 ));
5394 assert!(!device_id_is_local(
5395 "remote-device",
5396 Some(&active),
5397 Some("native-device"),
5398 ));
5399 }
5400
5401 #[test]
5402 fn native_presence_retry_does_not_repeat_successful_firestore_registration() {
5403 assert_eq!(native_presence_retry_targets(false, false), (true, true));
5404 assert_eq!(native_presence_retry_targets(true, false), (false, true));
5405 assert_eq!(native_presence_retry_targets(false, true), (true, false));
5406 assert_eq!(native_presence_retry_targets(true, true), (false, false));
5407 }
5408
5409 #[test]
5410 fn native_presence_reports_ready_only_after_both_publications() {
5411 assert!(!native_presence_is_ready(false, false));
5412 assert!(!native_presence_is_ready(true, false));
5413 assert!(!native_presence_is_ready(false, true));
5414 assert!(native_presence_is_ready(true, true));
5415 }
5416
5417 #[test]
5418 fn native_presence_retry_backoff_is_bounded() {
5419 assert_eq!(
5420 native_presence_retry_delay(0),
5421 std::time::Duration::from_secs(2),
5422 );
5423 assert_eq!(
5424 native_presence_retry_delay(1),
5425 std::time::Duration::from_secs(4),
5426 );
5427 assert_eq!(
5428 native_presence_retry_delay(5),
5429 std::time::Duration::from_secs(60),
5430 );
5431 assert_eq!(
5432 native_presence_retry_delay(100),
5433 std::time::Duration::from_secs(60),
5434 );
5435 }
5436
5437 #[tokio::test]
5438 async fn managed_native_presence_policy_never_falls_back_to_a_plain_ticket() {
5439 let base_dir = std::env::temp_dir().join(format!(
5440 "openrtc-managed-presence-policy-{}",
5441 std::time::SystemTime::now()
5442 .duration_since(std::time::UNIX_EPOCH)
5443 .expect("unix epoch")
5444 .as_nanos()
5445 ));
5446 let client = Client::new_with_app_tag(
5447 crate::test_constants::TEST_PROJECT_ID.to_string(),
5448 "test-app".to_string(),
5449 Box::new(|| None),
5450 );
5451 client
5452 .init_native_device_identity(base_dir.clone(), Some("Managed Presence Device"))
5453 .await
5454 .expect("native identity");
5455 client.init_iroh(None, Vec::new()).await.expect("iroh");
5456
5457 let ticket = client
5458 .resolve_native_presence_ticket(&NativePresenceTicketPolicy::ManagedUserDevice)
5459 .await
5460 .expect("managed ticket");
5461 let (iroh_ticket, suffix) = crate::session_token::split_ticket(&ticket);
5462 let payload = suffix
5463 .and_then(|value| crate::session_token::decode_payload(iroh_ticket, value))
5464 .expect("compound admission payload");
5465
5466 assert_eq!(payload.scope.as_str(), "user-device");
5467 assert!(!payload.token.trim().is_empty());
5468 let _ = tokio::fs::remove_dir_all(base_dir).await;
5469 }
5470
5471 #[tokio::test]
5472 async fn fixed_native_presence_policy_preserves_explicit_ticket_semantics() {
5473 let client = Client::new_with_app_tag(
5474 crate::test_constants::TEST_PROJECT_ID.to_string(),
5475 "test-app".to_string(),
5476 Box::new(|| None),
5477 );
5478 let ticket = client
5479 .resolve_native_presence_ticket(&NativePresenceTicketPolicy::Fixed(
5480 "explicit-ticket".to_string(),
5481 ))
5482 .await
5483 .expect("fixed ticket");
5484
5485 assert_eq!(ticket, "explicit-ticket");
5486 }
5487
5488 #[test]
5489 fn normalize_transport_name_accepts_official_path_labels() {
5490 for label in [
5491 "iroh",
5492 "iroh-quic",
5493 "iroh-lan",
5494 "iroh-relay",
5495 "ble",
5496 "webrtc",
5497 "moq",
5498 ] {
5499 assert_eq!(
5500 Client::normalize_transport_name(label).as_deref(),
5501 Some(label),
5502 "label {label} should be accepted"
5503 );
5504 }
5505 }
5506
5507 #[test]
5508 fn iroh_latency_is_attributed_only_to_the_selected_physical_path() {
5509 let labels = ["iroh-lan", "iroh-relay"];
5510 assert_eq!(
5511 selected_iroh_latency_label(crate::client::IrohPathKind::DirectLan, labels),
5512 Some("iroh-lan"),
5513 );
5514 assert_eq!(
5515 selected_iroh_latency_label(crate::client::IrohPathKind::Relay, labels),
5516 Some("iroh-relay"),
5517 );
5518 assert_eq!(
5519 selected_iroh_latency_label(crate::client::IrohPathKind::Ble, ["ble", "iroh-relay"],),
5520 Some("ble"),
5521 );
5522 assert_eq!(
5523 selected_iroh_latency_label(
5524 crate::client::IrohPathKind::WebRtc,
5525 ["iroh-relay", "webrtc"],
5526 ),
5527 Some("webrtc"),
5528 );
5529 assert_eq!(
5530 selected_iroh_latency_label(crate::client::IrohPathKind::Moq, ["moq", "iroh-relay"],),
5531 Some("moq"),
5532 );
5533 }
5534}