// SPDX-License-Identifier: GPL-3.0-or-later package lock import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "sync" "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: a waiting Acquire must not // ignore an interrupt - a cancelled ctx must return promptly with // ctx.Err(), not poll forever. A regression here would HANG rather than // fail, 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") } } // 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 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 lock file nobody holds blocked Acquire: %v", err) } defer l.Release() 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() } // 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 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) } }