datadirlock.go

  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}