修复 Runner GUI owner 跨重启登记
Project CI / Repository checks (push) Successful in 1m16s
Project CI / Frontend tests (push) Successful in 3m15s
Project CI / Backend tests (push) Successful in 3m49s
Project CI / Native shell tests (push) Successful in 12m42s

保存 GUI owner attach 参数并按 AppData 与 bootId 重放
在 Runner endpoint 返回前失败关闭未完成的 owner 登记
补充幂等、重试、配置隔离测试和生命周期文档
This commit is contained in:
2026-08-05 15:14:40 +08:00
parent 17f8f4b48b
commit 427fbca100
4 changed files with 267 additions and 7 deletions
@@ -8,6 +8,7 @@ use std::io::{self, BufRead, BufReader, Read, Write};
use std::net::{Ipv4Addr, SocketAddrV4, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Mutex, OnceLock};
use std::thread;
use std::time::{Duration, Instant};
@@ -16,6 +17,76 @@ const AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES: usize = 8 * 1024;
const AGENT_RUNNER_LOG_OUTPUT_MAX_CHARS: usize = 1_024;
const AGENT_RUNNER_CLIENT_EXIT_TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Default)]
pub(super) struct ExternalAgentRunnerGuiOwnerAttachmentState {
generation: u64,
registration: Option<ExternalAgentRunnerGuiOwnerRegistration>,
}
struct ExternalAgentRunnerGuiOwnerRegistration {
generation: u64,
config_dir: PathBuf,
params: ExternalAgentRunnerRequestParams,
attached_boot_id: Option<String>,
}
static EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE: OnceLock<
Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
> = OnceLock::new();
fn external_agent_runner_gui_owner_attachment_state(
) -> &'static Mutex<ExternalAgentRunnerGuiOwnerAttachmentState> {
EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE
.get_or_init(|| Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()))
}
pub(super) fn register_external_agent_runner_gui_owner_attachment(
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
config_dir: &Path,
params: ExternalAgentRunnerRequestParams,
) {
let mut state = lock_unpoisoned(state);
state.generation = state.generation.wrapping_add(1);
let generation = state.generation;
state.registration = Some(ExternalAgentRunnerGuiOwnerRegistration {
generation,
config_dir: config_dir.to_path_buf(),
params,
attached_boot_id: None,
});
}
pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with<F>(
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
config_dir: &Path,
endpoint: &ExternalAgentRunnerEndpoint,
attach: F,
) -> Result<(), String>
where
F: FnOnce(&ExternalAgentRunnerEndpoint, ExternalAgentRunnerRequestParams) -> Result<(), String>,
{
let Some((generation, params)) = ({
let state = lock_unpoisoned(state);
state.registration.as_ref().and_then(|registration| {
(registration.config_dir == config_dir
&& registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str()))
.then(|| (registration.generation, registration.params.clone()))
})
}) else {
return Ok(());
};
attach(endpoint, params)?;
let mut state = lock_unpoisoned(state);
if let Some(registration) = state.registration.as_mut() {
if registration.generation == generation && registration.config_dir == config_dir {
registration.attached_boot_id = Some(endpoint.boot_id.clone());
}
}
Ok(())
}
fn redact_url_queries(line: &str) -> String {
line.split_whitespace()
.map(|token| {
@@ -914,14 +985,22 @@ pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> {
pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> {
EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT
.store(true, std::sync::atomic::Ordering::Release);
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
let config_dir = external_agent_runner_config_dir()
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?;
let endpoint = ensure_external_agent_runner(&config_dir)?;
let result = send_external_agent_runner_request(
&endpoint,
"runner.attach_gui_owner",
register_external_agent_runner_gui_owner_attachment(
external_agent_runner_gui_owner_attachment_state(),
&config_dir,
ExternalAgentRunnerRequestParams::default(),
)?;
);
ensure_external_agent_runner(&config_dir).map(|_| ())
}
fn attach_external_agent_runner_gui_owner_at(
endpoint: &ExternalAgentRunnerEndpoint,
params: ExternalAgentRunnerRequestParams,
) -> Result<(), String> {
let result = send_external_agent_runner_request(endpoint, "runner.attach_gui_owner", params)?;
if result.get("attached").and_then(Value::as_bool) == Some(true) {
Ok(())
} else {
@@ -929,6 +1008,18 @@ pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> {
}
}
fn attach_registered_external_agent_runner_gui_owner_if_needed(
config_dir: &Path,
endpoint: &ExternalAgentRunnerEndpoint,
) -> Result<(), String> {
attach_registered_external_agent_runner_gui_owner_if_needed_with(
external_agent_runner_gui_owner_attachment_state(),
config_dir,
endpoint,
attach_external_agent_runner_gui_owner_at,
)
}
pub(super) fn shutdown_external_agent_runner_for_client_exit_at(
config_dir: &Path,
) -> Result<bool, String> {
@@ -1024,6 +1115,9 @@ pub(super) fn ensure_external_agent_runner(
match external_agent_runner_endpoint_reuse_decision(&endpoint, &executable_fingerprint) {
ExternalAgentRunnerReuseDecision::Reuse => {
if ping_external_agent_runner(&endpoint).is_ok() {
attach_registered_external_agent_runner_gui_owner_if_needed(
config_dir, &endpoint,
)?;
return Ok(endpoint);
}
}
@@ -1057,6 +1151,7 @@ pub(super) fn ensure_external_agent_runner(
let _ = launched.child.wait();
})
.map_err(|error| format!("启动 Agent Runner 子进程回收线程失败:{error}"))?;
attach_registered_external_agent_runner_gui_owner_if_needed(config_dir, &endpoint)?;
Ok(endpoint)
}
Err(error) => {
@@ -10,6 +10,7 @@ use std::io::{self, Cursor};
use std::net::{Ipv4Addr, SocketAddrV4, TcpListener};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
static TEST_DIRECTORY_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -551,6 +552,170 @@ fn runner_endpoint_rejects_hard_links() {
assert!(error.contains("硬链接"));
}
#[test]
fn gui_owner_registration_replays_once_for_each_runner_boot() {
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
let config_dir = PathBuf::from("registered-gui-appdata");
let params = ExternalAgentRunnerRequestParams {
action_id: Some("registered-owner-params".to_string()),
..ExternalAgentRunnerRequestParams::default()
};
register_external_agent_runner_gui_owner_attachment(&state, &config_dir, params);
let calls = std::cell::RefCell::new(Vec::new());
let endpoint_a = test_endpoint(
"gui-owner-replay-token-gui-owner-replay-token",
"gui-owner-boot-a",
31318,
);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint_a,
|endpoint, params| {
calls.borrow_mut().push((
endpoint.boot_id.clone(),
params.action_id.expect("registered params are retained"),
));
Ok(())
},
)
.expect("first boot attaches");
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint_a,
|_, _| panic!("same boot must not attach twice"),
)
.expect("same boot is idempotent");
let endpoint_b = test_endpoint(
"gui-owner-replay-token-gui-owner-replay-token",
"gui-owner-boot-b",
31319,
);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint_b,
|endpoint, params| {
calls.borrow_mut().push((
endpoint.boot_id.clone(),
params.action_id.expect("registered params are replayed"),
));
Ok(())
},
)
.expect("replacement boot reattaches");
assert_eq!(
calls.into_inner(),
vec![
(
"gui-owner-boot-a".to_string(),
"registered-owner-params".to_string()
),
(
"gui-owner-boot-b".to_string(),
"registered-owner-params".to_string()
),
]
);
}
#[test]
fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() {
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
let config_dir = PathBuf::from("retry-gui-appdata");
register_external_agent_runner_gui_owner_attachment(
&state,
&config_dir,
ExternalAgentRunnerRequestParams::default(),
);
let endpoint = test_endpoint(
"gui-owner-retry-token-gui-owner-retry-token",
"gui-owner-retry-boot",
31320,
);
let attempts = std::cell::Cell::new(0_u32);
let error = attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, _| {
attempts.set(attempts.get() + 1);
Err("injected attach failure".to_string())
},
)
.expect_err("failed attach must remain pending");
assert_eq!(error, "injected attach failure");
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, _| {
attempts.set(attempts.get() + 1);
Ok(())
},
)
.expect("same boot retries after failure");
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, _| panic!("successful retry must mark the boot attached"),
)
.expect("successful retry is idempotent");
assert_eq!(attempts.get(), 2);
}
#[test]
fn gui_owner_registration_does_not_cross_config_dirs() {
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
let registered_config_dir = PathBuf::from("registered-gui-appdata");
let other_config_dir = PathBuf::from("other-gui-appdata");
register_external_agent_runner_gui_owner_attachment(
&state,
&registered_config_dir,
ExternalAgentRunnerRequestParams::default(),
);
let endpoint = test_endpoint(
"gui-owner-config-token-gui-owner-config-token",
"gui-owner-config-boot",
31321,
);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&other_config_dir,
&endpoint,
|_, _| panic!("GUI owner registration must stay bound to its AppData"),
)
.expect("other AppData remains unattached");
let calls = std::cell::Cell::new(0_u32);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&registered_config_dir,
&endpoint,
|_, _| {
calls.set(calls.get() + 1);
Ok(())
},
)
.expect("registered AppData attaches");
assert_eq!(calls.get(), 1);
let unregistered = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&unregistered,
&registered_config_dir,
&endpoint,
|_, _| panic!("CLI state without GUI registration must not attach"),
)
.expect("unregistered CLI state remains unchanged");
}
#[test]
fn gui_owner_lock_allows_only_one_frontend_process_per_appdata() {
let directory = unique_test_directory();