fix(web): improve version parsing and timeout behavior

This commit is contained in:
lc6464 2026-03-27 19:50:10 +08:00
parent 18ec2631aa
commit c2d2ee6ae8
No known key found for this signature in database
GPG key ID: 53C61B42FEC71D6D
3 changed files with 216 additions and 27 deletions

View file

@ -35,14 +35,15 @@ func getPidPath() string {
} }
func isProcessRunning(pid int) bool { func isProcessRunning(pid int) bool {
if runtime.GOOS == "windows" { switch runtime.GOOS {
case "windows":
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid)) cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid))
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
return false return false
} }
return strings.Contains(string(output), strconv.Itoa(pid)) return strings.Contains(string(output), strconv.Itoa(pid))
} else if runtime.GOOS == "darwin" { case "darwin":
cmd := exec.Command("ps", "aux") cmd := exec.Command("ps", "aux")
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {

View file

@ -1,10 +1,18 @@
package api package api
import ( import (
"bufio"
"context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"os/exec"
"regexp"
"runtime"
"strings"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/web/backend/utils"
) )
type systemVersionResponse struct { type systemVersionResponse struct {
@ -14,18 +22,116 @@ type systemVersionResponse struct {
GoVersion string `json:"go_version"` GoVersion string `json:"go_version"`
} }
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`)
)
func (h *Handler) registerVersionRoutes(mux *http.ServeMux) { func (h *Handler) registerVersionRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/system/version", h.handleGetVersion) mux.HandleFunc("GET /api/system/version", h.handleGetVersion)
} }
// handleGetVersion returns runtime version information for web clients.
func (h *Handler) handleGetVersion(w http.ResponseWriter, _ *http.Request) { func (h *Handler) handleGetVersion(w http.ResponseWriter, _ *http.Request) {
buildTime, goVer := config.FormatBuildInfo() versionInfo := h.resolveSystemVersionInfo()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(systemVersionResponse{ json.NewEncoder(w).Encode(versionInfo)
}
// 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(), Version: config.GetVersion(),
GitCommit: config.GitCommit, GitCommit: config.GitCommit,
BuildTime: buildTime, BuildTime: buildTime,
GoVersion: goVer, GoVersion: goVer,
}) }
execPath := strings.TrimSpace(findPicoclawBinaryForInfo())
if execPath == "" {
return fallback
}
ctx, cancel := context.WithTimeout(context.Background(), versionCmdTimeout)
defer cancel()
output, err := runPicoclawVersionOutput(ctx, execPath)
if err != nil {
return fallback
}
parsed, ok := parsePicoclawVersionOutput(output)
if !ok {
return fallback
}
if parsed.GoVersion == "" {
parsed.GoVersion = fallback.GoVersion
if parsed.GoVersion == "" {
parsed.GoVersion = runtime.Version()
}
}
return parsed
}
// executePicoclawVersion runs version commands against the discovered picoclaw
// executable. It tries subcommand form first, then --version fallback.
func executePicoclawVersion(ctx context.Context, execPath string) (string, error) {
out, err := exec.CommandContext(ctx, execPath, "version").CombinedOutput()
if err == nil {
return string(out), nil
}
flagOut, flagErr := exec.CommandContext(ctx, execPath, "--version").CombinedOutput()
if flagErr == nil {
return string(flagOut), nil
}
return string(out), fmt.Errorf("failed to execute version command: %w", err)
}
// parsePicoclawVersionOutput extracts version/build/go fields from CLI output.
// It accepts banner/ANSI-decorated output and only requires the version line.
func parsePicoclawVersionOutput(raw string) (systemVersionResponse, bool) {
var result systemVersionResponse
scanner := bufio.NewScanner(strings.NewReader(raw))
for scanner.Scan() {
line := strings.TrimSpace(ansiEscapePattern.ReplaceAllString(scanner.Text(), ""))
if line == "" {
continue
}
if match := versionLinePattern.FindStringSubmatch(line); len(match) > 0 {
result.Version = strings.TrimSpace(match[1])
if len(match) > 2 {
result.GitCommit = strings.TrimSpace(match[2])
}
continue
}
if buildValue, ok := strings.CutPrefix(line, "Build:"); ok {
result.BuildTime = strings.TrimSpace(buildValue)
continue
}
if goValue, ok := strings.CutPrefix(line, "Go:"); ok {
result.GoVersion = strings.TrimSpace(goValue)
}
}
if result.Version == "" {
return systemVersionResponse{}, false
}
return result, true
} }

View file

@ -1,7 +1,9 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"runtime" "runtime"
@ -10,22 +12,88 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
) )
func TestGetSystemVersion(t *testing.T) { func TestGetSystemVersionUsesPicoclawBinaryInfo(t *testing.T) {
originalVersion := config.Version originalVersion := config.Version
originalGitCommit := config.GitCommit originalGitCommit := config.GitCommit
originalBuildTime := config.BuildTime originalBuildTime := config.BuildTime
originalGoVersion := config.GoVersion originalGoVersion := config.GoVersion
originalFinder := findPicoclawBinaryForInfo
originalRunner := runPicoclawVersionOutput
t.Cleanup(func() { t.Cleanup(func() {
config.Version = originalVersion config.Version = originalVersion
config.GitCommit = originalGitCommit config.GitCommit = originalGitCommit
config.BuildTime = originalBuildTime config.BuildTime = originalBuildTime
config.GoVersion = originalGoVersion config.GoVersion = originalGoVersion
findPicoclawBinaryForInfo = originalFinder
runPicoclawVersionOutput = originalRunner
}) })
config.Version = "v1.2.3" config.Version = "dev"
config.GitCommit = "deadbeef" config.GitCommit = ""
config.BuildTime = "2026-03-27T12:34:56Z" config.BuildTime = ""
config.GoVersion = "go1.24.1" config.GoVersion = ""
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
return "🦞 picoclaw v1.2.3 (git: deadbeef)\n Build: 2026-03-27T12:34:56Z\n Go: go1.25.8\n", nil
}
h := NewHandler("")
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var got systemVersionResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got.Version != "v1.2.3" {
t.Fatalf("version = %q, want %q", got.Version, "v1.2.3")
}
if got.GitCommit != "deadbeef" {
t.Fatalf("git_commit = %q, want %q", got.GitCommit, "deadbeef")
}
if got.BuildTime != "2026-03-27T12:34:56Z" {
t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T12:34:56Z")
}
if got.GoVersion != "go1.25.8" {
t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8")
}
}
func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.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"
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
return "", errors.New("binary unavailable")
}
h := NewHandler("") h := NewHandler("")
mux := http.NewServeMux() mux := http.NewServeMux()
@ -58,16 +126,40 @@ func TestGetSystemVersion(t *testing.T) {
} }
} }
func TestGetSystemVersionUsesRuntimeGoVersionFallback(t *testing.T) { func TestParsePicoclawVersionOutput(t *testing.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 {
t.Fatal("parsePicoclawVersionOutput() should parse valid output")
}
if got.Version != "18ec263" {
t.Fatalf("version = %q, want %q", got.Version, "18ec263")
}
if got.GitCommit != "18ec2631" {
t.Fatalf("git_commit = %q, want %q", got.GitCommit, "18ec2631")
}
if got.BuildTime != "2026-03-27T10:43:34+0000" {
t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T10:43:34+0000")
}
if got.GoVersion != "go1.25.8" {
t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8")
}
}
func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) {
originalVersion := config.Version originalVersion := config.Version
originalGitCommit := config.GitCommit originalGitCommit := config.GitCommit
originalBuildTime := config.BuildTime originalBuildTime := config.BuildTime
originalGoVersion := config.GoVersion originalGoVersion := config.GoVersion
originalFinder := findPicoclawBinaryForInfo
originalRunner := runPicoclawVersionOutput
t.Cleanup(func() { t.Cleanup(func() {
config.Version = originalVersion config.Version = originalVersion
config.GitCommit = originalGitCommit config.GitCommit = originalGitCommit
config.BuildTime = originalBuildTime config.BuildTime = originalBuildTime
config.GoVersion = originalGoVersion config.GoVersion = originalGoVersion
findPicoclawBinaryForInfo = originalFinder
runPicoclawVersionOutput = originalRunner
}) })
config.Version = "dev" config.Version = "dev"
@ -75,23 +167,13 @@ func TestGetSystemVersionUsesRuntimeGoVersionFallback(t *testing.T) {
config.BuildTime = "" config.BuildTime = ""
config.GoVersion = "" config.GoVersion = ""
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
return "picoclaw v1.0.0\n", nil
}
h := NewHandler("") h := NewHandler("")
mux := http.NewServeMux() got := h.resolveSystemVersionInfo()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var got systemVersionResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
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())
} }