// 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.Add(1) go func() { defer wg.Done() 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 := 0; i < numGoroutines; i++ { wg.Add(1) go func(id int) { defer wg.Done() successes := 0 for j := 0; j < iterations; j++ { f, err := os.OpenFile(lockFile, os.O_RDWR, 0644) if err != nil { t.Errorf("Goroutine %d: Failed to open file: %v", id, 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 }(i) } 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) }