on main help sign in
fix(server): detect and remove a dead socket before starting server

The client now checks whether a leftover socket has a live server behind it. If it does not, the client clears the dead socket and starts a fresh server instead of getting stuck trying to reach one that is no longer there. The detection is shared across platforms. Co-Authored-By: Charm Crush <crush@charm.land>

commit2e9f6342aba85f75afa036f2d1f8c90d560e25ce authorChristian Rocha <christian@rocha.is> dateMon 8 Jun 2026, 09:30 (4 months ago) parent443d0ab
3 files changed+53−18
internal/cmd/root.go+22 internal/server/socket.go+4−18 internal/server/socket_classify.go+27
internal/cmd/root.go+22
@@ -430,6 +430,28 @@ func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
430430 _, statErr := os.Stat(hostURL.Host)
431431 switch {
432432 case statErr == nil:
433+ // Probe the socket explicitly before the version-check
434+ // path. A stale unix socket file (the previous server
435+ // exited without cleaning up) would otherwise make
436+ // restartIfStale spin on a non-responsive endpoint; here
437+ // we detect it with a short DialTimeout and remove the
438+ // orphaned file so the normal spawn path can run.
439+ if hostURL.Scheme == "unix" {
440+ conn, dialErr := net.DialTimeout(
441+ hostURL.Scheme, hostURL.Host, 200*time.Millisecond,
442+ )
443+ if dialErr == nil {
444+ conn.Close()
445+ } else if server.IsStaleSocketErr(dialErr) {
446+ slog.Warn("Stale socket detected, removing",
447+ "path", hostURL.Host, "error", dialErr)
448+ if err := os.Remove(hostURL.Host); err != nil && !errors.Is(err, fs.ErrNotExist) {
449+ return fmt.Errorf("failed to remove stale server socket %q: %v", hostURL.Host, err)
450+ }
451+ needsStart = true
452+ break
453+ }
454+ }
433455 restarted, err := restartIfStale(cmd, hostURL)
434456 if err != nil {
435457 slog.Warn("Failed to check server version", "error", err)
internal/server/socket.go+4−18
@@ -2,23 +2,9 @@
22
33package server
44
5−import (
6− "errors"
7− "io/fs"
8− "net"
9− "syscall"
10−)
11−
12−// isStaleSocketErr reports whether err indicates a Unix-domain socket file
13−// exists on disk but no process is listening on it (a stale or orphaned
14−// socket). It returns false for nil and for timeout errors.
5+// isStaleSocketErr is the internal, non-Windows alias for the
6+// cross-platform IsStaleSocketErr. It is kept for the existing
7+// callers in net_other.go.
158func isStaleSocketErr(err error) bool {
16− if err == nil {
17− return false
18− }
19− var netErr net.Error
20− if errors.As(err, &netErr) && netErr.Timeout() {
21− return false
22− }
23− return errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, fs.ErrNotExist)
9+ return IsStaleSocketErr(err)
2410}
internal/server/socket_classify.gonew file+27
@@ -0,0 +1,27 @@
1+package server
2+
3+import (
4+ "errors"
5+ "io/fs"
6+ "net"
7+ "syscall"
8+)
9+
10+// IsStaleSocketErr reports whether err indicates that a Unix-domain
11+// socket file exists on disk but no process is listening on it (a stale
12+// or orphaned socket). It returns false for nil and for timeout errors.
13+//
14+// The classification is cross-platform: ECONNREFUSED and fs.ErrNotExist
15+// are defined on every supported OS, so callers on Windows can use the
16+// same helper even though stale-socket recovery only applies to Unix
17+// sockets in practice.
18+func IsStaleSocketErr(err error) bool {
19+ if err == nil {
20+ return false
21+ }
22+ var netErr net.Error
23+ if errors.As(err, &netErr) && netErr.Timeout() {
24+ return false
25+ }
26+ return errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, fs.ErrNotExist)
27+}