From d600b9e3eb4cd13cec590d854cfc3b8e91ea9203 Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:40:26 +0800 Subject: [PATCH] feat(web): implement version info caching and improve retrieval logic --- web/backend/api/version.go | 230 +++++++++++++++++++++++++++++--- web/backend/api/version_test.go | 157 +++++++++++++++++++++- 2 files changed, 370 insertions(+), 17 deletions(-) diff --git a/web/backend/api/version.go b/web/backend/api/version.go index 06cd0a031..7a180654a 100644 --- a/web/backend/api/version.go +++ b/web/backend/api/version.go @@ -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) { diff --git a/web/backend/api/version_test.go b/web/backend/api/version_test.go index 14c0704b7..b89c5296e 100644 --- a/web/backend/api/version_test.go +++ b/web/backend/api/version_test.go @@ -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") + } +}