diff --git a/cmd/picoclaw-launcher-tui/ui/gateway.go b/cmd/picoclaw-launcher-tui/ui/gateway.go index c14162505..397b712f7 100644 --- a/cmd/picoclaw-launcher-tui/ui/gateway.go +++ b/cmd/picoclaw-launcher-tui/ui/gateway.go @@ -50,10 +50,11 @@ func isProcessRunning(pid int) bool { return false } return strings.Contains(string(output), fmt.Sprintf(" %d ", pid)) + default: + // Linux and other unix-like systems. + _, err := os.Stat(fmt.Sprintf("/proc/%d", pid)) + return err == nil } - // Linux - _, err := os.Stat(fmt.Sprintf("/proc/%d", pid)) - return err == nil } func getGatewayStatus() gatewayStatus { diff --git a/web/backend/api/version.go b/web/backend/api/version.go index cde8b40a2..a8b912c34 100644 --- a/web/backend/api/version.go +++ b/web/backend/api/version.go @@ -30,12 +30,10 @@ type cachedSystemVersion struct { } type systemVersionCache struct { - mu sync.Mutex - current cachedSystemVersion - hasCurrent bool - inflightCh chan struct{} - monitorPID int - monitorCancel context.CancelFunc + mu sync.Mutex + current cachedSystemVersion + hasCurrent bool + inflightCh chan struct{} } func newSystemVersionCache() *systemVersionCache { @@ -47,12 +45,13 @@ var ( // giving slow/embedded hosts enough time for first command invocation while // staying independent from cross-file init ordering. versionCmdTimeout = 15 * time.Second - findPicoclawBinaryForInfo = utils.FindPicoclawBinary + maxVersionResolveAttempts = 3 + findPicoclawBinaryForInfo = resolveGatewayBinaryForVersionInfo runPicoclawVersionOutput = executePicoclawVersion currentGatewayVersionState = gatewayVersionState - versionCacheMonitorInterval = 5 * time.Second + launcherBuildInfoForVersion = fallbackSystemVersionInfoFromConfig versionInfoCache = newSystemVersionCache() - versionLinePattern = regexp.MustCompile(`\bpicoclaw\s+([^\s(]+)(?:\s+\(git:\s*([^)]+)\))?`) + versionLinePattern = regexp.MustCompile(`^(?:[^A-Za-z0-9]*\s*)?picoclaw(?:\.exe)?\s+([^\s(]+)(?:\s+\(git:\s*([^)]+)\))?\s*$`) ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) ) @@ -74,7 +73,7 @@ func (h *Handler) handleGetVersion(w http.ResponseWriter, r *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(ctx context.Context) systemVersionResponse { - for { + for range maxVersionResolveAttempts { gatewayPID, gatewayAlive := currentGatewayVersionState() if cached, ok := versionInfoCache.get(gatewayPID, gatewayAlive); ok { return cached @@ -93,6 +92,8 @@ func (h *Handler) resolveSystemVersionInfo(ctx context.Context) systemVersionRes versionInfoCache.finishResolve(resolved, gatewayPID, gatewayAlive) return resolved } + + return fallbackSystemVersionInfo() } func (h *Handler) resolveSystemVersionInfoUncached(ctx context.Context) systemVersionResponse { @@ -131,6 +132,10 @@ func (h *Handler) resolveSystemVersionInfoUncached(ctx context.Context) systemVe } func fallbackSystemVersionInfo() systemVersionResponse { + return launcherBuildInfoForVersion() +} + +func fallbackSystemVersionInfoFromConfig() systemVersionResponse { buildTime, goVer := config.FormatBuildInfo() return systemVersionResponse{ Version: config.GetVersion(), @@ -140,6 +145,24 @@ func fallbackSystemVersionInfo() systemVersionResponse { } } +// resolveGatewayBinaryForVersionInfo uses the same executable as the launcher +// gateway start path when available, then falls back to launcher binary lookup. +// This keeps version probing aligned with the actual gateway startup behavior, +// so web and gateway do not drift onto different binaries. +func resolveGatewayBinaryForVersionInfo() string { + gateway.mu.Lock() + cmd := gateway.cmd + gateway.mu.Unlock() + + if cmd != nil { + if execPath := strings.TrimSpace(cmd.Path); execPath != "" { + return execPath + } + } + + return utils.FindPicoclawBinary() +} + func gatewayVersionState() (int, bool) { gateway.mu.Lock() defer gateway.mu.Unlock() @@ -200,7 +223,6 @@ func (c *systemVersionCache) finishResolve(value systemVersionResponse, gatewayP if gatewayAlive && gatewayPID > 0 { c.current = cachedSystemVersion{value: value, gatewayPID: gatewayPID} c.hasCurrent = true - c.ensureMonitorLocked(gatewayPID) } else { c.clearCurrentLocked() } @@ -217,41 +239,6 @@ func (c *systemVersionCache) finishResolve(value systemVersionResponse, gatewayP 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() { @@ -260,31 +247,12 @@ func (c *systemVersionCache) resetForTest() { 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) { @@ -309,7 +277,11 @@ func parsePicoclawVersionOutput(raw string) (systemVersionResponse, bool) { } if match := versionLinePattern.FindStringSubmatch(line); len(match) > 0 { - result.Version = strings.TrimSpace(match[1]) + candidateVersion := strings.TrimSpace(match[1]) + if !isLikelyVersionValue(candidateVersion) { + continue + } + result.Version = candidateVersion if len(match) > 2 { result.GitCommit = strings.TrimSpace(match[2]) } @@ -326,9 +298,45 @@ func parsePicoclawVersionOutput(raw string) (systemVersionResponse, bool) { } } + if err := scanner.Err(); err != nil { + return systemVersionResponse{}, false + } + if result.Version == "" { return systemVersionResponse{}, false } return result, true } + +func isLikelyVersionValue(value string) bool { + v := strings.TrimSpace(strings.ToLower(value)) + if v == "" { + return false + } + if v == "dev" { + return true + } + + // Accept git-like short/long hashes even when they contain only letters (a-f). + if len(v) >= 7 && len(v) <= 40 { + allHex := true + for _, ch := range v { + if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') { + continue + } + allHex = false + break + } + if allHex { + return true + } + } + + for _, ch := range v { + if ch >= '0' && ch <= '9' { + return true + } + } + return false +} diff --git a/web/backend/api/version_test.go b/web/backend/api/version_test.go index b89c5296e..31c5366ab 100644 --- a/web/backend/api/version_test.go +++ b/web/backend/api/version_test.go @@ -7,51 +7,36 @@ import ( "fmt" "net/http" "net/http/httptest" + "os/exec" "runtime" "testing" - "time" - - "github.com/sipeed/picoclaw/pkg/config" ) func setupVersionTestIsolation(t *testing.T) { t.Helper() originalGatewayState := currentGatewayVersionState - originalMonitorInterval := versionCacheMonitorInterval + originalFinder := findPicoclawBinaryForInfo + originalRunner := runPicoclawVersionOutput + originalFallback := launcherBuildInfoForVersion t.Cleanup(func() { currentGatewayVersionState = originalGatewayState - versionCacheMonitorInterval = originalMonitorInterval + findPicoclawBinaryForInfo = originalFinder + runPicoclawVersionOutput = originalRunner + launcherBuildInfoForVersion = originalFallback 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 - originalGoVersion := config.GoVersion - originalFinder := findPicoclawBinaryForInfo - originalRunner := runPicoclawVersionOutput - t.Cleanup(func() { - config.Version = originalVersion - config.GitCommit = originalGitCommit - config.BuildTime = originalBuildTime - config.GoVersion = originalGoVersion - findPicoclawBinaryForInfo = originalFinder - runPicoclawVersionOutput = originalRunner - }) - - config.Version = "dev" - config.GitCommit = "" - config.BuildTime = "" - config.GoVersion = "" + launcherBuildInfoForVersion = func() systemVersionResponse { + return systemVersionResponse{Version: "fallback", GoVersion: "go-fallback"} + } findPicoclawBinaryForInfo = func() string { return "picoclaw" } runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { @@ -92,25 +77,13 @@ func TestGetSystemVersionUsesPicoclawBinaryInfo(t *testing.T) { func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) { setupVersionTestIsolation(t) - originalVersion := config.Version - originalGitCommit := config.GitCommit - originalBuildTime := config.BuildTime - originalGoVersion := config.GoVersion - originalFinder := findPicoclawBinaryForInfo - originalRunner := runPicoclawVersionOutput - t.Cleanup(func() { - config.Version = originalVersion - config.GitCommit = originalGitCommit - config.BuildTime = originalBuildTime - config.GoVersion = originalGoVersion - findPicoclawBinaryForInfo = originalFinder - runPicoclawVersionOutput = originalRunner - }) - - config.Version = "v9.9.9" - config.GitCommit = "cafebabe" - config.BuildTime = "2026-03-27T10:43:34+0000" - config.GoVersion = "go1.25.8" + expected := systemVersionResponse{ + Version: "v9.9.9", + GitCommit: "cafebabe", + BuildTime: "2026-03-27T10:43:34+0000", + GoVersion: "go1.25.8", + } + launcherBuildInfoForVersion = func() systemVersionResponse { return expected } findPicoclawBinaryForInfo = func() string { return "picoclaw" } runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { @@ -134,17 +107,17 @@ func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) { t.Fatalf("unmarshal response: %v", err) } - if got.Version != config.Version { - t.Fatalf("version = %q, want %q", got.Version, config.Version) + if got.Version != expected.Version { + t.Fatalf("version = %q, want %q", got.Version, expected.Version) } - if got.GitCommit != config.GitCommit { - t.Fatalf("git_commit = %q, want %q", got.GitCommit, config.GitCommit) + if got.GitCommit != expected.GitCommit { + t.Fatalf("git_commit = %q, want %q", got.GitCommit, expected.GitCommit) } - if got.BuildTime != config.BuildTime { - t.Fatalf("build_time = %q, want %q", got.BuildTime, config.BuildTime) + if got.BuildTime != expected.BuildTime { + t.Fatalf("build_time = %q, want %q", got.BuildTime, expected.BuildTime) } - if got.GoVersion != config.GoVersion { - t.Fatalf("go_version = %q, want %q", got.GoVersion, config.GoVersion) + if got.GoVersion != expected.GoVersion { + t.Fatalf("go_version = %q, want %q", got.GoVersion, expected.GoVersion) } } @@ -170,28 +143,38 @@ func TestParsePicoclawVersionOutput(t *testing.T) { } } +func TestParsePicoclawVersionOutputIgnoresUsageLine(t *testing.T) { + setupVersionTestIsolation(t) + + raw := "Usage: picoclaw version [flags]\n" + got, ok := parsePicoclawVersionOutput(raw) + if ok { + t.Fatalf("parsePicoclawVersionOutput() parsed usage line unexpectedly: %#v", got) + } +} + +func TestParsePicoclawVersionOutputAcceptsLetterOnlyHashVersion(t *testing.T) { + setupVersionTestIsolation(t) + + raw := "picoclaw abcdefa (git: abcdefabcdefabcdefabcdefabcdefabcdefabcd)\n" + got, ok := parsePicoclawVersionOutput(raw) + if !ok { + t.Fatal("parsePicoclawVersionOutput() should parse letter-only hash version") + } + if got.Version != "abcdefa" { + t.Fatalf("version = %q, want %q", got.Version, "abcdefa") + } + if got.GitCommit != "abcdefabcdefabcdefabcdefabcdefabcdefabcd" { + t.Fatalf("git_commit = %q, want %q", got.GitCommit, "abcdefabcdefabcdefabcdefabcdefabcdefabcd") + } +} + func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) { setupVersionTestIsolation(t) - originalVersion := config.Version - originalGitCommit := config.GitCommit - originalBuildTime := config.BuildTime - originalGoVersion := config.GoVersion - originalFinder := findPicoclawBinaryForInfo - originalRunner := runPicoclawVersionOutput - t.Cleanup(func() { - config.Version = originalVersion - config.GitCommit = originalGitCommit - config.BuildTime = originalBuildTime - config.GoVersion = originalGoVersion - findPicoclawBinaryForInfo = originalFinder - runPicoclawVersionOutput = originalRunner - }) - - config.Version = "dev" - config.GitCommit = "" - config.BuildTime = "" - config.GoVersion = "" + launcherBuildInfoForVersion = func() systemVersionResponse { + return systemVersionResponse{Version: "dev", GoVersion: ""} + } findPicoclawBinaryForInfo = func() string { return "picoclaw" } runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { @@ -208,18 +191,9 @@ func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) { 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" + launcherBuildInfoForVersion = func() systemVersionResponse { + return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"} + } findPicoclawBinaryForInfo = func() string { return "picoclaw" } pid := 4321 @@ -249,18 +223,9 @@ func TestResolveSystemVersionInfoCachesWhileGatewayAlive(t *testing.T) { 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" + launcherBuildInfoForVersion = func() systemVersionResponse { + return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"} + } findPicoclawBinaryForInfo = func() string { return "picoclaw" } alive := true @@ -302,16 +267,9 @@ func TestResolveSystemVersionInfoInvalidatesCacheWhenGatewayStops(t *testing.T) 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" + launcherBuildInfoForVersion = func() systemVersionResponse { + return systemVersionResponse{Version: "v3.0.0", GoVersion: "go-fallback"} + } findPicoclawBinaryForInfo = func() string { return "picoclaw" } runCount := 0 @@ -333,3 +291,27 @@ func TestResolveSystemVersionInfoSkipsCommandWhenContextCanceled(t *testing.T) { t.Fatalf("version = %q, want fallback %q", got.Version, "v3.0.0") } } + +func TestResolveGatewayBinaryForVersionInfoPrefersGatewayCommandPath(t *testing.T) { + setupVersionTestIsolation(t) + + originalFinder := findPicoclawBinaryForInfo + t.Cleanup(func() { + findPicoclawBinaryForInfo = originalFinder + }) + + gateway.mu.Lock() + originalCmd := gateway.cmd + gateway.cmd = &exec.Cmd{Path: "/tmp/picoclaw-from-gateway"} + gateway.mu.Unlock() + t.Cleanup(func() { + gateway.mu.Lock() + gateway.cmd = originalCmd + gateway.mu.Unlock() + }) + + got := resolveGatewayBinaryForVersionInfo() + if got != "/tmp/picoclaw-from-gateway" { + t.Fatalf("exec path = %q, want %q", got, "/tmp/picoclaw-from-gateway") + } +}