From 62bc9c12f96718a00d39d107d53d5ea1d56f1a7e Mon Sep 17 00:00:00 2001 From: SAOAsuna Date: Mon, 9 Nov 2026 01:41:49 +0800 Subject: [PATCH 1/2] fix(maa-core): stop-task releases pooled controller & web handler stops blocking the runtime Two defects in stop-task -> restart-task: 1. Web UI stop deadlocked the runtime: handle_stop_task called the synchronous stop_task_impl directly on an axum worker thread. post_stop needs framework callbacks processed, which the blocked runtime could not do - the UI reported success while the tasker kept running and users had to kill the process from the tray. Wrapped in tokio::task::spawn_blocking. 2. Stale controller reuse crashed the next run: after a game crash the pooled Controller still reported connected(), so the next start reused the dead HWND and screencap failed on every frame, cascading into task and game failure. stop_task_impl now removes the instance entry from controller_pool, unsubscribes, and clears instance.controller, forcing a fresh controller on the next connect. --- src-tauri/src/commands/maa_core.rs | 17 +++++++++++++++++ src-tauri/src/web_server.rs | 22 +++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/maa_core.rs b/src-tauri/src/commands/maa_core.rs index 09eda474..4b836df9 100644 --- a/src-tauri/src/commands/maa_core.rs +++ b/src-tauri/src/commands/maa_core.rs @@ -1693,6 +1693,23 @@ pub fn stop_task_impl( if running { tasker.unwrap().post_stop().map_err(|e| e.to_string())?; } + + // [PATCH-ZC-STOP-CTRL] 停止任务时把该实例的控制器从复用池里移除并 + // 置空。游戏崩溃/窗口失效后,池里缓存的 Controller 可能仍报告 + // connected(),下次「停止后重开」会直接复用这个死句柄,截屏在 + // 失效 HWND 上失败,进而把任务甚至游戏带崩。强制下一次连接重新 + // 走 ControllerPool miss → 新建控制器,保证截屏通道是干净的。 + if let Some(cfg) = instance.controller_config.take() { + let mut pool = state.controller_pool.lock().map_err(|e| e.to_string())?; + if pool.remove(&cfg).is_some() { + info!("ControllerPool: dropped controller after task stop, will recreate on next run"); + } + } + if let Some(subscribers) = instance.controller_subscribers.take() { + subscribers.unsubscribe(instance_id); + } + instance.controller = None; + instance.stop_in_progress = true; instance.stop_started_at = Some(Instant::now()); for status in instance.task_run_state.statuses.values_mut() { diff --git a/src-tauri/src/web_server.rs b/src-tauri/src/web_server.rs index 99093315..727e3228 100644 --- a/src-tauri/src/web_server.rs +++ b/src-tauri/src/web_server.rs @@ -1024,7 +1024,27 @@ async fn handle_stop_task( State(state): State, axum::extract::Path(instance_id): axum::extract::Path, ) -> impl IntoResponse { - match stop_task_impl(&state.app_handle, &state.maa_state, &instance_id) { + // [PATCH-ZC-STOP] stop_task_impl 是同步阻塞调用(内部会 post_stop 并等待 + // 框架事件循环推进)。直接在 axum 的 async worker 线程上执行会阻塞该线程, + // 导致 post_stop 依赖的任务回调无法被处理,形成死锁——用户表现为 + // WebUI 点「停止」无反应,只能去托盘右键停止(那条路径走 Tauri 命令, + // 在独立线程上执行所以正常)。这里包进 spawn_blocking 让它跑在 + // 阻塞线程池,不占用 async runtime。 + let app_handle = state.app_handle.clone(); + let maa_state = state.maa_state.clone(); + let instance_id_for_task = instance_id.clone(); + + let result = tokio::task::spawn_blocking(move || { + stop_task_impl(&app_handle, &maa_state, &instance_id_for_task) + }) + .await; + + let outcome = match result { + Ok(inner) => inner, + Err(join_err) => Err(format!("停止任务线程异常: {join_err}")), + }; + + match outcome { Ok(()) => { emit_state_changed(&state.app_handle, &instance_id, "task-stopped"); Json(serde_json::json!({ "ok": true })).into_response() From 21494a836f7887f3c907d095a5fcd11a86675e56 Mon Sep 17 00:00:00 2001 From: TsangAsuna Date: Mon, 9 Nov 2026 20:52:11 +0800 Subject: [PATCH 2/2] fix(maa-core): invalidate controller on every stop path, release the pool lock safely Addresses review findings on the stop-task patch: 1. Idle Tasker path skipped invalidation entirely. That is exactly the post-crash case: the game dies, the Tasker goes idle on its own, the user hits stop, and the old code returned Ok(()) before touching controller_config / controller / pool. The next run then reused the dead handle. Now that path invalidates too. 2. pool.remove ran unconditionally, unlike destroy_instance_impl and update_instance_controller which both skip removal when another instance still shares the same controller config. Without the check, stopping one instance split a shared device into two controllers competing for screencap. 3. The pool lock used , so a poisoned mutex aborted the stop path after post_stop but before stop_in_progress / begin_completion - leaving cleanup unscheduled. Removal is now best effort and logs a warning instead. 4. instance.controller was set to None while the instances lock was held; native controller destruction can block on callbacks that take that same lock. The handle is now taken inside the lock and dropped after it is released (same rule already documented at the destroy_instance_impl and update_instance_controller call sites). 5. controller_request is cancelled on stop, matching destroy_instance_impl, so a connect already in flight cannot publish a stale handle afterwards. --- src-tauri/src/commands/maa_core.rs | 149 +++++++++++++++++++++-------- 1 file changed, 107 insertions(+), 42 deletions(-) diff --git a/src-tauri/src/commands/maa_core.rs b/src-tauri/src/commands/maa_core.rs index 4b836df9..34139e20 100644 --- a/src-tauri/src/commands/maa_core.rs +++ b/src-tauri/src/commands/maa_core.rs @@ -3,6 +3,7 @@ //! 提供 MaaFramework 初始化、版本检查、设备搜索、控制器、资源和任务管理 use log::{debug, error, info, warn}; +use std::collections::HashMap; use std::sync::atomic::{AtomicI64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -19,8 +20,8 @@ use maa_framework::MaaStatus; use super::callback_lifecycle::CallbackGuard; use super::types::{ - AdbDevice, ConnectionStatus, ControllerConfig, GamescopeInstance, MaaState, TaskStatus, - VersionCheckResult, Win32Window, + AdbDevice, ConnectionStatus, ControllerConfig, GamescopeInstance, InstanceRuntime, MaaState, + TaskStatus, VersionCheckResult, Win32Window, }; use super::utils::{emit_callback_event, get_maafw_dir, handle_task_callback, normalize_path}; @@ -1659,8 +1660,17 @@ pub fn stop_task_impl( state: &Arc, instance_id: &str, ) -> Result<(), String> { - let completion = { + // What still has to happen once the `instances` lock is released. + // The controller handle must be dropped outside the lock: native destruction + // can block on callbacks which re-acquire `instances`. + let mut pending_completion: Option<(Option, Arc)> = None; + let mut pending_drop: Option = None; + { let mut instances = state.instances.lock().map_err(|e| e.to_string())?; + // Computed before taking the mutable borrow below: sharing only depends on + // controller_config, and the borrow checker will not let us read the map + // while `instance` is mutably borrowed. + let config_shared = is_controller_config_shared(&instances, instance_id); let instance = instances.get_mut(instance_id).ok_or("Instance not found")?; // Serialize focus reopening and cancellation with queue admission. Always // release a modal waiter before posting the framework stop operation. @@ -1675,58 +1685,113 @@ pub fn stop_task_impl( if tasker.is_none() { return Err("Tasker not created".to_string()); } - return Ok(()); - } - - if instance.stop_in_progress { - let elapsed = instance + // An idle Tasker is exactly the post-crash case this patch exists for: + // the game died, the Tasker went idle by itself, and the pooled + // controller may still claim connected(). Invalidate before returning + // so the next run cannot reuse the dead handle. + pending_drop = + invalidate_instance_controller(state, instance, instance_id, config_shared); + } else if instance.stop_in_progress + && instance .stop_started_at - .map(|t| t.elapsed()) - .unwrap_or(Duration::from_secs(0)); - if elapsed < Duration::from_millis(500) { - return Ok(()); + .map(|t| t.elapsed() < Duration::from_millis(500)) + .unwrap_or(false) + { + return Ok(()); + } else { + // Schedule cleanup even when all queued jobs are cancelled before they + // start, since that path need not emit a Tasker.Task terminal callback. + if running { + tasker.unwrap().post_stop().map_err(|e| e.to_string())?; } - } - // Schedule cleanup even when all queued jobs are cancelled before they - // start, since that path need not emit a Tasker.Task terminal callback. - if running { - tasker.unwrap().post_stop().map_err(|e| e.to_string())?; - } + let shared = config_shared; + pending_drop = invalidate_instance_controller(state, instance, instance_id, shared); - // [PATCH-ZC-STOP-CTRL] 停止任务时把该实例的控制器从复用池里移除并 - // 置空。游戏崩溃/窗口失效后,池里缓存的 Controller 可能仍报告 - // connected(),下次「停止后重开」会直接复用这个死句柄,截屏在 - // 失效 HWND 上失败,进而把任务甚至游戏带崩。强制下一次连接重新 - // 走 ControllerPool miss → 新建控制器,保证截屏通道是干净的。 - if let Some(cfg) = instance.controller_config.take() { - let mut pool = state.controller_pool.lock().map_err(|e| e.to_string())?; - if pool.remove(&cfg).is_some() { - info!("ControllerPool: dropped controller after task stop, will recreate on next run"); - } - } - if let Some(subscribers) = instance.controller_subscribers.take() { - subscribers.unsubscribe(instance_id); - } - instance.controller = None; - - instance.stop_in_progress = true; - instance.stop_started_at = Some(Instant::now()); - for status in instance.task_run_state.statuses.values_mut() { - if status == "pending" || status == "running" { - *status = "failed".to_string(); + instance.stop_in_progress = true; + instance.stop_started_at = Some(Instant::now()); + for status in instance.task_run_state.statuses.values_mut() { + if status == "pending" || status == "running" { + *status = "failed".to_string(); + } } + pending_completion = instance.begin_completion(false); } - instance.begin_completion(false) - }; + } // Telemetry and Agent cleanup run after the submission permit is released. - if let Some((tasker, completion)) = completion { + if let Some((tasker, completion)) = pending_completion { super::utils::schedule_run_completion(state, app, instance_id, tasker, completion); } + // Native handle destruction can block on callbacks that take the `instances` + // lock, so it happens only after that lock has been released. + drop(pending_drop); Ok(()) } +/// 除 `instance_id` 外是否还有别的实例共用同一控制器配置。 +fn is_controller_config_shared( + instances: &HashMap, + instance_id: &str, +) -> bool { + let Some(config) = instances + .get(instance_id) + .and_then(|inst| inst.controller_config.as_ref()) + else { + return false; + }; + instances + .iter() + .any(|(id, inst)| id.as_str() != instance_id && inst.controller_config.as_ref() == Some(config)) +} + +/// [PATCH-ZC-STOP-CTRL] 把实例的控制器从复用池里移除并置空。 +/// +/// 游戏崩溃/窗口失效后,池里缓存的 Controller 可能仍报告 connected(),下次 +/// 「停止后重开」会直接复用这个死句柄,截屏在失效 HWND 上失败,进而把任务 +/// 甚至游戏带崩。这里强制下一次连接走 ControllerPool miss → 新建控制器。 +/// +/// 池条目只在没有任何其他实例共享同一配置时才移除(`config_shared`),否则会 +/// 把共用同一设备的实例拆成两份控制器(共用 ADB/Win32 句柄时互相抢截屏)。 +/// 返回被摘下的控制器句柄,调用方必须在 `instances` 锁之外释放它。 +fn invalidate_instance_controller( + state: &Arc, + instance: &mut InstanceRuntime, + instance_id: &str, + config_shared: bool, +) -> Option { + // Cancel any in-flight connect so a late publish cannot restore the stale + // handle after cleanup (destroy_instance_impl does the same). + instance.controller_request.cancel(); + + let config = instance.controller_config.take(); + if let Some(subscribers) = instance.controller_subscribers.take() { + subscribers.unsubscribe(instance_id); + } + let dropped = instance.controller.take(); + + if let Some(cfg) = config { + if config_shared { + info!("ControllerPool: entry still shared by another instance, keeping it"); + } else { + // Best effort: a poisoned pool lock must not abort the stop path and + // leave stop_in_progress unset. + match state.controller_pool.lock() { + Ok(mut pool) => { + if pool.remove(&cfg).is_some() { + info!("ControllerPool: dropped controller after task stop, will recreate on next run"); + } + } + Err(err) => { + warn!("ControllerPool: pool lock poisoned during stop cleanup: {err}"); + } + } + } + } + + dropped +} + #[tauri::command] pub fn maa_stop_task( app: tauri::AppHandle,