[13] A Reentrant Lock Deadlock Froze the Proxy on Any Invalid Config Patch

The trick: never hold a lock across a callback. Split the locked section into its own function whose only exit is a single defer Unlock, and run callbacks only after that function returns.

Issue

A single invalid PATCH /api/config froze the whole Go proxy. Every request reads config through Current() under the same sync.RWMutex, so nothing recovered without a hard restart.

Root Cause

Reloader.Update held mu.Lock() while calling subscriber callbacks, one of which called Current() — which takes mu.RLock(). RWMutex isn’t reentrant. The first fix removed the deadlock by unlocking manually before the callback loop, but leaked the lock on both early-return error paths: validation failure and persist failure — exactly the paths a bad patch takes.

Solution

func (r *Reloader) applyUpdateLocked(patch Patch) (cfg Config, subs []Sub, err error) {
    r.mu.Lock()
    defer r.mu.Unlock()   // the only exit, on every path
    ...
    return cfg, subs, err
}
// caller runs subs' callbacks only after this returns, unlocked

💡 Takeaway

  • Don’t hand-roll unlocks before a return — split the locked work into its own function whose only exit is defer.
  • Test a lock-holding function’s error paths as hard as its success path.
  • Reentrant deadlocks are usually two honest decisions meeting, not one obvious bug.