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>
245 lines
9.5 KiB
Go
245 lines
9.5 KiB
Go
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 true(prevent close)。前端 modal(Design §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.appLog;test 用 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 false(server 沒起來、沒韌體任務可擋)
|
||
// 3. queryFirmwareTasks 回 hasActive=false 或錯誤 → return false(fail-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 task:emit 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 提供 state;Wails 在 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
|
||
}
|