Skip to content

Make a handle concurrency-safe

aferobilly.New returns a handle that can outlive the call that created it and be used from other goroutines. Whether that is safe depends on the locker you give it.

The default: no locking

fs := aferobilly.New(bfs)

The default locker is a no-op. The adapter adds no synchronisation, so the handle is exactly as safe as the billy filesystem underneath — correct when the backing filesystem is single-threaded or the caller owns synchronisation.

Serialise through the producer's mutex

fs := aferobilly.New(repo.Worktree().Filesystem, aferobilly.WithLocker(&repo.mu))

WithLocker acquires the locker around every Fs operation and every File operationRead, Write, Seek, Close, Truncate, ReadAt, WriteAt, Readdir, Stat, all of them.

The point is which mutex you pass. Give it the mutex that also guards whatever produces the filesystem, and the handle serialises against that producer's own operations. A live afero.Fs over a mutex-guarded git worktree then can never touch the worktree unsynchronised — even though the reference has escaped into code the producer knows nothing about.

That is the difference between "the caller must synchronise" (which the caller will eventually forget) and a handle that is safe by construction.

Footgun 1: operations are atomic, sequences are not

Each operation takes and releases the lock. A sequence of operations does not hold it across the gaps:

// NOT atomic — another goroutine can act between these two calls
data, _ := afero.ReadFile(fs, "config.yaml")
_ = afero.WriteFile(fs, "config.yaml", transform(data), 0o644)

When a sequence must be atomic with respect to other users of the same lock, hold the lock around the whole sequence yourself:

mu.Lock()
data, _ := afero.ReadFile(fs, "config.yaml")
_ = afero.WriteFile(fs, "config.yaml", transform(data), 0o644)
mu.Unlock()

...which leads directly to:

Footgun 2: non-reentrancy

Never use the handle inside a critical section holding the same locker

Go's sync.Mutex is not reentrant. If you hold the lock and then call an operation on a handle configured with that same lock, the operation blocks trying to re-acquire it and the goroutine deadlocks — against itself.

mu.Lock()
afero.ReadFile(fs, "x")   // ← DEADLOCK: fs re-locks mu
mu.Unlock()

The two footguns pull in opposite directions, so pick one pattern per handle:

  • Per-operation safety — a locked handle, used outside any critical section. Good for handing an afero.Fs to code that doesn't know about your lock.
  • Sequence atomicity — an unlocked handle used inside a critical section you manage yourself, or a callback API that takes the lock once and hands you a handle for the duration.

The second is why a producer wrapping this adapter often exposes a callback form (WithFS(func(afero.Fs) error)) alongside a plain accessor: the callback holds the lock for the whole closure, so a multi-step sequence is atomic.

No escape hatch, by design

The returned Fs and its files expose no accessor for the wrapped billy object. That is deliberate: if you could reach the underlying filesystem you could operate on it without the lock, and the guarantee above would only hold by convention. Removing the accessor removes the mistake.