Prhub

#49668 [graceful shutdown] fix http server start firstly before app signal handler register

原始 PR 作者 andyxning 合并时间 2026-08-01 04:51 文件变更 1 提交数 3 评论 5 代码增减 +12 / -0

执行摘要

修复 HTTP 服务器启动与信号处理器注册的竞态问题

PR body 中说明:server.serve 会先调用 capture_signals,它会覆盖 SIGINT 和 SIGTERM 处理器,然后再执行 startup 并启动服务器。如果 app 的信号处理器先注册,就会被 capture_signals 覆盖,导致服务器先被关闭,app 的优雅关闭流程无法按预期工作。这是一个真实的竞态条件,影响服务优雅关闭的可靠性。

该 PR 值得精读,因为它展示了异步启动中信号处理器注册的时序问题,以及如何通过轮询等待和任务状态检查来避免竞态。重点关注 while 循环的设计与启动失败时的异常传播,这是同类问题中的典型解法。对于维护者,建议后续补充针对启动失败场景的测试,并关注 njhill 提到的潜在 follow-up 改进。

讨论亮点

核心讨论来自 AI 辅助审查机器人 inferact-vllm-bot(代表 maintainer simon-mo 审核):

  • 启动失败挂起风险:机器人指出,若 server 启动失败(如端口占用),server.started 永远为 False,原始循环会无限旋转,永远无法到达 await server_task 来暴露失败。建议在循环条件中加入 not server_task.done(),并在循环后 await server_task 传播异常。
  • 结论:作者在最终版本中采纳了该建议,代码已包含 server_task.done() 检查和异常传播,问题已解决并获得 simon-mo 的 APPROVED。
    • 另外,njhill 在 issue 评论中提到开了一个更健壮的 follow-up PR,但给出的链接错误,andyxning 指出后未确认具体 PR 编号。

实现拆解

实现拆解如下:

  1. 修改文件与位置:变更集中在 vllm/entrypoints/launcher.py 的 serve_http 函数。在创建 server_task(调用 server.serve())之后、注册 app 的 SIGINT/SIGTERM 处理器之前,插入一段等待逻辑。

  2. 等待 UVicorn 启动完成:新增 while 循环,条件为 not server.started and not server_task.done(),每 0.1 秒 await asyncio.sleep(0.1) 检查一次。server.started 在 uvicorn 的 startup() 完成后置位,此时 capture_signals() 必然已执行过,因此 app 的信号处理器不会再被覆盖。

  3. 处理启动失败场景:循环体退出后,再检查 if server_task.done(),若 uvicorn 启动失败(如端口绑定错误导致 SystemExit),则 await server_task 会重新抛出异常,使 serve_http 及时报错而不是无限挂起。

  4. 后续逻辑:等待结束(或异常传播)后才创建 shutdown_event、定义 signal_handler 并调用 loop.add_signal_handler 注册 app 的信号处理器,从而保证时序正确。

该改动不涉及测试、配置或部署配套变更,仅调整了启动阶段的控制流。

文件 模块 状态 重要度
vllm/entrypoints/launcher.py 启动器 modified 5.8

关键符号

serve_http

关键源码片段

vllm/entrypoints/launcher.py core-logic

唯一的变更文件,位于 HTTP 服务启动入口,修复了信号处理器注册的竞态,并处理启动失败场景。

# 创建 uvicorn server task 后,需要等待 server 真正启动完成再注册 app 自己的信号处理器。
# 原因 : uvicorn.Server.serve() 内部先调用 capture_signals() 安装它自己的 SIGINT/SIGTERM 处理器,
# 再执行 startup() 启动服务。若 app 的信号处理器先注册,就会被 uvicorn 覆盖,
# 导致优雅关闭逻辑失效。server.started 在 startup() 完成后才置位,因此等待它即可保证顺序。
watchdog_task = loop.create_task(watchdog_loop(server, app.state.engine_client))
server_task = loop.create_task(server.serve(sockets=[sock] if sock else None))
​
# 等待 server 启动完成,或 server task 提前结束(如端口绑定失败抛异常)。
logger.info("API server: waiting for HTTP server to start")
while not server.started and not server_task.done():
    await asyncio.sleep(0.1)
if server_task.done():
    # 若启动失败,这里会重新抛出异常,避免 serve_http 无限挂起。
    await server_task
logger.info("API server: HTTP server started")
​
# 此时 uvicorn 已安装自己的信号处理器,注册 app 的处理器不会被覆盖。
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()
​
​
loop.add_signal_handler(signal.SIGINT, signal_handler)
loop.add_signal_handler(signal.SIGTERM, signal_handler)

评论区精华

等待循环在启动失败时可能永久挂起 正确性

inferact-vllm-bot 指出:若 server 启动失败(如端口占用),server.started 永远为 False,原始 while 循环会无限旋转,无法到达 await server_task 来暴露错误。建议在循环条件中加入 not server_task.done(),并在循环后 await server_task 以传播异常。

结论:作者在最终版本中采纳建议,循环条件同时检查 server_task.done(),并在循环后 await server_task 抛出启动异常。该问题已解决。 · 已解决

Follow-up PR 链接错误 other

njhill 在 issue 评论中表示已开启一个更健壮的 follow-up PR,但给出的链接指向 PR 49668 自身。andyxning 指出链接错误。

结论:未确认具体 follow-up PR 编号,可能存在后续优化但未在本次中找到。 · unresolved

风险与影响

主要风险包括:

  • 启动挂起残留风险:尽管已加入 server_task.done() 检查,但如果 uvicorn 在 startup() 中因外部依赖(如等待某个资源就绪)长时间不返回,且任务未标记为 done,serve_http 仍可能长时间轮询等待,不过这种情况理论上同样会导致服务不可用,影响有限。
  • 启动延迟:新增的轮询循环最多引入 0.1 秒延迟,几乎可忽略,但极端情况下如果 server.started 置位较晚,会线性增加启动时间。
  • 缺少测试覆盖:本次变更没有新增单元测试或集成测试来覆盖启动竞态和失败场景,回归风险主要依赖现有 CI 的启动测试。
  • 影响范围:改动位于 HTTP 服务启动主路径,所有调用 serve_http 的入口(包括 OpenAI 兼容服务器等)都会受影响,但逻辑简单,风险较低。

影响范围集中在 vLLM 的 HTTP 服务启动阶段,对所有通过 serve_http 启动的服务生效。主要正面影响是消除了 SIGINT/SIGTERM 处理器被 uvicorn 覆盖的竞态,确保优雅关闭(drain/abort)流程可靠执行,提升运维稳定性。启动失败时错误也能更及时地暴露。对用户表现为更可靠的关闭行为和更明确的启动错误。团队维护者需注意后续是否有更健壮的优化(如 njhill 提到的 follow-up)。

启动失败循环风险 缺少测试覆盖

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论