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:
jim800121chen 2026-05-25 15:07:29 +08:00
parent 06ff2fe987
commit ff9bbc81ed
13 changed files with 2000 additions and 30 deletions

View File

@ -389,7 +389,14 @@ Banner **不可手動關閉**(避免使用者忽略問題)。只有下列條
``` ```
- 「FORCE」輸入框比對與 firmware-management §6.1 「DOWNGRADE」相同邏輯嚴格 `===` 比對) - 「FORCE」輸入框比對與 firmware-management §6.1 「DOWNGRADE」相同邏輯嚴格 `===` 比對)
- 「確認強制關閉」按鈕在輸入 `FORCE` 前 disabled、輸入正確後變為 `destructive` 紅色 - 「確認強制關閉」按鈕在輸入 `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 保留視窗 **ESC 鍵**:等同點「繼續等待」(安全路徑)、關 modal 保留視窗
@ -461,11 +468,19 @@ Banner **不可手動關閉**(避免使用者忽略問題)。只有下列條
### 6a.11 與 Architect TDD §8.6 的銜接 ### 6a.11 與 Architect TDD §8.6 的銜接
- TDD §8.6 提供 `HasActiveTask()` method 與 server graceful shutdown 拒絕邏輯 - TDD §8.6 提供 server 端 `firmware.Service.HasActiveTask()` method 與 SIGTERM handler firmware-aware graceful 流程
- 控制台側透過既有 IPC或新增一個 `/api/firmware/status` 端點)查詢 active task - **韌體進行中偵測Wails close handler 端、實作 §8.6.2**
- 取得 active task 資訊(`deviceName / stage / etaSeconds / direction`)後渲染本節 modal - `OnBeforeClose` hook 觸發後、Wails 後端呼叫 `queryFirmwareActiveTasks()`、以 HTTP GET `http://127.0.0.1:{serverPort}/api/firmware/active-tasks`1s timeout、fail-open取得 active task 清單
- 「強制關閉」確認後、控制台需呼叫 server `force-shutdown` IPC methodbypass graceful shutdown、server 收到後直接送 SIGKILL 給 Python sidecar、不等 firmware task 完成 - 有 active task → Wails 後端透過 `wailsRuntime.EventsEmit(ctx, "app:firmware-in-progress", payload)` 推送事件給前端
- 詳細 IPC 規格依 TDD §8.6 規範Frontend M8-5 階段對接) - 前端 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 graceful7+1spath、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
--- ---

View File

@ -564,25 +564,31 @@ R5-B4 已有「Kneron 預置模型 re-distribution 授權」未解決問題。Fi
| `needsReset` flag | 本期**重用**FW 升降版完成後設 `true`、下次 connect 走完整 reset | | `needsReset` flag | 本期**重用**FW 升降版完成後設 `true`、下次 connect 走完整 reset |
| WS room 命名規範 | 新 `firmware:<id>`、跟既有 `flash:<id>` / `inference:<id>` 並列 | | 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 視窗**。 **問題背景**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 收到 SIGTERMWails close handler、systemd、`kill -TERM` 等): 當 server 收到 SIGTERMWails close handler、systemd、`kill -TERM` 等):
1. server 進 `shutting_down` state、停止 accept 新 HTTP 連線 1. **檢查 `firmware.Service.HasActiveTask()` 是否回 true**(任一 device 在 `StatusUpgrading` / `StatusDowngrading`
2. **檢查 `firmware.Service.HasActiveTask()` 是否回 true**(任一 device 在 `StatusUpgrading` / `StatusDowngrading` 2. 若有 active task — **延遲關閉、不立即終止**
3. 若有 active task - 透過 WebSocket `server:shutdown-pending` event 廣播到 `"system"` room
- server **延遲 graceful shutdown**、不殺 Python sidecar payload schema`{"type": "server:shutdown-pending", "tasks": [...ActiveTaskInfo]}`
- 透過 WebSocket `firmware:shutdown-rejected` event broadcast 給所有訂閱者 `tasks` 內欄位由 `firmware.Service.GetActiveTaskInfo()` 提供、**不含** `startTs`
- 持續等待 FW task 完成success / error 任一終態)或最多 180s timeoutKL720 升級的硬上界) 避免時間戳洩漏 — startTs 由 Frontend 自行依 `elapsedMs` 推算、見 Reviewer Minor-3
- FW task 完成後 → 走原本 7+1s graceful shutdown 流程 - 呼叫 `firmware.Service.RequestShutdown()` 設旗標、拒絕**新** firmware task 開始(既有 task 讓它自然跑完、不主動 cancel
- 180s timeout 後 → 仍未完成(罕見、可能 device 已 brick→ 強制走 shutdown、log 警告 - 呼叫 `firmware.Service.WaitForActiveTasks(180s)` 等所有既有 task 結束或 180s timeoutKL720 升級的硬上界)
4. 若無 active task正常 graceful shutdown既有 7+1s pattern - **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 控制台端攔截 #### 8.6.2 Wails 控制台端攔截
@ -613,7 +619,7 @@ Wails app 的 `OnBeforeClose` handler 改造:
| 使用者強制 `kill -9` / 工作管理員強殺 → 仍可能 brick | 屬接受的取捨、Design Spec R-FW-11 已聲明 | | 使用者強制 `kill -9` / 工作管理員強殺 → 仍可能 brick | 屬接受的取捨、Design Spec R-FW-11 已聲明 |
| 180s timeout 內 FW task 真的卡死 → server 永遠不關 | 180s hard timeout 後強制走 shutdown | | 180s timeout 內 FW task 真的卡死 → server 永遠不關 | 180s hard timeout 後強制走 shutdown |
| modal 阻擋使用者關 app 體驗困擾 | 接受、brick 風險 > 體驗困擾、且 modal 顯示 ETA 給使用者預期 | | 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 工時影響 #### 8.6.5 工時影響

View File

@ -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-1event 名 + 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 200stype.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-1spec 與實作對 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 methodbypass 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 shutdownserver 端 firmware-aware 180s timeout 保護」、刪掉「SIGKILL bypass」字眼。Code 註解firmware_close_guard.go:225-229已說明理由、寫得清楚、改 spec 採用實作的設計即可
或者反過來改 code 對齊 spec、但 reviewer 不建議——實作的設計更安全。
---
## 🟡 Minor
### Minor-1MaxShutdownWait 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 應該設 240s200s timeout + 30s margin + 10s buffer
**建議**MaxShutdownWait 提高到 220s 或 240s、註解說明來源。
### Minor-2Second 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-3WS 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 structtaskId / 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-1shutdown.go 三個 interface 設計取捨
3 個 interfaceShutdownNotifier / FirmwareLifecycle / ShutdownLogger拆得乾淨、好測。**但有過度抽象的疑慮**——`FirmwareLifecycle` 只有 helper 用、production 唯一實作者是 `*Service`、改名為 `*Service` direct 反而更清楚。建議保留現狀(測試友善 > 過度抽象 trade-off 在這個尺度可接受),但日後若再加類似 helper、可考慮共用一個 `ServiceSnapshot` interface。
### S-2firmware_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-3query_firmware_active_tasks.go 1s timeout 是否會 false negative
註解line 11-12「1 秒 timeoutserver 沒起來 / 卡死 / network error 全視為『無 active task』」。**極端情境**server 在升級 KL720 firmwarePython 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 應該 < 10msproduction load 上若有問題再加 retry-once 或拉到 3s不阻擋 M9-5
### S-4fakeLifecycle.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 不對 specMajor-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 truetaskWg 計數歸零、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.go6 tests
| Test | 情境 | 品質 |
|------|------|------|
| NoActiveTask | 沒 task 立刻 return true | ✅ 驗 hasActiveCalls / requestShutdown / waitForActive / notifier 都對 |
| ActiveTaskFinishesCleanly | 有 task、Wait return true | ✅ 驗 broadcast room + payload schematype / tasks key |
| ActiveTaskTimeout | 有 task、Wait timeout | ✅ MaxShutdownWait reset / Warn 觸發 |
| NilService | nil service 防呆 | ✅ |
| NilNotifierAndLogger | nil 不 panic | ✅ recover guard |
| RealServiceNoActive | 真接 Service 整合 | ✅ 驗 wire-up 不踩 nil |
**漏掉的情境**(非阻擋):
- 連續兩次呼叫 AwaitActiveTasksOrTimeoutsecond SIGTERM 場景)
- ctx 被 cancel 的行為(目前 helper 收 ctx 但沒用、預留給未來)
### query_firmware_active_tasks_test.go7 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.go8 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.1loopback 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 goroutinemain.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 = 100hub.go:56✅ 不會卡 SIGTERM goroutine
- hub.Run goroutine 在 server lifetime 一直跑、不會在 shutdown 開始前先死、broadcast 至少能進 channel ✅
無 race / 無 deadlock 風險。
---
## 是否阻擋 M9-5
**不阻擋**
理由:
- 核心 SIGTERM 流程正確、測試覆蓋好、有雙層保護兜底
- 1 個 Majorspec 不對齊)屬文件層面、不是功能 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 / 密碼變更、無新對外 APIactive-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:` roomConfirmForceClose 改成直接 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 6RealServiceNoActive整合驗 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 numberTDD §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

View File

@ -291,7 +291,42 @@
- S-1/S-2/S-4 backend 不修分析確認合理 - S-1/S-2/S-4 backend 不修分析確認合理
- 3 個極小 Suggestion 全部 backend 不需處理(純評估) - 3 個極小 Suggestion 全部 backend 不需處理(純評估)
- [x] **M9-3 整體完成**2026-05-25→ 通過、可進 M9-4 - [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 1spec/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 MinorMaxShutdownWait 180s vs KL720 230s 不對齊 / Second SIGTERM 不處理 / broadcast 含 startTs 洩漏)
- 4 Suggestioninterface 微過度抽象 / 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 → 220sKL720 200s upgrade + 20s buffer+ 註解說明
- Minor 3新增 `shutdownBroadcastTask` minimal struct8 欄、不含 startTs+ `toBroadcastTasks()` helper + broadcast 走過濾路徑
- shutdown.go +57 / shutdown_test.go +126 / 2 新 testfilter 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 - [x] **M9-4 Frontend FW badge + 升級 modal 完成**2026-05-25
- 12 新增 + 4 修改 = 16 檔、3052 行 - 12 新增 + 4 修改 = 16 檔、3052 行
- **新增 i18n**firmware.* 52 keys + devices.card.fwBadge.* 5 keys = 57 leaf × 2 lang = 114 翻譯字串 - **新增 i18n**firmware.* 52 keys + devices.card.fwBadge.* 5 keys = 57 leaf × 2 lang = 114 翻譯字串

View File

@ -0,0 +1,212 @@
package firmware
// shutdown.go — M9-4.5firmware-aware graceful shutdown helperTDD §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 taskservice 層 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.HubBroadcastToRoom。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已具備此三個 methodtest 用 fake。
type FirmwareLifecycle interface {
HasActiveTask() bool
GetActiveTaskInfo() []*ActiveTaskInfo
RequestShutdown()
WaitForActiveTasks(maxWait time.Duration) bool
}
// ShutdownLogger 是 helper 用來印 log 的介面(避免硬綁特定 logger 實作)。
// 實作方server pkg/logger.Logger具備 Info / Warntest 可省略(傳 nil
type ShutdownLogger interface {
Info(format string, args ...interface{})
Warn(format string, args ...interface{})
}
// shutdownBroadcastTask 是 broadcast 給 WS client 的最小 task 描述、刻意比
// ActiveTaskInfo 少 StartTs 欄位(第 1 輪 Reviewer Minor-3StartTs 是 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 boundTDD §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=falsetimeout、強制走、log warning
//
// caller 應在 return 後接著走原本的 shutdownFnkill httpServer / stop services
// 不論 cleanShutdown true / false 都要走。cleanShutdown 只是給 log / 觀測用。
//
// notifier / logger 可為 nilnotifier=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 整個 waitM9-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 過濾掉 StartTstime.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
}

View File

@ -0,0 +1,384 @@
package firmware
// shutdown_test.go — M9-4.5firmware-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-3tasks 應為 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 verificationbroadcast 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 helpertoBroadcastTasks 處理 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)
}
}

View File

@ -315,12 +315,35 @@ func main() {
Handler: r, Handler: r,
} }
// Handle OS signals for graceful shutdown // Handle OS signals for graceful shutdown.
//
// M9-4.5TDD §8.6.1 + §8.6.3firmware-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) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() { go func() {
sig := <-quit 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() shutdownFn()
os.Exit(0) os.Exit(0)
}() }()

View File

@ -128,6 +128,11 @@ type App struct {
// 成 no-op 或 spy。生產環境 restartStartFn == nil → 走預設的 a.ctrl.Start()。 // 成 no-op 或 spy。生產環境 restartStartFn == nil → 走預設的 a.ctrl.Start()。
// test-only正式環境不應設定。 // test-only正式環境不應設定。
restartStartFn func() error restartStartFn func() error
// M9-4.5firmware-aware close guardTDD §8.6.2)。
// 韌體升降版進行中時擋住 OnBeforeClose、emit event 給前端攔截 modal。
// 由 NewApp 初始化、shutdown 後保留thread-safe via 自身 mu
firmwareCloseGuard *FirmwareCloseGuard
} }
// NewApp 建立 App 實例。 // NewApp 建立 App 實例。
@ -147,7 +152,8 @@ func NewApp() *App {
} }
// R5-5a沒插硬體就顯示空白狀態由 UI 處理),一律真實硬體路徑 // R5-5a沒插硬體就顯示空白狀態由 UI 處理),一律真實硬體路徑
return &App{ return &App{
pythonMode: mode, pythonMode: mode,
firmwareCloseGuard: NewFirmwareCloseGuard(),
} }
} }

View 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 trueprevent close。前端 modalDesign §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.appLogtest 用 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 falseserver 沒起來、沒韌體任務可擋)
// 3. queryFirmwareTasks 回 hasActive=false 或錯誤 → return falsefail-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 taskemit 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 提供 stateWails 在 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
}

View File

@ -0,0 +1,280 @@
package main
// firmware_close_guard_test.go — M9-4.5Wails 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 trueprevent、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 schemahasActive + 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)
}

View File

@ -1,7 +1,6 @@
package main package main
import ( import (
"context"
"embed" "embed"
"github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2"
@ -26,12 +25,17 @@ func main() {
}, },
OnStartup: app.startup, OnStartup: app.startup,
OnShutdown: app.shutdown, OnShutdown: app.shutdown,
// M8-4 / R5-2關視窗 = 結束 server + 結束 app。 // M8-4 / R5-2關視窗 = 結束 server + 結束 app預設行為
// 不 hide-to-tray、不彈確認對話框。回 false 允許 Wails 繼續走 OnShutdown 流程。 // 不 hide-to-tray、不彈系統 native 對話框。
// OnShutdown 會呼叫 ctrl.Stop()7 秒 grace + 1 秒 modal //
OnBeforeClose: func(ctx context.Context) (prevent bool) { // M9-4.5TDD §8.6.2firmware-aware close guard。
return false // - 韌體升降版進行中 → 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{}{ Bind: []interface{}{
app, app,
}, },

View File

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

View File

@ -0,0 +1,250 @@
package main
// query_firmware_active_tasks_test.go — M9-4.5firmware 查詢 helper 測試
//
// 覆蓋情境:
// 1. port <= 0 → 立即 return hasActive=falsefail-open 預設值)
// 2. server 正常回 hasActive=true + tasks → 解析正確
// 3. server 正常回 hasActive=false + 空 tasks → tasks 非 nil避免 caller 處理 nil panic
// 4. server 回 500 → fail-openhasActive=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)")
}
}