Prhub

#1873 Serve HTTP from the mock engine on its allocated port

原始 PR 作者 fzyzcjy 合并时间 2026-09-04 13:06 文件变更 10 提交数 1 评论 3 代码增减 +267 / -163

执行摘要

重构测试 Mock 引擎,使其在自身分配的端口上提供 HTTP 服务,提升测试保真度。

当前的 real_ray 测试套件通过 stub 掉地址分配器函数并预绑定一个由驱动进程管理的 HTTP 服务器池来模拟引擎。这导致测试与分配器的存在和伪造的 rank->port 映射耦合,且测试中的 URL 没有真正的 HTTP 服务器在背后提供服务。此次重构旨在让 Mock 引擎模拟真实的 Ray Actor 行为:在分配的端口上启动 HTTP 服务器,并将请求日志记录在 Actor 内部。这使得测试能够验证生产代码从真实分配器推导出的 URL 是否确实可达,并且请求是否被正确记录。

此 PR 是有价值的测试基础设施改进,它遵循了“测试应尽可能接近生产环境”的最佳实践。建议精读,特别是 MockSGLangHttpServer 中连接跟踪和端口绑定的实现细节,以及测试夹具简化后的模式。设计决策上,将 HTTP 服务器生命周期与 Actor 绑定是正确的方向,但 必须关注并跟进 review 中提出的 IP 地址硬编码问题,这是一个在后续测试扩展中必须解决的技术债。

讨论亮点

reviewer guapisolo 指出了一个关键的设计问题(P1级别):重构后的 MockSGLangEngine._get_current_node_ip_and_free_port 方法仍然硬编码返回 127.0.0.1,而新的 MockSGLangHttpServer 也只绑定到回环地址。这在测试运行于真实多节点集群或隔离的网络命名空间时(如通过 RAY_ADDRESS 连接),会导致驱动端解析到的引擎 URL 无法访问,引发 ConnectionRefusedError。reviewer 要求返回真实的节点 IP 并绑定到该地址或 0.0.0.0,但指出此问题在后续 PR 中未修复。

实现拆解

  1. 核心源码改造 (MockSGLangHttpServer):在 miles/utils/test_utils/mock_sglang_http_server.py 中,为 MockSGLangHttpServer 的 __init__ 方法增加可选的 port 参数(默认 0 表示随机端口),使其能绑定到指定端口。同时增加了连接跟踪机制(_register_connection, _unregister_connection, _drain_connections),确保在 close() 时能主动断开已建立的 keep-alive 连接,模拟引擎崩溃的场景。MockSGLangHttpServerPool 类被移除。
  2. 核心源码改造 (MockSGLangEngine):在 miles/utils/test_utils/mock_sglang_engine.py 中,MockSGLangEngine 在 init() 时根据传入的 port 参数创建一个 MockSGLangHttpServer 实例,并在 shutdown() 时关闭它。新增了 get_http_paths() 和 get_http_payloads_of(path) 方法,允许测试通过 Ray Actor 远程调用获取请求日志。_get_current_node_ip_and_free_port 方法改为调用真实的 get_free_port 函数。
  3. 测试夹具简化:在 tests/fast/ray/rollout/real_ray/conftest.py 中,移除了 mock_engine_http_servers fixture 和 patched_sglang_engine 中的 _fake_alloc 程序。patched_sglang_engine fixture 现在只替换引擎类,保留真实的地址分配器。原先需要 mock HTTP 服务器列表来验证请求的测试,现在改为通过 ray.get(actor_handle.get_http_paths.remote()) 等方式从 Actor 获取日志。
  4. 测试断言迁移:多个测试文件(如 test_inference_controller.py, test_server_group.py, test_rollout_server.py, test_fault_tolerance.py)中的断言逻辑从直接查询 MockSGLangHttpServerPool 中的服务器对象,迁移为调用 Actor 的远程方法获取日志,使测试逻辑更贴近生产代码路径。
  5. 新增 Mock HTTP 服务器单元测试:在 tests/fast/utils/test_utils/ 下新增了 test_mock_sglang_http_server.py 和 conftest.py,为重构后的 MockSGLangHttpServer 提供了全面的单元测试,覆盖端口绑定、连接管理、请求记录和错误处理。
文件 模块 状态 重要度
miles/utils/test_utils/mock_sglang_http_server.py 测试工具 modified 8.17
miles/utils/test_utils/mock_sglang_engine.py 测试工具 modified 7.27
tests/fast/ray/rollout/real_ray/conftest.py 测试夹具 modified 6.67

关键符号

MockSGLangHttpServer.__init__ MockSGLangHttpServer.close MockSGLangHttpServer._register_connection MockSGLangHttpServer._unregister_connection MockSGLangHttpServer._drain_connections MockSGLangEngine.init MockSGLangEngine.shutdown MockSGLangEngine.get_http_paths MockSGLangEngine.get_http_payloads_of MockSGLangEngine._get_current_node_ip_and_free_port

关键源码片段

miles/utils/test_utils/mock_sglang_http_server.py dependency-wiring

核心变更之一。为 Mock HTTP 服务器增加了指定端口绑定和连接跟踪能力,使其能模拟引擎崩溃时断开连接的行为,为 Mock 引擎在 Actor 内部提供服务奠定了基础。

from __future__ import annotations
​
import dataclasses
import json
import logging
import socket
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
​
logger = logging.getLogger(__name__)
​
​
@dataclasses.dataclass(frozen=True)
class RecordedRequest:
    method: str
    path: str
    payload: dict[str, Any] | None
​
​
class MockSGLangHttpServer:
    # 核心变更:__init__ 新增 port 参数,允许绑定到指定端口;新增 _connections 集合用于跟踪活跃连接
    def __init__(self, response_payload: dict[str, Any] | None = None, port: int = 0):
        self._response_payload = response_payload if response_payload is not None else {"mock": True}
        self._requests: list[RecordedRequest] = []
        self._lock = threading.Lock()
        self._connections: set[socket.socket] = set() # 跟踪所有活跃的连接 socket
​
        # 绑定到指定端口(port=0 为随机端口)
        self._server = ThreadingHTTPServer(("127.0.0.1", port), self._make_handler())
        self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
        self._thread.start()
​
    # ... (host, port, url, requests, paths, payloads_of 属性不变 ) ...
​
    # 核心变更:close 方法现在会主动断开所有已建立的连接,模拟引擎崩溃
    def close(self) -> None:
        self._server.shutdown()
        self._server.server_close()
        # 主动关闭所有活跃连接,模拟引擎宕机时客户端立即感知
        for connection in self._drain_connections():
            try:
                connection.shutdown(socket.SHUT_RDWR)
            except OSError:
                logger.debug("mock sglang http server: connection was already down", exc_info=True)
            connection.close()
        self._thread.join(timeout=5)
​
    def _record(self, method: str, path: str, payload: dict[str, Any] | None) -> None:
        with self._lock:
            self._requests.append(RecordedRequest(method=method, path=path.split("?")[0], payload=payload))
​
    # 新增方法:注册、注销和排空连接
    def _register_connection(self, connection: socket.socket) -> None:
        with self._lock:
            self._connections.add(connection)
​
    def _unregister_connection(self, connection: socket.socket) -> None:
        with self._lock:
            self._connections.discard(connection)
​
    def _drain_connections(self) -> list[socket.socket]:
        with self._lock:
            connections = list(self._connections)
            self._connections.clear()
        return connections
​
    def _make_handler(self) -> type[BaseHTTPRequestHandler]:
        server = self
​
        class _Handler(BaseHTTPRequestHandler):
            protocol_version = "HTTP/1.1"
​
            # 核心变更:在 setup 和 finish 中跟踪连接生命周期
            def setup(self):
                super().setup()
                server._register_connection(self.connection) # 连接建立时注册
​
            def finish(self):
                server._unregister_connection(self.connection) # 连接结束时注销
                super().finish()
​
            # ... (do_GET, do_POST, log_message, _respond 不变 ) ...
​
        return _Handler
miles/utils/test_utils/mock_sglang_engine.py core-logic

核心变更之二。将 Mock 引擎从一个“哑”记录器改为一个在 init 时启动真实 HTTP 服务器的 Actor,并提供了获取请求日志的远程方法。

# ... ( 文件头部导入 ) ...
from miles.utils.misc import get_free_port
from miles.utils.test_utils.mock_sglang_http_server import MockSGLangHttpServer
​
class MockSGLangEngine:
    def __init__(self, args, rank: int, ...):
        # ... ( 其他属性初始化 ) ...
        # 移除了 _port_seq 和 _lock,改为持有 http_server 实例
        self._http_server: MockSGLangHttpServer | None = None
​
    # ... (set_fault, get_calls, get_init_kwargs 不变 ) ...
​
    # 新增远程方法:供测试通过 Ray 调用获取请求日志
    def get_http_paths(self) -> list[str]:
        return self._http_server.paths if self._http_server is not None else []
​
    def get_http_payloads_of(self, path: str) -> list[dict | None]:
        return self._http_server.payloads_of(path) if self._http_server is not None else []
​
    # 核心变更:init 方法现在会在分配的端口上启动 HTTP 服务器
    def init(self, **kwargs):
        self._record("init", (), kwargs)
        self._maybe_fault("init")
        # 使用 init 传入的 port 参数创建 HTTP 服务器,实现“在分配端口上服务”
        self._http_server = MockSGLangHttpServer(port=kwargs["port"])
        self.initialized = True
        return None
​
    # 核心变更:shutdown 方法现在会关闭 HTTP 服务器
    def shutdown(self):
        self._record("shutdown", (), {})
        self._maybe_fault("shutdown")
        if self._http_server is not None:
            self._http_server.close()
        self.initialized = False
        return True
​
    # ... (simulate_crash 不变 ) ...
​
    # 核心变更:使用真实的 get_free_port 函数获取端口,而非自己维护序列
    def _get_current_node_ip_and_free_port(self, start_port: int = 15000, consecutive: int = 1):
        self._record("_get_current_node_ip_and_free_port", (), {"start_port": start_port, "consecutive": consecutive})
        return ("127.0.0.1", get_free_port(start_port=start_port, consecutive=consecutive))
​
    # ... (_record, _maybe_fault 不变 ) ...
tests/fast/ray/rollout/real_ray/conftest.py test-coverage

展示了测试夹具的简化过程:移除了 Mock HTTP 服务器池和地址分配 stub,使测试能使用真实的地址分配流程。

# ... ( 文件头部导入和 placement_group_factory fixture 不变 ) ...
​
# 核心变更:移除了 mock_engine_http_servers fixture
# 核心变更:简化 patched_sglang_engine,不再依赖 mock_engine_http_servers 和 _fake_alloc
@pytest.fixture
def patched_sglang_engine(monkeypatch, mock_engine_class):
    """Replace SGLangEngine with the mock; the real addr allocator runs, and
    each mock engine serves HTTP on the port it is allocated, so the urls
    ServerGroup derives from the allocator actually serve requests."""
    import miles.ray.rollout.server_cell as cell_mod
​
    monkeypatch.setattr(cell_mod, "SGLangEngine", mock_engine_class)
    # 移除了对 allocate_rollout_engine_addr_and_ports_normal 的 monkeypatch
​
# 核心变更:移除了 patched_sglang_engine_real_allocator fixture
# 因为 patched_sglang_engine 现在已经使用真实的分配器

评论区精华

硬编码的 127.0.0.1 地址在多节点 / 隔离网络场景下的兼容性问题 正确性

Reviewer guapisolo 指出,`MockSGLangEngine._get_current_node_ip_and_free_port` 仍然返回 `127.0.0.1`,而 Mock HTTP 服务器也只绑定到 loopback 地址。当测试运行在真实多节点集群或具有隔离网络命名空间的环境(通过 `RAY_ADDRESS` 连接)时,驱动进程会解析引擎 URL 到自身的 loopback,导致 `ConnectionRefusedError`。Reviewer 要求返回 `get_current_node_ip()` 并绑定到该地址或 `0.0.0.0`。

结论:问题被识别并标记为 P1,但在此 PR 中未修复。reviewer 在后续评论中确认该问题在后续 PR 中仍未被修复。 · 待处理

风险与影响

  1. 兼容性/正确性风险:如 review 指出,Mock 引擎返回的 127.0.0.1 地址在非本地单机测试场景(如多节点、容器网络)下会导致测试失败,因为驱动进程和引擎 Actor 可能处于不同的网络命名空间。当前修复不完整,存在回归风险。
  2. 测试稳定性风险:端口分配从预计算改为运行时真实探测(get_free_port),理论上在并发测试或资源紧张环境下可能存在端口冲突的微小概率,但通过监听随机端口(port=0)来缓解。
  3. 代码依赖变更:移除了 MockSGLangHttpServerPool,可能影响其他未在此 PR 中更新、但依赖该类的测试代码(需确认已无其他依赖)。
  1. 对系统的影响:无直接影响。变更完全限于测试基础设施 (miles/utils/test_utils) 和测试代码 (tests/fast)。
  2. 对用户的影响:无直接影响。
  3. 对团队的影响:正面:提升了测试的保真度,使测试环境更接近生产环境,有助于发现因地址/端口处理不一致导致的潜在 bug。简化了测试夹具,减少了测试间的耦合。负面:引入了一个已知但未修复的兼容性问题(硬编码 IP),可能在后续扩展测试覆盖(如多节点测试)时造成阻碍。
硬编码 IP 地址 测试环境假设

关联 Issue

#1837 Tracking issue for refactoring and enhancements

完整报告

参与讨论