Files
opentf/internal/flock/filesystem_lock_test.go
Martin Atkins b2c20bda29 flock: Partially-manual modernization of waitgroup usage
The "go fix" modernizer for using wg.Go instead of explicit wg.Add/wg.Done
only works when the goroutine function has no arguments, so it didn't match
here where this code was still using an old trick to ensure that each
goroutine would capture a different value of "i".

But that old trick isn't needed anymore because modern Go already ensures
that each iteration of the loop has an independent "i", so I made a small
change to remove the argument and just let the closure capture "i" from
the outer loop, and then "go fix" was able to complete the rewrite to
use wg.Go here.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
2026-03-17 15:25:09 -07:00

359 lines
9.6 KiB
Go

// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package flock
import (
"context"
"os"
"path/filepath"
"runtime"
"sync"
"testing"
"time"
)
func TestLock_BasicFunctionality(t *testing.T) {
// Tests Lock and Unlock for a single file
tmpDir := t.TempDir()
lockFile := filepath.Join(tmpDir, "test.lock")
// Create test file
f, err := os.Create(lockFile)
if err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
defer f.Close()
// Test acquiring lock
err = Lock(f)
if err != nil {
t.Fatalf("Failed to acquire lock: %v", err)
}
// Test unlocking
err = Unlock(f)
if err != nil {
t.Fatalf("Failed to unlock: %v", err)
}
}
func TestLock_Contention(t *testing.T) {
// Tests back-to-back Lock calls on a single file
tmpDir := t.TempDir()
lockFile := filepath.Join(tmpDir, "contention.lock")
// Create test file
f1, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0644)
if err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
defer f1.Close()
// Open same file with different handle to simulate multiple accessors
f2, err := os.OpenFile(lockFile, os.O_RDWR, 0644)
if err != nil {
t.Fatalf("Failed to open test file: %v", err)
}
defer f2.Close()
// First lock should always succeed regardless of OS
err = Lock(f1)
if err != nil {
t.Fatalf("First lock should succeed: %v", err)
}
// Second lock behavior is OS-dependent due to different locking semantics:
//
// UNIX/Linux/macOS (fcntl-based):
// - fcntl locks are process-scoped, not file-descriptor-scoped
// - Multiple file descriptors in the same process can "share" the lock
// - The lock prevents OTHER PROCESSES from acquiring it, not other FDs in same process
// - This is POSIX-compliant behavior and intentional for flexibility
// - Real contention only occurs between different processes
//
// Windows (LockFileEx-based):
// - Locks are more granular and can be per-handle depending on flags
// - LOCKFILE_EXCLUSIVE_LOCK with different handles may behave differently
// - Behavior can vary based on how the file was opened and lock flags used
//
// Network File Systems (NFS/CIFS):
// - Lock behavior depends on server implementation and mount options
// - Some NFS versions don't support fcntl locks properly
// - CIFS locking can have different semantics than local filesystems
err = Lock(f2)
if err != nil {
// Case 1: True contention detected (expected on some systems or configurations)
t.Logf("Second lock failed as expected - true contention detected: %v", err)
// Verify that unlocking the first handle allows the second to succeed
// We are likely in a Windows OS now and our Unlock implementation for Windows is a no-op
// As commented there, we need to use Close
err = f1.Close()
if err != nil {
t.Fatalf("Failed to close the first file: %v", err)
}
// After releasing first lock, second should be able to acquire it
err = Lock(f2)
if err != nil {
t.Fatalf("Second lock should succeed after first unlock: %v", err)
}
err = Unlock(f2)
if err != nil {
t.Fatalf("Failed to unlock second handle: %v", err)
}
} else {
// Case 2: Same-process lock sharing (common on POSIX systems)
t.Logf("Second lock succeeded - same process can hold multiple locks (POSIX behavior)")
// Both handles now "hold" the lock from the OS perspective
// This is correct behavior for fcntl locks - they're process-scoped
// The actual protection is against OTHER PROCESSES, not other handles in same process
// Clean up both locks (order doesn't matter for same-process locks)
err = Unlock(f1)
if err != nil {
t.Logf("Unlock f1 returned: %v (may be no-op on some systems)", err)
}
err = Unlock(f2)
if err != nil {
t.Logf("Unlock f2 returned: %v (may be no-op on some systems)", err)
}
}
}
func TestLockBlocking_Success(t *testing.T) {
// Tests LockBlocking and Unlock on a single file
tmpDir := t.TempDir()
lockFile := filepath.Join(tmpDir, "blocking.lock")
// Create test file
f, err := os.Create(lockFile)
if err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
defer f.Close()
ctx := context.Background()
// Test blocking lock acquisition
err = LockBlocking(ctx, f)
if err != nil {
t.Fatalf("Failed to acquire blocking lock: %v", err)
}
// Test unlocking
err = Unlock(f)
if err != nil {
t.Fatalf("Failed to unlock: %v", err)
}
}
func TestLockBlocking_Cancellation(t *testing.T) {
// Tests cancellation of LockBlocking on a single file while a Lock is already in place
// Doesn't really test anything in POSIX systems since the same process can hold multiple locks
// on the same file
tmpDir := t.TempDir()
lockFile := filepath.Join(tmpDir, "cancel.lock")
// Create test file
f1, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0644)
if err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
defer f1.Close()
f2, err := os.OpenFile(lockFile, os.O_RDWR, 0644)
if err != nil {
t.Fatalf("Failed to open test file with a second handler: %v", err)
}
defer f2.Close()
// First, test if this system supports real contention between same-process handles
err = Lock(f1)
if err != nil {
t.Fatalf("Failed to acquire first lock: %v", err)
}
// Test if second lock fails
testErr := Lock(f2)
if testErr == nil {
// Same process can acquire multiple locks - POSIX behavior
t.Logf("System allows same-process multiple locks (POSIX fcntl behavior)")
t.Logf("Skipping cancellation test - no contention between same-process handles")
// Not checking return value here since any problem would already be highlighted by
// TestLock_BasicFunctionality
_ = Unlock(f1)
_ = Unlock(f2)
t.Skip("No contention between same-process handles on this system")
return
}
// We have real contention - Windows behaviour, so test cancellation
t.Logf("System supports real contention between same-process handles")
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
// Attempt blocking lock with second handle - should be cancelled
start := time.Now()
err = LockBlocking(ctx, f2)
elapsed := time.Since(start)
// Clean up first lock
_ = Unlock(f1)
// Should fail due to timeout or cancellation
if err == nil {
t.Fatal("Expected blocking lock to be cancelled, but it succeeded")
}
// Check cancellation behavior
if err == context.DeadlineExceeded || err == context.Canceled {
t.Logf("Lock was properly cancelled: %v (took %v)", err, elapsed)
// Should have been cancelled within reasonable time
if elapsed > 200*time.Millisecond {
t.Fatalf("Cancellation took too long: %v", elapsed)
}
} else {
t.Fatalf("Expected timeout/cancellation error, got: %v", err)
}
}
func TestLockBlocking_EventualSuccess(t *testing.T) {
// Tests eventual success of LockBlocking on a single file while a Lock is
// already in place which is then released
// Doesn't really test anything in POSIX systems since the same process can hold multiple locks
// on the same file
tmpDir := t.TempDir()
lockFile := filepath.Join(tmpDir, "eventual.lock")
// Create test file
f1, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0644)
if err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
defer f1.Close()
f2, err := os.OpenFile(lockFile, os.O_RDWR, 0644)
if err != nil {
t.Fatalf("Failed to open test file with a second handler: %v", err)
}
defer f2.Close()
// Acquire lock with first handle
err = Lock(f1)
if err != nil {
t.Fatalf("Failed to acquire first lock: %v", err)
}
var wg sync.WaitGroup
var lockErr error
// Start blocking lock in goroutine
wg.Go(func() {
ctx := context.Background()
lockErr = LockBlocking(ctx, f2)
})
// Release first lock after short delay
time.Sleep(50 * time.Millisecond)
if runtime.GOOS == "windows" {
err = f1.Close()
} else {
err = Unlock(f1)
}
if err != nil {
t.Fatalf("Failed to unlock first: %v", err)
}
// Wait for blocking lock to succeed
wg.Wait()
if lockErr != nil {
t.Fatalf("Blocking lock should have succeeded: %v", lockErr)
}
// Clean up
err = Unlock(f2)
if err != nil {
t.Fatalf("Failed to unlock second: %v", err)
}
}
func TestConcurrentLocking(t *testing.T) {
// Tests multiple goroutines simultaneously trying to acquire locks on a single file
tmpDir := t.TempDir()
lockFile := filepath.Join(tmpDir, "concurrent.lock")
const numGoroutines = 10
const iterations = 5
// Create test file
testFile, err := os.Create(lockFile)
if err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
testFile.Close()
var wg sync.WaitGroup
successCount := make(chan int, numGoroutines)
// Launch multiple goroutines trying to acquire locks
for i := range numGoroutines {
wg.Go(func() {
successes := 0
for range iterations {
f, err := os.OpenFile(lockFile, os.O_RDWR, 0644)
if err != nil {
t.Errorf("Goroutine %d: Failed to open file: %v", i, err)
continue
}
err = Lock(f)
if err == nil {
successes++
// Hold lock briefly
time.Sleep(1 * time.Millisecond)
_ = Unlock(f)
}
f.Close()
// Brief pause between attempts
time.Sleep(1 * time.Millisecond)
}
successCount <- successes
})
}
wg.Wait()
close(successCount)
// Count total successes
totalSuccesses := 0
for count := range successCount {
totalSuccesses += count
}
// Should have some successes, but not necessarily all attempts
if totalSuccesses == 0 {
t.Fatal("No goroutine managed to acquire any locks")
}
t.Logf("Total successful lock acquisitions: %d out of %d attempts",
totalSuccesses, numGoroutines*iterations)
}