on main help sign in
fix: detect stale server during development with BuildID

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

commit9e126c27dac80e0d754680eb6e1a24e9ba1ef38e authorTai Groot <tai@taigrr.com> dateWed 13 May 2026, 14:18 (5 months ago) parent08c5c7d
4 files changed+43−10
internal/backend/backend.go+1 internal/cmd/root.go+7−4 internal/proto/version.go+1 internal/version/version.go+34−6
internal/backend/backend.go+1
@@ -174,6 +174,7 @@ func (b *Backend) VersionInfo() proto.VersionInfo {
174174 return proto.VersionInfo{
175175 Version: version.Version,
176176 Commit: version.Commit,
177+ BuildID: version.BuildID,
177178 GoVersion: runtime.Version(),
178179 Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
179180 }
internal/cmd/root.go+7−4
@@ -614,12 +614,15 @@ func restartIfStale(cmd *cobra.Command, hostURL *url.URL) (restarted bool, err e
614614 if err != nil {
615615 return false, err
616616 }
617− if vi.Version == version.Version {
617+ if vi.Version == version.Version && vi.BuildID == version.BuildID {
618618 return false, nil
619619 }
620− slog.Info("Server version mismatch, restarting",
621− "server", vi.Version,
622− "client", version.Version,
620+ slog.Info(
621+ "Server version mismatch, restarting",
622+ "server_version", vi.Version,
623+ "client_version", version.Version,
624+ "server_build_id", vi.BuildID,
625+ "client_build_id", version.BuildID,
623626 )
624627 _ = c.ShutdownServer(cmd.Context())
625628 // Give the old process a moment to release the socket.
internal/proto/version.go+1
@@ -4,6 +4,7 @@ package proto
44type VersionInfo struct {
55 Version string `json:"version"`
66 Commit string `json:"commit"`
7+ BuildID string `json:"build_id"`
78 GoVersion string `json:"go_version"`
89 Platform string `json:"platform"`
910}
internal/version/version.go+34−6
@@ -1,12 +1,21 @@
11package version
22
3−import "runtime/debug"
3+import (
4+ "os"
5+ "runtime/debug"
6+ "strconv"
7+)
48
59// Build-time parameters set via -ldflags.
610
711var (
812 Version = "devel"
913 Commit = "unknown"
14+ // BuildID is a unique identifier for this build. For release builds it
15+ // equals Commit; for development builds (go run / go build without
16+ // ldflags) it is derived from the executable's modification time, which
17+ // changes on every recompilation.
18+ BuildID = ""
1019)
1120
1221// A user may install crush using `go install github.com/charmbracelet/crush@latest`.
@@ -15,11 +24,30 @@ var (
1524// is only set for `go install` and not for `go build`).
1625func init() {
1726 info, ok := debug.ReadBuildInfo()
18− if !ok {
19− return
27+ if ok {
28+ mainVersion := info.Main.Version
29+ if mainVersion != "" && mainVersion != "(devel)" {
30+ Version = mainVersion
31+ }
32+ }
33+
34+ // Derive BuildID when not set via ldflags.
35+ if BuildID == "" {
36+ BuildID = deriveBuildID()
37+ }
38+}
39+
40+// deriveBuildID uses the running executable's modification time as a unique
41+// build fingerprint. This changes on every recompilation (including `go run`),
42+// making it reliable for detecting stale servers during development.
43+func deriveBuildID() string {
44+ exe, err := os.Executable()
45+ if err != nil {
46+ return "unknown"
2047 }
21− mainVersion := info.Main.Version
22− if mainVersion != "" && mainVersion != "(devel)" {
23− Version = mainVersion
48+ fi, err := os.Stat(exe)
49+ if err != nil {
50+ return "unknown"
2451 }
52+ return strconv.FormatInt(fi.ModTime().UnixNano(), 36)
2553}