f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
210 lines
6.1 KiB
Go
210 lines
6.1 KiB
Go
// ABOUTME: startup-state.json publishes the starting daemon's pid, phase,
|
|
// ABOUTME: and progress so `serve status` can report them during startup.
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.kenn.io/agentsview/internal/db"
|
|
)
|
|
|
|
// startupStateFileName is the data-dir file holding the starting
|
|
// daemon's progress snapshot. It exists exactly as long as the daemon
|
|
// start lock is held; readers must only trust it while
|
|
// IsDaemonStarting reports true.
|
|
const startupStateFileName = "startup-state.json"
|
|
|
|
// startupDetailThrottle bounds how often detail-only updates rewrite
|
|
// the state file during high-frequency sync progress callbacks.
|
|
const startupDetailThrottle = time.Second
|
|
|
|
type startupState struct {
|
|
PID int `json:"pid"`
|
|
StartedAt time.Time `json:"started_at"`
|
|
Phase string `json:"phase"`
|
|
Detail string `json:"detail,omitempty"`
|
|
LogPath string `json:"log_path,omitempty"`
|
|
Host string `json:"host,omitempty"`
|
|
Port int `json:"port,omitempty"`
|
|
RuntimeError string `json:"runtime_error,omitempty"`
|
|
CreateTime string `json:"create_time,omitempty"`
|
|
APIVersion int `json:"api_version,omitempty"`
|
|
DataVersion int `json:"data_version,omitempty"`
|
|
CaddyPID int `json:"caddy_pid,omitempty"`
|
|
CaddyCreateTime string `json:"caddy_create_time,omitempty"`
|
|
RequireAuth bool `json:"require_auth,omitempty"`
|
|
RequireAuthKnown bool `json:"require_auth_known,omitempty"`
|
|
NoSync bool `json:"no_sync,omitempty"`
|
|
NoSyncKnown bool `json:"no_sync_known,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
func startupStatePath(dataDir string) string {
|
|
return filepath.Join(dataDir, startupStateFileName)
|
|
}
|
|
|
|
// serveLogPath is where a background-launched serve child writes its
|
|
// startup output.
|
|
func serveLogPath(dataDir string) string {
|
|
return filepath.Join(dataDir, "serve.log")
|
|
}
|
|
|
|
// startupStateWriter persists throttled startup progress snapshots.
|
|
// Write failures are logged once and otherwise ignored: startup
|
|
// transparency must never break startup.
|
|
type startupStateWriter struct {
|
|
mu sync.Mutex
|
|
path string
|
|
state startupState
|
|
lastWrite time.Time
|
|
warnOnce sync.Once
|
|
now func() time.Time
|
|
}
|
|
|
|
func newStartupStateWriter(
|
|
dataDir string, now func() time.Time,
|
|
) *startupStateWriter {
|
|
w := &startupStateWriter{
|
|
path: startupStatePath(dataDir),
|
|
now: now,
|
|
}
|
|
w.state.PID = os.Getpid()
|
|
w.state.StartedAt = now()
|
|
if runningAsBackgroundChild() {
|
|
// Only a background child's output lands in serve.log; a
|
|
// foreground serve prints to the invoking terminal.
|
|
w.state.LogPath = serveLogPath(dataDir)
|
|
}
|
|
return w
|
|
}
|
|
|
|
// SetPhase records a phase transition and clears the previous phase's
|
|
// detail. Phase changes persist immediately, bypassing the throttle.
|
|
func (w *startupStateWriter) SetPhase(phase string) {
|
|
if w == nil {
|
|
return
|
|
}
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
w.state.Phase = phase
|
|
w.state.Detail = ""
|
|
w.write()
|
|
}
|
|
|
|
// SetDetail records fine-grained progress within the current phase,
|
|
// persisted at most once per startupDetailThrottle.
|
|
func (w *startupStateWriter) SetDetail(detail string) {
|
|
if w == nil {
|
|
return
|
|
}
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
// state.Detail only changes when a write happens, so this dedup
|
|
// compares against what a reader can actually see. Storing a
|
|
// throttled detail in memory first would make a stable detail
|
|
// match itself on every later call and never persist.
|
|
if detail == "" || detail == w.state.Detail {
|
|
return
|
|
}
|
|
if w.now().Sub(w.lastWrite) < startupDetailThrottle {
|
|
return
|
|
}
|
|
w.state.Detail = detail
|
|
w.write()
|
|
}
|
|
|
|
// write persists the current state atomically via temp file + rename
|
|
// so readers never observe a partial JSON document. Callers hold w.mu.
|
|
func (w *startupStateWriter) write() {
|
|
w.state.UpdatedAt = w.now()
|
|
data, err := json.Marshal(w.state)
|
|
if err != nil {
|
|
w.warn(err)
|
|
return
|
|
}
|
|
tmp := w.path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
w.warn(err)
|
|
return
|
|
}
|
|
if err := os.Rename(tmp, w.path); err != nil {
|
|
w.warn(err)
|
|
return
|
|
}
|
|
w.lastWrite = w.now()
|
|
}
|
|
|
|
func (w *startupStateWriter) warn(err error) {
|
|
w.warnOnce.Do(func() {
|
|
log.Printf("warning: cannot write startup state: %v", err)
|
|
})
|
|
}
|
|
|
|
// readStartupState loads the startup snapshot, or nil when the file is
|
|
// missing or unreadable (legacy daemon version, mid-write race).
|
|
func readStartupState(dataDir string) *startupState {
|
|
data, err := os.ReadFile(startupStatePath(dataDir))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var st startupState
|
|
if err := json.Unmarshal(data, &st); err != nil {
|
|
return nil
|
|
}
|
|
return &st
|
|
}
|
|
|
|
// publishStartupStateFallback records the endpoint after bind when the
|
|
// definitive runtime record cannot be written. Keep the existing snapshot so
|
|
// lifecycle readers can still report startup progress and require a daemon-
|
|
// authored snapshot before trusting this fallback.
|
|
func publishStartupStateFallback(
|
|
dataDir, host string, port int, requireAuth, noSync bool, caddyPID int, runtimeErr error,
|
|
) {
|
|
st := readStartupState(dataDir)
|
|
if st == nil || host == "" || port <= 0 || runtimeErr == nil {
|
|
return
|
|
}
|
|
st.Host = host
|
|
st.Port = port
|
|
st.RuntimeError = runtimeErr.Error()
|
|
st.RequireAuth = requireAuth
|
|
st.RequireAuthKnown = true
|
|
st.NoSync = noSync
|
|
st.NoSyncKnown = true
|
|
st.APIVersion = daemonAPIVersion
|
|
st.DataVersion = db.CurrentDataVersion()
|
|
st.CaddyPID = caddyPID
|
|
st.CaddyCreateTime = ""
|
|
if caddyPID > 0 {
|
|
if caddyCreateTime, ok := processCreateTimeMillis(caddyPID); ok {
|
|
st.CaddyCreateTime = strconv.FormatInt(caddyCreateTime, 10)
|
|
}
|
|
}
|
|
if createTime, ok := processCreateTimeMillis(st.PID); ok {
|
|
st.CreateTime = strconv.FormatInt(createTime, 10)
|
|
}
|
|
st.UpdatedAt = time.Now()
|
|
data, err := json.Marshal(st)
|
|
if err != nil {
|
|
return
|
|
}
|
|
tmp := startupStatePath(dataDir) + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
return
|
|
}
|
|
if err := os.Rename(tmp, startupStatePath(dataDir)); err != nil {
|
|
_ = os.Remove(tmp)
|
|
}
|
|
}
|
|
|
|
func removeStartupState(dataDir string) {
|
|
_ = os.Remove(startupStatePath(dataDir))
|
|
}
|