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>
This commit is contained in:
parent
06ff2fe987
commit
ff9bbc81ed
@ -389,7 +389,14 @@ Banner **不可手動關閉**(避免使用者忽略問題)。只有下列條
|
||||
```
|
||||
- 「FORCE」輸入框比對與 firmware-management §6.1 「DOWNGRADE」相同邏輯(嚴格 `===` 比對)
|
||||
- 「確認強制關閉」按鈕在輸入 `FORCE` 前 disabled、輸入正確後變為 `destructive` 紅色
|
||||
- 點擊 → Wails close handler 跳過 server graceful shutdown 流程、直接 kill server process(傳 SIGKILL)+ 關閉視窗
|
||||
- 點擊 → 前端呼叫 Wails binding `App.ConfirmForceClose()`、後端設 `forceCloseAccepted=true` 旗標、再以 goroutine 觸發 `wailsRuntime.Quit(ctx)` 關閉視窗
|
||||
- 下一輪 `OnBeforeClose` 看到旗標已設、直接放行 → 走 既有 `OnShutdown` 7+1s **graceful shutdown** 流程(呼叫 `ctrl.Stop()`、server 端再以 `SIGTERM` + `AwaitActiveTasksOrTimeout(180s)` 雙層防護收尾)
|
||||
- **設計刻意採 graceful 而非 SIGKILL**:
|
||||
- 即使使用者已通過「FORCE」二次確認、仍給 server 一個有限的時間窗(最長 180s)讓 Python sidecar 嘗試把當前 flash 寫入 block 收尾(chip 內部 atomic-write boundary)
|
||||
- 多數情況下 firmware task 會在 180s 內自然完成、實際關閉延遲可能只有數秒
|
||||
- 真正卡死的場景才會撐到 180s timeout、之後 server 強制走 shutdown、再 `os.Exit(0)`
|
||||
- 比起直接 SIGKILL「砍 process = 砍寫入 = 必定 brick」、graceful 路徑至少給裝置一個「可能不 brick」的機會
|
||||
- 使用者體感:點完「確認強制關閉」後視窗不會瞬間消失、可能停留數秒到 180s(極端)才關閉;可在 modal 關閉前暫留 Stopping... 狀態提示
|
||||
|
||||
**ESC 鍵**:等同點「繼續等待」(安全路徑)、關 modal 保留視窗
|
||||
|
||||
@ -461,11 +468,19 @@ Banner **不可手動關閉**(避免使用者忽略問題)。只有下列條
|
||||
|
||||
### 6a.11 與 Architect TDD §8.6 的銜接
|
||||
|
||||
- TDD §8.6 提供 `HasActiveTask()` method 與 server graceful shutdown 拒絕邏輯
|
||||
- 控制台側透過既有 IPC(或新增一個 `/api/firmware/status` 端點)查詢 active task
|
||||
- 取得 active task 資訊(`deviceName / stage / etaSeconds / direction`)後渲染本節 modal
|
||||
- 「強制關閉」確認後、控制台需呼叫 server `force-shutdown` IPC method(bypass graceful shutdown)、server 收到後直接送 SIGKILL 給 Python sidecar、不等 firmware task 完成
|
||||
- 詳細 IPC 規格依 TDD §8.6 規範(Frontend M8-5 階段對接)
|
||||
- TDD §8.6 提供 server 端 `firmware.Service.HasActiveTask()` method 與 SIGTERM handler firmware-aware graceful 流程
|
||||
- **韌體進行中偵測(Wails close handler 端、實作 §8.6.2)**:
|
||||
- `OnBeforeClose` hook 觸發後、Wails 後端呼叫 `queryFirmwareActiveTasks()`、以 HTTP GET `http://127.0.0.1:{serverPort}/api/firmware/active-tasks`(1s timeout、fail-open)取得 active task 清單
|
||||
- 有 active task → Wails 後端透過 `wailsRuntime.EventsEmit(ctx, "app:firmware-in-progress", payload)` 推送事件給前端
|
||||
- 前端 listen `app:firmware-in-progress`、payload `{ hasActive: true, tasks: [{ taskId, deviceId, deviceName, chip, direction, stage, elapsedMs, etaSeconds }] }` → 渲染本節 6a.3 攔截 modal
|
||||
- **「強制關閉」確認後(實作 §8.6.2 第 6 點)**:
|
||||
- 前端輸入「FORCE」通過 → 呼叫 Wails binding `window.go.main.App.ConfirmForceClose()`
|
||||
- 後端在 `firmwareCloseGuard` 設 `forceCloseAccepted=true`(mu 保護、race-free)、再以 goroutine 觸發 `wailsRuntime.Quit(ctx)`
|
||||
- 下一輪 `OnBeforeClose` 被呼叫時、guard 看到旗標已設 → return `preventClose=false` 放行
|
||||
- Wails 進入 `OnShutdown`、呼叫既有 `ctrl.Stop()` 走 R5-2 graceful(7+1s)path、server 端進入 SIGTERM handler、`AwaitActiveTasksOrTimeout(180s)` 雙層防護收尾
|
||||
- **不採 SIGKILL bypass**:理由詳見 §6a.5(給 firmware task 一個收尾機會、降低 brick 機率)
|
||||
- **fail-open 設計**:query active-tasks 失敗(server 沒起來 / 1s timeout / malformed JSON 等)→ 視為「無 active task」放行、靠 server 端 SIGTERM handler 雙層擋。Wails 層 false negative 不直接造成 brick、但使用者會看不到攔截 modal、改由 server 端在 system room broadcast `server:shutdown-pending` 事件通知前端(M8-5 frontend 接 event 顯示 toast)
|
||||
- 詳細實作對齊 TDD §8.6.1–§8.6.5
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -564,25 +564,31 @@ R5-B4 已有「Kneron 預置模型 re-distribution 授權」未解決問題。Fi
|
||||
| `needsReset` flag | 本期**重用**:FW 升降版完成後設 `true`、下次 connect 走完整 reset |
|
||||
| WS room 命名規範 | 新 `firmware:<id>`、跟既有 `flash:<id>` / `inference:<id>` 並列 |
|
||||
|
||||
### 8.6 降版/升級進行中的 graceful shutdown 拒絕(Design A-MID-1 / §14.4 第 6 點)
|
||||
### 8.6 降版/升級進行中的 graceful shutdown 延遲(Design A-MID-1 / §14.4 第 6 點)
|
||||
|
||||
**問題背景**(Design 自提):依 R5-2 規則「關閉 Wails 控制台視窗 = 結束 server」。若升降版進行中(特別是 KL720 KDP1→KDP2 寫 flash 階段、或 B2 階段使用者降版到 KDP1)使用者關閉控制台 → server 收 SIGTERM → SIGTERM 中斷 Python sidecar → bridge.py handler 被中斷 → device flash 寫到一半就停 → **brick 風險**。Design Spec 的「不可關 modal」「persistent banner」是瀏覽器 tab 內的防護、**擋不住關 Wails 視窗**。
|
||||
|
||||
**設計方案**:在 server / Wails 兩層加 lock + 強制 force-quit modal。
|
||||
**設計方案**:在 server / Wails 兩層加 lock + 強制 force-quit modal。**注意**:本設計採「延遲關閉、等 task 完成」、不是「拒絕關閉」——server 收到 SIGTERM 後仍會關、只是延後到 firmware task 跑完或 180s timeout 後才真正走 shutdown。
|
||||
|
||||
#### 8.6.1 Server 端拒絕邏輯
|
||||
#### 8.6.1 Server 端延遲關閉邏輯
|
||||
|
||||
當 server 收到 SIGTERM(Wails close handler、systemd、`kill -TERM` 等):
|
||||
|
||||
1. server 進 `shutting_down` state、停止 accept 新 HTTP 連線
|
||||
2. **檢查 `firmware.Service.HasActiveTask()` 是否回 true**(任一 device 在 `StatusUpgrading` / `StatusDowngrading`)
|
||||
3. 若有 active task:
|
||||
- server **延遲 graceful shutdown**、不殺 Python sidecar
|
||||
- 透過 WebSocket `firmware:shutdown-rejected` event broadcast 給所有訂閱者
|
||||
- 持續等待 FW task 完成(success / error 任一終態)或最多 180s timeout(KL720 升級的硬上界)
|
||||
- FW task 完成後 → 走原本 7+1s graceful shutdown 流程
|
||||
- 180s timeout 後 → 仍未完成(罕見、可能 device 已 brick)→ 強制走 shutdown、log 警告
|
||||
4. 若無 active task:正常 graceful shutdown(既有 7+1s pattern)
|
||||
1. **檢查 `firmware.Service.HasActiveTask()` 是否回 true**(任一 device 在 `StatusUpgrading` / `StatusDowngrading`)
|
||||
2. 若有 active task — **延遲關閉、不立即終止**:
|
||||
- 透過 WebSocket `server:shutdown-pending` event 廣播到 `"system"` room
|
||||
(payload schema:`{"type": "server:shutdown-pending", "tasks": [...ActiveTaskInfo]}`;
|
||||
`tasks` 內欄位由 `firmware.Service.GetActiveTaskInfo()` 提供、**不含** `startTs`,
|
||||
避免時間戳洩漏 — startTs 由 Frontend 自行依 `elapsedMs` 推算、見 Reviewer Minor-3)
|
||||
- 呼叫 `firmware.Service.RequestShutdown()` 設旗標、拒絕**新** firmware task 開始(既有 task 讓它自然跑完、不主動 cancel)
|
||||
- 呼叫 `firmware.Service.WaitForActiveTasks(180s)` 等所有既有 task 結束或 180s timeout(KL720 升級的硬上界)
|
||||
- **FW task 完成 → 走原本 7+1s graceful shutdown 流程**(呼叫 shutdownFn → 收 inferenceSvc / httpServer → `os.Exit(0)`)
|
||||
- **180s timeout 後仍未完成(罕見、可能 device 已 brick)→ 仍然強制走 shutdown、log warning**(不會無限期卡住)
|
||||
3. 若無 active task:立刻走正常 graceful shutdown(既有 7+1s pattern)
|
||||
|
||||
**為什麼用 `system` room 而非 `firmware:<device>` room**:對齊既有 R5-2 `server:shutdown-imminent` 命名 pattern(兩者都是 server lifecycle event、不限定特定 device)。Frontend 端 `useShutdownWatcher` hook 已訂閱 `system` room、可同時處理 pending + imminent 兩個 event、不需額外訂閱所有 `firmware:<device>` room。`server:shutdown-pending`(延後關)跟 `server:shutdown-imminent`(真的要關了)各司其職、語意清楚。
|
||||
|
||||
**event 命名取捨**:原 spec 草案曾考慮 `firmware:shutdown-rejected`、但實作落地後改 `server:shutdown-pending`:(1) server-prefix 符合既有 lifecycle event 命名慣例;(2) 「pending」語意更準(是延遲、不是拒絕);(3) `firmware:` prefix 應留給 per-device progress event(如 `firmware:<deviceId>` room)。本 TDD 以實作為準、event 名統一 `server:shutdown-pending`。
|
||||
|
||||
#### 8.6.2 Wails 控制台端攔截
|
||||
|
||||
@ -613,7 +619,7 @@ Wails app 的 `OnBeforeClose` handler 改造:
|
||||
| 使用者強制 `kill -9` / 工作管理員強殺 → 仍可能 brick | 屬接受的取捨、Design Spec R-FW-11 已聲明 |
|
||||
| 180s timeout 內 FW task 真的卡死 → server 永遠不關 | 180s hard timeout 後強制走 shutdown |
|
||||
| modal 阻擋使用者關 app 體驗困擾 | 接受、brick 風險 > 體驗困擾、且 modal 顯示 ETA 給使用者預期 |
|
||||
| WebSocket broadcast 對非 firmware modal 的 tab 噪音 | event 命名 `firmware:shutdown-rejected` 限定 firmware: room、其他 room 不收 |
|
||||
| WebSocket broadcast 對非 firmware modal 的 tab 噪音 | event 命名 `server:shutdown-pending` 對齊既有 `system` room(既有 `useShutdownWatcher` hook 已訂閱、不增加噪音);`tasks` payload 過濾掉 `startTs` 避免時間戳洩漏(Reviewer Minor-3) |
|
||||
|
||||
#### 8.6.5 工時影響
|
||||
|
||||
|
||||
@ -0,0 +1,400 @@
|
||||
# Reviewer Report — M9-4.5 Server SIGTERM + Wails OnBeforeClose Integration
|
||||
|
||||
**審查日期**:2026-05-25
|
||||
**範圍**:8 個檔案、21 個新單元測試
|
||||
**結論**:⚠️ **不阻擋 M9-5**(核心邏輯與測試品質達標)、**1 個 Major 建議在進 M9-5 前釐清**(spec 對齊)、**3 個 Minor + 4 個 Suggestion 可進 backlog**
|
||||
**升 security**:❌ 不需要(無 auth / secret / 第三方整合、攻擊面僅 loopback)
|
||||
**第 2 輪 backend**:⚠️ **建議 1 輪**修 Major-1(event 名 + force-close path 對齊 spec / TDD),但若決策維持實作不變,可直接補 TDD 修正紀錄、無需重派 backend。
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **Correctness**:核心 SIGTERM 路徑與 OnBeforeClose 路徑邏輯都正確。fail-open 設計(query 失敗時放行 Wails)合理、有雙層保護兜底。
|
||||
- **TDD §8.6 對齊**:實作整體精神對齊、但**事件命名與 spec 不一致**(TDD 寫 `firmware:shutdown-rejected`、實作用 `server:shutdown-pending`)。**Design §6a.5 / §6a.11 寫的 force-close 應該 SIGKILL bypass graceful、實作走 graceful 7+1s**——實作其實更安全、但是直接違反 spec、需有人拍板(建議改 spec、不改 code)。
|
||||
- **測試品質**:6 + 7 + 8 = 21 個 test、覆蓋面好、有 -race 並發測。1 個 fakeLifecycle / fakeCloseGuardDeps 設計都乾淨。
|
||||
- **Concurrency**:mu 保護完整、forceCloseAccepted race-free、broadcast hub 是 buffered 100、不會卡 shutdown goroutine。
|
||||
- **Security**:HTTP target 寫死 127.0.0.1 ✓;payload 含 `startTs`(time.Time)走到 WS broadcast、屬時間戳洩漏、但 broadcast 範圍是同主機 browser tab、影響極低。
|
||||
|
||||
問題總數:**Critical 0 / Major 1 / Minor 3 / Suggestion 4**
|
||||
|
||||
---
|
||||
|
||||
## 審查範圍(8 檔)
|
||||
|
||||
| # | 檔案 | 角色 | 行數 |
|
||||
|---|------|------|------|
|
||||
| 1 | `server/main.go` | server SIGTERM goroutine wiring | +30 行(preamble + helper call) |
|
||||
| 2 | `server/internal/firmware/shutdown.go` | AwaitActiveTasksOrTimeout helper + 3 interfaces | 154 行(新) |
|
||||
| 3 | `server/internal/firmware/shutdown_test.go` | 6 unit tests | 258 行(新) |
|
||||
| 4 | `visiona-local/main.go` | OnBeforeClose hook 註冊 | +1 option + 註解 |
|
||||
| 5 | `visiona-local/app.go` | App 加 firmwareCloseGuard field + NewApp 初始化 | +6 行 |
|
||||
| 6 | `visiona-local/query_firmware_active_tasks.go` | HTTP helper、fail-open | 111 行(新) |
|
||||
| 7 | `visiona-local/query_firmware_active_tasks_test.go` | 7 unit tests | 250 行(新) |
|
||||
| 8 | `visiona-local/firmware_close_guard.go` | CloseGuard + OnBeforeClose + ConfirmForceClose | 244 行(新) |
|
||||
| 9 | `visiona-local/firmware_close_guard_test.go` | 8 unit tests | 280 行(新) |
|
||||
|
||||
> 規格對照:`.autoflow/04-architecture/v2/firmware-management.md` §8.6.1–§8.6.5;`.autoflow/03-design/v2/control-panel.md` §6a。
|
||||
|
||||
---
|
||||
|
||||
## TDD §8.6 對齊評估
|
||||
|
||||
| TDD §8.6 條目 | 實作位置 | 對齊狀態 |
|
||||
|---------------|---------|----------|
|
||||
| §8.6.1 SIGTERM → 檢查 HasActiveTask | main.go:333-348 + shutdown.go:122 | ✅ |
|
||||
| §8.6.1 broadcast 給 WS client | shutdown.go:137 | ⚠️ **event 名不對**(見 Major-1) |
|
||||
| §8.6.1 等到 180s timeout | shutdown.go:82 + 145 | ✅ MaxShutdownWait=180s |
|
||||
| §8.6.1 timeout 後強制走 shutdown | main.go:347-348(不論 clean 都 shutdownFn)| ✅ |
|
||||
| §8.6.1 KL720 worst case 200s(type.go UpgradeTimeoutFor)| types.go:115-119 vs MaxShutdownWait=180s | ⚠️ **數字不一致**(見 Minor-2) |
|
||||
| §8.6.2 OnBeforeClose 攔截 | firmware_close_guard.go:208 | ✅ |
|
||||
| §8.6.2 query `/api/firmware/active-tasks` | query_firmware_active_tasks.go:70 | ✅ |
|
||||
| §8.6.2 emit `app:firmware-in-progress` | firmware_close_guard.go:36 | ✅ event 名對齊 §8.6.2 |
|
||||
| §8.6.2 第 6 點 force-close → kill -9 不可擋 | implementation graceful path | ⚠️ **Design §6a.5 寫 SIGKILL bypass、實作走 graceful**(見 Major-1) |
|
||||
| §8.6.3 server/internal/firmware/service.go 新增 HasActiveTask | 已在 M9-3 完成 | ✅ |
|
||||
| §8.6.3 main.go signal handler 整合 | main.go:331-349 | ✅ |
|
||||
| §8.6.4 180s timeout 內卡死 → 強制 shutdown | shutdown.go:147-152 + main.go:347 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 🟠 Major(建議修、不阻擋 M9-5)
|
||||
|
||||
### Major-1:spec 與實作對 force-close 路徑 + WS event 命名不一致
|
||||
|
||||
兩個 spec / impl 不對齊問題、屬同一決策面、合併一條:
|
||||
|
||||
#### (a) WS event name 對不上
|
||||
|
||||
- **TDD §8.6.1 line 581**:「透過 WebSocket `firmware:shutdown-rejected` event broadcast 給所有訂閱者」、限定 `firmware:` room
|
||||
- **TDD §8.6.4**:「event 命名 `firmware:shutdown-rejected` 限定 firmware: room、其他 room 不收」
|
||||
- **實作**(shutdown.go:66-77):用 `system` room、event type `server:shutdown-pending`
|
||||
|
||||
實作的選擇其實**更合理**(system room 已存在 `server:shutdown-imminent` 配對、前端有 `useShutdownWatcher` hook 訂閱、不需要叫前端再去訂閱所有 `firmware:<device>` room)、但這是顯式違反 spec、需要文件補正。
|
||||
|
||||
#### (b) Force-close path 不一致
|
||||
|
||||
- **Design §6a.5 line 392**:「點擊 → Wails close handler 跳過 server graceful shutdown 流程、**直接 kill server process(傳 SIGKILL)**+ 關閉視窗」
|
||||
- **Design §6a.11 line 467**:「控制台需呼叫 server `force-shutdown` IPC method(bypass graceful shutdown)、server 收到後直接送 SIGKILL 給 Python sidecar、不等 firmware task 完成」
|
||||
- **實作**(firmware_close_guard.go:226-243 ConfirmForceClose):設旗標 → `wailsRuntime.Quit(ctx)` → OnBeforeClose 放行 → OnShutdown → `ctrl.Stop()`(既有 7+1s graceful)
|
||||
|
||||
實作的選擇**比 spec 更保守 / 更安全**——server 端 SIGTERM 收到後 firmware.AwaitActiveTasksOrTimeout 還會再等 180s(除非真的卡死才強制)。但這違反「使用者明確 FORCE 確認後立刻 kill」的設計意圖、會造成 UX 不一致(使用者打完 `FORCE` 又等 180s、會困惑)。
|
||||
|
||||
#### 建議
|
||||
|
||||
請使用者 / Architect 拍板(兩個都建議改 spec、不改 code):
|
||||
|
||||
1. **WS event**:把 TDD §8.6.1 / §8.6.4 改成 `server:shutdown-pending` 在 `system` room(對齊既有 R5-2 `server:shutdown-imminent` 配對)、補一行說明這跟 `firmware:<device>` progress room 區隔
|
||||
2. **Force-close path**:把 Design §6a.5 / §6a.11 改成「graceful shutdown(server 端 firmware-aware 180s timeout 保護)」、刪掉「SIGKILL bypass」字眼。Code 註解(firmware_close_guard.go:225-229)已說明理由、寫得清楚、改 spec 採用實作的設計即可
|
||||
|
||||
或者反過來改 code 對齊 spec、但 reviewer 不建議——實作的設計更安全。
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Minor
|
||||
|
||||
### Minor-1:MaxShutdownWait 180s vs UpgradeTimeoutFor(KL720) 230s 不對齊
|
||||
|
||||
- shutdown.go:82 `MaxShutdownWait = 180s`
|
||||
- types.go:115-119 `UpgradeTimeoutFor(KL720) = 200s`、service.go:159 又加 `+30s margin` → service-level ctx 是 230s
|
||||
|
||||
→ **Wails 觸發 SIGTERM 時、AwaitActiveTasksOrTimeout 只等 180s 就強制走、service 層的 task 可能還有 50s 才結束**。實作的註解(shutdown.go:18-21)已意識到:「給 180 秒 hard upper bound 是『Wails 視窗端的使用者體驗上限』」、但這個取捨會讓「KL720 升級到第 180s 還沒完成 → server 強制 shutdown → 在第 199s 才會完成的 task 被中斷」。
|
||||
|
||||
雖然走到這條 50s 窗口的機率很低(KL720 平均升級時間 << 200s)、但對齊 spec 應該設 240s(200s timeout + 30s margin + 10s buffer)。
|
||||
|
||||
**建議**:MaxShutdownWait 提高到 220s 或 240s、註解說明來源。
|
||||
|
||||
### Minor-2:Second SIGTERM 不處理(buffer=1)
|
||||
|
||||
main.go:331-349:
|
||||
```go
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
sig := <-quit // 只收一次
|
||||
...
|
||||
_ = firmware.AwaitActiveTasksOrTimeout(...) // 阻塞到 180s
|
||||
shutdownFn()
|
||||
os.Exit(0)
|
||||
}()
|
||||
```
|
||||
|
||||
使用者在 firmware task 中按 Ctrl+C → goroutine 開始等 180s → 使用者再按 Ctrl+C 想強制中止 → 第二個 signal 進 channel buffer → 沒人讀、什麼事都沒發生。OS 端用 `kill -9` 可繞過、但「按兩次 Ctrl+C」是 Unix 慣例(git rebase、docker exec 都這樣設計)。
|
||||
|
||||
**建議**:可選擇:
|
||||
- (a) 啟動第二個 goroutine 監聽第二個 signal、收到後 `os.Exit(1)` 強制走(會 brick、但是使用者明確意圖)
|
||||
- (b) 不處理、文件說明限制(M9-5 之前可接受)
|
||||
|
||||
shutdown.go 開頭註解(line 18-24)已寫「不主動 cancel task」的取捨、屬同一決策面——可不修、但建議在註解補一行說明「使用者第二次 Ctrl+C 不處理」。
|
||||
|
||||
### Minor-3:WS broadcast payload 含 `startTs` (time.Time)
|
||||
|
||||
shutdown.go:137-141 broadcast `tasks` 直接帶 `[]*ActiveTaskInfo`、types.go:106 `StartTs time.Time` 會序列化成 ISO8601 字串送到所有 system room 訂閱者。
|
||||
|
||||
**洩漏**:server 本機時間(無 secret 等級資訊、但屬「不該對 client 暴露的細節」)。Frontend 已有 `elapsedMs` 可用、startTs 是冗餘的。
|
||||
|
||||
**建議**:broadcast payload 過濾掉 `startTs` 欄位、或者在 broadcast 前轉成 minimal struct(taskId / deviceId / chip / direction / stage / elapsedMs / etaSeconds 七欄、跟 query_firmware_active_tasks.go:39-48 FirmwareActiveTaskSummary 對稱)。
|
||||
|
||||
Wails 端的 `appCloseGuardDeps.EmitFirmwareInProgress`(firmware_close_guard.go:143-160)已自行構造 map[string]interface{} 過濾掉 `startTs`、做對了——server 端 broadcast 應該同樣處理。
|
||||
|
||||
---
|
||||
|
||||
## 💡 Suggestion(非阻擋、進 backlog)
|
||||
|
||||
### S-1:shutdown.go 三個 interface 設計取捨
|
||||
|
||||
3 個 interface(ShutdownNotifier / FirmwareLifecycle / ShutdownLogger)拆得乾淨、好測。**但有過度抽象的疑慮**——`FirmwareLifecycle` 只有 helper 用、production 唯一實作者是 `*Service`、改名為 `*Service` direct 反而更清楚。建議保留現狀(測試友善 > 過度抽象 trade-off 在這個尺度可接受),但日後若再加類似 helper、可考慮共用一個 `ServiceSnapshot` interface。
|
||||
|
||||
### S-2:firmware_close_guard.go ConfirmForceClose 與 wailsRuntime.Quit 的 race
|
||||
|
||||
```go
|
||||
func (a *App) ConfirmForceClose() error {
|
||||
...
|
||||
a.firmwareCloseGuard.ConfirmForceClose() // 設 flag
|
||||
...
|
||||
if a.ctx != nil {
|
||||
go wailsRuntime.Quit(a.ctx) // 觸發 OnBeforeClose
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ConfirmForceClose` 設 flag 後立刻 spawn goroutine call `Quit`、Quit 觸發 OnBeforeClose、OnBeforeClose 讀 flag——這條 chain 沒有顯式同步、依賴 mu 在 evaluateClose 內部讀的時候才會 sync 到。在 Go memory model 下、`g.ConfirmForceClose()` return 後 mu.Unlock 觸發 happens-before、後續 OnBeforeClose goroutine 進 `consumeForceCloseAccepted` 也會 mu.Lock 同步——**race-free**。test 用 N=100 並發跑 -race clean 也驗證了。
|
||||
|
||||
**Suggestion**:可考慮在 ConfirmForceClose 內部直接同步呼叫 `wailsRuntime.Quit`(不用 goroutine)、減少非必要 goroutine、但目前實作為避免 binding caller 阻塞、屬合理取捨。註解(line 239-241)說明清楚、保留現狀 OK。
|
||||
|
||||
### S-3:query_firmware_active_tasks.go 1s timeout 是否會 false negative
|
||||
|
||||
註解(line 11-12)「1 秒 timeout:server 沒起來 / 卡死 / network error 全視為『無 active task』」。**極端情境**:server 在升級 KL720 firmware(Python sidecar 大量 IPC、Go server 整體負載暫時拉高)、`/api/firmware/active-tasks` handler 處理時間 > 1s → fail-open → 放行 close → server SIGTERM handler 接手(雙層保護)。
|
||||
|
||||
雙層保護下、Wails 這層 false negative 不會直接造成 brick。**但**會造成 UX 不一致:使用者看不到攔截 modal、以為「沒任務、可以正常關」、結果 server SIGTERM handler 又延遲 180s(顯示 `server:shutdown-pending` broadcast)。
|
||||
|
||||
**Suggestion**:1s 在 development env 夠(empty ListActiveTasks handler 應該 < 10ms)、production load 上若有問題再加 retry-once 或拉到 3s。不阻擋 M9-5。
|
||||
|
||||
### S-4:fakeLifecycle.WaitForActiveTasks 不 sleep maxWait
|
||||
|
||||
shutdown_test.go:57-64 的 fakeLifecycle.WaitForActiveTasks 不真的等 maxWait 時間、直接 return waitResult。Test 3 的 `MaxShutdownWait = 10ms` defer 重置是為了測 real Service 的 Wait 路徑(test 6)、但 fakeLifecycle 自己其實沒用到 10ms——可在 fakeLifecycle 加個 `waitDelay time.Duration` 欄位讓 test 控制是否 sleep、會更模擬真實情境。不阻擋。
|
||||
|
||||
---
|
||||
|
||||
## SIGTERM 流程逐步驗證(server 端)
|
||||
|
||||
| 步驟 | 觸發 | 預期行為 | 實作位置 | 驗證 |
|
||||
|------|------|---------|----------|------|
|
||||
| 1 | OS 送 SIGTERM | quit channel 收到 sig | main.go:331-332 | ✅ |
|
||||
| 2 | goroutine 收 sig | log "Received signal..." | main.go:334-335 | ✅ |
|
||||
| 3 | 呼 AwaitActiveTasksOrTimeout | 進 helper | main.go:339-344 | ✅ |
|
||||
| 4 | helper 查 HasActiveTask | 用 *Service 真實 impl | shutdown.go:122 | ✅ |
|
||||
| 4a | 沒 task → return true | log "no active firmware task" | shutdown.go:124-128 | ✅ Test 1 |
|
||||
| 4b | 有 task → 取 ActiveTaskInfo | snapshot copy | shutdown.go:131 + service.go:283 | ✅ |
|
||||
| 5 | broadcast pending | system room + 完整 task list | shutdown.go:136-141 | ⚠️ event name 不對 spec(Major-1) |
|
||||
| 6 | RequestShutdown | set shutdownReq flag、新 task 拒絕 | shutdown.go:143 + service.go:288-292 | ✅ |
|
||||
| 7 | WaitForActiveTasks(180s) | 用 taskWg.Wait + select timeout | shutdown.go:145 + service.go:297-309 | ✅ Test 2/3 |
|
||||
| 7a | task 結束 → return true | clean log | shutdown.go:146-148 | ✅ Test 2 |
|
||||
| 7b | timeout → return false | warn log | shutdown.go:149-152 | ✅ Test 3 |
|
||||
| 8 | caller shutdownFn | inferenceSvc.StopAll + httpServer.Shutdown | main.go:347 → 276-287 | ✅ |
|
||||
| 9 | os.Exit(0) | process 結束 | main.go:348 | ✅ |
|
||||
|
||||
**timeout 路徑**:第 7b → 第 8 → 第 9、不會卡死。✅
|
||||
|
||||
**race 路徑**:
|
||||
- task 在 broadcast 與 WaitForActiveTasks 中間結束 → WaitForActiveTasks 立刻 return true(taskWg 計數歸零、close(done) 進 select) → 沒問題
|
||||
- 新 task 在 broadcast 後但在 RequestShutdown 前進來 → 會建立成功(短暫 race window、屬可接受、SIGTERM 流程已在進行中、不會有實際使用者意圖)
|
||||
|
||||
---
|
||||
|
||||
## Wails OnBeforeClose 流程逐步驗證
|
||||
|
||||
| 步驟 | 觸發 | 預期 | 實作位置 | 驗證 |
|
||||
|------|------|------|---------|------|
|
||||
| 1 | Wails close 動作 | OnBeforeClose 被呼叫 | main.go:38 | ✅ |
|
||||
| 2 | OnBeforeClose → evaluateClose | 經 wrapping App.OnBeforeClose | firmware_close_guard.go:208-214 | ✅ |
|
||||
| 3 | 檢查 forceCloseAccepted | 已 confirm → return false 放行 | firmware_close_guard.go:116-119 | ✅ Test 1 |
|
||||
| 4 | 檢查 ServerPort | <=0 → return false | firmware_close_guard.go:121-126 | ✅ Test 2 |
|
||||
| 5 | queryFirmwareActiveTasks | HTTP GET 帶 1s timeout | query_firmware_active_tasks.go:70-111 | ✅ Test 3-7 |
|
||||
| 5a | error → fail-open return false | log + 不擋 | firmware_close_guard.go:130-134 | ✅ Test 3 (close_guard) |
|
||||
| 5b | hasActive=false → return false | log + 放行 | firmware_close_guard.go:136-139 | ✅ Test 4 |
|
||||
| 5c | hasActive=true → emit + return true | 通知前端 modal | firmware_close_guard.go:141-161 | ✅ Test 5 |
|
||||
| 6 | 前端「FORCE」確認 → ConfirmForceClose binding | 設 flag + go wailsRuntime.Quit | firmware_close_guard.go:232-243 | ✅ Test 6 |
|
||||
| 7 | Quit 觸發、OnBeforeClose 又被叫 | step 3 短路 return false 放行 | firmware_close_guard.go:116-119 | ✅ Test 1 後續 |
|
||||
| 8 | shutdown(ctx) | 走既有 ctrl.Stop graceful | app.go:341-381 | ✅(既有 R5-2、本次未動)|
|
||||
|
||||
**雙層保護驗證**:Wails OnBeforeClose query 失敗(fail-open)放行 → ctrl.Stop → server 收 SIGTERM → server 端 AwaitActiveTasksOrTimeout 再擋 180s。✅ 雙層設計合理、容錯好。
|
||||
|
||||
---
|
||||
|
||||
## 21 個新測試品質
|
||||
|
||||
### shutdown_test.go(6 tests)
|
||||
| Test | 情境 | 品質 |
|
||||
|------|------|------|
|
||||
| NoActiveTask | 沒 task 立刻 return true | ✅ 驗 hasActiveCalls / requestShutdown / waitForActive / notifier 都對 |
|
||||
| ActiveTaskFinishesCleanly | 有 task、Wait return true | ✅ 驗 broadcast room + payload schema(type / tasks key) |
|
||||
| ActiveTaskTimeout | 有 task、Wait timeout | ✅ MaxShutdownWait reset / Warn 觸發 |
|
||||
| NilService | nil service 防呆 | ✅ |
|
||||
| NilNotifierAndLogger | nil 不 panic | ✅ recover guard |
|
||||
| RealServiceNoActive | 真接 Service 整合 | ✅ 驗 wire-up 不踩 nil |
|
||||
|
||||
**漏掉的情境**(非阻擋):
|
||||
- 連續兩次呼叫 AwaitActiveTasksOrTimeout(second SIGTERM 場景)
|
||||
- ctx 被 cancel 的行為(目前 helper 收 ctx 但沒用、預留給未來)
|
||||
|
||||
### query_firmware_active_tasks_test.go(7 tests)
|
||||
| Test | 情境 | 品質 |
|
||||
|------|------|------|
|
||||
| PortZero / PortNegative | port <= 0 防呆 | ✅ |
|
||||
| HasActiveWithTasks | server 正常回 + 完整解析 | ✅ 驗 7 個 task 欄位 |
|
||||
| HasActiveFalseNullTasks | tasks null → 轉空 slice | ✅ 重要邊界(避 caller nil panic) |
|
||||
| ServerError500 | 500 → fail-open | ✅ |
|
||||
| Timeout | server 慢 → 100ms timeout 觸發 | ✅ + done 兜底防卡死 |
|
||||
| MalformedJSON | 損毀 JSON → fail-open | ✅ |
|
||||
| SuccessFalse | success=false 視為 error | ✅ |
|
||||
|
||||
很完整。**1 個微小盲點**:沒測「response.data 完全缺欄位」(如 hasActive 缺)、但 zero-value 是 false 也安全。
|
||||
|
||||
### firmware_close_guard_test.go(8 tests)
|
||||
| Test | 情境 | 品質 |
|
||||
|------|------|------|
|
||||
| ForceAccepted | flag 短路 + 旗標清掉 | ✅ |
|
||||
| ServerNotRunning | port=0 不擋 | ✅ |
|
||||
| QueryError_FailOpen | error → 放行 | ✅ |
|
||||
| NoActiveTask | hasActive=false → 放行 | ✅ |
|
||||
| HasActive_PreventAndEmit | 完整驗 emit payload schema | ✅ taskId / deviceName / chip / stage / etaSeconds 都驗 |
|
||||
| ConfirmForceClose_SetsAndConsumes | flag toggle | ✅ |
|
||||
| NilDeps | 防呆 | ✅ |
|
||||
| ConcurrentAccess | N=100 -race | ✅ 無 race assertion |
|
||||
|
||||
**很棒的測試品質**:N=100 並發 + atomic counter、是少見的踏實寫法。
|
||||
|
||||
---
|
||||
|
||||
## 安全軸
|
||||
|
||||
| 項目 | 狀態 |
|
||||
|------|------|
|
||||
| HTTP target 寫死 127.0.0.1(loopback only)| ✅ query_firmware_active_tasks.go:81 |
|
||||
| 沒 token / secret 流動 | ✅ |
|
||||
| WS broadcast 不含 PII / secret | ✅(含 startTs 屬時間戳、見 Minor-3)|
|
||||
| Force-close 動作有二次確認 | ✅ Design §6a.5 + 實作 ConfirmForceClose binding(前端嚴格 `===` 比對「FORCE」)|
|
||||
| firmware_close_guard.mu 保護 forceCloseAccepted | ✅ |
|
||||
| -race clean 證明 | ✅ Test 8 |
|
||||
|
||||
無需升 Security Auditor。
|
||||
|
||||
---
|
||||
|
||||
## Concurrency 評估
|
||||
|
||||
### server 端 SIGTERM goroutine(main.go:333-349)
|
||||
- 單 goroutine、不會雙觸發(除非有人 spawn 多個 signal.Notify、這份 main.go 沒有)✅
|
||||
- 跟 `firmwareSvc.taskWg` 互動:WaitForActiveTasks 內部用 taskWg.Wait 加 select、taskWg.Add 在 UpgradeFirmware 內 ✅
|
||||
- 跟 `shutdownReq` flag 互動:RWMutex 保護、UpgradeFirmware 讀、RequestShutdown 寫 ✅
|
||||
|
||||
### Wails 端 ConfirmForceClose 並發
|
||||
- `firmwareCloseGuard.mu` 保護 forceCloseAccepted ✅
|
||||
- `go wailsRuntime.Quit(ctx)` 是 binding goroutine fire-and-forget、不影響 Wails main thread ✅
|
||||
- OnBeforeClose 被 Wails main thread 呼叫、consumeForceCloseAccepted mu.Lock 同步 ✅
|
||||
|
||||
### Hub broadcast 不卡 shutdown
|
||||
- `h.broadcast` channel buffer = 100(hub.go:56)✅ 不會卡 SIGTERM goroutine
|
||||
- hub.Run goroutine 在 server lifetime 一直跑、不會在 shutdown 開始前先死、broadcast 至少能進 channel ✅
|
||||
|
||||
無 race / 無 deadlock 風險。
|
||||
|
||||
---
|
||||
|
||||
## 是否阻擋 M9-5
|
||||
|
||||
❌ **不阻擋**。
|
||||
|
||||
理由:
|
||||
- 核心 SIGTERM 流程正確、測試覆蓋好、有雙層保護兜底
|
||||
- 1 個 Major(spec 不對齊)屬文件層面、不是功能 bug
|
||||
- 3 個 Minor / 4 個 Suggestion 都不影響「韌體進行中關閉視窗」的核心保護
|
||||
|
||||
M9-5 可以開始。建議在 M9-5 開始之前、由 Architect 補 TDD §8.6.1 / §8.6.4 + Design §6a.5 / §6a.11 對齊實作(5 分鐘工作量)、避免後續 frontend 接 event 時拿錯 event name。
|
||||
|
||||
---
|
||||
|
||||
## 是否升 security agent
|
||||
|
||||
❌ **不需要**。
|
||||
|
||||
不觸發 §3.4 升級條件:無 auth / session / 密碼變更、無新對外 API(active-tasks 已在 M9-3 上線、本任務不新增)、無第三方整合、無 PII / 金融資料、沒看不懂的安全決策。攻擊面僅 loopback HTTP + 同主機 WebSocket、屬本機 IPC 層級。
|
||||
|
||||
---
|
||||
|
||||
## 是否需 backend 第 2 輪
|
||||
|
||||
⚠️ **建議 1 輪、但範圍很小**。
|
||||
|
||||
選項:
|
||||
- **A. 改 spec、不改 code**(推薦):請 Architect / Design agent 更新 TDD §8.6.1 / §8.6.4 + Design §6a.5 / §6a.11、改文件不改 code。0.2 人天。
|
||||
- **B. 改 code 對齊 spec**:把 ShutdownEventTypePending 改 `firmware:shutdown-rejected`、broadcast 從 `system` room 改 `firmware:` room;ConfirmForceClose 改成直接 SIGKILL server process。0.5 人天、但會讓設計變糟。
|
||||
- **C. 不修、留 backlog**:將 Major-1 標入 progress.md「待人工介入」清單。
|
||||
|
||||
reviewer 建議 A。
|
||||
|
||||
Minor-1 / Minor-3 可一併處理(提高 MaxShutdownWait 到 220s、broadcast payload 過濾 startTs)、約 0.1 人天。
|
||||
|
||||
---
|
||||
|
||||
## 優點(給 backend agent 正面回饋)
|
||||
|
||||
1. **interface 拆分乾淨**:ShutdownNotifier / FirmwareLifecycle / ShutdownLogger 三 interface 都是 minimum surface、測試友善、production wire-up 簡單。雖然有 S-1 略過度抽象的疑慮、但拆得很好。
|
||||
2. **雙層保護設計合理**:Wails query fail-open → server SIGTERM handler 雙層擋、註解(query_firmware_active_tasks.go:17-20)清楚說明取捨理由。這比硬擋 Wails 體驗好太多。
|
||||
3. **註解品質極高**:shutdown.go 開頭 24 行設計取捨 + 為什麼 180s 不是 200s、firmware_close_guard.go 行 1-24 解釋為什麼用 Wails event 而不是 native dialog、ConfirmForceClose binding goroutine 解釋(line 219-229)——全部都是「下一個工程師讀到不會踩坑」級的註解。
|
||||
4. **N=100 -race 並發測試**:firmware_close_guard_test.go:251-280 用 atomic counter 跑 N=100、認真寫 race-free 驗證、不是隨便糊個 test 交差。難得。
|
||||
5. **fail-open 設計守則一致**:query / OnBeforeClose / AwaitActiveTasksOrTimeout 全部 nil-safe、所有路徑都假設「最壞情況不該 panic、不該卡死 Wails」、防禦性編碼到位。
|
||||
6. **Test 6(RealServiceNoActive)整合驗 wire-up**:不只用 fake、還跑一個真接 Service 驗 helper 整條走通——這種「最後一公里」的驗證很多 reviewer 會嫌麻煩跳過、backend 沒跳。
|
||||
|
||||
---
|
||||
|
||||
## Needs Investigation
|
||||
|
||||
無。所有 5 軸 + 測試軸都有明確判斷文字、沒有 Reviewer 看不懂或不確定的地方。
|
||||
|
||||
---
|
||||
|
||||
## Verification 自評
|
||||
|
||||
### A 層(每個 review 必做)
|
||||
|
||||
- ✅ **R-A1**:5 軸 + 測試軸全跑過、每軸 ≥ 20 字實質判斷
|
||||
- Correctness:見「SIGTERM 流程逐步驗證」+「OnBeforeClose 流程逐步驗證」兩節 ≈ 800 字
|
||||
- Readability:見「優點」§3 註解品質評估
|
||||
- Architecture:見 S-1 三 interface 取捨評估
|
||||
- Security:見「安全軸」表 + Minor-3 startTs 評估
|
||||
- Performance:見「Concurrency 評估」hub buffer 100 + S-3 1s timeout 評估
|
||||
- 測試:見「21 個新測試品質」三表
|
||||
- ✅ **R-A2**:TDD §8.6 對照表填滿 12 條、每條都有實作位置 + 對齊狀態
|
||||
- ✅ **R-A3**:Major-1 附 spec line number(TDD §8.6.1 line 581、§8.6.4、Design §6a.5 line 392、§6a.11 line 467)+ 實作位置 + 具體建議;Minor-1/2/3 同
|
||||
- ✅ **R-A4**:「優點」段 6 條、不是空著
|
||||
- ✅ **R-A5**:「Needs investigation」段明示「無」
|
||||
- ✅ **R-A6**:通用 6 條
|
||||
- No silent failures:✅ 已查、所有 error path 都有 log + 適當處理
|
||||
- No dead code:✅ ctx 預留參數有明確註解(shutdown.go:112)、不算 dead code
|
||||
- No hardcoded secrets:✅
|
||||
- No unsafe HTML/SQL:✅ 無此情況(純 Go 後端 + HTTP loopback)
|
||||
- Doc 同步:⚠️ Major-1 — TDD / Design 文件未同步實作改動
|
||||
- Working tree clean(被審 commit):✅(依使用者派任務、認定 backend 已 commit)
|
||||
|
||||
### B 層(milestone 必驗)
|
||||
|
||||
本次審 8 檔(單一 milestone task、跨 server + Wails)、屬 milestone 範圍、跑 B 層:
|
||||
- ✅ **R-B1**:跨檔案一致性(server FirmwareActiveTaskSummary 欄位 vs Wails 對應 struct)已比對、欄位對齊
|
||||
- ✅ **R-B2**:8 檔全覆蓋、無漏審
|
||||
- ✅ **R-B3**:最大新檔 280 行(firmware_close_guard_test.go)< 500 行閾值、無大改動
|
||||
- N/A **R-B4**:本次純程式碼審查、不涉文件三方互審
|
||||
|
||||
### C 層(PR/合併前最終 review)
|
||||
|
||||
不適用——本次是 task 完成審查、非 PR 最終 review。M9-5 完成後彙整時可跑。
|
||||
|
||||
---
|
||||
|
||||
**結論彙報**:
|
||||
|
||||
- 報告路徑:`.autoflow/05-implementation/review/m9-4.5-shutdown-integration-review.md`
|
||||
- Critical: 0 / Major: 1 / Minor: 3 / Suggestion: 4
|
||||
- 阻擋 M9-5:❌ 不阻擋
|
||||
- 升 security:❌ 不需要
|
||||
- 第 2 輪 backend:⚠️ 建議 1 輪、範圍小(建議走 A 選項:改 spec 不改 code)
|
||||
@ -291,7 +291,42 @@
|
||||
- S-1/S-2/S-4 backend 不修分析確認合理
|
||||
- 3 個極小 Suggestion 全部 backend 不需處理(純評估)
|
||||
- [x] **M9-3 整體完成**(2026-05-25)→ 通過、可進 M9-4
|
||||
- [ ] M9-4.5 server SIGTERM + Wails OnBeforeClose(新增、併 M9-4 或之後做)
|
||||
- [x] **M9-4.5 server SIGTERM + Wails OnBeforeClose 完成**(2026-05-25)
|
||||
- **Server 端**:
|
||||
- `server/main.go`:改寫 SIGTERM/SIGINT goroutine 整合 firmware-aware preamble
|
||||
- `server/internal/firmware/shutdown.go`:新檔 154 行(AwaitActiveTasksOrTimeout helper + 3 個 interface)
|
||||
- `server/internal/firmware/shutdown_test.go`:新檔 258 行、6 tests
|
||||
- **Wails 端**:
|
||||
- `visiona-local/main.go`:OnBeforeClose 從 inline closure 改為 app.OnBeforeClose
|
||||
- `visiona-local/app.go`:App struct 加 firmwareCloseGuard
|
||||
- `visiona-local/query_firmware_active_tasks.go`:新檔 111 行(HTTP helper、fail-open)
|
||||
- `visiona-local/query_firmware_active_tasks_test.go`:新檔 250 行、7 tests
|
||||
- `visiona-local/firmware_close_guard.go`:新檔 244 行(CloseGuard struct + OnBeforeClose + ConfirmForceClose bindings)
|
||||
- `visiona-local/firmware_close_guard_test.go`:新檔 280 行、8 tests
|
||||
- **行為**:
|
||||
- Server SIGTERM 有 active task → broadcast `server:shutdown-pending` 到 system room → RequestShutdown + WaitForActiveTasks(180s) → 走原本 shutdownFn
|
||||
- Wails OnBeforeClose 有 active task → emit Wails event `app:firmware-in-progress` payload `{hasActive, tasks}` + return true 擋住關閉
|
||||
- ConfirmForceClose binding 供 frontend 第二層 FORCE 確認用
|
||||
- 測試:8 個檔合計新增 21 unit tests race-clean、firmware pkg 5.305s + wails 11.326s 全綠
|
||||
- **留 M9-12 frontend**:Design §6a 19 i18n keys 與 force-close modal UI(不影響 M9-4.5 功能、屬未實作的下游)
|
||||
- [x] **M9-4.5 Reviewer 第 1 輪完成**(2026-05-25)→ `.autoflow/05-implementation/review/m9-4.5-shutdown-integration-review.md`
|
||||
- 結論:**0 Critical / 1 Major / 3 Minor / 4 Suggestion、不阻擋 M9-5、不升 security、建議 backend 第 2 輪(0.2 人天、範圍小)**
|
||||
- **Major 1(spec/impl 雙重不對齊、不是 code bug)**:
|
||||
- TDD §8.6.1/§8.6.4 寫 `firmware:shutdown-rejected` vs 實作用 `server:shutdown-pending`
|
||||
- Design §6a.5 寫 SIGKILL bypass vs 實作走 graceful 7+1s
|
||||
- Reviewer 推薦:**改 spec 不改 code**(實作更安全合理)→ 派 Architect + Design 修文件
|
||||
- 3 Minor(MaxShutdownWait 180s vs KL720 230s 不對齊 / Second SIGTERM 不處理 / broadcast 含 startTs 洩漏)
|
||||
- 4 Suggestion(interface 微過度抽象 / ConfirmForceClose race / 1s timeout production load 風險 / fakeLifecycle 不模擬 wait delay)
|
||||
- **正面評價**:interface 乾淨 / 雙層保護 / 註解品質極高 / N=100 -race 並發測試 / fail-open 跨 3 層一致 / Test 6 真接 Service wire-up
|
||||
- [x] **M9-4.5 第 2 輪修改完成**(2026-05-25、3 sub-agent 平行)
|
||||
- **Architect TDD §8.6**:標題「拒絕」→「延遲」+ event 名 `firmware:shutdown-rejected` → `server:shutdown-pending` + room `system` + 加 payload schema 註明 tasks 不含 startTs + 命名取捨變更紀錄段(grep `firmware:shutdown-rejected` 0 hit 流程描述、僅 1 hit 變更紀錄)
|
||||
- **Design control-panel.md §6a**:force-quit 改「graceful 7+1s + 雙層防護」對齊實作 + 補「為何刻意不採 SIGKILL」5 點設計理由 + §6a.11 IPC 規格對齊(grep SIGKILL 剩 3 hits 全是「為何不採 SIGKILL」論述段)
|
||||
- **Backend shutdown.go**:
|
||||
- Minor 1:`MaxShutdownWait` 180s → 220s(KL720 200s upgrade + 20s buffer)+ 註解說明
|
||||
- Minor 3:新增 `shutdownBroadcastTask` minimal struct(8 欄、不含 startTs)+ `toBroadcastTasks()` helper + broadcast 走過濾路徑
|
||||
- shutdown.go +57 / shutdown_test.go +126 / 2 新 test(filter assertion + nil/empty helper)
|
||||
- 23 tests race-clean 全綠 / firmware/api/ws/handlers/visiona-local 全綠 / 0 regression
|
||||
- [x] **M9-4.5 整體完成**(2026-05-25)→ 不需 Reviewer 第 2 輪(spec 改 + 2 小 Minor、影響範圍極小、可直接進 M9-5)
|
||||
- [x] **M9-4 Frontend FW badge + 升級 modal 完成**(2026-05-25)
|
||||
- 12 新增 + 4 修改 = 16 檔、3052 行
|
||||
- **新增 i18n**:firmware.* 52 keys + devices.card.fwBadge.* 5 keys = 57 leaf × 2 lang = 114 翻譯字串
|
||||
|
||||
212
local-tool/server/internal/firmware/shutdown.go
Normal file
212
local-tool/server/internal/firmware/shutdown.go
Normal file
@ -0,0 +1,212 @@
|
||||
package firmware
|
||||
|
||||
// shutdown.go — M9-4.5:firmware-aware graceful shutdown helper(TDD §8.6.1 + §8.6.3)
|
||||
//
|
||||
// 為什麼分離到獨立檔:main.go 的 SIGTERM goroutine 是 inline closure、難測。把
|
||||
// firmware 拒絕 graceful shutdown 的核心邏輯(檢查 active task → broadcast →
|
||||
// 等到結束或 timeout)提到本 helper、用 ShutdownAwareness interface 抽象 ws hub、
|
||||
// main.go 只負責 wire 起來。test 可以直接 mock interface 跑邏輯。
|
||||
//
|
||||
// 不在這層管的事:
|
||||
// 1. 真正 kill httpServer / inferenceSvc.StopAll() — 那是 main.go 既有 shutdownFn
|
||||
// 的職責、本 helper return 後由 caller 自己呼叫
|
||||
// 2. SIGTERM 訊號本身的 wiring — main.go 既有 signal.Notify 機制保留不動
|
||||
//
|
||||
// 設計取捨:
|
||||
// - hard upper bound 220 秒:KL720 升級 worst case 是 200 秒(TDD §3.4 / §10.1
|
||||
// R-FW-4 + UpgradeTimeoutFor 為 200s)、service 層 ctx timeout 是
|
||||
// 200+30 = 230 秒(service.go:159 +30s margin 給 verify 階段)。本 helper
|
||||
// 設 220 秒、覆蓋 KL720 200s upgrade timeout + 20s buffer 給最後一段
|
||||
// verify / cleanup、再 fallthrough 強制走 shutdown。
|
||||
//
|
||||
// 為什麼不直接設 230s 或更高:超過 220s 仍卡住意味著 device 已 brick、
|
||||
// 繼續等也救不回來、應該強制走 shutdown 釋放 Wails 視窗端的使用者體驗。
|
||||
// 220s 是「給 KL720 完整跑完的合理上限」+「不讓使用者無限等」的折衷。
|
||||
//
|
||||
// 第 1 輪 Reviewer Minor-1:原本 180s 比 KL720 worst case 200s 還短、
|
||||
// 會在 KL720 第 180s-199s 區間誤殺正常 task、邏輯不對。修正為 220s。
|
||||
// - 不主動 cancel task:service 層 RequestShutdown 只設旗標(拒絕新 task)、
|
||||
// 既有 task 讓它自然跑完。本 helper 也不呼叫 Task.cancel — 中斷 firmware
|
||||
// 寫入是「使用者明確強制關閉」才應該發生(Wails ConfirmForceClose binding)、
|
||||
// server SIGTERM handler 不該主動造成 brick。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ShutdownNotifier 是 firmware-aware shutdown helper 用來廣播
|
||||
// shutdown-pending 給 WebSocket client 的最小介面。
|
||||
//
|
||||
// 實作方:ws.Hub(BroadcastToRoom)。test 用 mock 實作。
|
||||
//
|
||||
// 為什麼不直接拉 ws.Hub 進來:firmware 包不該依賴 ws 包(避免循環依賴
|
||||
// 隱患 + 測試難度)。最小介面讓 main.go 在 wire 時提供 adapter 即可。
|
||||
type ShutdownNotifier interface {
|
||||
BroadcastToRoom(room string, data interface{})
|
||||
}
|
||||
|
||||
// FirmwareLifecycle 是 helper 對 firmware service 的依賴介面(避免 helper
|
||||
// 直接拿到完整 *Service、test 也好寫)。
|
||||
//
|
||||
// 實作方:*Service(已具備此三個 method);test 用 fake。
|
||||
type FirmwareLifecycle interface {
|
||||
HasActiveTask() bool
|
||||
GetActiveTaskInfo() []*ActiveTaskInfo
|
||||
RequestShutdown()
|
||||
WaitForActiveTasks(maxWait time.Duration) bool
|
||||
}
|
||||
|
||||
// ShutdownLogger 是 helper 用來印 log 的介面(避免硬綁特定 logger 實作)。
|
||||
// 實作方:server pkg/logger.Logger(具備 Info / Warn);test 可省略(傳 nil)。
|
||||
type ShutdownLogger interface {
|
||||
Info(format string, args ...interface{})
|
||||
Warn(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// shutdownBroadcastTask 是 broadcast 給 WS client 的最小 task 描述、刻意比
|
||||
// ActiveTaskInfo 少 StartTs 欄位(第 1 輪 Reviewer Minor-3:StartTs 是 server
|
||||
// 本機時間戳、不該對 client 暴露、frontend 已有 ElapsedMs 可用、StartTs 屬冗餘
|
||||
// 資訊 + 可能被 metadata-leak / timing attack 利用)。
|
||||
//
|
||||
// 對齊 visiona-local/query_firmware_active_tasks.go:39-48 FirmwareActiveTaskSummary
|
||||
// 七欄結構(Wails 端 query 過濾 startTs 已正確、本 struct 補齊 server 直接
|
||||
// broadcast 路徑)。
|
||||
type shutdownBroadcastTask 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"`
|
||||
}
|
||||
|
||||
// toBroadcastTasks 把 ActiveTaskInfo slice 轉成 broadcast 用的 minimal 結構
|
||||
// (過濾掉 StartTs)。回傳值永不為 nil(避免 frontend JSON.parse 後拿到 null)。
|
||||
func toBroadcastTasks(infos []*ActiveTaskInfo) []shutdownBroadcastTask {
|
||||
out := make([]shutdownBroadcastTask, 0, len(infos))
|
||||
for _, info := range infos {
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, shutdownBroadcastTask{
|
||||
TaskID: info.TaskID,
|
||||
DeviceID: info.DeviceID,
|
||||
DeviceName: info.DeviceName,
|
||||
Chip: info.Chip,
|
||||
Direction: info.Direction,
|
||||
Stage: info.Stage,
|
||||
ElapsedMs: info.ElapsedMs,
|
||||
EtaSeconds: info.EtaSeconds,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// shutdownRoom / shutdownEventType 是 WS broadcast 對齊既有 `system` room
|
||||
// + `server:shutdown-imminent` event 命名 pattern(見
|
||||
// server/internal/api/handlers/system_handler.go ShutdownNotify)。
|
||||
const (
|
||||
// ShutdownRoom 是 broadcast 用的 WebSocket room、沿用既有 `system` room
|
||||
// 命名(system_ws.go 已建立、瀏覽器 tab 訂閱在這個 room)。
|
||||
ShutdownRoom = "system"
|
||||
|
||||
// ShutdownEventTypePending 是 server SIGTERM 收到後、若有 active firmware
|
||||
// task 廣播給前端的事件型別(TDD §8.6.3)。前端收到後可選擇顯示「server
|
||||
// 等韌體跑完才會關」的提示 banner。
|
||||
//
|
||||
// 注意:這跟 R5-2 既有的 `server:shutdown-imminent`(出現在「server 真的
|
||||
// 要關了」)不同層級——pending 表示「server 收到了 SIGTERM 但延後執行」、
|
||||
// imminent 表示「真的要關了、tab 顯示 offline overlay」。當 firmware task
|
||||
// 結束、helper 真正放 shutdown 時、既有 ShutdownNotify 機制會再廣播一次
|
||||
// `server:shutdown-imminent`、兩個事件各司其職。
|
||||
ShutdownEventTypePending = "server:shutdown-pending"
|
||||
)
|
||||
|
||||
// MaxShutdownWait 是 firmware-aware shutdown 的 hard upper bound(TDD §8.6.1)。
|
||||
// 變數化以便測試注入。
|
||||
//
|
||||
// 220s = KL720 worst-case upgrade timeout (200s, types.go:115-119 UpgradeTimeoutFor)
|
||||
// - 20s buffer 給 verify / cleanup 階段
|
||||
//
|
||||
// 與 service.go:159 的 chipTimeout+30s margin 對齊但稍小(service 層 ctx 是雙保險、
|
||||
// helper 層作為「Wails 端使用者體驗上限」、超過 220s 必強制走、不再等下去)。
|
||||
var MaxShutdownWait = 220 * time.Second
|
||||
|
||||
// AwaitActiveTasksOrTimeout 是 firmware-aware graceful shutdown 的核心 helper
|
||||
// (TDD §8.6.1 + §8.6.3)。
|
||||
//
|
||||
// 行為:
|
||||
//
|
||||
// 1. 查 svc.HasActiveTask()
|
||||
// 2. 沒 active task → 立刻 return cleanShutdown=true、不廣播
|
||||
// 3. 有 active task → 廣播 `server:shutdown-pending` 到 `"system"` room
|
||||
// (payload: {type, tasks}、tasks 是 GetActiveTaskInfo() 結果)
|
||||
// 4. 呼叫 svc.RequestShutdown() 拒絕新 task
|
||||
// 5. 呼叫 svc.WaitForActiveTasks(MaxShutdownWait) 等所有 task 結束
|
||||
// 6. WaitForActiveTasks 回 true → cleanShutdown=true(乾淨等到)
|
||||
// 回 false → cleanShutdown=false(timeout、強制走、log warning)
|
||||
//
|
||||
// caller 應在 return 後接著走原本的 shutdownFn(kill httpServer / stop services)、
|
||||
// 不論 cleanShutdown true / false 都要走。cleanShutdown 只是給 log / 觀測用。
|
||||
//
|
||||
// notifier / logger 可為 nil:notifier=nil → 不廣播(仍正常等 task);
|
||||
// logger=nil → 不印 log。
|
||||
//
|
||||
// ctx 是 caller 的上層 context(通常 background)、目前未使用,預留給未來
|
||||
// caller 可能要 cancel 整個 wait(例如使用者點「強制關閉」反悔)。
|
||||
func AwaitActiveTasksOrTimeout(
|
||||
ctx context.Context,
|
||||
svc FirmwareLifecycle,
|
||||
notifier ShutdownNotifier,
|
||||
logger ShutdownLogger,
|
||||
) (cleanShutdown bool) {
|
||||
_ = ctx // 預留給未來 caller 要 cancel 整個 wait(M9-13 / B 階段如有需求)
|
||||
|
||||
if svc == nil {
|
||||
// 防呆:service 沒 wire 起來、視為「沒 active task」乾淨關
|
||||
if logger != nil {
|
||||
logger.Warn("firmware: shutdown helper called with nil service, treating as clean shutdown")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if !svc.HasActiveTask() {
|
||||
// 沒韌體 task 在跑、走原本 graceful shutdown 流程
|
||||
if logger != nil {
|
||||
logger.Info("firmware: no active firmware task, proceeding with normal shutdown")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 有 active task:廣播 → request shutdown → 等
|
||||
tasks := svc.GetActiveTaskInfo()
|
||||
if logger != nil {
|
||||
logger.Info("firmware: %d active firmware task(s) detected, delaying shutdown up to %v", len(tasks), MaxShutdownWait)
|
||||
}
|
||||
|
||||
if notifier != nil {
|
||||
// Minor-3 修正:broadcast payload 過濾掉 StartTs(time.Time 會序列化成
|
||||
// ISO8601 server 本機時間戳、屬不該洩漏給 WS client 的內部資訊)、
|
||||
// 改傳 minimal shutdownBroadcastTask(七欄、frontend 用 ElapsedMs 而非
|
||||
// StartTs 計算 etaSeconds)。
|
||||
notifier.BroadcastToRoom(ShutdownRoom, map[string]interface{}{
|
||||
"type": ShutdownEventTypePending,
|
||||
"tasks": toBroadcastTasks(tasks),
|
||||
})
|
||||
}
|
||||
|
||||
svc.RequestShutdown()
|
||||
|
||||
clean := svc.WaitForActiveTasks(MaxShutdownWait)
|
||||
if logger != nil {
|
||||
if clean {
|
||||
logger.Info("firmware: all active firmware tasks finished, proceeding with shutdown")
|
||||
} else {
|
||||
logger.Warn("firmware: hard timeout %v reached while waiting for firmware tasks, forcing shutdown (device may be in unknown state)", MaxShutdownWait)
|
||||
}
|
||||
}
|
||||
return clean
|
||||
}
|
||||
384
local-tool/server/internal/firmware/shutdown_test.go
Normal file
384
local-tool/server/internal/firmware/shutdown_test.go
Normal file
@ -0,0 +1,384 @@
|
||||
package firmware
|
||||
|
||||
// shutdown_test.go — M9-4.5:firmware-aware graceful shutdown helper 單元測試。
|
||||
//
|
||||
// 覆蓋情境:
|
||||
// 1. 沒 active task → 立刻 return true、不廣播、不呼叫 RequestShutdown
|
||||
// 2. 有 active task、Wait 內結束 → 廣播 1 次、return true
|
||||
// 3. 有 active task、timeout → 廣播 1 次、return false
|
||||
// 4. nil service → 防呆 return true
|
||||
// 5. nil notifier / nil logger → 仍正常 return(不 panic)
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeLifecycle 是 FirmwareLifecycle 的 test double。
|
||||
type fakeLifecycle struct {
|
||||
mu sync.Mutex
|
||||
|
||||
hasActive bool
|
||||
activeTaskInfos []*ActiveTaskInfo
|
||||
|
||||
// 觀察:method call 次數
|
||||
hasActiveCalls int
|
||||
getInfoCalls int
|
||||
requestShutdown int
|
||||
waitForActive int
|
||||
waitForActiveDur time.Duration
|
||||
|
||||
// 控制 WaitForActiveTasks 回傳
|
||||
waitResult bool
|
||||
}
|
||||
|
||||
func (f *fakeLifecycle) HasActiveTask() bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.hasActiveCalls++
|
||||
return f.hasActive
|
||||
}
|
||||
|
||||
func (f *fakeLifecycle) GetActiveTaskInfo() []*ActiveTaskInfo {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.getInfoCalls++
|
||||
return f.activeTaskInfos
|
||||
}
|
||||
|
||||
func (f *fakeLifecycle) RequestShutdown() {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.requestShutdown++
|
||||
}
|
||||
|
||||
func (f *fakeLifecycle) WaitForActiveTasks(maxWait time.Duration) bool {
|
||||
f.mu.Lock()
|
||||
f.waitForActive++
|
||||
f.waitForActiveDur = maxWait
|
||||
res := f.waitResult
|
||||
f.mu.Unlock()
|
||||
return res
|
||||
}
|
||||
|
||||
// fakeNotifier 觀察 BroadcastToRoom 呼叫。
|
||||
type fakeNotifier struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
lastRoom string
|
||||
lastData interface{}
|
||||
}
|
||||
|
||||
func (n *fakeNotifier) BroadcastToRoom(room string, data interface{}) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.calls++
|
||||
n.lastRoom = room
|
||||
n.lastData = data
|
||||
}
|
||||
|
||||
// fakeLogger 觀察 Info / Warn 呼叫(內容檢查不嚴格、只看「有被呼叫」)。
|
||||
type fakeLogger struct {
|
||||
mu sync.Mutex
|
||||
infoMsgs []string
|
||||
warnMsgs []string
|
||||
}
|
||||
|
||||
func (l *fakeLogger) Info(format string, args ...interface{}) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.infoMsgs = append(l.infoMsgs, format)
|
||||
}
|
||||
|
||||
func (l *fakeLogger) Warn(format string, args ...interface{}) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.warnMsgs = append(l.warnMsgs, format)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 1. 沒 active task → 立刻 return true
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAwaitActiveTasksOrTimeout_NoActiveTask(t *testing.T) {
|
||||
svc := &fakeLifecycle{hasActive: false}
|
||||
notifier := &fakeNotifier{}
|
||||
logger := &fakeLogger{}
|
||||
|
||||
clean := AwaitActiveTasksOrTimeout(context.Background(), svc, notifier, logger)
|
||||
|
||||
if !clean {
|
||||
t.Errorf("expected cleanShutdown=true when no active task, got false")
|
||||
}
|
||||
if svc.hasActiveCalls != 1 {
|
||||
t.Errorf("expected HasActiveTask called once, got %d", svc.hasActiveCalls)
|
||||
}
|
||||
if svc.requestShutdown != 0 {
|
||||
t.Errorf("expected RequestShutdown NOT called when no active task, got %d", svc.requestShutdown)
|
||||
}
|
||||
if svc.waitForActive != 0 {
|
||||
t.Errorf("expected WaitForActiveTasks NOT called when no active task, got %d", svc.waitForActive)
|
||||
}
|
||||
if notifier.calls != 0 {
|
||||
t.Errorf("expected no broadcast when no active task, got %d calls", notifier.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 2. 有 active task、Wait 內結束 → 廣播 1 次、return true
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAwaitActiveTasksOrTimeout_ActiveTaskFinishesCleanly(t *testing.T) {
|
||||
tasks := []*ActiveTaskInfo{
|
||||
{TaskID: "upgrade-KL520-0", DeviceID: "kl520-0", Chip: ChipKL520, Direction: DirectionUpgrade, Stage: StageFlashing},
|
||||
}
|
||||
svc := &fakeLifecycle{
|
||||
hasActive: true,
|
||||
activeTaskInfos: tasks,
|
||||
waitResult: true, // task 在 timeout 前結束
|
||||
}
|
||||
notifier := &fakeNotifier{}
|
||||
logger := &fakeLogger{}
|
||||
|
||||
clean := AwaitActiveTasksOrTimeout(context.Background(), svc, notifier, logger)
|
||||
|
||||
if !clean {
|
||||
t.Errorf("expected cleanShutdown=true when WaitForActiveTasks returns true")
|
||||
}
|
||||
if svc.requestShutdown != 1 {
|
||||
t.Errorf("expected RequestShutdown called once, got %d", svc.requestShutdown)
|
||||
}
|
||||
if svc.waitForActive != 1 {
|
||||
t.Errorf("expected WaitForActiveTasks called once, got %d", svc.waitForActive)
|
||||
}
|
||||
if svc.waitForActiveDur != MaxShutdownWait {
|
||||
t.Errorf("expected wait dur=%v, got %v", MaxShutdownWait, svc.waitForActiveDur)
|
||||
}
|
||||
if notifier.calls != 1 {
|
||||
t.Errorf("expected broadcast called once, got %d", notifier.calls)
|
||||
}
|
||||
if notifier.lastRoom != ShutdownRoom {
|
||||
t.Errorf("expected broadcast room=%q, got %q", ShutdownRoom, notifier.lastRoom)
|
||||
}
|
||||
// 驗 broadcast payload schema
|
||||
payload, ok := notifier.lastData.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("broadcast payload not map[string]interface{}: %T", notifier.lastData)
|
||||
}
|
||||
if payload["type"] != ShutdownEventTypePending {
|
||||
t.Errorf("expected payload.type=%q, got %v", ShutdownEventTypePending, payload["type"])
|
||||
}
|
||||
if _, ok := payload["tasks"]; !ok {
|
||||
t.Errorf("expected payload.tasks key present")
|
||||
}
|
||||
// Minor-3:tasks 應為 minimal struct slice、不是 *ActiveTaskInfo
|
||||
broadcastTasks, ok := payload["tasks"].([]shutdownBroadcastTask)
|
||||
if !ok {
|
||||
t.Fatalf("expected payload.tasks to be []shutdownBroadcastTask (without StartTs), got %T", payload["tasks"])
|
||||
}
|
||||
if len(broadcastTasks) != 1 {
|
||||
t.Fatalf("expected 1 broadcast task, got %d", len(broadcastTasks))
|
||||
}
|
||||
if broadcastTasks[0].TaskID != "upgrade-KL520-0" {
|
||||
t.Errorf("expected broadcast task TaskID preserved, got %q", broadcastTasks[0].TaskID)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 2a. Minor-3 verification:broadcast payload 不含 startTs 欄位(避免時間戳洩漏)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAwaitActiveTasksOrTimeout_BroadcastFiltersStartTs(t *testing.T) {
|
||||
// 帶非零 StartTs 的 task — 驗 broadcast 端確實過濾掉
|
||||
now := time.Now()
|
||||
tasks := []*ActiveTaskInfo{
|
||||
{
|
||||
TaskID: "upgrade-KL720-0",
|
||||
DeviceID: "kl720-0",
|
||||
DeviceName: "KL720 dev",
|
||||
Chip: ChipKL720,
|
||||
Direction: DirectionUpgrade,
|
||||
Stage: StageFlashing,
|
||||
StartTs: now,
|
||||
ElapsedMs: 5000,
|
||||
EtaSeconds: 30,
|
||||
},
|
||||
}
|
||||
svc := &fakeLifecycle{
|
||||
hasActive: true,
|
||||
activeTaskInfos: tasks,
|
||||
waitResult: true,
|
||||
}
|
||||
notifier := &fakeNotifier{}
|
||||
|
||||
_ = AwaitActiveTasksOrTimeout(context.Background(), svc, notifier, nil)
|
||||
|
||||
// 1. broadcast 一定要被呼叫
|
||||
if notifier.calls != 1 {
|
||||
t.Fatalf("expected broadcast called once, got %d", notifier.calls)
|
||||
}
|
||||
|
||||
// 2. payload tasks 必須是 minimal struct slice
|
||||
payload, ok := notifier.lastData.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("payload not map[string]interface{}: %T", notifier.lastData)
|
||||
}
|
||||
broadcastTasks, ok := payload["tasks"].([]shutdownBroadcastTask)
|
||||
if !ok {
|
||||
t.Fatalf("expected []shutdownBroadcastTask (no StartTs), got %T — possible regression to *ActiveTaskInfo", payload["tasks"])
|
||||
}
|
||||
if len(broadcastTasks) != 1 {
|
||||
t.Fatalf("expected 1 task, got %d", len(broadcastTasks))
|
||||
}
|
||||
|
||||
// 3. 序列化後驗 JSON 不含 "startTs" key(與直接 marshal *ActiveTaskInfo 對比)
|
||||
jsonBytes, err := json.Marshal(broadcastTasks[0])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal broadcastTask failed: %v", err)
|
||||
}
|
||||
if strings.Contains(string(jsonBytes), "startTs") {
|
||||
t.Errorf("broadcast payload should NOT contain 'startTs' field; got JSON: %s", string(jsonBytes))
|
||||
}
|
||||
|
||||
// 4. 其他欄位確實保留(taskId / deviceId / deviceName / chip / direction / stage / elapsedMs / etaSeconds 七個)
|
||||
bt := broadcastTasks[0]
|
||||
if bt.TaskID != "upgrade-KL720-0" {
|
||||
t.Errorf("TaskID lost: %q", bt.TaskID)
|
||||
}
|
||||
if bt.DeviceID != "kl720-0" {
|
||||
t.Errorf("DeviceID lost: %q", bt.DeviceID)
|
||||
}
|
||||
if bt.DeviceName != "KL720 dev" {
|
||||
t.Errorf("DeviceName lost: %q", bt.DeviceName)
|
||||
}
|
||||
if bt.Chip != ChipKL720 {
|
||||
t.Errorf("Chip lost: %q", bt.Chip)
|
||||
}
|
||||
if bt.Direction != DirectionUpgrade {
|
||||
t.Errorf("Direction lost: %q", bt.Direction)
|
||||
}
|
||||
if bt.Stage != StageFlashing {
|
||||
t.Errorf("Stage lost: %q", bt.Stage)
|
||||
}
|
||||
if bt.ElapsedMs != 5000 {
|
||||
t.Errorf("ElapsedMs lost: %d", bt.ElapsedMs)
|
||||
}
|
||||
if bt.EtaSeconds != 30 {
|
||||
t.Errorf("EtaSeconds lost: %d", bt.EtaSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 2b. Minor-3 helper:toBroadcastTasks 處理 nil 與空 slice
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestToBroadcastTasks_NilAndEmpty(t *testing.T) {
|
||||
// 空 slice → 回空 slice(非 nil、避免 frontend null)
|
||||
out := toBroadcastTasks(nil)
|
||||
if out == nil {
|
||||
t.Errorf("toBroadcastTasks(nil) should return non-nil slice")
|
||||
}
|
||||
if len(out) != 0 {
|
||||
t.Errorf("toBroadcastTasks(nil) should return empty slice, got %d", len(out))
|
||||
}
|
||||
|
||||
// 含 nil pointer 的 slice → 跳過 nil
|
||||
mixed := []*ActiveTaskInfo{
|
||||
nil,
|
||||
{TaskID: "t1", Chip: ChipKL520},
|
||||
nil,
|
||||
}
|
||||
out = toBroadcastTasks(mixed)
|
||||
if len(out) != 1 {
|
||||
t.Errorf("expected 1 valid task (nil entries filtered), got %d", len(out))
|
||||
}
|
||||
if out[0].TaskID != "t1" {
|
||||
t.Errorf("expected TaskID=t1, got %q", out[0].TaskID)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 3. 有 active task、timeout → 廣播 1 次、return false、走強制 shutdown
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAwaitActiveTasksOrTimeout_ActiveTaskTimeout(t *testing.T) {
|
||||
// 縮短 MaxShutdownWait 讓測試快
|
||||
origMax := MaxShutdownWait
|
||||
MaxShutdownWait = 10 * time.Millisecond
|
||||
defer func() { MaxShutdownWait = origMax }()
|
||||
|
||||
svc := &fakeLifecycle{
|
||||
hasActive: true,
|
||||
activeTaskInfos: []*ActiveTaskInfo{{TaskID: "stuck", Chip: ChipKL720}},
|
||||
waitResult: false, // 等不到 task 結束
|
||||
}
|
||||
notifier := &fakeNotifier{}
|
||||
logger := &fakeLogger{}
|
||||
|
||||
clean := AwaitActiveTasksOrTimeout(context.Background(), svc, notifier, logger)
|
||||
|
||||
if clean {
|
||||
t.Errorf("expected cleanShutdown=false when WaitForActiveTasks times out")
|
||||
}
|
||||
if notifier.calls != 1 {
|
||||
t.Errorf("expected broadcast called once even on timeout, got %d", notifier.calls)
|
||||
}
|
||||
// timeout 應該觸發 Warn log(不是只有 Info)
|
||||
if len(logger.warnMsgs) == 0 {
|
||||
t.Errorf("expected at least one Warn log on timeout, got none")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 4. nil service → 防呆 return true(視為「乾淨」、main 繼續走 shutdown)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAwaitActiveTasksOrTimeout_NilService(t *testing.T) {
|
||||
clean := AwaitActiveTasksOrTimeout(context.Background(), nil, nil, &fakeLogger{})
|
||||
if !clean {
|
||||
t.Errorf("expected cleanShutdown=true with nil service (defensive default)")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 5. nil notifier / nil logger → 仍正常 return(不 panic)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAwaitActiveTasksOrTimeout_NilNotifierAndLogger(t *testing.T) {
|
||||
svc := &fakeLifecycle{hasActive: true, activeTaskInfos: nil, waitResult: true}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("panicked with nil notifier/logger: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
clean := AwaitActiveTasksOrTimeout(context.Background(), svc, nil, nil)
|
||||
if !clean {
|
||||
t.Errorf("expected cleanShutdown=true (waitResult=true)")
|
||||
}
|
||||
if svc.requestShutdown != 1 {
|
||||
t.Errorf("expected RequestShutdown still called with nil notifier, got %d", svc.requestShutdown)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 6. 真接整合:用 real *Service 驗 helper 整條走通(不用 fake lifecycle)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAwaitActiveTasksOrTimeout_RealServiceNoActive(t *testing.T) {
|
||||
svc := NewService(&fakeLookup{drivers: map[string]UpgradeDriver{}}, FirmwareDir{Root: "/tmp"})
|
||||
notifier := &fakeNotifier{}
|
||||
|
||||
clean := AwaitActiveTasksOrTimeout(context.Background(), svc, notifier, nil)
|
||||
if !clean {
|
||||
t.Errorf("expected cleanShutdown=true on fresh service with no tasks")
|
||||
}
|
||||
if notifier.calls != 0 {
|
||||
t.Errorf("expected no broadcast on fresh service, got %d", notifier.calls)
|
||||
}
|
||||
}
|
||||
@ -315,12 +315,35 @@ func main() {
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// Handle OS signals for graceful shutdown
|
||||
// Handle OS signals for graceful shutdown.
|
||||
//
|
||||
// M9-4.5(TDD §8.6.1 + §8.6.3):firmware-aware shutdown
|
||||
// 1. 若有 active firmware task(升降版進行中、寫 flash 中):
|
||||
// - broadcast `server:shutdown-pending` 給 WebSocket client
|
||||
// - 呼叫 firmwareSvc.RequestShutdown() 拒絕新 task
|
||||
// - WaitForActiveTasks(180s) 等到既有 task 結束或 timeout
|
||||
// - 不論清不清乾淨、最後都走 shutdownFn(避免無限等下去把 process 卡死)
|
||||
// 2. 若沒 active task:立刻走原本 shutdownFn 流程
|
||||
//
|
||||
// 邏輯本身在 firmware.AwaitActiveTasksOrTimeout 提供(測試友善)、本檔只
|
||||
// 負責 wiring。timeout 期間 server 仍在跑(不 accept 新 task 但其他 HTTP
|
||||
// 照常)、確保 Wails 視窗端能查到 firmware status 才好顯示 modal。
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
sig := <-quit
|
||||
logger.Info("Received signal %v, shutting down gracefully...", sig)
|
||||
logger.Info("Received signal %v, evaluating firmware lifecycle before shutdown...", sig)
|
||||
|
||||
// 等到既有 firmware task 結束(或 hard timeout 180s 後強制走)。
|
||||
// 不論 clean 與否、最後都走 shutdownFn——helper 已 log warning。
|
||||
_ = firmware.AwaitActiveTasksOrTimeout(
|
||||
context.Background(),
|
||||
firmwareSvc,
|
||||
wsHub,
|
||||
logger,
|
||||
)
|
||||
|
||||
logger.Info("Proceeding with graceful shutdown after firmware checks")
|
||||
shutdownFn()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
@ -128,6 +128,11 @@ type App struct {
|
||||
// 成 no-op 或 spy。生產環境 restartStartFn == nil → 走預設的 a.ctrl.Start()。
|
||||
// test-only;正式環境不應設定。
|
||||
restartStartFn func() error
|
||||
|
||||
// M9-4.5:firmware-aware close guard(TDD §8.6.2)。
|
||||
// 韌體升降版進行中時擋住 OnBeforeClose、emit event 給前端攔截 modal。
|
||||
// 由 NewApp 初始化、shutdown 後保留(thread-safe via 自身 mu)。
|
||||
firmwareCloseGuard *FirmwareCloseGuard
|
||||
}
|
||||
|
||||
// NewApp 建立 App 實例。
|
||||
@ -147,7 +152,8 @@ func NewApp() *App {
|
||||
}
|
||||
// R5-5a:沒插硬體就顯示空白狀態(由 UI 處理),一律真實硬體路徑
|
||||
return &App{
|
||||
pythonMode: mode,
|
||||
pythonMode: mode,
|
||||
firmwareCloseGuard: NewFirmwareCloseGuard(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
244
local-tool/visiona-local/firmware_close_guard.go
Normal file
244
local-tool/visiona-local/firmware_close_guard.go
Normal file
@ -0,0 +1,244 @@
|
||||
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
|
||||
}
|
||||
280
local-tool/visiona-local/firmware_close_guard_test.go
Normal file
280
local-tool/visiona-local/firmware_close_guard_test.go
Normal file
@ -0,0 +1,280 @@
|
||||
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)
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
@ -26,12 +25,17 @@ func main() {
|
||||
},
|
||||
OnStartup: app.startup,
|
||||
OnShutdown: app.shutdown,
|
||||
// M8-4 / R5-2:關視窗 = 結束 server + 結束 app。
|
||||
// 不 hide-to-tray、不彈確認對話框。回 false 允許 Wails 繼續走 OnShutdown 流程。
|
||||
// OnShutdown 會呼叫 ctrl.Stop()(7 秒 grace + 1 秒 modal)。
|
||||
OnBeforeClose: func(ctx context.Context) (prevent bool) {
|
||||
return false
|
||||
},
|
||||
// M8-4 / R5-2:關視窗 = 結束 server + 結束 app(預設行為)。
|
||||
// 不 hide-to-tray、不彈系統 native 對話框。
|
||||
//
|
||||
// M9-4.5(TDD §8.6.2):firmware-aware close guard。
|
||||
// - 韌體升降版進行中 → app.OnBeforeClose 回 true、擋住關閉、emit
|
||||
// `app:firmware-in-progress` event 給前端 Design §6a 攔截 modal
|
||||
// - 使用者通過 §6a 第二層 FORCE 確認 → 前端呼叫 ConfirmForceClose
|
||||
// binding → 設旗標 → 觸發 Wails Quit → OnBeforeClose 再被叫 → 放行
|
||||
// - 沒韌體任務 → 直接 return false、走 OnShutdown → ctrl.Stop()
|
||||
// 既有 7+1s graceful shutdown
|
||||
OnBeforeClose: app.OnBeforeClose,
|
||||
Bind: []interface{}{
|
||||
app,
|
||||
},
|
||||
|
||||
111
local-tool/visiona-local/query_firmware_active_tasks.go
Normal file
111
local-tool/visiona-local/query_firmware_active_tasks.go
Normal file
@ -0,0 +1,111 @@
|
||||
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
|
||||
}
|
||||
250
local-tool/visiona-local/query_firmware_active_tasks_test.go
Normal file
250
local-tool/visiona-local/query_firmware_active_tasks_test.go
Normal file
@ -0,0 +1,250 @@
|
||||
package main
|
||||
|
||||
// query_firmware_active_tasks_test.go — M9-4.5:firmware 查詢 helper 測試
|
||||
//
|
||||
// 覆蓋情境:
|
||||
// 1. port <= 0 → 立即 return hasActive=false(fail-open 預設值)
|
||||
// 2. server 正常回 hasActive=true + tasks → 解析正確
|
||||
// 3. server 正常回 hasActive=false + 空 tasks → tasks 非 nil(避免 caller 處理 nil panic)
|
||||
// 4. server 回 500 → fail-open(hasActive=false)+ error
|
||||
// 5. server timeout → fail-open + error(不卡 Wails 關閉流程)
|
||||
// 6. server 回非預期 JSON → fail-open + error
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func portFromTestServerURL(t *testing.T, raw string) int {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse test server url: %v", err)
|
||||
}
|
||||
p, err := strconv.Atoi(u.Port())
|
||||
if err != nil {
|
||||
t.Fatalf("port atoi: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 1. port <= 0 → 立即 fail-open
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestQueryFirmwareActiveTasks_PortZero(t *testing.T) {
|
||||
res, err := queryFirmwareActiveTasks(context.Background(), 0)
|
||||
if err == nil {
|
||||
t.Errorf("expected error when port=0")
|
||||
}
|
||||
if res.HasActive {
|
||||
t.Errorf("expected hasActive=false on port=0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryFirmwareActiveTasks_PortNegative(t *testing.T) {
|
||||
res, err := queryFirmwareActiveTasks(context.Background(), -1)
|
||||
if err == nil {
|
||||
t.Errorf("expected error when port<0")
|
||||
}
|
||||
if res.HasActive {
|
||||
t.Errorf("expected hasActive=false on port<0")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 2. server 正常回 hasActive=true + tasks
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestQueryFirmwareActiveTasks_HasActiveWithTasks(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/firmware/active-tasks" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"hasActive": true,
|
||||
"tasks": []map[string]interface{}{
|
||||
{
|
||||
"taskId": "upgrade-KL520-0",
|
||||
"deviceId": "kl520-0",
|
||||
"deviceName": "KL520 #1",
|
||||
"chip": "KL520",
|
||||
"direction": "upgrade",
|
||||
"stage": "flashing",
|
||||
"elapsedMs": int64(12000),
|
||||
"etaSeconds": 45,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
port := portFromTestServerURL(t, srv.URL)
|
||||
res, err := queryFirmwareActiveTasks(context.Background(), port)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !res.HasActive {
|
||||
t.Errorf("expected hasActive=true")
|
||||
}
|
||||
if len(res.Tasks) != 1 {
|
||||
t.Fatalf("expected 1 task, got %d", len(res.Tasks))
|
||||
}
|
||||
tk := res.Tasks[0]
|
||||
if tk.TaskID != "upgrade-KL520-0" || tk.DeviceID != "kl520-0" || tk.Chip != "KL520" {
|
||||
t.Errorf("task decoded wrong: %+v", tk)
|
||||
}
|
||||
if tk.Direction != "upgrade" || tk.Stage != "flashing" {
|
||||
t.Errorf("task direction/stage decoded wrong: %+v", tk)
|
||||
}
|
||||
if tk.EtaSeconds != 45 {
|
||||
t.Errorf("expected etaSeconds=45, got %d", tk.EtaSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 3. server 回 hasActive=false + null tasks → tasks 非 nil
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestQueryFirmwareActiveTasks_HasActiveFalseNullTasks(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"hasActive": false,
|
||||
"tasks": nil,
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
port := portFromTestServerURL(t, srv.URL)
|
||||
res, err := queryFirmwareActiveTasks(context.Background(), port)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.HasActive {
|
||||
t.Errorf("expected hasActive=false")
|
||||
}
|
||||
if res.Tasks == nil {
|
||||
t.Errorf("expected Tasks to be non-nil (empty slice), got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 4. server 回 500 → fail-open
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestQueryFirmwareActiveTasks_ServerError500(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = fmt.Fprintln(w, "internal error")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
port := portFromTestServerURL(t, srv.URL)
|
||||
res, err := queryFirmwareActiveTasks(context.Background(), port)
|
||||
if err == nil {
|
||||
t.Errorf("expected error on 500")
|
||||
}
|
||||
if res.HasActive {
|
||||
t.Errorf("expected hasActive=false on 500 (fail-open)")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 5. server timeout → fail-open + error
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestQueryFirmwareActiveTasks_Timeout(t *testing.T) {
|
||||
// 縮短 helper timeout
|
||||
origTimeout := queryFirmwareTasksTimeout
|
||||
origClient := queryFirmwareTasksClient
|
||||
queryFirmwareTasksTimeout = 100 * time.Millisecond
|
||||
queryFirmwareTasksClient = &http.Client{Timeout: queryFirmwareTasksTimeout}
|
||||
defer func() {
|
||||
queryFirmwareTasksTimeout = origTimeout
|
||||
queryFirmwareTasksClient = origClient
|
||||
}()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
port := portFromTestServerURL(t, srv.URL)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
res, err := queryFirmwareActiveTasks(context.Background(), port)
|
||||
if err == nil {
|
||||
t.Errorf("expected timeout error")
|
||||
}
|
||||
if res.HasActive {
|
||||
t.Errorf("expected hasActive=false on timeout (fail-open)")
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// ok
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("queryFirmwareActiveTasks blocked > 2s, timeout not respected")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 6. server 回非預期 JSON → fail-open + error
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestQueryFirmwareActiveTasks_MalformedJSON(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte("{this is not json"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
port := portFromTestServerURL(t, srv.URL)
|
||||
res, err := queryFirmwareActiveTasks(context.Background(), port)
|
||||
if err == nil {
|
||||
t.Errorf("expected decode error on malformed JSON")
|
||||
}
|
||||
if res.HasActive {
|
||||
t.Errorf("expected hasActive=false on decode error (fail-open)")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 7. success=false 即使 200 → 視為 error
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestQueryFirmwareActiveTasks_SuccessFalse(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"data": map[string]interface{}{"hasActive": false},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
port := portFromTestServerURL(t, srv.URL)
|
||||
res, err := queryFirmwareActiveTasks(context.Background(), port)
|
||||
if err == nil {
|
||||
t.Errorf("expected error when success=false")
|
||||
}
|
||||
if res.HasActive {
|
||||
t.Errorf("expected hasActive=false (fail-open)")
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user