1package db
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strconv"
10 "time"
11
12 "github.com/charmbracelet/crush/internal/version"
13)
14
15// ErrDataDirLocked is returned by Connect when the data directory is
16// already in use by another crush process.
17var ErrDataDirLocked = errors.New("data directory already in use by another crush process")
18
19// dataDirLockFile is the name of the lock file inside the data
20// directory. It lives next to crush.db so users can `ls` and find it.
21const dataDirLockFile = "crush.lock"
22
23// dataDirOwnerInfo is the JSON payload written into the lock file by
24// the process that currently owns it. It is purely informational; the
25// authoritative state of ownership is the operating system flock on
26// the file descriptor.
27type dataDirOwnerInfo struct {
28 PID int `json:"pid"`
29 Version string `json:"version,omitempty"`
30 StartedAt string `json:"started_at,omitempty"`
31}
32
33// dataDirLock represents an acquired exclusive lock on a data
34// directory. release closes the underlying file descriptor which the
35// kernel uses to drop the OS-level lock.
36type dataDirLock struct {
37 release func()
38}
39
40// acquireDataDirLock takes an exclusive non-blocking lock on
41// {dataDir}/crush.lock. If the lock is already held by another
42// process, it returns ErrDataDirLocked wrapped with a diagnostic that
43// includes whatever owner info that process wrote.
44//
45// Acquisition is skipped (returning a no-op lock) when
46// CRUSH_SKIP_DATADIR_LOCK is set to a truthy value. This is intended
47// as an escape hatch for hostile filesystems that do not implement
48// advisory locking; it should not be used in normal operation.
49func acquireDataDirLock(dataDir string) (*dataDirLock, error) {
50 if skipDataDirLock() {
51 return &dataDirLock{release: func() {}}, nil
52 }
53
54 path := filepath.Join(dataDir, dataDirLockFile)
55 release, err := tryFileLock(path)
56 if err != nil {
57 if errors.Is(err, errLockContended) {
58 return nil, contendedLockError(dataDir, path)
59 }
60 return nil, fmt.Errorf("failed to lock data directory %q: %w", dataDir, err)
61 }
62
63 // Record ownership metadata so a contending process can identify
64 // us. Failures here are non-fatal: the OS-level lock is what
65 // actually guarantees mutual exclusion, and a missing/partial JSON
66 // payload only degrades diagnostics.
67 if err := writeOwnerInfo(path); err != nil {
68 // Best-effort; log via stderr only when running in a debug
69 // context would be invasive here, so we silently swallow.
70 _ = err
71 }
72
73 return &dataDirLock{release: release}, nil
74}
75
76// skipDataDirLock reports whether the data-dir lock should be bypassed.
77func skipDataDirLock() bool {
78 v, _ := strconv.ParseBool(os.Getenv("CRUSH_SKIP_DATADIR_LOCK"))
79 return v
80}
81
82// writeOwnerInfo truncates and rewrites the lock file with the current
83// process's identifying information. It is called only after the lock
84// is held.
85func writeOwnerInfo(path string) error {
86 info := dataDirOwnerInfo{
87 PID: os.Getpid(),
88 Version: version.Version,
89 StartedAt: time.Now().UTC().Format(time.RFC3339),
90 }
91 payload, err := json.MarshalIndent(info, "", " ")
92 if err != nil {
93 return err
94 }
95 payload = append(payload, '\n')
96 return os.WriteFile(path, payload, 0o600)
97}
98
99// readOwnerInfo returns the lock file's recorded owner, if it parses.
100// A missing or malformed file yields an empty struct and no error;
101// the caller decides what to surface to the user.
102func readOwnerInfo(path string) dataDirOwnerInfo {
103 raw, err := os.ReadFile(path)
104 if err != nil || len(raw) == 0 {
105 return dataDirOwnerInfo{}
106 }
107 var info dataDirOwnerInfo
108 _ = json.Unmarshal(raw, &info)
109 return info
110}
111
112// contendedLockError builds a wrapped ErrDataDirLocked annotated with
113// whatever owner metadata is currently in the lock file.
114func contendedLockError(dataDir, lockPath string) error {
115 info := readOwnerInfo(lockPath)
116 details := ""
117 switch {
118 case info.PID != 0 && info.StartedAt != "":
119 details = fmt.Sprintf(" (owner pid=%d version=%s started_at=%s)",
120 info.PID, info.Version, info.StartedAt)
121 case info.PID != 0:
122 details = fmt.Sprintf(" (owner pid=%d)", info.PID)
123 }
124 return fmt.Errorf("%w: %s%s", ErrDataDirLocked, dataDir, details)
125}