feat(web): implement version info caching and improve retrieval logic
This commit is contained in:
parent
68e8b7e936
commit
d600b9e3eb
2 changed files with 370 additions and 17 deletions
|
|
@ -10,6 +10,8 @@ import (
|
|||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||
|
|
@ -22,14 +24,35 @@ type systemVersionResponse struct {
|
|||
GoVersion string `json:"go_version"`
|
||||
}
|
||||
|
||||
type cachedSystemVersion struct {
|
||||
value systemVersionResponse
|
||||
gatewayPID int
|
||||
}
|
||||
|
||||
type systemVersionCache struct {
|
||||
mu sync.Mutex
|
||||
current cachedSystemVersion
|
||||
hasCurrent bool
|
||||
inflightCh chan struct{}
|
||||
monitorPID int
|
||||
monitorCancel context.CancelFunc
|
||||
}
|
||||
|
||||
func newSystemVersionCache() *systemVersionCache {
|
||||
return &systemVersionCache{}
|
||||
}
|
||||
|
||||
var (
|
||||
// Reuse the launcher gateway startup window so embedded/slow devices
|
||||
// have enough time for first-run command initialization.
|
||||
versionCmdTimeout = gatewayStartupWindow
|
||||
findPicoclawBinaryForInfo = utils.FindPicoclawBinary
|
||||
runPicoclawVersionOutput = executePicoclawVersion
|
||||
versionLinePattern = regexp.MustCompile(`\bpicoclaw\s+([^\s(]+)(?:\s+\(git:\s*([^)]+)\))?`)
|
||||
ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
versionCmdTimeout = gatewayStartupWindow
|
||||
findPicoclawBinaryForInfo = utils.FindPicoclawBinary
|
||||
runPicoclawVersionOutput = executePicoclawVersion
|
||||
currentGatewayVersionState = gatewayVersionState
|
||||
versionCacheMonitorInterval = 5 * time.Second
|
||||
versionInfoCache = newSystemVersionCache()
|
||||
versionLinePattern = regexp.MustCompile(`\bpicoclaw\s+([^\s(]+)(?:\s+\(git:\s*([^)]+)\))?`)
|
||||
ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
)
|
||||
|
||||
func (h *Handler) registerVersionRoutes(mux *http.ServeMux) {
|
||||
|
|
@ -37,8 +60,8 @@ func (h *Handler) registerVersionRoutes(mux *http.ServeMux) {
|
|||
}
|
||||
|
||||
// handleGetVersion returns runtime version information for web clients.
|
||||
func (h *Handler) handleGetVersion(w http.ResponseWriter, _ *http.Request) {
|
||||
versionInfo := h.resolveSystemVersionInfo()
|
||||
func (h *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
|
||||
versionInfo := h.resolveSystemVersionInfo(r.Context())
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(versionInfo)
|
||||
|
|
@ -46,24 +69,44 @@ func (h *Handler) handleGetVersion(w http.ResponseWriter, _ *http.Request) {
|
|||
|
||||
// resolveSystemVersionInfo prefers the actual picoclaw binary version output,
|
||||
// and falls back to launcher build metadata when command execution fails.
|
||||
func (h *Handler) resolveSystemVersionInfo() systemVersionResponse {
|
||||
buildTime, goVer := config.FormatBuildInfo()
|
||||
fallback := systemVersionResponse{
|
||||
Version: config.GetVersion(),
|
||||
GitCommit: config.GitCommit,
|
||||
BuildTime: buildTime,
|
||||
GoVersion: goVer,
|
||||
func (h *Handler) resolveSystemVersionInfo(ctx context.Context) systemVersionResponse {
|
||||
for {
|
||||
gatewayPID, gatewayAlive := currentGatewayVersionState()
|
||||
if cached, ok := versionInfoCache.get(gatewayPID, gatewayAlive); ok {
|
||||
return cached
|
||||
}
|
||||
|
||||
leader, ok := versionInfoCache.waitOrStart(ctx)
|
||||
if !ok {
|
||||
return fallbackSystemVersionInfo()
|
||||
}
|
||||
if !leader {
|
||||
continue
|
||||
}
|
||||
|
||||
resolved := h.resolveSystemVersionInfoUncached(ctx)
|
||||
gatewayPID, gatewayAlive = currentGatewayVersionState()
|
||||
versionInfoCache.finishResolve(resolved, gatewayPID, gatewayAlive)
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) resolveSystemVersionInfoUncached(ctx context.Context) systemVersionResponse {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
fallback := fallbackSystemVersionInfo()
|
||||
|
||||
execPath := strings.TrimSpace(findPicoclawBinaryForInfo())
|
||||
if execPath == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), versionCmdTimeout)
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, versionCmdTimeout)
|
||||
defer cancel()
|
||||
|
||||
output, err := runPicoclawVersionOutput(ctx, execPath)
|
||||
output, err := runPicoclawVersionOutput(cmdCtx, execPath)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
|
@ -83,6 +126,161 @@ func (h *Handler) resolveSystemVersionInfo() systemVersionResponse {
|
|||
return parsed
|
||||
}
|
||||
|
||||
func fallbackSystemVersionInfo() systemVersionResponse {
|
||||
buildTime, goVer := config.FormatBuildInfo()
|
||||
return systemVersionResponse{
|
||||
Version: config.GetVersion(),
|
||||
GitCommit: config.GitCommit,
|
||||
BuildTime: buildTime,
|
||||
GoVersion: goVer,
|
||||
}
|
||||
}
|
||||
|
||||
func gatewayVersionState() (int, bool) {
|
||||
gateway.mu.Lock()
|
||||
defer gateway.mu.Unlock()
|
||||
|
||||
if gateway.cmd == nil || gateway.cmd.Process == nil {
|
||||
return 0, false
|
||||
}
|
||||
pid := gateway.cmd.Process.Pid
|
||||
if pid <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return pid, isCmdProcessAliveLocked(gateway.cmd)
|
||||
}
|
||||
|
||||
func (c *systemVersionCache) get(gatewayPID int, gatewayAlive bool) (systemVersionResponse, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.hasCurrent && (!gatewayAlive || gatewayPID <= 0 || gatewayPID != c.current.gatewayPID) {
|
||||
c.clearCurrentLocked()
|
||||
}
|
||||
|
||||
if c.hasCurrent {
|
||||
return c.current.value, true
|
||||
}
|
||||
|
||||
return systemVersionResponse{}, false
|
||||
}
|
||||
|
||||
func (c *systemVersionCache) waitOrStart(ctx context.Context) (bool, bool) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return false, false
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.inflightCh == nil {
|
||||
c.inflightCh = make(chan struct{})
|
||||
c.mu.Unlock()
|
||||
return true, true
|
||||
}
|
||||
waitCh := c.inflightCh
|
||||
c.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-waitCh:
|
||||
return false, true
|
||||
case <-ctx.Done():
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *systemVersionCache) finishResolve(value systemVersionResponse, gatewayPID int, gatewayAlive bool) {
|
||||
c.mu.Lock()
|
||||
if gatewayAlive && gatewayPID > 0 {
|
||||
c.current = cachedSystemVersion{value: value, gatewayPID: gatewayPID}
|
||||
c.hasCurrent = true
|
||||
c.ensureMonitorLocked(gatewayPID)
|
||||
} else {
|
||||
c.clearCurrentLocked()
|
||||
}
|
||||
|
||||
inflightCh := c.inflightCh
|
||||
c.inflightCh = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
if inflightCh != nil {
|
||||
close(inflightCh)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *systemVersionCache) clearCurrentLocked() {
|
||||
c.hasCurrent = false
|
||||
c.current = cachedSystemVersion{}
|
||||
c.stopMonitorLocked()
|
||||
}
|
||||
|
||||
func (c *systemVersionCache) ensureMonitorLocked(gatewayPID int) {
|
||||
if c.monitorPID == gatewayPID && c.monitorCancel != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.stopMonitorLocked()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c.monitorPID = gatewayPID
|
||||
c.monitorCancel = cancel
|
||||
|
||||
go monitorGatewayVersionCache(ctx, gatewayPID)
|
||||
}
|
||||
|
||||
func (c *systemVersionCache) stopMonitorLocked() {
|
||||
if c.monitorCancel != nil {
|
||||
c.monitorCancel()
|
||||
c.monitorCancel = nil
|
||||
}
|
||||
c.monitorPID = 0
|
||||
}
|
||||
|
||||
func (c *systemVersionCache) invalidateForPID(gatewayPID int) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.hasCurrent && c.current.gatewayPID == gatewayPID {
|
||||
c.current = cachedSystemVersion{}
|
||||
c.hasCurrent = false
|
||||
}
|
||||
if c.monitorPID == gatewayPID {
|
||||
c.stopMonitorLocked()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *systemVersionCache) resetForTest() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.current = cachedSystemVersion{}
|
||||
c.hasCurrent = false
|
||||
c.stopMonitorLocked()
|
||||
if c.inflightCh != nil {
|
||||
close(c.inflightCh)
|
||||
c.inflightCh = nil
|
||||
}
|
||||
}
|
||||
|
||||
func monitorGatewayVersionCache(ctx context.Context, gatewayPID int) {
|
||||
ticker := time.NewTicker(versionCacheMonitorInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
currentPID, alive := currentGatewayVersionState()
|
||||
if !alive || currentPID != gatewayPID {
|
||||
versionInfoCache.invalidateForPID(gatewayPID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executePicoclawVersion runs the version subcommand against the
|
||||
// discovered picoclaw executable.
|
||||
func executePicoclawVersion(ctx context.Context, execPath string) (string, error) {
|
||||
|
|
|
|||
|
|
@ -4,15 +4,35 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func setupVersionTestIsolation(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
originalGatewayState := currentGatewayVersionState
|
||||
originalMonitorInterval := versionCacheMonitorInterval
|
||||
t.Cleanup(func() {
|
||||
currentGatewayVersionState = originalGatewayState
|
||||
versionCacheMonitorInterval = originalMonitorInterval
|
||||
versionInfoCache.resetForTest()
|
||||
})
|
||||
|
||||
currentGatewayVersionState = func() (int, bool) { return 0, false }
|
||||
versionCacheMonitorInterval = 10 * time.Millisecond
|
||||
versionInfoCache.resetForTest()
|
||||
}
|
||||
|
||||
func TestGetSystemVersionUsesPicoclawBinaryInfo(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
originalVersion := config.Version
|
||||
originalGitCommit := config.GitCommit
|
||||
originalBuildTime := config.BuildTime
|
||||
|
|
@ -70,6 +90,8 @@ func TestGetSystemVersionUsesPicoclawBinaryInfo(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
originalVersion := config.Version
|
||||
originalGitCommit := config.GitCommit
|
||||
originalBuildTime := config.BuildTime
|
||||
|
|
@ -127,6 +149,8 @@ func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestParsePicoclawVersionOutput(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
raw := "\u001b[1;31m████\u001b[0m\n🦞 picoclaw 18ec263 (git: 18ec2631)\n Build: 2026-03-27T10:43:34+0000\n Go: go1.25.8\n"
|
||||
got, ok := parsePicoclawVersionOutput(raw)
|
||||
if !ok {
|
||||
|
|
@ -147,6 +171,8 @@ func TestParsePicoclawVersionOutput(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
originalVersion := config.Version
|
||||
originalGitCommit := config.GitCommit
|
||||
originalBuildTime := config.BuildTime
|
||||
|
|
@ -173,8 +199,137 @@ func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) {
|
|||
}
|
||||
|
||||
h := NewHandler("")
|
||||
got := h.resolveSystemVersionInfo()
|
||||
got := h.resolveSystemVersionInfo(context.Background())
|
||||
if got.GoVersion != runtime.Version() {
|
||||
t.Fatalf("go_version = %q, want runtime version %q", got.GoVersion, runtime.Version())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSystemVersionInfoCachesWhileGatewayAlive(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
originalVersion := config.Version
|
||||
originalFinder := findPicoclawBinaryForInfo
|
||||
originalRunner := runPicoclawVersionOutput
|
||||
originalGatewayState := currentGatewayVersionState
|
||||
t.Cleanup(func() {
|
||||
config.Version = originalVersion
|
||||
findPicoclawBinaryForInfo = originalFinder
|
||||
runPicoclawVersionOutput = originalRunner
|
||||
currentGatewayVersionState = originalGatewayState
|
||||
})
|
||||
|
||||
config.Version = "dev"
|
||||
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
|
||||
|
||||
pid := 4321
|
||||
currentGatewayVersionState = func() (int, bool) { return pid, true }
|
||||
|
||||
runCount := 0
|
||||
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
|
||||
runCount++
|
||||
return fmt.Sprintf("picoclaw v1.2.%d\n", runCount), nil
|
||||
}
|
||||
|
||||
h := NewHandler("")
|
||||
first := h.resolveSystemVersionInfo(context.Background())
|
||||
second := h.resolveSystemVersionInfo(context.Background())
|
||||
|
||||
if first.Version != "v1.2.1" {
|
||||
t.Fatalf("first version = %q, want %q", first.Version, "v1.2.1")
|
||||
}
|
||||
if second.Version != "v1.2.1" {
|
||||
t.Fatalf("second version = %q, want cached %q", second.Version, "v1.2.1")
|
||||
}
|
||||
if runCount != 1 {
|
||||
t.Fatalf("run count = %d, want %d", runCount, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSystemVersionInfoInvalidatesCacheWhenGatewayStops(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
originalVersion := config.Version
|
||||
originalFinder := findPicoclawBinaryForInfo
|
||||
originalRunner := runPicoclawVersionOutput
|
||||
originalGatewayState := currentGatewayVersionState
|
||||
t.Cleanup(func() {
|
||||
config.Version = originalVersion
|
||||
findPicoclawBinaryForInfo = originalFinder
|
||||
runPicoclawVersionOutput = originalRunner
|
||||
currentGatewayVersionState = originalGatewayState
|
||||
})
|
||||
|
||||
config.Version = "dev"
|
||||
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
|
||||
|
||||
alive := true
|
||||
pid := 9876
|
||||
currentGatewayVersionState = func() (int, bool) {
|
||||
if !alive {
|
||||
return 0, false
|
||||
}
|
||||
return pid, true
|
||||
}
|
||||
|
||||
runCount := 0
|
||||
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
|
||||
runCount++
|
||||
return fmt.Sprintf("picoclaw v2.0.%d\n", runCount), nil
|
||||
}
|
||||
|
||||
h := NewHandler("")
|
||||
first := h.resolveSystemVersionInfo(context.Background())
|
||||
second := h.resolveSystemVersionInfo(context.Background())
|
||||
|
||||
if first.Version != "v2.0.1" || second.Version != "v2.0.1" {
|
||||
t.Fatalf("expected cached version v2.0.1, got first=%q second=%q", first.Version, second.Version)
|
||||
}
|
||||
if runCount != 1 {
|
||||
t.Fatalf("run count after cache hit = %d, want %d", runCount, 1)
|
||||
}
|
||||
|
||||
alive = false
|
||||
third := h.resolveSystemVersionInfo(context.Background())
|
||||
if third.Version != "v2.0.2" {
|
||||
t.Fatalf("third version = %q, want refreshed %q", third.Version, "v2.0.2")
|
||||
}
|
||||
if runCount != 2 {
|
||||
t.Fatalf("run count after invalidation = %d, want %d", runCount, 2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSystemVersionInfoSkipsCommandWhenContextCanceled(t *testing.T) {
|
||||
setupVersionTestIsolation(t)
|
||||
|
||||
originalVersion := config.Version
|
||||
originalFinder := findPicoclawBinaryForInfo
|
||||
originalRunner := runPicoclawVersionOutput
|
||||
t.Cleanup(func() {
|
||||
config.Version = originalVersion
|
||||
findPicoclawBinaryForInfo = originalFinder
|
||||
runPicoclawVersionOutput = originalRunner
|
||||
})
|
||||
|
||||
config.Version = "v3.0.0"
|
||||
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
|
||||
|
||||
runCount := 0
|
||||
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
|
||||
runCount++
|
||||
return "picoclaw v9.9.9\n", nil
|
||||
}
|
||||
|
||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
h := NewHandler("")
|
||||
got := h.resolveSystemVersionInfo(canceledCtx)
|
||||
|
||||
if runCount != 0 {
|
||||
t.Fatalf("run count = %d, want %d", runCount, 0)
|
||||
}
|
||||
if got.Version != "v3.0.0" {
|
||||
t.Fatalf("version = %q, want fallback %q", got.Version, "v3.0.0")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue