From 801f86ce89299e49b49728781e23ebb7db722c23 Mon Sep 17 00:00:00 2001 From: menghao Date: Wed, 5 Aug 2026 17:04:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20manifest=20relay=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=B9=B6=E8=A1=8C=E9=98=BB=E5=A1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为全局事件接收端测试增加跨模块串行锁和 RAII 清理 为 relay accept 与 payload 读取增加五百毫秒有界等待 统一相关测试命名并覆盖超时、panic 清理和 GUI owner attach 同步开发验证流程与踩坑记录 --- .../src-tauri/src/agent/runtime_driver.rs | 5 +- .../src/agent/runtime_driver/entrypoints.rs | 33 ++++- .../src-tauri/src/runner/tests.rs | 15 +- .../src-tauri/src/tests/mod.rs | 131 ++++++++++++++++-- .../shared-memory/development-workflow.md | 6 + docs/project-memory/shared-memory/pitfalls.md | 7 + 6 files changed, 177 insertions(+), 20 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index 7a03a5eed..b35413714 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -5,6 +5,9 @@ pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock>, > = 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>, > = 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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 04b6afc71..195bcd2eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -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 { + 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> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 6cd353441..4e28d62ac 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -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] diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 726e055e2..0fac08451 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -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> { + 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( diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 1be942473..62c76d604 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -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 可继续。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 2b59908ae..cfe8ceb4f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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 清理。