package firmware // shutdown.go — M9-4.5:firmware-aware graceful shutdown helper(TDD §8.6.1 + §8.6.3) // // 為什麼分離到獨立檔:main.go 的 SIGTERM goroutine 是 inline closure、難測。把 // firmware 拒絕 graceful shutdown 的核心邏輯(檢查 active task → broadcast → // 等到結束或 timeout)提到本 helper、用 ShutdownAwareness interface 抽象 ws hub、 // main.go 只負責 wire 起來。test 可以直接 mock interface 跑邏輯。 // // 不在這層管的事: // 1. 真正 kill httpServer / inferenceSvc.StopAll() — 那是 main.go 既有 shutdownFn // 的職責、本 helper return 後由 caller 自己呼叫 // 2. SIGTERM 訊號本身的 wiring — main.go 既有 signal.Notify 機制保留不動 // // 設計取捨: // - hard upper bound 220 秒:KL720 升級 worst case 是 200 秒(TDD §3.4 / §10.1 // R-FW-4 + UpgradeTimeoutFor 為 200s)、service 層 ctx timeout 是 // 200+30 = 230 秒(service.go:159 +30s margin 給 verify 階段)。本 helper // 設 220 秒、覆蓋 KL720 200s upgrade timeout + 20s buffer 給最後一段 // verify / cleanup、再 fallthrough 強制走 shutdown。 // // 為什麼不直接設 230s 或更高:超過 220s 仍卡住意味著 device 已 brick、 // 繼續等也救不回來、應該強制走 shutdown 釋放 Wails 視窗端的使用者體驗。 // 220s 是「給 KL720 完整跑完的合理上限」+「不讓使用者無限等」的折衷。 // // 第 1 輪 Reviewer Minor-1:原本 180s 比 KL720 worst case 200s 還短、 // 會在 KL720 第 180s-199s 區間誤殺正常 task、邏輯不對。修正為 220s。 // - 不主動 cancel task:service 層 RequestShutdown 只設旗標(拒絕新 task)、 // 既有 task 讓它自然跑完。本 helper 也不呼叫 Task.cancel — 中斷 firmware // 寫入是「使用者明確強制關閉」才應該發生(Wails ConfirmForceClose binding)、 // server SIGTERM handler 不該主動造成 brick。 import ( "context" "time" ) // ShutdownNotifier 是 firmware-aware shutdown helper 用來廣播 // shutdown-pending 給 WebSocket client 的最小介面。 // // 實作方:ws.Hub(BroadcastToRoom)。test 用 mock 實作。 // // 為什麼不直接拉 ws.Hub 進來:firmware 包不該依賴 ws 包(避免循環依賴 // 隱患 + 測試難度)。最小介面讓 main.go 在 wire 時提供 adapter 即可。 type ShutdownNotifier interface { BroadcastToRoom(room string, data interface{}) } // FirmwareLifecycle 是 helper 對 firmware service 的依賴介面(避免 helper // 直接拿到完整 *Service、test 也好寫)。 // // 實作方:*Service(已具備此三個 method);test 用 fake。 type FirmwareLifecycle interface { HasActiveTask() bool GetActiveTaskInfo() []*ActiveTaskInfo RequestShutdown() WaitForActiveTasks(maxWait time.Duration) bool } // ShutdownLogger 是 helper 用來印 log 的介面(避免硬綁特定 logger 實作)。 // 實作方:server pkg/logger.Logger(具備 Info / Warn);test 可省略(傳 nil)。 type ShutdownLogger interface { Info(format string, args ...interface{}) Warn(format string, args ...interface{}) } // shutdownBroadcastTask 是 broadcast 給 WS client 的最小 task 描述、刻意比 // ActiveTaskInfo 少 StartTs 欄位(第 1 輪 Reviewer Minor-3:StartTs 是 server // 本機時間戳、不該對 client 暴露、frontend 已有 ElapsedMs 可用、StartTs 屬冗餘 // 資訊 + 可能被 metadata-leak / timing attack 利用)。 // // 對齊 visiona-local/query_firmware_active_tasks.go:39-48 FirmwareActiveTaskSummary // 七欄結構(Wails 端 query 過濾 startTs 已正確、本 struct 補齊 server 直接 // broadcast 路徑)。 type shutdownBroadcastTask struct { TaskID string `json:"taskId"` DeviceID string `json:"deviceId"` DeviceName string `json:"deviceName,omitempty"` Chip string `json:"chip"` Direction string `json:"direction"` Stage string `json:"stage"` ElapsedMs int64 `json:"elapsedMs"` EtaSeconds int `json:"etaSeconds,omitempty"` } // toBroadcastTasks 把 ActiveTaskInfo slice 轉成 broadcast 用的 minimal 結構 // (過濾掉 StartTs)。回傳值永不為 nil(避免 frontend JSON.parse 後拿到 null)。 func toBroadcastTasks(infos []*ActiveTaskInfo) []shutdownBroadcastTask { out := make([]shutdownBroadcastTask, 0, len(infos)) for _, info := range infos { if info == nil { continue } out = append(out, shutdownBroadcastTask{ TaskID: info.TaskID, DeviceID: info.DeviceID, DeviceName: info.DeviceName, Chip: info.Chip, Direction: info.Direction, Stage: info.Stage, ElapsedMs: info.ElapsedMs, EtaSeconds: info.EtaSeconds, }) } return out } // shutdownRoom / shutdownEventType 是 WS broadcast 對齊既有 `system` room // + `server:shutdown-imminent` event 命名 pattern(見 // server/internal/api/handlers/system_handler.go ShutdownNotify)。 const ( // ShutdownRoom 是 broadcast 用的 WebSocket room、沿用既有 `system` room // 命名(system_ws.go 已建立、瀏覽器 tab 訂閱在這個 room)。 ShutdownRoom = "system" // ShutdownEventTypePending 是 server SIGTERM 收到後、若有 active firmware // task 廣播給前端的事件型別(TDD §8.6.3)。前端收到後可選擇顯示「server // 等韌體跑完才會關」的提示 banner。 // // 注意:這跟 R5-2 既有的 `server:shutdown-imminent`(出現在「server 真的 // 要關了」)不同層級——pending 表示「server 收到了 SIGTERM 但延後執行」、 // imminent 表示「真的要關了、tab 顯示 offline overlay」。當 firmware task // 結束、helper 真正放 shutdown 時、既有 ShutdownNotify 機制會再廣播一次 // `server:shutdown-imminent`、兩個事件各司其職。 ShutdownEventTypePending = "server:shutdown-pending" ) // MaxShutdownWait 是 firmware-aware shutdown 的 hard upper bound(TDD §8.6.1)。 // 變數化以便測試注入。 // // 220s = KL720 worst-case upgrade timeout (200s, types.go:115-119 UpgradeTimeoutFor) // - 20s buffer 給 verify / cleanup 階段 // // 與 service.go:159 的 chipTimeout+30s margin 對齊但稍小(service 層 ctx 是雙保險、 // helper 層作為「Wails 端使用者體驗上限」、超過 220s 必強制走、不再等下去)。 var MaxShutdownWait = 220 * time.Second // AwaitActiveTasksOrTimeout 是 firmware-aware graceful shutdown 的核心 helper // (TDD §8.6.1 + §8.6.3)。 // // 行為: // // 1. 查 svc.HasActiveTask() // 2. 沒 active task → 立刻 return cleanShutdown=true、不廣播 // 3. 有 active task → 廣播 `server:shutdown-pending` 到 `"system"` room // (payload: {type, tasks}、tasks 是 GetActiveTaskInfo() 結果) // 4. 呼叫 svc.RequestShutdown() 拒絕新 task // 5. 呼叫 svc.WaitForActiveTasks(MaxShutdownWait) 等所有 task 結束 // 6. WaitForActiveTasks 回 true → cleanShutdown=true(乾淨等到) // 回 false → cleanShutdown=false(timeout、強制走、log warning) // // caller 應在 return 後接著走原本的 shutdownFn(kill httpServer / stop services)、 // 不論 cleanShutdown true / false 都要走。cleanShutdown 只是給 log / 觀測用。 // // notifier / logger 可為 nil:notifier=nil → 不廣播(仍正常等 task); // logger=nil → 不印 log。 // // ctx 是 caller 的上層 context(通常 background)、目前未使用,預留給未來 // caller 可能要 cancel 整個 wait(例如使用者點「強制關閉」反悔)。 func AwaitActiveTasksOrTimeout( ctx context.Context, svc FirmwareLifecycle, notifier ShutdownNotifier, logger ShutdownLogger, ) (cleanShutdown bool) { _ = ctx // 預留給未來 caller 要 cancel 整個 wait(M9-13 / B 階段如有需求) if svc == nil { // 防呆:service 沒 wire 起來、視為「沒 active task」乾淨關 if logger != nil { logger.Warn("firmware: shutdown helper called with nil service, treating as clean shutdown") } return true } if !svc.HasActiveTask() { // 沒韌體 task 在跑、走原本 graceful shutdown 流程 if logger != nil { logger.Info("firmware: no active firmware task, proceeding with normal shutdown") } return true } // 有 active task:廣播 → request shutdown → 等 tasks := svc.GetActiveTaskInfo() if logger != nil { logger.Info("firmware: %d active firmware task(s) detected, delaying shutdown up to %v", len(tasks), MaxShutdownWait) } if notifier != nil { // Minor-3 修正:broadcast payload 過濾掉 StartTs(time.Time 會序列化成 // ISO8601 server 本機時間戳、屬不該洩漏給 WS client 的內部資訊)、 // 改傳 minimal shutdownBroadcastTask(七欄、frontend 用 ElapsedMs 而非 // StartTs 計算 etaSeconds)。 notifier.BroadcastToRoom(ShutdownRoom, map[string]interface{}{ "type": ShutdownEventTypePending, "tasks": toBroadcastTasks(tasks), }) } svc.RequestShutdown() clean := svc.WaitForActiveTasks(MaxShutdownWait) if logger != nil { if clean { logger.Info("firmware: all active firmware tasks finished, proceeding with shutdown") } else { logger.Warn("firmware: hard timeout %v reached while waiting for firmware tasks, forcing shutdown (device may be in unknown state)", MaxShutdownWait) } } return clean }