openrtc 2.4.0

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(crate) const REASON_DISCONNECTED_BY_USER: &str = "disconnected by user";
pub(crate) const REASON_MANUAL_DISCONNECT: &str = "manual disconnect";
pub(crate) const REASON_SESSION_TOKEN_REVOKED: &str = "session-token-revoked";
pub(crate) const REASON_CLOSED: &str = "closed";
pub(crate) const REASON_DUPLICATE_KEPT_EXISTING: &str = "duplicate-kept-existing";
pub(crate) const REASON_DUPLICATE_ACCEPT_SUPERSEDED: &str = "duplicate-accept-superseded";
pub(crate) const REASON_DUPLICATE_DIAL_SUPERSEDED: &str = "duplicate-dial-superseded";
pub(crate) 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(crate) const REASON_REPLACED_BY_NEW_OUTBOUND: &str = "replaced-by-new-outbound";
pub(crate) const REASON_REPLACED_BY_FRESH_INCOMING: &str = "replaced-by-fresh-incoming";
/// The native node emits this when an authenticated transport-upgrade control
/// exchange replaces the current Iroh leg with a selected custom transport.
/// The old leg's close is expected replacement churn, not a terminal loss.
pub(crate) const REASON_NATIVE_CUSTOM_TRANSPORT_UPGRADE: &str = "native-custom-transport-upgrade";
pub(crate) const REASON_ZOMBIE_REPLACED_BY_FRESH_DIAL: &str = "zombie-replaced-by-fresh-dial";
pub(crate) const REASON_ZOMBIE_REPLACED_BY_FRESH_ACCEPT: &str = "zombie-replaced-by-fresh-accept";
pub(crate) const REASON_STALE_SETTLED_PEER_SNAPSHOT: &str = "stale-settled-peer-snapshot";
pub(crate) const REASON_SESSION_ADMISSION_REJECTED: &str = "session-admission-rejected";
pub(crate) const REASON_REPLACEMENT_PEER_ADMITTED: &str = "replacement-peer-admitted";
pub(crate) const REASON_INCOMING_TRANSPORT_CLOSED_WITHOUT_LIVE_REPLACEMENT: &str =
    "incoming-transport-closed-without-live-replacement";
pub(crate) const REASON_REPLACEMENT_IN_PROGRESS: &str = "replacement-in-progress";
pub(crate) 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(crate) const REASON_STALE_ACTIVE_CONNECTION_RECONNECT: &str =
    "stale-active-connection-reconnect";
pub(crate) const REASON_CONNECTION_FAILED_STABILITY: &str =
    "connection-failed-stability-verification";
pub(crate) const REASON_NETWORK_CHANGE_RECONNECT: &str = "network-change-forced-reconnect";
/// A global route-policy update selected a different custom Iroh packet
/// carrier. The current physical generation is retired so the existing
/// managed peer owner can reconnect and apply the new winner.
pub(crate) const REASON_IROH_CARRIER_POLICY_CHANGED: &str = "iroh-carrier-policy-changed";
/// The selected browser carrier mechanism ended while its Iroh generation was
/// still current. This is physical loss, not a logical disconnect: the
/// existing peer-session actor remains the sole owner of reconnection and
/// carrier re-selection.
pub(crate) const REASON_IROH_CARRIER_FAILED: &str = "iroh-carrier-failed";

#[cfg(feature = "test-harness")]
pub fn network_reconnect() -> &'static str {
    REASON_NETWORK_CHANGE_RECONNECT
}
pub(crate) const REASON_AUTO_CONNECT_FAILURE: &str = "auto-connect-failure";
pub(crate) const REASON_RETIRED_CONFLICTING_RECORD: &str = "retired-conflicting-record";
pub(crate) const REASON_ENDPOINT_HARD_RESET_REDIAL: &str = "endpoint-hard-reset-redial";
/// The authoritative coordination roster removed this peer. This is a
/// terminal close for the current session, but it is not a user/manual
/// disconnect and must not suppress a later fresh admission.
pub(crate) const REASON_DESIRED_PEER_WITHDRAWN: &str = "desired-peer-withdrawn";

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LifecycleReasonCode {
    ManualDisconnect,
    Closed,
    DuplicateKeptExisting,
    DuplicateAcceptSuperseded,
    DuplicateDialSuperseded,
    ReplacedByNewInbound,
    ReplacedByNewOutbound,
    ReplacedByFreshIncoming,
    NativeCustomTransportUpgrade,
    ZombieReplacedByFreshDial,
    ZombieReplacedByFreshAccept,
    StaleSettledPeerSnapshot,
    SessionTokenRevoked,
    SessionAdmissionRejected,
    ReplacementPeerAdmitted,
    IncomingTransportLost,
    ReplacementInProgress,
    AppBackgrounded,
    StaleActiveConnectionReconnect,
    ConnectionFailedStability,
    NetworkChangeReconnect,
    IrohCarrierPolicyChanged,
    IrohCarrierFailed,
    AutoConnectFailure,
    RetiredConflictingRecord,
    EndpointHardResetRedial,
    DesiredPeerWithdrawn,
    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_NATIVE_CUSTOM_TRANSPORT_UPGRADE) {
            return Some(Self::NativeCustomTransportUpgrade);
        }
        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::IncomingTransportLost);
        }
        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_IROH_CARRIER_POLICY_CHANGED) {
            return Some(Self::IrohCarrierPolicyChanged);
        }
        if value.contains(REASON_IROH_CARRIER_FAILED) {
            return Some(Self::IrohCarrierFailed);
        }
        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_DESIRED_PEER_WITHDRAWN) {
            return Some(Self::DesiredPeerWithdrawn);
        }
        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::NativeCustomTransportUpgrade => REASON_NATIVE_CUSTOM_TRANSPORT_UPGRADE,
            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::IncomingTransportLost => {
                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::IrohCarrierPolicyChanged => REASON_IROH_CARRIER_POLICY_CHANGED,
            Self::IrohCarrierFailed => REASON_IROH_CARRIER_FAILED,
            Self::AutoConnectFailure => REASON_AUTO_CONNECT_FAILURE,
            Self::RetiredConflictingRecord => REASON_RETIRED_CONFLICTING_RECORD,
            Self::EndpointHardResetRedial => REASON_ENDPOINT_HARD_RESET_REDIAL,
            Self::DesiredPeerWithdrawn => REASON_DESIRED_PEER_WITHDRAWN,
            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(self) -> bool {
        matches!(
            self,
            Self::StaleActiveConnectionReconnect
                | Self::NetworkChangeReconnect
                | Self::IrohCarrierPolicyChanged
                | Self::IrohCarrierFailed
                | Self::EndpointHardResetRedial
                | Self::RetiredConflictingRecord
        )
    }

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

    pub fn is_graceful(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(self) -> bool {
        matches!(
            self,
            Self::ManualDisconnect
                | Self::Closed
                | Self::SessionTokenRevoked
                | Self::SessionAdmissionRejected
                | Self::DesiredPeerWithdrawn
        )
    }

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

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

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

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

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

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

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn selected_carrier_failure_is_recoverable_not_terminal() {
        assert_eq!(
            LifecycleReasonCode::from_text(Some(REASON_IROH_CARRIER_FAILED)),
            Some(LifecycleReasonCode::IrohCarrierFailed),
        );
        assert!(is_transient(Some(REASON_IROH_CARRIER_FAILED)));
        assert!(!is_terminal(Some(REASON_IROH_CARRIER_FAILED)));
        assert!(!is_manual_disconnect(Some(REASON_IROH_CARRIER_FAILED)));
    }
}