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 := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
44 if err != nil {
45 return nil, fmt.Errorf("lifecycle: opening %s: %w", path, 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// Acquire waits until lifecycle ownership is available or ctx is cancelled.
58// Only lock contention is retried; filesystem and lock errors fail closed.
59func Acquire(ctx context.Context, runtimeDir string, retry time.Duration) (*Owner, error) {
60 if retry <= 0 {
61 retry = time.Millisecond
62 }
63 for {
64 if err := ctx.Err(); err != nil {
65 return nil, err
66 }
67 owner, err := TryAcquire(runtimeDir)
68 if err == nil {
69 return owner, nil
70 }
71 if !errors.Is(err, ErrBusy) {
72 return nil, err
73 }
74
75 timer := time.NewTimer(retry)
76 select {
77 case <-ctx.Done():
78 timer.Stop()
79 return nil, ctx.Err()
80 case <-timer.C:
81 }
82 }
83}
84
85// Release unlocks and closes the owner exactly once.
86func (o *Owner) Release() error {
87 if o == nil {
88 return nil
89 }
90 o.once.Do(func() {
91 if o.file == nil {
92 return
93 }
94 unlockErr := syscall.Flock(int(o.file.Fd()), syscall.LOCK_UN)
95 closeErr := o.file.Close()
96 if unlockErr != nil {
97 unlockErr = fmt.Errorf("lifecycle: unlocking %s: %w", o.path, unlockErr)
98 }
99 if closeErr != nil {
100 closeErr = fmt.Errorf("lifecycle: closing %s: %w", o.path, closeErr)
101 }
102 o.err = errors.Join(unlockErr, closeErr)
103 })
104 return o.err
105}