rullst-core 12.3.0

Core runtime, routing engine, and telemetry for the Rullst framework suite.
Documentation
#![allow(clippy::unwrap_used, clippy::expect_used)]

use std::sync::{Arc, Mutex, RwLock};
use std::task::Poll;

use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use tower_service::Service;

use super::*;

const TEST_RELOAD_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";

fn service(router: axum::Router) -> HotSwapService {
    HotSwapService {
        current_router: Arc::new(RwLock::new(router)),
        active_libraries: Arc::new(Mutex::new(Vec::new())),
        hmr_sender: tokio::sync::broadcast::channel(4).0,
        reload_lock: Arc::new(tokio::sync::Mutex::new(())),
        reload_token: Arc::from(TEST_RELOAD_TOKEN),
        lib_path: "/definitely/missing/rullst-app".to_owned(),
        is_dev: true,
        shield: None,
        limiter: None,
        lifecycle: None,
    }
}

async fn body_text(response: axum::response::Response) -> String {
    let body = to_bytes(response.into_body(), 64 * 1024)
        .await
        .expect("bounded response body");
    String::from_utf8(body.to_vec()).expect("UTF-8 fixture response")
}

#[tokio::test]
async fn forwards_normal_requests_and_reports_missing_reload_library() {
    let router = axum::Router::new().route("/ok", axum::routing::get(|| async { "ready" }));
    let mut service = service(router);

    let response = service
        .call(Request::builder().uri("/ok").body(Body::empty()).unwrap())
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::OK);
    assert_eq!(body_text(response).await, "ready");

    let response = service
        .call(
            Request::builder()
                .method("POST")
                .uri("/_rullst/internal/reload_dylib")
                .header(RELOAD_TOKEN_HEADER, TEST_RELOAD_TOKEN)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
    assert!(body_text(response).await.contains("Dylib not found"));
    assert!(service.active_libraries.lock().unwrap().is_empty());
}

#[tokio::test]
async fn reload_endpoint_rejects_missing_and_incorrect_tokens() {
    for token in [None, Some("incorrect-token")] {
        let mut service = service(axum::Router::new());
        let mut request = Request::builder()
            .method("POST")
            .uri("/_rullst/internal/reload_dylib");
        if let Some(token) = token {
            request = request.header(RELOAD_TOKEN_HEADER, token);
        }
        let response = service
            .call(request.body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::FORBIDDEN);
        assert!(body_text(response).await.is_empty());
    }
}

#[tokio::test]
async fn hmr_channel_is_reserved_and_requires_a_websocket_upgrade() {
    let router = axum::Router::new().route(
        "/_rullst_hmr",
        axum::routing::get(|| async { "application shadow" }),
    );
    let mut service = service(router);
    let response = service
        .call(
            Request::builder()
                .uri("/_rullst_hmr")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert!(response.status().is_client_error());
    assert!(!body_text(response).await.contains("application shadow"));
}

#[test]
fn retained_library_generation_limit_is_bounded() {
    assert!(!generation_limit_reached(MAX_HOT_RELOAD_GENERATIONS - 1));
    assert!(generation_limit_reached(MAX_HOT_RELOAD_GENERATIONS));
}

#[tokio::test]
async fn poisoned_router_lock_recovers_and_keeps_serving() {
    let router = axum::Router::new().route("/health", axum::routing::get(|| async { "healthy" }));
    let mut service = service(router);
    let current = Arc::clone(&service.current_router);
    let _ = std::thread::spawn(move || {
        let _guard = current.write().unwrap();
        panic!("poison test-only router lock");
    })
    .join();

    let response = service
        .call(
            Request::builder()
                .uri("/health")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::OK);
    assert_eq!(body_text(response).await, "healthy");
}

#[tokio::test]
async fn panic_and_cancellation_errors_become_bounded_error_responses() {
    let string_panic = tokio::spawn(async {
        std::panic::panic_any(String::from("owned panic message"));
    })
    .await
    .unwrap_err();
    let response = HotSwapService::handle_panic_error(string_panic)
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
    assert!(body_text(response).await.contains("owned panic message"));

    let opaque_panic = tokio::spawn(async { std::panic::panic_any(7_u8) })
        .await
        .unwrap_err();
    let response = HotSwapService::handle_panic_error(opaque_panic)
        .await
        .unwrap();
    assert!(
        body_text(response)
            .await
            .contains("Unhandled application panic")
    );

    let cancelled = tokio::spawn(std::future::pending::<()>());
    cancelled.abort();
    let response = HotSwapService::handle_panic_error(cancelled.await.unwrap_err())
        .await
        .unwrap();
    assert!(
        body_text(response)
            .await
            .contains("Request task was cancelled or aborted")
    );
}

#[tokio::test]
async fn service_readiness_and_fallback_error_response_are_infallible() {
    let mut service = service(axum::Router::new());
    let readiness = std::future::poll_fn(|context| {
        let result =
            <HotSwapService as Service<axum::extract::Request>>::poll_ready(&mut service, context);
        match result {
            Poll::Ready(result) => Poll::Ready(result),
            Poll::Pending => Poll::Pending,
        }
    })
    .await;
    assert!(readiness.is_ok());

    let response = HotSwapService::handle_oneshot_error().unwrap();
    assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
    assert!(body_text(response).await.is_empty());
}

#[tokio::test]
async fn application_lifecycle_also_gates_hot_swapped_routes() {
    let router = axum::Router::new().route("/ok", axum::routing::get(|| async { "ready" }));
    let mut service = service(router);
    let lifecycle = crate::lifecycle::ApplicationLifecycle::new();
    service.lifecycle = Some(lifecycle.clone());

    let starting = service
        .call(Request::builder().uri("/ok").body(Body::empty()).unwrap())
        .await
        .unwrap();
    assert_eq!(starting.status(), StatusCode::SERVICE_UNAVAILABLE);

    lifecycle.mark_ready().unwrap();
    let ready = service
        .call(Request::builder().uri("/ok").body(Body::empty()).unwrap())
        .await
        .unwrap();
    assert_eq!(ready.status(), StatusCode::OK);

    crate::lifecycle::tests::bounded_begin_draining(&lifecycle).unwrap();
    let draining = service
        .call(Request::builder().uri("/ok").body(Body::empty()).unwrap())
        .await
        .unwrap();
    assert_eq!(draining.status(), StatusCode::SERVICE_UNAVAILABLE);
}