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

112 lines
4.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 main
// query_firmware_active_tasks.go — M9-4.5firmware-aware close handler helper
//
// Wails 控制台收到使用者關閉視窗請求時、必須先查 server 是否有韌體升降版進
// 行中(會寫 flash 的破壞性操作、中斷會 brick。本 helper 提供小型 HTTP 客戶
// 端、打 `GET /api/firmware/active-tasks`M9-3 端點)、解析 hasActive +
// tasks 給 OnBeforeClose 做判斷。
//
// 設計原則(仿 shutdown_notify.go
// - 1 秒 timeoutserver 沒起來 / 卡死 / 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
}