diff --git a/flock.go b/flock.go new file mode 100644 index 0000000..bbb7c8a --- /dev/null +++ b/flock.go @@ -0,0 +1,77 @@ +//go:build linux + +// The flock package provides a file-system mediated locking mechanism on linux +// using the `flock` system call. + +package flock + +import ( + "errors" + "os" + "syscall" +) + +var ErrLocked = errors.New("locked") + +// Lock gets an exclusive lock on the file at the given path. If the file +// doesn't exist, it's created. +func Lock(path string) (*os.File, error) { + return lock(path, syscall.LOCK_EX) +} + +// TryLock attempts to lock the file at path. It will return ErrLocked if the +// file is already locked. +func TryLock(path string) (*os.File, error) { + return lock(path, syscall.LOCK_EX|syscall.LOCK_NB) +} + +// LockFile is like Lock, but takes an *os.File. +func LockFile(f *os.File) error { + return lockFile(f, syscall.LOCK_EX) +} + +// TryLockFile is like TryLock, but takes an *os.File. +func TryLockFile(f *os.File) error { + return lockFile(f, syscall.LOCK_EX|syscall.LOCK_NB) +} + +func lockFile(f *os.File, flags int) error { + if err := flock(int(f.Fd()), flags); err != nil { + if flags&syscall.LOCK_NB != 0 && errors.Is(err, syscall.EAGAIN) { + return ErrLocked + } + return err + } + return nil +} + +func flock(fd int, how int) error { + _, _, e1 := syscall.Syscall(syscall.SYS_FLOCK, uintptr(fd), uintptr(how), 0) + if e1 != 0 { + return syscall.Errno(e1) + } + return nil +} + +func lock(path string, flags int) (*os.File, error) { + perm := os.O_CREATE | os.O_RDWR + f, err := os.OpenFile(path, perm, 0600) + if err != nil { + return nil, err + } + + err = lockFile(f, flags) + if err != nil { + f.Close() + f = nil + } + return f, err +} + +// Unlock releases the lock acquired via the Lock function. +func Unlock(f *os.File) error { + if err := flock(int(f.Fd()), syscall.LOCK_UN); err != nil { + return err + } + return f.Close() +} diff --git a/flock_test.go b/flock_test.go new file mode 100644 index 0000000..3249759 --- /dev/null +++ b/flock_test.go @@ -0,0 +1,111 @@ +package flock + +import ( + "errors" + "os" + "testing" + "time" +) + +func TestLock_basic(t *testing.T) { + const path = "/tmp/fsutil-test-lock" + + f, err := Lock(path) + if err != nil { + t.Fatal(err) + } + + // locked is closed once the goroutine acquires the lock. + locked := make(chan struct{}) + go func() { + f2, err := Lock(path) // blocks until f is unlocked + if err != nil { + t.Error(err) + return + } + close(locked) + Unlock(f2) + }() + + // Goroutine should be blocked while we hold the lock. + select { + case <-locked: + t.Fatal("goroutine acquired lock while we hold it") + case <-time.After(50 * time.Millisecond): + } + + Unlock(f) + + // Now the goroutine should unblock and acquire the lock. + select { + case <-locked: + case <-time.After(time.Second): + t.Fatal("goroutine never acquired lock after release") + } +} + +func TestLock_badPath(t *testing.T) { + _, err := Lock("./dne/file.lock") + if err == nil { + t.Fatal("expected error for bad path") + } +} + +func TestTryLock(t *testing.T) { + const path = "/tmp/fsutil-test-lock" + + f, err := TryLock(path) + if err != nil { + t.Fatal(err) + } + defer Unlock(f) + + // While f holds the lock, TryLock should return ErrLocked. + _, err = TryLock(path) + if !errors.Is(err, ErrLocked) { + t.Fatalf("expected ErrLocked, got %v", err) + } + + // After unlocking, TryLock should succeed. + if err := Unlock(f); err != nil { + t.Fatal(err) + } + + f2, err := TryLock(path) + if err != nil { + t.Fatalf("expected lock after unlock, got %v", err) + } + Unlock(f2) +} + +func TestTryLockFile(t *testing.T) { + const path = "/tmp/fsutil-test-lockfile" + + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + if err := TryLockFile(f); err != nil { + t.Fatal(err) + } + + f2, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + t.Fatal(err) + } + defer f2.Close() + + if err := TryLockFile(f2); !errors.Is(err, ErrLocked) { + t.Fatalf("expected ErrLocked, got %v", err) + } + + if err := Unlock(f); err != nil { + t.Fatal(err) + } + + if err := TryLockFile(f2); err != nil { + t.Fatalf("expected lock after unlock, got %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c6015ec --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.crumpington.com/lib/flock + +go 1.25.1