Skip to content

Stop supervisor processes on daemon shutdown - #502

Open
GOLDKUN wants to merge 3 commits into
chaitin:mainfrom
GOLDKUN:agent/supervisor-shutdown
Open

Stop supervisor processes on daemon shutdown#502
GOLDKUN wants to merge 3 commits into
chaitin:mainfrom
GOLDKUN:agent/supervisor-shutdown

Conversation

@GOLDKUN

@GOLDKUN GOLDKUN commented Aug 20, 2026

Copy link
Copy Markdown

问题

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 仍没有机会:

  • 停止已启动的 runtime 子进程;
  • 取消等待中的 restart goroutine;
  • 清理实例的 PID/listen_addr;
  • 等待后台 goroutine 完整退出。

后续 review 进一步指出:daemon 启动时的顺序是 sup.RecoverEnabled(ctx) -> logStartupInventory(...) -> net.Listen(...)。一旦后两步失败,代码会跳过正常 shutdown select,因此同样需要纳入统一 cleanup。

优化方案

本 PR 将 Supervisor 纳入 daemon lifecycle:

  • 为 Supervisor 增加 lifecycle context、cancel 和 WaitGroup。
  • 新增 Supervisor.Shutdown(ctx)
    • cancel lifecycle context,阻止后续自动重启;
    • 递增 generation,使旧 restart 任务失效;
    • 停止当前 long-running 子进程;
    • 保留实例 Enabled 状态,保证下次 daemon 启动仍可通过 RecoverEnabled 恢复;
    • 清理 PIDListenAddr
    • 等待已注册后台 goroutine 退出。
  • restartAfterBackoff 从不可取消的 time.Sleep 改为可被 lifecycle context 取消的 timer。
  • daemon 的正常退出和 server error 退出路径都调用 sup.Shutdown,并设置 10s 超时。
  • serve 中增加一次性 Supervisor cleanup:只要执行过 RecoverEnabled,后续启动库存失败、端口绑定失败、server error、SIGTERM 都保证最多调用一次 sup.Shutdown,避免 early return 泄漏与重复关闭。

回归测试

新增测试覆盖:

  • shutdown 会停止运行中的子进程且不禁用实例;
  • shutdown 会取消崩溃后等待中的自动重启;
  • shutdown 会等待 start in-progress,避免 start/Shutdown 竞态产生孤儿进程;
  • shutdown 停止不响应 SIGINT 的进程时受 ctx deadline 约束;
  • daemon 启动阶段 public/admin 端口绑定失败时,会停止 RecoverEnabled 启动的实例并保留 Enabled
  • daemon 启动库存读取失败时,同样会清理 recovered instance 状态。

验证

  • go test ./cmd/octobus -run 'TestServeShutsDownRecoveredInstancesWhen(PublicBindFails|StartupInventoryFails)'
  • go test ./cmd/octobus
  • go test ./internal/supervisor

此前验证过:

  • go test ./internal/supervisor -run 'TestShutdown'
  • go test ./internal/supervisor
  • go test ./cmd/octobus

go test ./... 在本地环境未全量通过,失败集中在缺少 protoc 和未构建的 sdk/dist/cli.js,与本次改动无关。

@monkeyscan

monkeyscan Bot commented Aug 20, 2026

Copy link
Copy Markdown

PR Title: Stop supervisor processes on daemon shutdown

Commit: f00b9bb

本次变更实现"守护进程关闭时停止 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 状态。

@GOLDKUN
GOLDKUN marked this pull request as ready for review August 20, 2026 17:08
Comment thread internal/supervisor/supervisor.go
Comment thread internal/supervisor/supervisor.go
@monkeyscan

monkeyscan Bot commented Aug 20, 2026

Copy link
Copy Markdown

PR Title: Stop supervisor processes on daemon shutdown

Commit: d2afe3e

本次 PR 是针对 supervisor 关闭流程两个历史问题的修复:(1) Shutdown 串行停止实例且每实例等待不受 ctx 截止时间约束;(2) start 中途存在孤立窗口导致子进程在 Shutdown 后遗留。

核心改动:

  • 新增 beginOperation:startWithAttempt 在启动进程前即加入 s.wg,并通过 lifecycleCtx 派生的 opCtx 使进行中的 start 在 Shutdown 时被取消;defer finish 负责 cancel/等待 goroutine 退出/wg.Done。
  • startWithAttempt 各错误分支(lifecycleErrLocked、procs 不匹配、waitHealth 失败且处于关闭中)新增 persistStoppedInstance,确保关闭时落盘 Stopped/PID=nil/ListenAddr="",同时保留 Enabled。
  • Shutdown 改为 RunBounded 并发 4 停止实例;stopProcess 用 ctx 感知的 waitProcessDone(2s SIGINT 宽限 + SIGKILL 后再次等待)替代固定 time.After。
  • persistStoppedInstance 在传入 ctx 已过期时用全新 1s 超时 context 强制写库,避免预算用尽后遗留陈旧 Running/PID 状态。
  • waitHealth 增加 ctx.Err() 快速返回。
  • 新增两个测试:TestShutdownWaitsForStartInProgress、TestShutdownBoundsUnresponsiveProcessStopByContext。

评估:经核查 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 均被正确修复,未发现新的高置信度缺陷。

@kingfs

kingfs commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

审查结论:本 PR 将 Supervisor 纳入正常 daemon shutdown、取消 pending restart、等待后台 goroutine 的整体方向是科学且必要的;第二个提交也修复了此前 start/Shutdown 竞态和停止超时不受 deadline 约束的问题。

但当前实现仍有一个阻塞合并的问题:daemon 启动阶段存在未覆盖的提前返回路径,可能泄漏已恢复的子进程。

具体位置:cmd/octobus/main.go:106-119

当前顺序是:

  1. sup.RecoverEnabled(ctx),这里可能已经启动多个 long-running 子进程;
  2. logStartupInventory(...)
  3. net.Listen("tcp", opts.addr)
  4. 只有成功监听并进入后续 select 后,SIGTERM/server error 路径才调用 shutdownSupervisor

因此当 logStartupInventory 失败,或监听地址已被占用导致 net.Listen 失败时,serve 会直接返回,既不会调用 sup.Shutdown,也不会停止已经由 RecoverEnabled 启动的子进程。此时可能出现孤儿进程,以及 store 中残留 Running/PID/listen_addr 状态。这与本 PR “daemon 退出时收束 Supervisor 生命周期”的目标不一致。

建议修复:

  • serve 中建立统一的 cleanup/defer:只要 Supervisor 已创建且可能执行过 RecoverEnabled,所有后续返回路径都保证调用一次带超时的 sup.Shutdown
  • 或至少在 logStartupInventorynet.Listen 的错误返回前显式调用 shutdownSupervisor,并避免与正常 shutdown 路径重复执行;
  • 增加 daemon 层回归测试:预先占用 admin 端口,确保 RecoverEnabled 启动的实例进程被停止,实例状态被清理且 Enabled 保持不变;同时覆盖启动库存读取失败路径(可注入/mock)。

我在 PR 隔离副本中运行了 go test ./internal/supervisor ./cmd/octobusgo test -race ./internal/supervisor,均通过;但现有测试没有覆盖上述 daemon 启动失败路径。因此建议修复并补测后再合并。

@monkeyscan

monkeyscan Bot commented Aug 21, 2026

Copy link
Copy Markdown

PR Title: Stop supervisor processes on daemon shutdown

Commit: 25cd14c

本次变更修复了 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),但均属低风险、非阻断的维护性考量,不影响本次变更的正确性。

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