gastownhall/beads · error

timeout waiting for cache lock on %s

Error message

timeout waiting for cache lock on %s

What it means

acquireLock() polls a non-blocking exclusive flock on the cache entry's .lock file for up to 2 minutes (stale locks older than 5 minutes are removed first). This error means another process held the lock continuously for the whole deadline, so the operation was abandoned rather than mutating the dolt database concurrently.

Source

Thrown at internal/remotecache/cache.go:257

	f, err := os.OpenFile(lp, os.O_CREATE|os.O_RDWR, 0o600)
	if err != nil {
		return nil, err
	}

	// 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()

View on GitHub (pinned to 71377f2769)

Solutions

  1. Identify and finish/kill the other process holding the lock (lsof/fuser on <cache-entry>/.lock).
  2. If no process is using it and the lock is genuinely stale-but-young, remove the .lock file (or wait out staleLockAge) and retry.
  3. Serialize usage: avoid running multiple bd commands against the same remote concurrently (scripts, cron overlap).
  4. Retry after a delay — this is a transient contention error, not data corruption.

Example fix

// before: immediate retry, same contention
if err := cache.Push(ctx, url); err != nil { return err }
// after: detect lock timeout and back off
if err := cache.Push(ctx, url); err != nil {
    if strings.Contains(err.Error(), "timeout waiting for cache lock") {
        return fmt.Errorf("another bd process is syncing %s; retry later", url)
    }
    return err
}
Defensive patterns

Strategy: retry

Validate before calling

lockPath := filepath.Join(cacheDir, "beads", "remotes", remotecache.CacheKey(remoteURL), ".lock")
if info, err := os.Stat(lockPath); err == nil {
    age := time.Since(info.ModTime())
    if age < 5*time.Minute {
        return fmt.Errorf("cache lock active (%.0fs old); wait or stop the other process", age.Seconds())
    }
}

Try / catch

err := doSync(ctx) // Ensure/Push inside
if err != nil && strings.Contains(err.Error(), "timeout waiting for cache lock") {
    // check no bd process is running, optionally clear a stale .lock, then retry once
    select {
    case <-time.After(time.Minute):
        return doSync(ctx)
    case <-ctx.Done():
        return ctx.Err()
    }
}

Prevention

When it happens

Trigger: Cache.Ensure() or Cache.Push() on a remoteURL whose .lock is held by another live process (long-running bd sync, stuck dolt command) for >2 minutes; the lock file is younger than staleLockAge so it is not treated as stale.

Common situations: Two shells running `bd sync`/`bd pull` on the same remote simultaneously; a hung dolt operation leaving a live-looking lock; backup/indexing software holding the file; very slow network making a legitimate push exceed 2 minutes.

Understand the failure class

Related errors


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