Defines mandatory invariants for safe ProcessManager start, stop, restart, backoff, and zombie-kill behavior. Use when modifying fubon-proxy supervision or diagnosing lifecycle failures and related tests.
Install
npx skillscat add kaecer68/atlas-go/atlas-fubon-supervisor-invariants Install via the SkillsCat registry.
About this skill
Defines mandatory invariants for safe ProcessManager start, stop, restart, backoff, and zombie-kill behavior. Use when modifying fubon-proxy supervision or diagnosing lifecycle failures and related tests.
SKILL.md
Atlas Fubon Supervisor 不變式(F1~F9)
本技能定義修改 internal/fubonproxy 的 ProcessManager 時必須遵守的不變式,涵蓋 supervise()、Stop()、Start() pre-flight、backoff 與 zombie auto-kill 等核心邏輯。
何時觸發
- 修改
internal/fubonproxy/manager.go或相關 supervisor 邏輯 - 調整
Start()、Stop()、supervise()、waitForHealthy()、port probe、zombie kill 流程 - 新增或變更 fubon-proxy 測試(尤其是 race / 生命週期測試)
- 發現 fubon-proxy 有 orphan process、goroutine 堆積、Stop 阻塞、EADDRINUSE backoff loop 等現象
核心概念
ProcessManager
- 定義:
internal/fubonproxy中管理 Python FastAPI 微服務生命週期的結構。 - 實作位置:
internal/fubonproxy/manager.go - 關鍵:採 non-blocking supervisor pattern,
Start()立即返回,背景 goroutine 處理健康檢查與崩潰重啟。
supervise()
- 定義:背景 goroutine,負責啟動 Python 程序、執行同步健康檢查、崩潰時 backoff 重啟。
- 關鍵:健康檢查必須是同步呼叫,禁止 fire-and-forget。
Start() pre-flight probe
- 定義:
Start()在 spawn 前以net.Listen("tcp", "127.0.0.1:18081")探測 port 狀態。 - 關鍵:區分
portStateFree/Healthy/Foreign,Foreign 占用必須回傳 actionable error。
實作位置
| 概念 | 檔案路徑 | 關鍵函數 / 結構 |
|---|---|---|
| ProcessManager | internal/fubonproxy/manager.go |
ProcessManager、Start()、Stop()、supervise()、waitForHealthy() |
| Fubon proxy | services/fubon-proxy/main.py |
FastAPI 啟動與 host 綁定 |
| 測試 | internal/fubonproxy/manager_test.go |
TestProcessManager_* |
F1 — Stop/restart race
Stop()必須先設m.stopping=true再做任何檢查,並呼叫cancel()(nil-check cancel 函式)。即使m.running=false也要走完這步,否則supervise()會在重啟路徑中孤兒化新程序。supervise()啟動新程序後必須在鎖內檢查m.stopping;若已設則Kill()新程序並 return。
F2/F4 — supervise() 健康檢查必須同步
- 不要 fire-and-forget 背景 goroutine 跑
waitForHealthy。 - 崩潰循環中 fire-and-forget 會堆積 goroutine(最多 30s × 連環重啟次數)並以最短 backoff 連環重啟。
F3 — 測試必須驗證正確性,不僅時序
- assert
m.running=true - assert
m.cmd != nil - assert
process.Signal(syscall.Signal(0))成功(process 真的活著) - assert
Stop()後m.cmd == nil且 process 退出 - 不允許只測「Start() 在 N 秒內返回」。
F9 port 探測測試標準
- 必須用真實
net.Listen佔位(透過 package-levelproxyListenPortvar;測試用withFreeEphemeralPort(t)/bindEphemeralPort(t, handler)取得 OS-assigned ephemeral port 並覆寫proxyListenPort),不可用 mock。 - Production
proxyListenPort預設值仍為 18081(manager.go:74),不變。 - ephemeral port 永遠可用,無需
t.Skip;若 ephemeral bind 失敗(極罕見,僅在 fd 耗盡時)視為環境異常t.Fatal。 - 涵蓋:Free → spawn、Healthy(bind + /health=200)→ 跳過 spawn 且
m.running=false、Foreign(bind + /health=404)→ error 含"port %d"(用proxyListenPort)/"foreign"/"kill"。 - PR #943 變更:fubon-proxy 的
/health端點改為快速 process-only check(不再呼叫上游 Fubon API,永遠回 200 只要 FastAPI 活著)。此變更強化 ProcessManager 的偵測準確度 — 不再因上游故障將活的 proxy 誤判為 unhealthy。 lookupPortOccupant單元測試可在 lsof 不可用時 skip。
由於
proxyListenPort是 package-level var,這些 helper 不可 用於t.Parallel()測試(race)。
F5 — waitForHealthy 必須尊重 ctx.Deadline()
- deadline 採
min(startupTimeout, ctx.Deadline())。 - sleep 必須用
select { <-ctx.Done(): ... <-time.After(...): }包裝,讓取消能立即生效。
F6 — 重啟路徑必須在鎖內 snapshot m.ctx
- 重啟路徑讀取共享狀態需在鎖內以避免與
Stop()競爭。 m.ctx.Done()在select中讀取為例外(stdlib 慣例,安全)。
F7 — 不用 defer 解鎖 + Start 錯誤路徑清理
- supervisor 邏輯使用 lock-check-unlock-work-lock pattern。
exec.Cmd.Start()必須在鎖外執行,否則 panic 會永久卡死 mutex。Start()錯誤路徑必須關閉m.done並重置 ctx/cancel/done,避免Stop()永久阻塞(no-supervise edge case)。
F8 — doc.go 必須與 code 同步
- supervisor decouple fix 後
Start()是非同步的;package doc 必須反映此行為。
F9 — Start() 啟動前 port 探測
- 在
scriptPath與IsHealthy檢查之間,必須以 3-state switch 取代單一IsHealthy()早退:portStateFree→ fall through to spawnportStateHealthy→ 視為外部已管理,跳過 spawnportStateForeign→ 回傳 actionable error(含 PID 與kill指令)
- Probe 用
net.Listen而非net.Dial避免 side effect。 - probe 僅在
Start()進行一次,不在supervise()重啟路徑中重複。
F9 zombie auto-kill
isFubonZombie比對 lsof command name 是否含"python"或"uvicorn"。- 非 Python 程序(java, nginx, node, go, sh 等)不會被 auto-kill,直接回傳 actionable error。
killOccupant二段式:SIGTERM → 等待 1 秒 →signal(0)檢查 → 若仍在則 SIGKILL。killOccupant不回傳 process 資源給呼叫端,由 caller 自行cmd.Wait()收屍。- macOS zombie 陷阱:被 SIGKILL 的行程可能變成 zombie,
syscall.Kill(pid, 0)對 zombie 仍回nil,必須用cmd.Wait()真正回收。 - auto-kill 後立即 re-probe:Free → spawn;Healthy → 跳過;Foreign → 回傳 "still held after auto-kill"。
- re-probe 失敗:log warn + fall through to spawn。
Stop() 取消路徑(#500)
cancel()會透過exec.CommandContext立即 SIGKILL cmd。- 後續的 SIGINT 與 5s
gracefulShutdownTimeout為雙重安全網,正常情況下不會觸發。 - 若需要 SIGINT-based 優雅關閉,需改用
exec.Command並把 cancel 路徑改為 SIGINT → wait → SIGKILL 三段式。 TestProcessManager_Stop_SIGINTGracefulThenSIGKILL驗證實際 cancel-based kill 路徑。
Backoff 觸發條件(#500)
restartBackoffDelay(10s) 僅在cmd.Start()失敗時生效(supervise()重啟路徑中newCmd.Start()回傳 error)。- 連環 crash 場景:程式崩潰後若 health check 持續失敗,backoff 維持 3s(
restartInitialDelay),並被 30swaitForHealthytimeout 阻塞主導。整體重啟頻率約 33s 一次。 TestProcessManager_BackoffStateMachine_3sThen10s簡化為只測 1st→2nd 的 3s gap,並在 docstring 記錄 2nd→3rd 為何不可觀察。
驗證規則
- 修改 supervisor 邏輯後重新檢視 F1~F8
- 修改
Start()pre-flight 後重新檢視 F9 - 跑
go test -race -count=1 ./internal/fubonproxy/ - 跑
go vet ./internal/fubonproxy/+staticcheck ./internal/fubonproxy/ - 若改介面:更新
internal/fubonproxy/doc.go - 更新
internal/fubonproxy/AGENTS.md與本 skill 保持同步
相關技能
| 技能 | 關聯 |
|---|---|
atlas-pre-change-protocol |
修改 internal/fubonproxy/ 前必須執行 7 步檢查 |
版本歷史
| 版本 | 日期 | 變更 |
|---|---|---|
| 1.0 | 2026-06-26 | 從 internal/fubonproxy/AGENTS.md 抽出 F1~F9 不變式為獨立 skill |