fix(linux): prevent core shutdown when worker threads exit

- Spawn Linux core processes on a dedicated, persistent thread instead of temporary background workers.
- Preserve parent-death signaling to stop the core when the GUI exits.
- Keep existing process management and Windows/macOS launch behavior unchanged.
- Add regression tests for launcher thread exit, runtime shutdown, spawn failure recovery, and parent-process termination.
This commit is contained in:
lzt404 committed 2026-09-06 13:18:44 +08:00
1 parent 584c63bf2a
commit 08c915b18b
2 files changed
+174 -5

No files matched your search

+59 -5
View File
@@ -2,6 +2,62 @@ use super::*;
pub(crate) static CORE_OPERATION_LOCK: Mutex<()> = Mutex::new(());
#[cfg(any(target_os = "linux", test))]
struct CoreSpawnRequest {
command: Command,
reply: std::sync::mpsc::SyncSender<io::Result<Child>>,
}
#[cfg(any(target_os = "linux", test))]
static CORE_PROCESS_SPAWNER: LazyLock<Result<std::sync::mpsc::Sender<CoreSpawnRequest>, String>> =
LazyLock::new(|| {
let (sender, receiver) = std::sync::mpsc::channel::<CoreSpawnRequest>();
thread::Builder::new()
.name("cpa-core-spawner".to_string())
.spawn(move || {
for mut request in receiver {
configure_child_lifetime(&mut request.command);
let result = request.command.spawn();
if let Err(std::sync::mpsc::SendError(Ok(mut child))) =
request.reply.send(result)
{
let _ = child.kill();
let _ = child.wait();
}
}
})
.map_err(|error| format!("创建 CPA 内核启动线程失败: {error}"))?;
Ok(sender)
});
pub(crate) fn spawn_core_child(command: Command) -> Result<Child, String> {
#[cfg(target_os = "linux")]
{
spawn_core_child_on_lifetime_thread(command)
}
#[cfg(not(target_os = "linux"))]
{
let mut command = command;
command
.spawn()
.map_err(|error| format!("启动 CPA 内核失败: {error}"))
}
}
#[cfg(any(target_os = "linux", test))]
pub(crate) fn spawn_core_child_on_lifetime_thread(command: Command) -> Result<Child, String> {
let sender = CORE_PROCESS_SPAWNER.as_ref().map_err(Clone::clone)?;
let (reply, result) = std::sync::mpsc::sync_channel(1);
sender
.send(CoreSpawnRequest { command, reply })
.map_err(|_| "CPA 内核启动线程已退出".to_string())?;
result
.recv()
.map_err(|_| "CPA 内核启动线程未返回启动结果".to_string())?
.map_err(|error| format!("启动 CPA 内核失败: {error}"))
}
async fn run_core_command(
app: tauri::AppHandle,
operation: fn(&CoreProcessState, &GuiConfigState) -> Result<CoreStatus, String>,
@@ -1183,11 +1239,8 @@ pub(crate) fn start_core_process_inner(
.stdout(Stdio::null())
.stderr(Stdio::null());
configure_background_command(&mut command);
configure_child_lifetime(&mut command);
let mut child = command
.spawn()
.map_err(|err| format!("启动 CPA 内核失败: {err}"))?;
let mut child = spawn_core_child(command)?;
if let Err(error) = wait_for_core_management_port(&mut child, management_address) {
let _ = terminate_child(&mut child);
@@ -1224,7 +1277,8 @@ pub(crate) fn wait_for_core_management_port(
}
}
pub(crate) fn configure_child_lifetime(command: &mut Command) {
#[cfg(any(target_os = "linux", test))]
fn configure_child_lifetime(command: &mut Command) {
#[cfg(target_os = "linux")]
{
use std::os::unix::process::CommandExt;
+115
View File
@@ -34,6 +34,121 @@ fn core_process_discovery_sleep_helper() {
}
}
fn core_child_sleep_command() -> Command {
let mut command = Command::new(env::current_exe().unwrap());
command
.args([
"--exact",
"tests::core_runtime::core_process_discovery_sleep_helper",
"--nocapture",
])
.env("EASYCLIPROXYAPI_PROCESS_DISCOVERY_TEST_HELPER", "1")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
configure_background_command(&mut command);
command
}
fn assert_core_child_survives(mut child: Child) {
thread::sleep(Duration::from_millis(200));
let status = child.try_wait();
let _ = child.kill();
let _ = child.wait();
assert!(status.unwrap().is_none(), "core exited with its launcher");
}
#[test]
fn core_child_survives_launcher_thread_exit() {
let child = thread::spawn(|| spawn_core_child(core_child_sleep_command()).unwrap())
.join()
.unwrap();
assert_core_child_survives(child);
}
#[test]
fn core_child_survives_blocking_runtime_shutdown() {
let runtime = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let child = runtime
.block_on(runtime.spawn_blocking(|| spawn_core_child(core_child_sleep_command()).unwrap()))
.unwrap();
drop(runtime);
assert_core_child_survives(child);
}
#[test]
fn core_child_spawner_remains_available_after_spawn_failure() {
let missing_binary = agent_test_home("missing-core-spawner-binary").join(core_binary_name());
assert!(spawn_core_child_on_lifetime_thread(Command::new(missing_binary)).is_err());
let child =
thread::spawn(|| spawn_core_child_on_lifetime_thread(core_child_sleep_command()).unwrap())
.join()
.unwrap();
assert_core_child_survives(child);
}
#[cfg(target_os = "linux")]
#[test]
fn core_child_owner_process_helper() {
if env::var_os("EASYCLIPROXYAPI_CORE_OWNER_TEST_HELPER").is_none() {
return;
}
let mut command = core_child_sleep_command();
command.stdout(Stdio::inherit());
let _child = spawn_core_child(command).unwrap();
println!("CORE_CHILD_READY");
io::stdout().flush().unwrap();
thread::sleep(Duration::from_secs(10));
}
#[cfg(target_os = "linux")]
#[test]
fn core_child_stops_when_owner_process_is_killed() {
use std::io::BufRead;
let mut owner = Command::new(env::current_exe().unwrap())
.args([
"--exact",
"tests::core_runtime::core_child_owner_process_helper",
"--nocapture",
])
.env("EASYCLIPROXYAPI_CORE_OWNER_TEST_HELPER", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut output = io::BufReader::new(owner.stdout.take().unwrap());
let mut ready = false;
loop {
let mut line = String::new();
match output.read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) if line.trim() == "CORE_CHILD_READY" => {
ready = true;
break;
}
Ok(_) => {}
}
}
let killed = owner.kill();
let waited = owner.wait();
let shutdown_started = Instant::now();
let mut remaining_output = String::new();
let drained = output.read_to_string(&mut remaining_output);
assert!(ready, "owner did not finish spawning its core child");
killed.unwrap();
waited.unwrap();
drained.unwrap();
assert!(
shutdown_started.elapsed() < Duration::from_secs(5),
"core kept its output pipe open after the owner was killed"
);
}
#[test]
fn running_core_process_discovery_ignores_the_same_binary_name_in_another_directory() {
let root = agent_test_home("running-core-process-scope");