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