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>
281 lines
11 KiB
Go
281 lines
11 KiB
Go
package main
|
||
|
||
// firmware_close_guard_test.go — M9-4.5:Wails OnBeforeClose firmware-aware 攔截邏輯測試
|
||
//
|
||
// 覆蓋情境:
|
||
// 1. forceCloseAccepted=true → 放行(return false)、旗標被清掉
|
||
// 2. server port=0 → 放行
|
||
// 3. queryFirmwareTasks 失敗 → fail-open 放行
|
||
// 4. hasActive=false → 放行、不 emit event
|
||
// 5. hasActive=true → emit event + return true(prevent)、payload schema 正確
|
||
// 6. ConfirmForceClose → 設旗標 + 下次 evaluateClose 放行
|
||
// 7. nil deps → 防呆放行
|
||
// 8. concurrent ConfirmForceClose 與 evaluateClose → race-free
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
)
|
||
|
||
// fakeCloseGuardDeps 是 CloseGuardDeps 的 test double。
|
||
type fakeCloseGuardDeps struct {
|
||
mu sync.Mutex
|
||
|
||
port int
|
||
queryResp FirmwareActiveTasksResponse
|
||
queryErr error
|
||
queryCalls int
|
||
emitCalls int
|
||
lastEmitPayload map[string]interface{}
|
||
logLines []string
|
||
}
|
||
|
||
func (f *fakeCloseGuardDeps) ServerPort() int {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
return f.port
|
||
}
|
||
|
||
func (f *fakeCloseGuardDeps) QueryFirmwareTasks(ctx context.Context, port int) (FirmwareActiveTasksResponse, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
f.queryCalls++
|
||
return f.queryResp, f.queryErr
|
||
}
|
||
|
||
func (f *fakeCloseGuardDeps) EmitFirmwareInProgress(payload map[string]interface{}) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
f.emitCalls++
|
||
f.lastEmitPayload = payload
|
||
}
|
||
|
||
func (f *fakeCloseGuardDeps) AppLog(format string, args ...interface{}) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
f.logLines = append(f.logLines, format)
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
// 1. forceCloseAccepted=true → 放行、旗標清空
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
|
||
func TestEvaluateClose_ForceAccepted(t *testing.T) {
|
||
g := NewFirmwareCloseGuard()
|
||
g.ConfirmForceClose()
|
||
|
||
deps := &fakeCloseGuardDeps{
|
||
port: 8080,
|
||
queryResp: FirmwareActiveTasksResponse{HasActive: true}, // 即使有 active task 也該放行
|
||
}
|
||
|
||
prevent := g.evaluateClose(context.Background(), deps)
|
||
if prevent {
|
||
t.Errorf("expected prevent=false when force accepted, got true")
|
||
}
|
||
// queryFirmwareTasks 不該被叫(force-close 短路)
|
||
if deps.queryCalls != 0 {
|
||
t.Errorf("expected queryFirmwareTasks NOT called on force accept, got %d", deps.queryCalls)
|
||
}
|
||
if deps.emitCalls != 0 {
|
||
t.Errorf("expected no emit on force accept, got %d", deps.emitCalls)
|
||
}
|
||
|
||
// 旗標應該已被清掉、下次呼叫如沒 active 才放行
|
||
deps.queryResp = FirmwareActiveTasksResponse{HasActive: true, Tasks: []FirmwareActiveTaskSummary{{TaskID: "x"}}}
|
||
prevent2 := g.evaluateClose(context.Background(), deps)
|
||
if !prevent2 {
|
||
t.Errorf("expected prevent=true on 2nd call (flag cleared), got false")
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
// 2. server port<=0 → 放行
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
|
||
func TestEvaluateClose_ServerNotRunning(t *testing.T) {
|
||
g := NewFirmwareCloseGuard()
|
||
deps := &fakeCloseGuardDeps{port: 0}
|
||
prevent := g.evaluateClose(context.Background(), deps)
|
||
if prevent {
|
||
t.Errorf("expected prevent=false when server not running")
|
||
}
|
||
if deps.queryCalls != 0 {
|
||
t.Errorf("expected queryFirmwareTasks NOT called when port=0, got %d", deps.queryCalls)
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
// 3. queryFirmwareTasks 失敗 → fail-open 放行
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
|
||
func TestEvaluateClose_QueryError_FailOpen(t *testing.T) {
|
||
g := NewFirmwareCloseGuard()
|
||
deps := &fakeCloseGuardDeps{
|
||
port: 3721,
|
||
queryErr: errors.New("connection refused"),
|
||
}
|
||
prevent := g.evaluateClose(context.Background(), deps)
|
||
if prevent {
|
||
t.Errorf("expected prevent=false (fail-open) on query error, got true")
|
||
}
|
||
if deps.queryCalls != 1 {
|
||
t.Errorf("expected queryFirmwareTasks called once, got %d", deps.queryCalls)
|
||
}
|
||
if deps.emitCalls != 0 {
|
||
t.Errorf("expected no emit on query error, got %d", deps.emitCalls)
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
// 4. hasActive=false → 放行、不 emit
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
|
||
func TestEvaluateClose_NoActiveTask(t *testing.T) {
|
||
g := NewFirmwareCloseGuard()
|
||
deps := &fakeCloseGuardDeps{
|
||
port: 3721,
|
||
queryResp: FirmwareActiveTasksResponse{HasActive: false, Tasks: []FirmwareActiveTaskSummary{}},
|
||
}
|
||
prevent := g.evaluateClose(context.Background(), deps)
|
||
if prevent {
|
||
t.Errorf("expected prevent=false when no active task")
|
||
}
|
||
if deps.emitCalls != 0 {
|
||
t.Errorf("expected no emit when no active task, got %d", deps.emitCalls)
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
// 5. hasActive=true → emit event + return true、payload schema 正確
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
|
||
func TestEvaluateClose_HasActive_PreventAndEmit(t *testing.T) {
|
||
g := NewFirmwareCloseGuard()
|
||
deps := &fakeCloseGuardDeps{
|
||
port: 3721,
|
||
queryResp: FirmwareActiveTasksResponse{
|
||
HasActive: true,
|
||
Tasks: []FirmwareActiveTaskSummary{
|
||
{
|
||
TaskID: "upgrade-KL520-0",
|
||
DeviceID: "kl520-0",
|
||
DeviceName: "KL520 #1",
|
||
Chip: "KL520",
|
||
Direction: "upgrade",
|
||
Stage: "flashing",
|
||
ElapsedMs: 12000,
|
||
EtaSeconds: 45,
|
||
},
|
||
},
|
||
},
|
||
}
|
||
prevent := g.evaluateClose(context.Background(), deps)
|
||
if !prevent {
|
||
t.Errorf("expected prevent=true on active task")
|
||
}
|
||
if deps.emitCalls != 1 {
|
||
t.Errorf("expected emit called once, got %d", deps.emitCalls)
|
||
}
|
||
// payload schema:hasActive + tasks(slice) 必須存在
|
||
if deps.lastEmitPayload["hasActive"] != true {
|
||
t.Errorf("expected payload.hasActive=true, got %v", deps.lastEmitPayload["hasActive"])
|
||
}
|
||
tasksAny, ok := deps.lastEmitPayload["tasks"].([]interface{})
|
||
if !ok {
|
||
t.Fatalf("expected payload.tasks []interface{}, got %T", deps.lastEmitPayload["tasks"])
|
||
}
|
||
if len(tasksAny) != 1 {
|
||
t.Fatalf("expected 1 task in payload, got %d", len(tasksAny))
|
||
}
|
||
task, ok := tasksAny[0].(map[string]interface{})
|
||
if !ok {
|
||
t.Fatalf("expected task is map, got %T", tasksAny[0])
|
||
}
|
||
// 重要欄位 frontend modal 會用
|
||
if task["taskId"] != "upgrade-KL520-0" {
|
||
t.Errorf("expected taskId, got %v", task["taskId"])
|
||
}
|
||
if task["deviceName"] != "KL520 #1" {
|
||
t.Errorf("expected deviceName, got %v", task["deviceName"])
|
||
}
|
||
if task["chip"] != "KL520" {
|
||
t.Errorf("expected chip, got %v", task["chip"])
|
||
}
|
||
if task["stage"] != "flashing" {
|
||
t.Errorf("expected stage, got %v", task["stage"])
|
||
}
|
||
if task["etaSeconds"] != 45 {
|
||
t.Errorf("expected etaSeconds=45, got %v", task["etaSeconds"])
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
// 6. ConfirmForceClose 設旗標
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
|
||
func TestConfirmForceClose_SetsAndConsumesFlag(t *testing.T) {
|
||
g := NewFirmwareCloseGuard()
|
||
if g.consumeForceCloseAccepted() {
|
||
t.Errorf("expected default flag=false")
|
||
}
|
||
g.ConfirmForceClose()
|
||
if !g.consumeForceCloseAccepted() {
|
||
t.Errorf("expected flag=true after ConfirmForceClose")
|
||
}
|
||
// consume 後旗標清掉
|
||
if g.consumeForceCloseAccepted() {
|
||
t.Errorf("expected flag=false after consume")
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
// 7. nil deps → 防呆放行
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
|
||
func TestEvaluateClose_NilDeps(t *testing.T) {
|
||
g := NewFirmwareCloseGuard()
|
||
prevent := g.evaluateClose(context.Background(), nil)
|
||
if prevent {
|
||
t.Errorf("expected prevent=false with nil deps (defensive)")
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
// 8. concurrent ConfirmForceClose 與 evaluateClose 不 race(-race 模式)
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
|
||
func TestConfirmForceClose_ConcurrentAccess(t *testing.T) {
|
||
g := NewFirmwareCloseGuard()
|
||
deps := &fakeCloseGuardDeps{port: 3721, queryResp: FirmwareActiveTasksResponse{HasActive: false}}
|
||
|
||
const N = 100
|
||
var wg sync.WaitGroup
|
||
var preventCount, allowCount int64
|
||
|
||
for i := 0; i < N; i++ {
|
||
wg.Add(2)
|
||
go func() {
|
||
defer wg.Done()
|
||
g.ConfirmForceClose()
|
||
}()
|
||
go func() {
|
||
defer wg.Done()
|
||
prevent := g.evaluateClose(context.Background(), deps)
|
||
if prevent {
|
||
atomic.AddInt64(&preventCount, 1)
|
||
} else {
|
||
atomic.AddInt64(&allowCount, 1)
|
||
}
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
|
||
// 不檢查具體 prevent/allow 數量(race condition between Confirm + Evaluate
|
||
// 順序不可預期)、只驗 -race 模式沒抓到 race
|
||
t.Logf("concurrent stress: prevent=%d allow=%d (race-free under -race)", preventCount, allowCount)
|
||
}
|