Stop supervisor processes on daemon shutdown - #502
Conversation
|
PR Title: Stop supervisor processes on daemon shutdown Commit: 本次变更实现"守护进程关闭时停止 supervisor 子进程并取消待重启":Supervisor 新增 lifecycleCtx/lifecycleCancel、sync.WaitGroup 与可注入的 restartDelay;新增 Shutdown(),先取消生命周期上下文,再收集 s.procs/s.generations 以及 store 中应停止的长期运行实例,逐个 stopProcess(保留 Enabled 状态),最后等待全部 wait/重启 goroutine 结束;startWithAttempt 增加多处 lifecycleErr 检查,并仅在健康检查通过后把进程注册进 procs 与 WaitGroup;自动重启改为 timer + ctx.Done 的可取消等待;wait 改用 lifecycleContext 并在关闭后提前返回;cmd/octobus/main.go 在两个关闭路径调用 shutdownSupervisor(10 秒超时)。新增两个测试覆盖"关闭停止进程并保留 Enabled"与"关闭取消待重启"。 整体设计可靠:用同一把 mu 串行化"上下文取消"与"WaitGroup.Add",避免了 Add/Wait 竞态;关闭期间所有重启路径都受生命周期上下文约束。发现两个稳定性问题:1) startWithAttempt 在 cmd.Start() 之后、注册进 s.procs/WaitGroup 之前存在窗口,若此时并发 Shutdown,stopProcess 找不到 state 而不会信号该子进程,且该 Start 调用(admin API 路径)不在 WaitGroup 内,Shutdown 返回后守护进程退出可能孤立该子进程;2) Shutdown 串行停止实例,且 stopProcess 内部 2s/2s 的等待不受 ctx 截止时间约束,多实例且进程不响应 SIGINT 时会远超调用方预算,预算用尽后存储 Upsert 失败,遗留陈旧 Running/PID 状态。 |
|
PR Title: Stop supervisor processes on daemon shutdown Commit: 本次 PR 是针对 supervisor 关闭流程两个历史问题的修复:(1) Shutdown 串行停止实例且每实例等待不受 ctx 截止时间约束;(2) start 中途存在孤立窗口导致子进程在 Shutdown 后遗留。 核心改动:
评估:经核查 beginOperation 的 wg 计数与 Shutdown 的 wg.Wait() 无 Add-while-zero 竞态(Shutdown 先取消 lifecycleCtx,阻止新操作加入 wg);state.done 不存在双关路径(cleanupFailedStart 与 s.wait 互斥);stopProcess 的错误传播(errProcessStopTimeout 在 kill 成功后抑制、ctx 错误透传)逻辑自洽并与测试预期一致;store 为 WAL+busy_timeout,并发写为既有模式。两个历史 finding 均被正确修复,未发现新的高置信度缺陷。 |
|
审查结论:本 PR 将 Supervisor 纳入正常 daemon shutdown、取消 pending restart、等待后台 goroutine 的整体方向是科学且必要的;第二个提交也修复了此前 start/Shutdown 竞态和停止超时不受 deadline 约束的问题。 但当前实现仍有一个阻塞合并的问题:daemon 启动阶段存在未覆盖的提前返回路径,可能泄漏已恢复的子进程。 具体位置: 当前顺序是:
因此当 建议修复:
我在 PR 隔离副本中运行了 |
|
PR Title: Stop supervisor processes on daemon shutdown Commit: 本次变更修复了 daemon 启动失败路径上 supervisor 未清理的问题。 生产代码(cmd/octobus/main.go):serve() 在 sup.RecoverEnabled 之后注册幂等的 shutdownSupervisorOnce 闭包(supervisorShutdownNeeded 标志保证只执行一次),并新增仅测试用的 startupInventory 注入字段。当 startupInventory(默认 logStartupInventory)报错或公共监听端口 net.Listen 绑定失败时,defer 会关闭 supervisor 从而停止已恢复实例,修复此前这些路径下恢复实例泄漏的问题。正常关闭路径(serverErr / ctx.Done)也改为调用 shutdownSupervisorOnce,避免与 defer 重复关闭。defer 顺序正确(supervisor 先于 store/accessLogger 关闭),保证 supervisor 写 store 时数据库句柄仍有效。 测试(cmd/octobus/main_test.go):通过 TestMain + OCTOBUS_CMD_HELPER=1 环境变量实现 helper 子进程 fixture。setupServeRecoverFixture 在临时 dataDir 写入可执行入口脚本,脚本以 env OCTOBUS_CMD_HELPER=1 重新执行测试二进制并透传 supervisor 参数;runCmdHelper 解析 --runtime serve --port 后启动 gRPC health 服务模拟被恢复实例。两个新测试分别覆盖 startupInventory 失败与公共端口绑定失败两条路径,并通过 assertRecoveredInstanceStopped 验证恢复实例被干净停止(Enabled=true、Status=Stopped、PID=nil、ListenAddr 为空)。在未修复的旧代码上该断言会失败(实例保持 Running),因此测试有效。 总体评估:修复逻辑正确、测试覆盖到位,未发现高置信度的正确性/安全/数据完整性/并发缺陷。测试基础设施存在一些固有脆弱性(依赖 os.Args[0] 重新执行测试二进制、helper 与 supervisor 内部启动参数 --runtime serve --port 隐式耦合、helper 进程 select{} 永久阻塞等待被 kill),但均属低风险、非阻断的维护性考量,不影响本次变更的正确性。 |
问题
OctoBus daemon 关闭时只收束 HTTP/gRPC/gateway 层,没有把 Supervisor 纳入 daemon 生命周期。Supervisor 管理的 long-running 实例子进程以及自动重启 goroutine 仍可能继续存在,导致 daemon 退出后出现子进程泄漏、pending restart 继续启动实例、store 中残留旧 PID/listen_addr 等状态不一致问题。
此外,在评审中发现 daemon 启动阶段也存在提前返回路径:
RecoverEnabled已经恢复并启动实例后,如果启动库存读取失败,或 public/admin 端口绑定失败,serve会直接返回,导致刚恢复的子进程和 Running/PID/listen_addr 状态没有被清理。分析
原实现中
wait()和restartAfterBackoff()使用context.Background()/time.Sleep()驱动自动重启,缺少统一的生命周期取消信号。daemon shutdown 分支也没有调用 Supervisor 停止当前进程。因此即使 daemon 收到 SIGTERM 并关闭服务入口,Supervisor 仍没有机会:后续 review 进一步指出:daemon 启动时的顺序是
sup.RecoverEnabled(ctx)->logStartupInventory(...)->net.Listen(...)。一旦后两步失败,代码会跳过正常 shutdown select,因此同样需要纳入统一 cleanup。优化方案
本 PR 将 Supervisor 纳入 daemon lifecycle:
Supervisor.Shutdown(ctx):Enabled状态,保证下次 daemon 启动仍可通过RecoverEnabled恢复;PID和ListenAddr;restartAfterBackoff从不可取消的time.Sleep改为可被 lifecycle context 取消的 timer。sup.Shutdown,并设置 10s 超时。serve中增加一次性 Supervisor cleanup:只要执行过RecoverEnabled,后续启动库存失败、端口绑定失败、server error、SIGTERM 都保证最多调用一次sup.Shutdown,避免 early return 泄漏与重复关闭。回归测试
新增测试覆盖:
RecoverEnabled启动的实例并保留Enabled;验证
go test ./cmd/octobus -run 'TestServeShutsDownRecoveredInstancesWhen(PublicBindFails|StartupInventoryFails)'go test ./cmd/octobusgo test ./internal/supervisor此前验证过:
go test ./internal/supervisor -run 'TestShutdown'go test ./internal/supervisorgo test ./cmd/octobusgo test ./...在本地环境未全量通过,失败集中在缺少protoc和未构建的sdk/dist/cli.js,与本次改动无关。