Prhub

#50916 [Frontend] Disable uvicorn signal handlers instead of racing them

原始 PR 作者 njhill 合并时间 2026-08-08 03:40 文件变更 3 提交数 3 评论 0 代码增减 +18 / -15

执行摘要

禁用 uvicorn 信号处理,消除启动竞态与轮询延迟

PR body 明确说明这是 #49668 的 follow-up:原实现通过轮询 server.started 来保证在 uvicorn 安装信号处理器之后再注册 vLLM 自己的 handler,但这种方式引入启动延迟,且 uvicorn 在退出时会恢复其处理器,仍然存在覆盖竞态。代码注释中也写明:'uvicorn's would race with and override them (see #49668)'。本 PR 改为让 uvicorn 从不安装信号处理器(no-op capture_signals),从根源上消除竞态,并顺带修复 DPSupervisor 中相同的 override race。

值得精读。该 PR 提供了一个可复用的模式:通过子类覆写第三方组件的 hook(把 capture_signals 变成 no-op)来消除信号注册竞态,而不是用轮询等待标志位去“排序”事件。建议重点关注两处:一是删除轮询后启动失败(如端口占用)是否还能及时上报;二是 DP Supervisor 自身的信号处理路径是否覆盖了所有预期退出场景。若团队后续升级 uvicorn,建议把 capture_signals 覆写行为纳入回归验证。

讨论亮点

本 PR 没有实质性的 reviewer 评论线程。唯一的审核记录是:claude[bot] 指出 "This pull request is from a fork — automated review is disabled"(fork PR 不自动审查),随后维护者 mgoin 直接 APPROVED,未留下评论。因此没有设计争议或未解决问题的公开讨论;方案取舍主要体现在 PR body 与 commit message 中:用 no-op 覆写 capture_signals 从根因上消除竞态,而不是继续用轮询规避时序。

实现拆解

  1. 新增 NoSignalServer 类(vllm/entrypoints/launcher.py):定义 class NoSignalServer(uvicorn.Server),用 @contextlib.contextmanager 将 capture_signals 覆写为直接 yield 的 no-op。uvicorn 的 Server.serve() 在内部以 with self.capture_signals(): 包裹主循环,覆写后主循环正常执行但不会调用 loop.add_signal_handler,因此 uvicorn 从不安装 SIGINT/SIGTERM 处理器。这是方案的核心,把“等待时序排序”变成“唯一注册方”。
  2. serve_http 切换实现并删除轮询等待:server = uvicorn.Server(config) 改为 server = NoSignalServer(config);删除原来 while not server.started and not server_task.done(): await asyncio.sleep(0.1) 的等待块、if server_task.done(): await server_task 的失败传播逻辑及对应日志。vLLM 的 loop.add_signal_handler 现在直接注册,不依赖 uvicorn 内部时序,启动路径少一次轮询循环。
  3. 修复 DPSupervisor 的同类竞态(vllm/entrypoints/openai/dp_supervisor.py):在 _start_server 中把 supervisor_server = uvicorn.Server(config) 替换为 NoSignalServer(config),并新增 from vllm.entrypoints.launcher import NoSignalServer 导入。DP Supervisor 进程的信号处理由自身 _handle_signal 和 _shutdown_event 负责,切换后不再被 uvicorn 的默认 handler 覆盖。
  4. 同步测试 stub(tests/entrypoints/openai/test_dp_supervisor.py):test_shutdown_if_supervisor_server_error_on_startup 中 monkeypatch.setattr(dp_sup.uvicorn, 'Server', FakeServer) 改为 monkeypatch.setattr(dp_sup, 'NoSignalServer', FakeServer),因为 _start_server 现在直接引用导入的 NoSignalServer 类。本 PR 未新增针对信号行为的测试,仅保持既有用例有效。
文件 模块 状态 重要度
vllm/entrypoints/launcher.py 服务入口 modified 7.16
vllm/entrypoints/openai/dp_supervisor.py 数据并行 modified 5.16
tests/entrypoints/openai/test_dp_supervisor.py 数据并行 modified 3.25

关键符号

NoSignalServer capture_signals serve_http _start_server

关键源码片段

vllm/entrypoints/launcher.py core-logic

核心变更文件:新增 NoSignalServer 类并以 no-op 覆写 capture_signals,serve_http 改用该类并删除基于 server.started 的轮询等待,从根本上消除信号注册竞态和启动延迟。

import asyncio
import contextlib
import signal
from collections.abc import Generatorimport uvicorn
​
​
class NoSignalServer(uvicorn.Server):
    """Uvicorn server that never installs its own SIGINT/SIGTERM handlers.    Callers register their own handlers on the event loop for graceful
    shutdown; uvicorn's would race with and override them (see #49668).
    """
​
    @contextlib.contextmanager
    def capture_signals(self) -> Generator[None, None, None]:
        # uvicorn 的 serve() 会以 with self.capture_signals(): 包裹主循环,
        # 这里直接 yield 表示不安装任何信号处理器,
        # 让 vLLM 在 serve_http 中自行注册 SIGINT/SIGTERM 优雅停机回调,
        # 从而避免 uvicorn 与 vLLM 互相覆盖对方的 handler,这正是 #49668 的竞态。
        yield
​
​
async def serve_http(app, sock, enable_ssl_refresh=False, **uvicorn_kwargs):
    config = uvicorn.Config(app, **uvicorn_kwargs)
    config.load()
    # 用 NoSignalServer 替代 uvicorn.Server,主要变化:
    # 不再需要轮询 server.started 来等待 uvicorn 先装好 handler,
    # 后续可直接注册 vLLM 自己的信号处理器,启动等待逻辑被整体删除。
    server = NoSignalServer(config)
    app.state.server = server
​
    loop = asyncio.get_running_loop()
    server_task = loop.create_task(server.serve(sockets=[sock] if sock else None))
    shutdown_event = asyncio.Event()
​
    def signal_handler() -> None:
        if shutdown_event.is_set():
            return
        logger.info_once("[shutdown] API server: shutdown triggered")
        shutdown_event.set()
​
    # 注册顺序不再依赖 uvicorn 的内部时序。
    loop.add_signal_handler(signal.SIGINT, signal_handler)
    loop.add_signal_handler(signal.SIGTERM, signal_handler)

评论区精华

review 与审批 other

claude[bot] 指出该 PR 来自 fork,自动 review 被禁用("This pull request is from a fork — automated review is disabled");维护者 mgoin 随后直接 APPROVED,未留下评论。整个 PR 没有针对实现的讨论线程。

结论:没有实质技术争议;方案通过维护者审批。 · 已解决

风险与影响

  1. 启动失败传播行为变化:删除轮询块中的 if server_task.done(): await server_task 后,serve_http 不再主动检测 server_task 在启动阶段是否失败(如端口绑定错误)。旧代码会在约 0.1s 内把异常抛出;新代码中该异常只保留在后台 task 中,serve_http 会继续注册信号并等待 shutdown 事件,异常可能要等外层任务 await 或任务回调才可见。建议确认 api_server.py 等调用方是否仍能及时收到启动失败。
  2. 依赖 uvicorn 内部钩子:capture_signals 是 uvicorn 的内部方法,no-op 覆写依赖“uvicorn serve() 用 with self.capture_signals(): 包裹主循环”这一实现细节,uvicorn 升级后需要回归验证。
  3. 测试覆盖有限:只改了一个测试 stub,没有新增针对 serve_http 信号注册顺序、启动失败传播的测试;回归保障主要靠审查者人工判断。
  4. DPSupervisor 行为变化:DP Supervisor 不再享受 uvicorn 的默认信号处理,其进程退出完全依赖自身 _handle_signal 与 _shutdown_event;若此前有路径隐式依赖 uvicorn 的 Ctrl-C 处理,现在会走 vLLM 的优雅停机流程,需确认预期一致。

影响范围覆盖所有经 serve_http 启动的 API Server(OpenAI 兼容服务、DP supervisor 等)的启动与停机路径。用户侧收益:进程收到 SIGINT/SIGTERM 时由 vLLM 统一触发的 drain/abort 优雅停机不会再被 uvicorn handler 覆盖,行为更确定;同时消除 server.started 轮询带来的启动等待。系统侧:启动路径少一个同步点,信号注册不再依赖时序假设;uvicorn.Server 被自定义子类替代,后续如需在 HTTP 服务启动前做更早的信号准备有了统一注入点。团队侧:代码净减约 10 行,但引入了对 uvicorn 内部钩子的依赖,升级 uvicorn 或新增 server 启动变体时需要额外关注。

核心启动路径变更 启动失败错误传播行为变化 测试覆盖有限 依赖 uvicorn 内部钩子

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论