#![cfg(feature = "http-client")] //! Remote close protocol tests with a loopback HTTP boundary. Requests and responses //! are explicitly gated; Runtime lease integration belongs to workspace-server. use std::{ future::Future, sync::{ Arc, atomic::{AtomicBool, Ordering}, }, task::Poll, time::Duration, }; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::{TcpListener, TcpStream}, sync::mpsc, }; use workdir::{ Workdir, WorkdirError, WorkdirSession, http::{ OpenWorkdirSessionRequest, OpenWorkdirSessionResponse, RemoteWorkdirSession, WorkdirHttpAuthorization, WorkdirHttpAuthorizationFuture, WorkdirSessionId, }, }; struct Provider { url: reqwest::Url, requests: mpsc::UnboundedReceiver, task: tokio::task::JoinHandle<()>, } impl Drop for Provider { fn drop(&mut self) { self.task.abort(); } } impl Provider { async fn new() -> Self { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()) .parse() .unwrap(); let (tx, requests) = mpsc::unbounded_channel(); let task = tokio::spawn(async move { loop { let (mut stream, _) = listener.accept().await.unwrap(); let mut bytes = Vec::new(); while !bytes.ends_with(b"\r\n\r\n") { bytes.push(stream.read_u8().await.unwrap()); assert!(bytes.len() < 16384); } let headers = String::from_utf8(bytes).unwrap(); let length = headers .lines() .find_map(|line| { let (name, value) = line.split_once(':')?; name.eq_ignore_ascii_case("content-length") .then(|| value.trim().parse::().unwrap()) }) .unwrap_or(0); assert!(length < 16384); stream.read_exact(&mut vec![0; length]).await.unwrap(); assert!( headers.starts_with("POST /v1/working-directories/w/sessions ") || headers.starts_with("DELETE /v1/workdir-sessions/s ") ); if tx.send(stream).is_err() { break; } } }); Self { url, requests, task, } } async fn request_while(&mut self, future: std::pin::Pin<&mut F>) -> TcpStream { tokio::time::timeout(Duration::from_secs(5), async { tokio::select! { request = self.requests.recv() => request.expect("provider stopped"), _ = future => panic!("close/open completed without the expected HTTP request"), } }) .await .expect("request timed out") } async fn open(&mut self, auth: Arc) -> RemoteWorkdirSession { let future = RemoteWorkdirSession::open_with_authorization( reqwest::Client::builder() .no_proxy() .timeout(Duration::from_secs(5)) .build() .unwrap(), self.url.clone(), auth, Workdir::new("w").id().clone(), OpenWorkdirSessionRequest::default(), ); tokio::pin!(future); let stream = self.request_while(future.as_mut()).await; let body = serde_json::to_string(&OpenWorkdirSessionResponse { session_id: WorkdirSessionId::new("s").unwrap(), workdir_id: Workdir::new("w").id().clone(), capabilities: workdir::WorkdirSessionCapabilities::ALL, environment: workdir::WorkdirSessionEnvironment::root( workdir::WorkdirSessionCapabilities::ALL, ), }) .unwrap(); reply(stream, "200 OK", &body).await; future.await.unwrap() } } async fn reply(mut stream: TcpStream, status: &str, body: &str) { stream.write_all(format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); } #[derive(Debug, Default)] struct Authorization { reject_delete: AtomicBool, } impl WorkdirHttpAuthorization for Authorization { fn bearer_token( &self, method: String, _: String, _: Vec, ) -> WorkdirHttpAuthorizationFuture { let reject = method == "DELETE" && self.reject_delete.swap(false, Ordering::SeqCst); Box::pin(async move { if reject { Err(WorkdirError::Unavailable( "fixture authorization rejected".into(), )) } else { Ok("fixture-token".into()) } }) } } #[tokio::test] async fn close_authorization_failure_retries_same_session_and_confirmed_close_skips_http() { let mut provider = Provider::new().await; let auth = Arc::new(Authorization::default()); let remote = provider.open(auth.clone()).await; auth.reject_delete.store(true, Ordering::SeqCst); assert!(remote.close().await.is_err()); assert!(provider.requests.try_recv().is_err()); let retry = remote.close(); tokio::pin!(retry); let request = provider.request_while(retry.as_mut()).await; reply(request, "204 No Content", "").await; retry.await.unwrap(); remote.close().await.unwrap(); assert!(provider.requests.try_recv().is_err()); } #[derive(Debug)] struct GatedAuthorization { entered: mpsc::UnboundedSender<()>, release: std::sync::Mutex>>, } impl WorkdirHttpAuthorization for GatedAuthorization { fn bearer_token( &self, method: String, _: String, _: Vec, ) -> WorkdirHttpAuthorizationFuture { let gate = if method == "DELETE" { self.release.lock().unwrap().take() } else { None }; let entered = self.entered.clone(); Box::pin(async move { if let Some(gate) = gate { entered.send(()).unwrap(); gate.await.unwrap(); } Ok("fixture-token".into()) }) } } #[tokio::test] async fn close_cancel_during_authorization_allows_an_actual_retry() { let mut provider = Provider::new().await; let (entered, mut events) = mpsc::unbounded_channel(); let (_release, gate) = tokio::sync::oneshot::channel(); let remote = provider .open(Arc::new(GatedAuthorization { entered, release: std::sync::Mutex::new(Some(gate)), })) .await; let mut first = Box::pin(remote.close()); tokio::time::timeout(Duration::from_secs(5), async { tokio::select! { _ = events.recv() => (), _ = &mut first => panic!("close completed while authorization was gated"), } }) .await .unwrap(); drop(first); assert!(provider.requests.try_recv().is_err()); let retry = remote.close(); tokio::pin!(retry); let request = provider.request_while(retry.as_mut()).await; reply(request, "204 No Content", "").await; retry.await.unwrap(); } #[tokio::test] async fn close_http_rejection_or_lost_response_requires_another_delete() { for response in [ Some(( "503 Service Unavailable", r#"{"code":"unavailable","message":"fixture"}"#, )), None, ] { let mut provider = Provider::new().await; let remote = provider.open(Arc::new(Authorization::default())).await; let first = remote.close(); tokio::pin!(first); let request = provider.request_while(first.as_mut()).await; if let Some((status, body)) = response { reply(request, status, body).await; } else { drop(request); } // Runtime may have deleted; no response confirms it. assert!(first.await.is_err()); let retry = remote.close(); tokio::pin!(retry); let request = provider.request_while(retry.as_mut()).await; reply(request, "204 No Content", "").await; retry.await.unwrap(); } } #[tokio::test] async fn close_cancel_releases_serialization_and_waiter_must_confirm_delete() { let mut provider = Provider::new().await; let remote = provider.open(Arc::new(Authorization::default())).await; let mut first = Box::pin(remote.close()); let request = provider.request_while(first.as_mut()).await; let mut waiter = Box::pin(remote.close()); assert!( std::future::poll_fn(|cx| Poll::Ready(waiter.as_mut().poll(cx))) .await .is_pending() ); drop(first); drop(request); let request = provider.request_while(waiter.as_mut()).await; reply(request, "204 No Content", "").await; waiter.await.unwrap(); } #[tokio::test] async fn concurrent_close_waits_for_confirmation_and_retries_after_failure() { for success in [false, true] { let mut provider = Provider::new().await; let remote = provider.open(Arc::new(Authorization::default())).await; let mut first = Box::pin(remote.close()); let request = provider.request_while(first.as_mut()).await; let mut waiter = Box::pin(remote.close()); assert!( std::future::poll_fn(|cx| Poll::Ready(waiter.as_mut().poll(cx))) .await .is_pending() ); if success { reply(request, "204 No Content", "").await; first.await.unwrap(); } else { drop(request); assert!(first.await.is_err()); let request = provider.request_while(waiter.as_mut()).await; reply(request, "204 No Content", "").await; } waiter.await.unwrap(); assert!(provider.requests.try_recv().is_err()); } }