feat:移除了弹窗,服务器添加sls
This commit is contained in:
commit
6a295f9a7a
4082 files changed
+1322534
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,623 @@
|
||||
//! Shared per-authority HTTP/2 connections for file downloads.
|
||||
//!
|
||||
//! `reqwest`'s connection pool opens a fresh TCP+TLS connection for every
|
||||
//! request that arrives while no idle connection is available, so a batch of
|
||||
//! concurrent downloads to one CDN costs one handshake per file. This module
|
||||
//! instead maintains a long-lived HTTP/2 connection per authority and
|
||||
//! multiplexes every download as a separate stream over it (`SendRequest` is
|
||||
//! cheap to clone and each clone opens an independent stream). Asset batches
|
||||
//! may lazily add one sibling under sustained saturation; large files can also
|
||||
//! split into range streams over their shared connection.
|
||||
|
||||
use bytes::Bytes;
|
||||
use h2::client::SendRequest;
|
||||
use rustls::ClientConfig;
|
||||
use rustls_pki_types::ServerName;
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, Once};
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use tokio::sync::Notify;
|
||||
use tokio_rustls::TlsConnector;
|
||||
|
||||
use crate::util::fetch::DownloadRoute;
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const TLS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const DNS_PREWARM_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const STREAM_READY_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const IDLE_EVICTION_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
||||
const CONNECTION_WAIT_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub(crate) enum H2ConnectFailureKind {
|
||||
Tcp,
|
||||
Tls,
|
||||
Protocol,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct H2ConnectError {
|
||||
pub(crate) kind: H2ConnectFailureKind,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for H2ConnectError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(&self.detail)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for H2ConnectError {}
|
||||
|
||||
impl H2ConnectError {
|
||||
fn new(kind: H2ConnectFailureKind, detail: String) -> Self {
|
||||
Self { kind, detail }
|
||||
}
|
||||
}
|
||||
|
||||
/// A live shared HTTP/2 connection to one authority.
|
||||
pub struct SharedH2Connection {
|
||||
authority: String,
|
||||
sender: Mutex<SendRequest<Bytes>>,
|
||||
// One permit represents the shared TCP/TLS connection, not every H2
|
||||
// stream opened through it.
|
||||
physical_budget: Mutex<Option<super::native_budget::NativeBudgetPermit>>,
|
||||
/// Set to true by the driver task when the connection terminates.
|
||||
dead: Arc<std::sync::atomic::AtomicBool>,
|
||||
/// Number of application streams currently assigned to this connection.
|
||||
/// This is deliberately separate from HTTP/2's peer stream accounting: it
|
||||
/// lets an asset batch distribute work across sibling TCP connections.
|
||||
active_streams: Arc<AtomicUsize>,
|
||||
last_activity: Mutex<std::time::Instant>,
|
||||
evict: Arc<Notify>,
|
||||
}
|
||||
|
||||
pub(crate) struct H2StreamActivity {
|
||||
active_streams: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Drop for H2StreamActivity {
|
||||
fn drop(&mut self) {
|
||||
self.active_streams.fetch_sub(1, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
impl SharedH2Connection {
|
||||
fn new(
|
||||
authority: String,
|
||||
sender: SendRequest<Bytes>,
|
||||
physical_budget: Option<super::native_budget::NativeBudgetPermit>,
|
||||
) -> Self {
|
||||
Self {
|
||||
authority,
|
||||
sender: Mutex::new(sender),
|
||||
physical_budget: Mutex::new(physical_budget),
|
||||
dead: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
active_streams: Arc::new(AtomicUsize::new(0)),
|
||||
last_activity: Mutex::new(std::time::Instant::now()),
|
||||
evict: Arc::new(Notify::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn for_test(sender: SendRequest<Bytes>) -> Self {
|
||||
Self::new("test.invalid:443".to_string(), sender, None)
|
||||
}
|
||||
|
||||
pub fn is_dead(&self) -> bool {
|
||||
self.dead.load(std::sync::atomic::Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn active_streams(&self) -> usize {
|
||||
self.active_streams.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn track_stream(&self) -> H2StreamActivity {
|
||||
*self
|
||||
.last_activity
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) =
|
||||
std::time::Instant::now();
|
||||
self.active_streams.fetch_add(1, Ordering::AcqRel);
|
||||
H2StreamActivity {
|
||||
active_streams: Arc::clone(&self.active_streams),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn mark_idle_for_test(&self) {
|
||||
*self
|
||||
.last_activity
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) =
|
||||
std::time::Instant::now() - IDLE_EVICTION_TIMEOUT;
|
||||
}
|
||||
|
||||
fn is_idle_expired(&self) -> bool {
|
||||
self.active_streams() == 0
|
||||
&& self
|
||||
.last_activity
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.elapsed()
|
||||
>= IDLE_EVICTION_TIMEOUT
|
||||
}
|
||||
|
||||
fn evict(&self) {
|
||||
self.evict.notify_waiters();
|
||||
}
|
||||
|
||||
fn has_physical_budget(&self) -> bool {
|
||||
self.physical_budget
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn release_physical_budget(&self) {
|
||||
self.physical_budget
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.take();
|
||||
}
|
||||
|
||||
/// Sends a request on the shared connection and awaits the response
|
||||
/// headers, yielding the response and its receive stream. Each call
|
||||
/// opens an independent multiplexed stream.
|
||||
pub async fn open(
|
||||
&self,
|
||||
request: http::Request<()>,
|
||||
) -> Result<http::Response<h2::RecvStream>, h2::Error> {
|
||||
let ready_started = std::time::Instant::now();
|
||||
let sender = self
|
||||
.sender
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.clone();
|
||||
*self
|
||||
.last_activity
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) =
|
||||
std::time::Instant::now();
|
||||
let mut sender =
|
||||
tokio::time::timeout(STREAM_READY_TIMEOUT, sender.ready())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
h2::Error::from(h2::Reason::ENHANCE_YOUR_CALM)
|
||||
})??;
|
||||
let ready_wait = ready_started.elapsed();
|
||||
if ready_wait >= Duration::from_millis(25) {
|
||||
tracing::debug!(
|
||||
authority = %self.authority,
|
||||
ready_wait_ms = ready_wait.as_millis(),
|
||||
"HTTP/2 stream admission waited for peer or connection capacity"
|
||||
);
|
||||
}
|
||||
let (response, _) = sender.send_request(request, true)?;
|
||||
tokio::time::timeout(STREAM_READY_TIMEOUT, response)
|
||||
.await
|
||||
.map_err(|_| h2::Error::from(h2::Reason::ENHANCE_YOUR_CALM))?
|
||||
}
|
||||
}
|
||||
|
||||
type ConnectionSlot = Arc<AsyncMutex<Option<Arc<SharedH2Connection>>>>;
|
||||
|
||||
/// Registry of live shared connections, keyed by authority.
|
||||
static CONNECTIONS: std::sync::LazyLock<
|
||||
AsyncMutex<HashMap<String, ConnectionSlot>>,
|
||||
> = std::sync::LazyLock::new(|| AsyncMutex::new(HashMap::new()));
|
||||
|
||||
/// A bounded sibling connection for saturated asset batches. Normal file
|
||||
/// downloads always use `CONNECTIONS`; a second TCP congestion domain is only
|
||||
/// created by the asset scheduler after it observes sustained pressure.
|
||||
static BATCH_CONNECTIONS: std::sync::LazyLock<
|
||||
AsyncMutex<HashMap<String, ConnectionSlot>>,
|
||||
> = std::sync::LazyLock::new(|| AsyncMutex::new(HashMap::new()));
|
||||
|
||||
async fn connection_slot(authority: &str) -> ConnectionSlot {
|
||||
let mut connections = CONNECTIONS.lock().await;
|
||||
connections
|
||||
.entry(authority.to_string())
|
||||
.or_insert_with(|| Arc::new(AsyncMutex::new(None)))
|
||||
.clone()
|
||||
}
|
||||
|
||||
async fn batch_connection_slot(authority: &str) -> ConnectionSlot {
|
||||
let mut connections = BATCH_CONNECTIONS.lock().await;
|
||||
connections
|
||||
.entry(authority.to_string())
|
||||
.or_insert_with(|| Arc::new(AsyncMutex::new(None)))
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn tls_config() -> Arc<ClientConfig> {
|
||||
static INSTALL_PROVIDER: Once = Once::new();
|
||||
INSTALL_PROVIDER.call_once(|| {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
});
|
||||
static CONFIG: std::sync::LazyLock<Mutex<Option<Arc<ClientConfig>>>> =
|
||||
std::sync::LazyLock::new(|| Mutex::new(None));
|
||||
let mut guard = CONFIG
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if let Some(config) = guard.as_ref() {
|
||||
return Arc::clone(config);
|
||||
}
|
||||
let mut config = ClientConfig::builder()
|
||||
.with_root_certificates(platform_root_certs())
|
||||
.with_no_client_auth();
|
||||
config.enable_early_data = true;
|
||||
config.alpn_protocols = vec![b"h2".to_vec()];
|
||||
let config = Arc::new(config);
|
||||
*guard = Some(Arc::clone(&config));
|
||||
config
|
||||
}
|
||||
|
||||
fn platform_root_certs() -> rustls::RootCertStore {
|
||||
let mut store = rustls::RootCertStore::empty();
|
||||
let certs = rustls_native_certs::load_native_certs();
|
||||
for cert in certs.certs {
|
||||
let _ = store.add(cert);
|
||||
}
|
||||
if certs.errors.is_empty() {
|
||||
return store;
|
||||
}
|
||||
store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
store
|
||||
}
|
||||
|
||||
async fn connect_addresses(
|
||||
host: &str,
|
||||
port: u16,
|
||||
addresses: &[IpAddr],
|
||||
) -> std::io::Result<TcpStream> {
|
||||
let mut last_error = None;
|
||||
for address in addresses {
|
||||
match tokio::time::timeout(
|
||||
CONNECT_TIMEOUT,
|
||||
TcpStream::connect((*address, port)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(stream)) => {
|
||||
stream.set_nodelay(true).ok();
|
||||
return Ok(stream);
|
||||
}
|
||||
Ok(Err(error)) => last_error = Some(error),
|
||||
Err(_) => {
|
||||
last_error = Some(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!("connection to {host}:{port} timed out"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("no addresses available for {host}"),
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
async fn connect_tcp(host: &str, port: u16) -> std::io::Result<TcpStream> {
|
||||
// Prefer the ordered address list from the shared download resolver
|
||||
// (IPv4/IPv6 preference and per-IP reliability), falling back to the
|
||||
// system resolver when no list is cached yet.
|
||||
let resolver = &crate::util::fetch::DOWNLOAD_DNS_RESOLVER;
|
||||
let addresses = resolver.resolved_addresses(host);
|
||||
let mut last_error = None;
|
||||
if !addresses.is_empty() {
|
||||
match connect_addresses(host, port, &addresses).await {
|
||||
Ok(stream) => return Ok(stream),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
if resolver.record_connection_failure(host) {
|
||||
let _ = tokio::time::timeout(
|
||||
CONNECTION_WAIT_TIMEOUT,
|
||||
resolver.pre_resolve(host),
|
||||
)
|
||||
.await;
|
||||
let refreshed = resolver.resolved_addresses(host);
|
||||
if !refreshed.is_empty() && refreshed != addresses {
|
||||
match connect_addresses(host, port, &refreshed).await {
|
||||
Ok(stream) => return Ok(stream),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let stream = tokio::time::timeout(
|
||||
CONNECT_TIMEOUT,
|
||||
tokio::net::TcpStream::connect((host, port)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
last_error.take().unwrap_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!("connection to {host}:{port} timed out"),
|
||||
)
|
||||
})
|
||||
})?
|
||||
.map_err(|error| {
|
||||
last_error.take().unwrap_or_else(|| {
|
||||
std::io::Error::new(
|
||||
error.kind(),
|
||||
format!("connection to {host}:{port} failed: {error}"),
|
||||
)
|
||||
})
|
||||
})?;
|
||||
stream.set_nodelay(true).ok();
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// Connects a new shared HTTP/2 connection to `authority` (host[:port]).
|
||||
async fn establish(
|
||||
route: &DownloadRoute,
|
||||
reserve_native_budget: bool,
|
||||
) -> Result<Arc<SharedH2Connection>, H2ConnectError> {
|
||||
let authority =
|
||||
crate::util::fetch::url_authority(&route.url).ok_or_else(|| {
|
||||
H2ConnectError::new(
|
||||
H2ConnectFailureKind::Protocol,
|
||||
"HTTP/2 route has no authority".to_string(),
|
||||
)
|
||||
})?;
|
||||
let physical_budget = if reserve_native_budget {
|
||||
Some(super::native_budget::acquire(route).await.map_err(|error| {
|
||||
H2ConnectError::new(
|
||||
H2ConnectFailureKind::Tcp,
|
||||
format!("failed to reserve HTTP/2 connection capacity: {error}"),
|
||||
)
|
||||
})?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (host, port) = authority
|
||||
.rsplit_once(':')
|
||||
.map(|(host, port)| (host, port.parse::<u16>().unwrap_or(443)))
|
||||
.unwrap_or((&authority, 443));
|
||||
|
||||
// Pre-resolve so `connect_tcp` gets the ordered, reliability-ranked
|
||||
// address list shared with the legacy reqwest path.
|
||||
let _ = tokio::time::timeout(
|
||||
DNS_PREWARM_TIMEOUT,
|
||||
crate::util::fetch::DOWNLOAD_DNS_RESOLVER.pre_resolve(host),
|
||||
)
|
||||
.await;
|
||||
|
||||
let tcp = connect_tcp(host, port).await.map_err(|error| {
|
||||
H2ConnectError::new(
|
||||
H2ConnectFailureKind::Tcp,
|
||||
format!(
|
||||
"failed to establish shared HTTP/2 connection to {authority}: {error}"
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
let server_name =
|
||||
ServerName::try_from(host.to_string()).map_err(|error| {
|
||||
H2ConnectError::new(
|
||||
H2ConnectFailureKind::Tls,
|
||||
format!("invalid server name for {host}: {error}"),
|
||||
)
|
||||
})?;
|
||||
let connector = TlsConnector::from(tls_config());
|
||||
let tls = tokio::time::timeout(
|
||||
TLS_HANDSHAKE_TIMEOUT,
|
||||
connector.connect(server_name, tcp),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
H2ConnectError::new(
|
||||
H2ConnectFailureKind::Tls,
|
||||
format!("TLS handshake with {authority} timed out"),
|
||||
)
|
||||
})?
|
||||
.map_err(|error| {
|
||||
H2ConnectError::new(
|
||||
H2ConnectFailureKind::Tls,
|
||||
format!("TLS handshake with {authority} failed: {error}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut builder = h2::client::Builder::new();
|
||||
builder
|
||||
.initial_window_size(1024 * 1024)
|
||||
.initial_connection_window_size(64 * 1024 * 1024);
|
||||
let (sender, mut connection) =
|
||||
builder.handshake(Box::pin(tls)).await.map_err(|error| {
|
||||
H2ConnectError::new(
|
||||
H2ConnectFailureKind::Protocol,
|
||||
format!("HTTP/2 handshake with {authority} failed: {error}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Tune flow-control windows for high-stream multiplexing (e.g. hundreds
|
||||
// of concurrent asset downloads over one connection). With the default
|
||||
// 64 KiB connection window, concurrent streams would stall waiting for
|
||||
// connection-level window updates; a larger per-stream window also lets
|
||||
// the peer send a whole small file without round trips.
|
||||
// Keep the adaptive connection target aligned with the initial connection
|
||||
// window configured before the handshake.
|
||||
connection.set_target_window_size(64 * 1024 * 1024);
|
||||
|
||||
let shared = Arc::new(SharedH2Connection::new(
|
||||
authority.clone(),
|
||||
sender,
|
||||
physical_budget,
|
||||
));
|
||||
|
||||
let dead = Arc::clone(&shared.dead);
|
||||
let connection_budget = Arc::clone(&shared);
|
||||
let evict = Arc::clone(&shared.evict);
|
||||
let authority = authority.to_string();
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = connection => {}
|
||||
_ = evict.notified() => {
|
||||
tracing::debug!(authority, "Evicting idle shared HTTP/2 connection");
|
||||
}
|
||||
}
|
||||
dead.store(true, std::sync::atomic::Ordering::Release);
|
||||
connection_budget.release_physical_budget();
|
||||
tracing::debug!(authority, "Shared HTTP/2 connection closed");
|
||||
});
|
||||
|
||||
Ok(shared)
|
||||
}
|
||||
|
||||
/// Returns the live shared connection for `authority`, establishing one on
|
||||
/// first use or after a previous connection died.
|
||||
pub(crate) async fn shared_connection(
|
||||
route: &DownloadRoute,
|
||||
reserve_native_budget: bool,
|
||||
allow_cold_connection: bool,
|
||||
) -> Result<Arc<SharedH2Connection>, H2ConnectError> {
|
||||
let authority =
|
||||
crate::util::fetch::url_authority(&route.url).ok_or_else(|| {
|
||||
H2ConnectError::new(
|
||||
H2ConnectFailureKind::Protocol,
|
||||
"HTTP/2 route has no authority".to_string(),
|
||||
)
|
||||
})?;
|
||||
let slot = connection_slot(&authority).await;
|
||||
let mut cached = tokio::time::timeout(CONNECTION_WAIT_TIMEOUT, slot.lock())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
H2ConnectError::new(
|
||||
H2ConnectFailureKind::Protocol,
|
||||
format!(
|
||||
"timed out waiting for HTTP/2 connection slot {authority}"
|
||||
),
|
||||
)
|
||||
})?;
|
||||
if let Some(connection) = cached.as_ref().filter(|connection| {
|
||||
!connection.is_dead() && !connection.is_idle_expired()
|
||||
}) {
|
||||
if !reserve_native_budget || connection.has_physical_budget() {
|
||||
tracing::debug!(authority, "Reusing shared HTTP/2 connection");
|
||||
return Ok(Arc::clone(connection));
|
||||
}
|
||||
return Err(H2ConnectError::new(
|
||||
H2ConnectFailureKind::Protocol,
|
||||
"shared HTTP/2 connection is not covered by the native connection budget"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if cached.as_ref().is_some_and(|connection| {
|
||||
connection.is_dead() || connection.is_idle_expired()
|
||||
}) {
|
||||
if let Some(connection) = cached.as_ref() {
|
||||
connection.evict();
|
||||
}
|
||||
*cached = None;
|
||||
}
|
||||
if !allow_cold_connection {
|
||||
return Err(H2ConnectError::new(
|
||||
H2ConnectFailureKind::Protocol,
|
||||
"HTTP/2 policy requires an existing shared connection".to_string(),
|
||||
));
|
||||
}
|
||||
tracing::debug!(authority, "Establishing cold shared HTTP/2 connection");
|
||||
let connection = tokio::time::timeout(
|
||||
CONNECTION_WAIT_TIMEOUT,
|
||||
establish(route, reserve_native_budget),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
H2ConnectError::new(
|
||||
H2ConnectFailureKind::Tcp,
|
||||
format!("timed out establishing HTTP/2 connection to {authority}"),
|
||||
)
|
||||
})??;
|
||||
*cached = Some(Arc::clone(&connection));
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
/// Returns the optional second connection used exclusively by a busy asset
|
||||
/// batch. It is stored independently so ordinary file downloads retain their
|
||||
/// stable primary connection and never create extra TCP connections.
|
||||
pub(crate) async fn shared_batch_connection(
|
||||
route: &DownloadRoute,
|
||||
reserve_native_budget: bool,
|
||||
) -> Result<Arc<SharedH2Connection>, H2ConnectError> {
|
||||
let authority =
|
||||
crate::util::fetch::url_authority(&route.url).ok_or_else(|| {
|
||||
H2ConnectError::new(
|
||||
H2ConnectFailureKind::Protocol,
|
||||
"HTTP/2 route has no authority".to_string(),
|
||||
)
|
||||
})?;
|
||||
let slot = batch_connection_slot(&authority).await;
|
||||
let mut cached = tokio::time::timeout(
|
||||
CONNECTION_WAIT_TIMEOUT,
|
||||
slot.lock(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
H2ConnectError::new(
|
||||
H2ConnectFailureKind::Protocol,
|
||||
format!("timed out waiting for asset HTTP/2 connection slot {authority}"),
|
||||
)
|
||||
})?;
|
||||
if let Some(connection) = cached.as_ref().filter(|connection| {
|
||||
!connection.is_dead() && !connection.is_idle_expired()
|
||||
}) {
|
||||
if !reserve_native_budget || connection.has_physical_budget() {
|
||||
tracing::debug!(
|
||||
authority,
|
||||
"Reusing sibling HTTP/2 asset connection"
|
||||
);
|
||||
return Ok(Arc::clone(connection));
|
||||
}
|
||||
return Err(H2ConnectError::new(
|
||||
H2ConnectFailureKind::Protocol,
|
||||
"sibling HTTP/2 connection is not covered by the native connection budget"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if cached.as_ref().is_some_and(|connection| {
|
||||
connection.is_dead() || connection.is_idle_expired()
|
||||
}) {
|
||||
if let Some(connection) = cached.as_ref() {
|
||||
connection.evict();
|
||||
}
|
||||
*cached = None;
|
||||
}
|
||||
tracing::debug!(authority, "Establishing sibling HTTP/2 asset connection");
|
||||
let connection = tokio::time::timeout(
|
||||
CONNECTION_WAIT_TIMEOUT,
|
||||
establish(route, reserve_native_budget),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
H2ConnectError::new(
|
||||
H2ConnectFailureKind::Tcp,
|
||||
format!(
|
||||
"timed out establishing asset HTTP/2 connection to {authority}"
|
||||
),
|
||||
)
|
||||
})??;
|
||||
*cached = Some(Arc::clone(&connection));
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
pub(crate) async fn has_live_connection(authority: &str) -> bool {
|
||||
let connections = CONNECTIONS.lock().await;
|
||||
let Some(slot) = connections.get(authority).cloned() else {
|
||||
return false;
|
||||
};
|
||||
drop(connections);
|
||||
let live = slot
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.is_some_and(|connection| !connection.is_dead());
|
||||
live
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
//! Multi-range download of one file over a shared HTTP/2 connection.
|
||||
|
||||
use super::h2_download::{H2DownloadFailure, H2DownloadOutcome};
|
||||
use super::h2_pool::SharedH2Connection;
|
||||
use crate::util::fetch::{
|
||||
self, DownloadRequest, DownloadResult, DownloadRoute,
|
||||
};
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use http::header::{ACCEPT_ENCODING, RANGE};
|
||||
use http::{HeaderValue, StatusCode, Uri};
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
struct H2Range {
|
||||
start: u64,
|
||||
end: u64,
|
||||
}
|
||||
|
||||
struct PartCleanupGuard {
|
||||
path: PathBuf,
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl PartCleanupGuard {
|
||||
fn new(path: &Path) -> Self {
|
||||
Self {
|
||||
path: path.to_path_buf(),
|
||||
armed: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn disarm(&mut self) {
|
||||
self.armed = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PartCleanupGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.armed {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn download(
|
||||
connection: &Arc<SharedH2Connection>,
|
||||
uri: &Uri,
|
||||
request: &DownloadRequest,
|
||||
route: &DownloadRoute,
|
||||
destination: &Path,
|
||||
part_path: &Path,
|
||||
total_size: u64,
|
||||
concurrency: usize,
|
||||
) -> H2DownloadOutcome {
|
||||
if request
|
||||
.cancellation
|
||||
.as_ref()
|
||||
.is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
|
||||
{
|
||||
return H2DownloadOutcome::Canceled;
|
||||
}
|
||||
if total_size == 0 {
|
||||
return H2DownloadOutcome::Fallback {
|
||||
failure: H2DownloadFailure::Content,
|
||||
preserve_partial: false,
|
||||
};
|
||||
}
|
||||
let count = concurrency
|
||||
.max(1)
|
||||
.min(usize::try_from(total_size).unwrap_or(usize::MAX).max(1));
|
||||
let mut cleanup = PartCleanupGuard::new(part_path);
|
||||
let output =
|
||||
match super::range_output::RangeOutput::create(part_path, total_size)
|
||||
.await
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(_) => {
|
||||
return H2DownloadOutcome::Fallback {
|
||||
failure: H2DownloadFailure::Io,
|
||||
preserve_partial: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
let ranges = split_ranges(total_size, count);
|
||||
let downloaded = Arc::new(AtomicU64::new(0));
|
||||
let reported_bucket = Arc::new(AtomicU64::new(0));
|
||||
let progress_delta = (total_size / 200).max(256 * 1024);
|
||||
let mut tasks = FuturesUnordered::new();
|
||||
for range in ranges {
|
||||
tasks.push(download_range(
|
||||
Arc::clone(connection),
|
||||
uri.clone(),
|
||||
request,
|
||||
route,
|
||||
Arc::clone(&output),
|
||||
range,
|
||||
total_size,
|
||||
Arc::clone(&downloaded),
|
||||
Arc::clone(&reported_bucket),
|
||||
progress_delta,
|
||||
));
|
||||
}
|
||||
loop {
|
||||
let next = if let Some(cancellation) = request.cancellation.as_ref() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancellation.cancelled() => {
|
||||
drop(tasks);
|
||||
drop(output);
|
||||
return H2DownloadOutcome::Canceled;
|
||||
}
|
||||
result = tasks.next() => result,
|
||||
}
|
||||
} else {
|
||||
tasks.next().await
|
||||
};
|
||||
let Some(result) = next else {
|
||||
break;
|
||||
};
|
||||
if let Err(failure) = result {
|
||||
drop(tasks);
|
||||
drop(output);
|
||||
return H2DownloadOutcome::Fallback {
|
||||
failure,
|
||||
preserve_partial: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
drop(output);
|
||||
let verification = if let Some(cancellation) = request.cancellation.as_ref()
|
||||
{
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancellation.cancelled() => return H2DownloadOutcome::Canceled,
|
||||
result = fetch::verify_file(part_path, &request.integrity) => result,
|
||||
}
|
||||
} else {
|
||||
fetch::verify_file(part_path, &request.integrity).await
|
||||
};
|
||||
if let Err(error) = verification {
|
||||
let failure = if fetch::is_integrity_error(&error) {
|
||||
H2DownloadFailure::Integrity
|
||||
} else {
|
||||
H2DownloadFailure::Content
|
||||
};
|
||||
return H2DownloadOutcome::Fallback {
|
||||
failure,
|
||||
preserve_partial: false,
|
||||
};
|
||||
}
|
||||
let finalized = if let Some(cancellation) = request.cancellation.as_ref() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancellation.cancelled() => return H2DownloadOutcome::Canceled,
|
||||
result = fetch::finalize_download(part_path, destination) => result,
|
||||
}
|
||||
} else {
|
||||
fetch::finalize_download(part_path, destination).await
|
||||
};
|
||||
if finalized.is_err() {
|
||||
return H2DownloadOutcome::Fallback {
|
||||
failure: H2DownloadFailure::Io,
|
||||
preserve_partial: false,
|
||||
};
|
||||
}
|
||||
cleanup.disarm();
|
||||
H2DownloadOutcome::Completed(DownloadResult {
|
||||
path: destination.to_path_buf(),
|
||||
url: uri.to_string(),
|
||||
source: route.source,
|
||||
size: total_size,
|
||||
attempts: 1,
|
||||
fallback_count: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn download_range(
|
||||
connection: Arc<SharedH2Connection>,
|
||||
uri: Uri,
|
||||
request: &DownloadRequest,
|
||||
route: &DownloadRoute,
|
||||
output: Arc<super::range_output::RangeOutput>,
|
||||
range: H2Range,
|
||||
total_size: u64,
|
||||
downloaded: Arc<AtomicU64>,
|
||||
reported_bucket: Arc<AtomicU64>,
|
||||
progress_delta: u64,
|
||||
) -> Result<(), H2DownloadFailure> {
|
||||
super::h2_download::record_install_stage(
|
||||
request,
|
||||
crate::install::DownloadItemStatus::WaitingForResource,
|
||||
)
|
||||
.await;
|
||||
let _permit = tokio::time::timeout(
|
||||
Duration::from_secs(45),
|
||||
super::h2_stream_budget::acquire(route),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| H2DownloadFailure::Connect)?
|
||||
.map_err(|_| H2DownloadFailure::Connect)?;
|
||||
super::h2_download::record_install_stage(
|
||||
request,
|
||||
crate::install::DownloadItemStatus::Downloading,
|
||||
)
|
||||
.await;
|
||||
let mut writer = output
|
||||
.open_range(range.start, range.end + 1)
|
||||
.await
|
||||
.map_err(|_| H2DownloadFailure::Io)?;
|
||||
let mut headers = super::h2_download::request_headers(request, route);
|
||||
headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("identity"));
|
||||
headers.insert(
|
||||
RANGE,
|
||||
HeaderValue::from_str(&format!("bytes={}-{}", range.start, range.end))
|
||||
.map_err(|_| H2DownloadFailure::Http)?,
|
||||
);
|
||||
let (response, mut stream) =
|
||||
super::h2_download::open_stream(&connection, &uri, headers)
|
||||
.await
|
||||
.map_err(|_| H2DownloadFailure::Protocol)?;
|
||||
if response.status() != StatusCode::PARTIAL_CONTENT
|
||||
|| !content_range_matches(response.headers(), &range, total_size)
|
||||
{
|
||||
return Err(H2DownloadFailure::Http);
|
||||
}
|
||||
let activity = super::h2_receive::H2TransferActivity::begin();
|
||||
let mut offset = range.start;
|
||||
while let Some(chunk) =
|
||||
super::h2_receive::receive_chunk(&mut stream, "range")
|
||||
.await
|
||||
.map_err(|_| H2DownloadFailure::Protocol)?
|
||||
{
|
||||
let remaining = range.end.saturating_add(1).saturating_sub(offset);
|
||||
if chunk.len() as u64 > remaining {
|
||||
return Err(H2DownloadFailure::Protocol);
|
||||
}
|
||||
let accepted = chunk.len();
|
||||
writer
|
||||
.write_next(&chunk[..accepted])
|
||||
.await
|
||||
.map_err(|_| H2DownloadFailure::Io)?;
|
||||
activity.record_bytes(accepted);
|
||||
super::h2_receive::release_capacity(&mut stream, chunk.len())
|
||||
.map_err(|_| H2DownloadFailure::Protocol)?;
|
||||
offset += accepted as u64;
|
||||
let current = downloaded.fetch_add(accepted as u64, Ordering::Relaxed)
|
||||
+ accepted as u64;
|
||||
let bucket = current / progress_delta;
|
||||
let previous = reported_bucket.fetch_max(bucket, Ordering::Relaxed);
|
||||
if current >= total_size || bucket > previous {
|
||||
super::h2_download::record_install_progress(
|
||||
request,
|
||||
current.min(total_size),
|
||||
total_size,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
(offset == range.end + 1)
|
||||
.then_some(())
|
||||
.ok_or(H2DownloadFailure::Protocol)?;
|
||||
writer.flush().await.map_err(|_| H2DownloadFailure::Io)
|
||||
}
|
||||
|
||||
fn split_ranges(size: u64, count: usize) -> Vec<H2Range> {
|
||||
let base = size / count as u64;
|
||||
let remainder = size % count as u64;
|
||||
let mut start = 0;
|
||||
(0..count)
|
||||
.map(|index| {
|
||||
let length = base + u64::from(index < remainder as usize);
|
||||
let range = H2Range {
|
||||
start,
|
||||
end: start + length - 1,
|
||||
};
|
||||
start += length;
|
||||
range
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn content_range_matches(
|
||||
headers: &http::HeaderMap,
|
||||
range: &H2Range,
|
||||
total_size: u64,
|
||||
) -> bool {
|
||||
headers
|
||||
.get(http::header::CONTENT_RANGE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| {
|
||||
value
|
||||
== format!("bytes {}-{}/{}", range.start, range.end, total_size)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bytes::Bytes;
|
||||
use futures::future::poll_fn;
|
||||
use http::{Response, StatusCode};
|
||||
use sha1_smol::Sha1;
|
||||
|
||||
#[test]
|
||||
fn splits_file_into_eight_contiguous_ranges() {
|
||||
let ranges = split_ranges(83, 8);
|
||||
assert_eq!(ranges.len(), 8);
|
||||
assert_eq!(ranges.first().unwrap().start, 0);
|
||||
assert_eq!(ranges.last().unwrap().end, 82);
|
||||
assert!(
|
||||
ranges
|
||||
.windows(2)
|
||||
.all(|pair| pair[0].end + 1 == pair[1].start)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_file_into_sixteen_contiguous_ranges() {
|
||||
let ranges = split_ranges(1024 * 1024 + 7, 16);
|
||||
assert_eq!(ranges.len(), 16);
|
||||
assert_eq!(ranges.first().unwrap().start, 0);
|
||||
assert_eq!(ranges.last().unwrap().end, 1024 * 1024 + 6);
|
||||
assert!(
|
||||
ranges
|
||||
.windows(2)
|
||||
.all(|pair| pair[0].end + 1 == pair[1].start)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn downloads_one_file_over_eight_h2_range_streams() {
|
||||
let data = Arc::new(
|
||||
(0..2 * 1024 * 1024)
|
||||
.map(|index| (index % 251) as u8)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let request_count = Arc::new(AtomicU64::new(0));
|
||||
let (client_io, server_io) = tokio::io::duplex(256 * 1024);
|
||||
let server_data = Arc::clone(&data);
|
||||
let server_requests = Arc::clone(&request_count);
|
||||
let server = tokio::spawn(async move {
|
||||
let mut connection =
|
||||
h2::server::handshake(server_io).await.unwrap();
|
||||
while let Some(result) = connection.accept().await {
|
||||
let (request, mut respond) = result.unwrap();
|
||||
let data = Arc::clone(&server_data);
|
||||
let requests = Arc::clone(&server_requests);
|
||||
tokio::spawn(async move {
|
||||
requests.fetch_add(1, Ordering::Relaxed);
|
||||
let value = request.headers()[RANGE].to_str().unwrap();
|
||||
let value = value.strip_prefix("bytes=").unwrap();
|
||||
let (start, end) = value.split_once('-').unwrap();
|
||||
let start = start.parse::<usize>().unwrap();
|
||||
let end = end.parse::<usize>().unwrap();
|
||||
let response = Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header(
|
||||
http::header::CONTENT_RANGE,
|
||||
format!("bytes {start}-{end}/{}", data.len()),
|
||||
)
|
||||
.body(())
|
||||
.unwrap();
|
||||
let mut stream =
|
||||
respond.send_response(response, false).unwrap();
|
||||
let mut offset = start;
|
||||
while offset <= end {
|
||||
let wanted = (end - offset + 1).min(16 * 1024);
|
||||
stream.reserve_capacity(wanted);
|
||||
let capacity = poll_fn(|cx| stream.poll_capacity(cx))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let length = capacity.min(wanted);
|
||||
let finished = offset + length > end;
|
||||
stream
|
||||
.send_data(
|
||||
Bytes::copy_from_slice(
|
||||
&data[offset..offset + length],
|
||||
),
|
||||
finished,
|
||||
)
|
||||
.unwrap();
|
||||
offset += length;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let mut builder = h2::client::Builder::new();
|
||||
builder
|
||||
.initial_window_size(1024 * 1024)
|
||||
.initial_connection_window_size(64 * 1024 * 1024);
|
||||
let (sender, mut driver) =
|
||||
builder.handshake::<_, Bytes>(client_io).await.unwrap();
|
||||
driver.set_target_window_size(64 * 1024 * 1024);
|
||||
let client_driver = tokio::spawn(async move { driver.await });
|
||||
let connection = Arc::new(SharedH2Connection::for_test(sender));
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let destination = directory.path().join("pack.mrpack");
|
||||
let part_path = directory.path().join("pack.mrpack.part");
|
||||
let hash = Sha1::from(&data[..]).hexdigest();
|
||||
let request = DownloadRequest::new(
|
||||
"https://h2-range.test/pack.mrpack",
|
||||
fetch::ResourceClass::Modpack,
|
||||
)
|
||||
.with_integrity(
|
||||
fetch::Integrity::sha1(hash).with_size(data.len() as u64),
|
||||
);
|
||||
let route = DownloadRoute {
|
||||
url: request.url.clone(),
|
||||
source: fetch::DownloadRouteSource::Official,
|
||||
is_mirror: false,
|
||||
allow_sensitive_headers: true,
|
||||
supports_range: true,
|
||||
proxy: fetch::ProxyPolicy::Direct,
|
||||
};
|
||||
let uri = route.url.parse().unwrap();
|
||||
|
||||
let result = download(
|
||||
&connection,
|
||||
&uri,
|
||||
&request,
|
||||
&route,
|
||||
&destination,
|
||||
&part_path,
|
||||
data.len() as u64,
|
||||
8,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, H2DownloadOutcome::Completed(_)));
|
||||
assert_eq!(request_count.load(Ordering::Relaxed), 8);
|
||||
assert_eq!(tokio::fs::read(destination).await.unwrap(), *data);
|
||||
client_driver.abort();
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_stops_all_ranges_and_removes_partial_output() {
|
||||
let request_count = Arc::new(AtomicU64::new(0));
|
||||
let (client_io, server_io) = tokio::io::duplex(256 * 1024);
|
||||
let server_requests = Arc::clone(&request_count);
|
||||
let server = tokio::spawn(async move {
|
||||
let mut connection =
|
||||
h2::server::handshake(server_io).await.unwrap();
|
||||
while let Some(result) = connection.accept().await {
|
||||
let (request, mut respond) = result.unwrap();
|
||||
let requests = Arc::clone(&server_requests);
|
||||
tokio::spawn(async move {
|
||||
requests.fetch_add(1, Ordering::Relaxed);
|
||||
let value = request.headers()[RANGE].to_str().unwrap();
|
||||
let value = value.strip_prefix("bytes=").unwrap();
|
||||
let (start, end) = value.split_once('-').unwrap();
|
||||
let response = Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header(
|
||||
http::header::CONTENT_RANGE,
|
||||
format!("bytes {start}-{end}/2097152"),
|
||||
)
|
||||
.body(())
|
||||
.unwrap();
|
||||
let _stream =
|
||||
respond.send_response(response, false).unwrap();
|
||||
futures::future::pending::<()>().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let (sender, driver) = h2::client::handshake(client_io).await.unwrap();
|
||||
let client_driver = tokio::spawn(async move { driver.await });
|
||||
let connection = Arc::new(SharedH2Connection::for_test(sender));
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let destination = directory.path().join("pack.zip");
|
||||
let part_path = directory.path().join("pack.zip.part");
|
||||
let cancellation = tokio_util::sync::CancellationToken::new();
|
||||
let mut request = DownloadRequest::new(
|
||||
"https://h2-range.test/pack.zip",
|
||||
fetch::ResourceClass::CurseForge,
|
||||
)
|
||||
.with_integrity(fetch::Integrity::default().with_size(2 * 1024 * 1024));
|
||||
request.cancellation = Some(cancellation.clone());
|
||||
let route = DownloadRoute {
|
||||
url: request.url.clone(),
|
||||
source: fetch::DownloadRouteSource::Official,
|
||||
is_mirror: false,
|
||||
allow_sensitive_headers: true,
|
||||
supports_range: true,
|
||||
proxy: fetch::ProxyPolicy::Direct,
|
||||
};
|
||||
let uri = route.url.parse().unwrap();
|
||||
let part_for_task = part_path.clone();
|
||||
let destination_for_task = destination.clone();
|
||||
let download_task = tokio::spawn(async move {
|
||||
download(
|
||||
&connection,
|
||||
&uri,
|
||||
&request,
|
||||
&route,
|
||||
&destination_for_task,
|
||||
&part_for_task,
|
||||
2 * 1024 * 1024,
|
||||
16,
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
while request_count.load(Ordering::Relaxed) < 16 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cancellation.cancel();
|
||||
let outcome = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
download_task,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(outcome, H2DownloadOutcome::Canceled));
|
||||
assert!(!part_path.exists());
|
||||
assert!(!destination.exists());
|
||||
client_driver.abort();
|
||||
server.abort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Shared receive-side accounting for native HTTP/2 streams.
|
||||
|
||||
use bytes::Bytes;
|
||||
use std::time::Duration;
|
||||
|
||||
const STREAM_RECV_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const MIN_PROGRESS_BYTES: u64 = 256 * 1024;
|
||||
|
||||
/// Tracks one active H2 stream in the native automatic-concurrency metrics.
|
||||
pub(crate) struct H2TransferActivity {
|
||||
_activity: Option<crate::state::DownloadConnectionActivity>,
|
||||
}
|
||||
|
||||
impl H2TransferActivity {
|
||||
pub(crate) fn begin() -> Self {
|
||||
Self {
|
||||
_activity: crate::State::get_if_initialized()
|
||||
.map(|state| state.begin_download_connection()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_bytes(&self, bytes: usize) {
|
||||
if let Some(state) = crate::State::get_if_initialized() {
|
||||
state.record_download_bytes(bytes as u64);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Limits install-progress work before it reaches the shared reporter lock.
|
||||
pub(crate) struct H2ProgressGate {
|
||||
last_reported: u64,
|
||||
threshold: u64,
|
||||
}
|
||||
|
||||
impl H2ProgressGate {
|
||||
pub(crate) fn new(total_size: u64) -> Self {
|
||||
Self {
|
||||
last_reported: 0,
|
||||
threshold: MIN_PROGRESS_BYTES.max(total_size / 200),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn should_report(
|
||||
&mut self,
|
||||
downloaded: u64,
|
||||
total_size: u64,
|
||||
) -> bool {
|
||||
if downloaded < total_size
|
||||
&& downloaded.saturating_sub(self.last_reported) < self.threshold
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if downloaded == self.last_reported {
|
||||
return false;
|
||||
}
|
||||
self.last_reported = downloaded;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_chunk(
|
||||
stream: &mut h2::RecvStream,
|
||||
context: &str,
|
||||
) -> crate::Result<Option<Bytes>> {
|
||||
tokio::time::timeout(STREAM_RECV_TIMEOUT, stream.data())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
crate::ErrorKind::NetworkError(format!(
|
||||
"HTTP/2 {context} stream receive timed out"
|
||||
))
|
||||
})?
|
||||
.transpose()
|
||||
.map_err(|error| {
|
||||
crate::ErrorKind::NetworkError(format!(
|
||||
"HTTP/2 {context} stream error: {error}"
|
||||
))
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns receive-window capacity after the caller has consumed the frame.
|
||||
pub(crate) fn release_capacity(
|
||||
stream: &mut h2::RecvStream,
|
||||
bytes: usize,
|
||||
) -> crate::Result<()> {
|
||||
stream
|
||||
.flow_control()
|
||||
.release_capacity(bytes)
|
||||
.map_err(|error| {
|
||||
crate::ErrorKind::NetworkError(format!(
|
||||
"HTTP/2 flow-control release failed: {error}"
|
||||
))
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures::future::poll_fn;
|
||||
use http::{Request, Response};
|
||||
|
||||
#[test]
|
||||
fn progress_gate_throttles_intermediate_updates_and_reports_completion() {
|
||||
let total = 2 * 1024 * 1024;
|
||||
let mut gate = H2ProgressGate::new(total);
|
||||
|
||||
assert!(!gate.should_report(64 * 1024, total));
|
||||
assert!(gate.should_report(256 * 1024, total));
|
||||
assert!(!gate.should_report(320 * 1024, total));
|
||||
assert!(gate.should_report(total, total));
|
||||
assert!(!gate.should_report(total, total));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn receive_capacity_allows_body_larger_than_initial_window() {
|
||||
const BODY_SIZE: usize = 2 * 1024 * 1024;
|
||||
const FRAME_SIZE: usize = 16 * 1024;
|
||||
|
||||
let (client_io, server_io) = tokio::io::duplex(64 * 1024);
|
||||
let server = tokio::spawn(async move {
|
||||
let mut connection =
|
||||
h2::server::handshake(server_io).await.unwrap();
|
||||
let (_, mut respond) = connection.accept().await.unwrap().unwrap();
|
||||
// Keep polling the connection while the response body waits for
|
||||
// client WINDOW_UPDATE frames.
|
||||
tokio::spawn(async move {
|
||||
while connection.accept().await.is_some() {}
|
||||
});
|
||||
let mut stream =
|
||||
respond.send_response(Response::new(()), false).unwrap();
|
||||
let mut remaining = BODY_SIZE;
|
||||
while remaining > 0 {
|
||||
stream.reserve_capacity(remaining.min(FRAME_SIZE));
|
||||
let capacity = poll_fn(|cx| stream.poll_capacity(cx))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let length = capacity.min(remaining).min(FRAME_SIZE);
|
||||
remaining -= length;
|
||||
stream
|
||||
.send_data(Bytes::from(vec![0x5a; length]), remaining == 0)
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
let mut builder = h2::client::Builder::new();
|
||||
builder
|
||||
.initial_window_size(1024 * 1024)
|
||||
.initial_connection_window_size(64 * 1024 * 1024);
|
||||
let (mut sender, mut connection) =
|
||||
builder.handshake::<_, Bytes>(client_io).await.unwrap();
|
||||
connection.set_target_window_size(64 * 1024 * 1024);
|
||||
let client_driver = tokio::spawn(async move { connection.await });
|
||||
|
||||
let request =
|
||||
Request::get("https://h2-flow.test/file").body(()).unwrap();
|
||||
sender = sender.ready().await.unwrap();
|
||||
let (response, _) = sender.send_request(request, true).unwrap();
|
||||
let mut body = response.await.unwrap().into_body();
|
||||
let mut received = 0;
|
||||
while let Some(chunk) = receive_chunk(&mut body, "test").await.unwrap()
|
||||
{
|
||||
assert!(chunk.iter().all(|byte| *byte == 0x5a));
|
||||
received += chunk.len();
|
||||
release_capacity(&mut body, chunk.len()).unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(received, BODY_SIZE);
|
||||
server.await.unwrap();
|
||||
client_driver.abort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Logical stream budgets for shared native HTTP/2 connections.
|
||||
//!
|
||||
//! HTTP/2 streams share one physical connection per authority, so they must
|
||||
//! not consume the HTTP/1 connection permits held by `native_budget`.
|
||||
|
||||
use crate::util::fetch::{DownloadRoute, ProxyPolicy};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use tokio::sync::{AcquireError, OwnedSemaphorePermit, Semaphore};
|
||||
use tokio::time::{Duration, sleep};
|
||||
|
||||
const MAX_H2_STREAMS: usize = 128;
|
||||
const MAX_H2_STREAMS_PER_AUTHORITY: usize = 32;
|
||||
const MAX_ASSET_H2_STREAMS: usize = 96;
|
||||
const MAX_ASSET_H2_STREAMS_PER_AUTHORITY: usize = 24;
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
struct AuthorityKey {
|
||||
authority: String,
|
||||
proxy: ProxyPolicy,
|
||||
}
|
||||
|
||||
static AUTHORITY_BUDGETS: LazyLock<
|
||||
Mutex<HashMap<AuthorityKey, Arc<Semaphore>>>,
|
||||
> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
static GLOBAL_BUDGET: LazyLock<Arc<Semaphore>> =
|
||||
LazyLock::new(|| Arc::new(Semaphore::new(MAX_H2_STREAMS)));
|
||||
static ASSET_AUTHORITY_BUDGETS: LazyLock<
|
||||
Mutex<HashMap<AuthorityKey, Arc<Semaphore>>>,
|
||||
> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
static ASSET_GLOBAL_BUDGET: LazyLock<Arc<Semaphore>> =
|
||||
LazyLock::new(|| Arc::new(Semaphore::new(MAX_ASSET_H2_STREAMS)));
|
||||
|
||||
pub(crate) struct H2StreamPermit {
|
||||
_global: OwnedSemaphorePermit,
|
||||
_authority: Option<OwnedSemaphorePermit>,
|
||||
}
|
||||
|
||||
fn budget(route: &DownloadRoute) -> Option<Arc<Semaphore>> {
|
||||
let authority = crate::util::fetch::url_authority(&route.url)?;
|
||||
let key = AuthorityKey {
|
||||
authority,
|
||||
proxy: route.proxy,
|
||||
};
|
||||
let mut budgets = AUTHORITY_BUDGETS.lock();
|
||||
if budgets.len() >= 256 {
|
||||
budgets.retain(|_, budget| Arc::strong_count(budget) > 1);
|
||||
}
|
||||
Some(
|
||||
budgets
|
||||
.entry(key)
|
||||
.or_insert_with(|| {
|
||||
Arc::new(Semaphore::new(MAX_H2_STREAMS_PER_AUTHORITY))
|
||||
})
|
||||
.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn asset_budget(route: &DownloadRoute) -> Option<Arc<Semaphore>> {
|
||||
let authority = crate::util::fetch::url_authority(&route.url)?;
|
||||
let key = AuthorityKey {
|
||||
authority,
|
||||
proxy: route.proxy,
|
||||
};
|
||||
let mut budgets = ASSET_AUTHORITY_BUDGETS.lock();
|
||||
if budgets.len() >= 256 {
|
||||
budgets.retain(|_, budget| Arc::strong_count(budget) > 1);
|
||||
}
|
||||
Some(
|
||||
budgets
|
||||
.entry(key)
|
||||
.or_insert_with(|| {
|
||||
Arc::new(Semaphore::new(MAX_ASSET_H2_STREAMS_PER_AUTHORITY))
|
||||
})
|
||||
.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire(
|
||||
route: &DownloadRoute,
|
||||
) -> Result<H2StreamPermit, AcquireError> {
|
||||
let authority_budget = budget(route);
|
||||
loop {
|
||||
let global = Arc::clone(&GLOBAL_BUDGET).acquire_owned().await?;
|
||||
let authority = match authority_budget.as_ref() {
|
||||
Some(budget) => match Arc::clone(budget).try_acquire_owned() {
|
||||
Ok(permit) => Some(permit),
|
||||
Err(tokio::sync::TryAcquireError::NoPermits) => {
|
||||
drop(global);
|
||||
sleep(Duration::from_millis(5)).await;
|
||||
continue;
|
||||
}
|
||||
Err(tokio::sync::TryAcquireError::Closed) => {
|
||||
unreachable!("H2 authority budgets are never closed")
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
return Ok(H2StreamPermit {
|
||||
_global: global,
|
||||
_authority: authority,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquires a stream for the Minecraft asset batch. Assets retain a large
|
||||
/// logical worker window, but use a separate physical stream pool so they
|
||||
/// cannot consume the ordinary native content share.
|
||||
pub(crate) async fn acquire_asset(
|
||||
route: &DownloadRoute,
|
||||
) -> Result<H2StreamPermit, AcquireError> {
|
||||
let authority_budget = asset_budget(route);
|
||||
loop {
|
||||
let global = Arc::clone(&ASSET_GLOBAL_BUDGET).acquire_owned().await?;
|
||||
let authority = match authority_budget.as_ref() {
|
||||
Some(budget) => match Arc::clone(budget).try_acquire_owned() {
|
||||
Ok(permit) => Some(permit),
|
||||
Err(tokio::sync::TryAcquireError::NoPermits) => {
|
||||
drop(global);
|
||||
sleep(Duration::from_millis(5)).await;
|
||||
continue;
|
||||
}
|
||||
Err(tokio::sync::TryAcquireError::Closed) => {
|
||||
unreachable!("asset authority budgets are never closed")
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
return Ok(H2StreamPermit {
|
||||
_global: global,
|
||||
_authority: authority,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::util::fetch::DownloadRouteSource;
|
||||
|
||||
fn route() -> DownloadRoute {
|
||||
DownloadRoute {
|
||||
url: "https://h2-budget.example/file".to_string(),
|
||||
source: DownloadRouteSource::Official,
|
||||
is_mirror: false,
|
||||
allow_sensitive_headers: true,
|
||||
supports_range: true,
|
||||
proxy: ProxyPolicy::Direct,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authority_stream_budget_is_independent_from_connection_budget() {
|
||||
let route = route();
|
||||
let mut permits = Vec::new();
|
||||
for _ in 0..MAX_H2_STREAMS_PER_AUTHORITY {
|
||||
permits.push(acquire(&route).await.unwrap());
|
||||
}
|
||||
assert_eq!(budget(&route).unwrap().available_permits(), 0,);
|
||||
drop(permits);
|
||||
assert_eq!(
|
||||
budget(&route).unwrap().available_permits(),
|
||||
MAX_H2_STREAMS_PER_AUTHORITY,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn asset_stream_budget_is_separate_and_bounded() {
|
||||
let route = DownloadRoute {
|
||||
url: "https://asset-h2-budget.example/file".to_string(),
|
||||
source: DownloadRouteSource::Bmclapi,
|
||||
is_mirror: true,
|
||||
allow_sensitive_headers: false,
|
||||
supports_range: true,
|
||||
proxy: ProxyPolicy::Direct,
|
||||
};
|
||||
let mut permits = Vec::new();
|
||||
for _ in 0..MAX_ASSET_H2_STREAMS_PER_AUTHORITY {
|
||||
permits.push(acquire_asset(&route).await.unwrap());
|
||||
}
|
||||
assert_eq!(asset_budget(&route).unwrap().available_permits(), 0,);
|
||||
drop(permits);
|
||||
assert_eq!(
|
||||
asset_budget(&route).unwrap().available_permits(),
|
||||
MAX_ASSET_H2_STREAMS_PER_AUTHORITY,
|
||||
);
|
||||
assert_eq!(
|
||||
budget(&route).unwrap().available_permits(),
|
||||
MAX_H2_STREAMS_PER_AUTHORITY
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Legacy adaptive download engine.
|
||||
//!
|
||||
//! The native implementation currently lives in `fetch.rs`; this module is
|
||||
//! retained because the download module still exposes the legacy engine as a
|
||||
//! selectable implementation boundary.
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Compact local download-event log.
|
||||
//!
|
||||
//! Only stall/error events are written. Each line is intentionally
|
||||
//! short and numeric so the file stays tiny; `scripts/axolotl/decode-download-log.mjs`
|
||||
//! can turn it back into readable text.
|
||||
|
||||
use super::slow::{SlowEvent, SlowRule};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
|
||||
|
||||
pub fn engine_code(engine: &str) -> u8 {
|
||||
match engine {
|
||||
"xmcl" => 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn source_code(source: &str) -> u8 {
|
||||
match source {
|
||||
"official" => 0,
|
||||
"bmclapi" => 1,
|
||||
"mcim" => 2,
|
||||
"alternate" => 3,
|
||||
"tianpao" => 5,
|
||||
_ => 4,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_stall(event: &SlowEvent) -> String {
|
||||
let rule = match event.rule {
|
||||
SlowRule::R1NoProgress => 1,
|
||||
SlowRule::R2BelowExpectation => 2,
|
||||
SlowRule::R3SegmentWaste => 3,
|
||||
SlowRule::R4FrequentSwitches => 4,
|
||||
};
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
format!(
|
||||
"{timestamp}|{}|{rule}|{}|{}",
|
||||
engine_code(&event.engine),
|
||||
source_code(&event.source),
|
||||
event.detail
|
||||
)
|
||||
}
|
||||
|
||||
pub fn append_stall(log_path: &Path, event: &SlowEvent) -> std::io::Result<()> {
|
||||
rotate_if_needed(log_path)?;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(log_path)?;
|
||||
writeln!(file, "{}", encode_stall(event))
|
||||
}
|
||||
|
||||
fn rotate_if_needed(log_path: &Path) -> std::io::Result<()> {
|
||||
let Ok(metadata) = std::fs::metadata(log_path) else {
|
||||
return Ok(());
|
||||
};
|
||||
if metadata.len() < MAX_LOG_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
let rotated = log_path.with_extension("log.1");
|
||||
if rotated.exists() {
|
||||
std::fs::remove_file(&rotated)?;
|
||||
}
|
||||
std::fs::rename(log_path, rotated)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Modular download engine selection.
|
||||
//!
|
||||
//! The launcher can use either the native adaptive engine (HTTP/2
|
||||
//! multiplexing with shared per-authority connections, falling back to
|
||||
//! HTTP/1.1 single-stream) or the XMCL-compatible engine. The native engine
|
||||
//! is the default; the XMCL engine remains available for users who prefer it.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
pub mod h2_download;
|
||||
pub mod h2_pool;
|
||||
pub(crate) mod h2_range;
|
||||
pub(crate) mod h2_receive;
|
||||
pub(crate) mod h2_stream_budget;
|
||||
pub mod legacy;
|
||||
pub mod log;
|
||||
pub(crate) mod modrinth_redirect;
|
||||
pub(crate) mod native;
|
||||
pub(crate) mod native_breaker;
|
||||
pub(crate) mod native_budget;
|
||||
pub(crate) mod native_reputation;
|
||||
pub(crate) mod native_slow;
|
||||
pub(crate) mod range_output;
|
||||
pub mod shared;
|
||||
pub mod slow;
|
||||
pub mod xmcl;
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize,
|
||||
)]
|
||||
#[repr(u8)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DownloadEngine {
|
||||
#[default]
|
||||
Legacy,
|
||||
#[serde(rename = "xmcl", alias = "xmcl_compat")]
|
||||
XmclCompat,
|
||||
}
|
||||
|
||||
impl DownloadEngine {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Legacy => "legacy",
|
||||
Self::XmclCompat => "xmcl",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Self {
|
||||
match value {
|
||||
"xmcl" | "xmcl_compat" => Self::XmclCompat,
|
||||
_ => Self::Legacy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static ACTIVE_ENGINE: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
/// Returns the engine the launcher should use for new downloads.
|
||||
pub fn active_engine() -> DownloadEngine {
|
||||
match ACTIVE_ENGINE.load(Ordering::Relaxed) {
|
||||
1 => DownloadEngine::XmclCompat,
|
||||
_ => DownloadEngine::Legacy,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the engine used by new downloads.
|
||||
pub fn set_active_engine(engine: DownloadEngine) {
|
||||
ACTIVE_ENGINE.store(engine as u8, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn task_concurrency_limit(state: &crate::State) -> Option<usize> {
|
||||
if active_engine() == DownloadEngine::XmclCompat {
|
||||
None
|
||||
} else {
|
||||
Some(state.download_concurrency())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Modrinth CDN redirect normalization for native download requests.
|
||||
|
||||
use url::Url;
|
||||
|
||||
use crate::util::fetch::{
|
||||
MODRINTH_CDN_LEGACY_HOST, MODRINTH_CDN_OFFICIAL_HOST, TIANPAO_HOST,
|
||||
};
|
||||
|
||||
/// Identifies a Tianpao response that points at one of Modrinth's real CDN
|
||||
/// hosts. The caller uses this only to abandon the mirror request; it must
|
||||
/// follow the server-provided host without rewriting it.
|
||||
pub(crate) fn is_tianpao_official_redirect(
|
||||
current: &Url,
|
||||
location: Option<&str>,
|
||||
) -> bool {
|
||||
current
|
||||
.host_str()
|
||||
.is_some_and(|host| host.eq_ignore_ascii_case(TIANPAO_HOST))
|
||||
&& location.is_some_and(|location| {
|
||||
let Ok(redirect) = current.join(location) else {
|
||||
return false;
|
||||
};
|
||||
redirect.host_str().is_some_and(|host| {
|
||||
host.eq_ignore_ascii_case(MODRINTH_CDN_LEGACY_HOST)
|
||||
|| host.eq_ignore_ascii_case(MODRINTH_CDN_OFFICIAL_HOST)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn repair_official_redirect(
|
||||
original: &Url,
|
||||
redirect: &Url,
|
||||
location: &str,
|
||||
) -> Option<Url> {
|
||||
if location.is_ascii()
|
||||
|| !redirect.host_str().is_some_and(|host| {
|
||||
host.eq_ignore_ascii_case(MODRINTH_CDN_LEGACY_HOST)
|
||||
|| host.eq_ignore_ascii_case(MODRINTH_CDN_OFFICIAL_HOST)
|
||||
})
|
||||
|| original.path().is_empty()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut repaired = redirect.clone();
|
||||
repaired.set_path(original.path());
|
||||
repaired.set_query(original.query());
|
||||
repaired.set_fragment(original.fragment());
|
||||
Some(repaired)
|
||||
}
|
||||
|
||||
pub(crate) fn is_official_redirect(location: Option<&str>) -> bool {
|
||||
let Some(location) = location.filter(|location| {
|
||||
location.len() <= 8 * 1024
|
||||
&& location.is_ascii()
|
||||
&& location
|
||||
.get(..8)
|
||||
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("https://"))
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
let authority = location[8..]
|
||||
.split(['/', '?', '#'])
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
authority.eq_ignore_ascii_case(MODRINTH_CDN_OFFICIAL_HOST)
|
||||
|| authority
|
||||
.eq_ignore_ascii_case(&format!("{MODRINTH_CDN_OFFICIAL_HOST}:443"))
|
||||
|| authority.eq_ignore_ascii_case(MODRINTH_CDN_LEGACY_HOST)
|
||||
|| authority
|
||||
.eq_ignore_ascii_case(&format!("{MODRINTH_CDN_LEGACY_HOST}:443"))
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Policy boundary for the native download engine.
|
||||
|
||||
use crate::util::fetch::{DownloadRoute, ProxyPolicy};
|
||||
|
||||
const SMALL_FILE_LIMIT: u64 = 4 * 1024 * 1024;
|
||||
const MEDIUM_FILE_LIMIT: u64 = 16 * 1024 * 1024;
|
||||
const LARGE_FILE_LIMIT: u64 = 64 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct NativeH2Policy {
|
||||
/// Whether this selection may establish the authority's first H2
|
||||
/// connection instead of requiring an already-warm multiplexed session.
|
||||
pub(crate) allow_cold_connection: bool,
|
||||
pub(crate) abort_if_slow: bool,
|
||||
pub(crate) expected_speed: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum NativeH2IneligibleReason {
|
||||
Http1Fallback,
|
||||
SystemProxy,
|
||||
}
|
||||
|
||||
impl NativeH2IneligibleReason {
|
||||
pub(crate) const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Http1Fallback => "authority is temporarily using HTTP/1.1",
|
||||
Self::SystemProxy => "system proxy requires the reqwest transport",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn h2_ineligible_reason(
|
||||
route: &DownloadRoute,
|
||||
) -> Option<NativeH2IneligibleReason> {
|
||||
let authority = crate::util::fetch::url_authority(&route.url)?;
|
||||
if crate::util::fetch::authority_uses_http1_fallback(&authority) {
|
||||
return Some(NativeH2IneligibleReason::Http1Fallback);
|
||||
}
|
||||
if route.proxy == ProxyPolicy::System && system_proxy_configured() {
|
||||
return Some(NativeH2IneligibleReason::SystemProxy);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn explicit_h2_policy(
|
||||
route: &DownloadRoute,
|
||||
) -> Option<NativeH2Policy> {
|
||||
h2_ineligible_reason(route)
|
||||
.is_none()
|
||||
.then_some(NativeH2Policy {
|
||||
allow_cold_connection: true,
|
||||
abort_if_slow: true,
|
||||
expected_speed: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn h2_policy(
|
||||
route: &DownloadRoute,
|
||||
size: Option<u64>,
|
||||
) -> Option<NativeH2Policy> {
|
||||
if h2_ineligible_reason(route).is_some() {
|
||||
return None;
|
||||
}
|
||||
let Some(size) = size else {
|
||||
return Some(NativeH2Policy {
|
||||
allow_cold_connection: true,
|
||||
abort_if_slow: false,
|
||||
expected_speed: None,
|
||||
});
|
||||
};
|
||||
let authority = crate::util::fetch::url_authority(&route.url)?;
|
||||
let h2 = super::native_reputation::get_transport(
|
||||
&authority,
|
||||
route.proxy,
|
||||
super::native_reputation::NativeTransport::H2Single,
|
||||
);
|
||||
let multi_range = super::native_reputation::get_transport(
|
||||
&authority,
|
||||
route.proxy,
|
||||
super::native_reputation::NativeTransport::Http1MultiRange,
|
||||
);
|
||||
let expected_speed = h2
|
||||
.and_then(|health| health.throughput_bps)
|
||||
.map(|speed| speed.min(u64::MAX as f64) as u64);
|
||||
if size < SMALL_FILE_LIMIT {
|
||||
return Some(NativeH2Policy {
|
||||
allow_cold_connection: true,
|
||||
abort_if_slow: false,
|
||||
expected_speed,
|
||||
});
|
||||
}
|
||||
if size < MEDIUM_FILE_LIMIT {
|
||||
// The connection slot serializes the first handshake, so a batch of
|
||||
// medium files shares one cold-start attempt instead of stampeding the
|
||||
// CDN with independent TCP/TLS connections.
|
||||
return Some(NativeH2Policy {
|
||||
allow_cold_connection: true,
|
||||
abort_if_slow: true,
|
||||
expected_speed,
|
||||
});
|
||||
}
|
||||
if !super::h2_pool::has_live_connection(&authority).await {
|
||||
return None;
|
||||
}
|
||||
let h2 = h2.filter(|health| health.success_samples >= 2)?;
|
||||
let h2_speed = h2.throughput_bps?;
|
||||
let range_speed = multi_range.and_then(|health| health.throughput_bps);
|
||||
if size < LARGE_FILE_LIMIT {
|
||||
let h2_preferred = range_speed
|
||||
.map(|range_speed| h2_speed >= range_speed * 1.1)
|
||||
.unwrap_or(true);
|
||||
return h2_preferred.then_some(NativeH2Policy {
|
||||
allow_cold_connection: false,
|
||||
abort_if_slow: true,
|
||||
expected_speed: Some(h2_speed.min(u64::MAX as f64) as u64),
|
||||
});
|
||||
}
|
||||
let range = multi_range.filter(|health| health.success_samples >= 2)?;
|
||||
let range_speed = range.throughput_bps?;
|
||||
(h2.success_samples >= 3 && h2_speed >= range_speed * 1.25).then_some(
|
||||
NativeH2Policy {
|
||||
allow_cold_connection: false,
|
||||
abort_if_slow: true,
|
||||
expected_speed: Some(h2_speed.min(u64::MAX as f64) as u64),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn system_proxy_configured() -> bool {
|
||||
let environment_proxy = [
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
]
|
||||
.into_iter()
|
||||
.any(|name| std::env::var_os(name).is_some_and(|value| !value.is_empty()));
|
||||
environment_proxy || platform_proxy_configured()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn platform_proxy_configured() -> bool {
|
||||
use winreg::RegKey;
|
||||
use winreg::enums::HKEY_CURRENT_USER;
|
||||
|
||||
let Ok(settings) = RegKey::predef(HKEY_CURRENT_USER).open_subkey(
|
||||
"Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let enabled = settings
|
||||
.get_value::<u32, _>("ProxyEnable")
|
||||
.is_ok_and(|value| value != 0);
|
||||
let auto_configured = settings
|
||||
.get_value::<String, _>("AutoConfigURL")
|
||||
.is_ok_and(|value| !value.trim().is_empty());
|
||||
enabled || auto_configured
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn platform_proxy_configured() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::util::fetch::{DownloadRouteSource, ProxyPolicy};
|
||||
|
||||
fn route(proxy: ProxyPolicy) -> DownloadRoute {
|
||||
DownloadRoute {
|
||||
url: "https://native-policy.example/file".to_string(),
|
||||
source: DownloadRouteSource::Official,
|
||||
is_mirror: false,
|
||||
allow_sensitive_headers: true,
|
||||
supports_range: true,
|
||||
proxy,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_routes_ignore_system_proxy_environment() {
|
||||
assert_ne!(
|
||||
h2_ineligible_reason(&route(ProxyPolicy::Direct)),
|
||||
Some(NativeH2IneligibleReason::SystemProxy)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn medium_file_can_establish_a_cold_h2_connection() {
|
||||
assert_eq!(
|
||||
h2_policy(&route(ProxyPolicy::Direct), Some(8 * 1024 * 1024),)
|
||||
.await,
|
||||
Some(NativeH2Policy {
|
||||
allow_cold_connection: true,
|
||||
abort_if_slow: true,
|
||||
expected_speed: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn small_file_can_establish_h2_without_history() {
|
||||
assert_eq!(
|
||||
h2_policy(&route(ProxyPolicy::Direct), Some(1024 * 1024),).await,
|
||||
Some(NativeH2Policy {
|
||||
allow_cold_connection: true,
|
||||
abort_if_slow: false,
|
||||
expected_speed: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn large_file_still_requires_a_warm_h2_connection_and_history() {
|
||||
assert!(
|
||||
h2_policy(&route(ProxyPolicy::Direct), Some(32 * 1024 * 1024),)
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Short-lived route circuit breaker for native transfers.
|
||||
|
||||
use crate::util::fetch::{DownloadRoute, ProxyPolicy};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const FAILURE_THRESHOLD: u32 = 3;
|
||||
const MAX_FAILURE_COOLDOWN: Duration = Duration::from_secs(1);
|
||||
const DEFAULT_COOLDOWN: Duration = MAX_FAILURE_COOLDOWN;
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
struct BreakerKey {
|
||||
authority: String,
|
||||
proxy: ProxyPolicy,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BreakerState {
|
||||
consecutive_failures: u32,
|
||||
open_until: Option<Instant>,
|
||||
}
|
||||
|
||||
static BREAKERS: LazyLock<Mutex<HashMap<BreakerKey, BreakerState>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
fn key(route: &DownloadRoute) -> Option<BreakerKey> {
|
||||
Some(BreakerKey {
|
||||
authority: crate::util::fetch::url_authority(&route.url)?,
|
||||
proxy: route.proxy,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_open(route: &DownloadRoute) -> bool {
|
||||
let Some(key) = key(route) else {
|
||||
return false;
|
||||
};
|
||||
let mut breakers = BREAKERS.lock();
|
||||
let Some(state) = breakers.get_mut(&key) else {
|
||||
return false;
|
||||
};
|
||||
match state.open_until {
|
||||
Some(until) if until > Instant::now() => true,
|
||||
Some(_) => {
|
||||
state.open_until = None;
|
||||
state.consecutive_failures = 0;
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn should_skip(
|
||||
route: &DownloadRoute,
|
||||
has_healthy_alternate: bool,
|
||||
) -> bool {
|
||||
if !has_healthy_alternate {
|
||||
return false;
|
||||
}
|
||||
is_open(route)
|
||||
}
|
||||
|
||||
pub(crate) fn record_success(route: &DownloadRoute) {
|
||||
let Some(key) = key(route) else {
|
||||
return;
|
||||
};
|
||||
if let Some(state) = BREAKERS.lock().get_mut(&key) {
|
||||
state.consecutive_failures = 0;
|
||||
state.open_until = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_failure(route: &DownloadRoute) {
|
||||
record_failure_with_cooldown(route, DEFAULT_COOLDOWN);
|
||||
}
|
||||
|
||||
pub(crate) fn record_failure_with_cooldown(
|
||||
route: &DownloadRoute,
|
||||
cooldown: Duration,
|
||||
) {
|
||||
let Some(key) = key(route) else {
|
||||
return;
|
||||
};
|
||||
let mut breakers = BREAKERS.lock();
|
||||
let state = breakers.entry(key).or_default();
|
||||
state.consecutive_failures = state.consecutive_failures.saturating_add(1);
|
||||
if state.consecutive_failures >= FAILURE_THRESHOLD {
|
||||
state.open_until =
|
||||
Some(Instant::now() + cooldown.min(MAX_FAILURE_COOLDOWN));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::util::fetch::DownloadRouteSource;
|
||||
|
||||
fn route(host: &str) -> DownloadRoute {
|
||||
DownloadRoute {
|
||||
url: format!("https://{host}/file"),
|
||||
source: DownloadRouteSource::Official,
|
||||
is_mirror: false,
|
||||
allow_sensitive_headers: true,
|
||||
supports_range: true,
|
||||
proxy: ProxyPolicy::Direct,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_route_is_never_skipped() {
|
||||
let route = route("unique-breaker.example");
|
||||
for _ in 0..FAILURE_THRESHOLD {
|
||||
record_failure(&route);
|
||||
}
|
||||
assert!(should_skip(&route, true));
|
||||
assert!(!should_skip(&route, false));
|
||||
record_success(&route);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn externally_requested_cooldown_is_limited_to_one_second() {
|
||||
let route = route("clamped-breaker.example");
|
||||
for _ in 0..FAILURE_THRESHOLD {
|
||||
record_failure_with_cooldown(&route, Duration::from_secs(60));
|
||||
}
|
||||
assert!(is_open(&route));
|
||||
record_success(&route);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Per-authority connection budget for the native download engine.
|
||||
|
||||
use crate::util::fetch::{DownloadRoute, ProxyPolicy};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore, TryAcquireError};
|
||||
use tokio::time::{Duration, sleep};
|
||||
|
||||
const MAX_NATIVE_CONNECTIONS: usize = 32;
|
||||
const MAX_CONNECTIONS_PER_AUTHORITY: usize = 8;
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
struct AuthorityKey {
|
||||
authority: String,
|
||||
proxy: ProxyPolicy,
|
||||
}
|
||||
|
||||
static AUTHORITY_BUDGETS: LazyLock<
|
||||
Mutex<HashMap<AuthorityKey, Arc<Semaphore>>>,
|
||||
> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
static GLOBAL_BUDGET: LazyLock<Arc<Semaphore>> =
|
||||
LazyLock::new(|| Arc::new(Semaphore::new(MAX_NATIVE_CONNECTIONS)));
|
||||
|
||||
pub(crate) struct NativeBudgetPermit {
|
||||
_global: OwnedSemaphorePermit,
|
||||
_authority: Option<OwnedSemaphorePermit>,
|
||||
}
|
||||
|
||||
fn budget(route: &DownloadRoute) -> Option<Arc<Semaphore>> {
|
||||
let authority = crate::util::fetch::url_authority(&route.url)?;
|
||||
let key = AuthorityKey {
|
||||
authority,
|
||||
proxy: route.proxy,
|
||||
};
|
||||
let mut budgets = AUTHORITY_BUDGETS.lock();
|
||||
if budgets.len() >= 256 {
|
||||
budgets.retain(|_, budget| Arc::strong_count(budget) > 1);
|
||||
}
|
||||
Some(
|
||||
budgets
|
||||
.entry(key)
|
||||
.or_insert_with(|| {
|
||||
Arc::new(Semaphore::new(MAX_CONNECTIONS_PER_AUTHORITY))
|
||||
})
|
||||
.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire(
|
||||
route: &DownloadRoute,
|
||||
) -> Result<NativeBudgetPermit, tokio::sync::AcquireError> {
|
||||
let authority_budget = budget(route);
|
||||
loop {
|
||||
let global = Arc::clone(&GLOBAL_BUDGET).acquire_owned().await?;
|
||||
let authority = match authority_budget.as_ref() {
|
||||
Some(budget) => match Arc::clone(budget).try_acquire_owned() {
|
||||
Ok(permit) => Some(permit),
|
||||
Err(TryAcquireError::NoPermits) => {
|
||||
drop(global);
|
||||
sleep(Duration::from_millis(5)).await;
|
||||
continue;
|
||||
}
|
||||
Err(TryAcquireError::Closed) => {
|
||||
unreachable!("native authority budgets are never closed")
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
return Ok(NativeBudgetPermit {
|
||||
_global: global,
|
||||
_authority: authority,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire_many(
|
||||
route: &DownloadRoute,
|
||||
count: usize,
|
||||
) -> Result<Vec<NativeBudgetPermit>, tokio::sync::AcquireError> {
|
||||
let authority_budget = budget(route);
|
||||
let (global, authority) = loop {
|
||||
let global = Arc::clone(&GLOBAL_BUDGET)
|
||||
.acquire_many_owned(count as u32)
|
||||
.await?;
|
||||
let authority = match authority_budget.as_ref() {
|
||||
Some(budget) => {
|
||||
match Arc::clone(budget).try_acquire_many_owned(count as u32) {
|
||||
Ok(permit) => Some(permit),
|
||||
Err(TryAcquireError::NoPermits) => {
|
||||
drop(global);
|
||||
sleep(Duration::from_millis(5)).await;
|
||||
continue;
|
||||
}
|
||||
Err(TryAcquireError::Closed) => unreachable!(
|
||||
"native authority budgets are never closed"
|
||||
),
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
break (global, authority);
|
||||
};
|
||||
let mut global = global;
|
||||
let mut authority = authority;
|
||||
let mut permits = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let global = global
|
||||
.split(1)
|
||||
.expect("native global permit batch has enough permits");
|
||||
let authority = authority.as_mut().map(|authority| {
|
||||
authority
|
||||
.split(1)
|
||||
.expect("native authority permit batch has enough permits")
|
||||
});
|
||||
permits.push(NativeBudgetPermit {
|
||||
_global: global,
|
||||
_authority: authority,
|
||||
});
|
||||
}
|
||||
Ok(permits)
|
||||
}
|
||||
|
||||
pub(crate) fn try_acquire(
|
||||
route: &DownloadRoute,
|
||||
) -> Result<NativeBudgetPermit, TryAcquireError> {
|
||||
let global = Arc::clone(&GLOBAL_BUDGET).try_acquire_owned()?;
|
||||
let authority = match budget(route) {
|
||||
Some(budget) => match budget.try_acquire_owned() {
|
||||
Ok(permit) => Some(permit),
|
||||
Err(error) => return Err(error),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
Ok(NativeBudgetPermit {
|
||||
_global: global,
|
||||
_authority: authority,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn available(route: &DownloadRoute) -> usize {
|
||||
budget(route)
|
||||
.map(|budget| budget.available_permits())
|
||||
.unwrap_or(MAX_CONNECTIONS_PER_AUTHORITY)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::util::fetch::DownloadRouteSource;
|
||||
|
||||
fn route() -> DownloadRoute {
|
||||
DownloadRoute {
|
||||
url: "https://budget.example/file".to_string(),
|
||||
source: DownloadRouteSource::Official,
|
||||
is_mirror: false,
|
||||
allow_sensitive_headers: true,
|
||||
supports_range: true,
|
||||
proxy: ProxyPolicy::Direct,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authority_budget_is_bounded() {
|
||||
let route = route();
|
||||
let mut permits = Vec::new();
|
||||
for _ in 0..MAX_CONNECTIONS_PER_AUTHORITY {
|
||||
permits.push(acquire(&route).await.unwrap());
|
||||
}
|
||||
assert!(matches!(
|
||||
try_acquire(&route),
|
||||
Err(TryAcquireError::NoPermits)
|
||||
));
|
||||
drop(permits);
|
||||
assert!(try_acquire(&route).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//! Persisted route reputation for the native download engine.
|
||||
|
||||
use crate::util::fetch::ProxyPolicy;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{LazyLock, OnceLock};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
const FILE_NAME: &str = "native-download-reputation.json";
|
||||
const SCHEMA_VERSION: u32 = 1;
|
||||
const MAX_ENTRIES: usize = 256;
|
||||
const MAX_AGE_SECS: u64 = 7 * 24 * 60 * 60;
|
||||
const WRITE_DELAY: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum NativeTransport {
|
||||
H2Single,
|
||||
H2MultiRange,
|
||||
Http1MultiRange,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
struct ReputationKey {
|
||||
family: String,
|
||||
authority: String,
|
||||
proxy: ProxyPolicy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct NativeRouteReputation {
|
||||
pub(crate) success_samples: u32,
|
||||
pub(crate) failure_samples: u32,
|
||||
#[serde(default)]
|
||||
pub(crate) consecutive_failures: u32,
|
||||
pub(crate) ttfb_ms: Option<f64>,
|
||||
pub(crate) throughput_bps: Option<f64>,
|
||||
updated_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct PersistedStore {
|
||||
version: u32,
|
||||
routes: Vec<PersistedRoute>,
|
||||
#[serde(default)]
|
||||
transports: Vec<PersistedTransport>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct PersistedRoute {
|
||||
family: String,
|
||||
authority: String,
|
||||
proxy: ProxyPolicy,
|
||||
#[serde(flatten)]
|
||||
health: NativeRouteReputation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct NativeTransportReputation {
|
||||
pub(crate) success_samples: u32,
|
||||
pub(crate) throughput_bps: Option<f64>,
|
||||
updated_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
struct TransportKey {
|
||||
authority: String,
|
||||
proxy: ProxyPolicy,
|
||||
transport: NativeTransport,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct PersistedTransport {
|
||||
authority: String,
|
||||
proxy: ProxyPolicy,
|
||||
transport: NativeTransport,
|
||||
#[serde(flatten)]
|
||||
health: NativeTransportReputation,
|
||||
}
|
||||
|
||||
static REPUTATION: LazyLock<
|
||||
Mutex<HashMap<ReputationKey, NativeRouteReputation>>,
|
||||
> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
static TRANSPORT_REPUTATION: LazyLock<
|
||||
Mutex<HashMap<TransportKey, NativeTransportReputation>>,
|
||||
> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
static LOAD: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
|
||||
static PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||
static GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
static WRITE_SCHEDULED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub(crate) async fn load_if_needed() {
|
||||
LOAD.get_or_init(|| async {
|
||||
let Some(path) = reputation_path() else {
|
||||
return;
|
||||
};
|
||||
let _ = PATH.set(path.clone());
|
||||
let Ok(contents) = tokio::fs::read(&path).await else {
|
||||
return;
|
||||
};
|
||||
let Ok(store) = serde_json::from_slice::<PersistedStore>(&contents) else {
|
||||
tracing::warn!(path = %path.display(), "Ignoring invalid native download reputation");
|
||||
return;
|
||||
};
|
||||
if store.version != SCHEMA_VERSION {
|
||||
return;
|
||||
}
|
||||
let cutoff = now_secs().saturating_sub(MAX_AGE_SECS);
|
||||
let mut reputation = REPUTATION.lock();
|
||||
for route in store.routes.into_iter().take(MAX_ENTRIES) {
|
||||
if route.health.updated_at < cutoff {
|
||||
continue;
|
||||
}
|
||||
reputation.insert(
|
||||
ReputationKey {
|
||||
family: route.family,
|
||||
authority: route.authority,
|
||||
proxy: route.proxy,
|
||||
},
|
||||
route.health,
|
||||
);
|
||||
}
|
||||
drop(reputation);
|
||||
let mut transports = TRANSPORT_REPUTATION.lock();
|
||||
for transport in store.transports.into_iter().take(MAX_ENTRIES) {
|
||||
if transport.health.updated_at < cutoff {
|
||||
continue;
|
||||
}
|
||||
transports.insert(
|
||||
TransportKey {
|
||||
authority: transport.authority,
|
||||
proxy: transport.proxy,
|
||||
transport: transport.transport,
|
||||
},
|
||||
transport.health,
|
||||
);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) fn get_transport(
|
||||
authority: &str,
|
||||
proxy: ProxyPolicy,
|
||||
transport: NativeTransport,
|
||||
) -> Option<NativeTransportReputation> {
|
||||
TRANSPORT_REPUTATION
|
||||
.lock()
|
||||
.get(&TransportKey {
|
||||
authority: authority.to_string(),
|
||||
proxy,
|
||||
transport,
|
||||
})
|
||||
.copied()
|
||||
}
|
||||
|
||||
pub(crate) fn record_transport_success(
|
||||
authority: &str,
|
||||
proxy: ProxyPolicy,
|
||||
transport: NativeTransport,
|
||||
throughput_bps: f64,
|
||||
) {
|
||||
if !throughput_bps.is_finite() || throughput_bps <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let mut reputation = TRANSPORT_REPUTATION.lock();
|
||||
let entry = reputation
|
||||
.entry(TransportKey {
|
||||
authority: authority.to_string(),
|
||||
proxy,
|
||||
transport,
|
||||
})
|
||||
.or_default();
|
||||
entry.success_samples = entry.success_samples.saturating_add(1);
|
||||
entry.throughput_bps =
|
||||
Some(update_ewma(entry.throughput_bps, throughput_bps));
|
||||
entry.updated_at = now_secs();
|
||||
drop(reputation);
|
||||
schedule_write();
|
||||
}
|
||||
|
||||
pub(crate) fn get(
|
||||
family: &str,
|
||||
authority: &str,
|
||||
proxy: ProxyPolicy,
|
||||
) -> Option<NativeRouteReputation> {
|
||||
REPUTATION
|
||||
.lock()
|
||||
.get(&ReputationKey {
|
||||
family: family.to_string(),
|
||||
authority: authority.to_string(),
|
||||
proxy,
|
||||
})
|
||||
.copied()
|
||||
}
|
||||
|
||||
pub(crate) fn record_success(
|
||||
family: &str,
|
||||
authority: &str,
|
||||
proxy: ProxyPolicy,
|
||||
ttfb_ms: f64,
|
||||
throughput_bps: Option<f64>,
|
||||
) {
|
||||
let mut reputation = REPUTATION.lock();
|
||||
let entry = reputation
|
||||
.entry(ReputationKey {
|
||||
family: family.to_string(),
|
||||
authority: authority.to_string(),
|
||||
proxy,
|
||||
})
|
||||
.or_default();
|
||||
entry.success_samples = entry.success_samples.saturating_add(1);
|
||||
entry.consecutive_failures = 0;
|
||||
entry.ttfb_ms = Some(update_ewma(entry.ttfb_ms, ttfb_ms));
|
||||
if let Some(throughput_bps) = throughput_bps {
|
||||
entry.throughput_bps =
|
||||
Some(update_ewma(entry.throughput_bps, throughput_bps));
|
||||
}
|
||||
entry.updated_at = now_secs();
|
||||
drop(reputation);
|
||||
schedule_write();
|
||||
}
|
||||
|
||||
pub(crate) fn record_transfer_success(
|
||||
family: &str,
|
||||
authority: &str,
|
||||
proxy: ProxyPolicy,
|
||||
throughput_bps: f64,
|
||||
) {
|
||||
if !throughput_bps.is_finite() || throughput_bps <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let mut reputation = REPUTATION.lock();
|
||||
let entry = reputation
|
||||
.entry(ReputationKey {
|
||||
family: family.to_string(),
|
||||
authority: authority.to_string(),
|
||||
proxy,
|
||||
})
|
||||
.or_default();
|
||||
entry.success_samples = entry.success_samples.saturating_add(1);
|
||||
entry.consecutive_failures = 0;
|
||||
entry.throughput_bps =
|
||||
Some(update_ewma(entry.throughput_bps, throughput_bps));
|
||||
entry.updated_at = now_secs();
|
||||
drop(reputation);
|
||||
schedule_write();
|
||||
}
|
||||
|
||||
pub(crate) fn record_failure(
|
||||
family: &str,
|
||||
authority: &str,
|
||||
proxy: ProxyPolicy,
|
||||
) {
|
||||
let mut reputation = REPUTATION.lock();
|
||||
let entry = reputation
|
||||
.entry(ReputationKey {
|
||||
family: family.to_string(),
|
||||
authority: authority.to_string(),
|
||||
proxy,
|
||||
})
|
||||
.or_default();
|
||||
entry.failure_samples = entry.failure_samples.saturating_add(1);
|
||||
entry.consecutive_failures = entry.consecutive_failures.saturating_add(1);
|
||||
entry.updated_at = now_secs();
|
||||
drop(reputation);
|
||||
schedule_write();
|
||||
}
|
||||
|
||||
fn update_ewma(current: Option<f64>, sample: f64) -> f64 {
|
||||
current.map_or(sample, |current| current * 0.75 + sample * 0.25)
|
||||
}
|
||||
|
||||
fn schedule_write() {
|
||||
GENERATION.fetch_add(1, Ordering::AcqRel);
|
||||
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
|
||||
return;
|
||||
};
|
||||
if WRITE_SCHEDULED.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
runtime.spawn(async {
|
||||
loop {
|
||||
let generation = GENERATION.load(Ordering::Acquire);
|
||||
tokio::time::sleep(WRITE_DELAY).await;
|
||||
if let Err(error) = write_snapshot().await {
|
||||
tracing::warn!(%error, "Failed to persist native download reputation");
|
||||
}
|
||||
if GENERATION.load(Ordering::Acquire) == generation {
|
||||
WRITE_SCHEDULED.store(false, Ordering::Release);
|
||||
if GENERATION.load(Ordering::Acquire) == generation
|
||||
|| WRITE_SCHEDULED.swap(true, Ordering::AcqRel)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn write_snapshot() -> std::io::Result<()> {
|
||||
let Some(path) = PATH.get().cloned().or_else(reputation_path) else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut routes = REPUTATION
|
||||
.lock()
|
||||
.iter()
|
||||
.map(|(key, health)| PersistedRoute {
|
||||
family: key.family.clone(),
|
||||
authority: key.authority.clone(),
|
||||
proxy: key.proxy,
|
||||
health: *health,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
routes.sort_unstable_by_key(|route| {
|
||||
std::cmp::Reverse(route.health.updated_at)
|
||||
});
|
||||
routes.truncate(MAX_ENTRIES);
|
||||
let mut transports = TRANSPORT_REPUTATION
|
||||
.lock()
|
||||
.iter()
|
||||
.map(|(key, health)| PersistedTransport {
|
||||
authority: key.authority.clone(),
|
||||
proxy: key.proxy,
|
||||
transport: key.transport,
|
||||
health: *health,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
transports.sort_unstable_by_key(|transport| {
|
||||
std::cmp::Reverse(transport.health.updated_at)
|
||||
});
|
||||
transports.truncate(MAX_ENTRIES);
|
||||
let bytes = serde_json::to_vec(&PersistedStore {
|
||||
version: SCHEMA_VERSION,
|
||||
routes,
|
||||
transports,
|
||||
})
|
||||
.map_err(std::io::Error::other)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let temporary = temporary_path(&path);
|
||||
tokio::fs::write(&temporary, bytes).await?;
|
||||
if let Err(error) = tokio::fs::rename(&temporary, &path).await {
|
||||
if error.kind() != std::io::ErrorKind::AlreadyExists {
|
||||
let _ = tokio::fs::remove_file(&temporary).await;
|
||||
return Err(error);
|
||||
}
|
||||
tokio::fs::remove_file(&path).await?;
|
||||
tokio::fs::rename(&temporary, &path).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reputation_path() -> Option<PathBuf> {
|
||||
crate::State::get_if_initialized()
|
||||
.map(|state| state.directories.settings_dir.join(FILE_NAME))
|
||||
}
|
||||
|
||||
fn temporary_path(path: &Path) -> PathBuf {
|
||||
let mut name = path.file_name().unwrap_or_default().to_os_string();
|
||||
name.push(".tmp");
|
||||
path.with_file_name(name)
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn transport_reputation_is_independent() {
|
||||
record_transport_success(
|
||||
"transport-reputation.example:443",
|
||||
ProxyPolicy::Direct,
|
||||
NativeTransport::H2Single,
|
||||
1024.0,
|
||||
);
|
||||
assert!(
|
||||
get_transport(
|
||||
"transport-reputation.example:443",
|
||||
ProxyPolicy::Direct,
|
||||
NativeTransport::H2Single,
|
||||
)
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
get_transport(
|
||||
"transport-reputation.example:443",
|
||||
ProxyPolicy::Direct,
|
||||
NativeTransport::Http1MultiRange,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
get_transport(
|
||||
"transport-reputation.example:443",
|
||||
ProxyPolicy::Direct,
|
||||
NativeTransport::H2MultiRange,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
record_transport_success(
|
||||
"transport-reputation.example:443",
|
||||
ProxyPolicy::Direct,
|
||||
NativeTransport::H2MultiRange,
|
||||
4096.0,
|
||||
);
|
||||
assert_eq!(
|
||||
get_transport(
|
||||
"transport-reputation.example:443",
|
||||
ProxyPolicy::Direct,
|
||||
NativeTransport::H2Single,
|
||||
)
|
||||
.unwrap()
|
||||
.throughput_bps,
|
||||
Some(1024.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Slow-transfer and optimal-stop policy for the native download engine.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const WINDOW: Duration = Duration::from_secs(3);
|
||||
const MIN_FLOW: Duration = Duration::from_secs(10);
|
||||
const REQUIRED_SLOW_WINDOWS: u8 = 2;
|
||||
const MIN_REMAINING_BYTES: u64 = 1024 * 1024;
|
||||
const ABSOLUTE_SPEED_FLOOR: u64 = 16 * 1024;
|
||||
const COLD_SPEED_FLOOR: u64 = 256 * 1024;
|
||||
const RECONNECT_OVERHEAD: Duration = Duration::from_millis(600);
|
||||
const MIN_SAVINGS: Duration = Duration::from_secs(2);
|
||||
#[cfg(not(test))]
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
#[cfg(test)]
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum SlowDecision {
|
||||
Continue,
|
||||
Probe { bytes_per_second: u64 },
|
||||
Idle { elapsed: Duration },
|
||||
Commit,
|
||||
}
|
||||
|
||||
pub(crate) struct NativeSlowPolicy {
|
||||
started_at: Instant,
|
||||
window_started_at: Instant,
|
||||
window_start_bytes: u64,
|
||||
slow_windows: u8,
|
||||
committed: bool,
|
||||
expected_speed: Option<u64>,
|
||||
}
|
||||
|
||||
impl NativeSlowPolicy {
|
||||
pub(crate) fn new(
|
||||
starting_bytes: u64,
|
||||
expected_speed: Option<u64>,
|
||||
) -> Self {
|
||||
let now = Instant::now();
|
||||
Self {
|
||||
started_at: now,
|
||||
window_started_at: now,
|
||||
window_start_bytes: starting_bytes,
|
||||
slow_windows: 0,
|
||||
committed: false,
|
||||
expected_speed,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn observe(
|
||||
&mut self,
|
||||
downloaded: u64,
|
||||
remaining: u64,
|
||||
) -> SlowDecision {
|
||||
if self.committed {
|
||||
return SlowDecision::Continue;
|
||||
}
|
||||
let elapsed = self.window_started_at.elapsed();
|
||||
if downloaded == self.window_start_bytes && elapsed >= IDLE_TIMEOUT {
|
||||
return SlowDecision::Idle { elapsed };
|
||||
}
|
||||
if elapsed < WINDOW {
|
||||
return SlowDecision::Continue;
|
||||
}
|
||||
let speed = downloaded
|
||||
.saturating_sub(self.window_start_bytes)
|
||||
.checked_div(elapsed.as_secs().max(1))
|
||||
.unwrap_or(0);
|
||||
self.window_started_at = Instant::now();
|
||||
self.window_start_bytes = downloaded;
|
||||
|
||||
if remaining < MIN_REMAINING_BYTES
|
||||
|| estimated_duration(remaining, speed)
|
||||
<= RECONNECT_OVERHEAD + MIN_SAVINGS
|
||||
{
|
||||
self.committed = true;
|
||||
return SlowDecision::Commit;
|
||||
}
|
||||
let expected_floor = self
|
||||
.expected_speed
|
||||
.map(|expected| expected.saturating_mul(40) / 100)
|
||||
.unwrap_or(COLD_SPEED_FLOOR)
|
||||
.max(ABSOLUTE_SPEED_FLOOR);
|
||||
if speed < expected_floor {
|
||||
self.slow_windows = self.slow_windows.saturating_add(1);
|
||||
} else {
|
||||
self.slow_windows = 0;
|
||||
}
|
||||
if self.started_at.elapsed() >= MIN_FLOW
|
||||
&& self.slow_windows >= REQUIRED_SLOW_WINDOWS
|
||||
{
|
||||
self.slow_windows = 0;
|
||||
SlowDecision::Probe {
|
||||
bytes_per_second: speed,
|
||||
}
|
||||
} else {
|
||||
SlowDecision::Continue
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn commit(&mut self) {
|
||||
self.committed = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn should_switch(
|
||||
current_speed: u64,
|
||||
candidate_speed: u64,
|
||||
remaining_bytes: u64,
|
||||
restart_bytes: u64,
|
||||
) -> bool {
|
||||
if current_speed == 0 || candidate_speed == 0 {
|
||||
return false;
|
||||
}
|
||||
if candidate_speed.saturating_mul(100) < current_speed.saturating_mul(125) {
|
||||
return false;
|
||||
}
|
||||
let stay = estimated_duration(remaining_bytes, current_speed);
|
||||
let switch =
|
||||
RECONNECT_OVERHEAD + estimated_duration(restart_bytes, candidate_speed);
|
||||
let required_savings = MIN_SAVINGS.max(stay.mul_f64(0.15));
|
||||
switch.saturating_add(required_savings) < stay
|
||||
}
|
||||
|
||||
fn estimated_duration(bytes: u64, speed: u64) -> Duration {
|
||||
if speed == 0 {
|
||||
return Duration::MAX;
|
||||
}
|
||||
Duration::from_secs_f64(bytes as f64 / speed as f64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn switch_requires_repayment_of_restarted_bytes() {
|
||||
assert!(!should_switch(1024, 2048, 1024, 1024 * 1024));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faster_candidate_wins_early_in_a_large_download() {
|
||||
assert!(should_switch(
|
||||
256 * 1024,
|
||||
2 * 1024 * 1024,
|
||||
64 * 1024 * 1024,
|
||||
64 * 1024 * 1024,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_body_is_a_terminal_recovery_decision() {
|
||||
let mut policy = NativeSlowPolicy::new(0, None);
|
||||
std::thread::sleep(IDLE_TIMEOUT + Duration::from_millis(10));
|
||||
assert!(matches!(
|
||||
policy.observe(0, 64 * 1024 * 1024),
|
||||
SlowDecision::Idle { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//! Positional output for native HTTP/1.1 and HTTP/2 range downloads.
|
||||
|
||||
use crate::util::io::{self, IOError};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::fs::{File, OpenOptions};
|
||||
use tokio::io::{AsyncSeekExt, AsyncWriteExt, BufWriter, SeekFrom};
|
||||
|
||||
const RANGE_WRITER_BUFFER_CAPACITY: usize = 256 * 1024;
|
||||
|
||||
#[cfg(test)]
|
||||
struct RangeWriteTestProbe {
|
||||
delay: std::time::Duration,
|
||||
in_flight: std::sync::atomic::AtomicUsize,
|
||||
max_in_flight: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static RANGE_WRITE_TEST_PROBE: std::sync::Mutex<
|
||||
Option<Arc<RangeWriteTestProbe>>,
|
||||
> = std::sync::Mutex::new(None);
|
||||
|
||||
/// A preallocated `.part` file. Writers open their own handle so a slow range
|
||||
/// never serializes unrelated ranges behind a file mutex.
|
||||
pub(crate) struct RangeOutput {
|
||||
path: PathBuf,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
/// An independent sequential writer for one half-open byte range.
|
||||
pub(crate) struct RangeWriter {
|
||||
file: BufWriter<File>,
|
||||
offset: u64,
|
||||
end_exclusive: u64,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl RangeOutput {
|
||||
pub(crate) async fn create(
|
||||
path: &Path,
|
||||
size: u64,
|
||||
) -> Result<Arc<Self>, IOError> {
|
||||
io::retry_windows_sharing_violation(
|
||||
path,
|
||||
"creating ranged download output",
|
||||
|| async {
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(path)
|
||||
.await?;
|
||||
file.set_len(size).await?;
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| io::io_error_with_lock_info(error, path))?;
|
||||
Ok(Arc::new(Self {
|
||||
path: path.to_path_buf(),
|
||||
size,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn open_range(
|
||||
&self,
|
||||
start: u64,
|
||||
end_exclusive: u64,
|
||||
) -> Result<RangeWriter, IOError> {
|
||||
if start >= end_exclusive || end_exclusive > self.size {
|
||||
return Err(IOError::with_path(
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"invalid range bounds",
|
||||
),
|
||||
&self.path,
|
||||
));
|
||||
}
|
||||
let path = self.path.clone();
|
||||
let mut file = io::retry_windows_sharing_violation(
|
||||
&path,
|
||||
"opening ranged download output",
|
||||
|| async {
|
||||
OpenOptions::new().read(true).write(true).open(&path).await
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| io::io_error_with_lock_info(error, &path))?;
|
||||
file.seek(SeekFrom::Start(start))
|
||||
.await
|
||||
.map_err(|error| io::io_error_with_lock_info(error, &path))?;
|
||||
Ok(RangeWriter {
|
||||
file: BufWriter::with_capacity(RANGE_WRITER_BUFFER_CAPACITY, file),
|
||||
offset: start,
|
||||
end_exclusive,
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Legacy adapter; not used by the concurrent range hot path.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn write_at(
|
||||
&self,
|
||||
offset: u64,
|
||||
bytes: &[u8],
|
||||
path: &Path,
|
||||
) -> Result<(), IOError> {
|
||||
let end = offset.checked_add(bytes.len() as u64).ok_or_else(|| {
|
||||
IOError::with_path(
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"offset overflow",
|
||||
),
|
||||
path,
|
||||
)
|
||||
})?;
|
||||
let mut writer = self.open_range(offset, end).await?;
|
||||
writer.write_next(bytes).await?;
|
||||
writer.flush().await
|
||||
}
|
||||
|
||||
/// Legacy adapter for callers that previously flushed the shared handle.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn flush(&self, path: &Path) -> Result<(), IOError> {
|
||||
let mut file = io::retry_windows_sharing_violation(
|
||||
path,
|
||||
"flushing ranged download output",
|
||||
|| async {
|
||||
OpenOptions::new().read(true).write(true).open(path).await
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| io::io_error_with_lock_info(error, path))?;
|
||||
file.flush()
|
||||
.await
|
||||
.map_err(|error| io::io_error_with_lock_info(error, path))
|
||||
}
|
||||
}
|
||||
|
||||
impl RangeWriter {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn offset(&self) -> u64 {
|
||||
self.offset
|
||||
}
|
||||
|
||||
pub(crate) fn remaining(&self) -> u64 {
|
||||
self.end_exclusive.saturating_sub(self.offset)
|
||||
}
|
||||
|
||||
pub(crate) async fn write_next(
|
||||
&mut self,
|
||||
bytes: &[u8],
|
||||
) -> Result<(), IOError> {
|
||||
if bytes.len() as u64 > self.remaining() {
|
||||
return Err(IOError::with_path(
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"write exceeds range",
|
||||
),
|
||||
&self.path,
|
||||
));
|
||||
}
|
||||
#[cfg(test)]
|
||||
let probe = RANGE_WRITE_TEST_PROBE.lock().unwrap().clone();
|
||||
#[cfg(test)]
|
||||
if let Some(probe) = &probe {
|
||||
let in_flight = probe
|
||||
.in_flight
|
||||
.fetch_add(1, std::sync::atomic::Ordering::AcqRel)
|
||||
+ 1;
|
||||
probe
|
||||
.max_in_flight
|
||||
.fetch_max(in_flight, std::sync::atomic::Ordering::AcqRel);
|
||||
tokio::time::sleep(probe.delay).await;
|
||||
}
|
||||
|
||||
let result =
|
||||
self.file.write_all(bytes).await.map_err(|error| {
|
||||
io::io_error_with_lock_info(error, &self.path)
|
||||
});
|
||||
#[cfg(test)]
|
||||
if let Some(probe) = probe {
|
||||
probe
|
||||
.in_flight
|
||||
.fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
|
||||
}
|
||||
result?;
|
||||
self.offset += bytes.len() as u64;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn flush(&mut self) -> Result<(), IOError> {
|
||||
self.file
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|error| io::io_error_with_lock_info(error, &self.path))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures::future::try_join_all;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
#[tokio::test]
|
||||
async fn writes_non_overlapping_ranges_with_independent_writers() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("ranged.part");
|
||||
let output = RangeOutput::create(&path, 12).await.unwrap();
|
||||
let mut a = output.open_range(0, 4).await.unwrap();
|
||||
let mut b = output.open_range(4, 8).await.unwrap();
|
||||
let mut c = output.open_range(8, 12).await.unwrap();
|
||||
a.write_next(b"abcd").await.unwrap();
|
||||
b.write_next(b"efgh").await.unwrap();
|
||||
c.write_next(b"ijkl").await.unwrap();
|
||||
a.flush().await.unwrap();
|
||||
b.flush().await.unwrap();
|
||||
c.flush().await.unwrap();
|
||||
assert_eq!(tokio::fs::read(path).await.unwrap(), b"abcdefghijkl");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_writes_beyond_range() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("ranged.part");
|
||||
let output = RangeOutput::create(&path, 8).await.unwrap();
|
||||
let mut writer = output.open_range(0, 4).await.unwrap();
|
||||
assert!(writer.write_next(b"12345").await.is_err());
|
||||
assert_eq!(writer.offset(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_empty_and_out_of_file_ranges() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("ranged.part");
|
||||
let output = RangeOutput::create(&path, 8).await.unwrap();
|
||||
assert!(output.open_range(2, 2).await.is_err());
|
||||
assert!(output.open_range(7, 9).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn buffers_small_exact_and_large_writes() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("ranged.part");
|
||||
let small = vec![1_u8; 1024];
|
||||
let exact = vec![2_u8; RANGE_WRITER_BUFFER_CAPACITY];
|
||||
let large = vec![3_u8; RANGE_WRITER_BUFFER_CAPACITY + 1];
|
||||
let total = small.len() + exact.len() + large.len();
|
||||
let output = RangeOutput::create(&path, total as u64).await.unwrap();
|
||||
let mut writer = output.open_range(0, total as u64).await.unwrap();
|
||||
|
||||
writer.write_next(&small).await.unwrap();
|
||||
writer.write_next(&exact).await.unwrap();
|
||||
writer.write_next(&large).await.unwrap();
|
||||
writer.flush().await.unwrap();
|
||||
|
||||
let mut expected = small;
|
||||
expected.extend(exact);
|
||||
expected.extend(large);
|
||||
assert_eq!(tokio::fs::read(path).await.unwrap(), expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_writers_preserve_all_ranges_and_overlap() {
|
||||
let probe = Arc::new(RangeWriteTestProbe {
|
||||
delay: std::time::Duration::from_millis(2),
|
||||
in_flight: std::sync::atomic::AtomicUsize::new(0),
|
||||
max_in_flight: std::sync::atomic::AtomicUsize::new(0),
|
||||
});
|
||||
*RANGE_WRITE_TEST_PROBE.lock().unwrap() = Some(Arc::clone(&probe));
|
||||
|
||||
for range_count in [2, 4, 8] {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("ranged.part");
|
||||
let range_length = 32 * 1024;
|
||||
let expected = (0..range_length * range_count)
|
||||
.map(|index| (index % 251) as u8)
|
||||
.collect::<Vec<_>>();
|
||||
let output = RangeOutput::create(&path, expected.len() as u64)
|
||||
.await
|
||||
.unwrap();
|
||||
let writes = (0..range_count).map(|index| {
|
||||
let output = Arc::clone(&output);
|
||||
let start = index * range_length;
|
||||
let bytes = expected[start..start + range_length].to_vec();
|
||||
async move {
|
||||
let mut writer = output
|
||||
.open_range(start as u64, (start + range_length) as u64)
|
||||
.await?;
|
||||
let mut offset = 0;
|
||||
while offset < bytes.len() {
|
||||
let length = ((index * 521 + offset) % 4096 + 1)
|
||||
.min(bytes.len() - offset);
|
||||
writer
|
||||
.write_next(&bytes[offset..offset + length])
|
||||
.await?;
|
||||
offset += length;
|
||||
}
|
||||
writer.flush().await
|
||||
}
|
||||
});
|
||||
try_join_all(writes).await.unwrap();
|
||||
assert_eq!(tokio::fs::read(path).await.unwrap(), expected);
|
||||
}
|
||||
*RANGE_WRITE_TEST_PROBE.lock().unwrap() = None;
|
||||
|
||||
assert!(probe.max_in_flight.load(Ordering::Acquire) > 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#![allow(dead_code)]
|
||||
//! Shared download helpers used by both the legacy and XMCL-compatible
|
||||
//! engines.
|
||||
//!
|
||||
//! Route resolution, integrity verification, `.part` handling, atomic
|
||||
//! rename, and Windows file-lock retry logic will move here as the
|
||||
//! legacy engine is split out of `fetch.rs`.
|
||||
|
||||
pub const SEGMENTED_DOWNLOAD_THRESHOLD: u64 = 512 * 1024;
|
||||
pub const XMCL_RANGE_CONCURRENCY: usize = 4;
|
||||
pub const XMCL_BMCL_CONCURRENCY: usize = 16;
|
||||
pub const XMCL_OTHER_CONCURRENCY: usize = 16;
|
||||
@@ -0,0 +1,244 @@
|
||||
#![allow(dead_code)]
|
||||
//! Slow-download detection.
|
||||
//!
|
||||
//! The detector is intentionally local and cheap. It only fires while a
|
||||
//! download is stalled or wasting work, and the resulting events are
|
||||
//! written to the local compact log.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SlowRule {
|
||||
R1NoProgress,
|
||||
R2BelowExpectation,
|
||||
R3SegmentWaste,
|
||||
R4FrequentSwitches,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SlowPreset {
|
||||
#[default]
|
||||
Sensitive,
|
||||
Standard,
|
||||
Relaxed,
|
||||
}
|
||||
|
||||
impl SlowPreset {
|
||||
pub const fn no_progress_secs(self) -> u64 {
|
||||
match self {
|
||||
Self::Sensitive => 3,
|
||||
Self::Standard => 5,
|
||||
Self::Relaxed => 10,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn min_speed_bps(self) -> u64 {
|
||||
match self {
|
||||
Self::Sensitive => 64 * 1024,
|
||||
Self::Standard => 32 * 1024,
|
||||
Self::Relaxed => 16 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn min_remaining_bytes(self) -> u64 {
|
||||
match self {
|
||||
Self::Sensitive => 256 * 1024,
|
||||
Self::Standard => 1024 * 1024,
|
||||
Self::Relaxed => 4 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn max_segment_waste_ratio(self) -> f64 {
|
||||
match self {
|
||||
Self::Sensitive => 0.15,
|
||||
Self::Standard => 0.25,
|
||||
Self::Relaxed => 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn max_switches_per_file(self) -> u32 {
|
||||
match self {
|
||||
Self::Sensitive => 2,
|
||||
Self::Standard => 4,
|
||||
Self::Relaxed => 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlowEvent {
|
||||
pub rule: SlowRule,
|
||||
pub engine: String,
|
||||
pub source: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SlowDetector {
|
||||
preset: SlowPreset,
|
||||
started_at: Option<Instant>,
|
||||
last_progress_at: Option<Instant>,
|
||||
last_bytes: u64,
|
||||
total: Option<u64>,
|
||||
segment_waste: u64,
|
||||
route_switches: u32,
|
||||
}
|
||||
|
||||
impl Default for SlowDetector {
|
||||
fn default() -> Self {
|
||||
Self::new(SlowPreset::Standard)
|
||||
}
|
||||
}
|
||||
|
||||
impl SlowDetector {
|
||||
pub fn new(preset: SlowPreset) -> Self {
|
||||
Self {
|
||||
preset,
|
||||
started_at: None,
|
||||
last_progress_at: None,
|
||||
last_bytes: 0,
|
||||
total: None,
|
||||
segment_waste: 0,
|
||||
route_switches: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&mut self, total: Option<u64>) {
|
||||
let now = Instant::now();
|
||||
self.started_at = Some(now);
|
||||
self.last_progress_at = Some(now);
|
||||
self.last_bytes = 0;
|
||||
self.total = total;
|
||||
self.segment_waste = 0;
|
||||
self.route_switches = 0;
|
||||
}
|
||||
|
||||
pub fn progress(&mut self, bytes: u64, source: &str) -> Option<SlowEvent> {
|
||||
let now = Instant::now();
|
||||
let Some(started) = self.started_at else {
|
||||
return None;
|
||||
};
|
||||
let elapsed = now.duration_since(started).as_secs();
|
||||
let delta = bytes.saturating_sub(self.last_bytes);
|
||||
|
||||
if delta > 0 {
|
||||
self.last_bytes = bytes;
|
||||
self.last_progress_at = Some(now);
|
||||
return None;
|
||||
}
|
||||
|
||||
let no_progress_secs = now
|
||||
.duration_since(self.last_progress_at.unwrap_or(started))
|
||||
.as_secs();
|
||||
if no_progress_secs >= self.preset.no_progress_secs() {
|
||||
let remaining = self.total.map(|total| total.saturating_sub(bytes));
|
||||
if remaining.map_or(true, |value| {
|
||||
value >= self.preset.min_remaining_bytes()
|
||||
}) {
|
||||
return Some(SlowEvent {
|
||||
rule: SlowRule::R1NoProgress,
|
||||
engine: "xmcl".to_string(),
|
||||
source: source.to_string(),
|
||||
detail: format!(
|
||||
"no_progress={no_progress_secs}s elapsed={elapsed}s"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn record_segment_waste(
|
||||
&mut self,
|
||||
wasted_bytes: u64,
|
||||
source: &str,
|
||||
) -> Option<SlowEvent> {
|
||||
self.segment_waste = self.segment_waste.saturating_add(wasted_bytes);
|
||||
let total = self.total.unwrap_or(0);
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
let ratio = self.segment_waste as f64 / total as f64;
|
||||
if ratio >= self.preset.max_segment_waste_ratio() {
|
||||
Some(SlowEvent {
|
||||
rule: SlowRule::R3SegmentWaste,
|
||||
engine: "xmcl".to_string(),
|
||||
source: source.to_string(),
|
||||
detail: format!(
|
||||
"wasted={} total={total} ratio={ratio:.2}",
|
||||
self.segment_waste
|
||||
),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_route_switch(&mut self, source: &str) -> Option<SlowEvent> {
|
||||
self.route_switches += 1;
|
||||
if self.route_switches > self.preset.max_switches_per_file() {
|
||||
Some(SlowEvent {
|
||||
rule: SlowRule::R4FrequentSwitches,
|
||||
engine: "xmcl".to_string(),
|
||||
source: source.to_string(),
|
||||
detail: format!("switches={}", self.route_switches),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) {
|
||||
self.started_at = None;
|
||||
self.last_progress_at = None;
|
||||
self.last_bytes = 0;
|
||||
self.total = None;
|
||||
self.segment_waste = 0;
|
||||
self.route_switches = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn no_progress_fires_after_preset_window() {
|
||||
let mut detector = SlowDetector::new(SlowPreset::Sensitive);
|
||||
detector.start(Some(4 * 1024 * 1024));
|
||||
detector.progress(0, "official");
|
||||
std::thread::sleep(Duration::from_millis(3100));
|
||||
let event = detector.progress(0, "official");
|
||||
assert!(matches!(
|
||||
event,
|
||||
Some(SlowEvent {
|
||||
rule: SlowRule::R1NoProgress,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_switch_fires_after_preset_limit() {
|
||||
let mut detector = SlowDetector::new(SlowPreset::Sensitive);
|
||||
detector.start(Some(1024));
|
||||
let mut fired = None;
|
||||
for _ in 0..3 {
|
||||
fired = detector.record_route_switch("bmclapi");
|
||||
}
|
||||
assert!(matches!(
|
||||
fired,
|
||||
Some(SlowEvent {
|
||||
rule: SlowRule::R4FrequentSwitches,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,614 @@
|
||||
use parking_lot::Mutex;
|
||||
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// `lookup_host` does not expose the authoritative record TTL. Keep entries
|
||||
/// long enough to retain the connection-reuse benefit, but short enough that
|
||||
/// a changed CDN, VPN, or network is not pinned until the application exits.
|
||||
const CACHE_TTL: Duration = Duration::from_secs(5 * 60);
|
||||
const CONNECTION_FAILURES_BEFORE_REFRESH: u8 = 2;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedAddresses {
|
||||
addresses: Vec<IpAddr>,
|
||||
resolved_at: Instant,
|
||||
consecutive_connection_failures: u8,
|
||||
}
|
||||
|
||||
impl CachedAddresses {
|
||||
fn is_fresh(&self) -> bool {
|
||||
self.resolved_at.elapsed() < CACHE_TTL
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DownloadDnsResolver {
|
||||
reliability: Arc<Mutex<HashMap<IpAddr, f64>>>,
|
||||
last_resolved: Arc<Mutex<HashMap<String, CachedAddresses>>>,
|
||||
/// Locks only a single hostname's lookup. The map is held just long
|
||||
/// enough to obtain the per-host lock, never while DNS is awaited.
|
||||
resolving_hosts: Arc<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
|
||||
host_overrides: Arc<Mutex<HashMap<String, String>>>,
|
||||
#[cfg(test)]
|
||||
test_addresses: Arc<Mutex<HashMap<String, Vec<SocketAddr>>>>,
|
||||
#[cfg(test)]
|
||||
test_lookup_delays: Arc<Mutex<HashMap<String, Duration>>>,
|
||||
}
|
||||
|
||||
impl Default for DownloadDnsResolver {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
reliability: Arc::default(),
|
||||
last_resolved: Arc::default(),
|
||||
resolving_hosts: Arc::default(),
|
||||
host_overrides: Arc::default(),
|
||||
#[cfg(test)]
|
||||
test_addresses: Arc::default(),
|
||||
#[cfg(test)]
|
||||
test_lookup_delays: Arc::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DownloadDnsResolver {
|
||||
/// Resolves `host` through `resolver_host` while preserving the original
|
||||
/// URL host for HTTP Host headers and TLS SNI.
|
||||
#[allow(dead_code)]
|
||||
pub fn set_host_override(
|
||||
&self,
|
||||
host: &str,
|
||||
resolver_host: &str,
|
||||
) -> Result<(), &'static str> {
|
||||
let host = normalize_host(host)?;
|
||||
let resolver_host = normalize_host(resolver_host)?;
|
||||
let mut overrides = self.host_overrides.lock();
|
||||
if host == resolver_host {
|
||||
overrides.remove(&host);
|
||||
} else {
|
||||
overrides.insert(host.clone(), resolver_host);
|
||||
}
|
||||
drop(overrides);
|
||||
self.last_resolved.lock().remove(&host);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn clear_host_override(&self, host: &str) -> Result<(), &'static str> {
|
||||
let host = normalize_host(host)?;
|
||||
self.host_overrides.lock().remove(&host);
|
||||
self.last_resolved.lock().remove(&host);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn host_override(&self, host: &str) -> Option<String> {
|
||||
let host = normalize_host(host).ok()?;
|
||||
self.host_overrides.lock().get(&host).cloned()
|
||||
}
|
||||
|
||||
fn resolution_host(&self, host: &str) -> String {
|
||||
self.host_override(host).unwrap_or_else(|| host.to_string())
|
||||
}
|
||||
pub fn record_result(&self, address: IpAddr, result: f64) {
|
||||
let mut reliability = self.reliability.lock();
|
||||
reliability
|
||||
.entry(address)
|
||||
.and_modify(|value| *value = *value * 0.5 + result * 0.5)
|
||||
.or_insert(result * 0.5);
|
||||
}
|
||||
|
||||
pub fn record_host_success(&self, host: &str, address: IpAddr) {
|
||||
let mut cached = self.last_resolved.lock();
|
||||
if let Some(entry) = cached
|
||||
.get_mut(host)
|
||||
.filter(|entry| entry.addresses.contains(&address))
|
||||
{
|
||||
entry.consecutive_connection_failures = 0;
|
||||
drop(cached);
|
||||
self.record_result(address, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolved_addresses(&self, host: &str) -> Vec<IpAddr> {
|
||||
self.last_resolved
|
||||
.lock()
|
||||
.get(host)
|
||||
.filter(|entry| entry.is_fresh())
|
||||
.map(|entry| &entry.addresses)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Marks a failed connection attempt for `host`. The second consecutive
|
||||
/// failure expires its cache entry, so the next request or prewarm does a
|
||||
/// fresh lookup. A single transient failure keeps the hot cache intact.
|
||||
/// Returns whether this call expired the entry.
|
||||
pub fn record_connection_failure(&self, host: &str) -> bool {
|
||||
let mut cached = self.last_resolved.lock();
|
||||
let Some(entry) = cached.get_mut(host) else {
|
||||
return false;
|
||||
};
|
||||
entry.consecutive_connection_failures =
|
||||
entry.consecutive_connection_failures.saturating_add(1);
|
||||
if entry.consecutive_connection_failures
|
||||
< CONNECTION_FAILURES_BEFORE_REFRESH
|
||||
{
|
||||
return false;
|
||||
}
|
||||
entry.resolved_at = Instant::now() - CACHE_TTL;
|
||||
true
|
||||
}
|
||||
|
||||
fn cache_addresses(&self, host: String, addresses: Vec<SocketAddr>) {
|
||||
self.last_resolved.lock().insert(
|
||||
host,
|
||||
CachedAddresses {
|
||||
addresses: addresses
|
||||
.iter()
|
||||
.map(|address| address.ip())
|
||||
.collect(),
|
||||
resolved_at: Instant::now(),
|
||||
consecutive_connection_failures: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn resolving_lock(&self, host: &str) -> Arc<tokio::sync::Mutex<()>> {
|
||||
self.resolving_hosts
|
||||
.lock()
|
||||
.entry(host.to_string())
|
||||
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
|
||||
.clone()
|
||||
}
|
||||
|
||||
async fn lookup_addresses(
|
||||
&self,
|
||||
resolution_host: &str,
|
||||
) -> std::io::Result<Vec<SocketAddr>> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let delay =
|
||||
self.test_lookup_delays.lock().get(resolution_host).copied();
|
||||
if let Some(delay) = delay {
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
if let Some(addresses) =
|
||||
self.test_addresses.lock().get(resolution_host).cloned()
|
||||
{
|
||||
return Ok(addresses);
|
||||
}
|
||||
}
|
||||
tokio::net::lookup_host((resolution_host, 0))
|
||||
.await
|
||||
.map(|addresses| addresses.collect())
|
||||
}
|
||||
|
||||
async fn refresh(&self, host: &str) -> std::io::Result<Vec<IpAddr>> {
|
||||
let host = normalize_host(host).map_err(std::io::Error::other)?;
|
||||
let cached = self.resolved_addresses(&host);
|
||||
if !cached.is_empty() {
|
||||
return Ok(cached);
|
||||
}
|
||||
let host_lock = self.resolving_lock(&host);
|
||||
let _guard = host_lock.lock().await;
|
||||
let cached = self.resolved_addresses(&host);
|
||||
if !cached.is_empty() {
|
||||
return Ok(cached);
|
||||
}
|
||||
let resolution_host = self.resolution_host(&host);
|
||||
let mut addresses = self.lookup_addresses(&resolution_host).await?;
|
||||
if addresses.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
addresses = self.order_addresses(&host, addresses);
|
||||
let resolved = addresses.iter().map(|address| address.ip()).collect();
|
||||
self.cache_addresses(host, addresses);
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// Resolves a host ahead of the first request so batch downloads can
|
||||
/// share a single ordered address list. Idempotent and non-fatal: a
|
||||
/// failed lookup leaves the resolver untouched and requests will resolve
|
||||
/// on demand later.
|
||||
pub async fn pre_resolve(&self, host: &str) {
|
||||
let _ = self.refresh(host).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn set_test_addresses(&self, host: &str, addresses: Vec<SocketAddr>) {
|
||||
self.test_addresses
|
||||
.lock()
|
||||
.insert(host.to_string(), addresses);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn set_test_lookup_delay(&self, host: &str, delay: Duration) {
|
||||
self.test_lookup_delays
|
||||
.lock()
|
||||
.insert(host.to_string(), delay);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn expire_cache(&self, host: &str) {
|
||||
if let Some(entry) = self.last_resolved.lock().get_mut(host) {
|
||||
entry.resolved_at = Instant::now() - CACHE_TTL;
|
||||
}
|
||||
}
|
||||
|
||||
fn score(&self, address: IpAddr) -> f64 {
|
||||
self.reliability
|
||||
.lock()
|
||||
.get(&address)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn order_addresses(
|
||||
&self,
|
||||
host: &str,
|
||||
mut addresses: Vec<SocketAddr>,
|
||||
) -> Vec<SocketAddr> {
|
||||
addresses.sort_unstable_by_key(|address| address.ip());
|
||||
addresses.dedup_by_key(|address| address.ip());
|
||||
|
||||
let best_v4 = addresses
|
||||
.iter()
|
||||
.filter(|address| address.is_ipv4())
|
||||
.map(|address| self.score(address.ip()))
|
||||
.max_by(f64::total_cmp);
|
||||
let mut best_v6 = addresses
|
||||
.iter()
|
||||
.filter(|address| address.is_ipv6())
|
||||
.map(|address| self.score(address.ip()))
|
||||
.max_by(f64::total_cmp);
|
||||
if host == "api.modrinth.com" {
|
||||
best_v6 = best_v6.map(|score| score - 0.1);
|
||||
}
|
||||
addresses.sort_unstable_by(|left, right| {
|
||||
let preferred_v4 =
|
||||
best_v4.unwrap_or_default() >= best_v6.unwrap_or_default();
|
||||
let left_family = left.is_ipv4() == preferred_v4;
|
||||
let right_family = right.is_ipv4() == preferred_v4;
|
||||
right_family.cmp(&left_family).then_with(|| {
|
||||
self.score(right.ip()).total_cmp(&self.score(left.ip()))
|
||||
})
|
||||
});
|
||||
addresses
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_host(host: &str) -> Result<String, &'static str> {
|
||||
let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
|
||||
if host.is_empty()
|
||||
|| host.contains(['/', ':', '@', '[', ']'])
|
||||
|| host.split('.').any(str::is_empty)
|
||||
{
|
||||
return Err("DNS host override must be a hostname without a port");
|
||||
}
|
||||
Ok(host)
|
||||
}
|
||||
|
||||
impl Resolve for DownloadDnsResolver {
|
||||
fn resolve(&self, name: Name) -> Resolving {
|
||||
let host = name.as_str().to_string();
|
||||
let resolver = self.clone();
|
||||
Box::pin(async move {
|
||||
let addresses = resolver
|
||||
.refresh(&host)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|address| SocketAddr::new(address, 0))
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Box::new(addresses.into_iter()) as Addrs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
async fn spawn_ipv4_server() -> (u16, tokio::task::JoinHandle<()>) {
|
||||
let listener =
|
||||
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let handle = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut request = [0_u8; 1024];
|
||||
let _ = stream.read(&mut request).await;
|
||||
stream
|
||||
.write_all(
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
(port, handle)
|
||||
}
|
||||
|
||||
async fn request_with_resolver(
|
||||
resolver: DownloadDnsResolver,
|
||||
host: &str,
|
||||
port: u16,
|
||||
) -> String {
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.connect_timeout(Duration::from_secs(2))
|
||||
.dns_resolver(Arc::new(resolver))
|
||||
.build()
|
||||
.unwrap()
|
||||
.get(format!("http://{host}:{port}/"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_both_protocol_families_in_preferred_order() {
|
||||
let resolver = DownloadDnsResolver::default();
|
||||
let ipv4 = SocketAddr::from((Ipv4Addr::new(203, 0, 113, 10), 0));
|
||||
let ipv6 = SocketAddr::from((Ipv6Addr::LOCALHOST, 0));
|
||||
resolver.record_result(ipv6.ip(), -0.7);
|
||||
|
||||
assert_eq!(
|
||||
resolver.order_addresses("api.modrinth.com", vec![ipv6, ipv4]),
|
||||
vec![ipv4, ipv6]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_the_most_reliable_address_within_a_family() {
|
||||
let resolver = DownloadDnsResolver::default();
|
||||
let slower = SocketAddr::from((Ipv4Addr::new(203, 0, 113, 10), 0));
|
||||
let faster = SocketAddr::from((Ipv4Addr::new(203, 0, 113, 11), 0));
|
||||
resolver.record_result(faster.ip(), 0.5);
|
||||
|
||||
assert_eq!(
|
||||
resolver.order_addresses("cdn.example.com", vec![slower, faster]),
|
||||
vec![faster, slower]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_records_the_address_that_completed_the_request() {
|
||||
let resolver = DownloadDnsResolver::default();
|
||||
let failed = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 10));
|
||||
let succeeded = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 11));
|
||||
resolver.cache_addresses(
|
||||
"cdn.example.com".to_string(),
|
||||
vec![SocketAddr::new(failed, 0), SocketAddr::new(succeeded, 0)],
|
||||
);
|
||||
|
||||
resolver.record_host_success("cdn.example.com", succeeded);
|
||||
|
||||
assert_eq!(resolver.score(failed), 0.0);
|
||||
assert!(resolver.score(succeeded) > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_success_does_not_refresh_an_expired_dns_entry() {
|
||||
let resolver = DownloadDnsResolver::default();
|
||||
let address = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 11));
|
||||
resolver.cache_addresses(
|
||||
"expired-success.test".to_string(),
|
||||
vec![SocketAddr::new(address, 0)],
|
||||
);
|
||||
resolver.expire_cache("expired-success.test");
|
||||
|
||||
resolver.record_host_success("expired-success.test", address);
|
||||
|
||||
assert!(
|
||||
resolver
|
||||
.resolved_addresses("expired-success.test")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_success_does_not_undo_failure_expiration() {
|
||||
let resolver = DownloadDnsResolver::default();
|
||||
let address = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 12));
|
||||
resolver.cache_addresses(
|
||||
"late-success.test".to_string(),
|
||||
vec![SocketAddr::new(address, 0)],
|
||||
);
|
||||
assert!(!resolver.record_connection_failure("late-success.test"));
|
||||
assert!(resolver.record_connection_failure("late-success.test"));
|
||||
|
||||
resolver.record_host_success("late-success.test", address);
|
||||
|
||||
assert!(resolver.resolved_addresses("late-success.test").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_request_falls_back_when_the_first_ip_refuses_connection() {
|
||||
let resolver = DownloadDnsResolver::default();
|
||||
let refused = SocketAddr::from((Ipv4Addr::new(127, 0, 0, 2), 0));
|
||||
let available = SocketAddr::from((Ipv4Addr::LOCALHOST, 0));
|
||||
resolver.set_test_addresses("multi.test", vec![refused, available]);
|
||||
resolver.record_result(refused.ip(), 1.0);
|
||||
let (port, server) = spawn_ipv4_server().await;
|
||||
|
||||
let body = request_with_resolver(resolver, "multi.test", port).await;
|
||||
|
||||
assert_eq!(body, "ok");
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_request_falls_back_from_ipv6_to_ipv4() {
|
||||
let resolver = DownloadDnsResolver::default();
|
||||
let unavailable_v6 = SocketAddr::from((Ipv6Addr::LOCALHOST, 0));
|
||||
let available_v4 = SocketAddr::from((Ipv4Addr::LOCALHOST, 0));
|
||||
resolver.set_test_addresses(
|
||||
"dual-stack.test",
|
||||
vec![unavailable_v6, available_v4],
|
||||
);
|
||||
resolver.record_result(unavailable_v6.ip(), 1.0);
|
||||
let (port, server) = spawn_ipv4_server().await;
|
||||
|
||||
let body =
|
||||
request_with_resolver(resolver, "dual-stack.test", port).await;
|
||||
|
||||
assert_eq!(body, "ok");
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_cache_refreshes_to_the_current_addresses() {
|
||||
let resolver = DownloadDnsResolver::default();
|
||||
let old = SocketAddr::from((Ipv4Addr::new(127, 0, 0, 2), 0));
|
||||
let current = SocketAddr::from((Ipv4Addr::LOCALHOST, 0));
|
||||
resolver.set_test_addresses("ttl-refresh.test", vec![old]);
|
||||
resolver.pre_resolve("ttl-refresh.test").await;
|
||||
resolver.set_test_addresses("ttl-refresh.test", vec![current]);
|
||||
|
||||
assert_eq!(
|
||||
resolver.resolved_addresses("ttl-refresh.test"),
|
||||
vec![old.ip()]
|
||||
);
|
||||
resolver.expire_cache("ttl-refresh.test");
|
||||
resolver.pre_resolve("ttl-refresh.test").await;
|
||||
|
||||
assert_eq!(
|
||||
resolver.resolved_addresses("ttl-refresh.test"),
|
||||
vec![current.ip()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_connection_failures_refresh_the_cached_address() {
|
||||
let resolver = DownloadDnsResolver::default();
|
||||
let old = SocketAddr::from((Ipv4Addr::new(127, 0, 0, 2), 0));
|
||||
let current = SocketAddr::from((Ipv4Addr::LOCALHOST, 0));
|
||||
resolver.set_test_addresses("failure-refresh.test", vec![old]);
|
||||
resolver.pre_resolve("failure-refresh.test").await;
|
||||
resolver.set_test_addresses("failure-refresh.test", vec![current]);
|
||||
|
||||
assert!(!resolver.record_connection_failure("failure-refresh.test"));
|
||||
assert!(resolver.record_connection_failure("failure-refresh.test"));
|
||||
assert!(
|
||||
resolver
|
||||
.resolved_addresses("failure-refresh.test")
|
||||
.is_empty()
|
||||
);
|
||||
resolver.pre_resolve("failure-refresh.test").await;
|
||||
|
||||
assert_eq!(
|
||||
resolver.resolved_addresses("failure-refresh.test"),
|
||||
vec![current.ip()]
|
||||
);
|
||||
let (port, server) = spawn_ipv4_server().await;
|
||||
assert_eq!(
|
||||
request_with_resolver(resolver, "failure-refresh.test", port).await,
|
||||
"ok"
|
||||
);
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_resolving_one_host_does_not_block_another_host() {
|
||||
let resolver = DownloadDnsResolver::default();
|
||||
resolver.set_test_addresses(
|
||||
"slow-resolution.test",
|
||||
vec![SocketAddr::from((Ipv4Addr::LOCALHOST, 0))],
|
||||
);
|
||||
resolver.set_test_lookup_delay(
|
||||
"slow-resolution.test",
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
resolver.set_test_addresses(
|
||||
"fast-resolution.test",
|
||||
vec![SocketAddr::from((Ipv4Addr::LOCALHOST, 0))],
|
||||
);
|
||||
|
||||
let slow_resolver = resolver.clone();
|
||||
let slow = tokio::spawn(async move {
|
||||
slow_resolver.pre_resolve("slow-resolution.test").await;
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(50),
|
||||
resolver.pre_resolve("fast-resolution.test"),
|
||||
)
|
||||
.await
|
||||
.expect("an unrelated DNS lookup must not wait for the slow host");
|
||||
|
||||
assert!(
|
||||
!resolver
|
||||
.resolved_addresses("fast-resolution.test")
|
||||
.is_empty()
|
||||
);
|
||||
slow.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_override_uses_the_target_hosts_addresses() {
|
||||
let resolver = DownloadDnsResolver::default();
|
||||
let (port, server) = spawn_ipv4_server().await;
|
||||
resolver.set_test_addresses(
|
||||
"resolver-target.test",
|
||||
vec![SocketAddr::from((Ipv4Addr::LOCALHOST, 0))],
|
||||
);
|
||||
resolver
|
||||
.set_host_override("REQUEST-HOST.TEST.", "resolver-target.test")
|
||||
.unwrap();
|
||||
|
||||
let body =
|
||||
request_with_resolver(resolver.clone(), "request-host.test", port)
|
||||
.await;
|
||||
|
||||
assert_eq!(body, "ok");
|
||||
assert_eq!(
|
||||
resolver.host_override("request-host.test").as_deref(),
|
||||
Some("resolver-target.test"),
|
||||
);
|
||||
assert!(!resolver.resolved_addresses("request-host.test").is_empty());
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_override_removes_the_cached_request_host_addresses() {
|
||||
let resolver = DownloadDnsResolver::default();
|
||||
resolver
|
||||
.set_host_override("request-host.test", "resolver-target.test")
|
||||
.unwrap();
|
||||
resolver.cache_addresses(
|
||||
"request-host.test".to_string(),
|
||||
vec![SocketAddr::from((Ipv4Addr::LOCALHOST, 0))],
|
||||
);
|
||||
|
||||
resolver.clear_host_override("request-host.test").unwrap();
|
||||
|
||||
assert!(resolver.host_override("request-host.test").is_none());
|
||||
assert!(resolver.resolved_addresses("request-host.test").is_empty());
|
||||
assert!(normalize_host("https://resolver-target.test").is_err());
|
||||
assert!(normalize_host("resolver-target.test:443").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_resolver_does_not_override_tianpao() {
|
||||
assert!(
|
||||
DownloadDnsResolver::default()
|
||||
.host_override("mod.tianpao.top")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_resolver_does_not_override_legacy_modrinth_cdn() {
|
||||
assert!(
|
||||
DownloadDnsResolver::default()
|
||||
.host_override("cdn.modrinth.com")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
const INITIAL_SPEED_FLOOR: u64 = 256 * 1024;
|
||||
const SAMPLE_INTERVAL_MS: u128 = 100;
|
||||
const SAMPLE_HISTORY: usize = 30;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct SpeedSnapshot {
|
||||
pub aggregate_speed: u64,
|
||||
pub recent_average: u64,
|
||||
pub speed_floor: u64,
|
||||
pub sample_count: usize,
|
||||
}
|
||||
|
||||
struct SpeedState {
|
||||
last_sample_at: Instant,
|
||||
last_sample_bytes: u64,
|
||||
history: VecDeque<u64>,
|
||||
current_speed: u64,
|
||||
speed_floor: u64,
|
||||
}
|
||||
|
||||
impl Default for SpeedState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
last_sample_at: Instant::now(),
|
||||
last_sample_bytes: 0,
|
||||
history: VecDeque::new(),
|
||||
current_speed: 0,
|
||||
speed_floor: INITIAL_SPEED_FLOOR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DownloadSpeedTracker {
|
||||
completed_bytes: AtomicU64,
|
||||
speed: Mutex<SpeedState>,
|
||||
}
|
||||
|
||||
impl Default for DownloadSpeedTracker {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
completed_bytes: AtomicU64::new(0),
|
||||
speed: Mutex::new(SpeedState::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DownloadSpeedTracker {
|
||||
pub fn record_bytes(&self, bytes: u64) {
|
||||
self.completed_bytes.fetch_add(bytes, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn speed_snapshot(&self) -> SpeedSnapshot {
|
||||
let now = Instant::now();
|
||||
let total = self.completed_bytes.load(Ordering::Relaxed);
|
||||
let mut state = self.speed.lock();
|
||||
let elapsed = now.duration_since(state.last_sample_at);
|
||||
if elapsed.as_millis() >= SAMPLE_INTERVAL_MS {
|
||||
let downloaded = total.saturating_sub(state.last_sample_bytes);
|
||||
let instantaneous = (downloaded as f64
|
||||
/ elapsed.as_secs_f64().max(f64::MIN_POSITIVE))
|
||||
as u64;
|
||||
state.history.push_front(instantaneous);
|
||||
if state.history.len() > SAMPLE_HISTORY {
|
||||
state.history.pop_back();
|
||||
}
|
||||
state.last_sample_at = now;
|
||||
state.last_sample_bytes = total;
|
||||
|
||||
let mut weight = state.history.len() as u64;
|
||||
let mut weighted_total = 0_u64;
|
||||
let mut weight_total = 0_u64;
|
||||
for sample in &state.history {
|
||||
weighted_total = weighted_total
|
||||
.saturating_add(sample.saturating_mul(weight));
|
||||
weight_total += weight;
|
||||
weight = weight.saturating_sub(1);
|
||||
}
|
||||
state.current_speed = weighted_total / weight_total.max(1);
|
||||
|
||||
if state.history.len() >= 5 {
|
||||
let recent_count = state.history.len().min(10);
|
||||
let recent_average =
|
||||
state.history.iter().take(recent_count).sum::<u64>()
|
||||
/ recent_count as u64;
|
||||
let next_floor = recent_average.saturating_mul(85) / 100;
|
||||
state.speed_floor = state.speed_floor.max(next_floor);
|
||||
}
|
||||
}
|
||||
let recent_count = state.history.len().min(10);
|
||||
let recent_average = if recent_count == 0 {
|
||||
0
|
||||
} else {
|
||||
state.history.iter().take(recent_count).sum::<u64>()
|
||||
/ recent_count as u64
|
||||
};
|
||||
SpeedSnapshot {
|
||||
aggregate_speed: state.current_speed,
|
||||
recent_average,
|
||||
speed_floor: state.speed_floor,
|
||||
sample_count: state.history.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) fn is_incomplete_browser_download(file_name: &str) -> bool {
|
||||
let lower = file_name.to_ascii_lowercase();
|
||||
lower.ends_with(".crdownload")
|
||||
|| lower.ends_with(".part")
|
||||
|| lower.ends_with(".partial")
|
||||
|| lower.ends_with(".tmp")
|
||||
|| lower.ends_with(".download")
|
||||
}
|
||||
|
||||
pub(crate) fn browser_download_file_name_matches(
|
||||
actual: &str,
|
||||
expected: &str,
|
||||
) -> bool {
|
||||
if is_incomplete_browser_download(actual) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if browser_download_file_name_matches_without_localized_prefix(
|
||||
actual, expected,
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Some(actual) = actual.strip_prefix('[') else {
|
||||
return false;
|
||||
};
|
||||
let Some((title, actual)) = actual.split_once(']') else {
|
||||
return false;
|
||||
};
|
||||
if title.trim().is_empty() || actual.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
browser_download_file_name_matches_without_localized_prefix(
|
||||
actual, expected,
|
||||
)
|
||||
}
|
||||
|
||||
fn browser_download_file_name_matches_without_localized_prefix(
|
||||
actual: &str,
|
||||
expected: &str,
|
||||
) -> bool {
|
||||
if actual.eq_ignore_ascii_case(expected) {
|
||||
return true;
|
||||
}
|
||||
let actual = Path::new(actual);
|
||||
let expected = Path::new(expected);
|
||||
if actual
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.zip(
|
||||
expected
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str()),
|
||||
)
|
||||
.is_none_or(|(actual, expected)| !actual.eq_ignore_ascii_case(expected))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(actual_stem) = actual.file_stem().and_then(|stem| stem.to_str())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(expected_stem) =
|
||||
expected.file_stem().and_then(|stem| stem.to_str())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let actual_stem = actual_stem.to_lowercase();
|
||||
let expected_stem = expected_stem.to_lowercase();
|
||||
let Some(suffix) = actual_stem.strip_prefix(&expected_stem) else {
|
||||
return false;
|
||||
};
|
||||
let suffix = suffix.trim();
|
||||
suffix
|
||||
.strip_prefix('(')
|
||||
.and_then(|suffix| suffix.strip_suffix(')'))
|
||||
.is_some_and(|number| {
|
||||
!number.is_empty()
|
||||
&& number.bytes().all(|byte| byte.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn browser_duplicates_match_but_incomplete_files_do_not() {
|
||||
for actual in [
|
||||
"example-mod.jar",
|
||||
"EXAMPLE-MOD.JAR",
|
||||
"example-mod (1).jar",
|
||||
"example-mod(2).jar",
|
||||
"example-mod (123).JAR",
|
||||
] {
|
||||
assert!(
|
||||
browser_download_file_name_matches(actual, "example-mod.jar"),
|
||||
"expected {actual:?} to match"
|
||||
);
|
||||
}
|
||||
for actual in [
|
||||
"example-mod-fabric.jar",
|
||||
"example-mod (copy).jar",
|
||||
"example-mod ().jar",
|
||||
"example-mod (1) copy.jar",
|
||||
"example-mod (1).zip",
|
||||
"other-mod (1).jar",
|
||||
] {
|
||||
assert!(
|
||||
!browser_download_file_name_matches(actual, "example-mod.jar"),
|
||||
"expected {actual:?} not to match"
|
||||
);
|
||||
}
|
||||
for suffix in ["crdownload", "part", "partial", "tmp", "download"] {
|
||||
assert!(!browser_download_file_name_matches(
|
||||
&format!("example-mod.jar.{suffix}"),
|
||||
"example-mod.jar"
|
||||
));
|
||||
assert!(!browser_download_file_name_matches(
|
||||
&format!("example-mod.jar.{}", suffix.to_ascii_uppercase()),
|
||||
"example-mod.jar"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_localized_prefix_matches() {
|
||||
for actual in [
|
||||
"[测试]example-mod.jar",
|
||||
"[中文标题]example-mod.jar",
|
||||
"[午餐肉乐园]example-mod.jar",
|
||||
"[Test]EXAMPLE-MOD.JAR",
|
||||
"[测试]example-mod (1).jar",
|
||||
"[测试]example-mod(2).jar",
|
||||
"[测试]example-mod (123).JAR",
|
||||
] {
|
||||
assert!(
|
||||
browser_download_file_name_matches(actual, "example-mod.jar"),
|
||||
"expected {actual:?} to match"
|
||||
);
|
||||
}
|
||||
|
||||
for actual in [
|
||||
"abc-example-mod.jar",
|
||||
"foo-example-mod.jar",
|
||||
"abcEXAMPLE-MOD.jar",
|
||||
"[]example-mod.jar",
|
||||
"[ ]example-mod.jar",
|
||||
"[testexample-mod.jar",
|
||||
"test]example-mod.jar",
|
||||
"prefix[test]example-mod.jar",
|
||||
"[test]abc-example-mod.jar",
|
||||
"[test]foo-example-mod.jar",
|
||||
"[test]other-mod.jar",
|
||||
"[test]example-mod (copy).jar",
|
||||
"[test]example-mod ().jar",
|
||||
"[test]example-mod (1) copy.jar",
|
||||
"[test]example-mod (1).zip",
|
||||
"[a][b]example-mod.jar",
|
||||
] {
|
||||
assert!(
|
||||
!browser_download_file_name_matches(actual, "example-mod.jar"),
|
||||
"expected {actual:?} not to match"
|
||||
);
|
||||
}
|
||||
|
||||
for suffix in ["crdownload", "part", "partial", "tmp", "download"] {
|
||||
assert!(!browser_download_file_name_matches(
|
||||
&format!("[测试]example-mod.jar.{suffix}"),
|
||||
"example-mod.jar"
|
||||
));
|
||||
assert!(!browser_download_file_name_matches(
|
||||
&format!(
|
||||
"[测试]example-mod.jar.{}",
|
||||
suffix.to_ascii_uppercase()
|
||||
),
|
||||
"example-mod.jar"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,535 @@
|
||||
//! Detect processes that hold a file lock on a given path.
|
||||
//!
|
||||
//! Uses platform-specific lock inspection:
|
||||
//! - Windows: Restart Manager API, with `handle.exe` as a fallback
|
||||
//! - Linux: `fuser -v` with fallback to `lsof -t`
|
||||
//! - macOS: `lsof -F pcn`
|
||||
//!
|
||||
//! All operations gracefully return an empty vec on any error.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Serialize;
|
||||
use tracing::warn;
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::{collections::HashSet, os::windows::ffi::OsStrExt};
|
||||
#[cfg(windows)]
|
||||
use windows::{
|
||||
Win32::{
|
||||
Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS},
|
||||
System::RestartManager::{
|
||||
CCH_RM_SESSION_KEY, RM_PROCESS_INFO, RmEndSession, RmGetList,
|
||||
RmRegisterResources, RmStartSession,
|
||||
},
|
||||
},
|
||||
core::{PCWSTR, PWSTR},
|
||||
};
|
||||
|
||||
/// Information about a process that has a file handle open.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LockingProcess {
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
/// ISO 8601 timestamp of process start time, if available.
|
||||
pub start_time: Option<String>,
|
||||
}
|
||||
|
||||
/// Detect processes currently holding the file at `file_path` open.
|
||||
///
|
||||
/// Returns an empty vec when no locking processes are found, the platform
|
||||
/// cannot determine this, required tools are missing, or any error occurs.
|
||||
pub fn get_locking_processes(file_path: &Path) -> Vec<LockingProcess> {
|
||||
let path_str = file_path.to_string_lossy().to_string();
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let procs = get_locking_processes_windows(file_path, &path_str);
|
||||
if !procs.is_empty() {
|
||||
warn!(
|
||||
"Windows file lock detected on {}: {} process(es)",
|
||||
path_str,
|
||||
procs.len()
|
||||
);
|
||||
}
|
||||
procs
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let procs = get_locking_processes_linux(file_path, &path_str);
|
||||
if !procs.is_empty() {
|
||||
warn!(
|
||||
"Linux file lock detected on {}: {} process(es)",
|
||||
path_str,
|
||||
procs.len()
|
||||
);
|
||||
}
|
||||
procs
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let procs = get_locking_processes_macos(file_path, &path_str);
|
||||
if !procs.is_empty() {
|
||||
warn!(
|
||||
"macOS file lock detected on {}: {} process(es)",
|
||||
path_str,
|
||||
procs.len()
|
||||
);
|
||||
}
|
||||
procs
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
let _ = path_str;
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Windows: Restart Manager / handle.exe fallback ───────────────────────
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_locking_processes_windows(
|
||||
file_path: &Path,
|
||||
path_str: &str,
|
||||
) -> Vec<LockingProcess> {
|
||||
if let Some(processes) =
|
||||
get_locking_processes_windows_restart_manager(file_path, path_str)
|
||||
{
|
||||
return processes;
|
||||
}
|
||||
|
||||
let output =
|
||||
run_subprocess("handle.exe", &["-a", &file_path.to_string_lossy()]);
|
||||
|
||||
match output {
|
||||
Some(stdout) => parse_handle_exe_output(&stdout, path_str),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
struct RestartManagerSession(u32);
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Drop for RestartManagerSession {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
let _ = RmEndSession(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_locking_processes_windows_restart_manager(
|
||||
file_path: &Path,
|
||||
path_str: &str,
|
||||
) -> Option<Vec<LockingProcess>> {
|
||||
let mut session_handle = 0;
|
||||
let mut session_key = [0u16; CCH_RM_SESSION_KEY as usize + 1];
|
||||
let status = unsafe {
|
||||
RmStartSession(
|
||||
&raw mut session_handle,
|
||||
None,
|
||||
PWSTR(session_key.as_mut_ptr()),
|
||||
)
|
||||
};
|
||||
if status != ERROR_SUCCESS {
|
||||
warn!(
|
||||
"Restart Manager could not start a session for {}: error {}",
|
||||
path_str, status.0
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let session = RestartManagerSession(session_handle);
|
||||
|
||||
let wide_path = file_path
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect::<Vec<_>>();
|
||||
let filenames = [PCWSTR(wide_path.as_ptr())];
|
||||
let status =
|
||||
unsafe { RmRegisterResources(session.0, Some(&filenames), None, None) };
|
||||
if status != ERROR_SUCCESS {
|
||||
warn!(
|
||||
"Restart Manager could not register {}: error {}",
|
||||
path_str, status.0
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut needed = 0;
|
||||
let mut count = 0;
|
||||
let mut reboot_reasons = 0;
|
||||
let status = unsafe {
|
||||
RmGetList(
|
||||
session.0,
|
||||
&raw mut needed,
|
||||
&raw mut count,
|
||||
None,
|
||||
&raw mut reboot_reasons,
|
||||
)
|
||||
};
|
||||
if status == ERROR_SUCCESS && needed == 0 {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
if status != ERROR_MORE_DATA {
|
||||
warn!(
|
||||
"Restart Manager could not query {}: error {}",
|
||||
path_str, status.0
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
for _ in 0..3 {
|
||||
let mut process_info =
|
||||
vec![RM_PROCESS_INFO::default(); needed as usize];
|
||||
count = needed;
|
||||
let status = unsafe {
|
||||
RmGetList(
|
||||
session.0,
|
||||
&raw mut needed,
|
||||
&raw mut count,
|
||||
Some(process_info.as_mut_ptr()),
|
||||
&raw mut reboot_reasons,
|
||||
)
|
||||
};
|
||||
if status == ERROR_MORE_DATA {
|
||||
continue;
|
||||
}
|
||||
if status != ERROR_SUCCESS {
|
||||
warn!(
|
||||
"Restart Manager could not read {}: error {}",
|
||||
path_str, status.0
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
process_info.truncate(count as usize);
|
||||
let mut seen_pids = HashSet::new();
|
||||
let processes = process_info
|
||||
.into_iter()
|
||||
.filter_map(|process| {
|
||||
let pid = process.Process.dwProcessId;
|
||||
seen_pids.insert(pid).then(|| {
|
||||
let app_name = utf16_array_to_string(&process.strAppName);
|
||||
let service_name =
|
||||
utf16_array_to_string(&process.strServiceShortName);
|
||||
let name = if !app_name.is_empty() {
|
||||
app_name
|
||||
} else if !service_name.is_empty() {
|
||||
service_name
|
||||
} else {
|
||||
format!("PID {pid}")
|
||||
};
|
||||
LockingProcess {
|
||||
pid,
|
||||
name,
|
||||
path: path_str.to_string(),
|
||||
start_time: None,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
return Some(processes);
|
||||
}
|
||||
|
||||
warn!(
|
||||
"Restart Manager process list kept changing while querying {}",
|
||||
path_str
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn utf16_array_to_string(value: &[u16]) -> String {
|
||||
let length = value
|
||||
.iter()
|
||||
.position(|character| *character == 0)
|
||||
.unwrap_or(value.len());
|
||||
String::from_utf16_lossy(&value[..length])
|
||||
}
|
||||
|
||||
/// Parse handle.exe output to extract PID + process name.
|
||||
///
|
||||
/// handle.exe output lines look like:
|
||||
/// ```text
|
||||
/// chrome.exe pid: 1234 type: File <path>
|
||||
/// ```
|
||||
#[cfg(windows)]
|
||||
fn parse_handle_exe_output(
|
||||
output: &str,
|
||||
path_str: &str,
|
||||
) -> Vec<LockingProcess> {
|
||||
let mut result = Vec::new();
|
||||
for line in output.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(pid_start) = trimmed.find("pid:") {
|
||||
let after_pid = &trimmed[pid_start + 4..];
|
||||
let pid_str = after_pid.split_whitespace().next().unwrap_or("");
|
||||
if let Ok(pid) = pid_str.parse::<u32>() {
|
||||
let name = trimmed[..pid_start].trim().to_string();
|
||||
result.push(LockingProcess {
|
||||
pid,
|
||||
name,
|
||||
path: path_str.to_string(),
|
||||
start_time: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ─── Linux: fuser / lsof ───────────────────────────────────────────────────
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn get_locking_processes_linux(
|
||||
file_path: &Path,
|
||||
path_str: &str,
|
||||
) -> Vec<LockingProcess> {
|
||||
let output = run_subprocess("fuser", &["-v", &file_path.to_string_lossy()]);
|
||||
|
||||
let pids: Vec<u32> = match output {
|
||||
Some(stderr) => parse_fuser_output(&stderr),
|
||||
None => {
|
||||
let out =
|
||||
run_subprocess("lsof", &["-t", &file_path.to_string_lossy()]);
|
||||
match out {
|
||||
Some(stdout) => stdout
|
||||
.lines()
|
||||
.filter_map(|l| l.trim().parse::<u32>().ok())
|
||||
.collect(),
|
||||
None => return Vec::new(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pids.into_iter()
|
||||
.filter_map(|pid| {
|
||||
let name = read_proc_name(pid)?;
|
||||
let start_time = read_proc_start_time(pid);
|
||||
Some(LockingProcess {
|
||||
pid,
|
||||
name,
|
||||
path: path_str.to_string(),
|
||||
start_time,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_fuser_output(stderr: &str) -> Vec<u32> {
|
||||
let mut pids = Vec::new();
|
||||
for line in stderr.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with("USER") {
|
||||
// Skip the "USER PID ACCESS COMMAND" header line.
|
||||
continue;
|
||||
}
|
||||
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
// `fuser -v` data lines look like "path: USER PID ACCESS COMMAND",
|
||||
// so the PID is the third token; tolerate layouts without the
|
||||
// path prefix by falling back to the second token.
|
||||
let pid_candidate = if tokens.first().is_some_and(|t| t.ends_with(':'))
|
||||
{
|
||||
tokens.get(2)
|
||||
} else {
|
||||
tokens.get(1)
|
||||
};
|
||||
if let Some(pid_str) = pid_candidate
|
||||
&& let Ok(pid) = pid_str.parse::<u32>()
|
||||
&& pid > 0
|
||||
{
|
||||
pids.push(pid);
|
||||
}
|
||||
}
|
||||
pids
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn read_proc_name(pid: u32) -> Option<String> {
|
||||
let cmdline_path = format!("/proc/{pid}/cmdline");
|
||||
let content = std::fs::read_to_string(&cmdline_path).ok()?;
|
||||
let first = content.split('\0').next()?;
|
||||
if first.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
std::path::Path::new(first)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| first.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn read_proc_start_time(pid: u32) -> Option<String> {
|
||||
let stat_path = format!("/proc/{pid}/stat");
|
||||
let content = std::fs::read_to_string(&stat_path).ok()?;
|
||||
let paren_end = content.rfind(')')?;
|
||||
let after_paren = &content[paren_end + 1..];
|
||||
let fields: Vec<&str> = after_paren.split_whitespace().collect();
|
||||
// Field 22 (1-indexed) = starttime = index 19 (0-indexed).
|
||||
let starttime_str = fields.get(19)?;
|
||||
let clock_ticks: u64 = starttime_str.parse().ok()?;
|
||||
|
||||
let clk_tck = 100u64;
|
||||
let seconds_since_boot = clock_ticks / clk_tck;
|
||||
|
||||
let stat_content = std::fs::read_to_string("/proc/stat").ok()?;
|
||||
let btime_line = stat_content.lines().find(|l| l.starts_with("btime "))?;
|
||||
let boot_time_secs: u64 =
|
||||
btime_line.strip_prefix("btime ")?.trim().parse().ok()?;
|
||||
|
||||
let start_time_secs = boot_time_secs + seconds_since_boot;
|
||||
let dur =
|
||||
std::time::UNIX_EPOCH + std::time::Duration::from_secs(start_time_secs);
|
||||
let datetime = chrono::DateTime::<chrono::Utc>::from(dur);
|
||||
Some(datetime.to_rfc3339())
|
||||
}
|
||||
|
||||
// ─── macOS: lsof ───────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn get_locking_processes_macos(
|
||||
file_path: &Path,
|
||||
path_str: &str,
|
||||
) -> Vec<LockingProcess> {
|
||||
let output =
|
||||
run_subprocess("lsof", &["-F", "pcn", &file_path.to_string_lossy()]);
|
||||
|
||||
match output {
|
||||
Some(stdout) => parse_lsof_output(&stdout, path_str),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn parse_lsof_output(output: &str, path_str: &str) -> Vec<LockingProcess> {
|
||||
let mut result = Vec::new();
|
||||
let mut current_pid: Option<u32> = None;
|
||||
let mut current_name: Option<String> = None;
|
||||
|
||||
for line in output.lines() {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match line.chars().next() {
|
||||
Some('p') => {
|
||||
if let (Some(pid), Some(name)) =
|
||||
(current_pid.take(), current_name.take())
|
||||
{
|
||||
result.push(LockingProcess {
|
||||
pid,
|
||||
name,
|
||||
path: path_str.to_string(),
|
||||
start_time: None,
|
||||
});
|
||||
}
|
||||
current_pid = line[1..].parse::<u32>().ok();
|
||||
}
|
||||
Some('c') => {
|
||||
current_name = Some(line[1..].to_string());
|
||||
}
|
||||
Some('n') => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(pid), Some(name)) = (current_pid, current_name) {
|
||||
result.push(LockingProcess {
|
||||
pid,
|
||||
name,
|
||||
path: path_str.to_string(),
|
||||
start_time: None,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ─── Shared subprocess helper ──────────────────────────────────────────────
|
||||
|
||||
/// Run a subprocess with the given arguments, capturing combined output.
|
||||
/// Returns `None` if the command is not found or fails.
|
||||
fn run_subprocess(program: &str, args: &[&str]) -> Option<String> {
|
||||
let result = std::process::Command::new(program).args(args).output();
|
||||
match result {
|
||||
Ok(output) if output.status.success() => {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let combined = if stdout.is_empty() { stderr } else { stdout };
|
||||
if combined.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(combined)
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_unlocked_file() {
|
||||
let dir = tempdir().expect("temp dir");
|
||||
let file_path = dir.path().join("test.txt");
|
||||
std::fs::write(&file_path, "hello").expect("write file");
|
||||
let processes = get_locking_processes(&file_path);
|
||||
assert!(
|
||||
processes
|
||||
.iter()
|
||||
.all(|process| process.pid != std::process::id())
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_restart_manager_decodes_process_name() {
|
||||
let value = [b'A' as u16, b'x' as u16, b'o' as u16, 0, b'X' as u16];
|
||||
assert_eq!(utf16_array_to_string(&value), "Axo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonexistent_file() {
|
||||
let path = Path::new("/tmp/this_file_does_not_exist_xyz.test");
|
||||
let processes = get_locking_processes(path);
|
||||
assert!(processes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_directory_path() {
|
||||
let dir = tempdir().expect("temp dir");
|
||||
let processes = get_locking_processes(dir.path());
|
||||
let _ = processes;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn fuser_output_parses_pids_after_username() {
|
||||
let output = "\
|
||||
USER PID ACCESS COMMAND
|
||||
/tmp/foo.jar: user 1234 f.... minecraft
|
||||
/tmp/foo.jar: root 4321 F.... java
|
||||
";
|
||||
assert_eq!(parse_fuser_output(output), vec![1234, 4321]);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn fuser_output_without_locks_is_empty() {
|
||||
assert!(parse_fuser_output("").is_empty());
|
||||
assert!(parse_fuser_output("USER PID ACCESS COMMAND").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,999 @@
|
||||
// IO error
|
||||
// A wrapper around the tokio IO functions that adds the path to the error message, instead of the uninformative std::io::Error.
|
||||
|
||||
use eyre::{Context, ContextCompat, Result, eyre};
|
||||
use std::{
|
||||
future::Future,
|
||||
io::{ErrorKind, Write},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::task::spawn_blocking;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::util::file_lock::get_locking_processes;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum IOError {
|
||||
#[error("{source}, path: {path}")]
|
||||
IOPathError {
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
path: String,
|
||||
},
|
||||
#[error(transparent)]
|
||||
IOError(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
impl IOError {
|
||||
pub fn from(source: std::io::Error) -> Self {
|
||||
Self::IOError(source)
|
||||
}
|
||||
pub fn with_path(
|
||||
source: std::io::Error,
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Self {
|
||||
let path = path.as_ref();
|
||||
|
||||
Self::IOPathError {
|
||||
source,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> ErrorKind {
|
||||
match self {
|
||||
IOError::IOPathError { source, .. } => source.kind(),
|
||||
IOError::IOError(source) => source.kind(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an `std::io::Error` is a permission / sharing-violation that may
|
||||
/// indicate another process holds a file lock. If so, attempt to detect the
|
||||
/// locking processes and return a richer `IOError` with their details appended.
|
||||
pub(crate) fn io_error_with_lock_info(
|
||||
source: std::io::Error,
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> IOError {
|
||||
let path = path.as_ref();
|
||||
io_error_with_lock_info_for_paths(source, path, &[path])
|
||||
}
|
||||
|
||||
/// Join a user-provided relative path onto `root`, rejecting traversal.
|
||||
pub(crate) fn join_within_root(
|
||||
root: &Path,
|
||||
relative: &str,
|
||||
) -> crate::Result<PathBuf> {
|
||||
let normalized = relative.replace('\\', "/");
|
||||
let path = Path::new(&normalized);
|
||||
if path.is_absolute() {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Inner path must be relative".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let mut safe = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
std::path::Component::Normal(part) => safe.push(part),
|
||||
std::path::Component::CurDir => {}
|
||||
std::path::Component::ParentDir
|
||||
| std::path::Component::RootDir
|
||||
| std::path::Component::Prefix(_) => {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Inner path escapes the source root".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
if safe.as_os_str().is_empty() {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Inner path must not be empty".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(root.join(safe))
|
||||
}
|
||||
|
||||
pub(crate) fn io_error_with_lock_info_for_paths(
|
||||
source: std::io::Error,
|
||||
error_path: &Path,
|
||||
lock_paths: &[&Path],
|
||||
) -> IOError {
|
||||
let raw_os_error = source.raw_os_error();
|
||||
let is_lock_error = source.kind() == ErrorKind::PermissionDenied
|
||||
|| (cfg!(windows) && raw_os_error == Some(32))
|
||||
|| (cfg!(target_os = "linux") && raw_os_error == Some(26));
|
||||
|
||||
if is_lock_error
|
||||
&& let Some((locked_path, processes)) =
|
||||
lock_paths.iter().find_map(|path| {
|
||||
let processes = get_locking_processes(path);
|
||||
(!processes.is_empty()).then_some((*path, processes))
|
||||
})
|
||||
{
|
||||
warn!(
|
||||
"File lock detected on {} — {} holding process(es)",
|
||||
locked_path.display(),
|
||||
processes.len()
|
||||
);
|
||||
let detail = processes
|
||||
.iter()
|
||||
.map(|p| {
|
||||
format!(
|
||||
" PID {} - {} (locked path: {})",
|
||||
p.pid, p.name, p.path
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let enhanced = std::io::Error::new(
|
||||
source.kind(),
|
||||
format!("{source}\n\nFile locked by:\n{detail}"),
|
||||
);
|
||||
return IOError::IOPathError {
|
||||
source: enhanced,
|
||||
path: error_path.to_string_lossy().to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
IOError::IOPathError {
|
||||
source,
|
||||
path: error_path.to_string_lossy().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_SHARING_VIOLATION_RETRY_DELAYS: [std::time::Duration; 5] = [
|
||||
std::time::Duration::from_millis(100),
|
||||
std::time::Duration::from_millis(250),
|
||||
std::time::Duration::from_millis(500),
|
||||
std::time::Duration::from_secs(1),
|
||||
std::time::Duration::from_secs(2),
|
||||
];
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) async fn retry_windows_sharing_violation<T, F, Fut>(
|
||||
path: &Path,
|
||||
operation: &str,
|
||||
action: F,
|
||||
) -> std::io::Result<T>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = std::io::Result<T>>,
|
||||
{
|
||||
retry_windows_sharing_violation_with_delays(
|
||||
path,
|
||||
operation,
|
||||
&WINDOWS_SHARING_VIOLATION_RETRY_DELAYS,
|
||||
action,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn retry_windows_sharing_violation_with_delays<T, F, Fut>(
|
||||
path: &Path,
|
||||
operation: &str,
|
||||
delays: &[std::time::Duration],
|
||||
mut action: F,
|
||||
) -> std::io::Result<T>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = std::io::Result<T>>,
|
||||
{
|
||||
for (retry_index, delay) in delays.iter().enumerate() {
|
||||
match action().await {
|
||||
Err(error) if error.raw_os_error() == Some(32) => {
|
||||
warn!(
|
||||
"Windows sharing violation while {operation} {}. Retry {}/{} in {} ms",
|
||||
path.display(),
|
||||
retry_index + 1,
|
||||
delays.len(),
|
||||
delay.as_millis()
|
||||
);
|
||||
tokio::time::sleep(*delay).await;
|
||||
}
|
||||
result => return result,
|
||||
}
|
||||
}
|
||||
|
||||
action().await
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub(crate) async fn retry_windows_sharing_violation<T, F, Fut>(
|
||||
_path: &Path,
|
||||
_operation: &str,
|
||||
mut action: F,
|
||||
) -> std::io::Result<T>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = std::io::Result<T>>,
|
||||
{
|
||||
action().await
|
||||
}
|
||||
|
||||
pub fn canonicalize(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<std::path::PathBuf, IOError> {
|
||||
let path = path.as_ref();
|
||||
dunce::canonicalize(path).map_err(|e| IOError::IOPathError {
|
||||
source: e,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn read_dir(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<tokio::fs::ReadDir, IOError> {
|
||||
let path = path.as_ref();
|
||||
tokio::fs::read_dir(path)
|
||||
.await
|
||||
.map_err(|e| IOError::IOPathError {
|
||||
source: e,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_dir(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<(), IOError> {
|
||||
let path = path.as_ref();
|
||||
tokio::fs::create_dir(path)
|
||||
.await
|
||||
.map_err(|e| IOError::IOPathError {
|
||||
source: e,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_dir_all(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<(), IOError> {
|
||||
let path = path.as_ref();
|
||||
tokio::fs::create_dir_all(path)
|
||||
.await
|
||||
.map_err(|e| IOError::IOPathError {
|
||||
source: e,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_symlink_or_reparse(meta: &std::fs::Metadata) -> bool {
|
||||
if meta.file_type().is_symlink() {
|
||||
return true;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
if meta.file_attributes() & 0x400 != 0 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn remove_dir_all(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<(), IOError> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
fn remove_safe(path: &Path) -> Result<(), IOError> {
|
||||
let meta = match std::fs::symlink_metadata(path) {
|
||||
Ok(m) => m,
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(io_error_with_lock_info(e, path)),
|
||||
};
|
||||
|
||||
if is_symlink_or_reparse(&meta) {
|
||||
return std::fs::remove_file(path)
|
||||
.or_else(|_| std::fs::remove_dir(path))
|
||||
.map_err(|e| io_error_with_lock_info(e, path));
|
||||
}
|
||||
|
||||
if meta.is_file() {
|
||||
return std::fs::remove_file(path)
|
||||
.map_err(|e| io_error_with_lock_info(e, path));
|
||||
}
|
||||
|
||||
let rd = match std::fs::read_dir(path) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return Err(io_error_with_lock_info(e, path)),
|
||||
};
|
||||
for entry in rd {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(e) => return Err(io_error_with_lock_info(e, path)),
|
||||
};
|
||||
remove_safe(&entry.path())?;
|
||||
}
|
||||
|
||||
std::fs::remove_dir(path)
|
||||
.map_err(|e| io_error_with_lock_info(e, path))
|
||||
}
|
||||
|
||||
remove_safe(&path)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
IOError::IOError(std::io::Error::other(format!(
|
||||
"background task failed: {e}"
|
||||
)))
|
||||
})?
|
||||
}
|
||||
|
||||
/// Reads a text file to a string, automatically detecting its encoding and
|
||||
/// substituting any invalid characters with the Unicode replacement character.
|
||||
///
|
||||
/// This function is best suited for reading Minecraft instance files, whose
|
||||
/// encoding may vary depending on the platform, launchers, client versions
|
||||
/// (older Minecraft versions tended to rely on the system's default codepage
|
||||
/// more on Windows platforms), and mods used, while not being highly sensitive
|
||||
/// to occasional occurrences of mojibake or character replacements.
|
||||
pub async fn read_any_encoding_to_string(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<(String, &'static encoding_rs::Encoding), IOError> {
|
||||
let path = path.as_ref();
|
||||
let file_bytes =
|
||||
tokio::fs::read(path)
|
||||
.await
|
||||
.map_err(|e| IOError::IOPathError {
|
||||
source: e,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
})?;
|
||||
|
||||
let file_encoding = {
|
||||
let mut encoding_detector = chardetng::EncodingDetector::new();
|
||||
encoding_detector.feed(&file_bytes, true);
|
||||
encoding_detector.guess(None, true)
|
||||
};
|
||||
|
||||
let (file_string, actual_file_encoding, _) =
|
||||
file_encoding.decode(&file_bytes);
|
||||
Ok((file_string.to_string(), actual_file_encoding))
|
||||
}
|
||||
|
||||
pub async fn read(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<Vec<u8>, IOError> {
|
||||
let path = path.as_ref();
|
||||
tokio::fs::read(path)
|
||||
.await
|
||||
.map_err(|e| io_error_with_lock_info(e, path))
|
||||
}
|
||||
|
||||
pub async fn write(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
data: impl AsRef<[u8]>,
|
||||
) -> Result<(), IOError> {
|
||||
let path = path.as_ref().to_owned();
|
||||
let data = data.as_ref().to_owned();
|
||||
spawn_blocking(move || {
|
||||
let cloned_path = path.clone();
|
||||
sync_write(data, path)
|
||||
.map_err(|e| io_error_with_lock_info(e, &cloned_path))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| std::io::Error::other("background task failed"))??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_write(
|
||||
data: impl AsRef<[u8]>,
|
||||
path: impl AsRef<Path>,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let mut tempfile =
|
||||
NamedTempFile::new_in(path.as_ref().parent().ok_or_else(|| {
|
||||
std::io::Error::other(
|
||||
"could not get parent directory for temporary file",
|
||||
)
|
||||
})?)?;
|
||||
tempfile.write_all(data.as_ref())?;
|
||||
let tmp_path = tempfile.into_temp_path();
|
||||
let path = path.as_ref();
|
||||
tmp_path.persist(path)?;
|
||||
std::io::Result::Ok(())
|
||||
}
|
||||
|
||||
pub fn is_same_disk(old_dir: &Path, new_dir: &Path) -> Result<bool> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
use eyre::eyre;
|
||||
|
||||
// we need to use `symlink_metadata` instead of `metadata`, because
|
||||
// if this file is a symlink, we need to query the symlink file itself,
|
||||
// rather than the target.
|
||||
// downloaded JREs use symlinks to point to certain stuff like LICENSE
|
||||
// files.
|
||||
// this fixes moving JRE dirs.
|
||||
|
||||
let old_meta = std::fs::symlink_metadata(old_dir)
|
||||
.wrap_err_with(|| eyre!("getting meta of old dir {old_dir:?}"))?;
|
||||
let new_meta = std::fs::symlink_metadata(new_dir)
|
||||
.wrap_err_with(|| eyre!("getting meta of new dir {new_dir:?}"))?;
|
||||
|
||||
Ok(old_meta.dev() == new_meta.dev())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Extract the volume prefix from the raw path without following
|
||||
// symlinks/reparse points. `canonicalize` would follow them and
|
||||
// fail on dangling links (target already deleted), so we compare
|
||||
// drive letters directly from the path components first.
|
||||
let old_prefix = old_dir.components().next();
|
||||
let new_prefix = new_dir.components().next();
|
||||
|
||||
let same = match (old_prefix, new_prefix) {
|
||||
(
|
||||
Some(std::path::Component::Prefix(old)),
|
||||
Some(std::path::Component::Prefix(new)),
|
||||
) => old.as_os_str() == new.as_os_str(),
|
||||
_ => {
|
||||
// Fall back to canonicalization for relative paths, UNC, etc.
|
||||
let old_dir = canonicalize(old_dir)?;
|
||||
let new_dir = canonicalize(new_dir)?;
|
||||
match (old_dir.components().next(), new_dir.components().next())
|
||||
{
|
||||
(
|
||||
Some(std::path::Component::Prefix(old)),
|
||||
Some(std::path::Component::Prefix(new)),
|
||||
) => old.as_os_str() == new.as_os_str(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(same)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn rename_or_move(
|
||||
from: impl AsRef<std::path::Path>,
|
||||
to: impl AsRef<std::path::Path>,
|
||||
) -> Result<()> {
|
||||
let from = from.as_ref();
|
||||
let to = to.as_ref();
|
||||
|
||||
let to_parent = to
|
||||
.parent()
|
||||
.wrap_err_with(|| eyre!("getting parent of `to` dir {to:?}"))?;
|
||||
let same_disk = is_same_disk(from, to_parent).wrap_err_with(|| {
|
||||
eyre!("checking if `to_parent` ({to_parent:?}) and `from` ({from:?}) are on the same disk")
|
||||
})?;
|
||||
|
||||
if same_disk {
|
||||
tokio::fs::rename(from, to)
|
||||
.await
|
||||
.map_err(|e| io_error_with_lock_info(e, from))
|
||||
.wrap_err_with(|| eyre!("moving {from:?} to {to:?} on same disk"))
|
||||
} else {
|
||||
move_recursive(from, to).await.with_context(|| {
|
||||
eyre!("moving {from:?} to {to:?} on different disks")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_recursion::async_recursion]
|
||||
async fn move_recursive(from: &Path, to: &Path) -> Result<()> {
|
||||
let meta = tokio::fs::symlink_metadata(from)
|
||||
.await
|
||||
.wrap_err_with(|| eyre!("getting metadata of {from:?}"))?;
|
||||
|
||||
// Symlink / reparse point: recreate the link at the destination and
|
||||
// remove the source link. Never follow/copy the target.
|
||||
if is_symlink_or_reparse(&meta) {
|
||||
let target = tokio::fs::read_link(from)
|
||||
.await
|
||||
.wrap_err_with(|| eyre!("reading link target of {from:?}"))?;
|
||||
create_symlink(&target, to).await.wrap_err_with(|| {
|
||||
eyre!("recreating symlink at {to:?} (target: {target:?})")
|
||||
})?;
|
||||
// Remove the source link. On Unix, remove_file works for all
|
||||
// symlinks. On Windows, directory reparse points (junctions)
|
||||
// may need remove_dir as fallback.
|
||||
if tokio::fs::remove_file(from).await.is_err() {
|
||||
tokio::fs::remove_dir(from)
|
||||
.await
|
||||
.map_err(|e| io_error_with_lock_info(e, from))
|
||||
.wrap_err_with(|| eyre!("removing source link {from:?}"))?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if meta.is_file() {
|
||||
copy(from, to)
|
||||
.await
|
||||
.wrap_err_with(|| eyre!("copying {from:?} to {to:?}"))?;
|
||||
remove_file(from).await.wrap_err_with(|| {
|
||||
eyre!("removing {from:?} after copying to {to:?}")
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
create_dir(to)
|
||||
.await
|
||||
.wrap_err_with(|| eyre!("creating dir for {to:?}"))?;
|
||||
|
||||
let mut dir = read_dir(from)
|
||||
.await
|
||||
.wrap_err_with(|| eyre!("reading dir {from:?}"))?;
|
||||
while let Some(entry) = dir
|
||||
.next_entry()
|
||||
.await
|
||||
.wrap_err_with(|| eyre!("reading dir entry in {from:?}"))?
|
||||
{
|
||||
let new_path = to.join(entry.file_name());
|
||||
move_recursive(&entry.path(), &new_path)
|
||||
.await
|
||||
.with_context(|| {
|
||||
eyre!("moving {:?} to {new_path:?}", entry.path())
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn copy(
|
||||
from: impl AsRef<std::path::Path>,
|
||||
to: impl AsRef<std::path::Path>,
|
||||
) -> Result<u64, IOError> {
|
||||
let from = from.as_ref().to_path_buf();
|
||||
let to = to.as_ref().to_path_buf();
|
||||
|
||||
// Never follow symlinks / reparse points — recreate the link at the
|
||||
// destination instead of copying the target's content.
|
||||
let meta = tokio::fs::symlink_metadata(&from)
|
||||
.await
|
||||
.map_err(|e| io_error_with_lock_info(e, &from))?;
|
||||
|
||||
if is_symlink_or_reparse(&meta) {
|
||||
let target = tokio::fs::read_link(&from)
|
||||
.await
|
||||
.map_err(|e| io_error_with_lock_info(e, &from))?;
|
||||
create_symlink(&target, &to).await?;
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
retry_windows_sharing_violation(&to, "copying", || {
|
||||
let from = from.clone();
|
||||
let to = to.clone();
|
||||
async move { tokio::fs::copy(from, to).await }
|
||||
})
|
||||
.await
|
||||
.map_err(|e| io_error_with_lock_info(e, &from))
|
||||
}
|
||||
|
||||
/// Recursively copy a directory from `from` to `to`.
|
||||
///
|
||||
/// Creates the target directory and recursively copies all files and
|
||||
/// subdirectories. Directory symlinks / junctions are followed and their
|
||||
/// target subtree is materialized as a real directory; file links are copied
|
||||
/// as their target's content. Broken links and link cycles fail with a
|
||||
/// descriptive error instead of a generic read failure.
|
||||
pub async fn copy_dir(
|
||||
from: impl AsRef<std::path::Path>,
|
||||
to: impl AsRef<std::path::Path>,
|
||||
) -> Result<(), IOError> {
|
||||
let from = from.as_ref().to_path_buf();
|
||||
let to = to.as_ref().to_path_buf();
|
||||
copy_dir_inner(&from, &to, &mut Vec::new()).await
|
||||
}
|
||||
|
||||
#[async_recursion::async_recursion]
|
||||
async fn copy_dir_inner(
|
||||
from: &Path,
|
||||
to: &Path,
|
||||
dir_stack: &mut Vec<PathBuf>,
|
||||
) -> Result<(), IOError> {
|
||||
use async_walkdir::WalkDir;
|
||||
use futures::StreamExt as _;
|
||||
|
||||
create_dir_all(to).await?;
|
||||
|
||||
// Track canonicalized directories on the current recursion path so a
|
||||
// symlink pointing back to an ancestor fails cleanly instead of
|
||||
// recursing forever.
|
||||
let canonical = canonicalize(from)?;
|
||||
if dir_stack.contains(&canonical) {
|
||||
return Err(IOError::with_path(
|
||||
std::io::Error::other(format!(
|
||||
"symlink cycle detected while copying {from:?}"
|
||||
)),
|
||||
from,
|
||||
));
|
||||
}
|
||||
dir_stack.push(canonical);
|
||||
|
||||
let mut entries = WalkDir::new(from);
|
||||
while let Some(entry) = entries.next().await {
|
||||
let entry = entry.map_err(|e| {
|
||||
IOError::with_path(std::io::Error::other(e.to_string()), from)
|
||||
})?;
|
||||
|
||||
// Skip macOS resource forks.
|
||||
if entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.map(|s| s.starts_with("__MACOSX"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry_path = entry.path().to_path_buf();
|
||||
let relative = entry_path.strip_prefix(from).map_err(|_| {
|
||||
IOError::with_path(
|
||||
std::io::Error::other("path prefix mismatch"),
|
||||
&entry_path,
|
||||
)
|
||||
})?;
|
||||
let target = to.join(relative);
|
||||
|
||||
let meta = tokio::fs::symlink_metadata(&entry_path)
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &entry_path))?;
|
||||
|
||||
if is_symlink_or_reparse(&meta) {
|
||||
let raw_target = tokio::fs::read_link(&entry_path)
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &entry_path))?;
|
||||
// Relative link targets resolve against the link's parent
|
||||
// directory, not the process working directory.
|
||||
let resolved = if raw_target.is_absolute() {
|
||||
raw_target
|
||||
} else {
|
||||
entry_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(""))
|
||||
.join(raw_target)
|
||||
};
|
||||
let target_meta = match tokio::fs::metadata(&resolved).await {
|
||||
Ok(meta) => meta,
|
||||
Err(_) => {
|
||||
return Err(IOError::with_path(
|
||||
std::io::Error::other(format!(
|
||||
"broken symlink {entry_path:?} points to missing target {resolved:?}"
|
||||
)),
|
||||
&entry_path,
|
||||
));
|
||||
}
|
||||
};
|
||||
if target_meta.is_dir() {
|
||||
// Materialize the directory link: copy the target subtree as
|
||||
// a real directory instead of recreating the link.
|
||||
copy_dir_inner(&resolved, &target, dir_stack).await?;
|
||||
} else {
|
||||
// Materialize file links as their target's content.
|
||||
let bytes = read(&entry_path).await?;
|
||||
write(&target, bytes).await?;
|
||||
}
|
||||
} else if meta.is_dir() {
|
||||
create_dir_all(&target).await?;
|
||||
} else {
|
||||
let bytes = read(&entry_path).await?;
|
||||
write(&target, bytes).await?;
|
||||
}
|
||||
}
|
||||
|
||||
dir_stack.pop();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_file(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<(), IOError> {
|
||||
let path = path.as_ref();
|
||||
tokio::fs::remove_file(path)
|
||||
.await
|
||||
.map_err(|e| io_error_with_lock_info(e, path))
|
||||
}
|
||||
|
||||
pub async fn remove_dir(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<(), IOError> {
|
||||
let path = path.as_ref();
|
||||
tokio::fs::remove_dir(path)
|
||||
.await
|
||||
.map_err(|e| io_error_with_lock_info(e, path))
|
||||
}
|
||||
|
||||
pub async fn metadata(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<std::fs::Metadata, IOError> {
|
||||
let path = path.as_ref();
|
||||
tokio::fs::metadata(path)
|
||||
.await
|
||||
.map_err(|e| IOError::IOPathError {
|
||||
source: e,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Gets a resource file from the executable. Returns `theseus::Result<(TempDir, PathBuf)>`.
|
||||
#[macro_export]
|
||||
macro_rules! get_resource_file {
|
||||
(directory: $relative_dir:expr, file: $file_name:expr) => {
|
||||
'get_resource_file: {
|
||||
let dir = match tempfile::tempdir() {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
break 'get_resource_file $crate::Result::Err(
|
||||
$crate::util::io::IOError::from(e).into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let path = dir.path().join($file_name);
|
||||
if let Err(e) = $crate::util::io::write(
|
||||
&path,
|
||||
include_bytes!(concat!($relative_dir, "/", $file_name)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
break 'get_resource_file $crate::Result::Err(e.into());
|
||||
}
|
||||
let path = match $crate::util::io::canonicalize(path) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
break 'get_resource_file $crate::Result::Err(e.into());
|
||||
}
|
||||
};
|
||||
$crate::Result::Ok((dir, path))
|
||||
}
|
||||
};
|
||||
|
||||
($relative_dir:literal / $file_name:literal) => {
|
||||
get_resource_file!(directory: $relative_dir, file: $file_name)
|
||||
};
|
||||
|
||||
(env $dir_env_name:literal / $file_name:literal) => {
|
||||
get_resource_file!(directory: env!($dir_env_name), file: $file_name)
|
||||
};
|
||||
}
|
||||
|
||||
pub async fn create_symlink(
|
||||
target: impl AsRef<std::path::Path>,
|
||||
link: impl AsRef<std::path::Path>,
|
||||
) -> Result<(), IOError> {
|
||||
let target = target.as_ref().to_path_buf();
|
||||
let link = link.as_ref().to_path_buf();
|
||||
let link_for_error = link.clone();
|
||||
|
||||
if let Some(parent) = link.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, parent))?;
|
||||
}
|
||||
|
||||
if !target.exists() {
|
||||
return Err(IOError::with_path(
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("Target path does not exist: {}", target.display()),
|
||||
),
|
||||
&target,
|
||||
));
|
||||
}
|
||||
|
||||
let link_target = target.clone();
|
||||
let link_path = link.clone();
|
||||
let result = spawn_blocking(move || {
|
||||
super::symlink::create_link_blocking(&link_target, &link_path)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
IOError::with_path(
|
||||
std::io::Error::other(format!("symlink task panicked: {e}")),
|
||||
&link_for_error,
|
||||
)
|
||||
})?;
|
||||
|
||||
match result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => {
|
||||
#[cfg(target_os = "windows")]
|
||||
if error.kind() == ErrorKind::PermissionDenied
|
||||
|| error.raw_os_error() == Some(1314)
|
||||
{
|
||||
// Symbolic links need administrator privileges when Windows
|
||||
// Developer Mode is off. Run the creation in a short-lived
|
||||
// elevated helper process (UAC prompt) instead of elevating
|
||||
// the launcher itself, which would break drag-and-drop.
|
||||
return super::symlink::create_link_elevated(&target, &link)
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &link_for_error));
|
||||
}
|
||||
Err(IOError::with_path(error, &link_for_error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod windows_sharing_violation_tests {
|
||||
use super::*;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn retries_sharing_violation_then_succeeds() {
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let action_attempts = attempts.clone();
|
||||
let delays = [std::time::Duration::ZERO; 2];
|
||||
|
||||
retry_windows_sharing_violation_with_delays(
|
||||
Path::new("locked.test"),
|
||||
"testing",
|
||||
&delays,
|
||||
move || {
|
||||
let attempt = action_attempts.fetch_add(1, Ordering::SeqCst);
|
||||
async move {
|
||||
if attempt < 2 {
|
||||
Err(std::io::Error::from_raw_os_error(32))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("third attempt succeeds");
|
||||
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn does_not_retry_other_errors() {
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let action_attempts = attempts.clone();
|
||||
let delays = [std::time::Duration::ZERO; 2];
|
||||
|
||||
let error = retry_windows_sharing_violation_with_delays(
|
||||
Path::new("failed.test"),
|
||||
"testing",
|
||||
&delays,
|
||||
move || {
|
||||
action_attempts.fetch_add(1, Ordering::SeqCst);
|
||||
async { Err::<(), _>(std::io::Error::from_raw_os_error(5)) }
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("access denied must not be retried");
|
||||
|
||||
assert_eq!(error.raw_os_error(), Some(5));
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sharing_violation_retries_are_bounded() {
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let action_attempts = attempts.clone();
|
||||
let delays = [std::time::Duration::ZERO; 2];
|
||||
|
||||
let error = retry_windows_sharing_violation_with_delays(
|
||||
Path::new("locked.test"),
|
||||
"testing",
|
||||
&delays,
|
||||
move || {
|
||||
action_attempts.fetch_add(1, Ordering::SeqCst);
|
||||
async { Err::<(), _>(std::io::Error::from_raw_os_error(32)) }
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("sharing violation remains after retry budget");
|
||||
|
||||
assert_eq!(error.raw_os_error(), Some(32));
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod copy_dir_tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn copy_dir_copies_plain_tree() {
|
||||
let src = tempdir().expect("temp src");
|
||||
std::fs::create_dir(src.path().join("sub")).expect("sub dir");
|
||||
std::fs::write(src.path().join("root.txt"), b"root")
|
||||
.expect("write root");
|
||||
std::fs::write(src.path().join("sub/nested.txt"), b"nested")
|
||||
.expect("write nested");
|
||||
|
||||
let dst = tempdir().expect("temp dst");
|
||||
copy_dir(src.path(), dst.path().join("out"))
|
||||
.await
|
||||
.expect("copy");
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(dst.path().join("out/root.txt")).expect("read root"),
|
||||
b"root"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(dst.path().join("out/sub/nested.txt"))
|
||||
.expect("read nested"),
|
||||
b"nested"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix_symlinks {
|
||||
use super::*;
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
#[tokio::test]
|
||||
async fn copy_dir_materializes_directory_symlink() {
|
||||
let src = tempdir().expect("temp src");
|
||||
let real = src.path().join("real");
|
||||
std::fs::create_dir(&real).expect("real dir");
|
||||
std::fs::write(real.join("file.txt"), b"content")
|
||||
.expect("write file");
|
||||
symlink(&real, src.path().join("link")).expect("create link");
|
||||
|
||||
let dst = tempdir().expect("temp dst");
|
||||
copy_dir(src.path(), dst.path().join("out"))
|
||||
.await
|
||||
.expect("copy");
|
||||
|
||||
let copied = dst.path().join("out/link/file.txt");
|
||||
assert!(
|
||||
copied.is_file(),
|
||||
"directory symlink should be materialized"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(&copied).expect("read copied file"),
|
||||
b"content"
|
||||
);
|
||||
assert!(
|
||||
std::fs::symlink_metadata(dst.path().join("out/link"))
|
||||
.expect("meta")
|
||||
.file_type()
|
||||
.is_dir()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn copy_dir_reports_broken_symlink() {
|
||||
let src = tempdir().expect("temp src");
|
||||
symlink(src.path().join("missing"), src.path().join("broken"))
|
||||
.expect("create broken link");
|
||||
|
||||
let dst = tempdir().expect("temp dst");
|
||||
let err = copy_dir(src.path(), dst.path().join("out"))
|
||||
.await
|
||||
.expect_err("broken link should fail");
|
||||
let text = format!("{err}");
|
||||
assert!(
|
||||
text.contains("broken symlink"),
|
||||
"error should mention the broken link: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn copy_dir_rejects_symlink_cycle() {
|
||||
let src = tempdir().expect("temp src");
|
||||
std::fs::create_dir(src.path().join("a")).expect("a dir");
|
||||
symlink(src.path().join("a"), src.path().join("a/back"))
|
||||
.expect("create cycle link");
|
||||
|
||||
let dst = tempdir().expect("temp dst");
|
||||
let err = copy_dir(src.path(), dst.path().join("out"))
|
||||
.await
|
||||
.expect_err("cycle should fail");
|
||||
let text = format!("{err}");
|
||||
assert!(
|
||||
text.contains("symlink cycle"),
|
||||
"error should mention the cycle: {text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,19 @@
|
||||
//! Theseus utility functions
|
||||
pub mod download;
|
||||
pub mod download_dns;
|
||||
pub mod download_manager;
|
||||
pub mod downloads;
|
||||
pub mod fetch;
|
||||
pub mod file_lock;
|
||||
pub mod io;
|
||||
pub mod jre;
|
||||
pub mod mojang;
|
||||
pub mod network;
|
||||
pub mod platform;
|
||||
pub mod protocol_version;
|
||||
pub mod proxy;
|
||||
pub mod resolve_shortcut;
|
||||
pub mod rpc;
|
||||
pub mod server_ping;
|
||||
pub(crate) mod single_flight;
|
||||
pub mod symlink;
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Routing helpers for Mojang services that can be reached through the
|
||||
//! Fallen-Breath HTTP forwarding proxy.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use url::Url;
|
||||
|
||||
pub fn fallen_proxy_url(original: &str) -> Option<String> {
|
||||
let mut url = Url::parse(original).ok()?;
|
||||
let host = url.host_str()?.to_ascii_lowercase();
|
||||
let proxy_host = match host.as_str() {
|
||||
"authserver.mojang.com" => "auth.msp.fallenbreath.me",
|
||||
"sessionserver.mojang.com" => "session.msp.fallenbreath.me",
|
||||
"api.mojang.com"
|
||||
if url.path().starts_with("/users/profiles/minecraft") =>
|
||||
{
|
||||
"profiles.msp.fallenbreath.me"
|
||||
}
|
||||
"api.mojang.com" => "account.msp.fallenbreath.me",
|
||||
"api.minecraftservices.com" => "services.msp.fallenbreath.me",
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
url.set_host(Some(proxy_host)).ok()?;
|
||||
Some(url.into())
|
||||
}
|
||||
|
||||
pub fn mojang_service_url(original: &str, use_mirror: bool) -> Cow<'_, str> {
|
||||
if use_mirror {
|
||||
if let Some(mirror) = fallen_proxy_url(original) {
|
||||
return Cow::Owned(mirror);
|
||||
}
|
||||
}
|
||||
Cow::Borrowed(original)
|
||||
}
|
||||
|
||||
pub fn should_use_mojang_mirror() -> bool {
|
||||
crate::State::get_if_initialized()
|
||||
.is_some_and(|state| state.mojang_auth_use_mirror())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rewrites_each_mojang_service_to_its_fallen_proxy_host() {
|
||||
let cases = [
|
||||
(
|
||||
"https://authserver.mojang.com/authenticate",
|
||||
"https://auth.msp.fallenbreath.me/authenticate",
|
||||
),
|
||||
(
|
||||
"https://api.mojang.com/user/profile",
|
||||
"https://account.msp.fallenbreath.me/user/profile",
|
||||
),
|
||||
(
|
||||
"https://sessionserver.mojang.com/session/minecraft/join",
|
||||
"https://session.msp.fallenbreath.me/session/minecraft/join",
|
||||
),
|
||||
(
|
||||
"https://api.minecraftservices.com/launcher/login",
|
||||
"https://services.msp.fallenbreath.me/launcher/login",
|
||||
),
|
||||
(
|
||||
"https://api.mojang.com/users/profiles/minecraft/Notch",
|
||||
"https://profiles.msp.fallenbreath.me/users/profiles/minecraft/Notch",
|
||||
),
|
||||
];
|
||||
|
||||
for (original, expected) in cases {
|
||||
assert_eq!(fallen_proxy_url(original).as_deref(), Some(expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_unknown_hosts_untouched() {
|
||||
assert_eq!(
|
||||
fallen_proxy_url("https://api.modrinth.com/v2/project/foo"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_the_original_url_when_mirror_is_disabled() {
|
||||
let original = "https://api.minecraftservices.com/minecraft/profile";
|
||||
assert_eq!(mojang_service_url(original, false), original);
|
||||
assert_eq!(
|
||||
mojang_service_url(original, true),
|
||||
"https://services.msp.fallenbreath.me/minecraft/profile"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use crate::Result;
|
||||
use std::io;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
pub async fn tcp_listen_any_loopback() -> io::Result<TcpListener> {
|
||||
// IPv4 is tried first for the best compatibility and performance with most systems.
|
||||
// IPv6 is also tried in case IPv4 is not available. Resolving "localhost" is avoided
|
||||
// to prevent failures deriving from improper name resolution setup. Any available
|
||||
// ephemeral port is used to prevent conflicts with other services. This is all as per
|
||||
// RFC 8252's recommendations
|
||||
const ANY_LOOPBACK_SOCKET: &[SocketAddr] = &[
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
|
||||
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
|
||||
];
|
||||
|
||||
TcpListener::bind(ANY_LOOPBACK_SOCKET).await
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub async fn is_network_metered() -> Result<bool> {
|
||||
use windows::Networking::Connectivity::{
|
||||
NetworkCostType, NetworkInformation,
|
||||
};
|
||||
|
||||
let cost_type = NetworkInformation::GetInternetConnectionProfile()?
|
||||
.GetConnectionCost()?
|
||||
.NetworkCostType()?;
|
||||
Ok(matches!(
|
||||
cost_type,
|
||||
NetworkCostType::Fixed | NetworkCostType::Variable
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn is_network_metered() -> Result<bool> {
|
||||
use crate::ErrorKind;
|
||||
use cidre::dispatch::Queue;
|
||||
use cidre::nw::PathMonitor;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::future::FutureExt;
|
||||
|
||||
let (sender, mut receiver) = mpsc::channel(1);
|
||||
|
||||
let queue = Queue::new();
|
||||
let mut monitor = PathMonitor::new();
|
||||
monitor.set_queue(&queue);
|
||||
monitor.set_update_handler(move |path| {
|
||||
let _ = sender.try_send(path.is_constrained() || path.is_expensive());
|
||||
});
|
||||
|
||||
monitor.start();
|
||||
let result = receiver
|
||||
.recv()
|
||||
.timeout(Duration::from_millis(100))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
monitor.cancel();
|
||||
|
||||
result.ok_or_else(|| {
|
||||
ErrorKind::OtherError(
|
||||
"NWPathMonitor didn't provide an NWPath in time".to_string(),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub async fn is_network_metered() -> Result<bool> {
|
||||
// Thanks to https://github.com/Hakanbaban53/rclone-manager for showing how to do this
|
||||
use zbus::{Connection, Proxy};
|
||||
|
||||
let connection = Connection::system().await?;
|
||||
let proxy = Proxy::new(
|
||||
&connection,
|
||||
"org.freedesktop.NetworkManager",
|
||||
"/org/freedesktop/NetworkManager",
|
||||
"org.freedesktop.NetworkManager",
|
||||
)
|
||||
.await?;
|
||||
let metered = proxy.get_property("Metered").await?;
|
||||
Ok(matches!(metered, 1 | 3))
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
|
||||
pub async fn is_network_metered() -> Result<bool> {
|
||||
tracing::warn!(
|
||||
"is_network_metered called on unsupported platform. Assuming unmetered."
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Platform-related code
|
||||
use daedalus::minecraft::{Os, OsRule};
|
||||
|
||||
/// (e.g. "Run as administrator" on Windows).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn is_process_elevated() -> bool {
|
||||
use std::ffi::c_void;
|
||||
use std::ptr::null_mut;
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
unsafe extern "system" {
|
||||
fn GetCurrentProcess() -> *mut c_void;
|
||||
fn CloseHandle(handle: *mut c_void) -> i32;
|
||||
}
|
||||
|
||||
#[link(name = "advapi32")]
|
||||
unsafe extern "system" {
|
||||
fn OpenProcessToken(
|
||||
process_handle: *mut c_void,
|
||||
desired_access: u32,
|
||||
token_handle: *mut *mut c_void,
|
||||
) -> i32;
|
||||
fn GetTokenInformation(
|
||||
token_handle: *mut c_void,
|
||||
token_information_class: u32,
|
||||
token_information: *mut c_void,
|
||||
token_information_length: u32,
|
||||
return_length: *mut u32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
const TOKEN_QUERY: u32 = 0x8;
|
||||
const TOKEN_ELEVATION_TYPE: u32 = 18;
|
||||
const TOKEN_ELEVATION_TYPE_FULL: u32 = 2;
|
||||
|
||||
// SAFETY: GetCurrentProcess returns a pseudo-handle that must not be
|
||||
// closed. The token handle is queried and closed on every path.
|
||||
unsafe {
|
||||
let process = GetCurrentProcess();
|
||||
let mut token = null_mut();
|
||||
if OpenProcessToken(process, TOKEN_QUERY, &raw mut token) == 0
|
||||
|| token.is_null()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut elevation_type: u32 = 0;
|
||||
let mut size = 0u32;
|
||||
let success = GetTokenInformation(
|
||||
token,
|
||||
TOKEN_ELEVATION_TYPE,
|
||||
&raw mut elevation_type as *mut c_void,
|
||||
std::mem::size_of::<u32>() as u32,
|
||||
&raw mut size,
|
||||
) != 0;
|
||||
let _ = CloseHandle(token);
|
||||
success && elevation_type == TOKEN_ELEVATION_TYPE_FULL
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn is_process_elevated() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// Bit width
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
pub const ARCH_WIDTH: &str = "64";
|
||||
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
pub const ARCH_WIDTH: &str = "32";
|
||||
|
||||
// Platform rule handling
|
||||
fn normalize_architecture(java_arch: &str) -> &str {
|
||||
if java_arch.eq_ignore_ascii_case("amd64") {
|
||||
"x86_64"
|
||||
} else if java_arch.eq_ignore_ascii_case("i386")
|
||||
|| java_arch.eq_ignore_ascii_case("i686")
|
||||
{
|
||||
"x86"
|
||||
} else if java_arch.eq_ignore_ascii_case("arm64") {
|
||||
"aarch64"
|
||||
} else if java_arch.eq_ignore_ascii_case("arm32") {
|
||||
"arm"
|
||||
} else {
|
||||
java_arch
|
||||
}
|
||||
}
|
||||
|
||||
pub fn os_rule(
|
||||
rule: &OsRule,
|
||||
java_arch: &str,
|
||||
// Minecraft updated over 1.18.2 (supports MacOS Natively)
|
||||
_minecraft_updated: bool,
|
||||
) -> bool {
|
||||
let mut rule_match = true;
|
||||
let normalized_arch = normalize_architecture(java_arch);
|
||||
|
||||
if let Some(expected) = rule.arch.as_deref() {
|
||||
let actual = normalized_arch.to_ascii_lowercase();
|
||||
rule_match &= match expected.to_ascii_lowercase().as_str() {
|
||||
"x86" | "i386" | "i686" => {
|
||||
matches!(actual.as_str(), "x86" | "i386" | "i686")
|
||||
}
|
||||
"x86_64" | "amd64" => {
|
||||
matches!(actual.as_str(), "x86_64" | "amd64")
|
||||
}
|
||||
"arm" | "arm32" => actual.starts_with("arm") && actual != "arm64",
|
||||
"aarch64" | "arm64" => {
|
||||
matches!(actual.as_str(), "aarch64" | "arm64")
|
||||
}
|
||||
expected => expected == actual,
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(name) = &rule.name {
|
||||
let native = Os::native_arch(normalized_arch);
|
||||
rule_match &= name == &native || name == &native.get_os();
|
||||
}
|
||||
|
||||
// `rule.version` is ignored because it's not usually seen on real recent
|
||||
// Minecraft version manifests, its alleged regex syntax is undefined and is
|
||||
// likely to not match `Regex`'s, and the way to get the value to match it
|
||||
// against is allegedly calling `System.getProperty("os.version")`, which
|
||||
// on Windows the OpenJDK implements by fetching the kernel32.dll version,
|
||||
// an approach that no public Rust library implements. Moreover, launchers
|
||||
// such as PrismLauncher also ignore this field. Code references:
|
||||
// - https://github.com/openjdk/jdk/blob/948ade8e7003a41683600428c8e3155c7ed798db/src/java.base/windows/native/libjava/java_props_md.c#L556
|
||||
// - https://github.com/PrismLauncher/PrismLauncher/blob/1c20faccf88999474af70db098a4c10e7a03af33/launcher/minecraft/Rule.h#L77
|
||||
// - https://github.com/FillZpp/sys-info-rs/blob/60ecf1470a5b7c90242f429934a3bacb6023ec4d/c/windows.c#L23-L38
|
||||
|
||||
rule_match
|
||||
}
|
||||
|
||||
pub fn classpath_separator(java_arch: &str) -> &'static str {
|
||||
match Os::native_arch(java_arch) {
|
||||
Os::Osx
|
||||
| Os::OsxArm64
|
||||
| Os::Linux
|
||||
| Os::LinuxArm32
|
||||
| Os::LinuxArm64
|
||||
| Os::Unknown => ":",
|
||||
Os::Windows | Os::WindowsArm64 => ";",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
use phf::phf_map;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Copy, Clone)]
|
||||
pub struct ProtocolVersion {
|
||||
pub version: u32,
|
||||
pub legacy: bool,
|
||||
}
|
||||
|
||||
impl ProtocolVersion {
|
||||
pub const fn modern(version: u32) -> Self {
|
||||
Self {
|
||||
version,
|
||||
legacy: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn legacy(version: u32) -> Self {
|
||||
Self {
|
||||
version,
|
||||
legacy: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The map of protocol versions from before version.json was added. For newer versions, the
|
||||
/// protocol version can just be read from version.json.
|
||||
pub const OLD_PROTOCOL_VERSIONS: phf::Map<&str, ProtocolVersion> = phf_map! {
|
||||
// April fools versions
|
||||
"1.RV-Pre1" => ProtocolVersion::modern(108),
|
||||
"15w14a" => ProtocolVersion::modern(48),
|
||||
"2point0_purple" => ProtocolVersion::legacy(92),
|
||||
"2point0_red" => ProtocolVersion::legacy(91),
|
||||
"2point0_blue" => ProtocolVersion::legacy(90),
|
||||
|
||||
// Normal versions
|
||||
"18w47a" => ProtocolVersion::modern(446),
|
||||
"18w46a" => ProtocolVersion::modern(445),
|
||||
"18w45a" => ProtocolVersion::modern(444),
|
||||
"18w44a" => ProtocolVersion::modern(443),
|
||||
"18w43c" => ProtocolVersion::modern(442),
|
||||
"18w43b" => ProtocolVersion::modern(441),
|
||||
"18w43a" => ProtocolVersion::modern(440),
|
||||
"1.13.2" => ProtocolVersion::modern(404),
|
||||
"1.13.2-pre2" => ProtocolVersion::modern(403),
|
||||
"1.13.2-pre1" => ProtocolVersion::modern(402),
|
||||
"1.13.1" => ProtocolVersion::modern(401),
|
||||
"1.13.1-pre2" => ProtocolVersion::modern(400),
|
||||
"1.13.1-pre1" => ProtocolVersion::modern(399),
|
||||
"18w33a" => ProtocolVersion::modern(398),
|
||||
"18w32a" => ProtocolVersion::modern(397),
|
||||
"18w31a" => ProtocolVersion::modern(396),
|
||||
"18w30b" => ProtocolVersion::modern(395),
|
||||
"18w30a" => ProtocolVersion::modern(394),
|
||||
"1.13" => ProtocolVersion::modern(393),
|
||||
"1.13-pre10" => ProtocolVersion::modern(392),
|
||||
"1.13-pre9" => ProtocolVersion::modern(391),
|
||||
"1.13-pre8" => ProtocolVersion::modern(390),
|
||||
"1.13-pre7" => ProtocolVersion::modern(389),
|
||||
"1.13-pre6" => ProtocolVersion::modern(388),
|
||||
"1.13-pre5" => ProtocolVersion::modern(387),
|
||||
"1.13-pre4" => ProtocolVersion::modern(386),
|
||||
"1.13-pre3" => ProtocolVersion::modern(385),
|
||||
"1.13-pre2" => ProtocolVersion::modern(384),
|
||||
"1.13-pre1" => ProtocolVersion::modern(383),
|
||||
"18w22c" => ProtocolVersion::modern(382),
|
||||
"18w22b" => ProtocolVersion::modern(381),
|
||||
"18w22a" => ProtocolVersion::modern(380),
|
||||
"18w21b" => ProtocolVersion::modern(379),
|
||||
"18w21a" => ProtocolVersion::modern(378),
|
||||
"18w20c" => ProtocolVersion::modern(377),
|
||||
"18w20b" => ProtocolVersion::modern(376),
|
||||
"18w20a" => ProtocolVersion::modern(375),
|
||||
"18w19b" => ProtocolVersion::modern(374),
|
||||
"18w19a" => ProtocolVersion::modern(373),
|
||||
"18w16a" => ProtocolVersion::modern(372),
|
||||
"18w15a" => ProtocolVersion::modern(371),
|
||||
"18w14b" => ProtocolVersion::modern(370),
|
||||
"18w14a" => ProtocolVersion::modern(369),
|
||||
"18w11a" => ProtocolVersion::modern(368),
|
||||
"18w10d" => ProtocolVersion::modern(367),
|
||||
"18w10c" => ProtocolVersion::modern(366),
|
||||
"18w10b" => ProtocolVersion::modern(365),
|
||||
"18w10a" => ProtocolVersion::modern(364),
|
||||
"18w09a" => ProtocolVersion::modern(363),
|
||||
"18w08b" => ProtocolVersion::modern(362),
|
||||
"18w08a" => ProtocolVersion::modern(361),
|
||||
"18w07c" => ProtocolVersion::modern(360),
|
||||
"18w07b" => ProtocolVersion::modern(359),
|
||||
"18w07a" => ProtocolVersion::modern(358),
|
||||
"18w06a" => ProtocolVersion::modern(357),
|
||||
"18w05a" => ProtocolVersion::modern(356),
|
||||
"18w03b" => ProtocolVersion::modern(355),
|
||||
"18w03a" => ProtocolVersion::modern(354),
|
||||
"18w02a" => ProtocolVersion::modern(353),
|
||||
"18w01a" => ProtocolVersion::modern(352),
|
||||
"17w50a" => ProtocolVersion::modern(351),
|
||||
"17w49b" => ProtocolVersion::modern(350),
|
||||
"17w49a" => ProtocolVersion::modern(349),
|
||||
"17w48a" => ProtocolVersion::modern(348),
|
||||
"17w47b" => ProtocolVersion::modern(347),
|
||||
"17w47a" => ProtocolVersion::modern(346),
|
||||
"17w46a" => ProtocolVersion::modern(345),
|
||||
"17w45b" => ProtocolVersion::modern(344),
|
||||
"17w45a" => ProtocolVersion::modern(343),
|
||||
"17w43b" => ProtocolVersion::modern(342),
|
||||
"17w43a" => ProtocolVersion::modern(341),
|
||||
"1.12.2" => ProtocolVersion::modern(340),
|
||||
"1.21.2-pre2" => ProtocolVersion::modern(339),
|
||||
"1.21.2-pre1" => ProtocolVersion::modern(339),
|
||||
"1.12.1" => ProtocolVersion::modern(338),
|
||||
"1.12.1-pre1" => ProtocolVersion::modern(337),
|
||||
"17w31a" => ProtocolVersion::modern(336),
|
||||
"1.12" => ProtocolVersion::modern(335),
|
||||
"1.12-pre7" => ProtocolVersion::modern(334),
|
||||
"1.12-pre6" => ProtocolVersion::modern(333),
|
||||
"1.12-pre5" => ProtocolVersion::modern(332),
|
||||
"1.12-pre4" => ProtocolVersion::modern(331),
|
||||
"1.12-pre3" => ProtocolVersion::modern(330),
|
||||
"1.12-pre2" => ProtocolVersion::modern(329),
|
||||
"1.12-pre1" => ProtocolVersion::modern(328),
|
||||
"17w18b" => ProtocolVersion::modern(327),
|
||||
"17w18a" => ProtocolVersion::modern(326),
|
||||
"17w17b" => ProtocolVersion::modern(325),
|
||||
"17w17a" => ProtocolVersion::modern(324),
|
||||
"17w16b" => ProtocolVersion::modern(323),
|
||||
"17w16a" => ProtocolVersion::modern(322),
|
||||
"17w15a" => ProtocolVersion::modern(321),
|
||||
"17w14a" => ProtocolVersion::modern(320),
|
||||
"17w13b" => ProtocolVersion::modern(319),
|
||||
"17w13a" => ProtocolVersion::modern(318),
|
||||
"17w06a" => ProtocolVersion::modern(317),
|
||||
"1.11.2" => ProtocolVersion::modern(316),
|
||||
"1.11.1" => ProtocolVersion::modern(316),
|
||||
"16w50a" => ProtocolVersion::modern(316),
|
||||
"1.11" => ProtocolVersion::modern(315),
|
||||
"1.11-pre1" => ProtocolVersion::modern(314),
|
||||
"16w44a" => ProtocolVersion::modern(313),
|
||||
"16w43a" => ProtocolVersion::modern(313),
|
||||
"16w42a" => ProtocolVersion::modern(312),
|
||||
"16w41a" => ProtocolVersion::modern(311),
|
||||
"16w40a" => ProtocolVersion::modern(310),
|
||||
"16w39c" => ProtocolVersion::modern(309),
|
||||
"16w39b" => ProtocolVersion::modern(308),
|
||||
"16w39a" => ProtocolVersion::modern(307),
|
||||
"16w38a" => ProtocolVersion::modern(306),
|
||||
"16w36a" => ProtocolVersion::modern(305),
|
||||
"16w35a" => ProtocolVersion::modern(304),
|
||||
"16w33a" => ProtocolVersion::modern(303),
|
||||
"16w32b" => ProtocolVersion::modern(302),
|
||||
"16w32a" => ProtocolVersion::modern(301),
|
||||
"1.10.2" => ProtocolVersion::modern(210),
|
||||
"1.10.1" => ProtocolVersion::modern(210),
|
||||
"1.10" => ProtocolVersion::modern(210),
|
||||
"1.10-pre2" => ProtocolVersion::modern(205),
|
||||
"1.10-pre1" => ProtocolVersion::modern(204),
|
||||
"16w21b" => ProtocolVersion::modern(203),
|
||||
"16w21a" => ProtocolVersion::modern(202),
|
||||
"16w20a" => ProtocolVersion::modern(201),
|
||||
"1.9.4" => ProtocolVersion::modern(110),
|
||||
"1.9.3" => ProtocolVersion::modern(110),
|
||||
"1.9.3-pre3" => ProtocolVersion::modern(110),
|
||||
"1.9.3-pre2" => ProtocolVersion::modern(110),
|
||||
"1.9.3-pre1" => ProtocolVersion::modern(109),
|
||||
"16w15b" => ProtocolVersion::modern(109),
|
||||
"16w15a" => ProtocolVersion::modern(109),
|
||||
"16w14a" => ProtocolVersion::modern(109),
|
||||
"1.9.2" => ProtocolVersion::modern(109),
|
||||
"1.9.1" => ProtocolVersion::modern(108),
|
||||
"1.9.1-pre3" => ProtocolVersion::modern(108),
|
||||
"1.9.1-pre2" => ProtocolVersion::modern(108),
|
||||
"1.9.1-pre1" => ProtocolVersion::modern(107),
|
||||
"1.9" => ProtocolVersion::modern(107),
|
||||
"1.9-pre4" => ProtocolVersion::modern(106),
|
||||
"1.9-pre3" => ProtocolVersion::modern(105),
|
||||
"1.9-pre2" => ProtocolVersion::modern(104),
|
||||
"1.9-pre1" => ProtocolVersion::modern(103),
|
||||
"16w07b" => ProtocolVersion::modern(102),
|
||||
"16w07a" => ProtocolVersion::modern(101),
|
||||
"16w06a" => ProtocolVersion::modern(100),
|
||||
"16w05b" => ProtocolVersion::modern(99),
|
||||
"16w05a" => ProtocolVersion::modern(98),
|
||||
"16w04a" => ProtocolVersion::modern(97),
|
||||
"16w03a" => ProtocolVersion::modern(96),
|
||||
"16w02a" => ProtocolVersion::modern(95),
|
||||
"15w51b" => ProtocolVersion::modern(94),
|
||||
"15w51a" => ProtocolVersion::modern(93),
|
||||
"15w50a" => ProtocolVersion::modern(92),
|
||||
"15w49b" => ProtocolVersion::modern(91),
|
||||
"15w49a" => ProtocolVersion::modern(90),
|
||||
"15w47c" => ProtocolVersion::modern(89),
|
||||
"15w47b" => ProtocolVersion::modern(88),
|
||||
"15w47a" => ProtocolVersion::modern(87),
|
||||
"15w46a" => ProtocolVersion::modern(86),
|
||||
"15w45a" => ProtocolVersion::modern(85),
|
||||
"15w44b" => ProtocolVersion::modern(84),
|
||||
"15w44a" => ProtocolVersion::modern(83),
|
||||
"15w43c" => ProtocolVersion::modern(82),
|
||||
"15w43b" => ProtocolVersion::modern(81),
|
||||
"15w43a" => ProtocolVersion::modern(80),
|
||||
"15w42a" => ProtocolVersion::modern(79),
|
||||
"15w41b" => ProtocolVersion::modern(78),
|
||||
"15w41a" => ProtocolVersion::modern(77),
|
||||
"15w40b" => ProtocolVersion::modern(76),
|
||||
"15w40a" => ProtocolVersion::modern(75),
|
||||
"15w39c" => ProtocolVersion::modern(74),
|
||||
"15w39b" => ProtocolVersion::modern(74),
|
||||
"15w39a" => ProtocolVersion::modern(74),
|
||||
"15w38b" => ProtocolVersion::modern(73),
|
||||
"15w38a" => ProtocolVersion::modern(72),
|
||||
"15w37a" => ProtocolVersion::modern(71),
|
||||
"15w36d" => ProtocolVersion::modern(70),
|
||||
"15w36c" => ProtocolVersion::modern(69),
|
||||
"15w36b" => ProtocolVersion::modern(68),
|
||||
"15w36a" => ProtocolVersion::modern(67),
|
||||
"15w35e" => ProtocolVersion::modern(66),
|
||||
"15w35d" => ProtocolVersion::modern(65),
|
||||
"15w35c" => ProtocolVersion::modern(64),
|
||||
"15w35b" => ProtocolVersion::modern(63),
|
||||
"15w35a" => ProtocolVersion::modern(62),
|
||||
"15w34d" => ProtocolVersion::modern(61),
|
||||
"15w34c" => ProtocolVersion::modern(60),
|
||||
"15w34b" => ProtocolVersion::modern(59),
|
||||
"15w34a" => ProtocolVersion::modern(58),
|
||||
"15w33c" => ProtocolVersion::modern(57),
|
||||
"15w33b" => ProtocolVersion::modern(56),
|
||||
"15w33a" => ProtocolVersion::modern(55),
|
||||
"15w32c" => ProtocolVersion::modern(54),
|
||||
"15w32b" => ProtocolVersion::modern(53),
|
||||
"15w32a" => ProtocolVersion::modern(52),
|
||||
"15w31c" => ProtocolVersion::modern(51),
|
||||
"15w31b" => ProtocolVersion::modern(50),
|
||||
"15w31a" => ProtocolVersion::modern(49),
|
||||
"1.8.9" => ProtocolVersion::modern(47),
|
||||
"1.8.8" => ProtocolVersion::modern(47),
|
||||
"1.8.7" => ProtocolVersion::modern(47),
|
||||
"1.8.6" => ProtocolVersion::modern(47),
|
||||
"1.8.5" => ProtocolVersion::modern(47),
|
||||
"1.8.4" => ProtocolVersion::modern(47),
|
||||
"1.8.3" => ProtocolVersion::modern(47),
|
||||
"1.8.2" => ProtocolVersion::modern(47),
|
||||
"1.8.2-pre7" => ProtocolVersion::modern(47),
|
||||
"1.8.2-pre6" => ProtocolVersion::modern(47),
|
||||
"1.8.2-pre5" => ProtocolVersion::modern(47),
|
||||
"1.8.2-pre4" => ProtocolVersion::modern(47),
|
||||
"1.8.2-pre3" => ProtocolVersion::modern(47),
|
||||
"1.8.2-pre2" => ProtocolVersion::modern(47),
|
||||
"1.8.2-pre1" => ProtocolVersion::modern(47),
|
||||
"1.8.1" => ProtocolVersion::modern(47),
|
||||
"1.8.1-pre5" => ProtocolVersion::modern(47),
|
||||
"1.8.1-pre4" => ProtocolVersion::modern(47),
|
||||
"1.8.1-pre3" => ProtocolVersion::modern(47),
|
||||
"1.8.1-pre2" => ProtocolVersion::modern(47),
|
||||
"1.8.1-pre1" => ProtocolVersion::modern(47),
|
||||
"1.8" => ProtocolVersion::modern(47),
|
||||
"1.8-pre3" => ProtocolVersion::modern(46),
|
||||
"1.8-pre2" => ProtocolVersion::modern(45),
|
||||
"1.8-pre1" => ProtocolVersion::modern(44),
|
||||
"14w34d" => ProtocolVersion::modern(43),
|
||||
"14w34c" => ProtocolVersion::modern(42),
|
||||
"14w34b" => ProtocolVersion::modern(41),
|
||||
"14w34a" => ProtocolVersion::modern(40),
|
||||
"14w33c" => ProtocolVersion::modern(39),
|
||||
"14w33b" => ProtocolVersion::modern(38),
|
||||
"14w33a" => ProtocolVersion::modern(37),
|
||||
"14w32d" => ProtocolVersion::modern(36),
|
||||
"14w32c" => ProtocolVersion::modern(35),
|
||||
"14w32b" => ProtocolVersion::modern(34),
|
||||
"14w32a" => ProtocolVersion::modern(33),
|
||||
"14w31a" => ProtocolVersion::modern(32),
|
||||
"14w30c" => ProtocolVersion::modern(31),
|
||||
"14w30b" => ProtocolVersion::modern(30),
|
||||
"14w30a" => ProtocolVersion::modern(30),
|
||||
"14w29b" => ProtocolVersion::modern(29),
|
||||
"14w29a" => ProtocolVersion::modern(29),
|
||||
"14w28b" => ProtocolVersion::modern(28),
|
||||
"14w28a" => ProtocolVersion::modern(27),
|
||||
"14w27b" => ProtocolVersion::modern(26),
|
||||
"14w27a" => ProtocolVersion::modern(26),
|
||||
"14w26c" => ProtocolVersion::modern(25),
|
||||
"14w26b" => ProtocolVersion::modern(24),
|
||||
"14w26a" => ProtocolVersion::modern(23),
|
||||
"14w25b" => ProtocolVersion::modern(22),
|
||||
"14w25a" => ProtocolVersion::modern(21),
|
||||
"14w21b" => ProtocolVersion::modern(20),
|
||||
"14w21a" => ProtocolVersion::modern(19),
|
||||
"14w20b" => ProtocolVersion::modern(18),
|
||||
"14w20a" => ProtocolVersion::modern(18),
|
||||
"14w19a" => ProtocolVersion::modern(17),
|
||||
"14w18b" => ProtocolVersion::modern(16),
|
||||
"14w18a" => ProtocolVersion::modern(16),
|
||||
"14w17a" => ProtocolVersion::modern(15),
|
||||
"14w11b" => ProtocolVersion::modern(14),
|
||||
"14w11a" => ProtocolVersion::modern(14),
|
||||
"14w10c" => ProtocolVersion::modern(13),
|
||||
"14w10b" => ProtocolVersion::modern(13),
|
||||
"14w10a" => ProtocolVersion::modern(13),
|
||||
"14w08a" => ProtocolVersion::modern(12),
|
||||
"14w07a" => ProtocolVersion::modern(11),
|
||||
"14w06b" => ProtocolVersion::modern(10),
|
||||
"14w06a" => ProtocolVersion::modern(10),
|
||||
"14w05b" => ProtocolVersion::modern(9),
|
||||
"14w05a" => ProtocolVersion::modern(9),
|
||||
"14w04b" => ProtocolVersion::modern(8),
|
||||
"14w04a" => ProtocolVersion::modern(7),
|
||||
"14w03b" => ProtocolVersion::modern(6),
|
||||
"14w03a" => ProtocolVersion::modern(6),
|
||||
"14w02c" => ProtocolVersion::modern(5),
|
||||
"14w02b" => ProtocolVersion::modern(5),
|
||||
"14w02a" => ProtocolVersion::modern(5),
|
||||
"1.7.10" => ProtocolVersion::modern(5),
|
||||
"1.7.10-pre4" => ProtocolVersion::modern(5),
|
||||
"1.7.10-pre3" => ProtocolVersion::modern(5),
|
||||
"1.7.10-pre2" => ProtocolVersion::modern(5),
|
||||
"1.7.10-pre1" => ProtocolVersion::modern(5),
|
||||
"1.7.9" => ProtocolVersion::modern(5),
|
||||
"1.7.8" => ProtocolVersion::modern(5),
|
||||
"1.7.7" => ProtocolVersion::modern(5),
|
||||
"1.7.6" => ProtocolVersion::modern(5),
|
||||
"1.7.6-pre2" => ProtocolVersion::modern(5),
|
||||
"1.7.6-pre1" => ProtocolVersion::modern(5),
|
||||
"1.7.5" => ProtocolVersion::modern(4),
|
||||
"1.7.4" => ProtocolVersion::modern(4),
|
||||
"1.7.3" => ProtocolVersion::modern(4),
|
||||
"13w49a" => ProtocolVersion::modern(4),
|
||||
"13w48b" => ProtocolVersion::modern(4),
|
||||
"13w48a" => ProtocolVersion::modern(4),
|
||||
"13w47e" => ProtocolVersion::modern(4),
|
||||
"13w47d" => ProtocolVersion::modern(4),
|
||||
"13w47c" => ProtocolVersion::modern(4),
|
||||
"13w47b" => ProtocolVersion::modern(4),
|
||||
"13w47a" => ProtocolVersion::modern(4),
|
||||
"1.7.2" => ProtocolVersion::modern(4),
|
||||
"1.7.1" => ProtocolVersion::modern(3),
|
||||
"1.7" => ProtocolVersion::modern(3),
|
||||
"13w43a" => ProtocolVersion::modern(2),
|
||||
"13w42b" => ProtocolVersion::modern(1),
|
||||
"13w42a" => ProtocolVersion::modern(1),
|
||||
"13w41b" => ProtocolVersion::modern(0),
|
||||
"13w41a" => ProtocolVersion::modern(0),
|
||||
"13w39b" => ProtocolVersion::legacy(80),
|
||||
"13w39a" => ProtocolVersion::legacy(80),
|
||||
"13w38c" => ProtocolVersion::legacy(79),
|
||||
"13w38b" => ProtocolVersion::legacy(79),
|
||||
"13w38a" => ProtocolVersion::legacy(79),
|
||||
"1.6.4" => ProtocolVersion::legacy(78),
|
||||
"1.6.3" => ProtocolVersion::legacy(77),
|
||||
"13w37b" => ProtocolVersion::legacy(76),
|
||||
"13w37a" => ProtocolVersion::legacy(76),
|
||||
"13w36b" => ProtocolVersion::legacy(75),
|
||||
"13w36a" => ProtocolVersion::legacy(75),
|
||||
"1.6.2" => ProtocolVersion::legacy(74),
|
||||
"1.6.1" => ProtocolVersion::legacy(73),
|
||||
"1.6" => ProtocolVersion::legacy(72),
|
||||
"13w26a" => ProtocolVersion::legacy(72),
|
||||
"13w25c" => ProtocolVersion::legacy(71),
|
||||
"13w25b" => ProtocolVersion::legacy(71),
|
||||
"13w25a" => ProtocolVersion::legacy(71),
|
||||
"13w24b" => ProtocolVersion::legacy(70),
|
||||
"13w24a" => ProtocolVersion::legacy(69),
|
||||
"13w23b" => ProtocolVersion::legacy(68),
|
||||
"13w23a" => ProtocolVersion::legacy(67),
|
||||
"13w22a" => ProtocolVersion::legacy(67),
|
||||
"13w21b" => ProtocolVersion::legacy(67),
|
||||
"13w21a" => ProtocolVersion::legacy(67),
|
||||
"13w19a" => ProtocolVersion::legacy(66),
|
||||
"13w18c" => ProtocolVersion::legacy(65),
|
||||
"13w18b" => ProtocolVersion::legacy(65),
|
||||
"13w18a" => ProtocolVersion::legacy(65),
|
||||
"13w17a" => ProtocolVersion::legacy(64),
|
||||
"13w16b" => ProtocolVersion::legacy(63),
|
||||
"13w16a" => ProtocolVersion::legacy(62),
|
||||
"1.5.2" => ProtocolVersion::legacy(61),
|
||||
"1.5.1" => ProtocolVersion::legacy(60),
|
||||
"13w12~" => ProtocolVersion::legacy(60),
|
||||
"13w11a" => ProtocolVersion::legacy(60),
|
||||
"1.5" => ProtocolVersion::legacy(60),
|
||||
"13w10b" => ProtocolVersion::legacy(60),
|
||||
"13w10a" => ProtocolVersion::legacy(60),
|
||||
"13w09c" => ProtocolVersion::legacy(60),
|
||||
"13w09b" => ProtocolVersion::legacy(59),
|
||||
"13w09a" => ProtocolVersion::legacy(59),
|
||||
"13w07a" => ProtocolVersion::legacy(58),
|
||||
"13w06a" => ProtocolVersion::legacy(58),
|
||||
"13w05b" => ProtocolVersion::legacy(56),
|
||||
"13w05a" => ProtocolVersion::legacy(56),
|
||||
"13w04a" => ProtocolVersion::legacy(55),
|
||||
"13w03a" => ProtocolVersion::legacy(54),
|
||||
"13w02b" => ProtocolVersion::legacy(53),
|
||||
"13w02a" => ProtocolVersion::legacy(53),
|
||||
"13w01b" => ProtocolVersion::legacy(52),
|
||||
"13w01a" => ProtocolVersion::legacy(52),
|
||||
"1.4.7" => ProtocolVersion::legacy(51),
|
||||
"1.4.6" => ProtocolVersion::legacy(51),
|
||||
"12w50b" => ProtocolVersion::legacy(51),
|
||||
"12w50a" => ProtocolVersion::legacy(51),
|
||||
"12w49a" => ProtocolVersion::legacy(50),
|
||||
"1.4.5" => ProtocolVersion::legacy(49),
|
||||
"1.4.4" => ProtocolVersion::legacy(49),
|
||||
"1.4.3" => ProtocolVersion::legacy(48),
|
||||
"1.4.2" => ProtocolVersion::legacy(47),
|
||||
"1.4.1" => ProtocolVersion::legacy(47),
|
||||
"1.4" => ProtocolVersion::legacy(47),
|
||||
"12w42b" => ProtocolVersion::legacy(47),
|
||||
"12w42a" => ProtocolVersion::legacy(46),
|
||||
"12w41b" => ProtocolVersion::legacy(46),
|
||||
"12w41a" => ProtocolVersion::legacy(46),
|
||||
"12w40b" => ProtocolVersion::legacy(45),
|
||||
"12w40a" => ProtocolVersion::legacy(44),
|
||||
"12w39b" => ProtocolVersion::legacy(43),
|
||||
"12w39a" => ProtocolVersion::legacy(43),
|
||||
"12w38b" => ProtocolVersion::legacy(43),
|
||||
"12w38a" => ProtocolVersion::legacy(43),
|
||||
"12w37a" => ProtocolVersion::legacy(42),
|
||||
"12w36a" => ProtocolVersion::legacy(42),
|
||||
"12w34b" => ProtocolVersion::legacy(42),
|
||||
"12w34a" => ProtocolVersion::legacy(41),
|
||||
"12w32a" => ProtocolVersion::legacy(40),
|
||||
"1.3.2" => ProtocolVersion::legacy(39),
|
||||
"1.3.1" => ProtocolVersion::legacy(39),
|
||||
"1.3" => ProtocolVersion::legacy(39),
|
||||
"12w30e" => ProtocolVersion::legacy(39),
|
||||
"12w30d" => ProtocolVersion::legacy(39),
|
||||
"12w30c" => ProtocolVersion::legacy(39),
|
||||
"12w30b" => ProtocolVersion::legacy(38),
|
||||
"12w30a" => ProtocolVersion::legacy(38),
|
||||
"12w27a" => ProtocolVersion::legacy(38),
|
||||
"12w26a" => ProtocolVersion::legacy(37),
|
||||
"12w25a" => ProtocolVersion::legacy(37),
|
||||
"12w24a" => ProtocolVersion::legacy(36),
|
||||
"12w23b" => ProtocolVersion::legacy(35),
|
||||
"12w23a" => ProtocolVersion::legacy(35),
|
||||
"12w22a" => ProtocolVersion::legacy(34),
|
||||
"12w21b" => ProtocolVersion::legacy(33),
|
||||
"12w21a" => ProtocolVersion::legacy(33),
|
||||
"12w19a" => ProtocolVersion::legacy(32),
|
||||
"12w18a" => ProtocolVersion::legacy(32),
|
||||
"12w17a" => ProtocolVersion::legacy(31),
|
||||
"12w16a" => ProtocolVersion::legacy(30),
|
||||
"12w15a" => ProtocolVersion::legacy(29),
|
||||
"1.2.5" => ProtocolVersion::legacy(29),
|
||||
"1.2.4" => ProtocolVersion::legacy(29),
|
||||
"1.2.3" => ProtocolVersion::legacy(28),
|
||||
"1.2.2" => ProtocolVersion::legacy(28),
|
||||
"1.2.1" => ProtocolVersion::legacy(28),
|
||||
"1.2" => ProtocolVersion::legacy(28),
|
||||
"12w08a" => ProtocolVersion::legacy(28),
|
||||
"12w07b" => ProtocolVersion::legacy(27),
|
||||
"12w07a" => ProtocolVersion::legacy(27),
|
||||
"12w06a" => ProtocolVersion::legacy(25),
|
||||
"12w05b" => ProtocolVersion::legacy(24),
|
||||
"12w05a" => ProtocolVersion::legacy(24),
|
||||
"12w04a" => ProtocolVersion::legacy(24),
|
||||
"12w03a" => ProtocolVersion::legacy(24),
|
||||
"1.1" => ProtocolVersion::legacy(23),
|
||||
"12w01a" => ProtocolVersion::legacy(23),
|
||||
"11w50a" => ProtocolVersion::legacy(22),
|
||||
"11w49a" => ProtocolVersion::legacy(22),
|
||||
"11w48a" => ProtocolVersion::legacy(22),
|
||||
"11w47a" => ProtocolVersion::legacy(22),
|
||||
"1.0.1" => ProtocolVersion::legacy(22),
|
||||
"1.0.0" => ProtocolVersion::legacy(22),
|
||||
"1.0.0-rc2-1" => ProtocolVersion::legacy(22),
|
||||
"1.0.0-rc2-2" => ProtocolVersion::legacy(22),
|
||||
"1.0.0-rc2-3" => ProtocolVersion::legacy(22),
|
||||
"1.0.0-rc1" => ProtocolVersion::legacy(22),
|
||||
"b1.9-pre6" => ProtocolVersion::legacy(22),
|
||||
"b1.9-pre5" => ProtocolVersion::legacy(21),
|
||||
"b1.9-pre4" => ProtocolVersion::legacy(20),
|
||||
"b1.9-pre3" => ProtocolVersion::legacy(19),
|
||||
"b1.9-pre2" => ProtocolVersion::legacy(19),
|
||||
"b1.9-pre1" => ProtocolVersion::legacy(18),
|
||||
"b1.8.1" => ProtocolVersion::legacy(17),
|
||||
"b1.8" => ProtocolVersion::legacy(17),
|
||||
"b1.8-pre2" => ProtocolVersion::legacy(16),
|
||||
"b1.8-pre1-1" => ProtocolVersion::legacy(15),
|
||||
"b1.8-pre1-2" => ProtocolVersion::legacy(15),
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
use reqwest::{ClientBuilder, Proxy};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Result;
|
||||
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize,
|
||||
)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ProxyMode {
|
||||
/// Bypass all proxies and connect directly.
|
||||
None,
|
||||
#[default]
|
||||
System,
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl ProxyMode {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::System => "system",
|
||||
Self::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_string(value: &str) -> Self {
|
||||
match value {
|
||||
"none" => Self::None,
|
||||
"custom" => Self::Custom,
|
||||
_ => Self::System,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProxyConfig {
|
||||
pub mode: ProxyMode,
|
||||
pub url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl ProxyConfig {
|
||||
pub fn storage_key() -> &'static str {
|
||||
"proxy_config_v1"
|
||||
}
|
||||
|
||||
pub fn custom_url_trimmed(&self) -> Option<&str> {
|
||||
let trimmed = self.url.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_credentials(&self) -> bool {
|
||||
!self.username.trim().is_empty()
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.mode != ProxyMode::Custom {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(url) = self.custom_url_trimmed() else {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Custom proxy URL is required when mode is Custom".to_string(),
|
||||
)
|
||||
.into());
|
||||
};
|
||||
let parsed_url = reqwest::Url::parse(url).map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Proxy URL is invalid: {error}"
|
||||
))
|
||||
})?;
|
||||
let scheme = parsed_url.scheme().to_string();
|
||||
if !matches!(
|
||||
scheme.as_str(),
|
||||
"http" | "https" | "socks4" | "socks5" | "socks5h"
|
||||
) {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Unsupported proxy scheme '{scheme}'. Use http, https, socks5, or socks5h."
|
||||
))
|
||||
.into());
|
||||
}
|
||||
reqwest::Proxy::all(url).map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Proxy URL is invalid: {error}"
|
||||
))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply(&self, builder: ClientBuilder) -> Result<ClientBuilder> {
|
||||
match self.mode {
|
||||
ProxyMode::None => Ok(builder.no_proxy()),
|
||||
ProxyMode::System => Ok(builder),
|
||||
ProxyMode::Custom => {
|
||||
let url = self.custom_url_trimmed().ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Custom proxy URL is required when mode is Custom"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let parsed_url = reqwest::Url::parse(url).map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Proxy URL is invalid: {error}"
|
||||
))
|
||||
})?;
|
||||
let proxy = if self.has_credentials() {
|
||||
if parsed_url.scheme().starts_with("socks") {
|
||||
let mut authed_url = parsed_url;
|
||||
let _ = authed_url.set_username(&self.username);
|
||||
let _ = authed_url.set_password(Some(&self.password));
|
||||
Proxy::all(authed_url.as_str()).map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Proxy URL is invalid: {error}"
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
Proxy::all(url)
|
||||
.map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Proxy URL is invalid: {error}"
|
||||
))
|
||||
})?
|
||||
.basic_auth(&self.username, &self.password)
|
||||
}
|
||||
} else {
|
||||
Proxy::all(url).map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Proxy URL is invalid: {error}"
|
||||
))
|
||||
})?
|
||||
};
|
||||
Ok(builder.proxy(proxy))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProxyTestResult {
|
||||
pub success: bool,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub message: String,
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Resolve shortcuts, symlinks, and application bundles to their target paths.
|
||||
//!
|
||||
//! Supports:
|
||||
//! - Windows `.lnk` files via COM `IShellLinkW`
|
||||
//! - macOS `.app` bundles via `Contents/Info.plist`
|
||||
//! - Linux `.desktop` files
|
||||
//! - Symlinks on all platforms
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::warn;
|
||||
|
||||
/// Maximum recursion depth to prevent infinite loops.
|
||||
#[allow(dead_code)]
|
||||
const MAX_DEPTH: u32 = 3;
|
||||
|
||||
/// Resolve a shortcut, symlink, or application bundle to its target path.
|
||||
///
|
||||
/// Returns `None` when the path is not a recognised shortcut type, on any
|
||||
/// error, or when `max_depth` reaches 0 (recursion guard).
|
||||
pub fn resolve_shortcut(path: &Path, max_depth: u32) -> Option<PathBuf> {
|
||||
if max_depth == 0 {
|
||||
warn!(
|
||||
"Shortcut resolution exceeded max depth for: {}",
|
||||
path.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Always try symlink resolution first (platform-independent).
|
||||
if let Ok(target) = std::fs::read_link(path) {
|
||||
if target.is_absolute() {
|
||||
// Resolve symlink chains recursively.
|
||||
return resolve_shortcut(&target, max_depth - 1).or(Some(target));
|
||||
} else {
|
||||
// Relative symlink: resolve relative to the symlink's parent.
|
||||
let absolute = if let Some(parent) = path.parent() {
|
||||
parent.join(&target)
|
||||
} else {
|
||||
target
|
||||
};
|
||||
return resolve_shortcut(&absolute, max_depth - 1)
|
||||
.or(Some(absolute));
|
||||
}
|
||||
}
|
||||
|
||||
// Platform-specific shortcut resolution.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(resolved) = resolve_windows_lnk(path, max_depth) {
|
||||
return Some(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(resolved) = resolve_macos_app(path, max_depth) {
|
||||
return Some(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(resolved) = resolve_linux_desktop(path, max_depth) {
|
||||
return Some(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// ─── Windows .lnk resolution ───────────────────────────────────────────────
|
||||
|
||||
#[cfg(windows)]
|
||||
fn resolve_windows_lnk(path: &Path, _max_depth: u32) -> Option<PathBuf> {
|
||||
let ext = path.extension()?;
|
||||
if !ext.eq_ignore_ascii_case("lnk") {
|
||||
return None;
|
||||
}
|
||||
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows::Win32::System::Com::{
|
||||
CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, COINIT_DISABLE_OLE1DDE,
|
||||
CoCreateInstance, CoInitializeEx, IPersistFile, STGM,
|
||||
};
|
||||
use windows::Win32::UI::Shell::{IShellLinkW, ShellLink};
|
||||
use windows::core::{Interface, PCWSTR};
|
||||
|
||||
// SAFETY: COM is initialised for this thread. The guard ensures
|
||||
// `CoUninitialize` is called exactly once when we're done.
|
||||
unsafe {
|
||||
let init = CoInitializeEx(
|
||||
None,
|
||||
COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE,
|
||||
);
|
||||
if init.is_err() {
|
||||
return None;
|
||||
}
|
||||
let _guard = ComGuard;
|
||||
|
||||
let shortcut: IShellLinkW =
|
||||
CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER).ok()?;
|
||||
|
||||
let persist: IPersistFile = shortcut.cast().ok()?;
|
||||
|
||||
let wide_path: Vec<u16> = path
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
persist
|
||||
.Load(PCWSTR::from_raw(wide_path.as_ptr()), STGM(0))
|
||||
.ok()?;
|
||||
|
||||
let mut buf = [0u16; 1024];
|
||||
shortcut.GetPath(&mut buf, std::ptr::null_mut(), 0).ok()?;
|
||||
|
||||
// Find the null terminator.
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
let target = String::from_utf16(&buf[..len]).ok()?;
|
||||
Some(PathBuf::from(&target))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::System::Com::CoUninitialize;
|
||||
|
||||
#[cfg(windows)]
|
||||
struct ComGuard;
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Drop for ComGuard {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: COM was initialised in the current thread before this guard
|
||||
// was created.
|
||||
unsafe {
|
||||
CoUninitialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── macOS .app bundle resolution ──────────────────────────────────────────
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn resolve_macos_app(path: &Path, max_depth: u32) -> Option<PathBuf> {
|
||||
// Must be a directory ending in .app.
|
||||
if !path.is_dir() {
|
||||
return None;
|
||||
}
|
||||
let ext = path.extension()?;
|
||||
if !ext.eq_ignore_ascii_case("app") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let plist_path = path.join("Contents").join("Info.plist");
|
||||
if !plist_path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Parse the XML plist with quick_xml to find CFBundleExecutable.
|
||||
let content = std::fs::read_to_string(&plist_path).ok()?;
|
||||
let executable = parse_cfbundle_executable(&content)?;
|
||||
|
||||
let executable_path = path.join("Contents").join("MacOS").join(executable);
|
||||
if executable_path.exists() {
|
||||
resolve_shortcut(&executable_path, max_depth - 1)
|
||||
.or(Some(executable_path))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Naive XML parser for extracting `CFBundleExecutable` from a plist.
|
||||
///
|
||||
/// Uses simple string matching rather than a full XML parser to avoid adding
|
||||
/// a dependency. The plist is well-known enough that this is reliable.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn parse_cfbundle_executable(content: &str) -> Option<String> {
|
||||
// Look for: <key>CFBundleExecutable</key> followed by <string>value</string>
|
||||
let key_marker = "<key>CFBundleExecutable</key>";
|
||||
let key_pos = content.find(key_marker)?;
|
||||
let after_key = &content[key_pos + key_marker.len()..];
|
||||
|
||||
let string_start = after_key.find("<string>")?;
|
||||
let value_start = string_start + "<string>".len();
|
||||
let value_end = after_key[value_start..].find("</string>")?;
|
||||
|
||||
Some(
|
||||
after_key[value_start..value_start + value_end]
|
||||
.trim()
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Linux .desktop file resolution ────────────────────────────────────────
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn resolve_linux_desktop(path: &Path, _max_depth: u32) -> Option<PathBuf> {
|
||||
let ext = path.extension()?;
|
||||
if !ext.eq_ignore_ascii_case("desktop") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
parse_desktop_entry(&content)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_desktop_entry(content: &str) -> Option<PathBuf> {
|
||||
let mut in_desktop_entry = false;
|
||||
let mut exec_line: Option<&str> = None;
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == "[Desktop Entry]" {
|
||||
in_desktop_entry = true;
|
||||
continue;
|
||||
}
|
||||
if !in_desktop_entry {
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with('[') {
|
||||
// Reached next section — stop looking.
|
||||
break;
|
||||
}
|
||||
if let Some(value) = trimmed.strip_prefix("Exec=") {
|
||||
exec_line = Some(value);
|
||||
}
|
||||
}
|
||||
|
||||
let exec = exec_line?;
|
||||
|
||||
// Parse the Exec value: extract the first token that is not a parameter
|
||||
// placeholder like %f, %F, %u, %U, %k, %c.
|
||||
let tokens = shlex::split(exec);
|
||||
let binary =
|
||||
tokens
|
||||
.iter()
|
||||
.flat_map(|tokens| tokens.iter())
|
||||
.find(|token| {
|
||||
!token.starts_with('%')
|
||||
&& !token.starts_with('-')
|
||||
&& !token.starts_with("--")
|
||||
})?;
|
||||
|
||||
let binary_path = if binary.contains('/') {
|
||||
PathBuf::from(binary)
|
||||
} else {
|
||||
// Look up in PATH.
|
||||
std::env::var_os("PATH")
|
||||
.iter()
|
||||
.flat_map(|p| std::env::split_paths(p))
|
||||
.map(|dir| dir.join(binary))
|
||||
.find(|p| p.exists())?
|
||||
};
|
||||
|
||||
Some(binary_path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_symlink_resolution() {
|
||||
let dir = tempdir().expect("temp dir");
|
||||
let target = dir.path().join("real_file.txt");
|
||||
let link = dir.path().join("link.txt");
|
||||
|
||||
fs::write(&target, "hello").expect("write target");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&target, &link).expect("symlink");
|
||||
let resolved = resolve_shortcut(&link, MAX_DEPTH);
|
||||
assert!(resolved.is_some());
|
||||
assert_eq!(
|
||||
resolved.unwrap().canonicalize().ok(),
|
||||
Some(target.canonicalize().ok()).flatten()
|
||||
);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// On Windows, try junction or symlink if available.
|
||||
let _ = std::os::windows::fs::symlink_file(&target, &link);
|
||||
let resolved = resolve_shortcut(&link, MAX_DEPTH);
|
||||
// symlink creation may fail on some Windows configs — accept either outcome.
|
||||
if link.exists() {
|
||||
assert!(resolved.is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_broken_symlink() {
|
||||
let dir = tempdir().expect("temp dir");
|
||||
let link = dir.path().join("broken_link");
|
||||
std::os::unix::fs::symlink("/nonexistent/path", &link)
|
||||
.expect("symlink");
|
||||
let resolved = resolve_shortcut(&link, MAX_DEPTH);
|
||||
// `read_link` succeeds but the target doesn't exist — resolve_shortcut
|
||||
// still returns the target path from read_link.
|
||||
assert!(resolved.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_depth_zero() {
|
||||
let resolved = resolve_shortcut(Path::new("/some/path"), 0);
|
||||
assert!(resolved.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regular_file_no_shortcut() {
|
||||
let dir = tempdir().expect("temp dir");
|
||||
let file = dir.path().join("regular.txt");
|
||||
fs::write(&file, "data").expect("write");
|
||||
|
||||
let resolved = resolve_shortcut(&file, MAX_DEPTH);
|
||||
// Not a symlink, not a platform shortcut — should return None.
|
||||
assert!(resolved.is_none());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_relative_symlink() {
|
||||
let dir = tempdir().expect("temp dir");
|
||||
let target = dir.path().join("target.txt");
|
||||
|
||||
fs::write(&target, "hello").expect("write target");
|
||||
let link = dir.path().join("link.txt");
|
||||
// Create a relative symlink.
|
||||
std::os::unix::fs::symlink("target.txt", &link).expect("symlink");
|
||||
let resolved = resolve_shortcut(&link, MAX_DEPTH);
|
||||
assert!(resolved.is_some());
|
||||
let resolved = resolved.unwrap();
|
||||
assert!(resolved.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_a_symlink_directory() {
|
||||
let dir = tempdir().expect("temp dir");
|
||||
let subdir = dir.path().join("subdir");
|
||||
fs::create_dir(&subdir).expect("create dir");
|
||||
|
||||
let resolved = resolve_shortcut(&subdir, MAX_DEPTH);
|
||||
// A regular directory without any shortcut marker → None.
|
||||
assert!(resolved.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
use crate::prelude::tcp_listen_any_loopback;
|
||||
use crate::{ErrorKind, Result};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::task::AbortHandle;
|
||||
use tokio_util::codec::{Decoder, LinesCodec, LinesCodecError};
|
||||
use uuid::Uuid;
|
||||
|
||||
type HandlerFuture = Pin<Box<dyn Send + Future<Output = Result<Value>>>>;
|
||||
type HandlerMethod = Box<dyn Send + Sync + Fn(Vec<Value>) -> HandlerFuture>;
|
||||
type HandlerMap = HashMap<&'static str, HandlerMethod>;
|
||||
type WaitingResponsesMap =
|
||||
Arc<Mutex<HashMap<Uuid, oneshot::Sender<Result<Value>>>>>;
|
||||
|
||||
pub struct RpcServerBuilder {
|
||||
handlers: HandlerMap,
|
||||
}
|
||||
|
||||
impl RpcServerBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
handlers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// We'll use this function in the future. Please remove this #[allow] when we do.
|
||||
#[allow(dead_code)]
|
||||
pub fn handler(
|
||||
mut self,
|
||||
function_name: &'static str,
|
||||
handler: HandlerMethod,
|
||||
) -> Self {
|
||||
self.handlers.insert(function_name, Box::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn launch(self) -> Result<RpcServer> {
|
||||
let socket = tcp_listen_any_loopback().await?;
|
||||
let address = socket.local_addr()?;
|
||||
let (message_sender, message_receiver) = mpsc::unbounded_channel();
|
||||
let waiting_responses = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
let join_handle = {
|
||||
let waiting_responses = waiting_responses.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut server = RunningRpcServer {
|
||||
message_receiver,
|
||||
handlers: self.handlers,
|
||||
waiting_responses: waiting_responses.clone(),
|
||||
};
|
||||
if let Err(e) = server.run(socket).await {
|
||||
tracing::error!("Failed to run RPC server: {e}");
|
||||
}
|
||||
waiting_responses.lock().unwrap().clear();
|
||||
})
|
||||
};
|
||||
Ok(RpcServer {
|
||||
address,
|
||||
message_sender,
|
||||
waiting_responses,
|
||||
abort_handle: join_handle.abort_handle(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RpcServer {
|
||||
address: SocketAddr,
|
||||
message_sender: mpsc::UnboundedSender<RpcMessage>,
|
||||
waiting_responses: WaitingResponsesMap,
|
||||
abort_handle: AbortHandle,
|
||||
}
|
||||
|
||||
impl RpcServer {
|
||||
pub fn address(&self) -> SocketAddr {
|
||||
self.address
|
||||
}
|
||||
|
||||
pub async fn call_method<R: DeserializeOwned>(
|
||||
&self,
|
||||
method: &str,
|
||||
) -> Result<R> {
|
||||
self.call_method_any(method, vec![]).await
|
||||
}
|
||||
|
||||
pub async fn call_method_2<R: DeserializeOwned>(
|
||||
&self,
|
||||
method: &str,
|
||||
arg1: impl Serialize,
|
||||
arg2: impl Serialize,
|
||||
) -> Result<R> {
|
||||
self.call_method_any(
|
||||
method,
|
||||
vec![serde_json::to_value(arg1)?, serde_json::to_value(arg2)?],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn call_method_any<R: DeserializeOwned>(
|
||||
&self,
|
||||
method: &str,
|
||||
args: Vec<Value>,
|
||||
) -> Result<R> {
|
||||
if self.message_sender.is_closed() {
|
||||
return Err(ErrorKind::RpcError(
|
||||
"RPC connection closed".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let (send, recv) = oneshot::channel();
|
||||
self.waiting_responses.lock().unwrap().insert(id, send);
|
||||
|
||||
let message = RpcMessage {
|
||||
id,
|
||||
body: RpcMessageBody::Call {
|
||||
method: method.to_owned(),
|
||||
args,
|
||||
},
|
||||
};
|
||||
if self.message_sender.send(message).is_err() {
|
||||
self.waiting_responses.lock().unwrap().remove(&id);
|
||||
return Err(ErrorKind::RpcError(
|
||||
"RPC connection closed while sending".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
tracing::debug!("Waiting on result for {id}");
|
||||
let Ok(result) = recv.await else {
|
||||
self.waiting_responses.lock().unwrap().remove(&id);
|
||||
return Err(ErrorKind::RpcError(
|
||||
"RPC connection closed while waiting for response".to_string(),
|
||||
)
|
||||
.into());
|
||||
};
|
||||
result.and_then(|x| Ok(serde_json::from_value(x)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RpcServer {
|
||||
fn drop(&mut self) {
|
||||
self.abort_handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
struct RunningRpcServer {
|
||||
message_receiver: mpsc::UnboundedReceiver<RpcMessage>,
|
||||
handlers: HandlerMap,
|
||||
waiting_responses: WaitingResponsesMap,
|
||||
}
|
||||
|
||||
impl RunningRpcServer {
|
||||
async fn run(&mut self, listener: TcpListener) -> Result<()> {
|
||||
let (socket, _) = listener.accept().await?;
|
||||
drop(listener);
|
||||
|
||||
let mut socket = LinesCodec::new().framed(socket);
|
||||
loop {
|
||||
let to_send = tokio::select! {
|
||||
message = self.message_receiver.recv() => {
|
||||
if message.is_none() {
|
||||
break;
|
||||
}
|
||||
message
|
||||
},
|
||||
message = socket.next() => {
|
||||
let message: RpcMessage = match message {
|
||||
None => break,
|
||||
Some(Ok(message)) => serde_json::from_str(&message)?,
|
||||
Some(Err(LinesCodecError::Io(e))) => Err(e)?,
|
||||
Some(Err(LinesCodecError::MaxLineLengthExceeded)) => unreachable!(),
|
||||
};
|
||||
self.handle_message(message).await?
|
||||
},
|
||||
};
|
||||
if let Some(message) = to_send {
|
||||
let json = serde_json::to_string(&message)?;
|
||||
match socket.send(json).await {
|
||||
Ok(()) => {}
|
||||
Err(LinesCodecError::Io(e)) => Err(e)?,
|
||||
Err(LinesCodecError::MaxLineLengthExceeded) => {
|
||||
unreachable!()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_message(
|
||||
&self,
|
||||
message: RpcMessage,
|
||||
) -> Result<Option<RpcMessage>> {
|
||||
if let RpcMessageBody::Call { method, args } = message.body {
|
||||
let response = match self.handlers.get(method.as_str()) {
|
||||
Some(handler) => match handler(args).await {
|
||||
Ok(result) => RpcMessageBody::Respond { response: result },
|
||||
Err(e) => RpcMessageBody::Error {
|
||||
error: e.to_string(),
|
||||
},
|
||||
},
|
||||
None => RpcMessageBody::Error {
|
||||
error: format!("Unknown theseus RPC method {method}"),
|
||||
},
|
||||
};
|
||||
Ok(Some(RpcMessage {
|
||||
id: message.id,
|
||||
body: response,
|
||||
}))
|
||||
} else if let Some(sender) =
|
||||
self.waiting_responses.lock().unwrap().remove(&message.id)
|
||||
{
|
||||
let _ = sender.send(match message.body {
|
||||
RpcMessageBody::Respond { response } => Ok(response),
|
||||
RpcMessageBody::Error { error } => {
|
||||
Err(ErrorKind::RpcError(error).into())
|
||||
}
|
||||
_ => unreachable!(),
|
||||
});
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct RpcMessage {
|
||||
id: Uuid,
|
||||
#[serde(flatten)]
|
||||
body: RpcMessageBody,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RpcMessageBody {
|
||||
Call {
|
||||
method: String,
|
||||
args: Vec<Value>,
|
||||
},
|
||||
Respond {
|
||||
#[serde(default, skip_serializing_if = "Value::is_null")]
|
||||
response: Value,
|
||||
},
|
||||
Error {
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
use crate::ErrorKind;
|
||||
use crate::error::Result;
|
||||
use crate::util::protocol_version::ProtocolVersion;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
use std::time::Duration;
|
||||
use tokio::net::ToSocketAddrs;
|
||||
use tokio::select;
|
||||
use url::Url;
|
||||
|
||||
const MAX_MINECRAFT_STATUS_STRING_LENGTH: usize = 32_767;
|
||||
const MAX_MODERN_STATUS_PACKET_LENGTH: usize =
|
||||
MAX_MINECRAFT_STATUS_STRING_LENGTH + 4;
|
||||
const MAX_LEGACY_STATUS_UTF16_LENGTH: usize =
|
||||
MAX_MINECRAFT_STATUS_STRING_LENGTH;
|
||||
const SERVER_STATUS_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Ensures the length of a packet as stated by a server is not longer than a
|
||||
/// hard-coded limit.
|
||||
///
|
||||
/// For example, if we ping a server that says its status packet is 2 billion
|
||||
/// bytes long, we don't try to allocate a 2 billion byte buffer, since that
|
||||
/// will OOM our machine.
|
||||
///
|
||||
/// Implemented as a function so that you can easily find callsites and see
|
||||
/// where we accept unvalidated input from servers.
|
||||
fn cap_length(
|
||||
length: usize,
|
||||
max_length: usize,
|
||||
context: &'static str,
|
||||
) -> Result<usize> {
|
||||
if length > max_length {
|
||||
return Err(ErrorKind::InputError(context.to_string()).into());
|
||||
}
|
||||
|
||||
Ok(length)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerStatus {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<Box<RawValue>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub players: Option<ServerPlayers>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<ServerVersion>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub favicon: Option<Url>,
|
||||
#[serde(default)]
|
||||
pub enforces_secure_chat: bool,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ping: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct ServerPlayers {
|
||||
pub max: i32,
|
||||
pub online: i32,
|
||||
#[serde(default)]
|
||||
pub sample: Vec<ServerGameProfile>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct ServerGameProfile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct ServerVersion {
|
||||
pub name: String,
|
||||
pub protocol: i32,
|
||||
#[serde(skip_deserializing)]
|
||||
pub legacy: bool,
|
||||
}
|
||||
|
||||
pub async fn get_server_status(
|
||||
address: &impl ToSocketAddrs,
|
||||
original_address: (&str, u16),
|
||||
protocol_version: Option<ProtocolVersion>,
|
||||
) -> Result<ServerStatus> {
|
||||
select! {
|
||||
res = async {
|
||||
match protocol_version {
|
||||
Some(ProtocolVersion { legacy: true, version }) => legacy::status(address, original_address, Some(version as u8)).await,
|
||||
protocol => modern::status(address, original_address, protocol.map(|v| v.version)).await,
|
||||
}
|
||||
} => res,
|
||||
_ = tokio::time::sleep(SERVER_STATUS_TIMEOUT) => Err(ErrorKind::OtherError(
|
||||
format!("Ping of {}:{} timed out", original_address.0, original_address.1)
|
||||
).into())
|
||||
}
|
||||
}
|
||||
|
||||
mod modern {
|
||||
use super::ServerStatus;
|
||||
use crate::ErrorKind;
|
||||
use std::time::Instant;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpStream, ToSocketAddrs};
|
||||
|
||||
pub async fn status(
|
||||
address: &impl ToSocketAddrs,
|
||||
original_address: (&str, u16),
|
||||
protocol_version: Option<u32>,
|
||||
) -> crate::Result<ServerStatus> {
|
||||
let mut stream = TcpStream::connect(address).await?;
|
||||
stream.set_nodelay(true)?;
|
||||
handshake(&mut stream, original_address, protocol_version).await?;
|
||||
let mut result = status_body(&mut stream).await?;
|
||||
result.ping = ping(&mut stream).await.ok();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn handshake(
|
||||
stream: &mut TcpStream,
|
||||
original_address: (&str, u16),
|
||||
protocol_version: Option<u32>,
|
||||
) -> crate::Result<()> {
|
||||
let (host, port) = original_address;
|
||||
let protocol_version = protocol_version.map_or(-1, |x| x as i32);
|
||||
|
||||
const PACKET_ID: i32 = 0;
|
||||
const NEXT_STATE: i32 = 1;
|
||||
|
||||
let packet_size = varint::get_byte_size(PACKET_ID)
|
||||
+ varint::get_byte_size(protocol_version)
|
||||
+ varint::get_byte_size(host.len() as i32)
|
||||
+ host.len()
|
||||
+ size_of::<u16>()
|
||||
+ varint::get_byte_size(NEXT_STATE);
|
||||
|
||||
let mut packet_buffer = Vec::with_capacity(
|
||||
varint::get_byte_size(packet_size as i32) + packet_size,
|
||||
);
|
||||
|
||||
varint::write(&mut packet_buffer, packet_size as i32);
|
||||
varint::write(&mut packet_buffer, PACKET_ID);
|
||||
varint::write(&mut packet_buffer, protocol_version);
|
||||
varint::write(&mut packet_buffer, host.len() as i32);
|
||||
packet_buffer.extend_from_slice(host.as_bytes());
|
||||
packet_buffer.extend_from_slice(&port.to_be_bytes());
|
||||
varint::write(&mut packet_buffer, NEXT_STATE);
|
||||
|
||||
stream.write_all(&packet_buffer).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn status_body(
|
||||
stream: &mut TcpStream,
|
||||
) -> crate::Result<ServerStatus> {
|
||||
stream.write_all(&[0x01, 0x00]).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
let packet_length = cap_varint_length(
|
||||
varint::read(stream).await?,
|
||||
super::MAX_MODERN_STATUS_PACKET_LENGTH,
|
||||
"invalid status response packet length",
|
||||
)?;
|
||||
|
||||
let mut packet_stream = stream.take(packet_length as u64);
|
||||
let packet_id = varint::read(&mut packet_stream).await?;
|
||||
if packet_id != 0x00 {
|
||||
return Err(ErrorKind::InputError(
|
||||
"Unexpected status response".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let response_length = cap_varint_length(
|
||||
varint::read(&mut packet_stream).await?,
|
||||
super::MAX_MINECRAFT_STATUS_STRING_LENGTH,
|
||||
"invalid status response length",
|
||||
)?;
|
||||
let mut json_response = vec![0_u8; response_length];
|
||||
packet_stream.read_exact(&mut json_response).await?;
|
||||
|
||||
if packet_stream.limit() > 0 {
|
||||
tokio::io::copy(&mut packet_stream, &mut tokio::io::sink()).await?;
|
||||
}
|
||||
|
||||
Ok(serde_json::from_slice(&json_response)?)
|
||||
}
|
||||
|
||||
/// Ensures the length of a varint as stated by a server is not longer than a
|
||||
/// hard-coded limit.
|
||||
///
|
||||
/// For example, if we ping a server that says its status packet is 2 billion
|
||||
/// bytes long, we don't try to allocate a 2 billion byte buffer, since that
|
||||
/// will OOM our machine.
|
||||
///
|
||||
/// Implemented as a function so that you can easily find callsites and see
|
||||
/// where we accept unvalidated input from servers.
|
||||
fn cap_varint_length(
|
||||
length: i32,
|
||||
max_length: usize,
|
||||
context: &'static str,
|
||||
) -> crate::Result<usize> {
|
||||
if length < 0 {
|
||||
return Err(ErrorKind::InputError(context.to_string()).into());
|
||||
}
|
||||
|
||||
super::cap_length(length as usize, max_length, context)
|
||||
}
|
||||
|
||||
async fn ping(stream: &mut TcpStream) -> crate::Result<i64> {
|
||||
let ping_magic = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
let start_time = Instant::now();
|
||||
stream.write_all(&[0x09, 0x01]).await?;
|
||||
stream.write_i64(ping_magic).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
let mut response_prefix = [0_u8; 2];
|
||||
stream.read_exact(&mut response_prefix).await?;
|
||||
let response_magic = stream.read_i64().await?;
|
||||
if response_prefix != [0x09, 0x01] || response_magic != ping_magic {
|
||||
return Err(ErrorKind::InputError(
|
||||
"Unexpected ping response".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(start_time.elapsed().as_millis() as i64)
|
||||
}
|
||||
|
||||
mod varint {
|
||||
use std::io;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt};
|
||||
|
||||
const MAX_VARINT_SIZE: usize = 5;
|
||||
const DATA_BITS_MASK: u32 = 0x7f;
|
||||
const CONT_BIT_MASK_U8: u8 = 0x80;
|
||||
const CONT_BIT_MASK_U32: u32 = CONT_BIT_MASK_U8 as u32;
|
||||
const DATA_BITS_PER_BYTE: usize = 7;
|
||||
|
||||
pub fn get_byte_size(x: i32) -> usize {
|
||||
let x = x as u32;
|
||||
for size in 1..MAX_VARINT_SIZE {
|
||||
if (x & (u32::MAX << (size * DATA_BITS_PER_BYTE))) == 0 {
|
||||
return size;
|
||||
}
|
||||
}
|
||||
MAX_VARINT_SIZE
|
||||
}
|
||||
|
||||
pub fn write(out: &mut Vec<u8>, value: i32) {
|
||||
let mut value = value as u32;
|
||||
while value >= CONT_BIT_MASK_U32 {
|
||||
out.push(((value & DATA_BITS_MASK) | CONT_BIT_MASK_U32) as u8);
|
||||
value >>= DATA_BITS_PER_BYTE;
|
||||
}
|
||||
out.push(value as u8);
|
||||
}
|
||||
|
||||
pub async fn read<R: AsyncRead + Unpin>(
|
||||
reader: &mut R,
|
||||
) -> io::Result<i32> {
|
||||
let mut result = 0;
|
||||
let mut shift = 0;
|
||||
|
||||
loop {
|
||||
let b = reader.read_u8().await?;
|
||||
result |=
|
||||
(b as u32 & DATA_BITS_MASK) << (shift * DATA_BITS_PER_BYTE);
|
||||
shift += 1;
|
||||
if shift > MAX_VARINT_SIZE {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"VarInt too big",
|
||||
));
|
||||
}
|
||||
if b & CONT_BIT_MASK_U8 == 0 {
|
||||
return Ok(result as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod legacy {
|
||||
use super::ServerStatus;
|
||||
use crate::worlds::{ServerPlayers, ServerVersion};
|
||||
use crate::{Error, ErrorKind};
|
||||
use serde_json::value::to_raw_value;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpStream, ToSocketAddrs};
|
||||
|
||||
pub async fn status(
|
||||
address: &impl ToSocketAddrs,
|
||||
original_address: (&str, u16),
|
||||
protocol_version: Option<u8>,
|
||||
) -> crate::Result<ServerStatus> {
|
||||
let protocol_version = protocol_version.unwrap_or(74);
|
||||
|
||||
let mut packet = vec![0xfe];
|
||||
if protocol_version >= 47 {
|
||||
packet.push(0x01);
|
||||
}
|
||||
if protocol_version >= 73 {
|
||||
packet.push(0xfa);
|
||||
write_legacy(&mut packet, "MC|PingHost");
|
||||
|
||||
let (host, port) = original_address;
|
||||
let len_index = packet.len();
|
||||
packet.push(protocol_version);
|
||||
write_legacy(&mut packet, host);
|
||||
packet.extend_from_slice(&(port as u32).to_be_bytes());
|
||||
packet.splice(
|
||||
len_index..len_index,
|
||||
((packet.len() - len_index) as u16).to_be_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut stream = TcpStream::connect(address).await?;
|
||||
stream.write_all(&packet).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
let packet_id = stream.read_u8().await?;
|
||||
if packet_id != 0xff {
|
||||
return Err(Error::from(ErrorKind::InputError(
|
||||
"Unexpected legacy status response".to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
let data_length = super::cap_length(
|
||||
stream.read_u16().await? as usize,
|
||||
super::MAX_LEGACY_STATUS_UTF16_LENGTH,
|
||||
"invalid legacy status response length",
|
||||
)?;
|
||||
let data_byte_length = data_length.checked_mul(2).ok_or_else(|| {
|
||||
ErrorKind::InputError(
|
||||
"invalid legacy status response length".to_string(),
|
||||
)
|
||||
})?;
|
||||
let mut data = vec![0u8; data_byte_length];
|
||||
stream.read_exact(&mut data).await?;
|
||||
|
||||
drop(stream);
|
||||
|
||||
let data = String::from_utf16_lossy(
|
||||
&data
|
||||
.chunks_exact(2)
|
||||
.map(|a| u16::from_be_bytes([a[0], a[1]]))
|
||||
.collect::<Vec<u16>>(),
|
||||
);
|
||||
let mut ancient_server = false;
|
||||
let mut parts = data.split('\0');
|
||||
if parts.next() != Some("§1") {
|
||||
ancient_server = true;
|
||||
parts = data.split('§');
|
||||
}
|
||||
|
||||
Ok(ServerStatus {
|
||||
version: (!ancient_server).then(|| ServerVersion {
|
||||
protocol: parts
|
||||
.next()
|
||||
.and_then(|x| x.parse().ok())
|
||||
.unwrap_or(0),
|
||||
name: parts.next().unwrap_or("").to_owned(),
|
||||
legacy: true,
|
||||
}),
|
||||
description: parts.next().and_then(|x| to_raw_value(x).ok()),
|
||||
players: Some(ServerPlayers {
|
||||
online: parts.next().and_then(|x| x.parse().ok()).unwrap_or(-1),
|
||||
max: parts.next().and_then(|x| x.parse().ok()).unwrap_or(-1),
|
||||
sample: vec![],
|
||||
}),
|
||||
favicon: None,
|
||||
enforces_secure_chat: false,
|
||||
ping: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn write_legacy(out: &mut Vec<u8>, text: &str) {
|
||||
let encoded = text.encode_utf16().collect::<Vec<_>>();
|
||||
out.extend_from_slice(&(encoded.len() as u16).to_be_bytes());
|
||||
out.extend(encoded.into_iter().flat_map(u16::to_be_bytes));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Coalesce concurrent downloads for the same destination and integrity.
|
||||
//!
|
||||
//! This module is intentionally independent from the downloader engines. It
|
||||
//! only coordinates the operation supplied by the caller and shares a
|
||||
//! verified successful result with followers.
|
||||
|
||||
use super::fetch::{DownloadResult, Integrity};
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, LazyLock, Weak};
|
||||
use tokio::sync::watch;
|
||||
|
||||
struct InFlight {
|
||||
/// Outer `None` means the flight is still pending; `Some(None)` is a
|
||||
/// terminal failure or cancellation; `Some(Some(result))` is the shared
|
||||
/// successful result. A watch channel retains the latest state, so a
|
||||
/// late-arriving follower can read it without waiting for a notification.
|
||||
state: watch::Sender<Option<Option<DownloadResult>>>,
|
||||
}
|
||||
|
||||
static FLIGHTS: LazyLock<dashmap::DashMap<String, Weak<InFlight>>> =
|
||||
LazyLock::new(dashmap::DashMap::new);
|
||||
|
||||
fn key(destination: &Path, integrity: &Integrity) -> String {
|
||||
let destination = if cfg!(windows) {
|
||||
destination.display().to_string().to_uppercase()
|
||||
} else {
|
||||
destination.display().to_string()
|
||||
};
|
||||
format!(
|
||||
"{destination}\0size={:?}\0sha1={:?}\0sha512={:?}\0sha256={:?}\0md5={:?}\0content={:?}",
|
||||
integrity.size,
|
||||
integrity.sha1,
|
||||
integrity.sha512,
|
||||
integrity.sha256,
|
||||
integrity.md5,
|
||||
integrity.content,
|
||||
)
|
||||
}
|
||||
|
||||
/// Publishes the flight's terminal state when the leader future is dropped,
|
||||
/// so waiters observe failure or cancellation instead of waiting for a
|
||||
/// notification that will never fire again.
|
||||
struct LeaderGuard {
|
||||
state: watch::Sender<Option<Option<DownloadResult>>>,
|
||||
published: bool,
|
||||
}
|
||||
|
||||
impl LeaderGuard {
|
||||
fn new(state: watch::Sender<Option<Option<DownloadResult>>>) -> Self {
|
||||
Self {
|
||||
state,
|
||||
published: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the flight as finished. `None` is a terminal failure; `Some`
|
||||
/// carries the verified result shared with followers.
|
||||
fn publish(mut self, outcome: Option<DownloadResult>) {
|
||||
self.published = true;
|
||||
let _ = self.state.send(Some(outcome));
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LeaderGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.published {
|
||||
// The leader future was dropped before publishing a terminal
|
||||
// state (cancellation, panic, or the caller abandoning the
|
||||
// flight). Release every waiter with a terminal failure so it can
|
||||
// retry its own operation instead of hanging.
|
||||
let _ = self.state.send(Some(None));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run an operation as a single flight when an integrity contract exists.
|
||||
/// Followers receive the leader's verified result and never re-hash the file.
|
||||
/// Failed flights are not cached: followers retry their own operation after
|
||||
/// the leader publishes the failure, and a canceled leader releases waiters
|
||||
/// with a terminal failure instead of leaving them pending.
|
||||
pub(crate) async fn run<F, Fut>(
|
||||
destination: &Path,
|
||||
integrity: &Integrity,
|
||||
operation: F,
|
||||
) -> crate::Result<DownloadResult>
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = crate::Result<DownloadResult>>,
|
||||
{
|
||||
if integrity.is_empty() {
|
||||
return operation().await;
|
||||
}
|
||||
|
||||
use dashmap::mapref::entry::Entry;
|
||||
let flight_key = key(destination, integrity);
|
||||
let (flight, leader) = match FLIGHTS.entry(flight_key) {
|
||||
Entry::Occupied(mut entry) => match entry.get().upgrade() {
|
||||
Some(flight) => (flight, false),
|
||||
None => {
|
||||
let flight = Arc::new(InFlight {
|
||||
state: watch::channel(None).0,
|
||||
});
|
||||
entry.insert(Arc::downgrade(&flight));
|
||||
(flight, true)
|
||||
}
|
||||
},
|
||||
Entry::Vacant(entry) => {
|
||||
let flight = Arc::new(InFlight {
|
||||
state: watch::channel(None).0,
|
||||
});
|
||||
entry.insert(Arc::downgrade(&flight));
|
||||
(flight, true)
|
||||
}
|
||||
};
|
||||
|
||||
if leader {
|
||||
let guard = LeaderGuard::new(flight.state.clone());
|
||||
match operation().await {
|
||||
Ok(downloaded) => {
|
||||
guard.publish(Some(downloaded.clone()));
|
||||
Ok(downloaded)
|
||||
}
|
||||
Err(error) => {
|
||||
guard.publish(None);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut receiver = flight.state.subscribe();
|
||||
// Late arrivals read the retained terminal state directly; only a
|
||||
// flight that is still pending is worth waiting on. The watch guard
|
||||
// is dropped before any `await` so the waiter future stays `Send`.
|
||||
let state = receiver.borrow_and_update().clone();
|
||||
match state {
|
||||
Some(Some(result)) => return Ok(result),
|
||||
Some(None) => return operation().await,
|
||||
None => {}
|
||||
}
|
||||
loop {
|
||||
if receiver.changed().await.is_err() {
|
||||
// The flight entry was removed without a terminal state; run
|
||||
// the operation ourselves rather than hanging.
|
||||
return operation().await;
|
||||
}
|
||||
let state = receiver.borrow_and_update().clone();
|
||||
match state {
|
||||
Some(Some(result)) => return Ok(result),
|
||||
Some(None) => return operation().await,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_callers_share_successful_result() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("artifact.jar");
|
||||
let integrity = Integrity::sha1("deadbeef").with_size(7);
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let first_calls = Arc::clone(&calls);
|
||||
let first_path = path.clone();
|
||||
let first = run(&path, &integrity, move || async move {
|
||||
first_calls.fetch_add(1, Ordering::Relaxed);
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
Ok(DownloadResult {
|
||||
path: first_path,
|
||||
url: "https://example.invalid/artifact.jar".into(),
|
||||
source: super::super::fetch::DownloadRouteSource::Official,
|
||||
size: 7,
|
||||
attempts: 1,
|
||||
fallback_count: 0,
|
||||
})
|
||||
});
|
||||
let second_path = dir.path().join("artifact.jar");
|
||||
let second = run(&second_path, &integrity, || async {
|
||||
panic!("follower must not execute operation")
|
||||
});
|
||||
let (result, shared) = tokio::join!(first, second);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(shared.unwrap().size, 7);
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_leader_publishes_terminal_state() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("artifact.jar");
|
||||
let integrity = Integrity::sha1("deadbeef").with_size(7);
|
||||
let barrier = Arc::new(tokio::sync::Barrier::new(2));
|
||||
let leader_barrier = Arc::clone(&barrier);
|
||||
let leader_path = path.clone();
|
||||
let leader_integrity = integrity.clone();
|
||||
let leader = tokio::spawn(async move {
|
||||
let _ = run(&leader_path, &leader_integrity, move || async move {
|
||||
leader_barrier.wait().await;
|
||||
// Never resolves on its own; aborting the task drops the
|
||||
// future so the LeaderGuard must publish a terminal state.
|
||||
std::future::pending::<crate::Result<DownloadResult>>().await
|
||||
})
|
||||
.await;
|
||||
});
|
||||
barrier.wait().await;
|
||||
let retries = Arc::new(AtomicUsize::new(0));
|
||||
let follower_retries = Arc::clone(&retries);
|
||||
let follower_path = path.clone();
|
||||
let follower_integrity = integrity.clone();
|
||||
let follower = tokio::spawn(async move {
|
||||
run(&follower_path, &follower_integrity, move || async move {
|
||||
follower_retries.fetch_add(1, Ordering::Relaxed);
|
||||
Err(crate::ErrorKind::OtherError(
|
||||
"follower retried after the leader was canceled".into(),
|
||||
)
|
||||
.into())
|
||||
})
|
||||
.await
|
||||
});
|
||||
// Give the follower time to subscribe while the leader is pending.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
leader.abort();
|
||||
let result = tokio::time::timeout(Duration::from_secs(2), follower)
|
||||
.await
|
||||
.expect(
|
||||
"follower must observe the canceled leader's terminal state without hanging",
|
||||
)
|
||||
.expect("follower task must not panic");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(retries.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn late_follower_retries_failed_flight() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("artifact.jar");
|
||||
let integrity = Integrity::sha1("deadbeef").with_size(7);
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let first_calls = Arc::clone(&calls);
|
||||
let first_path = path.clone();
|
||||
let first = run(&first_path, &integrity, move || async move {
|
||||
first_calls.fetch_add(1, Ordering::Relaxed);
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
Err(crate::ErrorKind::OtherError("leader failed".into()).into())
|
||||
});
|
||||
let second_dest = path.clone();
|
||||
let second_result_path = path.clone();
|
||||
let second_integrity = integrity.clone();
|
||||
let second_calls = Arc::clone(&calls);
|
||||
let second = tokio::spawn(async move {
|
||||
// The follower subscribes while the failed flight is still
|
||||
// retained; it must observe the terminal failure and retry its
|
||||
// own operation instead of hanging.
|
||||
run(&second_dest, &second_integrity, move || async move {
|
||||
second_calls.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(DownloadResult {
|
||||
path: second_result_path,
|
||||
url: "https://example.invalid/artifact.jar".into(),
|
||||
source: super::super::fetch::DownloadRouteSource::Official,
|
||||
size: 7,
|
||||
attempts: 1,
|
||||
fallback_count: 0,
|
||||
})
|
||||
})
|
||||
.await
|
||||
});
|
||||
let (first_result, second_result) = tokio::join!(first, second);
|
||||
assert!(first_result.is_err());
|
||||
assert_eq!(
|
||||
second_result
|
||||
.expect("follower task must not panic")
|
||||
.expect("follower must retry and succeed")
|
||||
.size,
|
||||
7
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_integrity_contracts_use_different_keys() {
|
||||
let path = Path::new("artifact.jar");
|
||||
assert_ne!(
|
||||
key(path, &Integrity::sha1("a")),
|
||||
key(path, &Integrity::sha1("b"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SymlinkCapability {
|
||||
Supported,
|
||||
RequiresAdmin,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
static SYMLINK_CAPABILITY: OnceLock<SymlinkCapability> = OnceLock::new();
|
||||
|
||||
pub async fn check_symlink_capability() -> SymlinkCapability {
|
||||
if let Some(capability) = SYMLINK_CAPABILITY.get() {
|
||||
return *capability;
|
||||
}
|
||||
|
||||
let capability = check_symlink_capability_internal().await;
|
||||
let _ = SYMLINK_CAPABILITY.set(capability);
|
||||
capability
|
||||
}
|
||||
|
||||
async fn check_symlink_capability_internal() -> SymlinkCapability {
|
||||
let temp_dir = match tempfile::tempdir() {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
error!("failed to create temp dir — {e}");
|
||||
return SymlinkCapability::Unsupported;
|
||||
}
|
||||
};
|
||||
|
||||
let target_path = temp_dir.path().join("target");
|
||||
let link_path = temp_dir.path().join("link");
|
||||
|
||||
if let Err(e) = fs::create_dir(&target_path).await {
|
||||
error!("failed to create target dir — {e}");
|
||||
return SymlinkCapability::Unsupported;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let target = target_path.clone();
|
||||
let link = link_path.clone();
|
||||
let junction_result = tokio::task::spawn_blocking(move || {
|
||||
junction::create(&target, &link)
|
||||
})
|
||||
.await;
|
||||
|
||||
if matches!(junction_result, Ok(Ok(()))) {
|
||||
return SymlinkCapability::Supported;
|
||||
}
|
||||
if let Err(e) = junction_result {
|
||||
error!(error = %e, "junction::create task panicked");
|
||||
}
|
||||
|
||||
let target = target_path.clone();
|
||||
let link = link_path.clone();
|
||||
let symlink_result = tokio::task::spawn_blocking(move || {
|
||||
symlink_rs::symlink_dir(&target, &link)
|
||||
})
|
||||
.await;
|
||||
|
||||
match symlink_result {
|
||||
Ok(Ok(_)) => SymlinkCapability::Supported,
|
||||
Ok(Err(e)) => {
|
||||
let raw_os_error = e.raw_os_error();
|
||||
if e.kind() == std::io::ErrorKind::PermissionDenied
|
||||
|| raw_os_error == Some(1314)
|
||||
{
|
||||
error!("symlink: permission denied — {e}");
|
||||
SymlinkCapability::RequiresAdmin
|
||||
} else {
|
||||
error!("symlink: system does not support it — {e}");
|
||||
SymlinkCapability::Unsupported
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("symlink: blocking task panicked — {e}");
|
||||
SymlinkCapability::Unsupported
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let target = target_path.clone();
|
||||
let link = link_path.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
symlink_rs::symlink_dir(&target, &link)
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(_)) => SymlinkCapability::Supported,
|
||||
Ok(Err(e)) => {
|
||||
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
error!("symlink: permission denied — {e}");
|
||||
SymlinkCapability::RequiresAdmin
|
||||
} else {
|
||||
error!("symlink: system does not support it — {e}");
|
||||
SymlinkCapability::Unsupported
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("symlink: blocking task panicked — {e}");
|
||||
SymlinkCapability::Unsupported
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ElevatedLinkRequest {
|
||||
target: PathBuf,
|
||||
link: PathBuf,
|
||||
result_file: PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ElevatedLinkResult {
|
||||
ok: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Create a directory or file link synchronously without elevation.
|
||||
/// Directory links prefer junctions, which do not require administrator
|
||||
/// privileges on Windows, and fall back to symbolic links.
|
||||
pub(crate) fn create_link_blocking(
|
||||
target: &Path,
|
||||
link: &Path,
|
||||
) -> std::io::Result<()> {
|
||||
if let Some(parent) = link.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
if !target.exists() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("Target path does not exist: {}", target.display()),
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if target.is_dir() {
|
||||
match junction::create(target, link) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(junction_error) => {
|
||||
tracing::debug!(
|
||||
"junction creation failed: {junction_error}; falling back to symlink"
|
||||
);
|
||||
symlink_rs::symlink_dir(target, link)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
symlink_rs::symlink_file(target, link)
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
symlink_rs::symlink_auto(target, link)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a link through a short-lived elevated helper process. The current
|
||||
/// launcher executable is re-launched with `--elevated-create-link` via UAC,
|
||||
/// performs the operation, and exits immediately afterwards.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) async fn create_link_elevated(
|
||||
target: &Path,
|
||||
link: &Path,
|
||||
) -> std::io::Result<()> {
|
||||
let target = target.to_path_buf();
|
||||
let link = link.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
create_link_elevated_blocking(&target, &link)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
std::io::Error::other(format!("elevated link task panicked: {error}"))
|
||||
})?
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn create_link_elevated_blocking(
|
||||
target: &Path,
|
||||
link: &Path,
|
||||
) -> std::io::Result<()> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Command;
|
||||
|
||||
let result_file = std::env::temp_dir()
|
||||
.join(format!("axolotl-link-result-{}.json", uuid::Uuid::new_v4()));
|
||||
let request = ElevatedLinkRequest {
|
||||
target: target.to_path_buf(),
|
||||
link: link.to_path_buf(),
|
||||
result_file: result_file.clone(),
|
||||
};
|
||||
let payload = base64::Engine::encode(
|
||||
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
|
||||
serde_json::to_vec(&request).map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
error.to_string(),
|
||||
)
|
||||
})?,
|
||||
);
|
||||
|
||||
let executable = std::env::current_exe().map_err(|error| {
|
||||
std::io::Error::other(format!(
|
||||
"Could not locate launcher executable: {error}"
|
||||
))
|
||||
})?;
|
||||
let escaped = executable.to_string_lossy().replace('\'', "''");
|
||||
let command = format!(
|
||||
"$process = Start-Process -FilePath '{escaped}' -ArgumentList '--elevated-create-link','{payload}' -Verb RunAs -WindowStyle Hidden -Wait -PassThru -ErrorAction Stop; if ($null -eq $process) {{ exit 1 }}; exit $process.ExitCode"
|
||||
);
|
||||
let status = Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", &command])
|
||||
.creation_flags(0x0800_0000)
|
||||
.status()
|
||||
.map_err(|error| {
|
||||
std::io::Error::other(format!(
|
||||
"Could not request administrator permission: {error}"
|
||||
))
|
||||
})?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"Administrator permission was denied",
|
||||
));
|
||||
}
|
||||
|
||||
let raw_result = std::fs::read_to_string(&result_file).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"Administrator permission was denied",
|
||||
)
|
||||
})?;
|
||||
let _ = std::fs::remove_file(&result_file);
|
||||
|
||||
let result: ElevatedLinkResult = serde_json::from_str(&raw_result)
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
error.to_string(),
|
||||
)
|
||||
})?;
|
||||
if result.ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::other(
|
||||
result
|
||||
.error
|
||||
.unwrap_or_else(|| "Link creation failed".to_string()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Entry point for the elevated link-creation helper process. Decodes the
|
||||
/// request, creates the link, writes the outcome to the result file, and
|
||||
/// returns an exit code. The launcher binary calls this before Tauri
|
||||
/// initializes when started with `--elevated-create-link`.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn create_link_elevated_helper(payload: &str) -> i32 {
|
||||
let request = base64::Engine::decode(
|
||||
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
|
||||
payload.trim(),
|
||||
)
|
||||
.ok()
|
||||
.and_then(|bytes| {
|
||||
serde_json::from_slice::<ElevatedLinkRequest>(&bytes).ok()
|
||||
});
|
||||
|
||||
let Some(request) = request else {
|
||||
tracing::error!("Elevated link creation: invalid request payload");
|
||||
return 1;
|
||||
};
|
||||
|
||||
let result = match create_link_blocking(&request.target, &request.link) {
|
||||
Ok(()) => ElevatedLinkResult {
|
||||
ok: true,
|
||||
error: None,
|
||||
},
|
||||
Err(link_error) => ElevatedLinkResult {
|
||||
ok: false,
|
||||
error: Some(link_error.to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
let serialized = match serde_json::to_vec(&result) {
|
||||
Ok(serialized) => serialized,
|
||||
Err(serialize_error) => {
|
||||
tracing::error!(
|
||||
"Elevated link creation: failed to serialize result: {serialize_error}"
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(write_error) = std::fs::write(&request.result_file, serialized) {
|
||||
tracing::error!(
|
||||
"Elevated link creation: failed to write result file: {write_error}"
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if result.ok { 0 } else { 1 }
|
||||
}
|
||||
Reference in new issue
Block a user