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
1 change: 1 addition & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub mod log_secrets;
pub mod maa_agent;
pub mod maa_core;
pub mod notification;
mod pre_action_stops;
mod run_completion;
pub mod state;
pub mod system;
Expand Down
63 changes: 63 additions & 0 deletions src-tauri/src/commands/pre_action_stops.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
use std::collections::HashMap;
use std::sync::Mutex;
use tokio::sync::watch;

/// A Stop belongs to the processes already waiting on this instance. Reset opens
/// the next run without clearing cancellation on their existing receivers.
#[derive(Default)]
pub struct PreActionStops(Mutex<HashMap<String, watch::Sender<bool>>>);

impl PreActionStops {
pub fn subscribe(&self, instance_id: &str) -> watch::Receiver<bool> {
let mut requests = self.0.lock().unwrap_or_else(|e| e.into_inner());
requests.retain(|_, tx| tx.receiver_count() > 0 || *tx.borrow());
requests
.entry(instance_id.to_owned())
.or_insert_with(|| watch::Sender::new(false))
.subscribe()
}

pub fn set(&self, instance_id: String, stop: bool) {
let mut requests = self.0.lock().unwrap_or_else(|e| e.into_inner());
if stop {
requests
.entry(instance_id)
.or_insert_with(|| watch::Sender::new(false))
.send_replace(true);
} else if requests
.get(&instance_id)
.is_some_and(|tx| *tx.borrow() || tx.receiver_count() == 0)
{
requests.remove(&instance_id);
}
}
}

#[cfg(test)]
mod tests {
use super::PreActionStops;

#[test]
fn reset_does_not_clear_stop_for_an_older_waiter() {
let requests = PreActionStops::default();
let first = requests.subscribe("A");
let other = requests.subscribe("B");
requests.set("A".into(), true);
let already_stopped = requests.subscribe("A");
requests.set("A".into(), false);
let next = requests.subscribe("A");
assert!(*first.borrow());
assert!(*already_stopped.borrow());
assert!(!*next.borrow());
assert!(!*other.borrow());
}

#[test]
fn repeated_reset_does_not_detach_an_active_waiter() {
let requests = PreActionStops::default();
let waiter = requests.subscribe("A");
requests.set("A".into(), false);
requests.set("A".into(), true);
assert!(*waiter.borrow());
}
}
55 changes: 23 additions & 32 deletions src-tauri/src/commands/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -545,15 +545,7 @@ pub fn set_pre_action_stop(
instance_id: String,
stop: bool,
) -> Result<(), String> {
let mut requests = state
.pre_action_stop_requests
.lock()
.map_err(|e| e.to_string())?;
if stop {
requests.insert(instance_id);
} else {
requests.remove(&instance_id);
}
state.pre_action_stop_requests.set(instance_id, stop);
Ok(())
}

Expand Down Expand Up @@ -596,6 +588,10 @@ pub async fn run_action(
}

if wait_for_exit {
let stop = state.pre_action_stop_requests.subscribe(&instance_id);
if *stop.borrow() {
return Err("MXU_PRE_ACTION_CANCELLED".to_string());
}
let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to run action: {} - {}", program, e))?;
Expand All @@ -610,15 +606,7 @@ pub async fn run_action(
return Ok(exit_code);
}

let stop_requested = {
let requests = state
.pre_action_stop_requests
.lock()
.map_err(|e| e.to_string())?;
requests.contains(&instance_id)
};

if stop_requested {
if *stop.borrow() {
info!("run_action wait cancelled by stop request: {}", instance_id);
// 停止只中断等待,不强制终止前置程序;交给后台线程 wait 以避免子进程泄漏。
std::thread::spawn(move || {
Expand Down Expand Up @@ -705,6 +693,10 @@ pub async fn run_pretask_impl(
}
}

let stop = state.pre_action_stop_requests.subscribe(&instance_id);
if *stop.borrow() {
return Err("MXU_PRE_ACTION_CANCELLED".to_string());
}
let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to run pretask: {} - {}", program, e))?;
Expand Down Expand Up @@ -743,6 +735,7 @@ pub async fn run_pretask_impl(
));
}

let mut kill_requested = false;
loop {
if let Some(status) = child
.try_wait()
Expand All @@ -759,30 +752,28 @@ pub async fn run_pretask_impl(
}
output_batcher.flush();

if *stop.borrow() {
return Err("MXU_PRE_ACTION_CANCELLED".to_string());
}
return Ok(exit_code);
}

let stop_requested = {
let requests = state
.pre_action_stop_requests
.lock()
.map_err(|e| e.to_string())?;
requests.contains(&instance_id)
};

if stop_requested {
if !kill_requested && *stop.borrow() {
info!(
"run_pretask wait cancelled by stop request: {}",
instance_id
);
// pretask 由项目声明、受 MXU 管理,与前置程序不同,停止时直接终止,避免其在后台继续操作
if let Err(e) = child.kill() {
warn!("Failed to kill pretask {}: {}", program, e);
// The process can exit between try_wait and kill.
if !matches!(child.try_wait(), Ok(Some(_))) {
std::thread::spawn(move || {
let _ = child.wait();
});
return Err(format!("Failed to kill pretask {}: {}", program, e));
}
}
std::thread::spawn(move || {
let _ = child.wait();
});
return Err("MXU_PRE_ACTION_CANCELLED".to_string());
kill_requested = true;
}

sleep(Duration::from_millis(100)).await;
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/src/commands/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//!
//! 包含 Tauri 命令使用的数据结构和枚举

use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::collections::{BTreeMap, HashMap, VecDeque};
use std::path::PathBuf;
use std::process::Child;
use std::sync::{Arc, Mutex};
Expand Down Expand Up @@ -778,7 +778,7 @@ pub struct MaaState {
pub resource_dir: Mutex<Option<PathBuf>>,
pub instances: Mutex<HashMap<String, InstanceRuntime>>,
/// 前置程序停止请求(用于中断等待退出)
pub pre_action_stop_requests: Mutex<HashSet<String>>,
pub pre_action_stop_requests: super::pre_action_stops::PreActionStops,
/// Controller 连接池:相同配置的 Controller 复用同一个 MaaControllerHandle
pub controller_pool: Mutex<HashMap<ControllerConfig, ControllerPoolEntry>>,
pub controller_connection_gates: super::callback_lifecycle::ConnectionGates<ControllerConfig>,
Expand Down
10 changes: 2 additions & 8 deletions src-tauri/src/web_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1410,16 +1410,10 @@ async fn handle_stop_pretask(
axum::extract::Path(instance_id): axum::extract::Path<String>,
Json(body): Json<PretaskStopRequest>,
) -> impl IntoResponse {
let mut requests = state
state
.maa_state
.pre_action_stop_requests
.lock()
.unwrap_or_else(|e| e.into_inner());
if body.stop {
requests.insert(instance_id);
} else {
requests.remove(&instance_id);
}
.set(instance_id, body.stop);
Json(serde_json::json!({ "ok": true }))
}

Expand Down
33 changes: 33 additions & 0 deletions tests/pretask/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Pretask cancellation regression

From the repository root, with Python 3.11+ and Rust 1.89+:

```sh
python tests/pretask/run.py
# Use cached Cargo dependencies without accessing the network:
python tests/pretask/run.py --offline
# Check a revision before the fix; cancellation assertions should fail:
python tests/pretask/run.py --offline --ref <revision>
```

The runner copies the selected checkout's actual `run_pretask_impl`, `run_action`,
desktop/Web stop setters, cancellation registry, and launch-command builder into
a temporary Rust crate. It uses the direct dependency versions in that checkout's
Cargo.lock. App handles, event/log sinks, and HTTP wrappers are stubbed; command
logic and child-process operations are unchanged. This does not exercise Tauri,
HTTP transport, or a real Maa resource.

The child is the fixture executable itself. A ready file and a manually polled
future hold the first command inside its wait loop while Stop, reset, and a new
command complete. This deterministically checks that resetting the next run does
not erase the first run's cancellation. Other cases cover ordinary Stop,
redundant reset, stopping before spawn, instance isolation, normal exit codes,
and cancellation of a user-configured pre-action wait without killing its program.
An exclusive file lock checks that a canceled managed child has exited before
the command returns. Each waiting child also exits on its own after four seconds;
the runner has build and execution timeouts and keeps artifacts in a temporary
directory.

The registry's unit tests also run in the fixture and remain available through
the application's normal `cargo test --lib` suite. This standalone fixture is
useful when app linking or Windows app-manifest setup prevents running that suite.
Loading
Loading