diff --git a/src-tauri/download-xray.mjs b/src-tauri/download-xray.mjs index d83c994..c7a8cc1 100644 --- a/src-tauri/download-xray.mjs +++ b/src-tauri/download-xray.mjs @@ -121,6 +121,46 @@ function extractArchive(archive, destinationDir, windowsTarget) { }; } +/// Attempts for the archive download. A release asset fetch is a network call +/// on every CI job, and a single transport error ("fetch failed") has taken +/// whole builds down. Retrying is safe because the checksum below is verified +/// on every attempt, so a truncated or substituted archive still cannot pass. +const DOWNLOAD_ATTEMPTS = 3; + +async function downloadVerifiedArchive(url, archive, expectedSha256) { + let lastError; + + for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to download Xray-core (${response.status} ${response.statusText})`, + ); + } + writeFileSync(archive, Buffer.from(await response.arrayBuffer())); + + const actual = sha256(archive); + if (actual !== expectedSha256) { + throw new Error( + `Xray-core checksum mismatch: expected ${expectedSha256}, got ${actual}`, + ); + } + return; + } catch (error) { + lastError = error; + if (attempt < DOWNLOAD_ATTEMPTS) { + console.warn( + `Xray-core download attempt ${attempt} failed (${error.message}); retrying`, + ); + await new Promise((resolve) => setTimeout(resolve, attempt * 2000)); + } + } + } + + throw lastError; +} + export async function downloadXray(target = requestedTarget()) { const asset = XRAY_ASSETS[target]; if (!asset) { @@ -157,20 +197,11 @@ export async function downloadXray(target = requestedTarget()) { const scratch = mkdtempSync(join(tmpdir(), "donut-xray-")); try { const archive = join(scratch, basename(asset.name)); - const response = await fetch(xrayDownloadUrl(asset.name)); - if (!response.ok) { - throw new Error( - `Failed to download Xray-core (${response.status} ${response.statusText})`, - ); - } - writeFileSync(archive, Buffer.from(await response.arrayBuffer())); - - const actual = sha256(archive); - if (actual !== asset.sha256) { - throw new Error( - `Xray-core checksum mismatch: expected ${asset.sha256}, got ${actual}`, - ); - } + await downloadVerifiedArchive( + xrayDownloadUrl(asset.name), + archive, + asset.sha256, + ); const extracted = extractArchive(archive, scratch, windowsTarget); if (!existsSync(extracted.binary) || !existsSync(extracted.license)) { diff --git a/src-tauri/tests/vpn_integration.rs b/src-tauri/tests/vpn_integration.rs index e7416a4..06f9f65 100644 --- a/src-tauri/tests/vpn_integration.rs +++ b/src-tauri/tests/vpn_integration.rs @@ -627,6 +627,37 @@ async fn cleanup_runtime() { test_harness::stop_vpn_servers().await; } +/// Request through the proxy until the tunnel behind it actually carries the +/// traffic, or the deadline passes. +/// +/// Returns the last response either way, so a genuine failure still asserts +/// against the real body rather than a timeout message. +async fn wait_for_tunnel( + local_port: u16, + url: &str, + host_header: &str, + timeout: Duration, +) -> Result> { + let deadline = tokio::time::Instant::now() + timeout; + + loop { + let last = match raw_http_request_via_proxy(local_port, url, host_header).await { + Ok(response) => { + if response.contains("WG-TUNNEL-OK") { + return Ok(response); + } + response + } + Err(e) => format!("request error: {e}"), + }; + + if tokio::time::Instant::now() >= deadline { + return Ok(last); + } + sleep(Duration::from_millis(250)).await; + } +} + async fn wait_for_file( path: &std::path::Path, timeout: Duration, @@ -661,12 +692,22 @@ async fn run_proxy_feature_suite( let proxy = start_proxy_with_upstream(binary_path, &vpn_upstream, &[], None, Some(&profile_id)).await?; - sleep(Duration::from_millis(500)).await; - let internal_url = format!("http://{}:8080/", server_tunnel_ip); let internal_host = format!("{}:8080", server_tunnel_ip); - let http_response = - raw_http_request_via_proxy(proxy.local_port, &internal_url, &internal_host).await?; + + // The proxy answers as soon as it is listening, but the route behind it is + // not ready until the WireGuard handshake completes and the in-tunnel server + // accepts. A fixed sleep raced that on a loaded runner and came back + // `502 Bad Gateway`, which is the tunnel not being up yet rather than + // anything under test being wrong. Poll to a deadline instead, the same way + // `wait_for_file` does below. + let http_response = wait_for_tunnel( + proxy.local_port, + &internal_url, + &internal_host, + Duration::from_secs(20), + ) + .await?; assert!( http_response.contains("WG-TUNNEL-OK"), "HTTP traffic through donut-proxy+VPN tunnel should succeed, got: {}",