diff --git a/core/archipelago/src/api/rpc/package/runtime.rs b/core/archipelago/src/api/rpc/package/runtime.rs index efa8f699..781e809c 100644 --- a/core/archipelago/src/api/rpc/package/runtime.rs +++ b/core/archipelago/src/api/rpc/package/runtime.rs @@ -91,7 +91,9 @@ impl RpcHandler { )) .await; + let op_lock = app_op_lock(package_id); tokio::spawn(async move { + let _op_guard = op_lock.lock().await; let result = if let Some(orchestrator) = orchestrator.as_ref() { do_orchestrator_package_start(orchestrator.as_ref(), &to_start).await } else { @@ -172,7 +174,9 @@ impl RpcHandler { )) .await; + let op_lock = app_op_lock(package_id); tokio::spawn(async move { + let _op_guard = op_lock.lock().await; let result = if let Some(orchestrator) = orchestrator.as_ref() { do_orchestrator_package_stop(orchestrator.as_ref(), &to_stop).await } else { @@ -256,7 +260,9 @@ impl RpcHandler { )) .await; + let op_lock = app_op_lock(package_id); tokio::spawn(async move { + let _op_guard = op_lock.lock().await; let result = if let Some(orchestrator) = orchestrator.as_ref() { do_orchestrator_package_restart(orchestrator.as_ref(), &to_restart).await } else { @@ -1051,6 +1057,28 @@ fn orchestrator_app_id(package_id: &str) -> &str { } } +/// Per-app lifecycle-operation locks. package.start/stop/restart reply +/// immediately and run their multi-container sequences in spawned tasks; +/// unserialized, back-to-back RPCs interleave those sequences (gate run D, +/// 2026-07-09: package.start's member bring-up raced the still-running +/// package.stop's member shutdown — archy-mempool-db was stopped 2 seconds +/// after it started and the mempool stack finished with zero containers). +/// Workers take the app's lock as their first await; tokio's Mutex is fair +/// (FIFO), so queued operations run in RPC arrival order and the final +/// state matches the last request. +static APP_OP_LOCKS: std::sync::LazyLock< + std::sync::Mutex>>>, +> = std::sync::LazyLock::new(Default::default); + +fn app_op_lock(package_id: &str) -> Arc> { + APP_OP_LOCKS + .lock() + .expect("APP_OP_LOCKS poisoned") + .entry(orchestrator_app_id(package_id).to_string()) + .or_default() + .clone() +} + fn uses_single_orchestrator_app(package_id: &str) -> bool { startup_order(package_id).is_empty() && matches!(