From 1c62410a06e7a018db837efbfd5640540ceaf41c Mon Sep 17 00:00:00 2001 From: typebasedio Date: Tue, 21 Apr 2026 17:01:36 -0400 Subject: [PATCH] feat(v0.4): add WithLock helper that warns-and-skips on timeout --- internal/lockfile/writelock.go | 17 ++++++++++ internal/lockfile/writelock_test.go | 49 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/internal/lockfile/writelock.go b/internal/lockfile/writelock.go index aea3acd..f0ef71f 100644 --- a/internal/lockfile/writelock.go +++ b/internal/lockfile/writelock.go @@ -52,3 +52,20 @@ func Acquire(lockPath string, timeout, staleAfter time.Duration) (func(), error) } } } + +// WithLock acquires the lock at lockPath, runs fn while holding it, and +// releases the lock. If the lock cannot be acquired within timeout, fn is +// NOT called and (skipped=true, err=nil) is returned — this matches the +// ctask "warn and skip, never hang" contract for metadata writes. +// Any error returned by fn is surfaced to the caller with skipped=false. +func WithLock(lockPath string, timeout, staleAfter time.Duration, fn func() error) (skipped bool, err error) { + release, err := Acquire(lockPath, timeout, staleAfter) + if err != nil { + if errors.Is(err, ErrTimeout) { + return true, nil + } + return false, err + } + defer release() + return false, fn() +} diff --git a/internal/lockfile/writelock_test.go b/internal/lockfile/writelock_test.go index 5ea91e0..441f440 100644 --- a/internal/lockfile/writelock_test.go +++ b/internal/lockfile/writelock_test.go @@ -100,3 +100,52 @@ func TestStaleLockIsRemoved(t *testing.T) { } defer release() } + +func TestWithLockRunsFunction(t *testing.T) { + dir := t.TempDir() + lockPath := filepath.Join(dir, "write.lock") + + called := false + skipped, err := WithLock(lockPath, 1*time.Second, 10*time.Second, func() error { + called = true + return nil + }) + if err != nil { + t.Fatalf("WithLock: %v", err) + } + if skipped { + t.Error("should not have skipped on successful acquire") + } + if !called { + t.Error("fn was not called") + } + if _, err := os.Stat(lockPath); !os.IsNotExist(err) { + t.Errorf("lock should be released after WithLock, got err=%v", err) + } +} + +func TestWithLockSkipsOnTimeout(t *testing.T) { + dir := t.TempDir() + lockPath := filepath.Join(dir, "write.lock") + + r1, err := Acquire(lockPath, 1*time.Second, 10*time.Second) + if err != nil { + t.Fatalf("first Acquire: %v", err) + } + defer r1() + + called := false + skipped, err := WithLock(lockPath, 200*time.Millisecond, 10*time.Second, func() error { + called = true + return nil + }) + if err != nil { + t.Fatalf("WithLock should not return error on timeout: %v", err) + } + if !skipped { + t.Error("expected skipped=true on timeout") + } + if called { + t.Error("fn should not have been called on timeout") + } +}