修复 manifest relay 测试并行阻塞
为全局事件接收端测试增加跨模块串行锁和 RAII 清理 为 relay accept 与 payload 读取增加五百毫秒有界等待 统一相关测试命名并覆盖超时、panic 清理和 GUI owner attach 同步开发验证流程与踩坑记录
This commit is contained in:
@@ -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 不触发 attach;sink 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 清理。
|
||||
|
||||
Reference in New Issue
Block a user