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
|
// 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 file, waiting or failing depending
// on the caller, and Release lets it go. See docs/design.md §3, §11.
package lock
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
// ErrHeld is returned by Acquire when the lock is already held by a running
// krino 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 file. 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
// TookOverStale reports whether Acquire found a lock naming a pid that
// was no longer running, and took the lock over. The caller should
// mention this rather than stay silent about it.
TookOverStale bool
held bool
}
// Acquire takes the lock at path: an O_CREATE|O_EXCL file naming the
// holder's pid and start time, so a human can see who holds it. Parent
// directories are created as needed.
//
// When wait is false, Acquire fails immediately with ErrHeld if the lock is
// already held, 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 instead of ignoring it (fix
// round 2026-09-12/item 3 - a run blocked waiting for a held lock must
// still notice Ctrl-C). ctx is not consulted at all when wait is false or
// the lock is free on the first try, so -y's non-waiting callers are
// unaffected.
//
// A lock naming a pid that is not running is stale — the machine may have
// lost power mid-run. Acquire removes a stale lock and retries the O_EXCL
// create once; if that retry also loses, another process has reached the
// same conclusion first and Acquire treats the lock as held. A takeover is
// reported via the returned Lock's TookOverStale field.
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 at the lock: create it, or if it is held,
// decide whether the holder is stale and take it over.
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)
}
if err := create(path); err == nil {
return &Lock{Path: path, held: true}, nil
} else if !errors.Is(err, fs.ErrExist) {
return nil, fmt.Errorf("lock %s: %w", path, err)
}
pid, ok := readHolderPid(path)
if !ok || running(pid) {
return nil, ErrHeld
}
// Stale: the recorded pid is not running. Take the lock over by
// removing it and retrying the create once. If that retry also loses,
// another process beat us to the same conclusion — treat it as held.
os.Remove(path)
if err := create(path); err != nil {
if errors.Is(err, fs.ErrExist) {
return nil, ErrHeld
}
return nil, fmt.Errorf("lock %s: %w", path, err)
}
return &Lock{Path: path, held: true, TookOverStale: true}, nil
}
// create makes path with O_CREATE|O_EXCL and writes the holder's pid and
// start time into it. If the write or close fails after the file was
// created, the file is removed so no half-written lock is left behind.
func create(path string) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
return err
}
_, werr := fmt.Fprintf(f, "pid %d\nstarted %s\n", os.Getpid(), time.Now().UTC().Format(time.RFC3339))
cerr := f.Close()
if werr != nil || cerr != nil {
os.Remove(path)
if werr != nil {
return werr
}
return cerr
}
return nil
}
// readHolderPid reads the pid recorded in the lock file at path. ok is
// false when the file cannot be read or does not name a pid — in which
// case the caller must not treat the lock as stale.
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
}
// running reports whether pid names a process that is currently running.
func running(pid int) bool {
if pid <= 0 {
return false
}
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
return proc.Signal(syscall.Signal(0)) == nil
}
// Release removes the lock file. 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.held {
return nil
}
l.held = false
if err := os.Remove(l.Path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("release lock %s: %w", l.Path, err)
}
return nil
}
|