visionA/local-tool/visiona-local/server_control.go
jim800121chen 9c9e005d33 feat(local-tool): Stage 3 sub-step 進度 + 啟動完成後面板可收合
回應使用者三項需求:
1. healthCheckTimeout 60s → 180s(涵蓋 Defender + EDR 串行延遲最壞情境)
2. Stage 3「啟動本機伺服器」期間顯示細步在做什麼,並在 15 秒後改為「首次
   啟動較久屬正常」slow hint,避免使用者看著 spinner 不動以為 app 掛了
3. 啟動完成後 6 階段面板自動收合成一行 summary,使用者點擊可展開檢視歷
   史紀錄;Restart / Retry 會重置並展開新一輪

實作:

Go 端
- healthCheckTimeout 60s → 180s(理由註解寫清楚 Defender + EDR 各自延遲)
- waitHealthy() 加 progress callback,每 5 秒呼叫一次傳入 elapsedSeconds
- StartupPipeline 加 StartupStageDetailEvent + EmitStageDetail() API
- startServerV2 在 spawn 前 emit detail.spawn,等 health check 期間 callback
  emit detail.waitHealth(< 15s)或 detail.waitHealthSlow(>= 15s)

前端
- 新訂 startup:stage-detail event → updateStageDetail() 把 i18n key 解析為
  文案存到 stages[n].detail,paintStageRow 優先顯示 detail(蓋過 slow hint)
- collapseStartupPanel() / expandStartupPanel() / resetStartupPanel() 三個新
  API 取代 hideStartupPanel;startup:ready 觸發 collapse、Retry/Restart 觸
  發 reset+expand
- collapsed CSS:保留 panel 但縮成一行 summary(標題改「啟動完成」+ ✓ +
  「點此展開檢視」hint),整個 panel 可點擊;hover 加亮
- i18n 加 6 個 keys(zh-TW + en)

驗證:
- visiona-local 套件 go build / vet / test -race 全綠
- macOS dmg 重 build 163MB OK
- 乾淨 dataDir 啟動 wails app:startup 1 秒內完成(macOS 已 cache binary
  + Python venv),server listen 3721,Chrome 自動連上 — 整條 cold start
  正常

Windows 首次安裝預期行為(修復後):
- Stage 1 → Stage 2(首次 bootstrap pause hard timeout,跑 1-3 分鐘)→ Stage
  3 spawn → 等 health check 30-90 秒(Defender 掃 binary)期間有「已等 N
  秒」即時更新 → ready → 自動 collapse → 瀏覽器自動開啟

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

1202 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package main
// server_control.go — M8-4Server 生命週期狀態機 + bindings + logPump
//
// 本檔涵蓋 TDD
// - v2/control-panel.md §4.2§4.7bindings、型別、state machine、logPump
// - v2/server-lifecycle.md §4§6stdout/stderr pipe 捕捉、ServerController 防呆、
// watchServer 改為 Error state
//
// 設計重點:
// 1. ServerController 用 txMutransition lock+ mufield lock雙鎖
// 2. Start / Stop / Restart 整段邏輯由 txMu 序列化,避免 race
// 3. 新的 startServerV2 用 StdoutPipe / StderrPipe 接 server 子行程,
// 配合 logPump goroutine 把每一行送進 LogBuffer + emit Wails event
// 4. Restart 強制保留舊 portR5-F-2cold start 允許 fallback
// 5. watchServer 崩潰時進 Error state不 os.Exit同時發 OS 通知R5-D1
import (
"bufio"
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"sync"
"syscall"
"time"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// -----------------------------------------------------------------------
// 型別定義
// -----------------------------------------------------------------------
// ServerState 是控制台顯示的高階狀態TDD v2/control-panel.md §4.3)。
type ServerState string
const (
ServerStateIdle ServerState = "idle" // 初始,尚未啟動過
ServerStateStopped ServerState = "stopped" // 曾經 Running 過後又被停掉
ServerStateStarting ServerState = "starting" // 啟動中
ServerStateRunning ServerState = "running" // 正常運作
ServerStateStopping ServerState = "stopping" // 停止中SIGTERM → grace → SIGKILL
ServerStateError ServerState = "error" // 啟動失敗 / 健康檢查失敗
)
// ServerStatusV2 對應 control-panel.md §4.3。前端用這個結構更新 UI。
type ServerStatusV2 struct {
State ServerState `json:"state"`
Port int `json:"port,omitempty"`
URL string `json:"url,omitempty"`
PID int `json:"pid,omitempty"`
PythonBin string `json:"pythonBin,omitempty"`
PythonMode string `json:"pythonMode,omitempty"`
StartedAt int64 `json:"startedAt,omitempty"` // Unix ms
LastError string `json:"lastError,omitempty"`
}
// SystemInfo 給 Status card 顯示。
type SystemInfo struct {
AppVersion string `json:"appVersion"`
BuildTime string `json:"buildTime"`
DataDir string `json:"dataDir"`
LogsDir string `json:"logsDir"`
Platform string `json:"platform"`
}
// -----------------------------------------------------------------------
// ServerController — 狀態機 + Start/Stop/Restart
// -----------------------------------------------------------------------
// ServerController 持有 server 子行程的生命週期狀態。
// 它擁有 txMutransition lock+ mufield lock雙鎖
// - txMuStart / Stop / Restart 整段邏輯期間持有,保證互斥
// - mu保護 state / proc / startedAt / lastError 的快速讀寫
type ServerController struct {
app *App
mu sync.Mutex
state ServerState
proc *ServerProcess
startedAt time.Time
lastError string
txMu sync.Mutex
}
// NewServerController 建立一個新的 controller。
func NewServerController(app *App) *ServerController {
return &ServerController{
app: app,
state: ServerStateIdle,
}
}
// State 回傳目前狀態thread-safe snapshot
func (c *ServerController) State() ServerState {
c.mu.Lock()
defer c.mu.Unlock()
return c.state
}
// setState 更新狀態並 emit Wails event。
// 注意:呼叫方不得持有 c.mu本函式內部會取 c.mu。
func (c *ServerController) setState(s ServerState, errMsg string) {
c.mu.Lock()
c.state = s
c.lastError = errMsg
if s == ServerStateRunning {
c.startedAt = time.Now()
}
c.mu.Unlock()
// 取得最新 snapshot 後 emit
status := c.app.snapshotStatus()
if c.app.ctx != nil {
wailsRuntime.EventsEmit(c.app.ctx, "server:state-change", status)
}
}
// Start 啟動 server 子程序。冷啟動或手動從 Stopped / Error / Idle 啟動用。
// 允許 port fallback。
func (c *ServerController) Start() error {
return c.startInternal(0)
}
// StartWithPort 用指定的 preferred port 啟動。
// preferredPort == 0 表示走 cold start 邏輯(允許 fallback
// preferredPort != 0 表示必須使用該 portRestart 路徑port 被佔就失敗進 Error。
func (c *ServerController) StartWithPort(preferredPort int) error {
return c.startInternal(preferredPort)
}
func (c *ServerController) startInternal(preferredPort int) error {
c.txMu.Lock()
defer c.txMu.Unlock()
// 1. 先檢查狀態
c.mu.Lock()
s := c.state
c.mu.Unlock()
if s == ServerStateRunning || s == ServerStateStarting || s == ServerStateStopping {
return fmt.Errorf("cannot start: current state=%s", s)
}
// 2. 清理殘留 processError state 進來時常見)
c.mu.Lock()
if c.proc != nil {
oldProc := c.proc
c.proc = nil
c.mu.Unlock()
oldProc.stopGraceful() // 不發通知、快速清理
} else {
c.mu.Unlock()
}
// 3. 切到 Starting
c.setState(ServerStateStarting, "")
// 4. 真的啟動
proc, err := c.app.startServerV2(preferredPort)
if err != nil {
// M8-4b 補丁 M-1若 startup pipeline 已 FailStage`pipeline.emitError` 已經
// setState(Error) + 發過 OS 通知),這條 fallback 路徑 skip 一次設定,避免:
// - server:state-change event 連發兩次payload 相同、前端無傷但冗餘)
// - 使用者看到「啟動失敗」+「Server 啟動失敗」兩個不同的 OS 通知,誤以為
// 發生兩次獨立的錯誤
//
// 非冷啟動路徑RestartServer 等 pipeline 已 readycurrent==7或 pipeline==nil
// 場景下fallback 仍需要自己 setState + emit + 通知。
pipelineHandled := false
if c.app != nil && c.app.startupPipeline != nil {
pipelineHandled = c.app.startupPipeline.HasFailedStage()
}
if !pipelineHandled {
c.setState(ServerStateError, err.Error())
if c.app.ctx != nil {
wailsRuntime.EventsEmit(c.app.ctx, "server:error", map[string]any{
"reason": err.Error(),
})
}
// R5-D1OS 通知
go sendCrashNotification(
"visionA Local — Server 啟動失敗",
"請打開 visionA Local 查看錯誤詳情或按 Restart 重試。",
)
}
return err
}
c.mu.Lock()
c.proc = proc
c.mu.Unlock()
c.setState(ServerStateRunning, "")
// 5. R5-D3每次 Start 成功都檢查 AutoOpenBrowser
//
// M8-4b 補丁 M-2若目前處於冷啟動 pipeline 中stage 1-6openBrowser 改由
// runStartupStage5 統一負責,避免冷啟動時瀏覽器被開兩次:
// - 冷啟動app.startup → ctrl.Start → startInternal → (原本這裡 open 1 次)
// → runStartupStage5 → openBrowser (再 open 1 次) → CompleteStage(5)
// Linux/xdg-open 會開兩個 tabmacOS/open 通常會聚合但 log 兩次。
// - RestartServerpipeline 已 ready (current==7)IsInColdStart() 回 false
// startInternal 仍負責自己 openRestartServer 不走 runStartupStage5
// - RestartStartupSequencepipeline 重建後走 Start → 這裡 skip由上層的
// runStartupStage5 負責(見 app.go Step 5
inColdStart := false
if c.app != nil && c.app.startupPipeline != nil {
inColdStart = c.app.startupPipeline.IsInColdStart()
}
if !inColdStart && c.app.prefs.AutoOpenBrowser && proc != nil && proc.port > 0 {
url := fmt.Sprintf("http://127.0.0.1:%d", proc.port)
if err := openBrowser(url); err != nil {
fmt.Fprintf(os.Stderr, "[visiona-local] auto-open browser failed: %v\n", err)
}
}
return nil
}
// Stop 優雅停止 server。7 秒 grace + 1 秒 modal event。
func (c *ServerController) Stop() error {
c.txMu.Lock()
defer c.txMu.Unlock()
// MAJ-1 修復:先 cancel watchServerV2 goroutine避免它在 server 死後仍 poll
// health endpoint3 次失敗30s後誤觸 handleWatchFailure → setState(Error)
// → 發 OS 崩潰通知。使用者按 Stop 後不該收到「Server 崩潰」警報。
c.cancelWatcher()
c.mu.Lock()
s := c.state
proc := c.proc
c.mu.Unlock()
if s == ServerStateStopped || s == ServerStateError || s == ServerStateIdle {
// 沒東西可停
return nil
}
if s == ServerStateStarting {
// 理論上 txMu 已經保證不會併發,但保險起見
return fmt.Errorf("cannot stop while starting; retry shortly")
}
// 切到 Stopping必須先 set讓前端按鈕 disable
c.setState(ServerStateStopping, "")
if proc != nil {
proc.stopGraceful()
}
c.mu.Lock()
c.proc = nil
c.mu.Unlock()
c.setState(ServerStateStopped, "")
return nil
}
// cancelWatcher 取消正在跑的 watchServerV2 goroutine並把 a.watchCancel 清掉。
// 必須在 txMu 已被持有的情況下呼叫(避免與 startServerV2 重新建立 watcher 競態)。
//
// MAJ-1 修復用 helperStop / ForceKill 進場後第一件事就呼叫此函式,
// 確保 watcher 不會在 transition 期間 race 地翻動 state。
func (c *ServerController) cancelWatcher() {
if c.app == nil {
return
}
c.app.mu.Lock()
if c.app.watchCancel != nil {
c.app.watchCancel()
c.app.watchCancel = nil
}
c.app.mu.Unlock()
}
// Restart = Stop → Start強制保留舊 portR5-F-2
func (c *ServerController) Restart() error {
// 記住舊 port
c.mu.Lock()
oldPort := 0
if c.proc != nil {
oldPort = c.proc.port
}
c.mu.Unlock()
// MAJ-4 補丁:通知瀏覽器 tab「server 要 restart 了」
// 前端收到 reason=restart → 延遲 10 秒再顯示 Offline Overlay給 server 重啟時間。
// best-effort失敗不影響 Stop / Start 流程。
if c.app != nil && oldPort > 0 {
ctx := context.Background()
if c.app.ctx != nil {
ctx = c.app.ctx
}
notifyShutdownImminent(ctx, oldPort, "restart")
}
if err := c.Stop(); err != nil {
return err
}
return c.StartWithPort(oldPort)
}
// ForceKill 非 graceful 直接 SIGKILL 目前的 proc給 RestartStartupSequence 用)。
// 不 emit 崩潰通知,因為這是使用者主動要的「清乾淨重來」。
//
// 呼叫後 state → Stopped。
func (c *ServerController) ForceKill() error {
c.txMu.Lock()
defer c.txMu.Unlock()
// MAJ-1 修復:與 Stop() 同理,先 cancel watcher避免 ForceKill 後 watcher
// 還在跑 → 30 秒後把 Stopped 翻成 Error + 發崩潰通知。
c.cancelWatcher()
c.mu.Lock()
proc := c.proc
c.proc = nil
c.mu.Unlock()
if proc != nil {
proc.forceKill()
}
c.setState(ServerStateStopped, "")
return nil
}
// handleWatchFailure 是 watchServer goroutine 偵測到連續失敗時呼叫的。
// 進 Error state + OS 通知,不 os.Exit。
//
// MAJ-2 修復:必須持有 txMu與 Start/Stop/ForceKill/Restart 序列化,避免:
// - Stop 已把 state → Stoppedwatcher 又把它翻回 Error
// - ForceKill 已把 state → Stoppedwatcher 又翻回 Error 並發崩潰通知
//
// 進場後若 state 已不是 Running代表使用者剛主動 Stop/ForceKill/Restart 完成),
// 直接 return — watcher 的失敗結論已過時。
//
// 死結風險評估watchServerV2 是獨立 goroutine由 startServerV2 啟動後與
// 啟動流程脫鉤,不會在持有 txMu 的情況下呼叫 handleWatchFailure因此安全。
func (c *ServerController) handleWatchFailure(port int, reason string) {
c.txMu.Lock()
defer c.txMu.Unlock()
c.mu.Lock()
curState := c.state
proc := c.proc
c.mu.Unlock()
// 二次檢查:已不是 Running 代表 Stop / ForceKill / Restart 在我們等 txMu 時搶先了。
// 不能覆蓋已完成的 transition。
if curState != ServerStateRunning {
return
}
c.setState(ServerStateError, reason)
if c.app.ctx != nil {
wailsRuntime.EventsEmit(c.app.ctx, "server:error", map[string]any{
"reason": reason,
"port": port,
})
}
// 非阻塞發 OS 通知
go sendCrashNotification(
"visionA Local — Server 崩潰",
"本機伺服器停止回應。請打開 visionA Local 查看錯誤並按 Restart。",
)
// 嘗試清理 process可能還沒死
if proc != nil {
proc.forceKill()
}
}
// -----------------------------------------------------------------------
// ServerProcess v2擁有 pipe readers 與 pump 管線
// -----------------------------------------------------------------------
// stopGraceful 執行 7 秒 grace + 1 秒 modal 的 shutdown 流程。
// 對應 TDD v2/server-lifecycle.md §8.2 的 ServerProcess.stop()。
//
// 注意:這是 v2 新增的方法,與 v1 的 ServerProcess.stop() 並存以免動到太多舊路徑。
// v2 路徑ctrl.Stop 呼叫)一律走這個。
func (p *ServerProcess) stopGraceful() {
if p == nil || p.cmd == nil || p.cmd.Process == nil {
return
}
pid := p.cmd.Process.Pid
if p.app != nil {
p.app.appLog("stopGraceful: entered pid=%d", pid)
}
// Windows 沒有 SIGTERM直接 Kill
if runtime.GOOS == "windows" {
_ = p.cmd.Process.Kill()
} else {
_ = p.cmd.Process.Signal(syscall.SIGTERM)
}
done := make(chan struct{})
go func() {
_, _ = p.cmd.Process.Wait()
close(done)
}()
modalTimer := time.NewTimer(1 * time.Second)
graceTimer := time.NewTimer(shutdownGraceV2)
defer modalTimer.Stop()
defer graceTimer.Stop()
// 追蹤 modal 是否曾 showreturn 時需要對稱地 hide避免前端卡在
// 「正在停止伺服器…」popup
modalShown := false
defer func() {
if p.app != nil {
p.app.appLog("stopGraceful: return pid=%d modalShown=%v", pid, modalShown)
}
if modalShown && p.app != nil && p.app.ctx != nil {
wailsRuntime.EventsEmit(p.app.ctx, "shutdown:modal-hide", nil)
}
}()
// Watchdog無論哪個 branch最多 graceTimer + 2 秒 後強制離開,避免
// `<-done` 永遠阻塞Windows 上 Process.Wait 偶有情況不 return
// 2 秒是額外 safety margin用 timer.After 實作,不改動主 select。
hardBailout := time.NewTimer(shutdownGraceV2 + 2*time.Second)
defer hardBailout.Stop()
for {
select {
case <-done:
p.closeLogFiles()
return
case <-modalTimer.C:
if p.app != nil && p.app.ctx != nil {
wailsRuntime.EventsEmit(p.app.ctx, "shutdown:modal-show", nil)
modalShown = true
p.app.appLog("stopGraceful: modal-show emitted")
}
case <-graceTimer.C:
if p.app != nil {
p.app.appLog("stopGraceful: grace timer, force-kill pid=%d", pid)
}
_ = p.cmd.Process.Kill()
// 非阻塞等 done最多 1 秒(防止 Windows Wait 卡死)
select {
case <-done:
case <-time.After(1 * time.Second):
if p.app != nil {
p.app.appLog("stopGraceful: Process.Wait did not return within 1s after Kill, leaking")
}
}
p.closeLogFiles()
return
case <-hardBailout.C:
// 絕對上限:無論如何都要離開
if p.app != nil {
p.app.appLog("stopGraceful: hard bailout hit, leaking process pid=%d", pid)
}
p.closeLogFiles()
return
}
}
}
// forceKill 立即 SIGKILL不 graceful、不 emit event。
func (p *ServerProcess) forceKill() {
if p == nil || p.cmd == nil || p.cmd.Process == nil {
return
}
_ = p.cmd.Process.Kill()
_, _ = p.cmd.Process.Wait()
p.closeLogFiles()
}
// closeLogFiles 安全關閉 stdout/stderr log 檔。
func (p *ServerProcess) closeLogFiles() {
if p.stdoutLog != nil {
_ = p.stdoutLog.Close()
p.stdoutLog = nil
}
if p.stderrLog != nil {
_ = p.stderrLog.Close()
p.stderrLog = nil
}
}
// shutdownGraceV2 = 7 秒 grace periodPM Q4
const shutdownGraceV2 = 7 * time.Second
// -----------------------------------------------------------------------
// App.startServerV2 — 使用 pipe 捕捉 stdout/stderr
// -----------------------------------------------------------------------
// startServerV2 是 v2 版本的 server spawn改為
// 1. 使用 StdoutPipe / StderrPipe 讓 logPump 能逐行讀
// 2. 開兩個 goroutine 做 logPump寫檔 + append LogBuffer + emit event
// 3. 健康檢查成功後才 writeIPCPort
// 4. M8-4b穿插 startupPipeline.CompleteStage(2..4) 的 hook
//
// preferredPort
// 0 → cold start / manual Start允許從 defaultPreferredPort 開始 fallback
// 非 0 → 強制指定 portRestart 路徑port 被佔就失敗
//
// 成功回傳 *ServerProcess含 port / cmd / pipes失敗回 nil + error。
//
// pipeline 注意事項startServerV2 同時被冷啟動pipeline 還在跑)和 RestartServer
// pipeline 已 readyhook 會被 CompleteStage 內的 current!=stage 檢查擋下)共用。
// 因此呼叫 hook 是 idempotent / 安全的。
func (a *App) startServerV2(preferredPort int) (*ServerProcess, error) {
// 1. 決定 Python runtime — 對應啟動階段 2「檢查 Python runtime」
pyBin, pyMode, err := a.ensurePythonRuntime(a.pythonMode)
if err != nil {
// 階段 2 失敗
if a.startupPipeline != nil {
a.startupPipeline.FailStage(2, fmt.Errorf("python runtime unavailable: %w", err))
}
return nil, fmt.Errorf("python runtime unavailable: %w", err)
}
a.mu.Lock()
a.pythonBin = pyBin
a.pythonModeR = pyMode
a.mu.Unlock()
// 階段 2 完成 → 自動進入階段 3 running
if a.startupPipeline != nil {
a.startupPipeline.CompleteStage(2)
}
// 2. 首次啟動自動安裝 Kneron WinUSB driverWindows only
if pyBin != "" {
if derr := a.ensureDriverInstalled(pyBin); derr != nil {
a.appLog("driver auto-install failed (non-fatal): %v", derr)
}
}
// 3. 挑 port
var port int
if preferredPort > 0 {
// Restart 路徑:強制保留,不 fallback
if !portAvailable(preferredPort) {
// 先試著清掉 stale 的 visiona-local-server
if killStaleServerOnPort(preferredPort) {
time.Sleep(500 * time.Millisecond)
}
}
if !portAvailable(preferredPort) {
return nil, fmt.Errorf("Restart failed: port %d occupied", preferredPort)
}
port = preferredPort
} else {
p, perr := pickPort(defaultPreferredPort)
if perr != nil {
return nil, fmt.Errorf("no free port: %w", perr)
}
port = p
}
// 4. 定位 server binary
binPath, err := locateServerBinary()
if err != nil {
return nil, fmt.Errorf("server binary not found: %w", err)
}
// 5. 組參數
args := []string{
"--host", "127.0.0.1",
"--port", strconv.Itoa(port),
"--data-dir", a.dataDir,
"--python-mode", string(pyMode),
}
if pyBin != "" {
args = append(args, "--python", pyBin)
}
// 6. 開磁碟 log 檔append 模式)
logsDir := filepath.Join(a.dataDir, "logs")
_ = os.MkdirAll(logsDir, 0o755)
stdoutLog, _ := os.OpenFile(filepath.Join(logsDir, "server.stdout.log"),
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
stderrLog, _ := os.OpenFile(filepath.Join(logsDir, "server.stderr.log"),
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
// 7. 組 cmd
cmd := exec.Command(binPath, args...)
cmd.Dir = filepath.Dir(binPath)
configureSysProcAttr(cmd)
// 注入 bundle bin dirffmpeg / ffprobe
env := os.Environ()
if binDir, err := locateBundleBinDir(); err == nil {
env = append(env, "VISIONA_BUNDLE_BIN_DIR="+binDir)
}
if pyBin != "" {
env = append(env, "VISIONA_PYTHON="+pyBin)
}
cmd.Env = env
// 8. v2用 Pipe 而不是 cmd.Stdout = file
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
closeLogFilesSafe(stdoutLog, stderrLog)
return nil, fmt.Errorf("StdoutPipe: %w", err)
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
_ = stdoutPipe.Close()
closeLogFilesSafe(stdoutLog, stderrLog)
return nil, fmt.Errorf("StderrPipe: %w", err)
}
// 9. 啟動 process
if a.startupPipeline != nil && a.startupPipeline.IsInColdStart() {
a.startupPipeline.EmitStageDetail(3, "startup.stage.3.detail.spawn", 0)
}
if err := cmd.Start(); err != nil {
_ = stdoutPipe.Close()
_ = stderrPipe.Close()
closeLogFilesSafe(stdoutLog, stderrLog)
return nil, fmt.Errorf("exec.Start: %w", err)
}
proc := &ServerProcess{
cmd: cmd,
port: port,
stdoutLog: stdoutLog,
stderrLog: stderrLog,
app: a,
}
// 10. 啟動兩個 logPump goroutine
go a.logPump(stdoutPipe, "stdout", stdoutLog)
go a.logPump(stderrPipe, "stderr", stderrLog)
// 11. 等 health check — 對應啟動階段 3「啟動本機伺服器」
//
// 冷啟動時 pause hard timeoutWindows 首次執行 visiona-local-server.exe 會被
// Defender / EDR real-time scan 卡 30-120 秒healthCheckTimeout 本身 180 秒足夠
// 涵蓋大多數情境,但不該把這段 scan 時間算進 startup pipeline 的 60 秒 total
// budget那是日常啟動預算首次 bootstrap 應豁免,和 Stage 2 Python bootstrap
// 同理)。
//
// 判斷冷啟動pipeline 處於 stage 1-6 範圍內IsInColdStart
// RestartServerpipeline 已 readycurrent==7不 pause維持嚴格計時。
pausedForWait := false
if a.startupPipeline != nil && a.startupPipeline.IsInColdStart() {
a.startupPipeline.PauseHardTimeout()
pausedForWait = true
// Stage 3 sub-step 提示:告訴使用者 server binary 已送出,正在等待啟動
a.startupPipeline.EmitStageDetail(3, "startup.stage.3.detail.waitHealth", 0)
}
// waitHealthy progress callback每 5 秒更新一次 stage-hint 顯示已等待時間;
// 等待 >= 15 秒後改顯示 slow hint首次啟動 Defender 掃描較慢是正常情況)。
waitProgress := func(elapsed int) {
if a.startupPipeline == nil {
return
}
key := "startup.stage.3.detail.waitHealth"
if elapsed >= 15 {
key = "startup.stage.3.detail.waitHealthSlow"
}
a.startupPipeline.EmitStageDetail(3, key, elapsed)
}
if err := waitHealthy(port, healthCheckTimeout, waitProgress); err != nil {
if pausedForWait {
a.startupPipeline.ResumeHardTimeout()
}
proc.forceKill()
removeIPCPort(a.dataDir)
// 階段 3 失敗
if a.startupPipeline != nil {
a.startupPipeline.FailStage(3, fmt.Errorf("server did not become healthy: %w", err))
}
return nil, fmt.Errorf("server did not become healthy: %w", err)
}
if pausedForWait {
a.startupPipeline.ResumeHardTimeout()
}
// 階段 3 完成 → 自動進入階段 4 running
if a.startupPipeline != nil {
a.startupPipeline.CompleteStage(3)
}
// 12. 寫 ipc-port 檔
writeIPCPort(a.dataDir, port)
// 12.5. M8-4b階段 4「偵測 Kneron 裝置」— 探測 /api/devices 一次。
// 任何 HTTP response含 5xx 或 timeout都視為「server 已能 serve」階段 4 完成。
// 真正的 device list 內容由前端負責處理(空 list 也算正常)。
if a.startupPipeline != nil {
a.probeDeviceListAndComplete(port)
}
// 13. 啟動 watchServer失敗進 Error state
watchCtx, cancel := context.WithCancel(context.Background())
a.mu.Lock()
if a.watchCancel != nil {
a.watchCancel()
}
a.watchCancel = cancel
a.mu.Unlock()
go a.watchServerV2(watchCtx, proc)
return proc, nil
}
// probeDeviceListAndComplete 對 /api/devices 發一次 GET 作為「偵測 Kneron 裝置」階段,
// 不論回應內容與狀態碼,只要拿到 HTTP response 就算階段 4 完成(我們只是想確認
// server 已能 serve 業務 endpoint硬體有沒有插不重要 — 空 list 也合法)。
//
// timeout 設 2 秒。TDD §3 原意是「秒回即算完成」;實測 /api/devices handler 讀內部
// registry毫秒級就回2 秒 timeout 足以涵蓋正常 latency不需要 5 秒保守值。
// 逾時也算「偵測完成」(避免硬體 driver 卡住整個啟動流程)。
func (a *App) probeDeviceListAndComplete(port int) {
if a.startupPipeline == nil {
return
}
url := fmt.Sprintf("http://127.0.0.1:%d/api/devices", port)
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(url)
if resp != nil {
resp.Body.Close()
}
// 不論 err 或 status都視為階段 4 完成(只是 probe
if err != nil {
fmt.Fprintf(os.Stderr, "[visiona-local] startup stage 4: device probe non-fatal error: %v\n", err)
}
a.startupPipeline.CompleteStage(4)
}
func closeLogFilesSafe(files ...*os.File) {
for _, f := range files {
if f != nil {
_ = f.Close()
}
}
}
// -----------------------------------------------------------------------
// logPumppipe → LogBuffer → Wails event10ms micro-batch
// -----------------------------------------------------------------------
// logPump 讀取 server 子程序的 stdout 或 stderr pipe
// 每行同時:
// 1. 寫到磁碟 log 檔
// 2. append 到 ring buffer
// 3. 累積到 10ms micro-batchflush 時 emit "log:append" Wails event
//
// TDD ground truthv2/control-panel.md §4.5、v2/server-lifecycle.md §4.2。
//
// Pipe EOFprocess exit或 scanner 錯誤時 goroutine 自行退出。
func (a *App) logPump(pipe io.ReadCloser, stream string, fileWriter io.Writer) {
defer pipe.Close()
scanner := bufio.NewScanner(pipe)
scanner.Buffer(make([]byte, 64*1024), 1*1024*1024)
batch := make([]LogLine, 0, 16)
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
// 拆出 scanner goroutine讓 main loop 可以 select 於 ticker + lineCh
scanDone := make(chan struct{})
lineCh := make(chan string, 128)
go func() {
defer close(scanDone)
for scanner.Scan() {
text := scanner.Text()
select {
case lineCh <- text:
default:
// channel full同步 block避免 scanner 跑太快讓 batch 丟太多行
// 寧可 scanner 背壓一下,也要保留資料完整性
lineCh <- text
}
}
}()
flush := func() {
if len(batch) == 0 {
return
}
if a.ctx != nil && a.logBuf != nil {
// Rate limitburst 超過就不 emit但 ring buffer 已 append 過
if a.logBuf.ShouldEmit() {
wailsRuntime.EventsEmit(a.ctx, "log:append", batch)
}
}
batch = batch[:0]
}
// processLine 把單行寫檔 + append ring buffer + 加進 batch。
// MAJ-5 修復後 main loop 與 drain loop 共用此 helper確保兩條路徑語意一致。
processLine := func(line string) {
// 1. 寫檔(持久化)
if fileWriter != nil {
_, _ = fileWriter.Write([]byte(line + "\n"))
}
// 2. ring buffer
l := LogLine{
Ts: time.Now().UnixMilli(),
Stream: stream,
Line: line,
Level: parseLogLevel(line),
}
if a.logBuf != nil {
a.logBuf.Append(l)
}
// 3. 加到 batch
batch = append(batch, l)
}
for {
select {
case line, ok := <-lineCh:
if !ok {
flush()
return
}
processLine(line)
case <-ticker.C:
flush()
case <-scanDone:
// MAJ-5 修復scanner goroutine 已結束pipe EOF / process exit
// 但 lineCh buffercap 128內可能還有未消化的行 — 包括 server crash
// 時最後幾行 stderr stack trace。Go select 隨機選擇 ready case
// 直接 return 會導致這些行被丟棄。
//
// 修法drain lineCh 到底再 flush + return。drain 用 default 避免
// 永遠阻塞scanner goroutine 已 close → 沒人會再寫入drain 必能結束)。
ticker.Stop()
drain:
for {
select {
case line := <-lineCh:
processLine(line)
default:
break drain
}
}
flush()
return
}
}
}
// -----------------------------------------------------------------------
// watchServerV2 — 取代 watchServer失敗時進 Error state
// -----------------------------------------------------------------------
// watchServerV2 每 10 秒打 /api/system/health連續 3 次失敗進 Error state。
// 不再呼叫 reportFatal / os.Exit。
func (a *App) watchServerV2(ctx context.Context, sp *ServerProcess) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
failures := 0
healthURL := fmt.Sprintf("http://127.0.0.1:%d/api/system/health", sp.port)
client := &http.Client{Timeout: 3 * time.Second}
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
resp, err := client.Get(healthURL)
if err == nil && resp.StatusCode == 200 {
resp.Body.Close()
if failures > 0 && a.ctx != nil {
wailsRuntime.EventsEmit(a.ctx, "server:recovered", nil)
}
failures = 0
continue
}
if resp != nil {
resp.Body.Close()
}
failures++
fmt.Fprintf(os.Stderr, "[visiona-local] server health check failed (%d/3): %v\n", failures, err)
if failures >= 3 {
if a.ctrl != nil {
a.ctrl.handleWatchFailure(sp.port, "health check failed 3 times")
}
return
}
}
}
}
// -----------------------------------------------------------------------
// Bindings — Wails 自動暴露給前端
// -----------------------------------------------------------------------
// StartServer 啟動 server 子程序(允許 port fallback
// 對應 TDD v2/control-panel.md §4.2。
func (a *App) StartServer() error {
if a.ctrl == nil {
return fmt.Errorf("controller not initialized")
}
return a.ctrl.Start()
}
// StopServer 優雅停止 server。
func (a *App) StopServer() error {
if a.ctrl == nil {
return fmt.Errorf("controller not initialized")
}
return a.ctrl.Stop()
}
// RestartServer 強制保留舊 port 的重啟。
func (a *App) RestartServer() error {
if a.ctrl == nil {
return fmt.Errorf("controller not initialized")
}
return a.ctrl.Restart()
}
// ForceKillServer 強制 SIGKILL server非 graceful無通知
// 主要給 RestartStartupSequence 用。
func (a *App) ForceKillServer() error {
if a.ctrl == nil {
return fmt.Errorf("controller not initialized")
}
return a.ctrl.ForceKill()
}
// RestartStartupSequence 完整重置 6 階段啟動流程M8-4b / R5-E
// 由 Wails 控制台 Error state 的「Retry」按鈕呼叫。
//
// 與 RestartServer 的差別:
// RestartServer : Stop → Start保留 port、不重置 startup pipeline
// RestartStartupSequence : ForceKill → 清狀態 → 重建 StartupPipeline → 從階段 2 跑
//
// TDD 規格見 .autoflow/04-architecture/v2/startup-pipeline.md §8。
//
// 五個步驟:
// 1. 停掉舊 watcher goroutine
// 2. ForceKill server 子程序
// 3. 把 state machine 切回 Stopped避免 Error state 殘留)
// 4. 清 sentinel filecritical — 否則階段 6 會誤判為瞬間完成)
// 5. 重建 StartupPipeline、emit 階段 1 completed、開新 watcher、呼叫 ctrl.Start()
func (a *App) RestartStartupSequence() error {
if a.ctrl == nil {
return fmt.Errorf("controller not initialized")
}
// Step 1: 停掉舊 watcher goroutine
if a.pipelineCancelFn != nil {
a.pipelineCancelFn()
a.pipelineCancelFn = nil
}
// Step 2: 強制殺掉 server 子程序(不走 7s grace我們是在 recover failure
_ = a.ctrl.ForceKill()
// Step 3: state machine → Stopped避免 Error state 殘留)
a.ctrl.setState(ServerStateStopped, "")
// Step 4: 清 sentinel file前次 Run 的殘留會讓階段 6 立刻完成)
removeSentinelFile(a.dataDir)
// Step 5: 重建 StartupPipeline
a.startupPipeline = NewStartupPipeline(a)
// 階段 1「初始化 Wails 控制台」已是 running 狀態Wails app 本身),
// 不需要重做,直接 emit completed 然後手動切到階段 2 running
now := time.Now()
a.startupPipeline.mu.Lock()
a.startupPipeline.startedAt = now
a.startupPipeline.current = 1
a.startupPipeline.stages[1].status = "completed"
a.startupPipeline.stages[1].startedAt = now
a.startupPipeline.stages[1].completedAt = now
a.startupPipeline.mu.Unlock()
a.startupPipeline.emitProgress(1)
// 啟動 watcher goroutine
if a.ctx != nil {
watcherCtx, cancel := context.WithCancel(a.ctx)
a.pipelineCancelFn = cancel
a.startupPipeline.watcherCancel = cancel
a.startupPipeline.watcherDone = make(chan struct{})
go a.startupPipeline.watcher(watcherCtx)
}
// 切到階段 2 running
a.startupPipeline.mu.Lock()
a.startupPipeline.current = 2
a.startupPipeline.stages[2].status = "running"
a.startupPipeline.stages[2].startedAt = time.Now()
a.startupPipeline.mu.Unlock()
a.startupPipeline.emitProgress(2)
// 呼叫 StartServer內部會依序 CompleteStage(2..4)
// Retry 情境允許 port fallbackcold start 模式)
//
// M8-4b 補丁 M-3test hook — 單元測試可用 restartStartFn 替換 ctrl.Start
// 避免在測試環境 spawn 真的 python server。正式環境走預設 a.ctrl.Start()。
startFn := a.ctrl.Start
if a.restartStartFn != nil {
startFn = a.restartStartFn
}
if err := startFn(); err != nil {
// startServerV2 內已 FailStage不需重複err 仍 propagate 給前端讓 Retry 按鈕能 catch
return err
}
// 階段 5開瀏覽器或 skip
a.runStartupStage5()
// 階段 6 由 watcher poll sentinel file 觸發
return nil
}
// GetServerStatusV2 回傳 v2 版本的完整狀態。
// 注意v1 的 GetServerStatus() 仍保留以維持相容性,但內容為舊 struct。
func (a *App) GetServerStatusV2() ServerStatusV2 {
return a.snapshotStatus()
}
// snapshotStatus 是組裝 ServerStatusV2 的工具函式(內部使用)。
func (a *App) snapshotStatus() ServerStatusV2 {
a.mu.Lock()
pyBin := a.pythonBin
pyMode := string(a.pythonModeR)
a.mu.Unlock()
st := ServerStatusV2{
PythonBin: pyBin,
PythonMode: pyMode,
}
if a.ctrl != nil {
a.ctrl.mu.Lock()
st.State = a.ctrl.state
st.LastError = a.ctrl.lastError
if !a.ctrl.startedAt.IsZero() {
st.StartedAt = a.ctrl.startedAt.UnixMilli()
}
proc := a.ctrl.proc
a.ctrl.mu.Unlock()
if proc != nil && proc.cmd != nil && proc.cmd.Process != nil {
st.Port = proc.port
st.URL = fmt.Sprintf("http://127.0.0.1:%d", proc.port)
st.PID = proc.cmd.Process.Pid
}
} else {
st.State = ServerStateIdle
}
return st
}
// GetRecentLogs 回傳 ring buffer 最後 n 行。
// n <= 0 或 > 2000 → 回傳全部。
func (a *App) GetRecentLogs(n int) []LogLine {
if a.logBuf == nil {
return []LogLine{}
}
return a.logBuf.Snapshot(n)
}
// ClearLogs 清空 ring buffer只清 UI不清磁碟檔
func (a *App) ClearLogs() {
if a.logBuf != nil {
a.logBuf.Reset()
}
if a.ctx != nil {
wailsRuntime.EventsEmit(a.ctx, "log:clear", nil)
}
}
// GetSystemInfo 回傳靜態系統資訊。
func (a *App) GetSystemInfo() SystemInfo {
logsDir := ""
if a.dataDir != "" {
logsDir = filepath.Join(a.dataDir, "logs")
}
return SystemInfo{
AppVersion: appVersionString(),
BuildTime: appBuildTimeString(),
DataDir: a.dataDir,
LogsDir: logsDir,
Platform: runtime.GOOS + "/" + runtime.GOARCH,
}
}
// OpenInBrowser 用系統瀏覽器開啟 url。空字串 → 用當前 server URL。
func (a *App) OpenInBrowser(url string) error {
if url == "" {
a.mu.Lock()
if a.ctrl != nil {
a.ctrl.mu.Lock()
if a.ctrl.proc != nil {
url = fmt.Sprintf("http://127.0.0.1:%d", a.ctrl.proc.port)
}
a.ctrl.mu.Unlock()
}
a.mu.Unlock()
}
if url == "" {
return fmt.Errorf("no server URL available")
}
return openBrowser(url)
}
// RevealLogsFolder 在檔案管理器中開啟 <dataDir>/logs/ 目錄。
func (a *App) RevealLogsFolder() error {
logsDir := filepath.Join(a.dataDir, "logs")
if err := os.MkdirAll(logsDir, 0o755); err != nil {
return fmt.Errorf("mkdir logs dir: %w", err)
}
switch runtime.GOOS {
case "darwin":
return exec.Command("open", logsDir).Start()
case "windows":
return exec.Command("explorer", logsDir).Start()
default:
return exec.Command("xdg-open", logsDir).Start()
}
}
// ExportLog 把 ring buffer 當前內容寫到一個時間戳檔案並回傳絕對路徑。
func (a *App) ExportLog() (string, error) {
if a.logBuf == nil {
return "", fmt.Errorf("log buffer not initialized")
}
exportDir := filepath.Join(a.dataDir, "exports")
if err := os.MkdirAll(exportDir, 0o755); err != nil {
return "", fmt.Errorf("mkdir exports dir: %w", err)
}
ts := time.Now().Format("20060102-150405")
path := filepath.Join(exportDir, fmt.Sprintf("log-%s.txt", ts))
snap := a.logBuf.Snapshot(0)
f, err := os.Create(path)
if err != nil {
return "", fmt.Errorf("create export file: %w", err)
}
defer f.Close()
for _, line := range snap {
// 格式:"[level] 15:04:05.000 [stream] message\n"level 為空時省略前括號)
t := time.UnixMilli(line.Ts).Format("15:04:05.000")
var prefix string
if line.Level != "" {
prefix = fmt.Sprintf("[%s] %s [%s] ", line.Level, t, line.Stream)
} else {
prefix = fmt.Sprintf("%s [%s] ", t, line.Stream)
}
if _, err := f.WriteString(prefix + line.Line + "\n"); err != nil {
return "", fmt.Errorf("write export file: %w", err)
}
}
abs, err := filepath.Abs(path)
if err != nil {
return path, nil
}
return abs, nil
}
// GetPreferences 讀取 Preferencesin-memory
func (a *App) GetPreferences() Preferences {
a.mu.Lock()
defer a.mu.Unlock()
return a.prefs
}
// SetPreferences 更新並持久化 Preferences。
func (a *App) SetPreferences(p Preferences) error {
if a.dataDir == "" {
return fmt.Errorf("data dir not ready")
}
if err := SavePreferences(a.dataDir, p); err != nil {
return err
}
a.mu.Lock()
a.prefs = p
a.mu.Unlock()
return nil
}
// -----------------------------------------------------------------------
// 版本字串 placeholderM8 目前沒有 build 時注入版本,給一個合理預設)
// -----------------------------------------------------------------------
func appVersionString() string {
if v := os.Getenv("VISIONA_APP_VERSION"); v != "" {
return v
}
return "dev"
}
func appBuildTimeString() string {
if v := os.Getenv("VISIONA_BUILD_TIME"); v != "" {
return v
}
return "unknown"
}