From 570d346db2eecd521fe04a6e84c2518fc573cc76 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Fri, 17 Jul 2026 07:12:08 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=AC=E5=9C=B0=E5=9B=9E?= =?UTF-8?q?=E7=8E=AF=E7=AB=AF=E5=8F=A3=E6=B1=A0=E8=80=97=E5=B0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让Agent Runner与本地预览复用Linux非临时端口回退 让MCP HTTP测试夹具预留可用端口并有限重试 补充端口池耗尽排障与验证约束 --- .../src-tauri/src/preview.rs | 4 +- .../src-tauri/src/runner.rs | 8 +-- .../src-tauri/src/tests.rs | 60 ++++++++++++------- docs/project-memory/shared-memory/pitfalls.md | 8 +-- 4 files changed, 50 insertions(+), 30 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index aeab336b4..d90484145 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -289,8 +289,8 @@ pub(crate) fn start_local_game_preview_for_project( )); } - let listener = - TcpListener::bind(("127.0.0.1", 0)).map_err(|error| format!("启动预览失败:{error}"))?; + let listener = bind_loopback_listener_with_linux_fallback(root.to_string_lossy().as_ref()) + .map_err(|error| format!("启动预览失败:{error}"))?; let port = listener .local_addr() .map_err(|error| format!("读取预览端口失败:{error}"))? diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index 69392057d..b1fea8710 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -3009,18 +3009,18 @@ fn read_external_agent_runner_linux_fallback_ports(boot_id: &str) -> Option io::Result { +pub(crate) fn bind_loopback_listener_with_linux_fallback(seed: &str) -> io::Result { #[cfg(target_os = "linux")] { return bind_external_agent_runner_listener_with( - || read_external_agent_runner_linux_fallback_ports(boot_id).unwrap_or_default(), + || read_external_agent_runner_linux_fallback_ports(seed).unwrap_or_default(), |port| TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, port)), ); } #[cfg(not(target_os = "linux"))] { - let _ = boot_id; + let _ = seed; TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) } } @@ -3038,7 +3038,7 @@ pub(crate) fn run_external_agent_runner_server(config_dir: impl AsRef) -> &external_agent_runner_lock_path(&config_dir), &boot_id, )?; - let listener = bind_external_agent_runner_listener(&boot_id) + let listener = bind_loopback_listener_with_linux_fallback(&boot_id) .map_err(|error| format!("绑定 Agent Runner loopback 端口失败:{error}"))?; listener .set_nonblocking(true) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index 9bd7b1ec6..2b747a327 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -2768,26 +2768,46 @@ impl Drop for McpHttpFixtureChild { } fn spawn_mcp_http_fixture() -> (McpHttpFixtureChild, u16) { - let mut child = std::process::Command::new("node") - .arg(mcp_fixture_script_path()) - .arg("http") - .arg("0") - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::inherit()) - .spawn() - .expect("spawn MCP HTTP fixture"); - let stdout = child.stdout.take().expect("MCP HTTP fixture stdout"); - let mut line = String::new(); - BufReader::new(stdout) - .read_line(&mut line) - .expect("read MCP HTTP fixture address"); - let port = serde_json::from_str::(&line) - .expect("parse MCP HTTP fixture address")["port"] - .as_u64() - .and_then(|value| u16::try_from(value).ok()) - .expect("MCP HTTP fixture port"); - (McpHttpFixtureChild(child), port) + const MCP_HTTP_FIXTURE_BIND_ATTEMPTS: usize = 8; + let mut last_error = "未尝试启动".to_string(); + for _ in 0..MCP_HTTP_FIXTURE_BIND_ATTEMPTS { + let reservation = bind_test_tcp_listener("reserve MCP HTTP fixture port"); + let requested_port = reservation + .local_addr() + .expect("read reserved MCP HTTP fixture port") + .port(); + drop(reservation); + + let mut child = std::process::Command::new("node") + .arg(mcp_fixture_script_path()) + .arg("http") + .arg(requested_port.to_string()) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .expect("spawn MCP HTTP fixture"); + let stdout = child.stdout.take().expect("MCP HTTP fixture stdout"); + let mut line = String::new(); + match BufReader::new(stdout).read_line(&mut line) { + Ok(0) => last_error = "Node 在报告 MCP HTTP 地址前退出".to_string(), + Ok(_) => { + let reported_port = serde_json::from_str::(&line) + .ok() + .and_then(|value| value["port"].as_u64()) + .and_then(|value| u16::try_from(value).ok()); + if reported_port == Some(requested_port) { + return (McpHttpFixtureChild(child), requested_port); + } + last_error = + format!("Node 报告的 MCP HTTP 端口与预留端口不一致:{reported_port:?}"); + } + Err(error) => last_error = format!("读取 MCP HTTP fixture 地址失败:{error}"), + } + let _ = child.kill(); + let _ = child.wait(); + } + panic!("启动 MCP HTTP fixture 失败:{last_error}") } #[tokio::test] diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 37e3ef477..ebadb1bc6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3162,11 +3162,11 @@ ## loopback port 0 也会被临时端口池耗尽阻断 -- 现象:旧 Runner 已停止、endpoint 连接拒绝,但新 Runner 在 `TcpListener::bind(127.0.0.1:0)` 直接返回 `Address already in use`,所有 Agent 写命令随后报“Runner 在就绪前退出”。 +- 现象:旧 Runner 已停止、endpoint 连接拒绝,但新 Runner 在 `TcpListener::bind(127.0.0.1:0)` 直接返回 `Address already in use`,所有 Agent 写命令随后报“Runner 在就绪前退出”;同一主机上的本地预览和 Node HTTP 测试夹具也会以相同方式失败。 - 原因:port 0 仍需要内核从 `ip_local_port_range` 分配监听端口;本机 api-server 与 SpacetimeDB 的约 2.8 万双向连接占满 32768-60999 后,即使目标端口不是旧 endpoint 端口,自动分配也会失败。只看 `ss -ltn` 会漏掉占用本地端口的 established client socket。 -- 处理:先保留 port 0 正常路径;Linux 只在 `AddrInUse` 后懒读取 `ip_local_port_range / ip_unprivileged_port_start / ip_local_reserved_ports`,把候选限制在 61000-65535 高位段并排除临时范围、实际特权范围和 reserved ranges,再按随机起点尝试。不要停止用户 dev 栈,不要扫描常见服务低端口,不要使用非 loopback fallback,也不要用固定公开端口或无 token 协议绕过。 -- 验证:除纯 bind 回退、懒加载、非默认特权起点、reserved ranges、候选耗尽和范围解析单测外,还要在端口池真实耗尽的主机上启动 Runner,确认 endpoint 端口位于临时范围外、heartbeat 可读,并完成真实 Provider 任务。 -- 关联:`apps/ai-game-creator-shell/src-tauri/src/runner.rs`、`agent-runtime-real-e2e.mjs`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。 +- 处理:先保留 port 0 正常路径;Linux 只在 `AddrInUse` 后懒读取 `ip_local_port_range / ip_unprivileged_port_start / ip_local_reserved_ports`,把候选限制在 61000-65535 高位段并排除临时范围、实际特权范围和 reserved ranges,再按随机起点尝试。Runner 与本地预览复用同一 loopback binder;必须交给外部测试进程监听时,先用有同等回退能力的测试 listener 预留端口并有限重试交接。不要停止用户 dev 栈,不要扫描常见服务低端口,不要使用非 loopback fallback,也不要用固定公开端口或无 token 协议绕过。 +- 验证:除纯 bind 回退、懒加载、非默认特权起点、reserved ranges、候选耗尽和范围解析单测外,还要在端口池真实耗尽的主机上启动 Runner 和本地预览,确认监听端口位于临时范围外、heartbeat 与预览内容可读,并完成真实 Provider 任务及 MCP HTTP fixture 全量回归。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/runner.rs`、`preview.rs`、`tests.rs`、`agent-runtime-real-e2e.mjs`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。 ## 旧 process record 惰性迁移不能替代 resume 主动投影