diff --git a/internal/cmd/root.go b/internal/cmd/root.go index b8e751b7ea47d40405974402a474037e69bf9ce4..6533133025b0cc98b1c7e134be5fbb1c806e1cff 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -430,6 +430,28 @@ func ensureServer(cmd *cobra.Command, hostURL *url.URL) error { _, statErr := os.Stat(hostURL.Host) switch { case statErr == nil: + // Probe the socket explicitly before the version-check + // path. A stale unix socket file (the previous server + // exited without cleaning up) would otherwise make + // restartIfStale spin on a non-responsive endpoint; here + // we detect it with a short DialTimeout and remove the + // orphaned file so the normal spawn path can run. + if hostURL.Scheme == "unix" { + conn, dialErr := net.DialTimeout( + hostURL.Scheme, hostURL.Host, 200*time.Millisecond, + ) + if dialErr == nil { + conn.Close() + } else if server.IsStaleSocketErr(dialErr) { + slog.Warn("Stale socket detected, removing", + "path", hostURL.Host, "error", dialErr) + if err := os.Remove(hostURL.Host); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("failed to remove stale server socket %q: %v", hostURL.Host, err) + } + needsStart = true + break + } + } restarted, err := restartIfStale(cmd, hostURL) if err != nil { slog.Warn("Failed to check server version", "error", err) diff --git a/internal/server/socket.go b/internal/server/socket.go index 88cee66e6b3742d33ced61048c13305ad07fc277..d691e16ddb1598a3adad0d9ec902c11500a3e00f 100644 --- a/internal/server/socket.go +++ b/internal/server/socket.go @@ -2,23 +2,9 @@ package server -import ( - "errors" - "io/fs" - "net" - "syscall" -) - -// isStaleSocketErr reports whether err indicates a Unix-domain socket file -// exists on disk but no process is listening on it (a stale or orphaned -// socket). It returns false for nil and for timeout errors. +// isStaleSocketErr is the internal, non-Windows alias for the +// cross-platform IsStaleSocketErr. It is kept for the existing +// callers in net_other.go. func isStaleSocketErr(err error) bool { - if err == nil { - return false - } - var netErr net.Error - if errors.As(err, &netErr) && netErr.Timeout() { - return false - } - return errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, fs.ErrNotExist) + return IsStaleSocketErr(err) } diff --git a/internal/server/socket_classify.go b/internal/server/socket_classify.go new file mode 100644 index 0000000000000000000000000000000000000000..7edd1fdbca2b212d3c065396ee5fd7a37eeb9d2a --- /dev/null +++ b/internal/server/socket_classify.go @@ -0,0 +1,27 @@ +package server + +import ( + "errors" + "io/fs" + "net" + "syscall" +) + +// IsStaleSocketErr reports whether err indicates that a Unix-domain +// socket file exists on disk but no process is listening on it (a stale +// or orphaned socket). It returns false for nil and for timeout errors. +// +// The classification is cross-platform: ECONNREFUSED and fs.ErrNotExist +// are defined on every supported OS, so callers on Windows can use the +// same helper even though stale-socket recovery only applies to Unix +// sockets in practice. +func IsStaleSocketErr(err error) bool { + if err == nil { + return false + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return false + } + return errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, fs.ErrNotExist) +}