package datadir_test import ( "bufio" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/datadir" ) // holderEnv names the directory the re-executed test binary should // lock and hold. When it is unset the child test does nothing, so an // ordinary run is unaffected. const holderEnv = "WEBHOOKER_DATADIR_LOCK_HOLDER" // holderReadyPrefix labels the child's one-line report that it holds // the lock, so the parent can find it among the testing package's own // output on the same descriptor. const holderReadyPrefix = "DATADIR-LOCK-HELD " // holderReadyTimeout bounds the wait for the child to take the lock. // It only has to cover process start on a loaded shared host. const holderReadyTimeout = 60 * time.Second // holderHold is how long the child keeps the lock if nothing kills it. // A sleep rather than a bare block, so the runtime's deadlock detector // has a pending timer and the child cannot outlive a killed test run // by more than this. const holderHold = 10 * time.Minute // TestLockHolder is the child half of the two-process tests below. It // takes the lock on the directory named by holderEnv, reports the lock // file on standard output, and then holds it until it is killed. func TestLockHolder(t *testing.T) { t.Parallel() dir := os.Getenv(holderEnv) if dir == "" { return } lock, err := datadir.Acquire(dir) require.NoError(t, err) // Written to the descriptor directly: the parent reads fd 1, not // the testing package's buffered report. _, err = fmt.Fprintf( os.Stdout, "%s%s\n", holderReadyPrefix, lock.Path(), ) require.NoError(t, err) time.Sleep(holderHold) } // startHolder re-executes this test binary as a separate process that // takes and holds the lock on dir, and returns once that process // actually holds it. The child is killed when the test ends. func startHolder(t *testing.T, dir string) *exec.Cmd { t.Helper() //nolint:gosec // Re-executing this test binary, with a fixed arg. cmd := exec.CommandContext( t.Context(), os.Args[0], "-test.run", "^TestLockHolder$", ) cmd.Env = append(os.Environ(), holderEnv+"="+dir) cmd.Stderr = os.Stderr stdout, err := cmd.StdoutPipe() require.NoError(t, err) require.NoError(t, cmd.Start()) t.Cleanup(func() { _ = cmd.Process.Kill() _ = cmd.Wait() }) ready := make(chan string, 1) go func() { scanner := bufio.NewScanner(stdout) for scanner.Scan() { after, found := strings.CutPrefix( scanner.Text(), holderReadyPrefix, ) if found { ready <- after break } } close(ready) // Keep draining so the child never blocks on a full pipe. _, _ = io.Copy(io.Discard, stdout) }() select { case path, ok := <-ready: require.True( t, ok, "holder exited without taking the lock", ) require.Equal(t, filepath.Join(dir, datadir.LockFileName), path) case <-time.After(holderReadyTimeout): t.Fatal("timed out waiting for the holder to take the lock") } return cmd } // TestSecondInstanceRefused is the regression test for the duplicate // delivery this package exists to prevent: a real second process // pointed at a data directory a live process already holds must be // refused, with an error that names the directory. func TestSecondInstanceRefused(t *testing.T) { t.Parallel() dir := t.TempDir() startHolder(t, dir) lock, err := datadir.Acquire(dir) require.Error(t, err, "the second instance took the lock too") require.Nil(t, lock) require.ErrorIs( t, err, datadir.ErrLocked, "the refusal must be distinguishable from any other failure", ) assert.Contains( t, err.Error(), dir, "the refusal must name the directory it is about", ) } // TestRestartAfterHardKill is the other half of the regression: a // process killed with SIGKILL runs no cleanup and leaves its lock file // behind, and the next start must not be blocked by it. This is what a // pidfile would get wrong; the kernel drops a flock when the // descriptor closes, however the process died. func TestRestartAfterHardKill(t *testing.T) { t.Parallel() dir := t.TempDir() holder := startHolder(t, dir) require.NoError(t, holder.Process.Kill()) // Wait for the kill to have actually happened. Re-acquiring while // the corpse still holds a descriptor would be a race, and would // make this test pass or fail on scheduling. _ = holder.Wait() require.FileExists( t, filepath.Join(dir, datadir.LockFileName), "the stale lock file is what must not block the restart", ) lock, err := datadir.Acquire(dir) require.NoError( t, err, "a hard-killed instance must not block the next start", ) require.NoError(t, lock.Release()) } // TestSecondFdInSameProcessRefused pins the flock(2) property the // tests in cmd/webhooker rely on: descriptors are locked // independently, so a second acquisition is denied even when it comes // from the process that already holds the lock. func TestSecondFdInSameProcessRefused(t *testing.T) { t.Parallel() dir := t.TempDir() first, err := datadir.Acquire(dir) require.NoError(t, err) defer func() { _ = first.Release() }() _, err = datadir.Acquire(dir) require.ErrorIs(t, err, datadir.ErrLocked) } // TestReleaseAllowsReacquire covers the clean-shutdown path: the lock // is released on exit, so a restart is not blocked by the previous // run. func TestReleaseAllowsReacquire(t *testing.T) { t.Parallel() dir := t.TempDir() first, err := datadir.Acquire(dir) require.NoError(t, err) require.NoError(t, first.Release()) second, err := datadir.Acquire(dir) require.NoError(t, err) require.NoError(t, second.Release()) } // TestAcquireCreatesDataDir covers a first start against a DATA_DIR // that does not exist yet, which is the normal case for a fresh // deployment: the lock is taken before anything else creates it. func TestAcquireCreatesDataDir(t *testing.T) { t.Parallel() dir := filepath.Join(t.TempDir(), "nested", "data") lock, err := datadir.Acquire(dir) require.NoError(t, err) defer func() { _ = lock.Release() }() assert.Equal(t, dir, lock.Dir()) assert.FileExists(t, filepath.Join(dir, datadir.LockFileName)) } // TestAcquireEmptyDir rejects an empty directory rather than locking // the process's working directory. func TestAcquireEmptyDir(t *testing.T) { t.Parallel() _, err := datadir.Acquire("") require.ErrorIs(t, err, datadir.ErrNoDir) } // TestAcquireUnusableDir reports an unusable DATA_DIR clearly, naming // it, instead of failing later and deeper. func TestAcquireUnusableDir(t *testing.T) { t.Parallel() file := filepath.Join(t.TempDir(), "not-a-directory") require.NoError(t, os.WriteFile(file, nil, 0o600)) _, err := datadir.Acquire(file) require.Error(t, err) assert.Contains(t, err.Error(), file) }