diff --git a/packages/oneclient_core/src/clusters/manager.rs b/packages/oneclient_core/src/clusters/manager.rs index 72d11bac..9d7a9960 100644 --- a/packages/oneclient_core/src/clusters/manager.rs +++ b/packages/oneclient_core/src/clusters/manager.rs @@ -48,6 +48,37 @@ impl ClusterManager { pub async fn create( state: &LauncherState, options: CreateClusterOptions, + ) -> LauncherResult { + let _guard = state.provisioning.lock().await; + Self::create_core(state, options).await + } + + /// Create a cluster for a version/loader that doesn't exist yet, and provision it. + #[tracing::instrument(level = "debug", skip(state))] + pub async fn create_provisioned( + state: &LauncherState, + options: CreateClusterOptions, + ) -> LauncherResult> { + let _guard = state.provisioning.lock().await; + if cluster_dao::find_by_version_loader( + &state.services.db, + &options.mc_version, + options.mc_loader as i64, + ) + .await? + .is_some() + { + return Ok(None); + } + Self::create_core(state, options).await.map(Some) + } + + /// Folder-resolve + insert core shared by `create` and `create_provisioned`. + /// Callers MUST hold `state.provisioning` — this does not lock. + #[tracing::instrument(level = "debug", skip(state))] + async fn create_core( + state: &LauncherState, + options: CreateClusterOptions, ) -> LauncherResult { let name = Self::sanitize_name(&options.name); if name.is_empty() { diff --git a/packages/oneclient_core/src/clusters/provision.rs b/packages/oneclient_core/src/clusters/provision.rs index 898a3657..18cc8daf 100644 --- a/packages/oneclient_core/src/clusters/provision.rs +++ b/packages/oneclient_core/src/clusters/provision.rs @@ -39,13 +39,13 @@ pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult { + Ok(Some(cluster)) => { tracing::info!( cluster_id = cluster.id, mc_version = %cluster.mc_version, @@ -54,6 +54,7 @@ pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult {} Err(err) => { tracing::warn!( mc_version = %mc_version, @@ -100,13 +101,13 @@ pub async fn ensure_from_versions(state: &LauncherState) -> LauncherResult { + Ok(Some(cluster)) => { tracing::info!( cluster_id = cluster.id, mc_version = %cluster.mc_version, @@ -115,6 +116,7 @@ pub async fn ensure_from_versions(state: &LauncherState) -> LauncherResult {} Err(err) => { tracing::warn!( mc_version = %mc_version, diff --git a/packages/oneclient_core/src/dev.rs b/packages/oneclient_core/src/dev.rs index 7a00c356..4ea18906 100644 --- a/packages/oneclient_core/src/dev.rs +++ b/packages/oneclient_core/src/dev.rs @@ -242,6 +242,7 @@ pub async fn ephemeral_state() -> LauncherResult> { images: crate::images::ImageCacheStore::new(), games: crate::game::GameProcessManager::new(), discord: crate::discord::DiscordRpc::spawn(false), + provisioning: tokio::sync::Mutex::new(()), })) } diff --git a/packages/oneclient_core/src/recovery.rs b/packages/oneclient_core/src/recovery.rs index abdedb0b..16e42b07 100644 --- a/packages/oneclient_core/src/recovery.rs +++ b/packages/oneclient_core/src/recovery.rs @@ -40,6 +40,11 @@ impl RecoveryReport { pub async fn reconstruct_from_disk(state: &LauncherState) -> LauncherResult { let mut report = RecoveryReport::default(); + // Hold the provisioning lock across the whole scan+adopt so a concurrent + // cluster create (folder-on-disk written before its DB row commits) can't be + // misclassified as an orphan and adopted into a duplicate row. + let _guard = state.provisioning.lock().await; + let clusters_dir = paths::clusters_dir()?; let orphans = orphan_cluster_folders(&state.services.db, &clusters_dir).await?; if orphans.is_empty() { diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs index 2f829022..2e5ed101 100644 --- a/packages/oneclient_core/src/state.rs +++ b/packages/oneclient_core/src/state.rs @@ -40,6 +40,7 @@ pub struct LauncherState { pub images: ImageCacheStore, pub games: crate::game::GameProcessManager, pub discord: DiscordRpc, + pub provisioning: Mutex<()>, } impl LauncherState { @@ -75,6 +76,7 @@ impl LauncherState { images: ImageCacheStore::new(), games: crate::game::GameProcessManager::new(), discord, + provisioning: Mutex::new(()), }); STATE