wireframe 0.3.0

Simplify building servers and clients for custom binary protocols.
Documentation
//! `PanicWorld` fixture for rstest-bdd tests.
//!
//! Provides test fixtures to ensure the server remains resilient when
//! connection setup handlers panic before a client fully connects.

use std::net::SocketAddr;

use rstest::fixture;
use tokio::{net::TcpStream, sync::oneshot};
use wireframe::server::WireframeServer;
/// `TestResult` for step definitions.
pub use wireframe_testing::TestResult;
use wireframe_testing::{TestApp, unused_listener};

#[derive(Debug)]
struct PanicServer {
    addr: SocketAddr,
    shutdown: Option<oneshot::Sender<()>>,
    handle: tokio::task::JoinHandle<()>,
}

impl PanicServer {
    #[expect(
        clippy::expect_used,
        reason = "panic world should fail loudly if the panic app cannot be built"
    )]
    async fn spawn() -> TestResult<Self> {
        let factory = || {
            TestApp::new()
                .and_then(|app| app.on_connection_setup(|| async { panic!("boom") }))
                .expect("failed to build panic app")
        };
        let listener = unused_listener()?;
        let server = WireframeServer::new(factory)
            .workers(1)
            .bind_existing_listener(listener)?;
        let addr = server.local_addr().ok_or("Failed to get server address")?;
        let (tx_shutdown, rx_shutdown) = oneshot::channel();
        let (tx_ready, rx_ready) = oneshot::channel();

        let handle = tokio::spawn(async move {
            if let Err(err) = server
                .ready_signal(tx_ready)
                .run_with_shutdown(async {
                    let _ = rx_shutdown.await;
                })
                .await
            {
                tracing::error!("server task failed: {err}");
            }
        });
        rx_ready.await.map_err(|_| "Server did not signal ready")?;

        Ok(Self {
            addr,
            shutdown: Some(tx_shutdown),
            handle,
        })
    }
}

impl Drop for PanicServer {
    fn drop(&mut self) {
        use std::{thread, time::Duration};

        if let Some(tx) = self.shutdown.take() {
            let _ = tx.send(());
        }
        let timeout = Duration::from_secs(5);
        let handle = self.handle.abort_handle();
        thread::spawn(move || {
            thread::sleep(timeout);
            handle.abort();
        });
    }
}

/// Test world that drives a server which intentionally panics during setup.
#[derive(Debug, Default)]
pub struct PanicWorld {
    server: Option<PanicServer>,
    attempts: usize,
}

/// Fixture for `PanicWorld`.
// rustfmt collapses simple fixtures into one line, which triggers unused_braces.
#[rustfmt::skip]
#[fixture]
pub fn panic_world() -> PanicWorld {
    PanicWorld::default()
}

impl PanicWorld {
    /// Start a server that panics during connection setup.
    ///
    /// # Errors
    /// Returns an error if building the app factory or binding the server
    /// fails.
    pub async fn start_panic_server(&mut self) -> TestResult {
        let server = PanicServer::spawn().await?;
        self.server.replace(server);
        Ok(())
    }

    /// Connect to the running server once.
    ///
    /// # Errors
    /// Returns an error if the server address is unknown or the connection
    /// attempt fails.
    pub async fn connect_once(&mut self) -> TestResult {
        let addr = self.server.as_ref().ok_or("Server not started")?.addr;
        TcpStream::connect(addr).await?;
        self.attempts += 1;
        Ok(())
    }

    /// Verify both connections succeeded and shut down the server.
    ///
    /// # Errors
    /// Returns an error if the connection attempts do not match the expected
    /// count.
    pub async fn verify_and_shutdown(&mut self) -> TestResult {
        if self.attempts != 2 {
            return Err("expected two successful connection attempts".into());
        }
        // dropping PanicServer will shut it down
        self.server.take();
        tokio::task::yield_now().await;
        Ok(())
    }
}