Skip to content

Fix graceful shutdown for WebSocket server#401

Open
Copilot wants to merge 5 commits intomasterfrom
copilot/implement-graceful-shutdown-websocket
Open

Fix graceful shutdown for WebSocket server#401
Copilot wants to merge 5 commits intomasterfrom
copilot/implement-graceful-shutdown-websocket

Conversation

Copy link
Copy Markdown

Copilot AI commented May 4, 2026

Server.Stop() in WebSocket-only mode did not stop the HTTP listener — ListenWebsocketConn used http.ListenAndServe directly with no shutdown handle, leaving the port bound indefinitely after Stop() returned.

Changes

znet/server.go

  • ListenWebsocketConn: replaced bare http.ListenAndServe* with an explicit *http.Server using a local http.NewServeMux() (avoids global DefaultServeMux pollution); stores the instance in s.wsServer; starts the listener in a goroutine; blocks on <-s.exitChan (consistent with TCP/KCP pattern); calls Shutdown(ctx) with wsShutdownTimeout (5 s) on exit, falling back to Close()
  • Server struct: added wsServer *http.Server and stopOnce sync.Once
  • Stop(): wraps close(exitChan) in stopOnce.Do — makes repeated Stop() calls safe without panic
  • Added wsShutdownTimeout package-level constant

znet/server_test.go

  • TestWebsocketServerGracefulStop: starts a WS-only server, calls Stop(), asserts it returns within 3 s and the port is subsequently rebindable
  • TestWebsocketServerStopIdempotent: verifies double Stop() does not panic
  • Shared retry helpers (waitForPort, waitForPortListening) with named constants (retryInterval, dialTimeout) instead of fixed sleeps
// Before: no shutdown handle, port stays bound after Stop()
http.ListenAndServe(fmt.Sprintf("%s:%d", s.IP, s.WsPort), nil)

// After: explicit server, gracefully shut down when exitChan is closed
srv := &http.Server{Addr: fmt.Sprintf("%s:%d", s.IP, s.WsPort), Handler: mux}
s.wsServer = srv
go srv.ListenAndServe()
<-s.exitChan
ctx, cancel := context.WithTimeout(context.Background(), wsShutdownTimeout)
defer cancel()
srv.Shutdown(ctx)
Original prompt

目标:在 aceld/zinx 仓库 master 分支上实现 WebSocket 模式下的“优雅停服”,确保调用 (*znet.Server).Stop() 时能停止/关闭 WebSocket HTTP Server(释放端口),而不仅仅是不阻塞。

背景:此前 PR #399 已修复 Stop()exitChan <- struct{}{} 在 websocket-only 模式下无人接收导致永久阻塞的问题(改为仅 close(exitChan))。但当前 ListenWebsocketConn() 仍调用 http.ListenAndServe / ListenAndServeTLS,没有可控的 http.Server 实例,导致 Stop() 无法停止 WebSocket 监听。

需要实现:

  1. 将 WebSocket 监听从直接调用 http.ListenAndServe* 改为显式创建并保存 *http.Server(例如存到 Server 结构体字段,如 wsServer *http.Server),并使用自建 ServeMux(避免污染全局 http.DefaultServeMux)。
  2. Server.Stop() 中:
    • 在关闭 exitChan 之外,增加对 websocket server 的关闭逻辑:优先 Shutdown(ctx)(带超时 context),必要时兜底 Close()
    • 关闭逻辑应在 websocket 模式、以及默认模式(同时启 TCP+WS)下都生效。
  3. 停服过程要避免数据竞争/空指针:
    • Start() 里初始化相关字段;Stop() 里判空。
    • 尽量让 Stop() 可重复调用不 panic(建议用 sync.Once 或原子状态,确保 close(exitChan) 只执行一次;如果仓库已有相关机制则复用)。
  4. 保持对 TCP/KCP 监听逻辑的兼容,不改变现有行为。
  5. 增加最小必要的测试或示例/文档(若仓库已有测试框架则添加单测;否则至少补充注释或一个可运行示例,说明 websocket 模式下 Stop 会释放端口)。

验收标准:

  • websocket-only 模式启动后调用 Stop():不阻塞,且 HTTP server 能退出并释放监听端口。
  • tcp/kcp 模式行为不回归。
  • go test ./...(若存在)通过;CI 不应因为该变更失败。

相关 PR:

PR 标题(英文翻译):将中文“解决WebSocket优雅停服的问题”翻译为英文作为 PR 标题。建议译为:Fix graceful shutdown for WebSocket serverFix WebSocket graceful shutdown(选择更自然的一个)。

The following is the prior conversation context from the user's chat exploration (may be truncated):

User: @aceld/zinx/pull/399 这个PR,帮我验证一下正确性,用zinx的最新的master代码验证

Assistant: 结论:PR #399 的改动在“用 master 最新代码验证”的语境下是正确的,而且能修复作者描述的 websocket-only 模式 Stop() 永久阻塞问题;合并风险低。
它把 Stop() 里对 exitChan 的“发送信号”去掉,仅保留 close(exitChan),避免在无人接收时卡死。


PR Summary(#399

  • PR: fix: prevent exitChan blocking in websocket mode #399
  • 标题:fix: prevent exitChan blocking in websocket mode
  • 基线分支:aceld/zinx:master(当前 PR base sha: 6fb5bd58...
  • 变更规模:1 个文件,-1 行
  • 当前可合并状态:mergeable=truemergeable_state=clean(无冲突)
  • 主要意图(来自 PR 描述):websocket 模式下没有 goroutine 在 <-s.exitChan,导致 s.exitChan <- struct{}{} 永久阻塞

(我这边通过对 master 的 znet/server.go 现状核对,确认你们 master 里 Stop() 确实存在该阻塞发送。)


Core Changes(核心改动点)

改动文件:znet/server.go

Stop() 当前 master 逻辑是:

  1. s.ConnMgr.ClearConn()
  2. s.exitChan <- struct{}{} (这里可能阻塞)
  3. close(s.exitChan)

PR #399 删除了第 2 步的发送,只保留 close:

  • close(ch) 的语义是:立即唤醒所有正在 <-ch 的接收方(返回零值,且 ok=false),不会阻塞。
  • ch <- x 如果没有接收者就会一直阻塞(除非 ch 是带缓冲且有空位)。

在你们的实现里:

  • ListenTcpConn() / ListenKcpConn() 末尾确实有 select { case <-s.exitChan: ... },会有接收者。
  • ListenWebsocketConn() 里没有任何 <-s.exitChan,而且它调用 http.ListenAndServe 会阻塞在内部;因此当只启动 websocket 模式时,Stop() 的发送一定可能卡死(正是 PR 描述的问题)。

所以删掉发送是合理的,而且“仅 close”对 TCP/KCP 仍然有效(它们的 <-s.exitChan 会被 close 触发)。


变更 Diff(关键代码,按 PR patch)

 func (s *Server) Stop() {
     zlog.Ins().InfoF("[STOP] Zinx server , name %s", s.Name)

     // Clear other connection information or other information that needs to be cleaned up
     // (将其他需要清理的连接信息或者其他信息 也要一并停止或者清理)
     s.ConnMgr.ClearConn()
-    s.exitChan <- struct{}{}
     close(s.exitChan)
 }

Merge Readiness & Risk

我认为可以合并(风险低),原因:

  • 改动极小且直接修复确定的死锁/阻塞点。
  • close(exitChan) 足以触发 TCP/KCP 的退出分支;不会引入新阻塞。
  • close 相比 “send+close” 更符合“广播退出信号”的常见 Go 模式。

需要注意的一个边界(不是 PR 引入的,是原代码就存在/或值得补强):

  • 如果 Stop() 可能被调用多次,close(s.exitChan) 会 panic(重复 close)。这一点 PR 没有解决/也没有恶化;但如果你们期望 Stop 幂等,后续可以加 sync.Oncedefer recover/原子状态保护。

我建议你在 master 上如何“验证”(本地复现/回归点)

你说“用 zinx 最新 master 代码验证”,最直接的验证标准是:

  1. websocket-only 模式:调用 Stop() 不应阻塞/超时

    • 合并前:可能卡在 s.exitChan <- struct{}{}
    • 合并后:应立即返回(至少不会卡在 exitChan 上)
  2. tcp/kcp 模式:调用 Stop() 后监听协程能走到 case <-s.exitChan: 分支,正常关闭 listener

    • 合并后仍然成立(close 会唤醒接收)

如果你愿意,我可以继续帮你把“websocket 模式下 Stop 如何真正让 ListenAndServe 退出(优雅关闭 http server)”也一起评估下;因为当前实现即使不阻塞,ListenWebsocketConn() 的 http server 也不会因为 exitChan 自动停(需要 http.Server{} + Shutdown/Close 才能优雅停)。

User: 也就是,我现在可以合并这个PR吗?还是你帮我继续回复下

Assistant: 可以合并。就这个 PR 的改动本身(删掉 s.exitChan <- struct{}{})来说,它修复了 websocket-only 模式下 Stop() 可能永久阻塞的问题,且对 TCP/KCP 模式不构成破坏,合并风险低。

但我建议你在合并前/合并后都明确团队预期:它只解决“Stop 不要卡死”,并不等于 websocket 服务器会被 Stop 优雅关闭(当前 ListenWebsocketConn() 用的是 http.ListenAndServe(...),没有 Shutdown/Close 控制柄,所以 Stop 也停不掉 HTTP 服务,只是不再阻塞在 exitChan 上)。


我建议你在 PR 里给作者的回复(可直接粘贴)

  • 认可修复点:websocket-only 时确实没有任何 <-exitChan 接收者,exitChan <- struct{}{} 会阻塞;用 close(exitChan) 广播退出信号更合适。
  • 说明残留问题:Stop 仍无法停止 websocket 的 ListenAndServe,如需完整停服需要后续改造(引入 http.Server 并在 Stop 中调用 Shutdown/Close)。

你可以这样回:

确认问题:在仅 websocket 模式下没有 goroutine 消费 <-s.exitChan,因此 s.exitChan <- struct{}{} 会导致 Stop() 永久阻塞。
这个 PR 删除 send、仅保留 close(s.exitChan) 的方式是正确的,对 TCP/KCP 的退出监听同样生效(close 会唤醒 <-exitChan)。
注:当前 websocket 的 http.ListenAndServe 仍无法通过 Stop 优雅退出,后续如需完整停服可考虑改为 http.Server 并在 Stop 里调用 Shutdown/Close


...

This pull request was created from Copilot chat.

@aceld aceld marked this pull request as ready for review May 4, 2026 01:57
Copilot AI changed the title [WIP] Implement graceful shutdown for WebSocket server Fix graceful shutdown for WebSocket server May 4, 2026
Copilot AI requested a review from aceld May 4, 2026 02:04
Copilot finished work on behalf of aceld May 4, 2026 02:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants