1//go:build unix
2
3// Package lifecycle provides exclusive process ownership of vev's durable
4// state for the complete daemon lifetime.
5package lifecycle
6
7import (
8 "context"
9 "errors"
10 "fmt"
11 "os"
12 "path/filepath"
13 "sync"
14 "syscall"
15 "time"
16
17 "github.com/bnema/vev/pkg/safedir"
18)
19
20// ErrBusy reports that another process currently owns durable session state.
21var ErrBusy = errors.New("durable session state is busy")
22
23// Owner holds the lifecycle lock descriptor open until Release.
24type Owner struct {
25 file *os.File
26 path string
27 once sync.Once
28 err error
29}
30
31// Path returns the fixed lifecycle lock path in runtimeDir.
32func Path(runtimeDir string) string {
33 return filepath.Join(runtimeDir, "lifecycle.lock")
34}
35
36// TryAcquire attempts to take lifecycle ownership without waiting.
37func TryAcquire(runtimeDir string) (*Owner, error) {
38 if err := safedir.EnsurePrivate(runtimeDir); err != nil {
39 return nil, fmt.Errorf("lifecycle: securing runtime directory: %w", err)
40 }
41
42 path := Path(runtimeDir)
43 file, err := openLockFile(path)
44 if err != nil {
45 return nil, err
46 }
47 if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
48 closeErr := file.Close()
49 if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
50 return nil, errors.Join(ErrBusy, closeErr)
51 }
52 return nil, errors.Join(fmt.Errorf("lifecycle: locking %s: %w", path, err), closeErr)
53 }
54 return &Owner{file: file, path: path}, nil
55}
56
57// openLockFile opens the lock inode without following a symlink and refuses to
58// trust anything that is not a regular, owner-only file.
59//
60// O_NOFOLLOW makes a symlinked lock path fail closed with ELOOP instead of
61// flocking the link target, and the fstat check fails closed on a directory,
62// FIFO, device, or a file with group/other access, so a path another user could
63// plant can never become the lifecycle lock. Only group and other access is
64// refused: an owner-only file is trusted regardless of the exact owner bit
65// pattern, so a lock created under a restrictive umask (or normalized by a
66// different tool) is never a permanent, unrecoverable startup failure. A newly
67// created lock is additionally chmodded to 0600 so a permissive umask can never
68// leave group/other bits behind. The failure is reported as an ordinary error,
69// never ErrBusy.
70func openLockFile(path string) (*os.File, error) {
71 fd, err := syscall.Open(path, syscall.O_CREAT|syscall.O_EXCL|syscall.O_RDWR|syscall.O_CLOEXEC|syscall.O_NOFOLLOW, 0o600)
72 created := err == nil
73 if err != nil {
74 if !errors.Is(err, syscall.EEXIST) {
75 if errors.Is(err, syscall.ELOOP) {
76 return nil, fmt.Errorf("lifecycle: %s is a symlink", path)
77 }
78 return nil, fmt.Errorf("lifecycle: opening %s: %w", path, err)
79 }
80 fd, err = syscall.Open(path, syscall.O_RDWR|syscall.O_CLOEXEC|syscall.O_NOFOLLOW, 0)
81 if err != nil {
82 if errors.Is(err, syscall.ELOOP) {
83 return nil, fmt.Errorf("lifecycle: %s is a symlink", path)
84 }
85 return nil, fmt.Errorf("lifecycle: opening %s: %w", path, err)
86 }
87 }
88 file := os.NewFile(uintptr(fd), path)
89 if created {
90 if err := syscall.Fchmod(fd, 0o600); err != nil {
91 return nil, errors.Join(fmt.Errorf("lifecycle: chmod %s: %w", path, err), file.Close())
92 }
93 }
94 info, err := file.Stat()
95 if err != nil {
96 return nil, errors.Join(fmt.Errorf("lifecycle: stat %s: %w", path, err), file.Close())
97 }
98 if !info.Mode().IsRegular() {
99 return nil, errors.Join(fmt.Errorf("lifecycle: %s is not a regular file", path), file.Close())
100 }
101 if info.Mode().Perm()&0o077 != 0 {
102 return nil, errors.Join(fmt.Errorf("lifecycle: %s has permissions %04o, want owner-only", path, info.Mode().Perm()), file.Close())
103 }
104 return file, nil
105}
106
107// Acquire waits until lifecycle ownership is available or ctx is cancelled.
108// Only lock contention is retried; filesystem and lock errors fail closed.
109func Acquire(ctx context.Context, runtimeDir string, retry time.Duration) (*Owner, error) {
110 if retry <= 0 {
111 retry = time.Millisecond
112 }
113 for {
114 if err := ctx.Err(); err != nil {
115 return nil, err
116 }
117 owner, err := TryAcquire(runtimeDir)
118 if err == nil {
119 return owner, nil
120 }
121 if !errors.Is(err, ErrBusy) {
122 return nil, err
123 }
124
125 timer := time.NewTimer(retry)
126 select {
127 case <-ctx.Done():
128 timer.Stop()
129 return nil, ctx.Err()
130 case <-timer.C:
131 }
132 }
133}
134
135// Release unlocks and closes the owner exactly once.
136func (o *Owner) Release() error {
137 if o == nil {
138 return nil
139 }
140 o.once.Do(func() {
141 if o.file == nil {
142 return
143 }
144 unlockErr := syscall.Flock(int(o.file.Fd()), syscall.LOCK_UN)
145 closeErr := o.file.Close()
146 if unlockErr != nil {
147 unlockErr = fmt.Errorf("lifecycle: unlocking %s: %w", o.path, unlockErr)
148 }
149 if closeErr != nil {
150 closeErr = fmt.Errorf("lifecycle: closing %s: %w", o.path, closeErr)
151 }
152 o.err = errors.Join(unlockErr, closeErr)
153 })
154 return o.err
155}