feat(web): implement version info caching and improve retrieval logic

This commit is contained in:
lc6464 2026-03-27 20:40:26 +08:00
parent 68e8b7e936
commit d600b9e3eb
No known key found for this signature in database
GPG key ID: 53C61B42FEC71D6D
2 changed files with 370 additions and 17 deletions

View file

@ -10,6 +10,8 @@ import (
"regexp" "regexp"
"runtime" "runtime"
"strings" "strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/web/backend/utils" "github.com/sipeed/picoclaw/web/backend/utils"
@ -22,14 +24,35 @@ type systemVersionResponse struct {
GoVersion string `json:"go_version"` 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 ( var (
// Reuse the launcher gateway startup window so embedded/slow devices // Reuse the launcher gateway startup window so embedded/slow devices
// have enough time for first-run command initialization. // have enough time for first-run command initialization.
versionCmdTimeout = gatewayStartupWindow versionCmdTimeout = gatewayStartupWindow
findPicoclawBinaryForInfo = utils.FindPicoclawBinary findPicoclawBinaryForInfo = utils.FindPicoclawBinary
runPicoclawVersionOutput = executePicoclawVersion runPicoclawVersionOutput = executePicoclawVersion
versionLinePattern = regexp.MustCompile(`\bpicoclaw\s+([^\s(]+)(?:\s+\(git:\s*([^)]+)\))?`) currentGatewayVersionState = gatewayVersionState
ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) 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) { 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. // handleGetVersion returns runtime version information for web clients.
func (h *Handler) handleGetVersion(w http.ResponseWriter, _ *http.Request) { func (h *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
versionInfo := h.resolveSystemVersionInfo() versionInfo := h.resolveSystemVersionInfo(r.Context())
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(versionInfo) 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, // resolveSystemVersionInfo prefers the actual picoclaw binary version output,
// and falls back to launcher build metadata when command execution fails. // and falls back to launcher build metadata when command execution fails.
func (h *Handler) resolveSystemVersionInfo() systemVersionResponse { func (h *Handler) resolveSystemVersionInfo(ctx context.Context) systemVersionResponse {
buildTime, goVer := config.FormatBuildInfo() for {
fallback := systemVersionResponse{ gatewayPID, gatewayAlive := currentGatewayVersionState()
Version: config.GetVersion(), if cached, ok := versionInfoCache.get(gatewayPID, gatewayAlive); ok {
GitCommit: config.GitCommit, return cached
BuildTime: buildTime, }
GoVersion: goVer,
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()) execPath := strings.TrimSpace(findPicoclawBinaryForInfo())
if execPath == "" { if execPath == "" {
return fallback return fallback
} }
ctx, cancel := context.WithTimeout(context.Background(), versionCmdTimeout) cmdCtx, cancel := context.WithTimeout(ctx, versionCmdTimeout)
defer cancel() defer cancel()
output, err := runPicoclawVersionOutput(ctx, execPath) output, err := runPicoclawVersionOutput(cmdCtx, execPath)
if err != nil { if err != nil {
return fallback return fallback
} }
@ -83,6 +126,161 @@ func (h *Handler) resolveSystemVersionInfo() systemVersionResponse {
return parsed 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 // executePicoclawVersion runs the version subcommand against the
// discovered picoclaw executable. // discovered picoclaw executable.
func executePicoclawVersion(ctx context.Context, execPath string) (string, error) { func executePicoclawVersion(ctx context.Context, execPath string) (string, error) {

View file

@ -4,15 +4,35 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"runtime" "runtime"
"testing" "testing"
"time"
"github.com/sipeed/picoclaw/pkg/config" "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) { func TestGetSystemVersionUsesPicoclawBinaryInfo(t *testing.T) {
setupVersionTestIsolation(t)
originalVersion := config.Version originalVersion := config.Version
originalGitCommit := config.GitCommit originalGitCommit := config.GitCommit
originalBuildTime := config.BuildTime originalBuildTime := config.BuildTime
@ -70,6 +90,8 @@ func TestGetSystemVersionUsesPicoclawBinaryInfo(t *testing.T) {
} }
func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) { func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) {
setupVersionTestIsolation(t)
originalVersion := config.Version originalVersion := config.Version
originalGitCommit := config.GitCommit originalGitCommit := config.GitCommit
originalBuildTime := config.BuildTime originalBuildTime := config.BuildTime
@ -127,6 +149,8 @@ func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) {
} }
func TestParsePicoclawVersionOutput(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" 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) got, ok := parsePicoclawVersionOutput(raw)
if !ok { if !ok {
@ -147,6 +171,8 @@ func TestParsePicoclawVersionOutput(t *testing.T) {
} }
func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) { func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) {
setupVersionTestIsolation(t)
originalVersion := config.Version originalVersion := config.Version
originalGitCommit := config.GitCommit originalGitCommit := config.GitCommit
originalBuildTime := config.BuildTime originalBuildTime := config.BuildTime
@ -173,8 +199,137 @@ func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) {
} }
h := NewHandler("") h := NewHandler("")
got := h.resolveSystemVersionInfo() got := h.resolveSystemVersionInfo(context.Background())
if got.GoVersion != runtime.Version() { if got.GoVersion != runtime.Version() {
t.Fatalf("go_version = %q, want runtime version %q", 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")
}
}