Files
Starlight_Lancher/packages/app-lib/src/state/mod.rs
T
AxTps ead5d96b78
Sync source to CNB / Sync Git ref (push) Has been cancelled
feat: 移植截图中心后端(索引/分组/编辑保存)
2026-10-01 21:08:57 +08:00

1204 lines
41 KiB
Rust

//! Theseus state management system
use crate::util::fetch::{FetchSemaphore, IoSemaphore};
use dashmap::DashMap;
use parking_lot::RwLock;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::{
AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering,
};
use std::time::{Duration, Instant};
use tokio::sync::Mutex as AsyncMutex;
use tokio::sync::{OnceCell, Semaphore};
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::state::instances::watcher::FileWatcher;
use sqlx::SqlitePool;
// Submodules
mod dirs;
pub use self::dirs::*;
mod instance_types;
pub use self::instance_types::*;
pub(crate) mod instances;
pub use self::instances::*;
mod settings;
pub use self::settings::*;
mod proxy_settings;
mod installer_settings;
mod process;
pub use self::process::*;
mod java_globals;
pub use self::java_globals::*;
mod discovered_javas;
pub use self::discovered_javas::*;
mod minecraft_auth;
pub use self::minecraft_auth::*;
pub mod minecraft_skins;
mod cache;
pub use self::cache::*;
pub mod content_favorites;
pub use self::content_favorites::*;
mod friends;
pub use self::friends::*;
mod tunnel;
pub use self::tunnel::*;
pub mod db;
pub(crate) mod db_backup;
mod mr_auth;
pub use self::mr_auth::*;
mod legacy_converter;
pub mod attached_world_data;
pub mod server_join_log;
// Global state
// RwLock on state only has concurrent reads, except for config dir change which takes control of the State
static LAUNCHER_STATE: OnceCell<Arc<State>> = OnceCell::const_new();
const MAX_CONCURRENT_INSTALL_JOBS: usize = 1;
const AUTO_DOWNLOAD_CONCURRENCY_INITIAL: usize = 64;
const AUTO_DOWNLOAD_CONCURRENCY_MIN: usize = 16;
const AUTO_DOWNLOAD_CONCURRENCY_MAX: usize = 128;
const AUTO_DOWNLOAD_CONCURRENCY_STEP: usize = 8;
/// Number of consecutive pressured sample windows required before the global
/// download concurrency backs off. Host-level throttling reacts immediately.
const AUTO_DOWNLOAD_PRESSURE_WINDOWS: usize = 2;
const AUTO_DOWNLOAD_SAMPLE_INTERVAL: Duration = Duration::from_secs(3);
const AUTO_DOWNLOAD_PROBE_COOLDOWN: Duration = Duration::from_secs(30);
pub struct State {
/// Information on the location of files used in the launcher
pub directories: DirectoryInfo,
/// Semaphore used to limit concurrent network requests and avoid errors
pub fetch_semaphore: FetchSemaphore,
/// Global capacity for file transfers. Metadata and API requests use their
/// own semaphores so they cannot delay an active installation.
pub download_semaphore: FetchSemaphore,
/// Semaphore used to limit concurrent I/O and avoid errors
pub io_semaphore: IoSemaphore,
/// Semaphore to limit concurrent API requests. This is separate from the fetch semaphore
/// to keep API functionality while the app is performing intensive tasks.
pub api_semaphore: FetchSemaphore,
minecraft_metadata_source: AtomicU8,
minecraft_file_source: AtomicU8,
modrinth_source: AtomicU8,
curseforge_source: AtomicU8,
bypass_curseforge_download_restrictions: AtomicBool,
mojang_auth_use_mirror: AtomicBool,
auto_prefers_mirror: AtomicBool,
download_concurrency_target: AtomicUsize,
download_concurrency_limit: AtomicUsize,
fetch_concurrency_limit: AtomicUsize,
api_concurrency_limit: AtomicUsize,
auto_concurrent_downloads: AtomicBool,
auto_concurrency_ceiling: AtomicUsize,
download_active_connections: AtomicUsize,
download_sample_bytes: AtomicU64,
download_sample_requests: AtomicU64,
download_sample_errors: AtomicU64,
download_sample_throttles: AtomicU64,
pub(crate) install_job_semaphore: Semaphore,
pub(crate) install_db_semaphore: Semaphore,
pub(crate) install_job_cancellations: DashMap<Uuid, CancellationToken>,
pub(crate) install_job_operation_locks:
DashMap<Uuid, Arc<AsyncMutex<InstallJobOperationState>>>,
pub(crate) screenshot_locks: DashMap<String, Arc<AsyncMutex<()>>>,
/// Process manager
pub process_manager: ProcessManager,
// NOTE: we explicitly must NOT store the app identifier in the state object,
// because creating the state object is fallible (e.g. database missing),
// but we rely on the app identifier to create the state (data dir).
//
// /// App identifier string (like com.modrinth.AxolotlLauncher)
// pub app_identifier: String,
/// Friends socket
pub friends_socket: FriendsSocket,
pub restart_after_pending_update: AtomicBool,
/// Per-instance locks serializing content writes against instance
/// deletion, so a delete can never commit between a command loading an
/// instance and writing rows that reference it.
pub(crate) instance_locks: Arc<InstanceLockManager>,
pub(crate) pool: SqlitePool,
// Cloning reqwest::Client retains its underlying connection pool.
configured_http_client: RwLock<reqwest::Client>,
configured_http_client_update: AsyncMutex<()>,
pub(crate) file_watcher: FileWatcher,
}
#[derive(Default)]
pub(crate) struct InstallJobOperationState {
pub(crate) cache_repair_started: bool,
}
pub(crate) struct DownloadConnectionActivity {
state: Arc<State>,
}
impl Drop for DownloadConnectionActivity {
fn drop(&mut self) {
self.state
.download_active_connections
.fetch_sub(1, Ordering::AcqRel);
}
}
#[derive(Debug)]
struct AutoConcurrencyProbe {
previous_target: usize,
baseline_throughput: u64,
windows: usize,
throughput: u64,
}
#[derive(Debug, Default)]
struct AutoConcurrencyController {
high_utilization_windows: usize,
pressure_windows: usize,
probe: Option<AutoConcurrencyProbe>,
cooldown_until: Option<Instant>,
}
#[derive(Clone, Copy, Debug)]
struct AutoConcurrencySample {
active: usize,
bytes: u64,
requests: u64,
errors: u64,
throttles: u64,
}
/// Per-instance lock registry with task-local reentrancy.
///
/// Instance deletion removes the `instances` row (and cascades through content
/// files, entries, provider refs and update checks), while content commands
/// load the instance first and write those rows later. Without serialization a
/// concurrent delete can commit between the load and the write, surfacing as a
/// raw `SQLITE_CONSTRAINT_FOREIGNKEY` (code 787) error. The lock is reentrant
/// for the task that already holds it, since commands compose (update →
/// check → sync → record), while concurrent tasks are serialized per instance.
#[derive(Default)]
pub(crate) struct InstanceLockManager {
locks: DashMap<String, Arc<AsyncMutex<()>>>,
held_by_owner: std::sync::Mutex<HashMap<LockOwner, HashSet<String>>>,
}
impl InstanceLockManager {
pub(crate) async fn lock(
self: &Arc<Self>,
instance_id: &str,
) -> InstanceLockGuard {
let lock = self
.locks
.entry(instance_id.to_string())
.or_insert_with(|| Arc::new(AsyncMutex::new(())))
.clone();
let owner = current_lock_owner();
if self
.held_by_owner
.lock()
.unwrap_or_else(|poison| poison.into_inner())
.get(&owner)
.is_some_and(|held| held.contains(instance_id))
{
return InstanceLockGuard {
manager: Arc::clone(self),
inner: None,
owner: Some(owner),
instance_id: instance_id.to_string(),
};
}
let inner = lock.lock_owned().await;
self.held_by_owner
.lock()
.unwrap_or_else(|poison| poison.into_inner())
.entry(owner)
.or_default()
.insert(instance_id.to_string());
InstanceLockGuard {
manager: Arc::clone(self),
inner: Some(inner),
owner: Some(owner),
instance_id: instance_id.to_string(),
}
}
pub(crate) async fn lock_exclusive(
self: &Arc<Self>,
instance_id: &str,
) -> InstanceLockGuard {
let lock = self
.locks
.entry(instance_id.to_string())
.or_insert_with(|| Arc::new(AsyncMutex::new(())))
.clone();
let inner = lock.lock_owned().await;
InstanceLockGuard {
manager: Arc::clone(self),
inner: Some(inner),
owner: None,
instance_id: instance_id.to_string(),
}
}
}
/// Identity of the async execution context holding an instance lock.
///
/// Task IDs distinguish concurrent tasks on a multi-threaded runtime; when no
/// task context exists (for example the main test future) the thread ID is used
/// so re-entrant calls within the same context are still recognized.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum LockOwner {
Task(tokio::task::Id),
Thread(std::thread::ThreadId),
}
fn current_lock_owner() -> LockOwner {
tokio::task::try_id()
.map(LockOwner::Task)
.unwrap_or_else(|| LockOwner::Thread(std::thread::current().id()))
}
/// RAII guard for an instance lock. Only the outermost holder releases the
/// underlying mutex and removes the owner from the reentrancy registry.
pub(crate) struct InstanceLockGuard {
manager: Arc<InstanceLockManager>,
inner: Option<tokio::sync::OwnedMutexGuard<()>>,
owner: Option<LockOwner>,
instance_id: String,
}
impl Drop for InstanceLockGuard {
fn drop(&mut self) {
if self.inner.is_some()
&& let Some(owner) = self.owner
{
let mut held = self
.manager
.held_by_owner
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let mut remove_owner = false;
if let Some(held_instances) = held.get_mut(&owner) {
held_instances.remove(&self.instance_id);
remove_owner = held_instances.is_empty();
}
if remove_owner {
held.remove(&owner);
}
}
}
}
fn grow_semaphore(
semaphore: &Semaphore,
current_limit: &AtomicUsize,
target: usize,
) {
let mut current = current_limit.load(Ordering::Acquire);
while current < target {
match current_limit.compare_exchange(
current,
target,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => {
semaphore.add_permits(target - current);
return;
}
Err(updated) => current = updated,
}
}
}
async fn shrink_semaphore(
semaphore: &Semaphore,
current_limit: &AtomicUsize,
target: &AtomicUsize,
) {
loop {
if current_limit.load(Ordering::Acquire)
<= target.load(Ordering::Acquire)
{
return;
}
let Ok(permit) = semaphore.acquire().await else {
return;
};
loop {
let current = current_limit.load(Ordering::Acquire);
if current <= target.load(Ordering::Acquire) {
drop(permit);
return;
}
if current_limit
.compare_exchange(
current,
current - 1,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
permit.forget();
break;
}
}
}
}
impl AutoConcurrencyController {
fn next_target(
&mut self,
now: Instant,
current: usize,
ceiling: usize,
sample: AutoConcurrencySample,
) -> usize {
let error_rate = if sample.requests == 0 {
0.0
} else {
sample.errors as f64 / sample.requests as f64
};
// A single throttled window is handled by the per-host limiter; the
// global concurrency only backs off when pressure persists across
// several samples, and then in small steps instead of a big cut.
let pressured = sample.throttles > 0 || error_rate >= 0.05;
if pressured {
self.high_utilization_windows = 0;
self.probe = None;
if self.cooldown_until.is_none_or(|until| until <= now) {
self.pressure_windows += 1;
if self.pressure_windows >= AUTO_DOWNLOAD_PRESSURE_WINDOWS {
self.cooldown_until =
Some(now + AUTO_DOWNLOAD_PROBE_COOLDOWN);
return current
.saturating_sub(AUTO_DOWNLOAD_CONCURRENCY_STEP)
.max(AUTO_DOWNLOAD_CONCURRENCY_MIN);
}
}
return current;
}
self.pressure_windows = 0;
if let Some(probe) = &mut self.probe {
probe.windows += 1;
probe.throughput = probe.throughput.saturating_add(sample.bytes);
if probe.windows >= 2 {
let average = probe.throughput / probe.windows as u64;
let productive = probe.baseline_throughput > 0
&& average.saturating_mul(100)
>= probe.baseline_throughput.saturating_mul(105);
let previous = probe.previous_target;
self.probe = None;
if !productive {
self.cooldown_until =
Some(now + AUTO_DOWNLOAD_PROBE_COOLDOWN);
return previous;
}
}
return current;
}
if self.cooldown_until.is_some_and(|until| until > now)
|| error_rate >= 0.02
|| sample.throttles > 0
{
self.high_utilization_windows = 0;
return current;
}
if sample.bytes > 0
&& sample.active.saturating_mul(10) >= current.saturating_mul(9)
{
self.high_utilization_windows += 1;
} else {
self.high_utilization_windows = 0;
}
if self.high_utilization_windows < 2 || current >= ceiling {
return current;
}
self.high_utilization_windows = 0;
let target = (current + AUTO_DOWNLOAD_CONCURRENCY_STEP).min(ceiling);
self.probe = Some(AutoConcurrencyProbe {
previous_target: current,
baseline_throughput: sample.bytes,
windows: 0,
throughput: 0,
});
target
}
}
impl State {
pub async fn init(app_identifier: String) -> crate::Result<()> {
let state = LAUNCHER_STATE
.get_or_try_init(move || Self::initialize_state(app_identifier))
.await?;
if let Err(e) =
crate::install::recovery::recover_interrupted_jobs(state).await
{
tracing::error!("Error recovering interrupted install jobs: {e}");
}
if let Err(e) =
crate::api::curseforge::reconcile_persisted_curseforge_waiting_jobs(
state,
)
.await
{
tracing::error!(
"Error reconciling persisted CurseForge waiting install jobs: {e}"
);
}
let config_sync_state = Arc::clone(state);
tokio::task::spawn(async move {
instances::config_sync::run(config_sync_state).await;
});
let concurrency_state = Arc::clone(state);
tokio::spawn(async move {
concurrency_state.run_auto_concurrency_controller().await;
});
tokio::task::spawn(async move {
crate::google_ip::preload().await;
});
tokio::task::spawn(async move {
crate::util::fetch::cleanup_stale_partial_downloads(vec![
state.directories.metadata_dir(),
state.directories.caches_dir(),
]);
instances::watcher::watch_instances_init(
&state.file_watcher,
&state.directories,
&state.pool,
)
.await;
let res = tokio::try_join!(
instances::refresh_all_instances(),
Settings::migrate(&state.pool),
ModrinthCredentials::refresh_all(),
);
if let Err(e) = res {
tracing::error!("Error refreshing startup state: {e}");
}
// Axolotl does not connect to Modrinth's private friends socket.
});
Ok(())
}
#[cfg(all(test, not(feature = "tauri")))]
pub(crate) async fn init_for_test(
app_identifier: String,
) -> crate::Result<Arc<Self>> {
LAUNCHER_STATE
.get_or_try_init(move || Self::initialize_state(app_identifier))
.await
.cloned()
}
/// Get the current launcher state, waiting for initialization
pub async fn get() -> crate::Result<Arc<Self>> {
if !LAUNCHER_STATE.initialized() {
tracing::error!(
"Attempted to get state before it is initialized - this should never happen!"
);
while !LAUNCHER_STATE.initialized() {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
Ok(Arc::clone(
LAUNCHER_STATE.get().expect("State is not initialized!"),
))
}
pub fn initialized() -> bool {
LAUNCHER_STATE.initialized()
}
pub(crate) fn minecraft_metadata_source(&self) -> DownloadSourceMode {
DownloadSourceMode::from_u8(
self.minecraft_metadata_source.load(Ordering::Relaxed),
)
}
pub(crate) fn minecraft_file_source(&self) -> DownloadSourceMode {
DownloadSourceMode::from_u8(
self.minecraft_file_source.load(Ordering::Relaxed),
)
}
pub(crate) fn modrinth_source(&self) -> DownloadSourceMode {
DownloadSourceMode::from_u8(
self.modrinth_source.load(Ordering::Relaxed),
)
}
pub(crate) fn curseforge_source(&self) -> DownloadSourceMode {
DownloadSourceMode::from_u8(
self.curseforge_source.load(Ordering::Relaxed),
)
}
pub(crate) fn bypass_curseforge_download_restrictions(&self) -> bool {
self.bypass_curseforge_download_restrictions
.load(Ordering::Relaxed)
}
pub fn mojang_auth_use_mirror(&self) -> bool {
self.mojang_auth_use_mirror.load(Ordering::Relaxed)
}
pub fn set_mojang_auth_use_mirror(&self, use_mirror: bool) {
self.mojang_auth_use_mirror
.store(use_mirror, Ordering::Relaxed);
}
pub async fn proxy_config(
&self,
) -> crate::Result<crate::util::proxy::ProxyConfig> {
crate::state::proxy_settings::get(&self.pool).await
}
pub async fn update_proxy_config(
&self,
config: &crate::util::proxy::ProxyConfig,
) -> crate::Result<()> {
let _update = self.configured_http_client_update.lock().await;
let client = crate::util::fetch::build_configured_client(config)?;
crate::state::proxy_settings::set(&self.pool, config).await?;
*self.configured_http_client.write() = client;
Ok(())
}
pub(crate) fn configured_http_client(&self) -> reqwest::Client {
self.configured_http_client.read().clone()
}
pub(crate) fn auto_prefers_mirror(&self) -> bool {
self.auto_prefers_mirror.load(Ordering::Relaxed)
}
pub(crate) fn download_concurrency(&self) -> usize {
self.download_concurrency_target.load(Ordering::Acquire)
}
pub(crate) fn begin_download_connection(
self: &Arc<Self>,
) -> DownloadConnectionActivity {
self.download_active_connections
.fetch_add(1, Ordering::AcqRel);
self.download_sample_requests.fetch_add(1, Ordering::AcqRel);
DownloadConnectionActivity {
state: Arc::clone(self),
}
}
pub(crate) fn record_download_bytes(&self, bytes: u64) {
self.download_sample_bytes
.fetch_add(bytes, Ordering::AcqRel);
}
pub(crate) fn record_download_error(&self) {
self.download_sample_errors.fetch_add(1, Ordering::AcqRel);
}
pub(crate) fn update_download_settings(
self: &Arc<Self>,
settings: &Settings,
) {
self.minecraft_metadata_source
.store(settings.minecraft_metadata_source as u8, Ordering::Relaxed);
self.minecraft_file_source
.store(settings.minecraft_file_source as u8, Ordering::Relaxed);
self.modrinth_source
.store(settings.modrinth_source as u8, Ordering::Relaxed);
self.curseforge_source
.store(settings.curseforge_source as u8, Ordering::Relaxed);
self.bypass_curseforge_download_restrictions.store(
settings.bypass_curseforge_download_restrictions,
Ordering::Relaxed,
);
match settings.mojang_auth_source {
DownloadSourceMode::MirrorPreferred => {
self.mojang_auth_use_mirror.store(true, Ordering::Relaxed);
}
DownloadSourceMode::OfficialOnly => {
self.mojang_auth_use_mirror.store(false, Ordering::Relaxed);
}
DownloadSourceMode::Auto
| DownloadSourceMode::OfficialPreferred => {}
}
self.auto_prefers_mirror
.store(settings.auto_prefers_mirror(), Ordering::Relaxed);
let was_auto = self
.auto_concurrent_downloads
.swap(settings.auto_concurrent_downloads, Ordering::AcqRel);
self.auto_concurrency_ceiling
.store(AUTO_DOWNLOAD_CONCURRENCY_MAX, Ordering::Release);
if settings.auto_concurrent_downloads {
if !was_auto {
self.resize_download_concurrency(
AUTO_DOWNLOAD_CONCURRENCY_INITIAL,
);
}
} else {
self.resize_download_concurrency(
settings.effective_max_concurrent_downloads(),
);
}
}
async fn run_auto_concurrency_controller(self: Arc<Self>) {
let mut interval = tokio::time::interval(AUTO_DOWNLOAD_SAMPLE_INTERVAL);
interval.tick().await;
let mut controller = AutoConcurrencyController::default();
loop {
interval.tick().await;
if !self.auto_concurrent_downloads.load(Ordering::Acquire) {
controller = AutoConcurrencyController::default();
self.download_sample_bytes.swap(0, Ordering::AcqRel);
self.download_sample_requests.swap(0, Ordering::AcqRel);
self.download_sample_errors.swap(0, Ordering::AcqRel);
self.download_sample_throttles.swap(0, Ordering::AcqRel);
continue;
}
let sample = AutoConcurrencySample {
active: self
.download_active_connections
.load(Ordering::Acquire),
bytes: self.download_sample_bytes.swap(0, Ordering::AcqRel),
requests: self
.download_sample_requests
.swap(0, Ordering::AcqRel),
errors: self.download_sample_errors.swap(0, Ordering::AcqRel),
throttles: self
.download_sample_throttles
.swap(0, Ordering::AcqRel),
};
let current = self.download_concurrency();
let ceiling =
self.auto_concurrency_ceiling.load(Ordering::Acquire).clamp(
AUTO_DOWNLOAD_CONCURRENCY_MIN,
AUTO_DOWNLOAD_CONCURRENCY_MAX,
);
let target = controller.next_target(
Instant::now(),
current,
ceiling,
sample,
);
if target != current {
tracing::info!(
current,
target,
active = sample.active,
bytes = sample.bytes,
requests = sample.requests,
errors = sample.errors,
throttles = sample.throttles,
"Adjusted automatic download concurrency"
);
self.resize_download_concurrency(target);
}
}
}
fn resize_download_concurrency(self: &Arc<Self>, target: usize) {
let target = target.clamp(1, 256);
self.download_concurrency_target
.store(target, Ordering::Release);
grow_semaphore(
&self.fetch_semaphore.0,
&self.fetch_concurrency_limit,
target,
);
grow_semaphore(
&self.download_semaphore.0,
&self.download_concurrency_limit,
target,
);
grow_semaphore(
&self.api_semaphore.0,
&self.api_concurrency_limit,
target,
);
if self.fetch_concurrency_limit.load(Ordering::Acquire) > target {
let state = Arc::clone(self);
tokio::spawn(async move {
shrink_semaphore(
&state.fetch_semaphore.0,
&state.fetch_concurrency_limit,
&state.download_concurrency_target,
)
.await;
});
}
if self.download_concurrency_limit.load(Ordering::Acquire) > target {
let state = Arc::clone(self);
tokio::spawn(async move {
shrink_semaphore(
&state.download_semaphore.0,
&state.download_concurrency_limit,
&state.download_concurrency_target,
)
.await;
});
}
if self.api_concurrency_limit.load(Ordering::Acquire) > target {
let state = Arc::clone(self);
tokio::spawn(async move {
shrink_semaphore(
&state.api_semaphore.0,
&state.api_concurrency_limit,
&state.download_concurrency_target,
)
.await;
});
}
}
pub fn get_if_initialized() -> Option<Arc<Self>> {
LAUNCHER_STATE.get().map(Arc::clone)
}
#[tracing::instrument]
async fn initialize_state(
app_identifier: String,
) -> crate::Result<Arc<Self>> {
tracing::info!("Connecting to app database");
let pool = db::connect(&app_identifier).await?;
legacy_converter::migrate_legacy_data(&pool).await?;
tracing::info!("Fetching app settings");
let mut settings = Settings::get(&pool).await?;
installer_settings::apply_pending_installer_directory(
&mut settings,
&pool,
&app_identifier,
)
.await?;
let download_concurrency =
settings.effective_max_concurrent_downloads();
let fetch_semaphore =
FetchSemaphore(Semaphore::new(download_concurrency));
let download_semaphore =
FetchSemaphore(Semaphore::new(download_concurrency));
let io_semaphore =
IoSemaphore(Semaphore::new(settings.max_concurrent_writes));
let api_semaphore =
FetchSemaphore(Semaphore::new(download_concurrency));
let auto_prefers_mirror = settings.auto_prefers_mirror();
let proxy_config = proxy_settings::get(&pool).await?;
let configured_http_client =
crate::util::fetch::build_configured_client(&proxy_config)?;
tracing::info!("Initializing directories");
DirectoryInfo::move_launcher_directory(
&mut settings,
&pool,
&io_semaphore,
&app_identifier,
)
.await?;
let directories =
DirectoryInfo::init(settings.custom_dir, &app_identifier).await?;
tracing::info!("Initializing file watcher");
let file_watcher = instances::watcher::init_watcher().await?;
let process_manager = ProcessManager::new();
let friends_socket = FriendsSocket::new();
Ok(Arc::new(Self {
directories,
fetch_semaphore,
download_semaphore,
io_semaphore,
api_semaphore,
minecraft_metadata_source: AtomicU8::new(
settings.minecraft_metadata_source as u8,
),
minecraft_file_source: AtomicU8::new(
settings.minecraft_file_source as u8,
),
modrinth_source: AtomicU8::new(settings.modrinth_source as u8),
curseforge_source: AtomicU8::new(settings.curseforge_source as u8),
bypass_curseforge_download_restrictions: AtomicBool::new(
settings.bypass_curseforge_download_restrictions,
),
mojang_auth_use_mirror: AtomicBool::new(
match settings.mojang_auth_source {
DownloadSourceMode::MirrorPreferred => true,
DownloadSourceMode::Auto => auto_prefers_mirror,
DownloadSourceMode::OfficialOnly
| DownloadSourceMode::OfficialPreferred => false,
},
),
auto_prefers_mirror: AtomicBool::new(auto_prefers_mirror),
download_concurrency_target: AtomicUsize::new(download_concurrency),
download_concurrency_limit: AtomicUsize::new(download_concurrency),
fetch_concurrency_limit: AtomicUsize::new(download_concurrency),
api_concurrency_limit: AtomicUsize::new(download_concurrency),
auto_concurrent_downloads: AtomicBool::new(
settings.auto_concurrent_downloads,
),
auto_concurrency_ceiling: AtomicUsize::new(
AUTO_DOWNLOAD_CONCURRENCY_MAX,
),
download_active_connections: AtomicUsize::new(0),
download_sample_bytes: AtomicU64::new(0),
download_sample_requests: AtomicU64::new(0),
download_sample_errors: AtomicU64::new(0),
download_sample_throttles: AtomicU64::new(0),
install_job_semaphore: Semaphore::new(MAX_CONCURRENT_INSTALL_JOBS),
install_db_semaphore: Semaphore::new(1),
install_job_cancellations: DashMap::new(),
install_job_operation_locks: DashMap::new(),
screenshot_locks: DashMap::new(),
process_manager,
friends_socket,
restart_after_pending_update: AtomicBool::new(false),
instance_locks: Arc::new(InstanceLockManager::default()),
pool,
configured_http_client: RwLock::new(configured_http_client),
configured_http_client_update: AsyncMutex::new(()),
file_watcher,
// app_identifier,
}))
}
/// Acquire the lock serializing content writes and instance deletion for
/// the given instance. Reentrant within the task that already holds it.
pub(crate) async fn lock_instance_content(
&self,
instance_id: &str,
) -> InstanceLockGuard {
self.instance_locks.lock(instance_id).await
}
pub(crate) async fn lock_instance_content_exclusive(
&self,
instance_id: &str,
) -> InstanceLockGuard {
self.instance_locks.lock_exclusive(instance_id).await
}
pub(crate) async fn lock_instance_screenshots(
&self,
instance_id: &str,
) -> tokio::sync::OwnedMutexGuard<()> {
self.screenshot_locks
.entry(instance_id.to_string())
.or_insert_with(|| Arc::new(AsyncMutex::new(())))
.clone()
.lock_owned()
.await
}
}
#[cfg(test)]
pub(crate) async fn test_state(
directories: DirectoryInfo,
pool: SqlitePool,
) -> crate::Result<Arc<State>> {
let file_watcher = instances::watcher::init_watcher().await?;
let proxy_config = proxy_settings::get(&pool).await?;
let configured_http_client =
crate::util::fetch::build_configured_client(&proxy_config)?;
Ok(Arc::new(State {
directories,
fetch_semaphore: FetchSemaphore(Semaphore::new(8)),
download_semaphore: FetchSemaphore(Semaphore::new(8)),
io_semaphore: IoSemaphore(Semaphore::new(8)),
api_semaphore: FetchSemaphore(Semaphore::new(8)),
minecraft_metadata_source: AtomicU8::new(0),
minecraft_file_source: AtomicU8::new(0),
modrinth_source: AtomicU8::new(0),
curseforge_source: AtomicU8::new(0),
bypass_curseforge_download_restrictions: AtomicBool::new(true),
mojang_auth_use_mirror: AtomicBool::new(false),
auto_prefers_mirror: AtomicBool::new(false),
download_concurrency_target: AtomicUsize::new(8),
download_concurrency_limit: AtomicUsize::new(8),
fetch_concurrency_limit: AtomicUsize::new(8),
api_concurrency_limit: AtomicUsize::new(8),
auto_concurrent_downloads: AtomicBool::new(false),
auto_concurrency_ceiling: AtomicUsize::new(8),
download_active_connections: AtomicUsize::new(0),
download_sample_bytes: AtomicU64::new(0),
download_sample_requests: AtomicU64::new(0),
download_sample_errors: AtomicU64::new(0),
download_sample_throttles: AtomicU64::new(0),
install_job_semaphore: Semaphore::new(1),
install_db_semaphore: Semaphore::new(1),
install_job_cancellations: DashMap::new(),
install_job_operation_locks: DashMap::new(),
screenshot_locks: DashMap::new(),
process_manager: ProcessManager::new(),
friends_socket: FriendsSocket::new(),
restart_after_pending_update: AtomicBool::new(false),
instance_locks: Arc::new(InstanceLockManager::default()),
pool,
configured_http_client: RwLock::new(configured_http_client),
configured_http_client_update: AsyncMutex::new(()),
file_watcher,
}))
}
#[cfg(test)]
mod auto_concurrency_tests {
use super::*;
fn healthy(active: usize, bytes: u64) -> AutoConcurrencySample {
AutoConcurrencySample {
active,
bytes,
requests: 100,
errors: 0,
throttles: 0,
}
}
#[test]
fn probes_up_after_two_saturated_windows_and_respects_ceiling() {
let mut controller = AutoConcurrencyController::default();
let now = Instant::now();
assert_eq!(controller.next_target(now, 64, 128, healthy(64, 100)), 64);
assert_eq!(controller.next_target(now, 64, 128, healthy(64, 100)), 72);
let mut capped = AutoConcurrencyController::default();
assert_eq!(capped.next_target(now, 128, 128, healthy(128, 100)), 128);
assert_eq!(capped.next_target(now, 128, 128, healthy(128, 100)), 128);
}
#[test]
fn unproductive_probe_reverts_and_throttle_reacts_to_sustained_pressure() {
let mut controller = AutoConcurrencyController::default();
let now = Instant::now();
controller.next_target(now, 64, 128, healthy(64, 100));
assert_eq!(controller.next_target(now, 64, 128, healthy(64, 100)), 72);
assert_eq!(controller.next_target(now, 72, 128, healthy(72, 100)), 72);
assert_eq!(controller.next_target(now, 72, 128, healthy(72, 100)), 64);
let throttled = AutoConcurrencySample {
throttles: 1,
..healthy(96, 100)
};
// A single throttled window is absorbed by the per-host limiter.
let mut throttled_controller = AutoConcurrencyController::default();
assert_eq!(
throttled_controller.next_target(now, 96, 128, throttled),
96
);
// Sustained pressure backs off by one step, not a quarter.
assert_eq!(
throttled_controller.next_target(now, 96, 128, throttled),
88
);
}
#[test]
fn sustained_pressure_steps_down_across_cooldowns_and_recovers() {
let mut controller = AutoConcurrencyController::default();
let now = Instant::now();
let throttled = AutoConcurrencySample {
throttles: 1,
..healthy(96, 100)
};
assert_eq!(controller.next_target(now, 96, 128, throttled), 96);
assert_eq!(controller.next_target(now, 96, 128, throttled), 88);
// Backing off starts a cooldown that absorbs further pressure.
assert_eq!(controller.next_target(now, 88, 128, throttled), 88);
// After the cooldown, continued pressure steps down again.
let later = now + Duration::from_secs(31);
assert_eq!(controller.next_target(later, 88, 128, throttled), 80);
// Clean samples reset pressure and allow growth after the cooldown.
let later_2 = now + Duration::from_secs(62);
assert_eq!(
controller.next_target(later_2, 80, 128, healthy(80, 100)),
80
);
assert_eq!(
controller.next_target(later_2, 80, 128, healthy(80, 100)),
88
);
}
#[test]
fn high_error_rate_drops_but_never_below_minimum() {
let mut controller = AutoConcurrencyController::default();
let sample = AutoConcurrencySample {
active: 16,
bytes: 0,
requests: 100,
errors: 5,
throttles: 0,
};
assert_eq!(controller.next_target(Instant::now(), 16, 128, sample), 16);
}
}
#[cfg(test)]
mod instance_lock_tests {
use super::*;
use std::time::Duration;
#[tokio::test]
async fn serializes_concurrent_tasks_for_the_same_instance() {
let manager = Arc::new(InstanceLockManager::default());
let first = manager.lock("instance-1").await;
let manager_for_task = Arc::clone(&manager);
let mut contender = tokio::spawn(async move {
let _guard = manager_for_task.lock("instance-1").await;
});
assert!(
tokio::time::timeout(Duration::from_millis(100), &mut contender)
.await
.is_err(),
"a second task must wait for the instance lock"
);
drop(first);
tokio::time::timeout(Duration::from_millis(500), contender)
.await
.expect("contender acquires the lock after the holder drops")
.unwrap();
}
#[tokio::test]
async fn does_not_serialize_different_instances() {
let manager = Arc::new(InstanceLockManager::default());
let first = manager.lock("instance-1").await;
let second = tokio::time::timeout(
Duration::from_millis(200),
manager.lock("instance-2"),
)
.await
.expect("a different instance lock must be acquirable immediately");
drop(first);
drop(second);
}
#[tokio::test]
async fn is_reentrant_within_the_same_task() {
let manager = Arc::new(InstanceLockManager::default());
let outer = manager.lock("instance-1").await;
let inner = tokio::time::timeout(
Duration::from_millis(200),
manager.lock("instance-1"),
)
.await
.expect("re-entering the same task must not deadlock");
drop(inner);
drop(outer);
}
#[tokio::test]
async fn releases_the_lock_for_other_tasks_after_drop() {
let manager = Arc::new(InstanceLockManager::default());
let outer = manager.lock("instance-1").await;
drop(outer);
let acquired = tokio::time::timeout(
Duration::from_millis(200),
manager.lock("instance-1"),
)
.await
.expect("the lock must be free after the guard drops");
drop(acquired);
}
#[tokio::test]
async fn detached_blocking_writer_keeps_instance_locked_until_exit() {
let manager = Arc::new(InstanceLockManager::default());
let (acquired_tx, acquired_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let (done_tx, done_rx) = tokio::sync::oneshot::channel();
let guard = manager.lock_exclusive("instance-1").await;
let writer = tokio::task::spawn_blocking(move || {
let _guard = guard;
let _ = acquired_tx.send(());
let _ = release_rx.recv();
let _ = done_tx.send(());
});
tokio::time::timeout(Duration::from_millis(500), acquired_rx)
.await
.expect("blocking writer acquires the instance lock")
.unwrap();
drop(writer);
let mut contender = Box::pin(manager.lock("instance-1"));
assert!(
tokio::time::timeout(Duration::from_millis(100), &mut contender)
.await
.is_err(),
"cleanup in the original task must wait for the detached writer"
);
release_tx.send(()).unwrap();
tokio::time::timeout(Duration::from_millis(500), done_rx)
.await
.expect("blocking writer exits after release")
.unwrap();
let guard = tokio::time::timeout(Duration::from_millis(500), contender)
.await
.expect("cleanup can acquire the lock after the writer exits");
drop(guard);
}
}