Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

198 lines
7.1 KiB
Go

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)
}