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¶
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¶
WithLocker acquires the locker around every Fs operation and every File
operation — Read, 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.
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.Fsto 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.