fix: detect stale server during development with BuildID

Tai Groot created

The version check compared Version strings, but during development both
client and server report "devel" — so stale servers were never restarted.

BuildID is now derived from the executable's modification time, which
changes on every recompilation (including go run). The server exposes it
via /v1/version and the client checks both Version and BuildID match.

Assisted-by: Crush:AWS Claude Opus 4.6

Change summary

internal/backend/backend.go |  1 
internal/cmd/root.go        | 11 ++++++---
internal/proto/version.go   |  1 
internal/version/version.go | 40 +++++++++++++++++++++++++++++++++-----
4 files changed, 43 insertions(+), 10 deletions(-)

Detailed changes

internal/backend/backend.go 🔗

@@ -174,6 +174,7 @@ func (b *Backend) VersionInfo() proto.VersionInfo {
 	return proto.VersionInfo{
 		Version:   version.Version,
 		Commit:    version.Commit,
+		BuildID:   version.BuildID,
 		GoVersion: runtime.Version(),
 		Platform:  fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
 	}

internal/cmd/root.go 🔗

@@ -614,12 +614,15 @@ func restartIfStale(cmd *cobra.Command, hostURL *url.URL) (restarted bool, err e
 	if err != nil {
 		return false, err
 	}
-	if vi.Version == version.Version {
+	if vi.Version == version.Version && vi.BuildID == version.BuildID {
 		return false, nil
 	}
-	slog.Info("Server version mismatch, restarting",
-		"server", vi.Version,
-		"client", version.Version,
+	slog.Info(
+		"Server version mismatch, restarting",
+		"server_version", vi.Version,
+		"client_version", version.Version,
+		"server_build_id", vi.BuildID,
+		"client_build_id", version.BuildID,
 	)
 	_ = c.ShutdownServer(cmd.Context())
 	// Give the old process a moment to release the socket.

internal/proto/version.go 🔗

@@ -4,6 +4,7 @@ package proto
 type VersionInfo struct {
 	Version   string `json:"version"`
 	Commit    string `json:"commit"`
+	BuildID   string `json:"build_id"`
 	GoVersion string `json:"go_version"`
 	Platform  string `json:"platform"`
 }

internal/version/version.go 🔗

@@ -1,12 +1,21 @@
 package version
 
-import "runtime/debug"
+import (
+	"os"
+	"runtime/debug"
+	"strconv"
+)
 
 // Build-time parameters set via -ldflags.
 
 var (
 	Version = "devel"
 	Commit  = "unknown"
+	// BuildID is a unique identifier for this build. For release builds it
+	// equals Commit; for development builds (go run / go build without
+	// ldflags) it is derived from the executable's modification time, which
+	// changes on every recompilation.
+	BuildID = ""
 )
 
 // A user may install crush using `go install github.com/charmbracelet/crush@latest`.
@@ -15,11 +24,30 @@ var (
 // is only set for `go install` and not for `go build`).
 func init() {
 	info, ok := debug.ReadBuildInfo()
-	if !ok {
-		return
+	if ok {
+		mainVersion := info.Main.Version
+		if mainVersion != "" && mainVersion != "(devel)" {
+			Version = mainVersion
+		}
+	}
+
+	// Derive BuildID when not set via ldflags.
+	if BuildID == "" {
+		BuildID = deriveBuildID()
+	}
+}
+
+// deriveBuildID uses the running executable's modification time as a unique
+// build fingerprint. This changes on every recompilation (including `go run`),
+// making it reliable for detecting stale servers during development.
+func deriveBuildID() string {
+	exe, err := os.Executable()
+	if err != nil {
+		return "unknown"
 	}
-	mainVersion := info.Main.Version
-	if mainVersion != "" && mainVersion != "(devel)" {
-		Version = mainVersion
+	fi, err := os.Stat(exe)
+	if err != nil {
+		return "unknown"
 	}
+	return strconv.FormatInt(fi.ModTime().UnixNano(), 36)
 }