修复本地回环端口池耗尽

让Agent Runner与本地预览复用Linux非临时端口回退
让MCP HTTP测试夹具预留可用端口并有限重试
补充端口池耗尽排障与验证约束
This commit is contained in:
AIGameCreator App
2026-07-17 07:12:08 +08:00
parent 0e1b158579
commit 570d346db2
4 changed files with 50 additions and 30 deletions
@@ -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}"))?
@@ -3009,18 +3009,18 @@ fn read_external_agent_runner_linux_fallback_ports(boot_id: &str) -> Option<Vec<
))
}
fn bind_external_agent_runner_listener(boot_id: &str) -> io::Result<TcpListener> {
pub(crate) fn bind_loopback_listener_with_linux_fallback(seed: &str) -> io::Result<TcpListener> {
#[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<Path>) ->
&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)
@@ -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::<serde_json::Value>(&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::<serde_json::Value>(&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]
@@ -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 主动投影