使用者在 Windows 乾淨環境跑 installer 後首次啟動,看到「伺服器無法
啟動」紅 banner + Settings Stop 卡住。根因:
Stage 2 ensurePythonRuntime 在首次 bootstrap 要做 (1) 解壓 ~15MB
Python tarball (2) 建 venv (3) pip install 9 個 wheel(含 numpy 20MB
+ opencv 50MB + KneronPLUS 等 ~150MB 解壓後)。乾淨 Windows 環境上
這三步合計 2-5 分鐘,遠超 R5-E1 的 60 秒 startup hard timeout,導致
pipeline FailStage + emitError(total-timeout) → Error state → 紅 banner。
R5-E1 的 60 秒預算是針對「日常啟動」,不含首次一次性 bootstrap。
修法:StartupPipeline 加 PauseHardTimeout / ResumeHardTimeout API,
app.go 在 ensureBundledPython 偵測到「真正 bootstrap」條件(pythonBin
不存在)時呼叫 Pause,defer Resume。暫停期間 sinceTotal 扣掉 paused
duration,hard timeout 不觸發。Soft timeout(每階段 20 秒「正在重試」
hint)照常,使用者仍能看到進度提示。
配套:修 killStaleServerOnPort 識別 go run 編出來的 server(Bug A)。
原本用 ps -o comm= 比對 "visiona-local-server" 字串,但 go run 產物
comm 只是 "server"(或 "exe"),生產環境不受影響,但開發 / Reviewer
測試流程會踩到(早上 M8-4 Reviewer 留了一組 go run server 孤兒占住
port 3721 到現在)。改用 ps -o args= 取完整 command line,匹配 規則:
1. 含 "visiona-local-server" — packaged binary
2. 含 "/go-build" 且含 "visiona-local/server" 或 "/exe/server" — go run
驗證:
- visiona-local 套件 go build / vet / test / test -race 全綠
- server 套件 go build / vet / test 全綠
- 3 個新 unit test 通過:
- PauseHardTimeout_ExcludesPausedDuration(effective 時鐘正確扣除)
- PauseHardTimeout_PreventsHardTimeout(wall clock 120s + paused 90s
= effective 30s,不觸發 60s hard timeout)
- ResumeHardTimeout_NoopWhenNotPaused(idempotent)
- macOS dmg 重 build 163MB OK
待做(M8-10b):使用者在 Windows 乾淨環境重新 install + 驗證首次啟動。
如果仍失敗,Windows log 位置:
%APPDATA%\visiona-local\logs\server.stdout.log
%APPDATA%\visiona-local\logs\server.stderr.log
%APPDATA%\visiona-local\logs\wails.log(若有)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
493 lines
16 KiB
Go
493 lines
16 KiB
Go
package main
|
||
|
||
// startup_pipeline.go — M8-4b:6 階段啟動進度 + soft/hard timeout + watcher
|
||
//
|
||
// TDD ground truth:
|
||
// - .autoflow/04-architecture/v2/startup-pipeline.md(518 行完整規格)
|
||
// - R5-E1 ~ R5-E6(AC-1.3 從 10 秒硬指標 → 60 秒 + 階段化進度)
|
||
//
|
||
// 設計重點:
|
||
// 1. 6 個階段(1 = Wails console / 2 = Python runtime / 3 = server / 4 = devices /
|
||
// 5 = open browser / 6 = wait Web UI WebSocket)
|
||
// 2. soft timeout = 20 秒(每階段),emit "startup:stage-timeout" 但不中斷
|
||
// 3. hard timeout = 60 秒(總時),emit "startup:error" + 進 Error state
|
||
// 4. 階段 5/6 在 AutoOpenBrowser=false 時:
|
||
// - 階段 5 → status="skipped"
|
||
// - 階段 6 → 不檢查 timeout(使用者必須手動點 Open in Browser 才會建立 WebSocket)
|
||
// 5. 階段 6 透過 sentinel file `<dataDir>/.first-ws-connected` 偵測;
|
||
// server 端 Hub 在第一個 WS client 連上時寫檔(見 server/internal/api/ws/hub.go)
|
||
// 6. RestartStartupSequence binding:Retry 按鈕用,5 步驟重置整個流程
|
||
//
|
||
// 1-indexed stages:陣列多配一格避免 off-by-one,stages[1] ~ stages[6] 使用。
|
||
// current sentinel 值:0 = 未啟動、1-6 = 進行中、7 = ready、-1 = 已失敗。
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sync"
|
||
"time"
|
||
|
||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||
)
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 常量
|
||
// -----------------------------------------------------------------------
|
||
|
||
const (
|
||
startupTotalStages = 6
|
||
startupSoftTimeout = 20 * time.Second
|
||
startupHardTimeout = 60 * time.Second
|
||
startupWatcherTick = 1 * time.Second
|
||
)
|
||
|
||
// startupSentinelFileName 是 server 端寫入、Wails 端 poll 的檔名。
|
||
// 路徑:<dataDir>/.first-ws-connected
|
||
const startupSentinelFileName = ".first-ws-connected"
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Event payload
|
||
// -----------------------------------------------------------------------
|
||
|
||
// StartupProgressEvent 對應 startup-pipeline.md §1.1。
|
||
// 每階段 status 變化(running / completed / failed / skipped)都會 emit 一次。
|
||
type StartupProgressEvent struct {
|
||
Stage int `json:"stage"` // 1-6
|
||
TotalStages int `json:"totalStages"` // 固定 6
|
||
LabelKey string `json:"labelKey"` // 如 "startup.stage.2.label"
|
||
Status string `json:"status"` // "pending" | "running" | "completed" | "failed" | "skipped"
|
||
StartedAt int64 `json:"startedAt"` // Unix ms,該階段 startedAt
|
||
}
|
||
|
||
// StartupStageTimeoutEvent — soft timeout 提示,不中斷流程,只 emit 一次/階段。
|
||
type StartupStageTimeoutEvent struct {
|
||
Stage int `json:"stage"`
|
||
SoftTimeoutSeconds int `json:"softTimeoutSeconds"` // 固定 20
|
||
}
|
||
|
||
// StartupErrorEvent — 階段失敗或總時 hard timeout。emit 後 pipeline 停止。
|
||
type StartupErrorEvent struct {
|
||
Stage int `json:"stage"`
|
||
Error string `json:"error"`
|
||
Cause string `json:"cause"` // "stage-failure" | "total-timeout"
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// stageState — 單一階段的內部狀態
|
||
// -----------------------------------------------------------------------
|
||
|
||
type stageState struct {
|
||
status string // "pending" | "running" | "completed" | "failed" | "skipped"
|
||
startedAt time.Time
|
||
completedAt time.Time
|
||
softTimeoutEmitted bool
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// StartupPipeline — 主 struct
|
||
// -----------------------------------------------------------------------
|
||
|
||
// StartupPipeline 管理 6 階段啟動流程。
|
||
// 與 ServerController 解耦:StartupPipeline 只 emit Wails event,
|
||
// 它對 server lifecycle 的影響由 ServerController(透過 emitError → setState)負責。
|
||
type StartupPipeline struct {
|
||
app *App
|
||
|
||
mu sync.Mutex
|
||
stages [startupTotalStages + 1]stageState // 1-indexed
|
||
current int // 0=未啟動、1-6=進行中、7=ready、-1=failed
|
||
startedAt time.Time
|
||
|
||
// Hard timeout pause 機制(首次 Python bootstrap 專用)。
|
||
//
|
||
// R5-E1 的 60 秒 hard timeout 預算是針對「日常啟動」,不含首次安裝 Python
|
||
// runtime(解壓 ~15MB tarball + 建 venv + pip install 9 個 wheel 含 numpy /
|
||
// opencv 合計 ~150MB)這種一次性 bootstrap 工作——那可能花 2-5 分鐘。
|
||
//
|
||
// 解法:讓 app.go 在真正需要首次 bootstrap 時(pythonBin 不存在)呼叫
|
||
// PauseHardTimeout(),完成後呼叫 ResumeHardTimeout()。暫停期間不算進
|
||
// sinceTotal,避開 hard timeout。Soft timeout 繼續照常(每階段 20 秒的
|
||
// 視覺提示不受影響,使用者仍會看到「正在重試...」hint)。
|
||
//
|
||
// 只暫停 hard timeout,不暫停整體時鐘——因為 soft timeout 的語意是
|
||
// 「單一階段停滯太久」,首次 bootstrap 確實會停滯,該提示就該提示。
|
||
pausedDuration time.Duration // 累積暫停總時
|
||
pauseStartedAt time.Time // 當前暫停開始時間;zero = 未暫停
|
||
|
||
// watcher goroutine 控制。pipelineCancelFn 由 app.go 持有;本 struct 只記 done channel。
|
||
watcherCancel context.CancelFunc
|
||
watcherDone chan struct{}
|
||
}
|
||
|
||
// NewStartupPipeline 建立新的 pipeline。並未啟動 watcher,必須呼叫 Start。
|
||
func NewStartupPipeline(app *App) *StartupPipeline {
|
||
return &StartupPipeline{
|
||
app: app,
|
||
current: 0,
|
||
}
|
||
}
|
||
|
||
// Start 啟動整個 pipeline,從階段 1 開始 emit running,並開啟 watcher goroutine。
|
||
// 只能呼叫一次(重複呼叫會建立多個 watcher 是合法的,但會浪費資源 — 上層應避免)。
|
||
func (p *StartupPipeline) Start(ctx context.Context) {
|
||
p.mu.Lock()
|
||
p.startedAt = time.Now()
|
||
p.current = 1
|
||
p.stages[1].status = "running"
|
||
p.stages[1].startedAt = time.Now()
|
||
p.mu.Unlock()
|
||
|
||
p.emitProgress(1)
|
||
|
||
watcherCtx, cancel := context.WithCancel(ctx)
|
||
p.watcherCancel = cancel
|
||
p.watcherDone = make(chan struct{})
|
||
go p.watcher(watcherCtx)
|
||
}
|
||
|
||
// CompleteStage 標記 stage 為 completed,並進入下一階段(若還有)。
|
||
// 若 stage == startupTotalStages → 觸發 markReady。
|
||
//
|
||
// 順序錯誤(重複呼叫或階段不對)→ 安靜 ignore,避免被 race condition 害到。
|
||
func (p *StartupPipeline) CompleteStage(stage int) {
|
||
p.mu.Lock()
|
||
if p.current != stage || p.current <= 0 {
|
||
p.mu.Unlock()
|
||
return
|
||
}
|
||
p.stages[stage].status = "completed"
|
||
p.stages[stage].completedAt = time.Now()
|
||
p.mu.Unlock()
|
||
|
||
p.emitProgress(stage)
|
||
|
||
if stage == startupTotalStages {
|
||
p.markReady()
|
||
return
|
||
}
|
||
|
||
// 進入下一階段
|
||
p.mu.Lock()
|
||
next := stage + 1
|
||
p.current = next
|
||
p.stages[next].status = "running"
|
||
p.stages[next].startedAt = time.Now()
|
||
p.mu.Unlock()
|
||
p.emitProgress(next)
|
||
}
|
||
|
||
// SkipStage 標記 stage 為 skipped,並進入下一階段。
|
||
// 用於:階段 5 在 prefs.AutoOpenBrowser=false 時跳過 OpenInBrowser 呼叫。
|
||
// Watcher 看到 status=skipped → 不檢查 soft timeout(也不檢查 hard timeout)。
|
||
func (p *StartupPipeline) SkipStage(stage int) {
|
||
p.mu.Lock()
|
||
if p.current != stage || p.current <= 0 {
|
||
p.mu.Unlock()
|
||
return
|
||
}
|
||
p.stages[stage].status = "skipped"
|
||
p.stages[stage].completedAt = time.Now()
|
||
p.mu.Unlock()
|
||
|
||
p.emitProgress(stage)
|
||
|
||
if stage == startupTotalStages {
|
||
p.markReady()
|
||
return
|
||
}
|
||
|
||
p.mu.Lock()
|
||
next := stage + 1
|
||
p.current = next
|
||
p.stages[next].status = "running"
|
||
p.stages[next].startedAt = time.Now()
|
||
p.mu.Unlock()
|
||
p.emitProgress(next)
|
||
}
|
||
|
||
// PauseHardTimeout 暫停 hard timeout 計時。
|
||
// 用於首次 Python bootstrap(可能花 2-5 分鐘解壓 + pip install)期間。
|
||
// 重複呼叫安全:第二次呼叫會先 Resume 再 Pause,避免累積偏差。
|
||
// 呼叫者:app.go ensureBundledPython 的「真正 bootstrap」路徑。
|
||
func (p *StartupPipeline) PauseHardTimeout() {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
// 已在暫停 → 先結束前一次暫停區間再開新的(保持精確)
|
||
if !p.pauseStartedAt.IsZero() {
|
||
p.pausedDuration += time.Since(p.pauseStartedAt)
|
||
}
|
||
p.pauseStartedAt = time.Now()
|
||
}
|
||
|
||
// ResumeHardTimeout 結束 hard timeout 暫停。
|
||
// 若未處於暫停狀態則 no-op(不回 error,避免上層要做空檢查)。
|
||
func (p *StartupPipeline) ResumeHardTimeout() {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
if p.pauseStartedAt.IsZero() {
|
||
return
|
||
}
|
||
p.pausedDuration += time.Since(p.pauseStartedAt)
|
||
p.pauseStartedAt = time.Time{}
|
||
}
|
||
|
||
// effectiveSinceTotal 回傳扣除暫停時間後的總時。watcher 用這個判斷 hard timeout。
|
||
// 呼叫者必須已持有 p.mu。
|
||
func (p *StartupPipeline) effectiveSinceTotalLocked() time.Duration {
|
||
total := time.Since(p.startedAt) - p.pausedDuration
|
||
if !p.pauseStartedAt.IsZero() {
|
||
// 目前正在暫停中 → 扣掉當前暫停區間
|
||
total -= time.Since(p.pauseStartedAt)
|
||
}
|
||
if total < 0 {
|
||
total = 0
|
||
}
|
||
return total
|
||
}
|
||
|
||
// FailStage 標記 stage 為 failed,pipeline 停止並進 Error state。
|
||
//
|
||
// 副作用(透過 emitError):
|
||
// - emit "startup:error" Wails event
|
||
// - 若 ctrl 存在 → setState(Error)(前端會收到 server:state-change)
|
||
// - 發 OS 通知(R5-D1)
|
||
func (p *StartupPipeline) FailStage(stage int, err error) {
|
||
p.mu.Lock()
|
||
if p.current <= 0 {
|
||
p.mu.Unlock()
|
||
return
|
||
}
|
||
p.stages[stage].status = "failed"
|
||
p.current = -1
|
||
p.mu.Unlock()
|
||
|
||
p.emitProgress(stage)
|
||
p.emitError(stage, err, "stage-failure")
|
||
p.stopWatcher()
|
||
}
|
||
|
||
// markReady 6 階段都完成後觸發。emit "startup:ready" 並停 watcher。
|
||
func (p *StartupPipeline) markReady() {
|
||
p.mu.Lock()
|
||
p.current = startupTotalStages + 1
|
||
p.mu.Unlock()
|
||
|
||
if p.app != nil && p.app.ctx != nil {
|
||
// 同步 emit(成功路徑沒有 IPC backlog 風險)
|
||
wailsRuntime.EventsEmit(p.app.ctx, "startup:ready", nil)
|
||
}
|
||
p.stopWatcher()
|
||
}
|
||
|
||
// emitProgress 取當前 stage 狀態 snapshot 並 emit "startup:progress" event。
|
||
// 用 goroutine 包起來避免 Wails IPC 慢拖累呼叫者(CompleteStage / SkipStage 等)。
|
||
func (p *StartupPipeline) emitProgress(stage int) {
|
||
p.mu.Lock()
|
||
if stage <= 0 || stage > startupTotalStages {
|
||
p.mu.Unlock()
|
||
return
|
||
}
|
||
st := p.stages[stage]
|
||
p.mu.Unlock()
|
||
|
||
if p.app == nil || p.app.ctx == nil {
|
||
return
|
||
}
|
||
go func() {
|
||
wailsRuntime.EventsEmit(p.app.ctx, "startup:progress", StartupProgressEvent{
|
||
Stage: stage,
|
||
TotalStages: startupTotalStages,
|
||
LabelKey: fmt.Sprintf("startup.stage.%d.label", stage),
|
||
Status: st.status,
|
||
StartedAt: st.startedAt.UnixMilli(),
|
||
})
|
||
}()
|
||
}
|
||
|
||
// emitError emit "startup:error" 並通知 ctrl 進 Error state + 發 OS 通知。
|
||
// cause 取值:"stage-failure" | "total-timeout"
|
||
func (p *StartupPipeline) emitError(stage int, err error, cause string) {
|
||
if p.app == nil {
|
||
return
|
||
}
|
||
if p.app.ctx != nil {
|
||
go func() {
|
||
wailsRuntime.EventsEmit(p.app.ctx, "startup:error", StartupErrorEvent{
|
||
Stage: stage,
|
||
Error: err.Error(),
|
||
Cause: cause,
|
||
})
|
||
}()
|
||
}
|
||
// 同步通知 ServerController 進 Error state
|
||
if p.app.ctrl != nil {
|
||
p.app.ctrl.setState(ServerStateError, err.Error())
|
||
}
|
||
// R5-D1:發 OS 通知(fire-and-forget)
|
||
go sendCrashNotification(
|
||
"visionA Local — 啟動失敗",
|
||
fmt.Sprintf("第 %d 階段失敗:%s", stage, err.Error()),
|
||
)
|
||
}
|
||
|
||
// watcher 每秒 tick:
|
||
// 1. 階段 6 時檢查 sentinel file → 存在則 CompleteStage(6)
|
||
// 2. 檢查 hard timeout(總時 > 60 s)→ FailStage + emitError(total-timeout)
|
||
// 3. 檢查 soft timeout(單一階段 > 20 s)→ emit "startup:stage-timeout"
|
||
//
|
||
// skip timeout 規則:
|
||
// - 該階段已 skipped → 完全不檢查 soft / hard
|
||
// - 階段 6 + AutoOpenBrowser=false → 完全不檢查 soft / hard
|
||
//
|
||
// 退出條件:ctx.Done()、current 已不在 1-6 範圍(ready 或 failed)。
|
||
func (p *StartupPipeline) watcher(ctx context.Context) {
|
||
defer close(p.watcherDone)
|
||
ticker := time.NewTicker(startupWatcherTick)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
p.mu.Lock()
|
||
if p.current <= 0 || p.current > startupTotalStages {
|
||
p.mu.Unlock()
|
||
return
|
||
}
|
||
cur := p.current
|
||
st := p.stages[cur]
|
||
curStatus := st.status
|
||
sinceStage := time.Since(st.startedAt)
|
||
// Hard timeout 走扣除暫停時間的 effective 時鐘,soft timeout 照常
|
||
// 用實際 stage startedAt —— 這是故意的:首次 bootstrap 時使用者
|
||
// 仍會看到 20 秒「正在重試」hint,但不會在 60 秒時被強制 fail。
|
||
sinceTotal := p.effectiveSinceTotalLocked()
|
||
softEmitted := st.softTimeoutEmitted
|
||
p.mu.Unlock()
|
||
|
||
// 階段 6:每次 tick 檢查 sentinel file
|
||
if cur == 6 {
|
||
if p.checkSentinelFile() {
|
||
p.CompleteStage(6)
|
||
return
|
||
}
|
||
}
|
||
|
||
// skip timeout 判斷
|
||
skipTimeout := false
|
||
if curStatus == "skipped" {
|
||
skipTimeout = true
|
||
}
|
||
if cur == 6 && p.app != nil && !p.app.prefs.AutoOpenBrowser {
|
||
skipTimeout = true
|
||
}
|
||
|
||
// hard timeout(總時 > 60 s)
|
||
if !skipTimeout && sinceTotal > startupHardTimeout {
|
||
err := fmt.Errorf("startup total timeout: %s > %s", sinceTotal, startupHardTimeout)
|
||
// 直接 mark failed + emit 兩個 event,不走 FailStage 因為 cause 不一樣
|
||
p.mu.Lock()
|
||
p.stages[cur].status = "failed"
|
||
p.current = -1
|
||
p.mu.Unlock()
|
||
p.emitProgress(cur)
|
||
p.emitError(cur, err, "total-timeout")
|
||
p.stopWatcher()
|
||
return
|
||
}
|
||
|
||
if skipTimeout {
|
||
continue
|
||
}
|
||
|
||
// soft timeout(單一階段 > 20 s)
|
||
if sinceStage > startupSoftTimeout && !softEmitted {
|
||
p.mu.Lock()
|
||
p.stages[cur].softTimeoutEmitted = true
|
||
p.mu.Unlock()
|
||
|
||
if p.app != nil && p.app.ctx != nil {
|
||
stageRef := cur
|
||
go func() {
|
||
wailsRuntime.EventsEmit(p.app.ctx, "startup:stage-timeout", StartupStageTimeoutEvent{
|
||
Stage: stageRef,
|
||
SoftTimeoutSeconds: int(startupSoftTimeout.Seconds()),
|
||
})
|
||
}()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// checkSentinelFile 檢查 <dataDir>/.first-ws-connected 是否存在。
|
||
// 存在 → 階段 6 完成(server 端的 WebSocket Hub 寫了這個檔,代表第一個 client 已連上)
|
||
func (p *StartupPipeline) checkSentinelFile() bool {
|
||
if p.app == nil || p.app.dataDir == "" {
|
||
return false
|
||
}
|
||
path := filepath.Join(p.app.dataDir, startupSentinelFileName)
|
||
_, err := os.Stat(path)
|
||
return err == nil
|
||
}
|
||
|
||
// stopWatcher 主動取消 watcher goroutine。重複呼叫安全(cancel 之後再 cancel 是 no-op)。
|
||
func (p *StartupPipeline) stopWatcher() {
|
||
if p.watcherCancel != nil {
|
||
p.watcherCancel()
|
||
}
|
||
}
|
||
|
||
// HasFailedStage 回傳 pipeline 是否已有任何階段被標記為 failed。
|
||
//
|
||
// M8-4b 補丁(M-1 修復):startInternal 用這個判斷「pipeline 是否已經 FailStage 過」,
|
||
// 如果是則 skip 自己的 sendCrashNotification / setState(Error),避免使用者看到兩個
|
||
// 獨立的錯誤通知(一個由 pipeline.emitError 發、一個由 startInternal fallback 發)。
|
||
//
|
||
// 語義:current == -1(整個 pipeline 已停在 failed)或任一 stage status == "failed"。
|
||
func (p *StartupPipeline) HasFailedStage() bool {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
if p.current == -1 {
|
||
return true
|
||
}
|
||
for i := 1; i <= startupTotalStages; i++ {
|
||
if p.stages[i].status == "failed" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// IsInColdStart 回傳 pipeline 是否正在「冷啟動中」(stage 1-6 running)。
|
||
//
|
||
// M8-4b 補丁(M-2 修復):startInternal 的 R5-D3 openBrowser 邏輯用這個判斷
|
||
// 「目前是否走冷啟動 pipeline 路徑」。若是,由 runStartupStage5 / pipeline stage 5 hook
|
||
// 負責 openBrowser,startInternal 跳過避免瀏覽器開兩次(Linux 會開兩個 tab)。
|
||
//
|
||
// 非冷啟動場景:
|
||
// - pipeline == nil(單測或 Wails 未初始化)
|
||
// - current == 0(尚未 Start)
|
||
// - current == 7(已 ready,屬於 RestartServer 直接 Start 的情境)
|
||
// - current == -1(已 failed)
|
||
//
|
||
// 這些場景下 startInternal 仍需要自己呼叫 openBrowser。
|
||
func (p *StartupPipeline) IsInColdStart() bool {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
return p.current >= 1 && p.current <= startupTotalStages
|
||
}
|
||
|
||
// removeSentinelFile 移除 sentinel file。失敗時不回 error(檔案不存在是正常情況)。
|
||
// 由 RestartStartupSequence、StartServer 前置、shutdown 呼叫。
|
||
func removeSentinelFile(dataDir string) {
|
||
if dataDir == "" {
|
||
return
|
||
}
|
||
path := filepath.Join(dataDir, startupSentinelFileName)
|
||
_ = os.Remove(path)
|
||
}
|