merge: bring v1.2.15 Tauri fixes (shared HTTP client / fd exhaustion, qobuz bundle-token cache + bounded fetch) into slint-mvp

cc
This commit is contained in:
vicrodh committed 2026-06-05 15:11:48 -06:00
commit 1072195740
18 files changed
+371 -35

No files matched your search

+47 -4
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use tokio::sync::RwLock;
use super::auth::{get_timestamp, parse_login_response, sign_get_favorites, sign_get_file_url};
use super::bundle::{extract_bundle_tokens, BundleTokens};
use super::bundle::BundleTokens;
use super::endpoints::{self, paths};
use super::error::{ApiError, Result};
use super::models::*;
@@ -40,6 +40,9 @@ impl QobuzClient {
let http = Client::builder()
.user_agent(USER_AGENT)
.cookie_store(true)
// Bound the TCP connect phase so a dead route can't hang startup.
// Body-read time is unaffected, so streaming reads are fine.
.connect_timeout(std::time::Duration::from_secs(10))
.build()?;
Ok(Self {
@@ -51,10 +54,50 @@ impl QobuzClient {
})
}
/// Initialize client by extracting bundle tokens
/// Initialize client by extracting bundle tokens.
///
/// Shares the on-disk token cache with the qbz_qobuz core client (same
/// `~/.cache/qbz/bundle_tokens.json`), so a cold start downloads Qobuz's
/// ~7 MB bundle at most once across both clients, and warm starts never
/// block the UI on it. On a warm start the cached tokens are applied
/// immediately and refreshed in the background (re-downloading only if
/// Qobuz rotated the bundle version).
pub async fn init(&self) -> Result<()> {
let tokens = extract_bundle_tokens(&self.http).await?;
*self.tokens.write().await = Some(tokens);
if let Some(cached) = qbz_qobuz::bundle::load_cached_bundle() {
let version = cached.bundle_version.clone();
log::info!("[Bundle] (api) Using cached tokens (version {})", version);
*self.tokens.write().await = Some(BundleTokens {
app_id: cached.app_id,
secrets: cached.secrets,
private_key: cached.private_key,
});
let http = self.http.clone();
let tokens_arc = Arc::clone(&self.tokens);
tokio::spawn(async move {
if let Some(fresh) =
qbz_qobuz::bundle::refresh_bundle_if_changed(&http, &version).await
{
*tokens_arc.write().await = Some(BundleTokens {
app_id: fresh.app_id,
secrets: fresh.secrets,
private_key: fresh.private_key,
});
log::info!("[Bundle] (api) Background refresh applied rotated tokens");
}
});
return Ok(());
}
log::info!("[Bundle] (api) No cached tokens, extracting from Qobuz...");
let fresh = qbz_qobuz::bundle::extract_and_cache_bundle_tokens(&self.http)
.await
.map_err(|e| ApiError::BundleExtractionError(e.to_string()))?;
*self.tokens.write().await = Some(BundleTokens {
app_id: fresh.app_id,
secrets: fresh.secrets,
private_key: fresh.private_key,
});
Ok(())
}
+16 -1
View File
@@ -1027,6 +1027,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) {
@@ -1044,7 +1059,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()
+7 -1
View File
@@ -53,8 +53,14 @@ pub async fn runtime_bootstrap(
log::info!("[Runtime] Bootstrap starting...");
// Step 1: Initialize API client (bundle tokens)
// Step 1: Initialize API client (bundle tokens).
// On a cold start (no cached tokens) this must download Qobuz's ~7 MB bundle,
// which can take many seconds when their CDN is slow — tell the frontend so it
// shows a "connecting to Qobuz" state instead of a frozen splash.
{
if qbz_qobuz::bundle::load_cached_bundle().is_none() {
let _ = app.emit("runtime:event", RuntimeEvent::BundleFetchStarted);
}
let client = app_state.client.read().await;
match client.init().await {
Ok(_) => {