package daemon import ( "fmt" "hash/fnv" "os" "path/filepath" "runtime" "github.com/zzet/gortex/internal/platform" ) // stateDir returns the directory the daemon keeps its runtime state in // (socket, PID file, logs, snapshot) and whether it could be resolved. // // An absolute $XDG_CACHE_HOME is honoured on every platform. When it is // unset the location stays at the historical default so an existing // daemon state directory is not orphaned: // // - Windows: %USERPROFILE%\.gortex\cache (via os.UserCacheDir). // - macOS / Linux: $HOME/.gortex/cache. // // The boolean is false when the home / cache directory can't be // resolved at all, in which case callers fall back to the temp dir. func stateDir() (string, bool) { if runtime.GOOS == "windows" { if v := os.Getenv("XDG_CACHE_HOME"); v == "" || !filepath.IsAbs(v) { if _, err := os.UserCacheDir(); err != nil { return "", false } } return platform.OSCacheDir(), true } if v := os.Getenv("XDG_CACHE_HOME"); v == "" || !filepath.IsAbs(v) { if _, err := os.UserHomeDir(); err != nil { return "", false } } return platform.CacheDir(), true } // SocketPath returns the socket path the daemon listens on. The socket // is an AF_UNIX socket on every supported OS — Windows has supported // AF_UNIX since Windows 10 1803, so the same transport works there. // // Order of preference: // 1. $GORTEX_DAEMON_SOCKET — explicit override (tests, custom deployments). // 2. $XDG_RUNTIME_DIR/gortex.sock — Linux standard for user runtime files. // This path is cleaned automatically on logout and has sensible perms. // 3. The per-user state dir — $HOME/.gortex/cache on macOS/Linux, // %USERPROFILE%\.gortex\cache on Windows. // // AF_UNIX socket paths have a length limit (~104 bytes on macOS, 108 on Linux // and Windows). An auto-computed path that would exceed it — a deeply-nested // home directory, a long $XDG_RUNTIME_DIR — is replaced by a short, stable // temp-dir fallback (clampSocketPath) so the listener binds instead of failing. // An explicit $GORTEX_DAEMON_SOCKET override is honoured verbatim: the user // chose that path and gets a loud failure if it's too long, never a silent // redirect. func SocketPath() string { if override := os.Getenv("GORTEX_DAEMON_SOCKET"); override != "" { return override } return clampSocketPath(autoSocketPath()) } // autoSocketPath computes the daemon's default socket path from the runtime / // state directories, before any length clamping. func autoSocketPath() string { if rt := os.Getenv("XDG_RUNTIME_DIR"); rt != "" && runtime.GOOS == "linux" { return filepath.Join(rt, "gortex.sock") } if dir, ok := stateDir(); ok { return filepath.Join(dir, "daemon.sock") } // Fall back to the temp dir as a last resort; the daemon must start // somewhere. return filepath.Join(os.TempDir(), "gortex.sock") } // socketAddrMax is the AF_UNIX sun_path limit for the current OS: 104 bytes on // macOS/BSD, 108 on Linux and Windows. A path whose length reaches this fails // the bind, so we clamp strictly below it. func socketAddrMax() int { if runtime.GOOS == "darwin" { return 104 } return 108 } // clampSocketPath returns p unchanged when it is short enough to bind, else a // short temp-dir fallback derived from a stable hash of p — so two daemons that // would have used different over-long paths still get distinct sockets. func clampSocketPath(p string) string { if len(p) < socketAddrMax() { return p } h := fnv.New32a() _, _ = h.Write([]byte(p)) return filepath.Join(os.TempDir(), fmt.Sprintf("gx-%08x.sock", h.Sum32())) } // PIDFilePath returns the path of the daemon PID file. The daemon writes // this on startup and removes it on graceful shutdown. Staleness detection // (for crashed daemons that never removed their PID) is a process-liveness // probe — see platform.ProcessAlive. func PIDFilePath() string { if override := os.Getenv("GORTEX_DAEMON_PIDFILE"); override != "" { return override } if dir, ok := stateDir(); ok { return filepath.Join(dir, "daemon.pid") } return filepath.Join(os.TempDir(), "gortex-daemon.pid") } // LogFilePath returns the path the daemon writes logs to when running in // --detach mode. In foreground mode stderr is used instead. func LogFilePath() string { if override := os.Getenv("GORTEX_DAEMON_LOGFILE"); override != "" { return override } if dir, ok := stateDir(); ok { return filepath.Join(dir, "daemon.log") } return filepath.Join(os.TempDir(), "gortex-daemon.log") } // SnapshotPath returns the legacy backend-agnostic snapshot path — // `daemon.gob.gz` under the state dir. Kept for callers that haven't // moved to backend-tagged storage yet (the legacy cloud indexer // worker). The daemon itself routes through // BackendSnapshotPath so a memory ↔ disk-backend switch can't read the // other backend's snapshot — see that function's doc. func SnapshotPath() string { if override := os.Getenv("GORTEX_DAEMON_SNAPSHOT"); override != "" { return override } if dir, ok := stateDir(); ok { return filepath.Join(dir, "daemon.gob.gz") } return filepath.Join(os.TempDir(), "gortex-daemon.gob.gz") } // BackendSnapshotPath returns a backend-tagged snapshot path so the // memory and disk backends use distinct files. The memory backend // snapshot is a full gob+gzip of the in-memory graph; the disk // backend snapshot is metadata-only (FileMtimes, contracts, vector // index) because the graph itself lives in the on-disk store. Loading // the memory backend's snapshot into a disk-backed daemon (or vice // versa) silently produced wrong state — empty graph after disk→memory // switch, decode-and-discard nodes after memory→disk — so a fresh // daemon now picks the right file by backend tag. // // Empty backend tag falls back to SnapshotPath() so embedded callers // that don't know the backend (the cloud indexer worker) keep working. // // GORTEX_DAEMON_SNAPSHOT overrides every backend tag — the override // is an explicit "use exactly this path" signal. func BackendSnapshotPath(backend string) string { if override := os.Getenv("GORTEX_DAEMON_SNAPSHOT"); override != "" { return override } tag := normalizeBackendTag(backend) if tag == "" { return SnapshotPath() } filename := "daemon-" + tag + ".gob.gz" if dir, ok := stateDir(); ok { return filepath.Join(dir, filename) } return filepath.Join(os.TempDir(), "gortex-"+filename) } // normalizeBackendTag canonicalizes a backend identifier into the // short tag used in the snapshot filename — "memory" / "sqlite" / // etc. Empty / unknown input returns the empty string so the caller // can fall back to the legacy unsuffixed path. func normalizeBackendTag(backend string) string { switch backend { case "memory", "mem", "in-memory": return "memory" case "sqlite", "sqlite3": return "sqlite" default: return "" } } // EnsureParentDir creates the parent directory of path with permissions // 0o700 (user only). Daemon state files live under the user's cache dir // and should not be world-readable. The mode is advisory on Windows, // where filesystem ACLs already scope %USERPROFILE% to the user. func EnsureParentDir(path string) error { dir := filepath.Dir(path) return os.MkdirAll(dir, 0o700) }