jim800121chen 8cd5751ce3 feat(local-tool): M8 重構 — Wails 控制台 + 瀏覽器 Web UI(R5 決策)
依 R5 五輪決策把 visionA-local 從「Wails 內嵌 Next.js」重構為「Wails
本機伺服器控制台 + 瀏覽器 Web UI」模式(類比 Docker Desktop / Ollama)。

程式碼變動
  - M8-1 砍 yt-dlp 全套(後端 resolver / URL handler / 前端 URL tab /
    Makefile vendor / installer / bootstrap / CI workflow,-555 行)
  - M8-2 砍 Mock 模式全套(driver/mock、mock_camera、Settings runtimeMode、
    VISIONA_MOCK 環境變數,-528 行)
  - M8-3 ffmpeg 從 GPL 切換到 LGPL 混合方案:Windows/Linux 用 BtbN 現成
    LGPL binary,macOS 自 build minimal decoder-only 進 git
    (vendor/ffmpeg/macos/ffmpeg 5.7MB + ffprobe 5.6MB,比 GPL 版省 85% 空間)
  - M8-4 Wails Server Controller:state machine、log ring buffer 2000 行、
    preferences.json atomic write、boot-id、Gin SkipPaths、shutdown 7+1 秒、
    notify_*.go 三平台 OS 通知、watchServer 改 Error state 不 os.Exit
  - M8-4b 啟動階段管線 R5-E:6 階段進度 event、20s soft / 60s hard timeout、
    stage 5/6 skip 規則、sentinel file、RestartStartupSequence 5 步驟
  - M8-5 Wails 控制台 vanilla HTML/JS/CSS(9 檔 ~2012 行)取代 M7-B splash:
    state 視覺、log panel、startup progress panel、Stage 6 manual CTA
    pulse、shutdown modal、Settings、Dark Mode、i18n 中英雙語
  - M8-6 上傳影片副檔名擴充(mp4/avi/mov/mpeg/mpg)
  - M8-7 Web UI Server Offline Overlay(role=alertdialog + focus trap +
    wsEverConnected 容錯 + Page Visibility)
  - M8-8 CORS middleware(127.0.0.1/localhost only + suffix attack 防護)+
    ws/origin.go 獨立 WebSocket CheckOrigin 避 package cycle
  - MAJ-4 server:shutdown-imminent WebSocket broadcast 機制
    (/ws/system endpoint + notifyShutdownImminent helper)
  - M8-9 Boot-ID + 瀏覽器 tab 自動重連(sessionStorage loop guard)

品質
  - ~105+ 新 unit test + race detector (-count=2) 全綠
  - 10 個 milestone 全部通過 Reviewer 審查
  - 三方 v2 + v2.1 文件(PRD / Design Spec / TDD)+ 交叉互審紀錄
    收錄在 .autoflow/

交付前待處理(M8-10)
  - 重跑 make payload-macos 把舊 GPL 77MB binary 換成新 LGPL
  - 三平台 end-to-end build 驗證

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:57:54 +08:00

361 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Reviewer 審查 M8-4 ServerController + Log Ring Buffer + Preferences + Notify + Boot-ID2026-04-15
## 摘要
- **總結論**:⚠️ 需小改(核心架構正確,有 2 個 Major 阻擋後續 milestone
- **阻擋情況**
- **阻擋 M8-5/M8-7/M8-9**watchServerV2 在 Stop 後仍存活會誤觸 Error state誤發崩潰通知→ 直接破壞 M8-5 控制台 UXserver 端 `server:shutdown-imminent` WebSocket 廣播未實作會直接讓 M8-9 的 race 風險回來
- **不阻擋 M8-4b**M8-4b 是擴充 ServerController 加 pipeline hook本層 state machine 介面已就緒,可平行進行
- 親跑驗證build / vet / test / **race detector 全綠**smoke test boot-id + SkipPaths 完全符合預期
---
## A. Log Ring Buffer`log_buffer.go`
| 檢查項 | 結論 | 備註 |
|--------|------|------|
| 容量 2000 行 | ✅ | `logBufferCap = 2000`L26 |
| Thread-safe | ✅ | Append/Snapshot/Reset/Size/Dropped 全 mutex |
| Wrap-around | ✅ | head 環形遞增、size 飽和、dropped 計數正確test 驗證 line-100..2099 |
| Snapshot 回 copy | ✅ | `out := make([]LogLine, 0, n)` + appendL96-105 |
| Rate limit 200/sec burst | ⚠️ Minor | 演算法本身正確(固定視窗 CAS但**呼叫單位有偏差**(見 D 段) |
| `parseLogLevel` | ✅ | bracket / 冒號 / Gin 三種格式齊備;空字串、非 ASCII 不 panic |
| 5 個 unit test | ✅ | TestLogBuffer_*、TestParseLogLevel 全綠 |
**Minor 1A.1**`Snapshot``i := 0` 走到 `b.size`,前 `skip` 行只是 continue可以直接從 `(start + skip) % logBufferCap` 開始走 `n` 步,效率較好。當 size = 2000、n = 100 時白跑 1900 圈。**非阻擋**,效能影響小於 0.1 ms。
**Minor 2A.2**`ShouldEmit` 內 CAS 與 `Store(0)` 的微 race — A 視窗內 CAS 成功還沒 Store(0) 之前B 已先 `Add(1)`,可能讓新視窗第一行被舊 count 計算。實際只會多放 ≤ 1 行 emit**不影響功能正確性**,可不修。
---
## B. Preferences`preferences.go`
| 檢查項 | 結論 | 備註 |
|--------|------|------|
| `DefaultPreferences()` 平台分支 | ✅ | `runtime.GOOS != "linux"` → trueL42 |
| Atomic write-rename | ✅ | tmp → `os.Rename` → cleanupL78-95 |
| 讀取失敗 fallback | ✅ | 缺檔 / JSON 損毀 → 回 default + warningL57-71 |
| JSON 欄位齊全 | ✅ | autoOpenBrowser / locale / logRingSize |
| 5 個 unit test | ✅ | 平台預設 / missing file / corrupt JSON / roundtrip / 殘留 tmp |
**極佳**`SavePreferences` 失敗時 `os.Remove(tmpPath)` 把垃圾 tmp 清掉L92符合 atomic 持久化最佳實踐。
**Minor 3B.1**`SavePreferences` 路徑在跨檔案系統 rename 時會失敗(罕見邊界,例如 dataDir 是 NFS / overlayfs。建議將來加 `io.Copy + os.Remove(tmp)` fallback但本 milestone 不必處理。
---
## C. ServerController State Machine`server_control.go`
### C.1 雙鎖機制
| 檢查項 | 結論 | 備註 |
|--------|------|------|
| State enum 完整 | ✅ | Idle / Starting / Running / Stopping / Stopped / ErrorL43-50 |
| txMu 序列化 transition | ✅ | Start/Stop/ForceKill 都 `c.txMu.Lock(); defer c.txMu.Unlock()` |
| mu 保護欄位讀寫 | ✅ | state / proc / startedAt / lastError 全走 mu |
| setState emit Wails event | ✅ | L120 `EventsEmit("server:state-change", status)` |
| 鎖順序避免 deadlock | ✅ | a.mu → c.mu 全部單向 |
| race detector | ✅ | `go test -race ./...` 全綠 |
### C.2 Start / Stop / Restart / ForceKill 行為
| 行為 | 結論 | 備註 |
|------|------|------|
| Start 允許 cold start fallback | ✅ | `startInternal(0)``pickPort(defaultPreferredPort)` |
| Restart 強制保留 port | ✅ | L233-244 讀 oldPort → Stop → StartWithPort(oldPort) |
| StartWithPort 對佔 port 不 fallback | ✅ | L408-411 直接 return error |
| `killStaleServerOnPort` 嘗試清乾淨 | ✅ | L405-408500 ms wait |
| ForceKill 不發 crash 通知 | ✅ | 直接呼叫 `proc.forceKill()`,不走 handleWatchFailure |
| Shutdown 7 + 1 秒 modal | ✅ | `shutdownGraceV2 = 7s`、modalTimer = 1sL319-339 |
| Windows 沒 SIGTERM 直接 Kill | ✅ | L307 `runtime.GOOS == "windows"` 分支 |
| watchServerV2 連 3 次失敗進 Error | ✅ | L644-649 `handleWatchFailure` |
| 不 os.Exit | ✅ | watchServerV2 完全沒呼叫 `reportFatal` / `os.Exit` |
### C.3 Major 與 Minor 問題(針對 ServerController
> **Major 1C.MAJ-1****`Stop()` / `ForceKill()` 不會 cancel `watchCancel`,導致 watchServerV2 在 server 死後仍存活30 秒3 × 10s後**會把 state 從 `Stopped` 翻成 `Error`,並發 OS 崩潰通知**。
>
> **檔案/行**`server_control.go:198-229`Stop、`server_control.go:251-265`ForceKill、`server_control.go:617-652`watchServerV2
>
> **重現**
> 1. Start → Running
> 2. Stop → state = Stopped、proc = nil
> 3. watchServerV2 仍持有舊 `sp` 與舊 port下一個 ticker 到 → health check fail → failures++
> 4. 30 秒後 failures = 3 → `handleWatchFailure` → `setState(Error)` + `EventsEmit("server:error")` + `sendCrashNotification("Server 崩潰")`
>
> **後果**:使用者按 Stop 後 30 秒看到「visionA Local — Server 崩潰」OS 通知M8-5 控制台 UI 會把 Stopped 翻紅變 Error。**這是嚴重 UX bug**。
>
> **修法**`Stop()` 與 `ForceKill()` 在持有 txMu 期間先 cancel `a.watchCancel`
> ```go
> a.mu.Lock()
> if a.watchCancel != nil { a.watchCancel(); a.watchCancel = nil }
> a.mu.Unlock()
> ```
> 一行集中到 helper 函式更乾淨。
> **Major 2C.MAJ-2****`handleWatchFailure` 不持 `txMu`,會與正在進行的 Stop / ForceKill / Restart race**。
>
> **檔案/行**`server_control.go:269-291`。
>
> **重現**
> 1. watchServerV2 連續失敗 2 次failures = 2
> 2. 使用者按 Stop → ctrl.Stop() 取得 txMu → setState(Stopping) → proc.stopGraceful() → setState(Stopped)
> 3. 同時 watchServerV2 第 3 次失敗 → handleWatchFailure → setState(Error)
> 4. **競態結果**state 可能是 Error覆蓋 Stopped即使使用者剛剛主動 Stop
>
> **修法**`handleWatchFailure` 進場先取 txMu並在 setState 前重新檢查 state已是 Stopped/Stopping/Idle 就 return。或更穩watchServerV2 在 ticker 失敗時也檢查 `c.State() != ServerStateRunning` 直接退出 goroutine。
**Minor 4C.MIN-1**`Restart()` 拆成獨立的 `Stop()``StartWithPort()` 兩段 txMu 持有,**中間有窗口期**讓另一個 binding 呼叫插隊。實務上前端按鈕在 Stopping 時 disable 應該擋住,但仍是潛在 race。建議 `Restart()` 自己持 txMu 整段(或加 `restartInternal` 不再呼叫 Stop / StartWithPort而是內部 inline 邏輯)。
**Minor 5C.MIN-2**`stopGraceful` 走完 `done` 路徑後立刻 `closeLogFiles()`,但 logPump goroutine 還在處理 lineCh 中 buffered 的最後幾行(會寫 fileWriter。pipe EOF 與 stopGraceful 的時序可能有極窄的 racerace detector 沒抓到是因為 unit test 沒實際 spawn process。**建議**stopGraceful 等待 logPump 結束(加 done channel再 close file或讓 logPump 持有 file ownership 自己 defer close。
---
## D. Log Pump goroutine
| 檢查項 | 結論 | 備註 |
|--------|------|------|
| 每個 pipe 一個 goroutine | ✅ | `go a.logPump(stdoutPipe, ...)` × 2L491-492 |
| 10 ms micro-batch | ✅ | `time.NewTicker(10 * time.Millisecond)` |
| Rate limit fallback超量只寫檔不 emit | ⚠️ 偏差 | 見下方 D.MIN-1 |
| pipe EOF 後 goroutine 自行退出 | ✅ | scanner.Scan() 結束 → close(scanDone) → main loop 收到 → flush + return |
| lineCh 沒被 close | ⚠️ Minor | 由 GC 處理scanDone 訊號替代 |
> **D.MIN-1rate limit 計數單位)**TDD §4.5 + Reviewer prompt 描述「1 秒 200 行」,但 logPump 是**每個 batch 呼叫一次 `ShouldEmit()`**`server_control.go:572`),不是每行。因為 batch 大小 1~數十不等,實際 burst 容許行數可能達 200 × 平均 batch size。
>
> 不算 bug限流仍能擋住災難級瀑布但**單元測試 `TestLogBuffer_RateLimit`line 108-127的假設與真實使用方式不符** — test 模擬「每行一次 ShouldEmit」實際 logPump 是「每 flush 一次 ShouldEmit」。
>
> **建議**:要嘛把 `ShouldEmit(n int)` 改成接行數參數一次扣 n要嘛在 logPump flush 時 `for i := 0; i < len(batch); i++ { if !ShouldEmit() { drop } }`。**不阻擋本 milestone**。
> **D.MAJ-1**(雖然影響不大,但屬於資料丟失 → 標 Major**logPump 在 server 子程序剛 exit 時可能丟掉最後幾行 stderr崩潰原因**。
>
> **檔案/行**`server_control.go:579-608`。
>
> **原因**select 同時看 lineCh 與 scanDone。當 scanner goroutine 結束 → close(scanDone)**lineCh buffer 內可能仍有最多 128 行未處理**。Go select 在多個 ready case 隨機選擇,可能直接走 `<-scanDone` flush 後 return把 lineCh 中剩餘的行**全數丟棄**,包括寫檔與 ring buffer。
>
> **後果**server crash 時最後幾行 stderr最有價值的 stack trace有機率丟。
>
> **修法**scanner goroutine 結束時 close(lineCh) 而不是 close(scanDone)main loop 用 `case line, ok := <-lineCh: if !ok { return }` 作為唯一退出條件。或更簡單scanDone 收到後**先 drain lineCh** 再 return
> ```go
> case <-scanDone:
> for {
> select {
> case line := <-lineCh:
> // 處理寫檔 + ring buffer + batch
> default:
> flush(); return
> }
> }
> ```
**Minor 6D.MIN-2**scanner goroutine 內 `select { case lineCh <- text: default: lineCh <- text }` 寫法奇怪 — default 之後又 blocking send 同一個值,等同沒 default。建議直接 `lineCh <- text` 一行。
---
## E. OnBeforeClose handler`main.go`
| 檢查項 | 結論 | 備註 |
|--------|------|------|
| 呼叫關閉時觸發 graceful shutdown | ✅ | return false → Wails OnShutdown → app.shutdown → ctrl.Stop |
| return false 允許關閉 | ✅ | L34 |
| 不彈 confirm 對話框 | ✅ | 無對話框邏輯,符合 R5-2 |
**潛在 UX 風險(非 bug**Wails 視窗一旦開始 close前端 JS 是否還能渲染 `shutdown:modal-show` 是不確定的(不同 OS 行為不同。M8-5 控制台 UI 改寫時應實機驗證;現階段不阻擋。
---
## F. Notify 三平台
| 檔案 | build tag | 機制 | 結論 |
|------|----------|------|------|
| `notify_darwin.go` | `//go:build darwin` ✅ | osascript display notification + escape | ✅ |
| `notify_linux.go` | `//go:build linux` ✅ | notify-send -u critical -i dialog-error | ✅ |
| `notify_windows.go` | `//go:build windows` ✅ | PowerShell BurntToast + msg * fallback | ✅ |
| 檢查項 | 結論 | 備註 |
|--------|------|------|
| 三平台 build tag 齊全 | ✅ | 沒重複定義 / 沒缺失 |
| AppleScript escape | ✅ | 反斜線 + 雙引號 |
| PowerShell escape | ✅ | 單引號 → '' |
| fire-and-forget 呼叫 | ✅ | 呼叫端用 `go sendCrashNotification(...)` |
| 失敗只 log 不 fail | ✅ | 三平台都 fmt.Fprintf stderr |
**Minor 7F.MIN-1**:三平台都用 `cmd.Run()` 同步等子程序結束。雖然呼叫端用 `go` wrapper但 goroutine 內若 osascript / powershell hang 住會 leak極罕見。**建議**:包一個 `context.WithTimeout(5*time.Second)` + `exec.CommandContext`,過期 Kill。本 milestone 可不修。
**Minor 8F.MIN-2**notify_linux.go 假設 `notify-send` 一定在 PATH 上。雖然 prompt 提到「若不存在 log warning 不 fail」實作確實不會 crash**但** `exec.Command.Run()` 在 binary not found 時回的是 `*exec.Error`,輸出的 warning 對使用者不夠友善(純 path not found。可接受不修。
---
## G. Boot-ID + SkipPaths
| 檢查項 | 結論 | 備註 |
|--------|------|------|
| `crypto/rand.Read` + `hex.EncodeToString` | ✅ | `system_handler.go:27-32` |
| 32 字元 hex | ✅ | smoke test 實測 `bd94ae7f976a6e526bd9840fc385b92d` |
| process-scoped每次啟動新 ID | ✅ | `NewSystemHandler` 內呼叫 `newBootID()` |
| `GET /api/system/boot-id` 註冊 | ✅ | `router.go:59` |
| Response shape | ✅ | `{success, data: {bootId, startedAt}}` |
| `broadcasterLoggerSkipPaths` 含 boot-id + health | ✅ | `router.go:120-123` |
| skip 邏輯只比對 path | ✅ | `router.go:140-142` |
| **Smoke test兩個 endpoint 不寫 access log** | ✅ | 實測 stdout 只有 `[GIN] 200 ... GET /api/devices`**沒有** boot-id 或 health 的 GIN log |
> **Minor 9G.MIN-1**boot-id 產生位置偏離 TDD §9.1。TDD 寫的是「`server/main.go` 在啟動時產生 boot-id**傳入** SystemHandler」實作把 `newBootID()` 放在 `NewSystemHandler` 內部呼叫(`system_handler.go:42`)。功能完全等價,但讓單元測試難以注入固定 boot-id 做 deterministic test。**Minor 偏離規格**,可接受。
> **Major 3G.MAJ-1****TDD §8.1 + milestone-plan 要求 `server/main.go` 的 `shutdownFn` timeout 從 10 秒改成 6 秒,未改**。
>
> **檔案/行**`server/main.go:166`。
>
> ```go
> ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
> ```
>
> 應改為 `6*time.Second`。
>
> **後果**Wails ctrl.Stop 在第 7 秒會 SIGKILL但 server 自己內部還在 10 秒 graceful cleanup window 中 — 可能出現「server 還在 sync 檔案到一半被打斷」。對 v2 「7 + 1 秒 modal」UX 設計直接破壞。
---
## H. v1/v2 並存策略評估
實作保留了 `ServerProcess.stop()` / `kill()` / `watchServer` v1 路徑(`app.go:583-685`+ v1 `shutdownGracePeriod = 5s` 常數宣稱為「fallback」 + 「M8-5 前端改寫期間相容」。
**評估結論**:⚠️ **策略合理但需立即標記砍除時程**
| 問題 | 風險 | 結論 |
|------|------|------|
| v1/v2 路徑會被同時呼叫嗎? | 不會 | startup 只走 ctrl.Start → startServerV2shutdown 只走 ctrl.Stop → stopGraceful。`app.stopServer()``App.startServer` 已被 dead code |
| v1 常數 `shutdownGracePeriod = 5s` 會被誤用嗎? | 低 | 只有 v1 ServerProcess.stop() 用到,已是 dead path |
| v1 `watchServer` 會被誤啟動嗎? | 低 | 只在 v1 startServer 的 dead path 啟動;新 startServerV2 走 watchServerV2 |
| reportFatal v1 路徑仍存在 | 中 | startup 失敗 / single-instance 失敗仍會走這合理startup 完全失敗時控制台還沒就緒,無 fallback 只能原生對話框) |
| 未來何時砍 | 未明確 | **必須**在 progress.md「未解決問題」記錄M8-5 完成後**立即砍**全部 v1`ServerProcess.stop/kill``watchServer``stopServer``shutdownGracePeriod``startServer` |
**Minor 10H.MIN-1**`shutdown()`app.go:222的 fallback 路徑 `a.stopServer()` 是 dead code因為 ctrl 在 startup 一定會被建立 — 直接刪 else 分支,避免讓未來的開發者誤以為兩條路徑都有效。
**Minor 11H.MIN-2**`v1 watchServer``v2 watchServerV2` 內容 90% 相同,只差最後失敗的 reportFatal vs handleWatchFailure。等 M8-5 砍 v1 即可消除這份重複。
---
## I. Build / Test / Race detector 結果
```
cd server && go build ./... ✅ PASS
cd server && go vet ./... ✅ PASS
cd server && go test ./... ✅ PASSdevice + model 既有 test
cd server && go test -race ./... ✅ PASS
cd visiona-local && go build . ✅ PASS
cd visiona-local && go vet ./... ✅ PASS
cd visiona-local && go test -count=1 ./... ✅ PASS20 tests 全綠)
cd visiona-local && go test -race ./... ✅ PASS
```
**20 個 unit test 明細**
- log_buffer_test.go5 個Append / Wrap / Reset / RateLimit / parseLogLevel
- preferences_test.go5 個platform default / missing / corrupt / roundtrip / atomic rename
- server_control_test.go10 個Initial / setState / Stop×2 / ForceKill / snapshotStatus / GetRecentLogs / GetSystemInfo / ClearLogs / GetServerStatusV2
**race detector 沒抓到任何 race**,但要注意 unit test **沒有實際 spawn server binary**,因此 logPump↔stopGraceful 的 file handle raceC.MIN-2 / D.MAJ-1 / Major 1必須靠人工 review 與 integration test 才能驗證。
---
## J. Smoke test 結果
執行:`go run server` 啟動真實 servercurl 各 endpoint。
| 步驟 | 結果 |
|------|------|
| 1. `curl /api/system/boot-id` | ✅ `{"data":{"bootId":"bd94ae7f976a6e526bd9840fc385b92d","startedAt":1776213642971},"success":true}`32 字元 hex |
| 2. `curl /api/system/health` | ✅ `{"status":"ok"}` |
| 3. `curl /api/devices` | ✅ 正常回應 |
| 4. **stdout 內 GIN access log** | ✅ **只有** `[GIN] 200 \| 137.674µs \| GET /api/devices`**沒有** boot-id / health 的 access log |
| 5. 連續 4 次 poll boot-id 都回同一 IDprocess-scoped | ✅ |
**SkipPaths 機制完全符合 TDD §9.1a 與 milestone-plan 預期。**
---
## K. 遺漏 / 誤解
> **Major 4K.MAJ-1****未實作 `server:shutdown-imminent` WebSocket 廣播**milestone-plan #157「Minor 4」明確要求
>
> **檔案/行**`server_control.go:302-340`stopGraceful 內應在 SIGTERM 前先廣播server 端應有 `/ws/system` 或擴充 `/ws/server-logs` 來廣播 `server:shutdown-imminent`。
>
> **影響**M8-9 的 web UI offline overlay race condition 防護機制完全缺席。M8-9 將會被迫補做、或 fall back 到「3 次 poll 失敗 = 15s 才顯示 overlay」的舊行為。
>
> **狀態****阻擋 M8-9建議在 M8-4b 一併補做或拆獨立小 task**。
**Minor 12K.MIN-1**milestone-plan #153 要求「`shutdownGracePeriod` 5 s → **7 s**」,實作改用新常數 `shutdownGraceV2 = 7s`、舊常數 5s 還在。功能等效但**字面違反 milestone-plan**。可接受作為 v1/v2 並存策略的副作用,但要在 H 段提到的「砍 v1」清單中一併處理。
**Minor 13K.MIN-2**`ForceKillServer` 確認**不**發崩潰通知 — 走 `proc.forceKill()` 路徑直接 SIGKILL不經過 handleWatchFailure符合 prompt 要求。✅
**Minor 14K.MIN-3**preferences 讀取失敗的 fallback 行為符合 TDD §11不 fail、用 default。✅
**Minor 15K.MIN-4**notify 三平台 timeout 處理F.MIN-1— 已在 F 段提,不重複列。
---
## L. 問題清單
### Major阻擋後續 milestone
| # | 檔案:行 | 問題 | 影響的 milestone | 修法 |
|---|---------|------|-----------------|------|
| MAJ-1 | server_control.go:198-229 (Stop), 251-265 (ForceKill) | `Stop()` / `ForceKill()` 不 cancel watchCancel30 秒後誤觸 Error state + 發 OS 崩潰通知 | M8-5、M8-7 | Stop/ForceKill 進場後加 `if a.watchCancel != nil { a.watchCancel(); a.watchCancel = nil }` |
| MAJ-2 | server_control.go:269-291 | `handleWatchFailure` 不取 txMu與 Stop/ForceKill race可能把 Stopped 翻成 Error | M8-5 | 進場取 txMusetState 前重新檢查 state或 watchServerV2 ticker 內檢查 `c.State() != ServerStateRunning` 直接退出 |
| MAJ-3 | server/main.go:166 | shutdownFn timeout 仍是 10s未對齊 TDD §8.1 + milestone-plan 的 6s | M8-5、M8-9 | 改 `6*time.Second` |
| MAJ-4 | server_control.go:302-340 + 缺 server WebSocket 廣播 | 未實作 `server:shutdown-imminent` 廣播milestone-plan Minor 4 | M8-9 | server 端加 `/ws/system` 或擴充 `/ws/server-logs`ctrl.Stop SIGTERM 前 HTTP/WS 觸發 |
| MAJ-5 | server_control.go:579-608 (logPump main loop) | scanDone 觸發後 lineCh 仍有 buffered 行未處理 → 資料丟失(含崩潰時最後幾行 stderr | M8-5 (debug experience) | scanDone case 加 drain loop 或讓 scanner goroutine 自己 close lineCh |
### Minor建議修不阻擋
| # | 檔案 | 問題 |
|---|------|------|
| MIN-1 | log_buffer.go:99-105 | Snapshot 從 i=0 走 size 圈,可改為從 (start+skip) 走 n 圈 |
| MIN-2 | log_buffer.go:139-151 | ShouldEmit CAS raceA 視窗 Store(0) 前 B 已 Add(1),極小機率多放 1 行 |
| MIN-3 | preferences.go:91 | 跨 filesystem rename 失敗時無 io.Copy fallback罕見邊界 |
| MIN-4 | server_control.go:232-245 | Restart 拆兩段 txMu中間有插隊窗口 |
| MIN-5 | server_control.go:302-340 | stopGraceful 結束時關 file 與 logPump goroutine 還在寫的時序 race |
| MIN-6 | server_control.go:556-562 | scanner goroutine `select { case lineCh<-text: default: lineCh<-text }` 寫法奇怪、等同 blocking |
| MIN-7 | notify_*.go | 三平台都用 cmd.Run() 無 timeout極罕見情況下 hang 會 leak goroutine |
| MIN-8 | notify_linux.go | notify-send not in PATH 時 warning 不夠友善 |
| MIN-9 | system_handler.go:42 | newBootID 放在 handler 內呼叫,偏離 TDD §9.1 「main.go 注入」設計,不利於 deterministic test |
| MIN-10 | app.go:222-227 | shutdown() 的 v1 fallback 是 dead code建議刪 |
| MIN-11 | app.go:583-622 | watchServer v1 與 watchServerV2 高度重複,待 M8-5 後砍 |
| MIN-12 | app.go:46 | shutdownGracePeriod = 5s 仍在,違反 milestone-plan #153 字面 |
| MIN-13 | log_buffer_test.go:108-127 | TestLogBuffer_RateLimit 假設「每行一次 ShouldEmit」與 logPump 實際「每 batch 一次」不一致 |
---
## M. 結論
M8-4 的核心架構**正確且品質高**
**做得好的地方(值得讚許):**
- 雙鎖機制 `txMu` + `mu` 設計清晰、race detector 全綠
- LogBuffer 的 ring buffer + atomic rate limit 結構漂亮
- Preferences atomic write-rename 含 tmp cleanup
- 三平台 notify 用 build tag 隔離乾淨、escape 處理到位
- boot-id 用 crypto/rand 不引 google/uuid符合 TDD 定版決策
- broadcasterLogger SkipPaths 實測完美過 smoke test
- 20 unit test 全綠、race detector 全綠
- v1/v2 並存策略雖然增加維護成本,但邏輯上沒有衝突
- 文件註解極詳細,每個函式都標 TDD 出處
**必須處理的問題(阻擋後續 milestone**
- **5 個 Major** — MAJ-1 ~ MAJ-5。其中 MAJ-1watch leak 誤觸 Error與 MAJ-2handleWatchFailure race是直接破壞 M8-5 UX 的 bug**第二輪修改前必須處理**。MAJ-3shutdownFn 6s與 MAJ-4shutdown-imminent 廣播是純遺漏補就好。MAJ-5logPump 丟最後幾行)影響 debug 體驗。
- **15 個 Minor** — 大多可以放到 v1 砍除時一併處理,或當作技術債記錄。
**對 Orchestrator 的建議:**
1. **第二輪修改範圍**(必須):
- MAJ-1Stop/ForceKill cancel watchCancel
- MAJ-2handleWatchFailure 取 txMu + 二次檢查 state
- MAJ-3server/main.go shutdownFn 6s
- MAJ-5logPump scanDone 加 drain loop
2. **MAJ-4 處理時機**M8-4b 一併做,因為它需要 server 端新增 `/ws/system` endpoint與 startup-pipeline 的 event 設計可一起規劃。**或拆獨立 micro-task 立刻補做,不要等到 M8-9 才發現缺**。
3. **v1 砍除追蹤**:在 progress.md「未解決問題」加一條「M8-4 v1 路徑ServerProcess.stop/kill、watchServer、stopServer、shutdownGracePeriod、startServer保留作為相容過渡M8-5 完成後立即砍除(含相關 dead code 清理 MIN-10/11/12」。
4. **整合測試**M8-4 的 unit test 沒實際 spawn server binary因此 logPump file handle raceMIN-5、stopGraceful timing、preferences 跨檔案系統等只能靠 manual / integration test 驗證。建議在 M8-5 完成後安排一次完整 integration smoke testStart → 噴 log → Stop → Restart → Force Kill → 模擬 crash → 各事件都要正確)。
**結論**:⚠️ **需小改後通過**。修完 5 個 Major 即可進 M8-4b / M8-5。Minor 不阻擋。