openrtc 1.0.4

OpenRTC: a Rust-first P2P runtime for device discovery, signaling, and iroh/QUIC networking.
Documentation
//! Structured lifecycle reason helpers.
//!
//! Transport libraries still surface close reasons as debug strings in several
//! places, but runtime policy should branch on reason codes rather than ad-hoc
//! substring checks scattered through the codebase.

pub const REASON_DISCONNECTED_BY_USER: &str = "disconnected by user";
pub const REASON_MANUAL_DISCONNECT: &str = "manual disconnect";
pub const REASON_REMOTE_MANUAL_DISCONNECT: &str = "remote manual disconnect";
pub const REASON_SESSION_TOKEN_REVOKED: &str = "session-token-revoked";
pub const REASON_CLOSED: &str = "closed";
pub const REASON_DUPLICATE_KEPT_EXISTING: &str = "duplicate-kept-existing";
pub const REASON_DUPLICATE_ACCEPT_SUPERSEDED: &str = "duplicate-accept-superseded";
pub const REASON_DUPLICATE_DIAL_SUPERSEDED: &str = "duplicate-dial-superseded";
pub const REASON_REPLACED_BY_NEW_INBOUND: &str = "replaced-by-new-inbound";
/// Emitted by `native_node::connect` / `connect_addr` when a fresh outbound
/// dial replaces an earlier connection to the same endpoint (mirror of
/// `REASON_REPLACED_BY_NEW_INBOUND` for the dialer side). Must be classified
/// as replacement churn — without it, glare resolution retires the kept
/// transport with `incoming-transport-closed-without-live-replacement` even
/// though a fresher replacement is already adopted.
pub const REASON_REPLACED_BY_NEW_OUTBOUND: &str = "replaced-by-new-outbound";
pub const REASON_REPLACED_BY_FRESH_INCOMING: &str = "replaced-by-fresh-incoming";
pub const REASON_ZOMBIE_REPLACED_BY_FRESH_DIAL: &str = "zombie-replaced-by-fresh-dial";
pub const REASON_ZOMBIE_REPLACED_BY_FRESH_ACCEPT: &str = "zombie-replaced-by-fresh-accept";
pub const REASON_STALE_SETTLED_PEER_SNAPSHOT: &str = "stale-settled-peer-snapshot";
pub const REASON_SESSION_ADMISSION_REJECTED: &str = "session-admission-rejected";
pub const REASON_REPLACEMENT_PEER_ADMITTED: &str = "replacement-peer-admitted";
pub const REASON_INCOMING_TRANSPORT_CLOSED_WITHOUT_LIVE_REPLACEMENT: &str =
    "incoming-transport-closed-without-live-replacement";
pub const REASON_REPLACEMENT_IN_PROGRESS: &str = "replacement-in-progress";
pub const REASON_APP_BACKGROUNDED: &str = "app-backgrounded";

// Internal auto-connect lifecycle closes. These are NEVER user actions; using the
// generic `disconnect()` (which defaults to `disconnected by user`) for them
// mis-classified routine reconnect/retire churn as a manual disconnect, which made
// the peer treat a recoverable transition as terminal and contributed to
// browser<->native re-dial storms. Each internal close now carries its real code.
pub const REASON_STALE_ACTIVE_CONNECTION_RECONNECT: &str = "stale-active-connection-reconnect";
pub const REASON_CONNECTION_FAILED_STABILITY: &str = "connection-failed-stability-verification";
pub const REASON_NETWORK_CHANGE_RECONNECT: &str = "network-change-forced-reconnect";
pub const REASON_AUTO_CONNECT_FAILURE: &str = "auto-connect-failure";
pub const REASON_RETIRED_CONFLICTING_RECORD: &str = "retired-conflicting-record";
pub const REASON_ENDPOINT_HARD_RESET_REDIAL: &str = "endpoint-hard-reset-redial";

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LifecycleReasonCode {
    ManualDisconnect,
    Closed,
    DuplicateKeptExisting,
    DuplicateAcceptSuperseded,
    DuplicateDialSuperseded,
    ReplacedByNewInbound,
    ReplacedByNewOutbound,
    ReplacedByFreshIncoming,
    ZombieReplacedByFreshDial,
    ZombieReplacedByFreshAccept,
    StaleSettledPeerSnapshot,
    SessionTokenRevoked,
    SessionAdmissionRejected,
    ReplacementPeerAdmitted,
    IncomingTransportClosedWithoutLiveReplacement,
    ReplacementInProgress,
    AppBackgrounded,
    StaleActiveConnectionReconnect,
    ConnectionFailedStability,
    NetworkChangeReconnect,
    AutoConnectFailure,
    RetiredConflictingRecord,
    EndpointHardResetRedial,
    Unknown,
}

impl LifecycleReasonCode {
    pub fn from_text(value: Option<&str>) -> Option<Self> {
        let value = value.map(str::trim).filter(|value| !value.is_empty())?;
        if value.contains(REASON_MANUAL_DISCONNECT) {
            return Some(Self::ManualDisconnect);
        }
        if value.contains(REASON_DUPLICATE_KEPT_EXISTING) {
            return Some(Self::DuplicateKeptExisting);
        }
        if value.contains(REASON_DUPLICATE_ACCEPT_SUPERSEDED) {
            return Some(Self::DuplicateAcceptSuperseded);
        }
        if value.contains(REASON_DUPLICATE_DIAL_SUPERSEDED) {
            return Some(Self::DuplicateDialSuperseded);
        }
        if value.contains(REASON_REPLACED_BY_NEW_INBOUND) {
            return Some(Self::ReplacedByNewInbound);
        }
        if value.contains(REASON_REPLACED_BY_NEW_OUTBOUND) {
            return Some(Self::ReplacedByNewOutbound);
        }
        if value.contains(REASON_REPLACED_BY_FRESH_INCOMING) {
            return Some(Self::ReplacedByFreshIncoming);
        }
        if value.contains(REASON_ZOMBIE_REPLACED_BY_FRESH_DIAL) {
            return Some(Self::ZombieReplacedByFreshDial);
        }
        if value.contains(REASON_ZOMBIE_REPLACED_BY_FRESH_ACCEPT) {
            return Some(Self::ZombieReplacedByFreshAccept);
        }
        if value.contains(REASON_STALE_SETTLED_PEER_SNAPSHOT) {
            return Some(Self::StaleSettledPeerSnapshot);
        }
        if value.contains(REASON_SESSION_TOKEN_REVOKED) {
            return Some(Self::SessionTokenRevoked);
        }
        if value.contains(REASON_SESSION_ADMISSION_REJECTED) {
            return Some(Self::SessionAdmissionRejected);
        }
        if value.contains(REASON_REPLACEMENT_PEER_ADMITTED) {
            return Some(Self::ReplacementPeerAdmitted);
        }
        if value.contains(REASON_INCOMING_TRANSPORT_CLOSED_WITHOUT_LIVE_REPLACEMENT) {
            return Some(Self::IncomingTransportClosedWithoutLiveReplacement);
        }
        if value.contains(REASON_REPLACEMENT_IN_PROGRESS) {
            return Some(Self::ReplacementInProgress);
        }
        if value.contains(REASON_APP_BACKGROUNDED) {
            return Some(Self::AppBackgrounded);
        }
        if value.contains(REASON_STALE_ACTIVE_CONNECTION_RECONNECT) {
            return Some(Self::StaleActiveConnectionReconnect);
        }
        if value.contains(REASON_CONNECTION_FAILED_STABILITY) {
            return Some(Self::ConnectionFailedStability);
        }
        if value.contains(REASON_NETWORK_CHANGE_RECONNECT) {
            return Some(Self::NetworkChangeReconnect);
        }
        if value.contains(REASON_AUTO_CONNECT_FAILURE) {
            return Some(Self::AutoConnectFailure);
        }
        if value.contains(REASON_RETIRED_CONFLICTING_RECORD) {
            return Some(Self::RetiredConflictingRecord);
        }
        if value.contains(REASON_ENDPOINT_HARD_RESET_REDIAL) {
            return Some(Self::EndpointHardResetRedial);
        }
        if value.contains(REASON_DISCONNECTED_BY_USER) {
            return Some(Self::Closed);
        }
        if value.contains("ApplicationClosed") && value.contains("b\"closed\"") {
            return Some(Self::Closed);
        }
        if value == REASON_CLOSED {
            return Some(Self::Closed);
        }
        Some(Self::Unknown)
    }

    pub fn as_str(self) -> &'static str {
        match self {
            Self::ManualDisconnect => REASON_MANUAL_DISCONNECT,
            Self::Closed => REASON_CLOSED,
            Self::DuplicateKeptExisting => REASON_DUPLICATE_KEPT_EXISTING,
            Self::DuplicateAcceptSuperseded => REASON_DUPLICATE_ACCEPT_SUPERSEDED,
            Self::DuplicateDialSuperseded => REASON_DUPLICATE_DIAL_SUPERSEDED,
            Self::ReplacedByNewInbound => REASON_REPLACED_BY_NEW_INBOUND,
            Self::ReplacedByNewOutbound => REASON_REPLACED_BY_NEW_OUTBOUND,
            Self::ReplacedByFreshIncoming => REASON_REPLACED_BY_FRESH_INCOMING,
            Self::ZombieReplacedByFreshDial => REASON_ZOMBIE_REPLACED_BY_FRESH_DIAL,
            Self::ZombieReplacedByFreshAccept => REASON_ZOMBIE_REPLACED_BY_FRESH_ACCEPT,
            Self::StaleSettledPeerSnapshot => REASON_STALE_SETTLED_PEER_SNAPSHOT,
            Self::SessionTokenRevoked => REASON_SESSION_TOKEN_REVOKED,
            Self::SessionAdmissionRejected => REASON_SESSION_ADMISSION_REJECTED,
            Self::ReplacementPeerAdmitted => REASON_REPLACEMENT_PEER_ADMITTED,
            Self::IncomingTransportClosedWithoutLiveReplacement => {
                REASON_INCOMING_TRANSPORT_CLOSED_WITHOUT_LIVE_REPLACEMENT
            }
            Self::ReplacementInProgress => REASON_REPLACEMENT_IN_PROGRESS,
            Self::AppBackgrounded => REASON_APP_BACKGROUNDED,
            Self::StaleActiveConnectionReconnect => REASON_STALE_ACTIVE_CONNECTION_RECONNECT,
            Self::ConnectionFailedStability => REASON_CONNECTION_FAILED_STABILITY,
            Self::NetworkChangeReconnect => REASON_NETWORK_CHANGE_RECONNECT,
            Self::AutoConnectFailure => REASON_AUTO_CONNECT_FAILURE,
            Self::RetiredConflictingRecord => REASON_RETIRED_CONFLICTING_RECORD,
            Self::EndpointHardResetRedial => REASON_ENDPOINT_HARD_RESET_REDIAL,
            Self::Unknown => "unknown",
        }
    }

    /// True for internal auto-connect closes that are part of an in-flight
    /// reconnect/redial — the peer should treat them as recoverable transitions,
    /// never as a terminal/manual disconnect.
    pub fn is_transient_reconnect(self) -> bool {
        matches!(
            self,
            Self::StaleActiveConnectionReconnect
                | Self::NetworkChangeReconnect
                | Self::EndpointHardResetRedial
                | Self::RetiredConflictingRecord
        )
    }

    pub fn is_manual_disconnect(self) -> bool {
        matches!(self, Self::ManualDisconnect)
    }

    pub fn is_graceful_disconnect(self) -> bool {
        matches!(self, Self::ManualDisconnect | Self::Closed)
    }

    /// True when the current logical peer session must settle closed without
    /// waiting for a replacement transport. Authorization failures are
    /// terminal for the current session even though they are not user-driven
    /// graceful disconnects.
    pub fn is_terminal_disconnect(self) -> bool {
        matches!(
            self,
            Self::ManualDisconnect
                | Self::Closed
                | Self::SessionTokenRevoked
                | Self::SessionAdmissionRejected
        )
    }

    pub fn is_replacement_churn(self) -> bool {
        matches!(
            self,
            Self::DuplicateKeptExisting
                | Self::DuplicateAcceptSuperseded
                | Self::DuplicateDialSuperseded
                | Self::ReplacedByNewInbound
                | Self::ReplacedByNewOutbound
                | Self::ReplacedByFreshIncoming
                | Self::ZombieReplacedByFreshDial
                | Self::ZombieReplacedByFreshAccept
        )
    }

    pub fn should_immediately_retire_active_webrtc(self) -> bool {
        matches!(
            self,
            Self::ManualDisconnect
                | Self::Closed
                | Self::StaleSettledPeerSnapshot
                | Self::SessionTokenRevoked
                | Self::SessionAdmissionRejected
                | Self::ReplacementPeerAdmitted
                | Self::IncomingTransportClosedWithoutLiveReplacement
                | Self::AppBackgrounded
        )
    }
}

pub fn reason_is_manual_disconnect(value: Option<&str>) -> bool {
    LifecycleReasonCode::from_text(value)
        .map(LifecycleReasonCode::is_manual_disconnect)
        .unwrap_or(false)
}

pub fn reason_is_graceful_disconnect(value: Option<&str>) -> bool {
    LifecycleReasonCode::from_text(value)
        .map(LifecycleReasonCode::is_graceful_disconnect)
        .unwrap_or(false)
}

pub fn reason_is_terminal_disconnect(value: Option<&str>) -> bool {
    LifecycleReasonCode::from_text(value)
        .map(LifecycleReasonCode::is_terminal_disconnect)
        .unwrap_or(false)
}

pub fn reason_is_transient_reconnect(value: Option<&str>) -> bool {
    LifecycleReasonCode::from_text(value)
        .map(LifecycleReasonCode::is_transient_reconnect)
        .unwrap_or(false)
}

pub fn reason_is_replacement_churn(value: Option<&str>) -> bool {
    LifecycleReasonCode::from_text(value)
        .map(LifecycleReasonCode::is_replacement_churn)
        .unwrap_or(false)
}