package pidlock_test import ( "os" "path/filepath" "strconv" "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/vaultik/internal/pidlock" ) func TestAcquireAndRelease(t *testing.T) { t.Parallel() tmpDir := t.TempDir() // Acquire lock lock, err := pidlock.Acquire(tmpDir) require.NoError(t, err) require.NotNil(t, lock) // Verify PID file exists with our PID pidPath := filepath.Join(tmpDir, "vaultik.pid") data, err := os.ReadFile(pidPath) //nolint:gosec // G304: test's own temp file require.NoError(t, err) pid, err := strconv.Atoi(string(data)) require.NoError(t, err) assert.Equal(t, os.Getpid(), pid) // Release lock err = lock.Release() require.NoError(t, err) // Verify PID file is empty data, err = os.ReadFile(pidPath) //nolint:gosec // G304: test's own temp file require.NoError(t, err) assert.Empty(t, data) } func TestAcquireBlocksSecondInstance(t *testing.T) { t.Parallel() tmpDir := t.TempDir() // Acquire first lock lock1, err := pidlock.Acquire(tmpDir) require.NoError(t, err) require.NotNil(t, lock1) defer func() { _ = lock1.Release() }() // Try to acquire second lock - should fail lock2, err := pidlock.Acquire(tmpDir) require.ErrorIs(t, err, pidlock.ErrAlreadyRunning) assert.Nil(t, lock2) // Once the first lock is released, the next Acquire succeeds require.NoError(t, lock1.Release()) lock3, err := pidlock.Acquire(tmpDir) require.NoError(t, err) require.NoError(t, lock3.Release()) } // TestConcurrentAcquireAdmitsOne starts many Acquire calls at the same // moment, as two cron entries firing together would, and checks that // exactly one of them gets the lock. func TestConcurrentAcquireAdmitsOne(t *testing.T) { t.Parallel() const callers = 50 tmpDir := t.TempDir() start := make(chan struct{}) var ( mu sync.Mutex acquired []*pidlock.Lock failures []error wg sync.WaitGroup ) for range callers { wg.Go(func() { <-start lock, err := pidlock.Acquire(tmpDir) mu.Lock() defer mu.Unlock() if err != nil { failures = append(failures, err) return } acquired = append(acquired, lock) }) } close(start) wg.Wait() for _, lock := range acquired { require.NoError(t, lock.Release()) } assert.Len(t, acquired, 1, "exactly one caller should hold the lock") for _, err := range failures { require.ErrorIs(t, err, pidlock.ErrAlreadyRunning) } } func TestAcquireWithStaleLock(t *testing.T) { t.Parallel() tmpDir := t.TempDir() // Write a stale PID file (PID that doesn't exist) stalePID := 999999999 // Unlikely to be a real process pidPath := filepath.Join(tmpDir, "vaultik.pid") err := os.WriteFile(pidPath, []byte(strconv.Itoa(stalePID)), 0600) require.NoError(t, err) // Should be able to acquire lock (stale lock is cleaned up) lock, err := pidlock.Acquire(tmpDir) require.NoError(t, err) require.NotNil(t, lock) defer func() { _ = lock.Release() }() // Verify our PID is now in the file data, err := os.ReadFile(pidPath) //nolint:gosec // G304: test's own temp file require.NoError(t, err) pid, err := strconv.Atoi(string(data)) require.NoError(t, err) assert.Equal(t, os.Getpid(), pid) } func TestReleaseIsIdempotent(t *testing.T) { t.Parallel() tmpDir := t.TempDir() lock, err := pidlock.Acquire(tmpDir) require.NoError(t, err) // Release multiple times - should not error err = lock.Release() require.NoError(t, err) err = lock.Release() require.NoError(t, err) } func TestReleaseNilLock(t *testing.T) { t.Parallel() var lock *pidlock.Lock err := lock.Release() require.NoError(t, err) } func TestAcquireCreatesDirectory(t *testing.T) { t.Parallel() tmpDir := t.TempDir() nestedDir := filepath.Join(tmpDir, "nested", "dir") lock, err := pidlock.Acquire(nestedDir) require.NoError(t, err) require.NotNil(t, lock) defer func() { _ = lock.Release() }() // Verify directory was created info, err := os.Stat(nestedDir) require.NoError(t, err) assert.True(t, info.IsDir()) }