diff options
Diffstat (limited to 'internal/lock')
| -rw-r--r-- | internal/lock/lock.go | 181 | ||||
| -rw-r--r-- | internal/lock/lock_test.go | 124 |
2 files changed, 305 insertions, 0 deletions
diff --git a/internal/lock/lock.go b/internal/lock/lock.go new file mode 100644 index 0000000..462b26e --- /dev/null +++ b/internal/lock/lock.go @@ -0,0 +1,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 +} diff --git a/internal/lock/lock_test.go b/internal/lock/lock_test.go new file mode 100644 index 0000000..fca4d76 --- /dev/null +++ b/internal/lock/lock_test.go @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package lock + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestAcquireAndRelease(t *testing.T) { + path := filepath.Join(t.TempDir(), "state", "dl.lock") + l, err := Acquire(context.Background(), path, false) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("lock file missing: %v", err) + } + if b, _ := os.ReadFile(path); !strings.Contains(string(b), fmt.Sprint(os.Getpid())) { + t.Errorf("lock file does not name the holder's pid: %q", b) + } + if err := l.Release(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("Release left the lock file behind") + } + if err := l.Release(); err != nil { + t.Errorf("a second Release must be harmless: %v", err) + } +} + +func TestAcquireFailsWhenHeldAndNotWaiting(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() + if _, err := Acquire(context.Background(), path, false); !errors.Is(err, ErrHeld) { + t.Fatalf("second Acquire err = %v, want ErrHeld", err) + } +} + +func TestAcquireWaitsUntilReleased(t *testing.T) { + path := filepath.Join(t.TempDir(), "dl.lock") + first, err := Acquire(context.Background(), path, false) + if err != nil { + t.Fatal(err) + } + go func() { + time.Sleep(150 * time.Millisecond) + first.Release() + }() + start := time.Now() + second, err := Acquire(context.Background(), path, true) + if err != nil { + t.Fatalf("waiting Acquire failed: %v", err) + } + defer second.Release() + if time.Since(start) < 100*time.Millisecond { + t.Error("Acquire returned before the first holder released") + } +} + +// TestAcquireRespectsContextCancellation is fix round 2026-09-12/item 3: a +// waiting Acquire must not ignore an interrupt - a cancelled ctx must return +// promptly with ctx.Err(), not poll forever. The unfixed code HANGS rather +// than fails here, so the wait for Acquire's result is itself bounded with +// its own hard timeout: a regression must fail this test, not hang the +// whole suite. +func TestAcquireRespectsContextCancellation(t *testing.T) { + path := filepath.Join(t.TempDir(), "dl.lock") + held, err := Acquire(context.Background(), path, false) + if err != nil { + t.Fatal(err) + } + defer held.Release() + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + result := make(chan error, 1) + start := time.Now() + go func() { + _, err := Acquire(ctx, path, true) + result <- err + }() + + select { + case err := <-result: + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Acquire err = %v, want context.DeadlineExceeded", err) + } + if elapsed := time.Since(start); elapsed > 500*time.Millisecond { + t.Errorf("Acquire took %v to notice cancellation, want well under a second", elapsed) + } + case <-time.After(2 * time.Second): + t.Fatal("Acquire ignored context cancellation and is still blocked") + } +} + +// 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) { + 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 { + t.Fatal(err) + } + l, err := Acquire(context.Background(), path, false) + if err != nil { + t.Fatalf("a stale lock blocked Acquire: %v", err) + } + defer l.Release() + if !l.TookOverStale { + t.Error("the takeover was not reported to the caller") + } +} |
