Documentation
¶
Overview ¶
Package lockio provides a go/analysis-style checker that flags a lock (or lease) held across a store I/O round trip. It generalizes a checker originally written directly inside a Workflow host app's test suite to enforce "no lock held across store I/O" for one specific set of method names; this package makes the same detection logic reusable by any host app, parameterized entirely by method name.
Background: a staging incident traced to a lock held while a slow store write ran, starving unrelated requests behind it, and was made worse because the two symptom shapes it produced — a platform edge proxy's own HTML error page, and the app's own structured error once contention resolved — looked identical from the outside for hours. See https://github.com/GoCodeAlone/workflow-compute (docs/plans/2026-07-04-durable-mutation-lifecycle-design.md, "Upstream Guards" U2) for the full incident writeup that motivated this package.
The checker enforces two independent rules, both driven purely by method name — no lock-state data-flow tracking, no type information:
- Restricted I/O (ClassRestrictedIO): a call to one of Config.RestrictedIOMethods is flagged unless the enclosing function is listed in Config.PermanentIOCallers. These methods are assumed to do I/O directly under the assumption that the caller already holds the lock; only sanctioned wrapper functions (or storage backends with no separable lock/I-O split) should call them.
- Uncovered return paths (ClassUncoveredReturnPath): once a function calls one of Config.AcquireMethods using the idiom `v, err := recv.Acquire(...); if err != nil { return ... }`, every return path reachable afterward must pass through a call to one of Config.ReleaseMethods before returning, unless the enclosing function is listed in Config.PermanentReturnPathFunctions. A release call inside an unawaited `go func(){ ... }()` does not count: it returns with no guarantee of running before (or ever, relative to) the enclosing function's return.
Scope note: ClassUncoveredReturnPath checks release-path completeness for the acquire idiom above — it does not itself detect I/O happening between a Lock() call and a deferred Unlock(), since a `defer mu.Unlock()` trivially covers every return path by construction. A plain sync.Mutex Lock()/defer Unlock() wrapping a restricted I/O call is still caught, but by ClassRestrictedIO (list the I/O method in Config.RestrictedIOMethods), not by ClassUncoveredReturnPath. A cross-repo sweep of Workflow ecosystem code found this exact shape — a lock held across a slow I/O call via Lock()/defer Unlock(), contending with a hot-path caller of the same lock — in at least nine instances beyond the workflow-compute incident that motivated this package; see lockio_test.go's "ecosystem shape" fixtures.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
Types ¶
type Class ¶
type Class string
Class identifies which rule a Violation broke.
const ( // ClassRestrictedIO marks a call to a RestrictedIOMethods entry from a // function not listed in PermanentIOCallers. ClassRestrictedIO Class = "restricted-io" // ClassUncoveredReturnPath marks a return path reachable after an // AcquireMethods call that is not covered by a synchronous // ReleaseMethods call. ClassUncoveredReturnPath Class = "uncovered-return-path" )
type Config ¶
type Config struct {
// AcquireMethods are method names whose call, recognized only via the
// idiom `v, err := recv.Method(...); if err != nil { return ... }`,
// begins a critical section that must be closed by a ReleaseMethods call
// on every path reachable afterward.
AcquireMethods map[string]bool
// ReleaseMethods are method names that legitimately end a critical
// section opened by an AcquireMethods call — by committing, aborting, or
// otherwise releasing the lock/lease.
ReleaseMethods map[string]bool
// RestrictedIOMethods are method names that perform store I/O directly,
// assuming a lock/lease is already held. A RestrictedIOMethods entry
// that should also satisfy the ReleaseMethods requirement (i.e. calling
// it does complete the critical section, even when the call site itself
// is separately flagged as unsanctioned) must be listed in both maps;
// the two sets are independent by design.
RestrictedIOMethods map[string]bool
// PermanentIOCallers are function/method names allowed to call
// RestrictedIOMethods directly: structural exceptions such as the
// sanctioned release helper itself, or a storage backend with no
// separate lock/I-O split — not migration debt, not expected to shrink.
PermanentIOCallers map[string]bool
// PermanentReturnPathFunctions are function names exempt from the
// uncovered-return-path check: verified safe by means outside this
// checker's single-function, block-scoped reach (for example, a boolean
// flag set immediately before an unconditional release call two
// statements earlier that the checker cannot correlate back).
PermanentReturnPathFunctions map[string]bool
}
Config parameterizes the checker for one host codebase's lock/lease and store-I/O method names. All four method-name sets are matched against the selector name of a call expression (e.g. "Save" for `a.Save()`); receiver type is not checked, so pick names specific enough to a single codebase's convention to avoid false matches on unrelated types.
type Violation ¶
type Violation struct {
Pos token.Pos
File string // base name, e.g. "server.go"
Func string // enclosing function/method name
Class Class
}
Violation is one finding from FindViolations (or an Analyzer built with NewAnalyzer).
func FindViolations ¶
FindViolations scans already-parsed files sharing fset for both violation classes using cfg. Files may come from a single package or be scanned independently; each file is analyzed on its own (no cross-file call resolution), matching the original checker's per-file design.
func (Violation) Key ¶
Key returns a stable identity for the violation independent of line number, in the form "file|class|func". Host apps can use this to build a shrink-only allowlist test the way workflow-compute's original guard test did: fail loudly both on an undocumented new violation and on a stale allowlist entry that no longer violates.