gastownhall/beads · warning

interrupted waiting for cache lock on %s: %w

Error message

interrupted waiting for cache lock on %s: %w

What it means

acquireLock() selects on both the lock poll and the caller's context while waiting for the cache lock. This error means the context passed to Ensure()/Push() was cancelled or its deadline expired while blocked on lock contention. The underlying ctx.Err() (context.Canceled or context.DeadlineExceeded) is wrapped so callers can errors.Is() it.

Source

Thrown at internal/remotecache/cache.go:262

	// Poll with timeout
	deadline := time.Now().Add(2 * time.Minute)
	for {
		err := lockfile.FlockExclusiveNonBlocking(f)
		if err == nil {
			return f, nil
		}
		if !lockfile.IsLocked(err) {
			_ = f.Close()
			return nil, err
		}
		if time.Now().After(deadline) {
			_ = f.Close()
			return nil, fmt.Errorf("timeout waiting for cache lock on %s", remoteURL)
		}
		select {
		case <-ctx.Done():
			_ = f.Close()
			return nil, fmt.Errorf("interrupted waiting for cache lock on %s: %w", remoteURL, ctx.Err())
		case <-time.After(100 * time.Millisecond):
		}
	}
}

// releaseLock releases a cache entry file lock.
// The lock file is intentionally NOT removed: deleting it after unlock creates
// a TOCTOU race where another process's newly-acquired lock gets deleted.
// Stale lock files are cleaned up by acquireLock's age check instead.
func (c *Cache) releaseLock(f *os.File) {
	if f != nil {
		_ = lockfile.FlockUnlock(f)
		_ = f.Close()
	}
}

// readMeta reads the cache metadata for a remote URL.
func (c *Cache) readMeta(remoteURL string) *CacheMeta {

View on GitHub (pinned to 71377f2769)

Solutions

  1. Treat as expected cancellation: check errors.Is(err, context.Canceled) / context.DeadlineExceeded and unwind cleanly.
  2. If wait time is legitimate, pass a longer-lived context (context.Background() or a bigger deadline).
  3. Resolve the underlying contention (see lock timeout guidance) so waits are short.
  4. Retry the operation with a fresh context once the other process finishes.

Example fix

// before: cancelling a background sync after 5s
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := cache.Ensure(ctx, url) // lock wait often exceeds 5s
// after: generous deadline for lock waits
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
_, err := cache.Ensure(ctx, url)
Defensive patterns

Strategy: try-catch

Validate before calling

// check the context budget you're giving the call
deadline, ok := ctx.Deadline()
if ok && time.Until(deadline) < 30*time.Second {
    fmt.Fprintln(os.Stderr, "warning: context may expire while waiting for cache lock")
}

Try / catch

if _, err := cache.Ensure(ctx, url); err != nil {
    switch {
    case errors.Is(err, context.Canceled):
        return fmt.Errorf("sync cancelled by user: %w", err)
    case errors.Is(err, context.DeadlineExceeded):
        return fmt.Errorf("sync deadline too short for lock wait: %w", err)
    }
    return err
}

Prevention

When it happens

Trigger: Cache.Ensure() or Cache.Push() called with a context that gets cancelled (Ctrl-C/SIGINT, timeout, parent cancellation) while another process holds the .lock for the same remoteURL.

Common situations: User hits Ctrl-C during a slow sync blocked behind another process; command run with a short timeout (e.g. exec.CommandContext or HTTP request context) that expires while waiting; orchestrator cancelling jobs.

Related errors


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/ced27ae7f3de660b. Report an issue: GitHub.