Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 108 additions & 26 deletions src-tauri/src/commands/maa_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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};

Expand Down Expand Up @@ -1659,8 +1660,17 @@ pub fn stop_task_impl(
state: &Arc<MaaState>,
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<Tasker>, Arc<super::run_completion::RunCompletion>)> = None;
let mut pending_drop: Option<Controller> = 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.
Expand All @@ -1675,41 +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())?;
}
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();
let shared = config_shared;
pending_drop = invalidate_instance_controller(state, instance, instance_id, shared);

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<String, InstanceRuntime>,
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<MaaState>,
instance: &mut InstanceRuntime,
instance_id: &str,
config_shared: bool,
) -> Option<Controller> {
// 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,
Expand Down
22 changes: 21 additions & 1 deletion src-tauri/src/web_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1024,7 +1024,27 @@ async fn handle_stop_task(
State(state): State<WebState>,
axum::extract::Path(instance_id): axum::extract::Path<String>,
) -> 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 || {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 中 · 不受信任的网站可以停止任务

启用 Web 服务器后,如果用户在任务运行期间访问恶意网页,针对未认证的停止路由发起的请求现在会让 stop_task_impl 执行完成。恶意网页可以从可读取的 /api/maa/state 端点获取实例 ID,然后发送 POST 请求停止正在运行的任务;服务器的通配符 CORS 策略允许跨源请求,因此用户的自动化任务会停止。

对会修改数据的 Web API 路由要求身份验证,并验证请求来源或 CSRF 令牌。

给 AI 智能体的提示
在 `src-tauri/src/web_server.rs` 第 1037 行:

**不受信任的网站可以停止任务**

启用 Web 服务器后,如果用户在任务运行期间访问恶意网页,针对未认证的停止路由发起的请求现在会让 `stop_task_impl` 执行完成。恶意网页可以从可读取的 `/api/maa/state` 端点获取实例 ID,然后发送 POST 请求停止正在运行的任务;服务器的通配符 CORS 策略允许跨源请求,因此用户的自动化任务会停止。

对会修改数据的 Web API 路由要求身份验证,并验证请求来源或 CSRF 令牌。
Original comment in English

🟡 Medium · Untrusted sites can stop tasks

When the web server is enabled and a user visits a malicious webpage while a task is running, stop_task_impl now runs to completion for requests to the unauthenticated stop route. A malicious webpage can obtain instance IDs from the readable /api/maa/state endpoint and POST to stop an active task; the server’s wildcard CORS policy allows the cross-origin request, so the user’s automation stops.

Require authentication and validate the requesting origin or a CSRF token on mutating web API routes.

Prompt for AI agents
In `src-tauri/src/web_server.rs` at line 1037:

**Untrusted sites can stop tasks**

When the web server is enabled and a user visits a malicious webpage while a task is running, `stop_task_impl` now runs to completion for requests to the unauthenticated stop route. A malicious webpage can obtain instance IDs from the readable `/api/maa/state` endpoint and POST to stop an active task; the server’s wildcard CORS policy allows the cross-origin request, so the user’s automation stops.

Require authentication and validate the requesting origin or a CSRF token on mutating web API routes.

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}")),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return a localizable stop-worker error code

When the blocking worker fails (for example by panicking), /tasks/stop returns this Chinese sentence as the API error, and apiPost surfaces the response text verbatim, so the message cannot follow the user's locale. Return a stable error code and translate it through src/i18n/locales instead.

AGENTS.md reference: AGENTS.md:L40-L41

Useful? React with 👍 / 👎.

};

match outcome {
Ok(()) => {
emit_state_changed(&state.app_handle, &instance_id, "task-stopped");
Json(serde_json::json!({ "ok": true })).into_response()
Expand Down
Loading