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>
112 lines
4.2 KiB
Go
112 lines
4.2 KiB
Go
package main
|
||
|
||
// query_firmware_active_tasks.go — M9-4.5:firmware-aware close handler helper
|
||
//
|
||
// Wails 控制台收到使用者關閉視窗請求時、必須先查 server 是否有韌體升降版進
|
||
// 行中(會寫 flash 的破壞性操作、中斷會 brick)。本 helper 提供小型 HTTP 客戶
|
||
// 端、打 `GET /api/firmware/active-tasks`(M9-3 端點)、解析 hasActive +
|
||
// tasks 給 OnBeforeClose 做判斷。
|
||
//
|
||
// 設計原則(仿 shutdown_notify.go):
|
||
// - 1 秒 timeout:server 沒起來 / 卡死 / network error 全視為「無 active task」
|
||
// 不擋關閉、避免使用者卡在「關不掉的視窗」
|
||
// - port <= 0 → server 還沒起來、直接視為「無 active task」乾淨關
|
||
// - 任何錯誤、不擋關閉路徑(fail-open):寧可錯放也不卡使用者
|
||
// - 變數化 client / timeout 以便測試注入
|
||
//
|
||
// 為什麼 fail-open:誤判「沒 active task」最壞情況是進到既有 OnShutdown
|
||
// 的 ctrl.Stop() → server SIGTERM handler、那邊 firmware.AwaitActiveTasksOrTimeout
|
||
// 還會再擋一層;誤判「有 active task」要使用者強制關閉 = 體驗很差。雙層
|
||
// 防護下 Wails 這層 fail-open 是合理取捨。
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"time"
|
||
)
|
||
|
||
// 變數化以便測試注入。
|
||
var (
|
||
queryFirmwareTasksTimeout = 1 * time.Second
|
||
queryFirmwareTasksClient = &http.Client{Timeout: queryFirmwareTasksTimeout}
|
||
)
|
||
|
||
// FirmwareActiveTaskSummary 是 OnBeforeClose 用來顯示 modal 所需的最小欄位。
|
||
// 對齊 server 端 firmware.ActiveTaskInfo 的 JSON 欄位(snake → camel 由 server
|
||
// 端 jsonTag 統一)。
|
||
type FirmwareActiveTaskSummary 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"`
|
||
}
|
||
|
||
// FirmwareActiveTasksResponse 對應 server 端 GET /api/firmware/active-tasks
|
||
// 回應的 `data` 欄位(server 包裝 {success, data:{hasActive, tasks}}、本 struct
|
||
// 只描述 data 內層)。
|
||
type FirmwareActiveTasksResponse struct {
|
||
HasActive bool `json:"hasActive"`
|
||
Tasks []FirmwareActiveTaskSummary `json:"tasks"`
|
||
}
|
||
|
||
// queryFirmwareActiveTasks 打 GET /api/firmware/active-tasks、回傳 hasActive
|
||
// + 任務清單。
|
||
//
|
||
// 錯誤路徑(fail-open):任何錯誤回傳 hasActive=false + 空 tasks + 該 error、
|
||
// caller 可選擇 log(不強制)、但**不**應該據此擋關閉。
|
||
//
|
||
// 參數:
|
||
//
|
||
// ctx — caller context(會被 timeout 包裹)
|
||
// port — server 正在聽的 port;<= 0 代表 server 沒起來、直接回 hasActive=false
|
||
//
|
||
// 回傳 error 主要供 caller 寫 log debug 用;正常 fail-open 邏輯不檢查 error。
|
||
func queryFirmwareActiveTasks(ctx context.Context, port int) (FirmwareActiveTasksResponse, error) {
|
||
empty := FirmwareActiveTasksResponse{HasActive: false, Tasks: nil}
|
||
if port <= 0 {
|
||
return empty, fmt.Errorf("server port not available")
|
||
}
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
reqCtx, cancel := context.WithTimeout(ctx, queryFirmwareTasksTimeout)
|
||
defer cancel()
|
||
|
||
url := fmt.Sprintf("http://127.0.0.1:%d/api/firmware/active-tasks", port)
|
||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil)
|
||
if err != nil {
|
||
return empty, fmt.Errorf("build request: %w", err)
|
||
}
|
||
resp, err := queryFirmwareTasksClient.Do(req)
|
||
if err != nil {
|
||
return empty, fmt.Errorf("http get: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return empty, fmt.Errorf("unexpected status %d", resp.StatusCode)
|
||
}
|
||
|
||
// server 包了一層 {success, data:{...}}、本 helper 只取 data
|
||
var wrapper struct {
|
||
Success bool `json:"success"`
|
||
Data FirmwareActiveTasksResponse `json:"data"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&wrapper); err != nil {
|
||
return empty, fmt.Errorf("decode response: %w", err)
|
||
}
|
||
if !wrapper.Success {
|
||
return empty, fmt.Errorf("server returned success=false")
|
||
}
|
||
if wrapper.Data.Tasks == nil {
|
||
wrapper.Data.Tasks = []FirmwareActiveTaskSummary{}
|
||
}
|
||
return wrapper.Data, nil
|
||
}
|