fix(web): harden gateway version probing and tests

This commit is contained in:
lc6464 2026-03-29 13:02:55 +08:00
parent 6c58b23946
commit 6f974195a8
No known key found for this signature in database
GPG key ID: 53C61B42FEC71D6D
3 changed files with 165 additions and 174 deletions

View file

@ -50,11 +50,12 @@ func isProcessRunning(pid int) bool {
return false return false
} }
return strings.Contains(string(output), fmt.Sprintf(" %d ", pid)) return strings.Contains(string(output), fmt.Sprintf(" %d ", pid))
} default:
// Linux // Linux and other unix-like systems.
_, err := os.Stat(fmt.Sprintf("/proc/%d", pid)) _, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
return err == nil return err == nil
} }
}
func getGatewayStatus() gatewayStatus { func getGatewayStatus() gatewayStatus {
pidPath := getPidPath() pidPath := getPidPath()

View file

@ -34,8 +34,6 @@ type systemVersionCache struct {
current cachedSystemVersion current cachedSystemVersion
hasCurrent bool hasCurrent bool
inflightCh chan struct{} inflightCh chan struct{}
monitorPID int
monitorCancel context.CancelFunc
} }
func newSystemVersionCache() *systemVersionCache { func newSystemVersionCache() *systemVersionCache {
@ -47,12 +45,13 @@ var (
// giving slow/embedded hosts enough time for first command invocation while // giving slow/embedded hosts enough time for first command invocation while
// staying independent from cross-file init ordering. // staying independent from cross-file init ordering.
versionCmdTimeout = 15 * time.Second versionCmdTimeout = 15 * time.Second
findPicoclawBinaryForInfo = utils.FindPicoclawBinary maxVersionResolveAttempts = 3
findPicoclawBinaryForInfo = resolveGatewayBinaryForVersionInfo
runPicoclawVersionOutput = executePicoclawVersion runPicoclawVersionOutput = executePicoclawVersion
currentGatewayVersionState = gatewayVersionState currentGatewayVersionState = gatewayVersionState
versionCacheMonitorInterval = 5 * time.Second launcherBuildInfoForVersion = fallbackSystemVersionInfoFromConfig
versionInfoCache = newSystemVersionCache() 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`) 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, // 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(ctx context.Context) systemVersionResponse { func (h *Handler) resolveSystemVersionInfo(ctx context.Context) systemVersionResponse {
for { for range maxVersionResolveAttempts {
gatewayPID, gatewayAlive := currentGatewayVersionState() gatewayPID, gatewayAlive := currentGatewayVersionState()
if cached, ok := versionInfoCache.get(gatewayPID, gatewayAlive); ok { if cached, ok := versionInfoCache.get(gatewayPID, gatewayAlive); ok {
return cached return cached
@ -93,6 +92,8 @@ func (h *Handler) resolveSystemVersionInfo(ctx context.Context) systemVersionRes
versionInfoCache.finishResolve(resolved, gatewayPID, gatewayAlive) versionInfoCache.finishResolve(resolved, gatewayPID, gatewayAlive)
return resolved return resolved
} }
return fallbackSystemVersionInfo()
} }
func (h *Handler) resolveSystemVersionInfoUncached(ctx context.Context) systemVersionResponse { func (h *Handler) resolveSystemVersionInfoUncached(ctx context.Context) systemVersionResponse {
@ -131,6 +132,10 @@ func (h *Handler) resolveSystemVersionInfoUncached(ctx context.Context) systemVe
} }
func fallbackSystemVersionInfo() systemVersionResponse { func fallbackSystemVersionInfo() systemVersionResponse {
return launcherBuildInfoForVersion()
}
func fallbackSystemVersionInfoFromConfig() systemVersionResponse {
buildTime, goVer := config.FormatBuildInfo() buildTime, goVer := config.FormatBuildInfo()
return systemVersionResponse{ return systemVersionResponse{
Version: config.GetVersion(), 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) { func gatewayVersionState() (int, bool) {
gateway.mu.Lock() gateway.mu.Lock()
defer gateway.mu.Unlock() defer gateway.mu.Unlock()
@ -200,7 +223,6 @@ func (c *systemVersionCache) finishResolve(value systemVersionResponse, gatewayP
if gatewayAlive && gatewayPID > 0 { if gatewayAlive && gatewayPID > 0 {
c.current = cachedSystemVersion{value: value, gatewayPID: gatewayPID} c.current = cachedSystemVersion{value: value, gatewayPID: gatewayPID}
c.hasCurrent = true c.hasCurrent = true
c.ensureMonitorLocked(gatewayPID)
} else { } else {
c.clearCurrentLocked() c.clearCurrentLocked()
} }
@ -217,41 +239,6 @@ func (c *systemVersionCache) finishResolve(value systemVersionResponse, gatewayP
func (c *systemVersionCache) clearCurrentLocked() { func (c *systemVersionCache) clearCurrentLocked() {
c.hasCurrent = false c.hasCurrent = false
c.current = cachedSystemVersion{} 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() { func (c *systemVersionCache) resetForTest() {
@ -260,31 +247,12 @@ func (c *systemVersionCache) resetForTest() {
c.current = cachedSystemVersion{} c.current = cachedSystemVersion{}
c.hasCurrent = false c.hasCurrent = false
c.stopMonitorLocked()
if c.inflightCh != nil { if c.inflightCh != nil {
close(c.inflightCh) close(c.inflightCh)
c.inflightCh = nil 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) {
@ -309,7 +277,11 @@ func parsePicoclawVersionOutput(raw string) (systemVersionResponse, bool) {
} }
if match := versionLinePattern.FindStringSubmatch(line); len(match) > 0 { 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 { if len(match) > 2 {
result.GitCommit = strings.TrimSpace(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 == "" { if result.Version == "" {
return systemVersionResponse{}, false return systemVersionResponse{}, false
} }
return result, true 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
}

View file

@ -7,51 +7,36 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os/exec"
"runtime" "runtime"
"testing" "testing"
"time"
"github.com/sipeed/picoclaw/pkg/config"
) )
func setupVersionTestIsolation(t *testing.T) { func setupVersionTestIsolation(t *testing.T) {
t.Helper() t.Helper()
originalGatewayState := currentGatewayVersionState originalGatewayState := currentGatewayVersionState
originalMonitorInterval := versionCacheMonitorInterval originalFinder := findPicoclawBinaryForInfo
originalRunner := runPicoclawVersionOutput
originalFallback := launcherBuildInfoForVersion
t.Cleanup(func() { t.Cleanup(func() {
currentGatewayVersionState = originalGatewayState currentGatewayVersionState = originalGatewayState
versionCacheMonitorInterval = originalMonitorInterval findPicoclawBinaryForInfo = originalFinder
runPicoclawVersionOutput = originalRunner
launcherBuildInfoForVersion = originalFallback
versionInfoCache.resetForTest() versionInfoCache.resetForTest()
}) })
currentGatewayVersionState = func() (int, bool) { return 0, false } currentGatewayVersionState = func() (int, bool) { return 0, false }
versionCacheMonitorInterval = 10 * time.Millisecond
versionInfoCache.resetForTest() versionInfoCache.resetForTest()
} }
func TestGetSystemVersionUsesPicoclawBinaryInfo(t *testing.T) { func TestGetSystemVersionUsesPicoclawBinaryInfo(t *testing.T) {
setupVersionTestIsolation(t) setupVersionTestIsolation(t)
originalVersion := config.Version launcherBuildInfoForVersion = func() systemVersionResponse {
originalGitCommit := config.GitCommit return systemVersionResponse{Version: "fallback", GoVersion: "go-fallback"}
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 = ""
findPicoclawBinaryForInfo = func() string { return "picoclaw" } findPicoclawBinaryForInfo = func() string { return "picoclaw" }
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
@ -92,25 +77,13 @@ func TestGetSystemVersionUsesPicoclawBinaryInfo(t *testing.T) {
func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) { func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) {
setupVersionTestIsolation(t) setupVersionTestIsolation(t)
originalVersion := config.Version expected := systemVersionResponse{
originalGitCommit := config.GitCommit Version: "v9.9.9",
originalBuildTime := config.BuildTime GitCommit: "cafebabe",
originalGoVersion := config.GoVersion BuildTime: "2026-03-27T10:43:34+0000",
originalFinder := findPicoclawBinaryForInfo GoVersion: "go1.25.8",
originalRunner := runPicoclawVersionOutput }
t.Cleanup(func() { launcherBuildInfoForVersion = func() systemVersionResponse { return expected }
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"
findPicoclawBinaryForInfo = func() string { return "picoclaw" } findPicoclawBinaryForInfo = func() string { return "picoclaw" }
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
@ -134,17 +107,17 @@ func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) {
t.Fatalf("unmarshal response: %v", err) t.Fatalf("unmarshal response: %v", err)
} }
if got.Version != config.Version { if got.Version != expected.Version {
t.Fatalf("version = %q, want %q", got.Version, config.Version) t.Fatalf("version = %q, want %q", got.Version, expected.Version)
} }
if got.GitCommit != config.GitCommit { if got.GitCommit != expected.GitCommit {
t.Fatalf("git_commit = %q, want %q", got.GitCommit, config.GitCommit) t.Fatalf("git_commit = %q, want %q", got.GitCommit, expected.GitCommit)
} }
if got.BuildTime != config.BuildTime { if got.BuildTime != expected.BuildTime {
t.Fatalf("build_time = %q, want %q", got.BuildTime, config.BuildTime) t.Fatalf("build_time = %q, want %q", got.BuildTime, expected.BuildTime)
} }
if got.GoVersion != config.GoVersion { if got.GoVersion != expected.GoVersion {
t.Fatalf("go_version = %q, want %q", got.GoVersion, config.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) { func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) {
setupVersionTestIsolation(t) setupVersionTestIsolation(t)
originalVersion := config.Version launcherBuildInfoForVersion = func() systemVersionResponse {
originalGitCommit := config.GitCommit return systemVersionResponse{Version: "dev", GoVersion: ""}
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 = ""
findPicoclawBinaryForInfo = func() string { return "picoclaw" } findPicoclawBinaryForInfo = func() string { return "picoclaw" }
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
@ -208,18 +191,9 @@ func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) {
func TestResolveSystemVersionInfoCachesWhileGatewayAlive(t *testing.T) { func TestResolveSystemVersionInfoCachesWhileGatewayAlive(t *testing.T) {
setupVersionTestIsolation(t) setupVersionTestIsolation(t)
originalVersion := config.Version launcherBuildInfoForVersion = func() systemVersionResponse {
originalFinder := findPicoclawBinaryForInfo return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"}
originalRunner := runPicoclawVersionOutput }
originalGatewayState := currentGatewayVersionState
t.Cleanup(func() {
config.Version = originalVersion
findPicoclawBinaryForInfo = originalFinder
runPicoclawVersionOutput = originalRunner
currentGatewayVersionState = originalGatewayState
})
config.Version = "dev"
findPicoclawBinaryForInfo = func() string { return "picoclaw" } findPicoclawBinaryForInfo = func() string { return "picoclaw" }
pid := 4321 pid := 4321
@ -249,18 +223,9 @@ func TestResolveSystemVersionInfoCachesWhileGatewayAlive(t *testing.T) {
func TestResolveSystemVersionInfoInvalidatesCacheWhenGatewayStops(t *testing.T) { func TestResolveSystemVersionInfoInvalidatesCacheWhenGatewayStops(t *testing.T) {
setupVersionTestIsolation(t) setupVersionTestIsolation(t)
originalVersion := config.Version launcherBuildInfoForVersion = func() systemVersionResponse {
originalFinder := findPicoclawBinaryForInfo return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"}
originalRunner := runPicoclawVersionOutput }
originalGatewayState := currentGatewayVersionState
t.Cleanup(func() {
config.Version = originalVersion
findPicoclawBinaryForInfo = originalFinder
runPicoclawVersionOutput = originalRunner
currentGatewayVersionState = originalGatewayState
})
config.Version = "dev"
findPicoclawBinaryForInfo = func() string { return "picoclaw" } findPicoclawBinaryForInfo = func() string { return "picoclaw" }
alive := true alive := true
@ -302,16 +267,9 @@ func TestResolveSystemVersionInfoInvalidatesCacheWhenGatewayStops(t *testing.T)
func TestResolveSystemVersionInfoSkipsCommandWhenContextCanceled(t *testing.T) { func TestResolveSystemVersionInfoSkipsCommandWhenContextCanceled(t *testing.T) {
setupVersionTestIsolation(t) setupVersionTestIsolation(t)
originalVersion := config.Version launcherBuildInfoForVersion = func() systemVersionResponse {
originalFinder := findPicoclawBinaryForInfo return systemVersionResponse{Version: "v3.0.0", GoVersion: "go-fallback"}
originalRunner := runPicoclawVersionOutput }
t.Cleanup(func() {
config.Version = originalVersion
findPicoclawBinaryForInfo = originalFinder
runPicoclawVersionOutput = originalRunner
})
config.Version = "v3.0.0"
findPicoclawBinaryForInfo = func() string { return "picoclaw" } findPicoclawBinaryForInfo = func() string { return "picoclaw" }
runCount := 0 runCount := 0
@ -333,3 +291,27 @@ func TestResolveSystemVersionInfoSkipsCommandWhenContextCanceled(t *testing.T) {
t.Fatalf("version = %q, want fallback %q", got.Version, "v3.0.0") 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")
}
}