修复 manifest relay 测试并行阻塞
Project CI / Repository checks (pull_request) Successful in 1m11s
Project CI / Frontend tests (pull_request) Successful in 3m16s
Project CI / Backend tests (pull_request) Successful in 3m54s
Project CI / Native shell tests (pull_request) Successful in 12m21s

为全局事件接收端测试增加跨模块串行锁和 RAII 清理
为 relay accept 与 payload 读取增加五百毫秒有界等待
统一相关测试命名并覆盖超时、panic 清理和 GUI owner attach
同步开发验证流程与踩坑记录
This commit is contained in:
2026-08-05 17:04:43 +08:00
parent 95b4fea0fc
commit 801f86ce89
6 changed files with 177 additions and 20 deletions
@@ -5,6 +5,9 @@ pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock<tauri::
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock<
std::sync::Mutex<Option<GameCreatorManifestInvalidationEventSink>>,
> = OnceLock::new();
#[cfg(test)]
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK: std::sync::Mutex<()> =
std::sync::Mutex::new(());
pub(super) static STATIC_DELEGATE_PARENT_WAKE_SINGLEFLIGHT: OnceLock<
std::sync::Mutex<std::collections::BTreeMap<String, bool>>,
> = OnceLock::new();
@@ -238,7 +241,7 @@ pub(in crate::agent) use task_queue::*;
pub(in crate::agent) use task_start::*;
#[cfg(test)]
pub(crate) use entrypoints::clear_game_creator_manifest_invalidation_event_sink_for_test;
pub(crate) use entrypoints::acquire_game_creator_manifest_invalidation_event_sink_test_guard;
#[allow(unused_imports)]
pub(crate) use entrypoints::{
chat_with_game_creator_agent_at, chat_with_game_creator_role_agent_at,
@@ -80,8 +80,37 @@ pub(crate) fn configure_game_creator_manifest_invalidation_event_sink(
}
#[cfg(test)]
pub(crate) fn clear_game_creator_manifest_invalidation_event_sink_for_test() {
*lock_game_creator_manifest_invalidation_event_sink() = None;
pub(crate) struct GameCreatorManifestInvalidationEventSinkTestGuard {
_isolation: std::sync::MutexGuard<'static, ()>,
}
#[cfg(test)]
impl GameCreatorManifestInvalidationEventSinkTestGuard {
pub(crate) fn configure(&self, port: u16, token: &str) -> Result<(), String> {
configure_game_creator_manifest_invalidation_event_sink(port, token)
}
pub(crate) fn configured_sink(&self) -> Option<GameCreatorManifestInvalidationEventSink> {
lock_game_creator_manifest_invalidation_event_sink().clone()
}
}
#[cfg(test)]
impl Drop for GameCreatorManifestInvalidationEventSinkTestGuard {
fn drop(&mut self) {
*lock_game_creator_manifest_invalidation_event_sink() = None;
}
}
#[cfg(test)]
pub(crate) fn acquire_game_creator_manifest_invalidation_event_sink_test_guard(
) -> GameCreatorManifestInvalidationEventSinkTestGuard {
let isolation = GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
GameCreatorManifestInvalidationEventSinkTestGuard {
_isolation: isolation,
}
}
fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> {
@@ -854,7 +854,9 @@ fn gui_owner_lock_allows_only_one_frontend_process_per_appdata() {
}
#[test]
fn attached_gui_owner_loss_forces_runner_shutdown() {
fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_up() {
let sink_guard = crate::acquire_game_creator_manifest_invalidation_event_sink_test_guard();
assert_eq!(sink_guard.configured_sink(), None);
let directory = unique_test_directory();
let config_dir = private_runner_test_config_dir(&directory);
let token = "gui-owner-monitor-token-gui-owner-monitor-token";
@@ -880,6 +882,13 @@ fn attached_gui_owner_loss_forces_runner_shutdown() {
);
assert!(attached.ok);
assert!(state.gui_owner_attached.load(Ordering::Acquire));
assert_eq!(
sink_guard.configured_sink(),
Some(crate::GameCreatorManifestInvalidationEventSink {
port: 31_318,
token: "b".repeat(64),
})
);
assert!(
!external_agent_runner_shutdown_if_gui_owner_lost(&state).expect("owner remains present")
);
@@ -890,7 +899,9 @@ fn attached_gui_owner_loss_forces_runner_shutdown() {
assert!(state.draining.load(Ordering::Acquire));
assert!(state.force_shutdown_requested.load(Ordering::Acquire));
assert!(state.shutdown_requested.load(Ordering::Acquire));
crate::clear_game_creator_manifest_invalidation_event_sink_for_test();
drop(sink_guard);
let cleanup_guard = crate::acquire_game_creator_manifest_invalidation_event_sink_test_guard();
assert_eq!(cleanup_guard.configured_sink(), None);
}
#[test]
@@ -3,18 +3,75 @@ use base64::Engine as _;
use serde_json::Value;
use sha2::{Digest as _, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Read, Write};
use std::io::{self, Read, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Barrier, Condvar, Mutex as StdMutex, MutexGuard as StdMutexGuard};
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use zip::write::SimpleFileOptions;
static TEST_PROJECT_COUNTER: AtomicU64 = AtomicU64::new(0);
static TEST_MOCK_PORT_COUNTER: AtomicU64 = AtomicU64::new(20_000);
static TEST_CONFIG_LOCK: StdMutex<()> = StdMutex::new(());
const MANIFEST_INVALIDATION_RELAY_TEST_ACCEPT_TIMEOUT: Duration = Duration::from_millis(500);
const MANIFEST_INVALIDATION_RELAY_TEST_PAYLOAD_TIMEOUT: Duration = Duration::from_millis(500);
const MANIFEST_INVALIDATION_RELAY_TEST_MAX_BYTES: usize = 64 * 1024;
fn read_manifest_invalidation_relay_payload_with_deadline(
listener: &TcpListener,
) -> io::Result<Vec<u8>> {
listener.set_nonblocking(true)?;
let accept_deadline = Instant::now() + MANIFEST_INVALIDATION_RELAY_TEST_ACCEPT_TIMEOUT;
let (mut stream, _) = loop {
match listener.accept() {
Ok(accepted) => break accepted,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
if Instant::now() >= accept_deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"manifest invalidation relay accept timed out",
));
}
std::thread::yield_now();
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error),
}
};
stream.set_nonblocking(true)?;
let payload_deadline = Instant::now() + MANIFEST_INVALIDATION_RELAY_TEST_PAYLOAD_TIMEOUT;
let mut payload = Vec::new();
let mut buffer = [0_u8; 4096];
loop {
match stream.read(&mut buffer) {
Ok(0) => return Ok(payload),
Ok(read) => {
payload.extend_from_slice(&buffer[..read]);
if payload.len() > MANIFEST_INVALIDATION_RELAY_TEST_MAX_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"manifest invalidation relay payload exceeded test limit",
));
}
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
if Instant::now() >= payload_deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"manifest invalidation relay payload timed out",
));
}
std::thread::yield_now();
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error),
}
}
}
#[test]
fn non_supervisor_runtime_update_invalidates_manifest_on_the_wire_and_runner_relay() {
fn manifest_invalidation_sink_isolation_relays_non_supervisor_runtime_update() {
let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard();
let root = unique_project_path();
init_local_game_project_at(&root, "runtime-event-contract", "Runtime 事件合同测试")
.expect("init runtime event contract project");
@@ -33,20 +90,12 @@ fn non_supervisor_runtime_update_invalidates_manifest_on_the_wire_and_runner_rel
.expect("read manifest invalidation relay fixture address")
.port();
let relay_token = "a".repeat(64);
configure_game_creator_manifest_invalidation_event_sink(relay_port, &relay_token)
sink_guard
.configure(relay_port, &relay_token)
.expect("configure manifest invalidation relay fixture");
emit_game_creator_agent_runtime_update(&root, "art-asset-plan");
let (mut relay_stream, _) = relay_listener
.accept()
.expect("accept manifest invalidation relay");
relay_stream
.set_read_timeout(Some(Duration::from_secs(1)))
.expect("set manifest invalidation relay read timeout");
let mut relay_payload = Vec::new();
relay_stream
.read_to_end(&mut relay_payload)
.expect("read manifest invalidation relay");
clear_game_creator_manifest_invalidation_event_sink_for_test();
let relay_payload = read_manifest_invalidation_relay_payload_with_deadline(&relay_listener)
.expect("receive manifest invalidation relay within deadline");
let relay: GameCreatorManifestInvalidationRelayEnvelope =
serde_json::from_slice(&relay_payload).expect("parse manifest invalidation relay");
assert_eq!(relay.token, relay_token);
@@ -56,6 +105,58 @@ fn non_supervisor_runtime_update_invalidates_manifest_on_the_wire_and_runner_rel
fs::remove_dir_all(root).ok();
}
#[test]
fn manifest_invalidation_sink_isolation_bounds_timeouts_and_cleans_up_with_raii() {
let cleanup_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.expect("bind manifest invalidation cleanup fixture");
let cleanup_port = cleanup_listener
.local_addr()
.expect("read manifest invalidation cleanup fixture address")
.port();
let cleanup_token = "b".repeat(64);
let unwind = std::panic::catch_unwind(|| {
let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard();
sink_guard
.configure(cleanup_port, &cleanup_token)
.expect("configure manifest invalidation cleanup fixture");
assert_eq!(
sink_guard.configured_sink(),
Some(GameCreatorManifestInvalidationEventSink {
port: cleanup_port,
token: cleanup_token.clone(),
})
);
panic!("exercise manifest invalidation sink guard unwind cleanup");
});
assert!(unwind.is_err());
let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard();
assert_eq!(sink_guard.configured_sink(), None);
let empty_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.expect("bind empty manifest invalidation relay fixture");
let accept_started = Instant::now();
let accept_error = read_manifest_invalidation_relay_payload_with_deadline(&empty_listener)
.expect_err("missing relay must time out");
assert_eq!(accept_error.kind(), io::ErrorKind::TimedOut);
assert!(accept_started.elapsed() < Duration::from_secs(2));
let stalled_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.expect("bind stalled manifest invalidation relay fixture");
let stalled_stream = TcpStream::connect(
stalled_listener
.local_addr()
.expect("read stalled manifest invalidation relay fixture address"),
)
.expect("connect stalled manifest invalidation relay fixture");
let payload_started = Instant::now();
let payload_error = read_manifest_invalidation_relay_payload_with_deadline(&stalled_listener)
.expect_err("incomplete relay payload must time out");
assert_eq!(payload_error.kind(), io::ErrorKind::TimedOut);
assert!(payload_started.elapsed() < Duration::from_secs(2));
drop(stalled_stream);
}
#[test]
fn gui_final_exit_is_the_only_run_event_that_requests_runner_shutdown() {
assert!(game_creator_gui_run_event_requests_runner_shutdown(
@@ -94,6 +94,12 @@ npm run test -- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts --run
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml autonomous_completion_contract -- --nocapture --test-threads=1
```
修改 manifest invalidation relay、GUI owner attach 或其测试夹具后,所有会读写进程全局事件 sink 的测试统一使用 `manifest_invalidation_sink_isolation_` 前缀,并至少以 2 个 test thread 重复运行该 filter。测试 fixture 的 accept 和 payload 读取都必须使用总 deadline,不能只在 accept 成功后给 `TcpStream` 设置 read timeout;全局 sink 只能在共享 test-only 串行锁内由 RAII guard 配置和清理。
```bash
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml manifest_invalidation_sink_isolation_ -- --nocapture --test-threads=2
```
source allowlist、game-chat 单轮完成门和 Canvas spritesheet 引用门禁均须命中实际测试;若完整 Rust suite 受 Windows `os error 32` 既有文件锁竞态影响,应单独复跑新增 filter 并如实记录,不能把锁竞态失败改报为本次改动通过。
Windows release 的非交互后台命令统一使用 `CREATE_NO_WINDOW`,包括 `command.exec / project.verify`、STDIO MCP、Repository Context Git、`git.inspect / project.git_commit``taskkill` 清理命令;需要进程组终止时再叠加 `CREATE_NEW_PROCESS_GROUP`,不要使用 `DETACHED_PROCESS`。smoke 时应在实际任务运行期间观察无额外控制台窗口,并在关闭客户端后核对整棵后台进程树为零,再重启确认 reconciliation 可继续。
@@ -4173,3 +4173,10 @@
- 原因:把 OS owner 生命周期约束与进程内事件 sink attachment 混成同一状态,或在 `ensure_external_agent_runner` 之外执行一次性 attach;测试若用 actionId 等无关字段代替真实 sink port/token,也无法证明新 boot 重放的是可用接收端。
- 处理:GUI 按规范化 AppData 私有登记真实 sink port/token`ensure_external_agent_runner` 的 endpoint 复用和新 Runner 就绪两条成功路径都按 `bootId` 重放。同 boot 成功后幂等,新 boot 必须重挂;RPC、`attached``eventSinkAttached` 任一失败或缺失都不得记录成功 boot,并允许同 boot 后续重试。不同 AppData 不共享登记,未登记 CLI 不触发 attachsink token 不进入日志、错误或公共状态。
- 验证:分别覆盖真实 port/token 跨 boot 原样重放、同 boot 幂等、新 boot 重挂、普通 attach 失败、`eventSinkAttached` 缺失与 false 后同 boot 重试、AppData 隔离和未登记 CLI 零副作用。
## manifest relay 测试不能并行覆盖同一个全局 sink(2026-08-05
- 现象:crate 根 relay 测试在配置全局 sink 后阻塞等待 `TcpListener::accept()`,同时 Runner GUI owner attach 测试通过另一条路径覆盖并清空 sink;事件可能被发往另一端口,原 listener 随后永久等待。断言或 `expect` 提前失败时,成功路径末尾的手动 clear 也不会执行。
- 原因:两个跨模块测试读写同一进程全局状态,却没有共用隔离边界;只给 accept 后取得的 stream 设置 read timeout 无法约束 accept 本身,payload 读取也缺少总 deadline。
- 处理:全部全局 sink 测试共用一把 test-only 串行锁,并由 RAII guard 在 `Drop` 中无条件清空;测试统一使用 `manifest_invalidation_sink_isolation_` 前缀。relay fixture 对 accept 和 payload 分别使用非阻塞轮询与总 deadline,不使用固定 sleep;生产 loopback、token、连接 / 写入超时和 payload 大小校验保持不变。
- 验证:用 `--test-threads=2` 重复运行统一 filter,覆盖正常 relay、无事件 accept 超时、不完整 payload 超时、panic 展开清理,以及 GUI owner attach 配置与 guard 清理。