use std::convert::Infallible;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::axum::body::Body;
use crate::axum::http::Response;
use crate::dev_proxy::endpoint::IpcEndpoint;
use crate::dev_proxy::service::{DevProxyLayer, DevProxyService, forward};
use super::pages;
type Request = crate::axum::extract::Request<Body>;
type BoxFuture = Pin<Box<dyn Future<Output = Result<Response<Body>, Infallible>> + Send>>;
pub const DEFAULT_HOLD: Duration = Duration::from_secs(5);
const RECONNECT_INTERVAL: Duration = Duration::from_millis(20);
#[derive(Clone, Debug)]
pub enum BackendStatus {
Building,
Ready,
Failed(Arc<str>),
}
#[derive(Clone, Debug)]
pub struct BackendHandle {
status: Arc<tokio::sync::watch::Sender<BackendStatus>>,
}
impl Default for BackendHandle {
fn default() -> Self {
Self::new()
}
}
impl BackendHandle {
#[must_use]
pub fn new() -> Self {
Self {
status: Arc::new(tokio::sync::watch::Sender::new(BackendStatus::Building)),
}
}
#[must_use]
pub fn status(&self) -> BackendStatus {
self.status.borrow().clone()
}
pub fn mark_building(&self) {
self.set(BackendStatus::Building);
}
pub fn mark_ready(&self) {
self.set(BackendStatus::Ready);
}
pub fn mark_failed(&self, diagnostics: impl Into<Arc<str>>) {
self.set(BackendStatus::Failed(diagnostics.into()));
}
fn set(&self, status: BackendStatus) {
let _previous = self.status.send_replace(status);
}
async fn settle(&self, deadline: Instant) -> Settled {
let mut changes = self.status.subscribe();
loop {
let current = changes.borrow_and_update().clone();
match current {
BackendStatus::Ready => return Settled::Ready,
BackendStatus::Failed(diagnostics) => return Settled::Failed(diagnostics),
BackendStatus::Building => {}
}
let changed = tokio::time::timeout_at(deadline.into(), changes.changed()).await;
match changed {
Ok(Ok(())) => {}
Ok(Err(_)) | Err(_) => return Settled::StillBuilding,
}
}
}
}
enum Settled {
Ready,
Failed(Arc<str>),
StillBuilding,
}
#[derive(Clone)]
pub struct BackendService {
endpoint: Arc<IpcEndpoint>,
backend: BackendHandle,
hold: Duration,
}
impl tower::Service<Request> for BackendService {
type Response = Response<Body>;
type Error = Infallible;
type Future = BoxFuture;
fn poll_ready(
&mut self,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
std::task::Poll::Ready(Ok(()))
}
fn call(&mut self, req: Request) -> Self::Future {
let endpoint = Arc::clone(&self.endpoint);
let backend = self.backend.clone();
let hold = self.hold;
Box::pin(async move { Ok(hold_then_forward(endpoint, backend, hold, req).await) })
}
}
async fn hold_then_forward(
endpoint: Arc<IpcEndpoint>,
backend: BackendHandle,
hold: Duration,
req: Request,
) -> Response<Body> {
let started = Instant::now();
let deadline = started + hold;
loop {
match backend.settle(deadline).await {
Settled::Ready => {}
Settled::Failed(diagnostics) => return pages::compile_error(&diagnostics),
Settled::StillBuilding => return pages::building(started.elapsed()),
}
match endpoint.connect().await {
Ok(stream) => {
return match forward(stream, req).await {
Ok(response) => response,
Err(error) => pages::backend_gone(&error.to_string()),
};
}
Err(error) => {
if Instant::now() >= deadline {
return pages::backend_gone(&error.to_string());
}
tokio::time::sleep(RECONNECT_INTERVAL).await;
}
}
}
}
#[derive(Clone)]
pub struct Supervisor {
inner: DevProxyService<BackendService>,
}
impl Supervisor {
#[must_use]
pub fn new(
vite_endpoint: PathBuf,
app_endpoint: PathBuf,
backend: BackendHandle,
hold: Duration,
) -> Self {
use tower::Layer as _;
let application = BackendService {
endpoint: Arc::new(IpcEndpoint::new(app_endpoint)),
backend,
hold,
};
Self {
inner: DevProxyLayer::new(Some(IpcEndpoint::new(vite_endpoint))).layer(application),
}
}
pub async fn handle(&mut self, req: Request) -> Response<Body> {
match tower::Service::call(self, req).await {
Ok(response) => response,
Err(never) => match never {},
}
}
}
impl tower::Service<Request> for Supervisor {
type Response = Response<Body>;
type Error = Infallible;
type Future = BoxFuture;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request) -> Self::Future {
let clone = self.inner.clone();
let mut inner = std::mem::replace(&mut self.inner, clone);
Box::pin(async move { inner.call(req).await })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::axum::http::{Request as HttpRequest, StatusCode};
pub(super) fn endpoint(label: &str) -> PathBuf {
let pid = std::process::id();
#[cfg(windows)]
{
PathBuf::from(format!(r"\\.\pipe\arcature-dev-test-{label}-{pid}"))
}
#[cfg(unix)]
{
std::env::temp_dir().join(format!("arcature-dev-test-{label}-{pid}.sock"))
}
}
fn get(path: &str) -> Request {
HttpRequest::builder()
.uri(path)
.body(Body::empty())
.expect("test request should build")
}
pub(super) async fn body_of(response: Response<Body>) -> String {
let bytes = crate::axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("the test bodies are small and complete");
String::from_utf8_lossy(&bytes).into_owned()
}
#[tokio::test]
async fn a_request_that_outlives_the_hold_is_answered_rather_than_dropped() {
let mut supervisor = Supervisor::new(
endpoint("hold-vite"),
endpoint("hold-app"),
BackendHandle::new(),
Duration::from_millis(60),
);
let response = supervisor.handle(get("/dashboard")).await;
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert!(body_of(response).await.contains("rebuilding"));
}
#[tokio::test]
async fn a_failed_build_reaches_the_browser_instead_of_a_wait() {
let backend = BackendHandle::new();
backend.mark_failed("error[E0308]: expected `Vec<u8>`, found `&str`");
let mut supervisor = Supervisor::new(
endpoint("failed-vite"),
endpoint("failed-app"),
backend,
Duration::from_secs(60),
);
let response = supervisor.handle(get("/")).await;
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body = body_of(response).await;
assert!(body.contains("E0308"), "{body}");
assert!(body.contains("<u8>"), "{body}");
}
#[tokio::test]
async fn readiness_that_turns_out_to_be_stale_is_reported_as_a_dead_backend() {
let backend = BackendHandle::new();
backend.mark_ready();
let mut supervisor = Supervisor::new(
endpoint("stale-vite"),
endpoint("stale-app"),
backend,
Duration::from_millis(60),
);
let response = supervisor.handle(get("/")).await;
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
}
#[test]
fn a_fresh_handle_starts_out_building_so_first_boot_holds_too() {
assert!(matches!(
BackendHandle::new().status(),
BackendStatus::Building
));
}
#[test]
fn a_handle_reports_the_last_status_written_to_it() {
let backend = BackendHandle::new();
backend.mark_ready();
assert!(matches!(backend.status(), BackendStatus::Ready));
backend.mark_failed("boom");
assert!(matches!(backend.status(), BackendStatus::Failed(_)));
backend.mark_building();
assert!(matches!(backend.status(), BackendStatus::Building));
}
#[test]
fn a_clone_of_a_handle_sees_the_original_writes() {
let backend = BackendHandle::new();
let observer = backend.clone();
backend.mark_ready();
assert!(matches!(observer.status(), BackendStatus::Ready));
}
#[test]
fn a_status_written_while_nothing_is_waiting_is_not_thrown_away() {
let backend = BackendHandle::new();
assert_eq!(backend.status.receiver_count(), 0);
backend.mark_ready();
assert!(matches!(backend.status(), BackendStatus::Ready));
backend.mark_failed("error[E0308]: mismatched types");
match backend.status() {
BackendStatus::Failed(diagnostics) => assert!(diagnostics.contains("E0308")),
other => panic!("a write made with no receivers was dropped: {other:?}"),
}
}
}
#[cfg(all(test, feature = "macros"))]
mod topology_tests {
use super::tests::{body_of, endpoint};
use super::*;
use crate::application::serve_ipc::IpcListener;
use crate::axum::http::{Request as HttpRequest, StatusCode};
use crate::axum::{Router, routing::get as route};
fn application() -> Router {
Router::new()
.route("/", route(|| async { "home" }))
.route("/api/ping", route(|| async { "pong" }))
}
fn vite() -> Router {
Router::new()
.route("/resources/js/app.tsx", route(|| async { "export {}" }))
.route("/only-vite-has-this", route(|| async { "from vite" }))
}
#[tokio::test]
async fn a_request_arriving_while_the_backend_is_down_is_answered_once_it_returns() {
let app_endpoint = endpoint("queued-app");
let backend = BackendHandle::new();
let mut supervisor = Supervisor::new(
endpoint("queued-vite"),
app_endpoint.clone(),
backend.clone(),
Duration::from_secs(10),
);
let late = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(150)).await;
let served = serve_over_ipc(&app_endpoint, application()).await;
backend.mark_ready();
served
});
let response = supervisor.handle(get("/")).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(body_of(response).await, "home");
drop(late.await.expect("the backend task should not panic"));
}
#[tokio::test]
async fn a_vite_request_is_served_while_the_backend_is_still_building() {
let vite_endpoint = endpoint("hmr-vite");
let served = serve_over_ipc(&vite_endpoint, vite()).await;
let mut supervisor = Supervisor::new(
vite_endpoint,
endpoint("hmr-app"),
BackendHandle::new(),
Duration::from_secs(3600),
);
let response = supervisor.handle(get("/resources/js/app.tsx")).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(body_of(response).await, "export {}");
drop(served);
}
#[tokio::test]
async fn both_dev_topologies_answer_the_same_request_the_same_way() {
use tower::{Layer as _, Service as _};
let vite_endpoint = endpoint("parity-vite");
let app_endpoint = endpoint("parity-app");
let vite_served = serve_over_ipc(&vite_endpoint, vite()).await;
let app_served = serve_over_ipc(&app_endpoint, application()).await;
let mut in_process = DevProxyLayer::new(Some(IpcEndpoint::new(vite_endpoint.clone())))
.layer(application().into_service::<Body>());
let backend = BackendHandle::new();
backend.mark_ready();
let mut supervised = Supervisor::new(
vite_endpoint,
app_endpoint,
backend,
Duration::from_secs(10),
);
for path in [
"/", "/api/ping", "/resources/js/app.tsx", "/only-vite-has-this", "/nobody-has-this", ] {
let direct = in_process
.call(get(path))
.await
.expect("the in-process pipeline is infallible");
let through_supervisor = supervised.handle(get(path)).await;
assert_eq!(
direct.status(),
through_supervisor.status(),
"the two topologies disagree on the status of {path}"
);
assert_eq!(
body_of(direct).await,
body_of(through_supervisor).await,
"the two topologies disagree on the body of {path}"
);
}
drop((vite_served, app_served));
}
async fn serve_over_ipc(endpoint: &std::path::Path, router: Router) -> Served {
let listener = IpcListener::bind(endpoint)
.await
.expect("the test endpoint should be creatable");
let task = tokio::spawn(async move {
let _ = axum::serve(listener, router.into_make_service()).await;
});
super::super::endpoints::wait_until_listening(endpoint, Duration::from_secs(5), || None)
.await
.expect("the test server should start listening");
Served(task)
}
struct Served(tokio::task::JoinHandle<()>);
impl Drop for Served {
fn drop(&mut self) {
self.0.abort();
}
}
fn get(path: &str) -> Request {
HttpRequest::builder()
.uri(path)
.body(Body::empty())
.expect("test request should build")
}
}