jim800121chen ff9bbc81ed feat(local-tool): M9-4.5 — server SIGTERM + Wails OnBeforeClose firmware-aware shutdown
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>
2026-05-25 15:07:29 +08:00

213 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package firmware
// shutdown.go — M9-4.5firmware-aware graceful shutdown helperTDD §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 taskservice 層 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.HubBroadcastToRoom。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已具備此三個 methodtest 用 fake。
type FirmwareLifecycle interface {
HasActiveTask() bool
GetActiveTaskInfo() []*ActiveTaskInfo
RequestShutdown()
WaitForActiveTasks(maxWait time.Duration) bool
}
// ShutdownLogger 是 helper 用來印 log 的介面(避免硬綁特定 logger 實作)。
// 實作方server pkg/logger.Logger具備 Info / Warntest 可省略(傳 nil
type ShutdownLogger interface {
Info(format string, args ...interface{})
Warn(format string, args ...interface{})
}
// shutdownBroadcastTask 是 broadcast 給 WS client 的最小 task 描述、刻意比
// ActiveTaskInfo 少 StartTs 欄位(第 1 輪 Reviewer Minor-3StartTs 是 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 boundTDD §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=falsetimeout、強制走、log warning
//
// caller 應在 return 後接著走原本的 shutdownFnkill httpServer / stop services
// 不論 cleanShutdown true / false 都要走。cleanShutdown 只是給 log / 觀測用。
//
// notifier / logger 可為 nilnotifier=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 整個 waitM9-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 過濾掉 StartTstime.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
}