A 階段尾端 milestone、雙層防護避免使用者在 firmware 升級進行中關閉 app 造成 dongle brick。 Server 端 (3 改): - main.go: SIGTERM/SIGINT goroutine 加 firmware-aware preamble - server/internal/firmware/shutdown.go: 新 211 行(AwaitActiveTasksOrTimeout + 3 interfaces + shutdownBroadcastTask minimal struct + toBroadcastTasks helper) - server/internal/firmware/shutdown_test.go: 新 384 行、8 tests Wails 端 (3 新 + 2 改): - visiona-local/main.go: OnBeforeClose 從 inline → app.OnBeforeClose - visiona-local/app.go: App struct 加 firmwareCloseGuard - visiona-local/firmware_close_guard.go: 新 244 行(CloseGuard + OnBeforeClose + ConfirmForceClose) - visiona-local/firmware_close_guard_test.go: 新 280 行、8 tests - visiona-local/query_firmware_active_tasks.go: 新 111 行(HTTP helper、fail-open、1s timeout) - visiona-local/query_firmware_active_tasks_test.go: 新 250 行、7 tests 行為: - Server SIGTERM 有 active task → broadcast `server:shutdown-pending` to "system" room → RequestShutdown + WaitForActiveTasks(220s) → 走原本 shutdownFn - Wails OnBeforeClose 有 active task → emit Wails event `app:firmware-in-progress` + return true 擋住關閉 - ConfirmForceClose binding 給 frontend 第二層 FORCE 確認用、走 graceful 7+1s shutdown(不是 SIGKILL bypass、雙層防護) Reviewer 兩輪審查: - Round 1: 0 Critical / 1 Major / 3 Minor / 4 Suggestion - 第 2 輪修法(3 sub-agent 平行): - Architect: TDD §8.6 改 event 名 `firmware:shutdown-rejected` → `server:shutdown-pending`、標題「拒絕」→「延遲」、補 payload schema 註明 tasks 不含 startTs - Design: control-panel.md §6a 改「SIGKILL bypass」→「graceful 7+1s 雙層防護」、補「為何不採 SIGKILL」5 點設計理由、§6a.11 IPC 規格對齊 - Backend: MaxShutdownWait 180s → 220s(KL720 200s upgrade + 20s buffer)+ broadcast 過濾 startTs(shutdownBroadcastTask minimal struct + toBroadcastTasks helper) 測試: - server: go test ./... -race 全綠(firmware 2.7s + api/ws/handlers) - wails: go test ./... -race 全綠(visiona-local 11.2s、21 tests) - 合計新增 23 unit tests race-clean、0 regression 下一步: M9-5 三平台實機驗證 + 順手修 MJ3(backend smoke test schema phase→stage / firmware:progress→firmware_progress) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
213 lines
9.2 KiB
Go
213 lines
9.2 KiB
Go
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
|
||
}
|