#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NativeWebRTCAttemptReservation {
Start(u32),
JustExhausted(u32),
AlreadyExhausted,
}
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NativeWebRTCNativeTrigger {
AdmissionAccepted,
IncomingConnection,
AutoConnectConnected,
RelayPath,
ForegroundResume,
ExplicitRequest,
ScheduledRetry,
}
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NativeWebRTCBrowserSignalTrigger {
TypeScriptHandshake,
InboundSdp,
InboundRenegotiate,
}
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NativeWebRTCRecoveryTrigger {
Native(NativeWebRTCNativeTrigger),
BrowserSignal(NativeWebRTCBrowserSignalTrigger),
}
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NativeWebRTCCoordinator {
Native,
BrowserSignal,
}
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
impl NativeWebRTCRecoveryTrigger {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Native(trigger) => trigger.as_str(),
Self::BrowserSignal(trigger) => trigger.as_str(),
}
}
pub(crate) fn skips_when_session_active(self) -> bool {
matches!(
self,
Self::Native(
NativeWebRTCNativeTrigger::RelayPath
| NativeWebRTCNativeTrigger::ForegroundResume
| NativeWebRTCNativeTrigger::ExplicitRequest
) | Self::BrowserSignal(NativeWebRTCBrowserSignalTrigger::TypeScriptHandshake)
)
}
fn is_inbound_signal(self) -> bool {
matches!(
self,
Self::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::InboundSdp
| NativeWebRTCBrowserSignalTrigger::InboundRenegotiate
)
)
}
pub(crate) fn coordinator(self) -> NativeWebRTCCoordinator {
match self {
Self::Native(_) => NativeWebRTCCoordinator::Native,
Self::BrowserSignal(_) => NativeWebRTCCoordinator::BrowserSignal,
}
}
pub(crate) fn can_preempt_in_flight_attempt(
self,
preferred_negotiation_id: Option<&str>,
matches_in_flight: bool,
) -> bool {
match self {
Self::BrowserSignal(NativeWebRTCBrowserSignalTrigger::InboundRenegotiate) => true,
Self::BrowserSignal(NativeWebRTCBrowserSignalTrigger::InboundSdp) => false,
Self::Native(NativeWebRTCNativeTrigger::ExplicitRequest) => {
preferred_negotiation_id.is_some() && !matches_in_flight
}
_ => false,
}
}
}
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
impl NativeWebRTCNativeTrigger {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::AdmissionAccepted => "admission-accepted",
Self::IncomingConnection => "incoming-connection",
Self::AutoConnectConnected => "auto-connect-connected",
Self::RelayPath => "relay-path",
Self::ForegroundResume => "foreground-resume",
Self::ExplicitRequest => "explicit-request",
Self::ScheduledRetry => "scheduled-retry",
}
}
}
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
impl NativeWebRTCBrowserSignalTrigger {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::TypeScriptHandshake => "typescript-handshake",
Self::InboundSdp => "inbound-sdp",
Self::InboundRenegotiate => "inbound-renegotiate",
}
}
}
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct NativeWebRTCRecoveryOptions<'a> {
pub(crate) force_restart: bool,
pub(crate) preferred_negotiation_id: Option<&'a str>,
pub(crate) role_override: Option<crate::transport::NativeWebRTCRole>,
}
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
pub(crate) fn reserve_attempt_slot(
current: u32,
max_attempts: u32,
) -> NativeWebRTCAttemptReservation {
if current > max_attempts {
return NativeWebRTCAttemptReservation::AlreadyExhausted;
}
if current == max_attempts {
return NativeWebRTCAttemptReservation::JustExhausted(max_attempts + 1);
}
NativeWebRTCAttemptReservation::Start(current + 1)
}
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
pub(crate) fn inbound_signal_can_bootstrap_responder(
signal_type: &str,
sdp_type: Option<&str>,
) -> bool {
match signal_type {
"sdp" => sdp_type == Some("offer"),
"candidate" | "renegotiate" => true,
_ => false,
}
}
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
pub(crate) fn should_ignore_stale_inbound_signal_recovery(
trigger: NativeWebRTCRecoveryTrigger,
active_state: crate::transport::NativeWebRTCState,
active_negotiation_id: &str,
preferred_negotiation_id: Option<&str>,
_has_active_attempt: bool,
) -> bool {
if !trigger.is_inbound_signal() {
return false;
}
if matches!(
trigger,
NativeWebRTCRecoveryTrigger::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::InboundRenegotiate
)
) && matches!(active_state, crate::transport::NativeWebRTCState::Connected)
{
return true;
}
let Some(signal_negotiation_id) = preferred_negotiation_id
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return false;
};
if signal_negotiation_id == active_negotiation_id {
return false;
}
match trigger {
NativeWebRTCRecoveryTrigger::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::InboundSdp,
) => matches!(
active_state,
crate::transport::NativeWebRTCState::Connecting
| crate::transport::NativeWebRTCState::Connected
),
NativeWebRTCRecoveryTrigger::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::InboundRenegotiate,
) => false,
_ => false,
}
}
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
pub(crate) fn native_webrtc_session_is_active(state: crate::transport::NativeWebRTCState) -> bool {
matches!(
state,
crate::transport::NativeWebRTCState::Connecting
| crate::transport::NativeWebRTCState::Connected
)
}
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
pub(crate) fn native_webrtc_suppression_blocks_recovery(
preferred_negotiation_id: Option<&str>,
) -> bool {
preferred_negotiation_id
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none()
}
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
pub(crate) fn should_immediately_retire_active_webrtc_for_reason(reason: Option<&str>) -> bool {
crate::lifecycle_reason::LifecycleReasonCode::from_text(reason)
.map(crate::lifecycle_reason::LifecycleReasonCode::should_immediately_retire_active_webrtc)
.unwrap_or(false)
}
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
pub(crate) fn native_signal_send_failure_is_terminal_for_attempt(error: &str) -> bool {
let normalized = error.to_ascii_lowercase();
normalized.contains("no live iroh connection")
|| normalized.contains("missing iroh connection")
|| normalized.contains("connection retired")
|| normalized.contains("session admission rejected")
}
#[cfg(test)]
#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
mod tests {
use super::*;
#[test]
fn attempt_budget_latches_after_exhaustion() {
assert_eq!(
reserve_attempt_slot(0, 8),
NativeWebRTCAttemptReservation::Start(1)
);
assert_eq!(
reserve_attempt_slot(8, 8),
NativeWebRTCAttemptReservation::JustExhausted(9)
);
assert_eq!(
reserve_attempt_slot(9, 8),
NativeWebRTCAttemptReservation::AlreadyExhausted
);
}
#[test]
fn preferred_negotiation_bypasses_restart_suppression() {
assert!(native_webrtc_suppression_blocks_recovery(None));
assert!(native_webrtc_suppression_blocks_recovery(Some(" ")));
assert!(!native_webrtc_suppression_blocks_recovery(Some(
"native-explicit-123"
)));
}
#[test]
fn terminal_disconnect_reasons_do_not_defer_active_webrtc_retirement() {
assert!(should_immediately_retire_active_webrtc_for_reason(Some(
"disconnected by user"
)));
assert!(should_immediately_retire_active_webrtc_for_reason(Some(
"manual disconnect"
)));
assert!(should_immediately_retire_active_webrtc_for_reason(Some(
"stale-settled-peer-snapshot"
)));
assert!(should_immediately_retire_active_webrtc_for_reason(Some(
"replacement-peer-admitted"
)));
assert!(should_immediately_retire_active_webrtc_for_reason(Some(
"session-token-revoked"
)));
assert!(should_immediately_retire_active_webrtc_for_reason(Some(
"app-backgrounded"
)));
assert!(!should_immediately_retire_active_webrtc_for_reason(Some(
"upgrade-timeout"
)));
assert!(!should_immediately_retire_active_webrtc_for_reason(None));
}
#[test]
fn stale_inbound_signal_recovery_cannot_replace_a_settled_generation() {
assert!(should_ignore_stale_inbound_signal_recovery(
NativeWebRTCRecoveryTrigger::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::InboundSdp
),
crate::transport::NativeWebRTCState::Connecting,
"active-negotiation",
Some("stale-negotiation"),
true,
));
assert!(should_ignore_stale_inbound_signal_recovery(
NativeWebRTCRecoveryTrigger::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::InboundRenegotiate
),
crate::transport::NativeWebRTCState::Connected,
"active-negotiation",
Some("delayed-negotiation-after-attempt-cleared"),
false,
));
assert!(should_ignore_stale_inbound_signal_recovery(
NativeWebRTCRecoveryTrigger::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::InboundRenegotiate
),
crate::transport::NativeWebRTCState::Connected,
"active-negotiation",
Some("active-negotiation"),
false,
));
assert!(should_ignore_stale_inbound_signal_recovery(
NativeWebRTCRecoveryTrigger::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::InboundRenegotiate
),
crate::transport::NativeWebRTCState::Connected,
"active-negotiation",
None,
false,
));
assert!(should_ignore_stale_inbound_signal_recovery(
NativeWebRTCRecoveryTrigger::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::InboundRenegotiate
),
crate::transport::NativeWebRTCState::Connected,
"active-negotiation",
Some("stale-negotiation"),
true,
));
assert!(!should_ignore_stale_inbound_signal_recovery(
NativeWebRTCRecoveryTrigger::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::InboundSdp
),
crate::transport::NativeWebRTCState::Failed,
"active-negotiation",
Some("stale-negotiation"),
true,
));
assert!(!should_ignore_stale_inbound_signal_recovery(
NativeWebRTCRecoveryTrigger::Native(NativeWebRTCNativeTrigger::ScheduledRetry),
crate::transport::NativeWebRTCState::Connected,
"active-negotiation",
Some("stale-negotiation"),
true,
));
assert!(should_ignore_stale_inbound_signal_recovery(
NativeWebRTCRecoveryTrigger::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::InboundSdp
),
crate::transport::NativeWebRTCState::Connected,
"native-old-negotiation",
Some("conn-new-negotiation"),
false,
));
}
#[test]
fn inbound_signal_responder_bootstrap_is_limited_to_remote_driven_signals() {
assert!(inbound_signal_can_bootstrap_responder("sdp", Some("offer")));
assert!(!inbound_signal_can_bootstrap_responder(
"sdp",
Some("answer")
));
assert!(!inbound_signal_can_bootstrap_responder("sdp", None));
assert!(inbound_signal_can_bootstrap_responder("candidate", None));
assert!(inbound_signal_can_bootstrap_responder("renegotiate", None));
assert!(!inbound_signal_can_bootstrap_responder(
"capability-update",
None
));
}
#[test]
fn recovery_triggers_express_coordinator_and_preemption() {
assert_eq!(
NativeWebRTCRecoveryTrigger::Native(NativeWebRTCNativeTrigger::AdmissionAccepted)
.coordinator(),
NativeWebRTCCoordinator::Native
);
assert_eq!(
NativeWebRTCRecoveryTrigger::Native(NativeWebRTCNativeTrigger::ScheduledRetry)
.coordinator(),
NativeWebRTCCoordinator::Native
);
assert_eq!(
NativeWebRTCRecoveryTrigger::Native(NativeWebRTCNativeTrigger::ExplicitRequest)
.coordinator(),
NativeWebRTCCoordinator::Native
);
assert_eq!(
NativeWebRTCRecoveryTrigger::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::TypeScriptHandshake
)
.coordinator(),
NativeWebRTCCoordinator::BrowserSignal
);
assert_eq!(
NativeWebRTCRecoveryTrigger::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::InboundSdp
)
.coordinator(),
NativeWebRTCCoordinator::BrowserSignal
);
assert_eq!(
NativeWebRTCRecoveryTrigger::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::InboundRenegotiate
)
.coordinator(),
NativeWebRTCCoordinator::BrowserSignal
);
assert!(NativeWebRTCRecoveryTrigger::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::InboundRenegotiate
)
.can_preempt_in_flight_attempt(None, false));
assert!(!NativeWebRTCRecoveryTrigger::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::InboundSdp
)
.can_preempt_in_flight_attempt(Some("fresh-negotiation"), false));
assert!(!NativeWebRTCRecoveryTrigger::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::InboundSdp
)
.can_preempt_in_flight_attempt(Some("same-negotiation"), true));
assert!(!NativeWebRTCRecoveryTrigger::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::TypeScriptHandshake
)
.can_preempt_in_flight_attempt(Some("fresh-negotiation"), false));
assert!(
NativeWebRTCRecoveryTrigger::Native(NativeWebRTCNativeTrigger::ExplicitRequest)
.can_preempt_in_flight_attempt(Some("fresh-negotiation"), false)
);
assert!(
!NativeWebRTCRecoveryTrigger::Native(NativeWebRTCNativeTrigger::ExplicitRequest)
.can_preempt_in_flight_attempt(Some("same-negotiation"), true)
);
assert!(!NativeWebRTCRecoveryTrigger::Native(
NativeWebRTCNativeTrigger::AutoConnectConnected
)
.can_preempt_in_flight_attempt(Some("fresh-negotiation"), false));
}
#[test]
fn only_live_webrtc_sessions_are_exposed_to_product_handlers() {
assert!(native_webrtc_session_is_active(
crate::transport::NativeWebRTCState::Connecting
));
assert!(native_webrtc_session_is_active(
crate::transport::NativeWebRTCState::Connected
));
assert!(!native_webrtc_session_is_active(
crate::transport::NativeWebRTCState::Failed
));
assert!(!native_webrtc_session_is_active(
crate::transport::NativeWebRTCState::Closed
));
}
#[test]
fn recovery_attempts_allow_a_bounded_retry_window_then_latch() {
const MAX_ATTEMPTS: u32 = 8;
for attempt in 0..MAX_ATTEMPTS {
assert_eq!(
reserve_attempt_slot(attempt, MAX_ATTEMPTS),
NativeWebRTCAttemptReservation::Start(attempt + 1),
"attempt {attempt} should remain inside the retry budget",
);
}
assert_eq!(
reserve_attempt_slot(MAX_ATTEMPTS, MAX_ATTEMPTS),
NativeWebRTCAttemptReservation::JustExhausted(MAX_ATTEMPTS + 1),
);
assert_eq!(
reserve_attempt_slot(MAX_ATTEMPTS + 1, MAX_ATTEMPTS),
NativeWebRTCAttemptReservation::AlreadyExhausted,
);
}
#[test]
fn delayed_signals_cannot_churn_a_live_generation_but_terminal_state_can_recover() {
for state in [
crate::transport::NativeWebRTCState::Connecting,
crate::transport::NativeWebRTCState::Connected,
] {
assert!(should_ignore_stale_inbound_signal_recovery(
NativeWebRTCRecoveryTrigger::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::InboundSdp
),
state,
"generation-current",
Some("generation-delayed"),
true,
));
}
for state in [
crate::transport::NativeWebRTCState::Failed,
crate::transport::NativeWebRTCState::Closed,
] {
assert!(!should_ignore_stale_inbound_signal_recovery(
NativeWebRTCRecoveryTrigger::BrowserSignal(
NativeWebRTCBrowserSignalTrigger::InboundSdp
),
state,
"generation-current",
Some("generation-recovery"),
true,
));
}
}
#[test]
fn terminal_send_failures_are_terminal_for_current_attempt() {
assert!(native_signal_send_failure_is_terminal_for_attempt(
"no live iroh connection for endpoint abc123"
));
assert!(native_signal_send_failure_is_terminal_for_attempt(
"missing Iroh connection for endpoint abc123"
));
assert!(native_signal_send_failure_is_terminal_for_attempt(
"connection retired before signal send"
));
assert!(native_signal_send_failure_is_terminal_for_attempt(
"session admission rejected: unknown session token"
));
assert!(!native_signal_send_failure_is_terminal_for_attempt(
"initial write failed: reset by peer"
));
}
}