Utopian CI setup phase 2: Better HTTP abstractions (#61)

This commit is contained in:
Miles Wirht
2026-06-14 21:58:25 -04:00
committed by GitHub
parent 64318f59f3
commit bf71c690a3
17 changed files with 196 additions and 1164 deletions
Generated
+34 -978
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -49,7 +49,7 @@ derive_more = { version = "2.1.1", features = [
] }
futures = "0.3.32"
futures-concurrency = "7.7.1"
http_client = { git = "https://github.com/zed-industries/zed", rev = "876ec5a8a074ba83cce2129ed4d76b59c05a37e9" }
http = "1.3"
image = "0.25.1"
inventory = "0.3.19"
itertools = "0.14.0"
@@ -59,7 +59,7 @@ postage = { version = "0.5", features = ["futures-traits"] }
proptest = { git = "https://github.com/proptest-rs/proptest", rev = "3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b", features = ["attr-macro"] }
chrono = { version = "0.4", features = ["serde"] }
profiling = "1"
rand = "0.9"
rand = "0.9.4"
regex = "1.5"
refineable = { git = "https://github.com/zed-industries/zed", rev = "876ec5a8a074ba83cce2129ed4d76b59c05a37e9" }
scheduler = { git = "https://github.com/zed-industries/zed", rev = "876ec5a8a074ba83cce2129ed4d76b59c05a37e9" }
@@ -70,7 +70,7 @@ serde_json = { version = "1.0.144", features = ["preserve_order", "raw_value"] }
slotmap = "1.0.6"
smallvec = { version = "1.6", features = ["union", "const_new"] }
async-channel = "2.5.0"
stacksafe = "0.1"
stacksafe = "1.0"
strum = { version = "0.27.2", features = ["derive"] }
sum_tree = { git = "https://github.com/zed-industries/zed" }
thiserror = "2.0.12"
@@ -91,7 +91,7 @@ metal = "0.33"
scap = { git = "https://github.com/zed-industries/scap", rev = "4afea48c3b002197176fb19cd0f9b180dd36eaac", default-features = false, package = "zed-scap", version = "0.0.8-zed" }
env_logger = "0.11"
unicode-segmentation = "1.10"
reqwest_client = { git = "https://github.com/zed-industries/zed", rev = "876ec5a8a074ba83cce2129ed4d76b59c05a37e9" }
wasm-bindgen = "0.2.120"
heck = "0.5"
proc-macro2 = "1.0.93"
+2 -5
View File
@@ -21,7 +21,6 @@ default = ["font-kit", "wayland", "x11", "windows-manifest"]
test-support = [
"leak-detection",
"collections/test-support",
"http_client/test-support",
"wayland",
"x11",
"proptest",
@@ -59,7 +58,7 @@ futures.workspace = true
futures-concurrency.workspace = true
gpui_macros.workspace = true
gpui_shared_string.workspace = true
http_client.workspace = true
http.workspace = true
image.workspace = true
inventory.workspace = true
itertools.workspace = true
@@ -159,9 +158,7 @@ rand.workspace = true
scheduler = { workspace = true, features = ["test-support"] }
unicode-segmentation = { workspace = true }
[target.'cfg(not(target_family = "wasm"))'.dev-dependencies]
http_client = { workspace = true, features = ["test-support"] }
reqwest_client = { workspace = true, features = ["test-support"] }
[target.'cfg(target_family = "wasm")'.dev-dependencies]
wasm-bindgen = { workspace = true }
+2 -3
View File
@@ -8,7 +8,7 @@ use gpui::{
Bounds, Context, ImageSource, KeyBinding, Menu, MenuItem, Point, SharedString, SharedUri,
TitlebarOptions, Window, WindowBounds, WindowOptions,
};
use reqwest_client::ReqwestClient;
use std::sync::Arc;
struct Assets {
base: PathBuf,
@@ -155,8 +155,7 @@ fn main() {
base: manifest_dir.join("examples"),
})
.run(move |cx: &mut App| {
let http_client = ReqwestClient::user_agent("gpui example").unwrap();
cx.set_http_client(Arc::new(http_client));
cx.set_http_client(Arc::new(gpui::http_client::BlockedHttpClient::new()));
cx.activate(true);
cx.on_action(|_: &Quit, cx| cx.quit());
+1 -24
View File
@@ -29,7 +29,7 @@ pub use entity_map::*;
use gpui_util::{ResultExt, debug_panic};
#[cfg(any(test, feature = "test-support"))]
pub use headless_app_context::*;
use http_client::{HttpClient, Url};
use crate::http_client::{HttpClient, NullHttpClient};
use smallvec::SmallVec;
#[cfg(any(test, feature = "test-support"))]
pub use test_app::*;
@@ -2710,30 +2710,7 @@ pub struct KeystrokeEvent {
pub context_stack: Vec<KeyContext>,
}
struct NullHttpClient;
impl HttpClient for NullHttpClient {
fn send(
&self,
_req: http_client::Request<http_client::AsyncBody>,
) -> futures::future::BoxFuture<
'static,
anyhow::Result<http_client::Response<http_client::AsyncBody>>,
> {
async move {
anyhow::bail!("No HttpClient available");
}
.boxed()
}
fn user_agent(&self) -> Option<&http_client::http::HeaderValue> {
None
}
fn proxy(&self) -> Option<&Url> {
None
}
}
/// A mutable reference to an entity owned by GPUI
pub struct GpuiBorrow<'a, T> {
+1 -1
View File
@@ -87,7 +87,7 @@ impl HeadlessAppContext {
);
let text_system = Arc::new(TextSystem::new(platform_text_system));
let http_client = http_client::FakeHttpClient::with_404_response();
let http_client = crate::http_client::FakeHttpClient::with_404_response();
let app = App::new_app(platform, asset_source, http_client);
app.borrow_mut().mode = GpuiMode::test();
+1 -1
View File
@@ -88,7 +88,7 @@ impl TestApp {
),
None => TestPlatform::new(background_executor.clone(), foreground_executor.clone()),
};
let http_client = http_client::FakeHttpClient::with_404_response();
let http_client = crate::http_client::FakeHttpClient::with_404_response();
let text_system = Arc::new(TextSystem::new(
platform_text_system.unwrap_or_else(|| platform.text_system.clone()),
));
+1 -1
View File
@@ -129,7 +129,7 @@ impl TestAppContext {
let foreground_executor = ForegroundExecutor::new(arc_dispatcher);
let platform = TestPlatform::new(background_executor.clone(), foreground_executor.clone());
let asset_source = Arc::new(());
let http_client = http_client::FakeHttpClient::with_404_response();
let http_client = crate::http_client::FakeHttpClient::with_404_response();
let text_system = Arc::new(TextSystem::new(platform.text_system()));
let app = App::new_app(platform.clone(), asset_source, http_client);
+1 -1
View File
@@ -71,7 +71,7 @@ impl VisualTestAppContext {
let text_system = Arc::new(TextSystem::new(platform.text_system()));
let http_client = http_client::FakeHttpClient::with_404_response();
let http_client = crate::http_client::FakeHttpClient::with_404_response();
let mut app = App::new_app(platform.clone(), asset_source, http_client);
app.borrow_mut().mode = GpuiMode::test();
+19 -21
View File
@@ -618,27 +618,25 @@ impl Asset for ImageAssetLoader {
async move {
let bytes = match source.clone() {
Resource::Path(uri) => fs::read(uri.as_ref())?,
Resource::Uri(uri) => {
use anyhow::Context as _;
use futures::AsyncReadExt as _;
Resource::Uri(uri) => {
use anyhow::Context as _;
let mut response = client
.get(uri.as_ref(), ().into(), true)
.await
.with_context(|| format!("loading image asset from {uri:?}"))?;
let mut body = Vec::new();
response.body_mut().read_to_end(&mut body).await?;
if !response.status().is_success() {
let mut body = String::from_utf8_lossy(&body).into_owned();
let first_line = body.lines().next().unwrap_or("").trim_end();
body.truncate(first_line.len());
return Err(ImageCacheError::BadStatus {
uri,
status: response.status(),
body,
});
}
body
let response = client
.get(uri.as_ref(), true)
.await
.with_context(|| format!("loading image asset from {uri:?}"))?;
if !response.status.is_success() {
let mut error_body =
String::from_utf8_lossy(&response.body).into_owned();
let first_line = error_body.lines().next().unwrap_or("").trim_end();
error_body.truncate(first_line.len());
return Err(ImageCacheError::BadStatus {
uri,
status: response.status,
body: error_body,
});
}
response.body
}
Resource::Embedded(path) => {
let data = asset_source.load(&path).ok().flatten();
@@ -763,7 +761,7 @@ pub enum ImageCacheError {
/// The URI of the image.
uri: SharedUri,
/// The HTTP status code.
status: http_client::StatusCode,
status: http::StatusCode,
/// The HTTP response body.
body: String,
},
+1 -1
View File
@@ -464,7 +464,7 @@ mod test {
let platform = TestPlatform::new(background_executor.clone(), foreground_executor);
let asset_source = Arc::new(());
let http_client = http_client::FakeHttpClient::with_404_response();
let http_client = crate::http_client::FakeHttpClient::with_404_response();
let app = App::new_app(platform, asset_source, http_client);
(dispatcher, background_executor, app)
+2 -1
View File
@@ -100,7 +100,8 @@ pub use gpui_macros::{
};
pub use gpui_shared_string::*;
pub use gpui_util::arc_cow::ArcCow;
pub use http_client;
/// HTTP client abstraction for making requests.
pub mod http_client;
pub use input::*;
pub use inspector::*;
pub use interactive::*;
+105
View File
@@ -0,0 +1,105 @@
use futures::future::BoxFuture;
use http::StatusCode;
/// A simple HTTP response.
pub struct HttpResponse {
/// The HTTP status code.
pub status: StatusCode,
/// The response body bytes.
pub body: Vec<u8>,
}
/// A trait for making HTTP requests.
pub trait HttpClient: 'static + Send + Sync {
/// Perform a GET request and return the full response.
fn get(
&self,
url: &str,
follow_redirects: bool,
) -> BoxFuture<'static, anyhow::Result<HttpResponse>>;
}
/// An HTTP client that always returns an error.
pub struct NullHttpClient;
impl HttpClient for NullHttpClient {
fn get(
&self,
_url: &str,
_follow_redirects: bool,
) -> BoxFuture<'static, anyhow::Result<HttpResponse>> {
Box::pin(async { anyhow::bail!("No HttpClient available") })
}
}
/// An HTTP client that blocks all requests.
pub struct BlockedHttpClient;
impl BlockedHttpClient {
/// Create a new `BlockedHttpClient`.
pub fn new() -> Self {
Self
}
}
impl Default for BlockedHttpClient {
fn default() -> Self {
Self
}
}
impl HttpClient for BlockedHttpClient {
fn get(
&self,
_url: &str,
_follow_redirects: bool,
) -> BoxFuture<'static, anyhow::Result<HttpResponse>> {
Box::pin(async {
Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"BlockedHttpClient disallowed request",
)
.into())
})
}
}
/// A fake HTTP client for testing.
#[cfg(any(test, feature = "test-support"))]
pub struct FakeHttpClient {
status: StatusCode,
}
#[cfg(any(test, feature = "test-support"))]
impl FakeHttpClient {
/// Create a fake client that returns 404 responses.
pub fn with_404_response() -> std::sync::Arc<dyn HttpClient> {
std::sync::Arc::new(Self {
status: StatusCode::NOT_FOUND,
})
}
/// Create a fake client that returns 200 responses.
pub fn with_200_response() -> std::sync::Arc<dyn HttpClient> {
std::sync::Arc::new(Self {
status: StatusCode::OK,
})
}
}
#[cfg(any(test, feature = "test-support"))]
impl HttpClient for FakeHttpClient {
fn get(
&self,
_url: &str,
_follow_redirects: bool,
) -> BoxFuture<'static, anyhow::Result<HttpResponse>> {
let status = self.status;
Box::pin(async move {
Ok(HttpResponse {
status,
body: Vec::new(),
})
})
}
}
-1
View File
@@ -60,7 +60,6 @@ image.workspace = true
futures.workspace = true
gpui.workspace = true
gpui_wgpu = { workspace = true, optional = true, features = ["font-kit"] }
http_client.workspace = true
itertools.workspace = true
libc.workspace = true
log.workspace = true
@@ -15,7 +15,7 @@ use calloop::{
use calloop_wayland_source::WaylandSource;
use collections::HashMap;
use filedescriptor::Pipe;
use http_client::Url;
use url::Url;
use smallvec::SmallVec;
use util::ResultExt as _;
use wayland_backend::client::ObjectId;
+1 -1
View File
@@ -7,7 +7,7 @@ use calloop::{
use collections::HashMap;
use core::str;
use gpui::{Capslock, TaskTiming, profiler};
use http_client::Url;
use url::Url;
use log::Level;
use smallvec::SmallVec;
use std::{
+20 -120
View File
@@ -1,6 +1,5 @@
use anyhow::anyhow;
use futures::AsyncReadExt as _;
use http_client::{AsyncBody, HttpClient, RedirectPolicy};
use gpui::http_client::{HttpClient, HttpResponse};
use std::future::Future;
use std::pin::Pin;
use std::task::Poll;
@@ -13,58 +12,29 @@ extern "C" {
fn global_fetch(input: &web_sys::Request) -> Result<js_sys::Promise, JsValue>;
}
pub struct FetchHttpClient {
user_agent: Option<http_client::http::header::HeaderValue>,
}
pub struct FetchHttpClient;
impl Default for FetchHttpClient {
fn default() -> Self {
Self { user_agent: None }
Self
}
}
#[cfg(feature = "multithreaded")]
impl FetchHttpClient {
/// # Safety
///
/// The caller must ensure that the created `FetchHttpClient` is only used in a single thread environment.
pub unsafe fn new() -> Self {
Self::default()
}
/// # Safety
///
/// The caller must ensure that the created `FetchHttpClient` is only used in a single thread environment.
pub unsafe fn with_user_agent(user_agent: &str) -> anyhow::Result<Self> {
Ok(Self {
user_agent: Some(http_client::http::header::HeaderValue::from_str(
user_agent,
)?),
})
Self
}
}
#[cfg(not(feature = "multithreaded"))]
impl FetchHttpClient {
pub fn new() -> Self {
Self::default()
}
pub fn with_user_agent(user_agent: &str) -> anyhow::Result<Self> {
Ok(Self {
user_agent: Some(http_client::http::header::HeaderValue::from_str(
user_agent,
)?),
})
Self
}
}
/// Wraps a `!Send` future to satisfy the `Send` bound on `BoxFuture`.
///
/// Safety: only valid in WASM contexts where the `FetchHttpClient` is
/// confined to a single thread (guaranteed by the caller via unsafe
/// constructors when `multithreaded` is enabled, or by the absence of
/// threads when it is not).
struct AssertSend<F>(F);
unsafe impl<F> Send for AssertSend<F> {}
@@ -73,64 +43,29 @@ impl<F: Future> Future for AssertSend<F> {
type Output = F::Output;
fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
// Safety: pin projection for a single-field newtype wrapper.
let inner = unsafe { self.map_unchecked_mut(|this| &mut this.0) };
inner.poll(cx)
}
}
impl HttpClient for FetchHttpClient {
fn user_agent(&self) -> Option<&http_client::http::header::HeaderValue> {
self.user_agent.as_ref()
}
fn proxy(&self) -> Option<&http_client::Url> {
None
}
fn send(
fn get(
&self,
req: http_client::http::Request<AsyncBody>,
) -> futures::future::BoxFuture<'static, anyhow::Result<http_client::http::Response<AsyncBody>>>
{
let (parts, body) = req.into_parts();
url: &str,
follow_redirects: bool,
) -> futures::future::BoxFuture<'static, anyhow::Result<HttpResponse>> {
let url = url.to_string();
Box::pin(AssertSend(async move {
let body_bytes = read_body_to_bytes(body).await?;
let init = web_sys::RequestInit::new();
init.set_method(parts.method.as_str());
init.set_method("GET");
if let Some(redirect_policy) = parts.extensions.get::<RedirectPolicy>() {
match redirect_policy {
RedirectPolicy::NoFollow => {
init.set_redirect(web_sys::RequestRedirect::Manual);
}
RedirectPolicy::FollowLimit(_) | RedirectPolicy::FollowAll => {
init.set_redirect(web_sys::RequestRedirect::Follow);
}
}
if !follow_redirects {
init.set_redirect(web_sys::RequestRedirect::Manual);
}
if let Some(ref bytes) = body_bytes {
let uint8array = js_sys::Uint8Array::from(bytes.as_slice());
init.set_body(uint8array.as_ref());
}
let url = parts.uri.to_string();
let request = web_sys::Request::new_with_str_and_init(&url, &init)
.map_err(|error| anyhow!("failed to create fetch Request: {error:?}"))?;
let request_headers = request.headers();
for (name, value) in &parts.headers {
let value_str = value
.to_str()
.map_err(|_| anyhow!("non-ASCII header value for {name}"))?;
request_headers
.set(name.as_str(), value_str)
.map_err(|error| anyhow!("failed to set header {name}: {error:?}"))?;
}
let promise = global_fetch(&request)
.map_err(|error| anyhow!("fetch threw an error: {error:?}"))?;
let response_value = wasm_bindgen_futures::JsFuture::from(promise)
@@ -141,35 +76,9 @@ impl HttpClient for FetchHttpClient {
.dyn_into()
.map_err(|error| anyhow!("fetch result is not a Response: {error:?}"))?;
let status = web_response.status();
let mut builder = http_client::http::Response::builder().status(status);
let status_code = http::StatusCode::from_u16(web_response.status())
.map_err(|_| anyhow!("invalid status code"))?;
// `Headers` is a JS iterable yielding `[name, value]` pairs.
// `js_sys::Array::from` calls `Array.from()` which accepts any iterable.
let header_pairs = js_sys::Array::from(&web_response.headers());
for index in 0..header_pairs.length() {
match header_pairs.get(index).dyn_into::<js_sys::Array>() {
Ok(pair) => match (pair.get(0).as_string(), pair.get(1).as_string()) {
(Some(name), Some(value)) => {
builder = builder.header(name, value);
}
(name, value) => {
log::warn!(
"skipping response header at index {index}: \
name={name:?}, value={value:?}"
);
}
},
Err(entry) => {
log::warn!("skipping non-array header entry at index {index}: {entry:?}");
}
}
}
// The entire response body is eagerly buffered into memory via
// `arrayBuffer()`. The Fetch API does not expose a synchronous
// streaming interface; streaming would require `ReadableStream`
// interop which is significantly more complex.
let body_promise = web_response
.array_buffer()
.map_err(|error| anyhow!("failed to initiate response body read: {error:?}"))?;
@@ -179,21 +88,12 @@ impl HttpClient for FetchHttpClient {
let array_buffer: js_sys::ArrayBuffer = body_value
.dyn_into()
.map_err(|error| anyhow!("response body is not an ArrayBuffer: {error:?}"))?;
let response_bytes = js_sys::Uint8Array::new(&array_buffer).to_vec();
let body = js_sys::Uint8Array::new(&array_buffer).to_vec();
builder
.body(AsyncBody::from(response_bytes))
.map_err(|error| anyhow!(error))
Ok(HttpResponse {
status: status_code,
body,
})
}))
}
}
async fn read_body_to_bytes(mut body: AsyncBody) -> anyhow::Result<Option<Vec<u8>>> {
let mut buffer = Vec::new();
body.read_to_end(&mut buffer).await?;
if buffer.is_empty() {
Ok(None)
} else {
Ok(Some(buffer))
}
}