exfiltrate 0.3.0

An embeddable debug tool for Rust.
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0
//! The proxy websocket's reconnect decisions, separated from the socket itself.
//!
//! The browser-side proxy wedged twice for reasons that were pure state-machine
//! bugs — a failed connection cached as if it were live, and a closed socket
//! kept forever — but they were unreachable by any test, because the logic sat
//! inline in a loop that owns a real [`web_sys::WebSocket`] in a worker.
//!
//! Everything here is target-independent and takes its socket through
//! [`ProxySocket`], so both rules are exercised natively and in a browser
//! against a fake whose `ready_state` the test chooses.

/// `WebSocket.readyState`, from the WHATWG spec.
///
/// Restated here so the state machine is testable without a browser; the
/// wasm32 implementation asserts these against `web_sys`' own constants.
pub(crate) const CONNECTING: u16 = 0;
pub(crate) const OPEN: u16 = 1;
pub(crate) const CLOSING: u16 = 2;
pub(crate) const CLOSED: u16 = 3;

/// The one thing the reconnect loop needs to know about a socket.
pub(crate) trait ProxySocket {
    fn ready_state(&self) -> u16;
}

/// Whether a socket can still carry traffic.
///
/// `CONNECTING` counts as usable: the handshake may yet succeed, and discarding
/// it would spawn a second socket on every tick.
pub(crate) fn is_usable(ready_state: u16) -> bool {
    ready_state == CONNECTING || ready_state == OPEN
}

/// Discard a socket that can no longer carry traffic, and report whether a
/// connection attempt is now needed.
pub(crate) fn needs_connect<S: ProxySocket>(socket: &mut Option<S>) -> bool {
    if let Some(s) = socket.as_ref()
        && !is_usable(s.ready_state())
    {
        *socket = None;
    }
    socket.is_none()
}

/// Store the outcome of a connection attempt, returning the error for logging.
///
/// A failure must leave the slot empty. Storing it would make the next tick
/// believe a socket already exists, and the loop would never retry — which is
/// exactly how the proxy went silent.
pub(crate) fn store_attempt<S: ProxySocket, E>(
    socket: &mut Option<S>,
    attempt: Result<S, E>,
) -> Result<(), E> {
    match attempt {
        Ok(s) => {
            *socket = Some(s);
            Ok(())
        }
        Err(e) => {
            *socket = None;
            Err(e)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{
        CLOSED, CLOSING, CONNECTING, OPEN, ProxySocket, is_usable, needs_connect, store_attempt,
    };

    /// A socket that is nothing but a `ready_state`.
    struct FakeSocket {
        ready_state: u16,
    }

    impl ProxySocket for FakeSocket {
        fn ready_state(&self) -> u16 {
            self.ready_state
        }
    }

    fn socket(ready_state: u16) -> Option<FakeSocket> {
        Some(FakeSocket { ready_state })
    }

    #[wasm_lite::wasm_lite_test]
    fn connecting_and_open_are_usable() {
        assert!(
            is_usable(CONNECTING),
            "a pending handshake may still succeed"
        );
        assert!(is_usable(OPEN));
        assert!(!is_usable(CLOSING));
        assert!(!is_usable(CLOSED));
    }

    #[wasm_lite::wasm_lite_test]
    fn an_empty_slot_needs_a_connection() {
        let mut s: Option<FakeSocket> = None;
        assert!(needs_connect(&mut s));
    }

    #[wasm_lite::wasm_lite_test]
    fn a_live_socket_is_left_alone() {
        for state in [CONNECTING, OPEN] {
            let mut s = socket(state);
            assert!(!needs_connect(&mut s), "state {state} should be kept");
            assert!(s.is_some(), "state {state} should not be discarded");
        }
    }

    /// The second wedge: a closed socket was kept forever, so the loop decided
    /// it already had one and never reconnected.
    #[wasm_lite::wasm_lite_test]
    fn a_dead_socket_is_discarded_and_triggers_a_connect() {
        for state in [CLOSING, CLOSED] {
            let mut s = socket(state);
            assert!(
                needs_connect(&mut s),
                "state {state} should force a connect"
            );
            assert!(s.is_none(), "state {state} should be cleared");
        }
    }

    /// The first wedge: a failed attempt was cached as though it were a live
    /// socket, so every later tick took the "already connected" path.
    #[wasm_lite::wasm_lite_test]
    fn a_failed_attempt_is_not_cached() {
        let mut s: Option<FakeSocket> = None;
        let outcome = store_attempt(&mut s, Err::<FakeSocket, _>("connect refused"));

        assert_eq!(outcome, Err("connect refused"), "the error reaches the log");
        assert!(s.is_none(), "a failure must not occupy the slot");
        assert!(
            needs_connect(&mut s),
            "the next tick must retry, not conclude it is connected"
        );
    }

    /// Pins the *clearing*, not just the not-storing. Every other test here
    /// fails an attempt against an already-empty slot, where "leave it alone"
    /// and "clear it" look identical — a mutation that dropped the clear went
    /// undetected until this case existed.
    #[wasm_lite::wasm_lite_test]
    fn a_failed_attempt_clears_whatever_was_there() {
        let mut s = socket(OPEN);
        let _ = store_attempt(&mut s, Err::<FakeSocket, _>("refused"));
        assert!(
            s.is_none(),
            "a failed attempt must empty the slot, not leave a stale socket"
        );
    }

    #[wasm_lite::wasm_lite_test]
    fn a_successful_attempt_is_stored() {
        let mut s: Option<FakeSocket> = None;
        let outcome =
            store_attempt::<FakeSocket, &str>(&mut s, Ok(FakeSocket { ready_state: OPEN }));

        assert!(outcome.is_ok());
        assert!(!needs_connect(&mut s), "a live socket needs no reconnect");
    }

    /// The whole failure the fix was for: connect fails, then the socket that
    /// does arrive later dies, and the loop has to recover from both without
    /// wedging in between.
    #[wasm_lite::wasm_lite_test]
    fn the_loop_recovers_from_a_failure_then_a_drop() {
        let mut s: Option<FakeSocket> = None;
        let mut attempts = 0;

        // Three ticks: refused, refused, then connected.
        for tick in 0..3 {
            if needs_connect(&mut s) {
                attempts += 1;
                let attempt = if tick < 2 {
                    Err("refused")
                } else {
                    Ok(FakeSocket { ready_state: OPEN })
                };
                let _ = store_attempt(&mut s, attempt);
            }
        }
        assert_eq!(attempts, 3, "each failure must be retried");
        assert!(s.is_some(), "the third attempt connected");

        // The connection then drops. The next tick must notice and reconnect.
        s = socket(CLOSED);
        assert!(needs_connect(&mut s), "a dropped connection is retried");
        let _ = store_attempt(&mut s, Ok::<_, &str>(FakeSocket { ready_state: OPEN }));
        assert!(
            !needs_connect(&mut s),
            "and the loop settles once reconnected"
        );
    }
}