Skip to content

🔧 [Enhancement] 修复测试端口分配的 TOCTOU 竞态条件 #396

@CodeCasterX

Description

@CodeCasterX

问题描述

当前测试框架使用的端口分配机制存在经典的 TOCTOU (Time of Check, Time of Use) 竞态条件,这是 Issue #395 中报告的服务器启动失败的根本原因

虽然 #395 通过添加超时机制实现了快速失败(治标),但本 Issue 旨在从根本上解决端口分配的竞态问题(治本)。

根本原因分析

当前实现的问题

文件位置framework/fit/java/fit-test/fit-test-framework/src/main/java/modelengine/fitframework/test/domain/util/TestUtils.java:26-32

```java
public static int getLocalAvailablePort() {
try (ServerSocket serverSocket = new ServerSocket(0)) {
return serverSocket.getLocalPort(); // OS 分配端口 8080
} // ← ServerSocket 关闭,端口 8080 被释放
// ⚠️ 时间窗口:从这里到 Netty bind() 之间,其他进程可能占用 8080
}
```

TOCTOU 竞态条件

  1. T1: `ServerSocket(0)` → OS 分配端口 8080
  2. T2: `ServerSocket.close()` → 端口 8080 被释放
  3. [时间窗口] ⚠️ 通常 1-100ms,高负载下可达数秒
  4. T3: 其他进程/测试可能占用端口 8080
  5. T4: `NettyHttpClassicServer.bind(8080)` → 绑定失败!

为什么在并发构建中更容易发生

  • Maven 并行执行多个模块测试(Surefire forkCount > 1)
  • 多个 JVM 进程同时请求端口
  • 连续 145+ 个测试服务器,端口池耗尽
  • 系统负载高时,端口回收延迟

影响范围

  • 所有使用 @MvcTest@EnableMockMvc 注解的测试类(至少 6 个)
  • 偶发性端口冲突导致测试失败
  • 在 CI/CD 并发构建环境中概率更高

建议的修复方案

方案 A:让 Netty 自动分配端口(推荐)

核心思路:不预先获取端口,而是让 Netty 在 bind 时自动分配,然后从 Channel 获取实际端口。

优点

  • 彻底消除 TOCTOU 竞态条件
  • 简化端口管理逻辑
  • 符合 Netty 最佳实践

缺点

  • 需要修改多个组件
  • 工作量较大(预估 8-16 小时)

实施步骤

  1. 修改 `TestUtils.getLocalAvailablePort()` 或移除此方法
  2. 修改 `DefaultFitTestService`:传递端口 0 给 TestFitRuntime
  3. 修改 `NettyHttpClassicServer`:bind(0) 后从 Channel 获取实际端口
  4. 修改 `MockMvc`:在服务器启动后获取真实端口
  5. 确保 `MockController` 健康检查仍然可访问

关键代码示例
```java
// NettyHttpClassicServer.java
private void startServer() {
// ...
if (this.httpPort == 0) {
// 让 Netty 自动分配端口
Channel channel = serverBootstrap.bind(0).sync().channel();
this.httpPort = ((InetSocketAddress) channel.localAddress()).getPort();
log.info("HTTP server bound to auto-assigned port: {}", this.httpPort);
} else {
// 使用指定端口
Channel channel = serverBootstrap.bind(this.httpPort).sync().channel();
}
// ...
}

// 需要提供获取实际端口的方法
public int getActualPort() {
return this.httpPort;
}
```

方案 B:保持 ServerSocket 打开直到成功绑定

核心思路:不关闭 ServerSocket,将其传递给 Netty,由 Netty 接管。

优点

  • 消除时间窗口
  • 修改范围相对较小

缺点

  • 实现复杂,需要传递 ServerSocket
  • Netty 可能不直接支持这种方式

方案 C:添加端口冲突重试机制

核心思路:如果端口绑定失败,自动重试 2-3 次。

优点

  • 实现相对简单
  • 降低冲突概率

缺点

  • 不能彻底解决问题,只是降低概率
  • 增加启动延迟

工作量评估

  • 复杂度:中高(Medium-High)
  • 工作量:8-16 小时
  • 风险等级:中(Medium)- 需要充分测试
  • 优先级:中高(Medium-High)

分解

  1. 代码修改:4-6 小时
  2. 测试验证(包括并发场景):3-6 小时
  3. 代码审查和文档更新:1-4 小时

测试策略

  1. 单元测试:验证端口分配逻辑
  2. 并发测试:并发启动 10 个测试服务器
  3. 压力测试:模拟 160 个模块并发构建
  4. 回归测试:运行所有 @mvctest 测试

相关文件

  • `framework/fit/java/fit-test/fit-test-framework/src/main/java/modelengine/fitframework/test/domain/util/TestUtils.java`
  • `framework/fit/java/fit-builtin/plugins/fit-http-server-netty/src/main/java/modelengine/fit/http/server/netty/NettyHttpClassicServer.java`
  • `framework/fit/java/fit-test/fit-test-framework/src/main/java/modelengine/fitframework/test/service/DefaultFitTestService.java`
  • `framework/fit/java/fit-test/fit-test-framework/src/main/java/modelengine/fitframework/test/domain/listener/MockMvcListener.java`

依赖关系

参考资料

确认事项

  • 已分析根本原因(TOCTOU 竞态条件)
  • 已识别影响范围
  • 已评估工作量和风险
  • 已设计多个可行方案

关联 Issue#395(短期兜底机制修复)
标签建议type: enhancement, in: fit, priority: medium-high

Metadata

Metadata

Assignees

Labels

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions