fix(images): reuse a shared HTTP client to stop fd exhaustion

Discover and other image-heavy views created a fresh
reqwest::blocking::Client per image (and per notification artwork),
exhausting the process file-descriptor limit and crashing the app with
"Too many open files". Reuse a single shared client across downloads.

cc
This commit is contained in:
vicrodh committed 2026-06-05 01:09:58 -06:00
1 parent 0cefe1ee80
commit adf0961f9d
2 files changed
+37 -3

No files matched your search

+16 -1
View File
@@ -1039,6 +1039,21 @@ fn v2_resolve_local_artwork(url: &str) -> Option<PathBuf> {
None
}
/// Shared blocking HTTP client for notification artwork downloads. Reused across
/// track changes so a long listening session doesn't leak a file descriptor per
/// track via a fresh `reqwest::blocking::Client::new()` (EMFILE — see the image
/// cache for the same fix on the Discover grid).
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn notification_http_client() -> &'static reqwest::blocking::Client {
static CLIENT: std::sync::OnceLock<reqwest::blocking::Client> = std::sync::OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::blocking::Client::builder()
.pool_max_idle_per_host(2)
.build()
.expect("failed to build shared notification HTTP client")
})
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub fn v2_cache_notification_artwork(url: &str) -> Result<PathBuf, String> {
if let Some(local_path) = v2_resolve_local_artwork(url) {
@@ -1056,7 +1071,7 @@ pub fn v2_cache_notification_artwork(url: &str) -> Result<PathBuf, String> {
return Ok(cache_path);
}
let response = reqwest::blocking::Client::new()
let response = notification_http_client()
.get(url)
.header("User-Agent", "Mozilla/5.0")
.timeout(std::time::Duration::from_secs(5))
+21 -2
View File
@@ -1,12 +1,31 @@
use std::sync::OnceLock;
use tauri::State;
/// Shared blocking HTTP client for image downloads.
///
/// Discover (and any grid view) loads dozens of images at once, each through
/// `v2_get_cached_image`. Building a fresh `reqwest::blocking::Client` per image
/// opens a new connection pool + file descriptors every time and exhausts the
/// process fd limit — the `Os { code: 24, "Too many open files" }` (EMFILE)
/// panic seen in `Client::new()`. One reused client fixes the leak and enables
/// connection keep-alive across downloads.
fn image_http_client() -> &'static reqwest::blocking::Client {
static CLIENT: OnceLock<reqwest::blocking::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::blocking::Client::builder()
.pool_max_idle_per_host(4)
.build()
.expect("failed to build shared image HTTP client")
})
}
/// Download an image via reqwest (rustls) and write to a temp file.
/// Returns a file:// URL that WebKit can load without needing system TLS.
/// Used as fallback when the image cache service is unavailable.
async fn download_image_to_temp(url: &str) -> Result<String, String> {
let url_owned = url.to_string();
let bytes = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
let response = reqwest::blocking::Client::new()
let response = image_http_client()
.get(&url_owned)
.timeout(std::time::Duration::from_secs(10))
.send()
@@ -110,7 +129,7 @@ pub async fn v2_get_cached_image(
// Download the image via reqwest (uses rustls — own CA bundle)
let url_clone = url.clone();
let bytes = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
let response = reqwest::blocking::Client::new()
let response = image_http_client()
.get(&url_clone)
.timeout(std::time::Duration::from_secs(10))
.send()