From daf449baec4c7ac357047628cf618b588d416ea5 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:36:50 -0600 Subject: [PATCH] fix(music-link): give the Tidal/proxy fast path a timeout and a shared client Both requests built bare reqwest clients with no timeout; a stalled connection parked the fast path forever and the Odesli fallback, which only runs afterwards, never got its turn. One OnceLock client with the same 10 s / 5 s budget as odesli.rs. cc --- crates/Cargo.lock | 1 + crates/qbz-music-link/Cargo.toml | 5 ++ crates/qbz-music-link/src/fast_path.rs | 72 +++++++++++++++++++++----- 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 8c136e0e7..db999b796 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -4316,6 +4316,7 @@ dependencies = [ "qbz-models", "qbz-qobuz", "reqwest", + "rustls", "serde", "serde_json", "thiserror 2.0.20", diff --git a/crates/qbz-music-link/Cargo.toml b/crates/qbz-music-link/Cargo.toml index d84043353..0d8114d9a 100644 --- a/crates/qbz-music-link/Cargo.toml +++ b/crates/qbz-music-link/Cargo.toml @@ -25,3 +25,8 @@ log = { workspace = true } # Error handling thiserror = { workspace = true } + +[dev-dependencies] +# The timeout test builds a reqwest client, which panics with "No provider +# set" until a process-level rustls CryptoProvider exists (same as qbz-qobuz). +rustls = { version = "0.23", features = ["aws_lc_rs"] } diff --git a/crates/qbz-music-link/src/fast_path.rs b/crates/qbz-music-link/src/fast_path.rs index a3142be2b..2d6a73335 100644 --- a/crates/qbz-music-link/src/fast_path.rs +++ b/crates/qbz-music-link/src/fast_path.rs @@ -10,6 +10,30 @@ use crate::detection::{spotify, MusicProvider}; /// URL hardcoded in the `src-tauri` original. No API keys are embedded here. const QBZ_PROXY_BASE: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev"; +/// Same budget as `odesli.rs` (`REQUEST_TIMEOUT`). A bare +/// `reqwest::Client::new()` has NO timeout, so a black-holed connection hung +/// the fast path forever — and since `lib.rs` only falls back to Odesli after +/// the fast path returns, the link never resolved. +const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +/// Build the fast-path client with a total deadline of `total`. +pub(crate) fn build_client(total: std::time::Duration) -> reqwest::Client { + reqwest::Client::builder() + .timeout(total) + .connect_timeout(CONNECT_TIMEOUT.min(total)) + .user_agent("QBZ/1.0.0") + .build() + .expect("reqwest client") +} + +/// The one shared client (connection pool + timeouts) for the direct +/// platform calls and the QBZ proxy. +fn client() -> &'static reqwest::Client { + static CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); + CLIENT.get_or_init(|| build_client(REQUEST_TIMEOUT)) +} + /// Try to get title+artist directly from the platform API. /// Returns None if the platform isn't supported or the request fails. pub(crate) async fn try_direct_platform_metadata( @@ -98,7 +122,7 @@ async fn try_tidal_metadata(url: &str, is_track: bool) -> Option<(String, String ); log::debug!("Link resolver: Tidal direct API: {}", api_url); - let data: serde_json::Value = reqwest::Client::new() + let data: serde_json::Value = client() .get(&api_url) .header("Authorization", format!("Bearer {}", token)) .send() @@ -134,17 +158,7 @@ async fn try_tidal_metadata(url: &str, is_track: bool) -> Option<(String, String /// Get an OAuth token from the QBZ proxy for the given platform. async fn get_proxy_token(platform: &str) -> Option { let url = format!("{}/{}/token", QBZ_PROXY_BASE, platform); - let data: serde_json::Value = reqwest::Client::builder() - .default_headers({ - let mut h = reqwest::header::HeaderMap::new(); - h.insert( - reqwest::header::USER_AGENT, - reqwest::header::HeaderValue::from_static("QBZ/1.0.0"), - ); - h - }) - .build() - .ok()? + let data: serde_json::Value = client() .get(&url) .send() .await @@ -156,3 +170,37 @@ async fn get_proxy_token(platform: &str) -> Option { .and_then(|v| v.as_str()) .map(|v| v.to_string()) } + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + /// A silent server (accepts, never answers) must not park the fast path: + /// the Odesli fallback only runs after it returns. + #[tokio::test] + async fn fast_path_client_gives_up_on_a_silent_server() { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/tidal/token", listener.local_addr().unwrap()); + let hold = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + tokio::time::sleep(Duration::from_secs(30)).await; + drop(socket); + }); + + let started = Instant::now(); + let err = super::build_client(Duration::from_millis(400)) + .get(&url) + .send() + .await + .unwrap_err(); + + assert!(err.is_timeout(), "expected a timeout, got: {err}"); + assert!( + started.elapsed() < Duration::from_secs(5), + "took {:?}", + started.elapsed() + ); + hold.abort(); + } +}