1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
|
// SPDX-License-Identifier: GPL-3.0-or-later
// Package lock keeps two krino runs from acting on the same directory at
// once: Acquire takes an exclusive lock on a lock file, waiting or failing
// depending on the caller, and Release lets it go. See docs/design.md §3,
// §11.
//
// The lock is the kernel's, taken with flock(2) on an open file descriptor,
// not a pid written into a file and believed. The difference is what
// happens when a run dies: the kernel drops the lock when the last
// descriptor closes, however the process ended, so there is no such thing
// as a stale krino lock and nothing has to guess whether a recorded pid is
// still the run that wrote it. A pid whose number has since been reused
// would make that guess wrong for ever, and two runs reaching the same
// "this one is stale" conclusion at the same moment could both take the
// lock over.
//
// The file itself still names the holder, for a human reading it.
package lock
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
// ErrHeld is returned by Acquire when another krino holds the lock and wait
// is false.
var ErrHeld = errors.New("another krino is working in this directory")
// pollInterval is how often a waiting Acquire retries the lock.
const pollInterval = 100 * time.Millisecond
// Lock is a held lock: the open descriptor whose flock the kernel is
// keeping for us. The zero Lock holds nothing; Release on it, or on a nil
// *Lock, is a no-op.
type Lock struct {
// Path is where the lock file lives.
Path string
f *os.File
}
// Acquire takes the lock at path. Parent directories are created as needed.
//
// When wait is false, Acquire fails immediately with ErrHeld if another
// process holds it, so a cron job never piles up behind a stuck run. When
// wait is true, Acquire polls every 100ms, with no fixed timeout - but it
// does not poll forever regardless of ctx: a cancelled or expired ctx makes
// a waiting Acquire return ctx.Err() promptly, so a run blocked on a lock
// still notices Ctrl-C. ctx is not consulted when wait is false or the lock
// is free on the first try.
func Acquire(ctx context.Context, path string, wait bool) (*Lock, error) {
for {
l, err := tryAcquire(path)
if err == nil {
return l, nil
}
if !errors.Is(err, ErrHeld) || !wait {
return nil, err
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(pollInterval):
}
}
}
// tryAcquire makes one attempt: open the lock file, take the kernel's lock
// on it, and make sure the file we locked is still the one at path.
//
// That last check is the one subtlety. Release unlinks the file before
// closing it, so a process that opened the file just beforehand can end up
// holding a lock on an inode that no longer has a name, while another
// process creates a fresh file at path and locks that. Comparing the
// descriptor's identity with what path now names catches exactly that case,
// and the retry then races for the new file like everyone else.
func tryAcquire(path string) (*Lock, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("lock %s: %w", path, err)
}
for {
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return nil, fmt.Errorf("lock %s: %w", path, err)
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
f.Close()
if errors.Is(err, syscall.EWOULDBLOCK) {
return nil, heldBy(path)
}
return nil, fmt.Errorf("lock %s: %w", path, err)
}
same, err := stillAtPath(f, path)
if err != nil {
f.Close()
return nil, fmt.Errorf("lock %s: %w", path, err)
}
if !same {
// The holder unlinked it between our open and our flock.
f.Close()
continue
}
if err := writeHolder(f); err != nil {
f.Close()
return nil, fmt.Errorf("lock %s: %w", path, err)
}
return &Lock{Path: path, f: f}, nil
}
}
// stillAtPath reports whether f is the file path names now. A path that has
// gone missing is not an error here: it means the previous holder unlinked
// it, which the caller handles by retrying.
func stillAtPath(f *os.File, path string) (bool, error) {
fi, err := f.Stat()
if err != nil {
return false, err
}
on, err := os.Stat(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return false, nil
}
return false, err
}
return os.SameFile(fi, on), nil
}
// writeHolder records who holds the lock, for a human who finds the file
// and wants to know which process to look at. Nothing reads it back: the
// kernel, not this text, is what keeps two runs apart.
func writeHolder(f *os.File) error {
if err := f.Truncate(0); err != nil {
return err
}
if _, err := f.Seek(0, 0); err != nil {
return err
}
_, err := fmt.Fprintf(f, "pid %d\nstarted %s\n", os.Getpid(), time.Now().UTC().Format(time.RFC3339))
return err
}
// readHolderPid reads the pid recorded in the lock file at path, for the
// message only. ok is false when the file cannot be read or names none.
func readHolderPid(path string) (pid int, ok bool) {
b, err := os.ReadFile(path)
if err != nil {
return 0, false
}
return parsePid(string(b))
}
// parsePid extracts the pid from a lock file's "pid N" line.
func parsePid(s string) (int, bool) {
const prefix = "pid "
i := strings.Index(s, prefix)
if i < 0 {
return 0, false
}
s = s[i+len(prefix):]
if j := strings.IndexAny(s, "\n\r \t"); j >= 0 {
s = s[:j]
}
n, err := strconv.Atoi(s)
if err != nil || n <= 0 {
return 0, false
}
return n, true
}
// heldBy is ErrHeld naming the file, so a user who wants to know what is
// holding a directory has somewhere to look.
func heldBy(path string) error {
if pid, ok := readHolderPid(path); ok {
return fmt.Errorf("%w (pid %d; lock %s)", ErrHeld, pid, path)
}
return fmt.Errorf("%w (lock %s)", ErrHeld, path)
}
// Release unlinks the lock file and closes the descriptor, which is what
// drops the kernel's lock. Unlinking first means a process waiting on this
// file sees it disappear and retries for the new one rather than holding a
// lock on an inode with no name. Release on a Lock that was never acquired
// (the zero Lock, a nil *Lock, or one already released) is harmless.
func (l *Lock) Release() error {
if l == nil || l.f == nil {
return nil
}
f := l.f
l.f = nil
rmErr := os.Remove(l.Path)
if rmErr != nil && errors.Is(rmErr, fs.ErrNotExist) {
rmErr = nil
}
closeErr := f.Close()
if rmErr != nil {
return fmt.Errorf("release lock %s: %w", l.Path, rmErr)
}
if closeErr != nil {
return fmt.Errorf("release lock %s: %w", l.Path, closeErr)
}
return nil
}
|