diff options
Diffstat (limited to 'internal/lock')
| -rw-r--r-- | internal/lock/lock.go | 197 | ||||
| -rw-r--r-- | internal/lock/lock_test.go | 111 |
2 files changed, 208 insertions, 100 deletions
diff --git a/internal/lock/lock.go b/internal/lock/lock.go index 76a2b71..c59aab0 100644 --- a/internal/lock/lock.go +++ b/internal/lock/lock.go @@ -1,8 +1,21 @@ // 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. +// 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 ( @@ -10,7 +23,6 @@ import ( "errors" "fmt" "io/fs" - "math" "os" "path/filepath" "strconv" @@ -19,45 +31,32 @@ import ( "time" ) -// ErrHeld is returned by Acquire when the lock is already held by a running -// krino and wait is false. +// 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 file. The zero Lock holds nothing; Release on it, or -// on a nil *Lock, is a no-op. +// 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 - // 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 + f *os.File } -// 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 - 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. +// Acquire takes the lock at path. Parent directories are created as needed. // -// 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. +// 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) @@ -75,60 +74,83 @@ func Acquire(ctx context.Context, path string, wait bool) (*Lock, error) { } } -// tryAcquire makes one attempt at the lock: create it, or if it is held, -// decide whether the holder is stale and take it over. +// 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) } - - 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) + 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 } +} - pid, ok := readHolderPid(path) - if !ok || running(pid) { - return nil, ErrHeld +// 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 } - - // 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 + on, err := os.Stat(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil } - return nil, fmt.Errorf("lock %s: %w", path, err) + return false, err } - return &Lock{Path: path, held: true, TookOverStale: true}, nil + return os.SameFile(fi, on), 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 { +// 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 } - _, 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 + if _, err := f.Seek(0, 0); err != nil { + return err } - return nil + _, 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. 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. +// 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 { @@ -155,29 +177,36 @@ func parsePid(s string) (int, bool) { return n, true } -// running reports whether pid names a process that is currently running. -// A pid outside the kernel's 32-bit range names no process: kill(2) would -// cut it to its low bits and ask about another one. -func running(pid int) bool { - if pid <= 0 || pid > math.MaxInt32 { - return false - } - proc, err := os.FindProcess(pid) - if err != nil { - return false +// 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 proc.Signal(syscall.Signal(0)) == nil + return fmt.Errorf("%w (lock %s)", ErrHeld, path) } -// Release removes the lock file. Release on a Lock that was never acquired +// 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.held { + if l == nil || l.f == nil { 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) + 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 } diff --git a/internal/lock/lock_test.go b/internal/lock/lock_test.go index 04023aa..c756bb2 100644 --- a/internal/lock/lock_test.go +++ b/internal/lock/lock_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" ) @@ -106,32 +107,110 @@ func TestAcquireRespectsContextCancellation(t *testing.T) { } } -// TestStaleLockIsTakenOver: a lock naming a pid that is not running must not -// wedge krino - a machine that lost power mid-run would need manual cleanup. -func TestStaleLockIsTakenOver(t *testing.T) { +// TestALockFileWithoutAHolderIsFree: a machine that lost power mid-run +// leaves the lock file behind, but nothing holding it. The text in that +// file is for a human; it must not wedge the next run, whatever it says. +func TestALockFileWithoutAHolderIsFree(t *testing.T) { path := filepath.Join(t.TempDir(), "dl.lock") - if err := os.WriteFile(path, []byte("pid 4294967000\nstarted 2020-01-01T00:00:00Z\n"), 0o644); err != nil { + if err := os.WriteFile(path, []byte("pid 1\nstarted 2020-01-01T00:00:00Z\n"), 0o644); err != nil { t.Fatal(err) } l, err := Acquire(context.Background(), path, false) if err != nil { - t.Fatalf("a stale lock blocked Acquire: %v", err) + t.Fatalf("a lock file nobody holds blocked Acquire: %v", err) } defer l.Release() - if !l.TookOverStale { - t.Error("the takeover was not reported to the caller") + if b, _ := os.ReadFile(path); !strings.Contains(string(b), fmt.Sprint(os.Getpid())) { + t.Errorf("the lock file still names the old holder: %q", b) + } +} + +// TestALockDiesWithItsProcess: the kernel drops an flock when the last +// descriptor closes, however the process ended. A holder that vanishes +// without unlinking - a crash, a kill -9 - must leave the directory usable +// at once. This is the reason the lock is the kernel's rather than a pid +// written into a file and believed. +func TestALockDiesWithItsProcess(t *testing.T) { + path := filepath.Join(t.TempDir(), "dl.lock") + l, err := Acquire(context.Background(), path, false) + if err != nil { + t.Fatal(err) + } + if _, err := Acquire(context.Background(), path, false); !errors.Is(err, ErrHeld) { + t.Fatalf("while held, Acquire err = %v, want ErrHeld", err) + } + // Close the descriptor without unlinking, as a crash would leave it. + if err := l.f.Close(); err != nil { + t.Fatal(err) + } + l.f = nil + if _, err := os.Stat(path); err != nil { + t.Fatalf("the lock file should still be there: %v", err) + } + second, err := Acquire(context.Background(), path, false) + if err != nil { + t.Fatalf("a lock file left by a dead holder still blocks Acquire: %v", err) } + second.Release() } -// TestRunningRejectsPidsBeyondTheKernelsRange: a pid wider than 32 bits in a -// damaged lock file is cut to its low bits by kill(2), so without a range -// check it names some other process - here this very one - and a stale lock -// would never be taken over. -func TestRunningRejectsPidsBeyondTheKernelsRange(t *testing.T) { - if running(os.Getpid() + 1<<32) { - t.Error("running(pid + 2^32) is true: the pid was truncated to this process") +// TestOnlyOneAcquireWinsAtOnce: the promise is that a directory is locked +// while krino works in it. Judging a lock stale and recreating it cannot be +// made atomic, so two runs reaching that conclusion together could both +// believe they held it; the kernel's lock has one winner by construction. +func TestOnlyOneAcquireWinsAtOnce(t *testing.T) { + path := filepath.Join(t.TempDir(), "dl.lock") + for round := 0; round < 20; round++ { + const n = 8 + start := make(chan struct{}) + var mu sync.Mutex + var won []*Lock + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + if l, err := Acquire(context.Background(), path, false); err == nil { + mu.Lock() + won = append(won, l) + mu.Unlock() + } + }() + } + close(start) + wg.Wait() + if len(won) != 1 { + t.Fatalf("round %d: %d callers hold the same lock at once, want 1", round, len(won)) + } + for _, l := range won { + if err := l.Release(); err != nil { + t.Fatal(err) + } + } + } +} + +// TestHeldErrorNamesTheLockAndHolder: "another krino is working in this +// directory" leaves nothing to act on when the holder is a pid that was +// reused after a crash - the lock is then live for ever as far as krino can +// tell. The error must name the file to remove and the pid it blames. +func TestHeldErrorNamesTheLockAndHolder(t *testing.T) { + path := filepath.Join(t.TempDir(), "dl.lock") + first, err := Acquire(context.Background(), path, false) + if err != nil { + t.Fatal(err) + } + defer first.Release() + + _, err = Acquire(context.Background(), path, false) + if !errors.Is(err, ErrHeld) { + t.Fatalf("second Acquire err = %v, want ErrHeld", err) + } + if got := err.Error(); !strings.Contains(got, path) { + t.Errorf("the error does not name the lock file %q: %s", path, got) } - if !running(os.Getpid()) { - t.Error("running(own pid) is false") + if got := err.Error(); !strings.Contains(got, fmt.Sprint(os.Getpid())) { + t.Errorf("the error does not name the holder's pid %d: %s", os.Getpid(), got) } } |
