check / check (push) Waiting to run
Acquire read vaultik.pid, checked whether that PID was alive, then wrote its own, so two writers started together could both pass the check and both run. The lock is now an flock on vaultik.pid, held while the file stays open; the kernel drops it when the process exits, so the stale-PID check is gone. Release empties the file instead of deleting it. Deleting it would let a process that opened the old file a moment earlier lock it while another creates and locks a new one. The new concurrent test fails against the old code only when the race is hit, not on every run; against the fix it cannot admit two callers. Model: opus-5-5
190 lines
4.0 KiB
Go
190 lines
4.0 KiB
Go
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())
|
|
}
|