visionA/local-tool/visiona-local/firmware_close_guard.go
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

245 lines
9.5 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 main
// firmware_close_guard.go — M9-4.5 (TDD §8.6.2)Wails 控制台關閉攔截
//
// 為什麼有這個檔:
// R5-2 設定「關 Wails 視窗 = 結束 server」、預設 OnBeforeClose return false
// 讓 Wails 進 OnShutdown 流程;但韌體升降版進行中(會寫 flash中斷 = device
// brick。本檔提供 firmware-aware close guard、在 close 動作前查 server 是否
// 有 active firmware task
//
// 1. 有 active task → emit Wails event `app:firmware-in-progress` 給前端
// modal、return trueprevent close。前端 modalDesign §6a顯示
// 進度 / ETA / 「繼續等待」/「強制關閉」按鈕。
// 2. 沒 active task → return false、走原本 R5-2 流程
//
// 前端「強制關閉」按鈕(經 §6a.5 第二層 FORCE 確認後)會呼叫 App 的
// `ConfirmForceClose` binding、binding 內 set forceCloseAccepted=true、再
// 觸發 Wails Quit、下次 OnBeforeClose 直接 return false 放行。
//
// 為什麼用 Wails event 而不是 native dialog
// Design §6a 規格要求 modal 與前端設計系統一致color tokens / focus
// management / i18n / 二層 FORCE 確認、Wails native dialog 風格不一致也
// 無法做二層確認字串輸入。改用前端 modal、Wails 端只負責 emit event +
// 接收 binding 回應。
import (
"context"
"fmt"
"sync"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// firmwareInProgressEvent 是 emit 給前端的事件型別(對齊 TDD §8.6.2)。
// 前端訂閱此事件、收到後渲染 Design §6a 的攔截 modal。
const firmwareInProgressEvent = "app:firmware-in-progress"
// FirmwareCloseGuard 封裝關閉攔截邏輯(含 forceCloseAccepted 狀態)。
// 嵌進 App 的 method 是 OnBeforeClose / ConfirmForceClose 兩個 wrapper。
//
// 拆出獨立 struct 是為了:(1) 測試友善(可不靠 App 全域 state(2)
// thread-safe — Wails OnBeforeClose 由 main thread 呼叫、ConfirmForceClose
// 由 binding goroutine 呼叫、必須 mu 保護。
type FirmwareCloseGuard struct {
mu sync.Mutex
// forceCloseAccepted使用者已通過 Design §6a 第二層 FORCE 確認、下次
// OnBeforeClose 被叫到時直接放行。confirmForceClose() 設、放行後自動清。
forceCloseAccepted bool
}
// NewFirmwareCloseGuard 建空 guard。
func NewFirmwareCloseGuard() *FirmwareCloseGuard {
return &FirmwareCloseGuard{}
}
// ConfirmForceClose 由前端「強制關閉」按鈕§6a.5 第二層 FORCE 確認後)呼叫。
// 設旗標、下次 OnBeforeClose 直接 return false 放行。
func (g *FirmwareCloseGuard) ConfirmForceClose() {
g.mu.Lock()
g.forceCloseAccepted = true
g.mu.Unlock()
}
// consumeForceCloseAccepted 取出旗標(讀完即清)、給 OnBeforeClose 判斷用。
// 抽出 method 方便測試直接觀察狀態。
func (g *FirmwareCloseGuard) consumeForceCloseAccepted() bool {
g.mu.Lock()
defer g.mu.Unlock()
if g.forceCloseAccepted {
g.forceCloseAccepted = false
return true
}
return false
}
// CloseGuardDeps 是 evaluateClose 對外部依賴的介面(測試友善)。
//
// 實作方production = App透過 GetServerStatus 拿 port + emit event 用 ctx
// test = fake 提供確定的 port + 觀察 emit。
type CloseGuardDeps interface {
// ServerPort 回傳 server 目前 listen 的 port、<=0 表示 server 沒起來。
ServerPort() int
// QueryFirmwareTasks 查詢 server 是否有 active firmware task。
// production 直接打到 queryFirmwareActiveTasks(ctx, port)。
QueryFirmwareTasks(ctx context.Context, port int) (FirmwareActiveTasksResponse, error)
// EmitFirmwareInProgress 通知前端「韌體進行中、不能關」。
// production = wailsRuntime.EventsEmit(ctx, firmwareInProgressEvent, payload)。
EmitFirmwareInProgress(payload map[string]interface{})
// AppLog 寫 wails.log。production = App.appLogtest 用 no-op。
AppLog(format string, args ...interface{})
}
// evaluateClose 是 OnBeforeClose 的核心邏輯(不直接接 Wails ctx、用 CloseGuardDeps
// 抽象出來方便測試)。
//
// 回傳 prevent=true → Wails 應該擋住關閉(保留視窗);
// 回傳 prevent=false → 走原本 R5-2 流程OnShutdown → ctrl.Stop → 結束 server
//
// 邏輯流程:
//
// 1. forceCloseAccepted=true → 重置 + return false 放行(使用者已通過二層確認)
// 2. ServerPort()<=0 → return falseserver 沒起來、沒韌體任務可擋)
// 3. queryFirmwareTasks 回 hasActive=false 或錯誤 → return falsefail-open
// 4. hasActive=true → emit `app:firmware-in-progress` event + return true
func (g *FirmwareCloseGuard) evaluateClose(ctx context.Context, deps CloseGuardDeps) bool {
if deps == nil {
// 防呆deps 沒注入、不擋(避免使用者卡住)
return false
}
// 1. 使用者已通過二層確認、放行
if g.consumeForceCloseAccepted() {
deps.AppLog("close-guard: force-close confirmed by user, allowing close")
return false
}
port := deps.ServerPort()
if port <= 0 {
// server 沒起來、沒韌體任務可擋
deps.AppLog("close-guard: server not running (port=%d), allowing close", port)
return false
}
// 2. 查 server firmware active task
res, err := deps.QueryFirmwareTasks(ctx, port)
if err != nil {
// fail-open查不到狀態不擋server 端 SIGTERM handler 還會擋一層)
deps.AppLog("close-guard: query firmware tasks failed (fail-open allow close): %v", err)
return false
}
if !res.HasActive {
deps.AppLog("close-guard: no active firmware task, allowing close")
return false
}
// 3. 有 active taskemit event 通知前端 modal、prevent close
tasksAny := make([]interface{}, 0, len(res.Tasks))
for _, t := range res.Tasks {
tasksAny = append(tasksAny, map[string]interface{}{
"taskId": t.TaskID,
"deviceId": t.DeviceID,
"deviceName": t.DeviceName,
"chip": t.Chip,
"direction": t.Direction,
"stage": t.Stage,
"elapsedMs": t.ElapsedMs,
"etaSeconds": t.EtaSeconds,
})
}
payload := map[string]interface{}{
"hasActive": true,
"tasks": tasksAny,
}
deps.EmitFirmwareInProgress(payload)
deps.AppLog("close-guard: %d active firmware task(s) detected, preventing close + emitted %s event", len(res.Tasks), firmwareInProgressEvent)
return true
}
// ──────────────────────────────────────────────────────────────────────
// App 層 wiring將 App 的 method 適配進 CloseGuardDeps
// ──────────────────────────────────────────────────────────────────────
// appCloseGuardDeps 將 *App 包裝成 CloseGuardDeps。production wire-up。
type appCloseGuardDeps struct {
app *App
}
func (a *appCloseGuardDeps) ServerPort() int {
if a.app == nil {
return 0
}
st := a.app.GetServerStatus()
if !st.Running {
return 0
}
return st.Port
}
func (a *appCloseGuardDeps) QueryFirmwareTasks(ctx context.Context, port int) (FirmwareActiveTasksResponse, error) {
return queryFirmwareActiveTasks(ctx, port)
}
func (a *appCloseGuardDeps) EmitFirmwareInProgress(payload map[string]interface{}) {
if a.app == nil || a.app.ctx == nil {
return
}
wailsRuntime.EventsEmit(a.app.ctx, firmwareInProgressEvent, payload)
}
func (a *appCloseGuardDeps) AppLog(format string, args ...interface{}) {
if a.app == nil {
return
}
a.app.appLog(format, args...)
}
// OnBeforeClose 是給 Wails options.App.OnBeforeClose 用的 callback。
// M9-4.5 之前:永遠 return false。
// M9-4.5 之後firmware-aware見 evaluateClose
//
// 由 App 持有的 firmwareCloseGuard 提供 stateWails 在 main thread 呼叫、
// CloseGuard 的 mu 保護 ConfirmForceClose 與 OnBeforeClose 的併發。
func (a *App) OnBeforeClose(ctx context.Context) bool {
if a.firmwareCloseGuard == nil {
// 防呆guard 沒初始化(極早期啟動 / test 直接 new App→ 走預設不擋
return false
}
return a.firmwareCloseGuard.evaluateClose(ctx, &appCloseGuardDeps{app: a})
}
// ConfirmForceClose 是 Wails binding、給前端 Design §6a 第二層 FORCE 確認
// modal 的「確認強制關閉」按鈕呼叫。
//
// 行為:
// 1. 設 forceCloseAccepted=true
// 2. 立刻呼叫 wailsRuntime.Quit(a.ctx) 觸發關閉流程
// 3. Wails 之後叫 OnBeforeClose → 看到旗標 → 直接放行 → 走 OnShutdown
// → ctrl.Stop() 既有 7+1s graceful shutdown
//
// 為什麼不直接 ForceKill使用者「確認強制關閉」的意圖是「我知道風險、繼續
// 走、但仍想優雅關閉 server 業務(會 broadcast offline overlay 給其他 tab」、
// graceful shutdown 流程比 SIGKILL 更友善。server 端 firmware-aware SIGTERM
// handler 收到 SIGTERM 後 ${firmware.MaxShutdownWait}s 內仍會等 firmware task、
// 真的卡死才強制走 — 屬於雙層防護的合理取捨。
//
// 回傳 error 給 binding caller 觀察(目前永遠 nil、預留未來擴展
func (a *App) ConfirmForceClose() error {
if a.firmwareCloseGuard == nil {
return fmt.Errorf("firmware close guard not initialized")
}
a.firmwareCloseGuard.ConfirmForceClose()
a.appLog("ConfirmForceClose: user accepted force close, scheduling Wails Quit")
if a.ctx != nil {
// 用 goroutine 避免 binding caller 阻塞、且讓 Wails event loop 自由
// 處理 close 流程OnBeforeClose 會被再叫一次、看到旗標放行)
go wailsRuntime.Quit(a.ctx)
}
return nil
}