Documentation
¶
Overview ¶
Package callbackunderlock catches calls through func-typed values while a sync mutex is held.
The bug class is a wedged registry: an app-supplied callback (a per-tool gate, a subscriber hook) invoked between Lock and Unlock can block for as long as it likes — while holding the lock every other reader needs — or panic past a plain (non-deferred) Unlock and leave the mutex locked forever. Go's RWMutex is writer-preferring, so one queued writer turns a slow callback into a full stop of every reader. The 419-probe audit found this shape in every MCP listing path (probe TestListingGateNeverBlocksRegistry, fixed b79942f7): listTools, handlePromptsList and handleResourcesTemplatesList all evaluated per-caller gates inside the registry read lock; the fix snapshots under the lock and evaluates gates outside it. This rule fires on the shape, not those sites: any call whose callee is a func-typed struct field or map element, lexically between a sync.Mutex/sync.RWMutex acquisition on the same receiver and its release (including the whole tail of the function when the release is deferred).
Postures it deliberately stays silent on, because they are not this bug or are a different one: calls to NAMED functions and methods (they may take the lock themselves — locking twice is a deadlock bug, not a callback-under-lock bug, and is not diagnosable from shape); calls through locals and parameters — EXCEPT locals whose every binding is map-derived (`v, ok := m[k]`, `for _, v := range m`), which are registry callbacks by construction — because the repo's own convention copies a func field to a local and calls THAT under the lock precisely to run framework-controlled, non-blocking gates there (core/mcp listTools' callGate snapshot), and "which callbacks the framework allows to block its registry" is not visible from shape; a mutex reached through an alias (`m := &s.mu; m.Lock()`) or a custom wrapper type with its own Lock method (only sync.Mutex and sync.RWMutex selectors count); calls inside nested function literals (a closure deferred or handed to `go` does not run in the linear region, and a synchronous comparator's own lock behavior is its business); callees launched with `go` or deferred between Lock and Unlock (`go sub(ev)`, `defer h.done()`) — the launch is non-blocking and the callee runs on its own stack outside the held region; printf-shaped logger fields (func(format string, args ...any) — the logf/Logger fields the webhook manager, static builder and process supervisor inject), which are logging plumbing, not app callbacks, per the sibling recovercallback analyzer's classification; and context.CancelFunc fields (moduleproto's cancel slots): CancelFunc never re-enters user code — it is documented safe to call concurrently and only touches context internals — so holding p.mu across it cannot deadlock on a callback that re-takes the lock; and the deliberately-serialized one-shot — a callback on the SAME object as the mutex (the callee's root identifier is the receiver the mutex hangs off; a callback on any OTHER object, parameter or not, never qualifies — a blocking foreign callback wedges this registry's lock regardless), called at most once outside any loop, under a DEFERRED release (the style cache's compute-under-entry-lock, where the lock IS the exactly-once guarantee; battery/setup's runner used this shape too until its step callback moved outside the lock with an in-flight flag) — because a panic unwinds through the deferred unlock and the blocked parties are the same object's own callers, not a shared registry's every reader.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var Analyzer = &analysis.Analyzer{
Name: "callbackunderlock",
Doc: "report calls through func-typed fields/map elements while a sync mutex is held",
Run: run,
}
Functions ¶
This section is empty.
Types ¶
This section is empty.