Skip to content

[linter-miner] Add manual-mutex-unlock linter to detect non-deferred mutex unlocks#34091

Merged
pelikhan merged 4 commits into
mainfrom
linter-miner/manual-mutex-unlock-d7cb2952dc6202d1
May 22, 2026
Merged

[linter-miner] Add manual-mutex-unlock linter to detect non-deferred mutex unlocks#34091
pelikhan merged 4 commits into
mainfrom
linter-miner/manual-mutex-unlock-d7cb2952dc6202d1

Conversation

@github-actions
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot commented May 22, 2026

Summary

Adds a new Go static analysis linter manualmutexunlock that detects sync.Mutex and sync.RWMutex unlock calls not protected by defer, reducing the risk of lock leaks from early returns or panics.

Changes

Added Files

  • pkg/linters/manualmutexunlock/manualmutexunlock.go — Core analyzer implementation tracking per-variable mutex state using types.Object to identify manual unlock calls
  • pkg/linters/manualmutexunlock/manualmutexunlock_test.go — Integration test harness using golang.org/x/tools/go/analysis/analysistest
  • pkg/linters/manualmutexunlock/testdata/src/manualmutexunlock/manualmutexunlock.go — Test fixtures with diagnostic markers covering both correct (deferred) and incorrect (manual) patterns for sync.Mutex and sync.RWMutex
  • docs/adr/34091-add-manual-mutex-unlock-linter.md — Architecture Decision Record documenting rationale, specification, and requirements

Modified Files

  • cmd/linters/main.go — Registered manualmutexunlock analyzer in the linter registry

Technical Details

Implementation Approach

  • Uses types.Object identity to track mutex variables across lock/unlock calls
  • Distinguishes between deferred and direct unlock calls via ast.DeferStmt inspection
  • Supports both sync.Mutex and sync.RWMutex types (Lock/Unlock and RLock/RUnlock)
  • Handles selector expressions (obj.mu.Unlock()) to correctly identify the mutex variable

Diagnostic Output

Reports violations with position information pointing to non-deferred unlock calls, enabling integration with standard Go tooling workflows.

Impact Assessment

  • Breaking Changes: None
  • New Capabilities: Detects a common concurrency bug pattern in Go code
  • Test Coverage: Includes comprehensive test fixtures for both violation and compliant cases

Commit History

  • 703236018 — Merge branch 'main' into linter-miner/manual-mutex-unlock-d7cb2952dc6202d1
  • 1484c62ed — Fix manualmutexunlock state overwrite and add selector fixtures
  • c6ab3cb67 — Add draft ADR-34091 for manual-mutex-unlock linter
  • 4599e2c4c — Add manual-mutex-unlock linter to detect non-deferred mutex unlocks

Files Changed

cmd/linters/main.go (modified)
docs/adr/34091-add-manual-mutex-unlock-linter.md (added)
pkg/linters/manualmutexunlock/manualmutexunlock.go (added)
pkg/linters/manualmutexunlock/manualmutexunlock_test.go (added)
pkg/linters/manualmutexunlock/testdata/src/manualmutexunlock/manualmutexunlock.go (added)

Generated by PR Description Updater for issue #34091 · ● 1.2M ·

Reports mutex Unlock() calls that are not deferred, which can lead to
deadlocks if a panic or early return occurs between Lock() and Unlock().

Evidence: 12+ instances found in codebase including:
- pkg/cli/compile_watch.go:189 (manual unlocks at lines 204, 209)
- pkg/console/spinner.go:130-138, 159-168
- pkg/cli/docker_images.go:149-151

The linter detects both sync.Mutex and sync.RWMutex violations and
provides clear diagnostics for manual unlock patterns.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added automation cookie Issue Monster Loves Cookies! go-linters labels May 22, 2026
@pelikhan pelikhan marked this pull request as ready for review May 22, 2026 20:52
Copilot AI review requested due to automatic review settings May 22, 2026 20:52
@github-actions
Copy link
Copy Markdown
Contributor Author

github-actions Bot commented May 22, 2026

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions
Copy link
Copy Markdown
Contributor Author

github-actions Bot commented May 22, 2026

Design Decision Gate 🏗️ completed the design decision gate check.

@github-actions
Copy link
Copy Markdown
Contributor Author

github-actions Bot commented May 22, 2026

PR Code Quality Reviewer completed the code quality review.

Code review completed - comprehensive analysis done, no blocking issues found

@github-actions
Copy link
Copy Markdown
Contributor Author

github-actions Bot commented May 22, 2026

🧪 Test Quality Sentinel completed test quality analysis.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new custom Go go/analysis linter (manualmutexunlock) to the project’s linter runner to detect mutex unlock patterns that are not deferred, with accompanying analysistest-based fixtures/tests.

Changes:

  • Added pkg/linters/manualmutexunlock analyzer implementation and unit tests.
  • Added analysistest fixtures under pkg/linters/manualmutexunlock/testdata.
  • Registered the analyzer in cmd/linters/main.go.
Show a summary per file
File Description
pkg/linters/manualmutexunlock/manualmutexunlock.go New analyzer that tracks mutex Lock/RLock and flags manual Unlock/RUnlock without a defer.
pkg/linters/manualmutexunlock/manualmutexunlock_test.go Runs the analyzer via analysistest.Run.
pkg/linters/manualmutexunlock/testdata/src/manualmutexunlock/manualmutexunlock.go Test fixtures covering good/bad lock/unlock patterns.
cmd/linters/main.go Registers the new analyzer with the multichecker.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comments suppressed due to low confidence (2)

pkg/linters/manualmutexunlock/manualmutexunlock.go:173

  • getLockCallObj/getUnlockCallObj only handle receivers that are *ast.Ident (e.g., mu.Lock()), so the analyzer will miss common patterns like s.mu.Lock() / pullState.mu.Lock() where the receiver expression is a selector. Consider extracting the underlying types.Object from selector receivers too (e.g., via pass.TypesInfo.Selections for s.mu, or by handling *ast.SelectorExpr/*ast.StarExpr recursively) so the linter actually covers struct fields and package-level vars used via selectors.
// getLockCallObj returns the types.Object for the receiver if call is like mu.Lock() or mu.RLock()
func getLockCallObj(pass *analysis.Pass, call *ast.CallExpr) types.Object {
	sel, ok := call.Fun.(*ast.SelectorExpr)
	if !ok {
		return nil
	}
	if sel.Sel.Name != "Lock" && sel.Sel.Name != "RLock" {
		return nil
	}
	ident, ok := sel.X.(*ast.Ident)
	if !ok {
		return nil
	}
	
	// Check if the receiver is a mutex type
	obj := pass.TypesInfo.ObjectOf(ident)
	if obj == nil {
		return nil
	}
	
	if !isMutexType(pass.TypesInfo.TypeOf(ident)) {
		return nil
	}
	
	return obj
}

// getUnlockCallObj returns the types.Object for the receiver if call is like mu.Unlock() or mu.RUnlock()
func getUnlockCallObj(pass *analysis.Pass, call *ast.CallExpr) types.Object {
	sel, ok := call.Fun.(*ast.SelectorExpr)
	if !ok {
		return nil
	}
	if sel.Sel.Name != "Unlock" && sel.Sel.Name != "RUnlock" {
		return nil
	}
	ident, ok := sel.X.(*ast.Ident)
	if !ok {
		return nil
	}
	
	// Check if the receiver is a mutex type
	obj := pass.TypesInfo.ObjectOf(ident)
	if obj == nil {
		return nil
	}
	
	if !isMutexType(pass.TypesInfo.TypeOf(ident)) {
		return nil
	}
	
	return obj
}

pkg/linters/manualmutexunlock/manualmutexunlock.go:108

  • The diagnostic message hard-codes "Unlock()"/"Lock()" even when the pattern is RLock()/RUnlock(), which makes reports confusing for RWMutex read locks. Consider making the message generic (lock/unlock) or selecting wording based on whether the call was Lock vs RLock so it matches the actual API being used.
		// Report mutexes with manual unlock but no defer
		for _, state := range mutexVars {
			if state.hasManualUnlock && !state.hasDefer {
				pass.Report(analysis.Diagnostic{
					Pos:     state.lockPos,
					Message: "mutex Unlock() should be deferred immediately after Lock() to prevent deadlocks on panic or early return",
				})
			}
  • Files reviewed: 4/4 changed files
  • Comments generated: 2

Comment on lines +63 to +73
// New lock call - initialize or update state
if _, exists := mutexVars[obj]; !exists {
mutexVars[obj] = &mutexVarState{
lockPos: call.Pos(),
}
} else {
// Reset state for new lock on same variable
mutexVars[obj] = &mutexVarState{
lockPos: call.Pos(),
}
}
// ... do work ...

mu2.Unlock()
}
Copy link
Copy Markdown
Contributor Author

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skills-Based Review 🧠

Applied /tdd and /zoom-out — requesting changes to improve test coverage and diagnostic quality.

📋 Key Themes & Highlights

Key Themes

  1. Test coverage gaps ([/tdd]): Missing edge cases for conditional unlocks, struct field mutexes, and re-lock patterns
  2. Scope documentation ([/zoom-out]): Unclear whether struct field mutexes (s.mu.Lock()) are intentionally excluded
  3. Diagnostic positioning ([/zoom-out]): Error messages point to Lock() lines instead of the problematic Unlock() calls

Positive Highlights

Clean implementation: Type-safe tracking using types.Object for mutex variables
Good coverage of basics: Tests validate core patterns (Mutex, RWMutex, multiple mutexes)
Proper integration: Correctly registered in cmd/linters/main.go and follows linter conventions
Clear documentation: Package comment explains the safety concern

🔍 Review Summary by Skill

[/tdd] Test-Driven Development

The test suite covers happy and unhappy paths for simple mutex patterns, but misses critical edge cases:

  • Missing: Conditional unlock scenarios (defer declared after a conditional unlock path)
  • Missing: Re-lock patterns (multiple Lock() calls on the same variable)
  • Missing: Struct field mutexes (s.mu.Lock()) — these appear in the PR's evidence section
  • Missing: Goroutine independence tests

Without these tests, it's unclear whether the linter handles real-world patterns or only synthetic examples.

[/zoom-out] Architectural Context

The linter only handles direct variable references (mu.Lock()). From the PR description, the codebase has patterns like:

  • pkg/cli/compile_watch.go:189debounceMu.Lock()
  • pkg/console/spinner.go:130-138 — likely struct field mutexes

Current implementation skips s.mu, (*ptr), and receiver mutexes due to:

ident, ok := sel.X.(*ast.Ident)  // Only matches simple identifiers

This scope limitation should be either:

  1. Documented explicitly in tests (add test cases showing what's excluded)
  2. Expanded to handle struct field mutexes (more valuable but more work)

User experience concern: Diagnostics point to Lock() lines, but developers need to fix Unlock() lines. Consider tracking unlock positions for better error messages.


Next Steps: Address test coverage gaps (especially struct field mutexes and conditional unlocks) and consider improving diagnostic positioning. These changes will make the linter more robust and developer-friendly.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · ● 735.2K

}

// Look for mutex Lock() calls
if exprStmt, ok := node.(*ast.ExprStmt); ok {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] Missing edge case: nested if or switch statements with conditional unlocks.

💡 Why this matters

The current implementation only tracks whether a mutex has any manual unlock and any defer. It doesn't verify that the defer comes before the manual unlock in execution order.

Consider this false negative:

func conditional() {
    var mu sync.Mutex
    mu.Lock()
    
    if someCondition {
        mu.Unlock()  // Manual unlock in branch
        return
    }
    
    defer mu.Unlock()  // Defer comes after conditional unlock
    // ... work ...
}

The linter won't flag this because hasDefer = true and hasManualUnlock = true, but the defer doesn't protect the early return path.

Suggestion: Add a test case for conditional unlocks with early returns to document current behavior or enhance the linter to detect this pattern.

// ... do work ...
}

// Wrong: multiple locks, one without defer
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] Add test case: re-locking the same mutex should reset tracking state.

💡 Re-lock pattern test

The implementation resets mutexVars[obj] when it encounters a new Lock() on the same variable (lines 68-71 in the main file). This behavior should have explicit test coverage:

// Correct: re-lock with defer after first unlock
func GoodRelockPattern() {
    var mu sync.Mutex
    
    mu.Lock()
    defer mu.Unlock()
    // ... work ...
    mu.Unlock()
    
    mu.Lock()  // Second lock — should not be flagged if followed by defer
    defer mu.Unlock()
    // ... more work ...
}

// Wrong: second lock without defer
func BadRelockPattern() {
    var mu sync.Mutex
    
    mu.Lock()
    defer mu.Unlock()
    mu.Unlock()
    
    mu.Lock() // want `mutex Unlock\(\) should be deferred...`
    // ... work ...
    mu.Unlock()
}

Without this test, the re-lock reset logic is untested.

// Look for mutex Lock() calls
if exprStmt, ok := node.(*ast.ExprStmt); ok {
if call, ok := exprStmt.X.(*ast.CallExpr); ok {
if obj := getLockCallObj(pass, call); obj != nil {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/zoom-out] Potential false negatives: pointer receivers and struct field mutexes.

💡 Broader pattern analysis

The linter only handles direct variable references (mu.Lock()). Real-world mutex usage often involves:

  1. Struct field mutexes: s.mu.Lock() / s.mu.Unlock()
  2. Pointer indirection: (*mutexPtr).Lock()
  3. Method receivers: func (s *Server) handler() { s.mu.Lock(); ... }

From the PR description, the codebase has patterns like:

  • pkg/cli/compile_watch.go:189debounceMu.Lock()
  • pkg/console/spinner.go:130-138 — likely struct field mutexes

Current implementation:

ident, ok := sel.X.(*ast.Ident)  // Only matches simple identifiers
if !ok {
    return nil  // Skips s.mu, (*ptr), etc.
}

Suggestion: Add test cases for these patterns to document what's in scope vs. explicitly out of scope for this linter.


return true
})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/zoom-out] Reporting loop loses individual unlock positions — diagnostics point to Lock() instead of Unlock().

💡 User experience concern

When the linter reports a diagnostic, it uses state.lockPos (the position of Lock()), not the position of the problematic Unlock() call:

pass.Report(analysis.Diagnostic{
    Pos:     state.lockPos,  // Points to mu.Lock()
    Message: "mutex Unlock() should be deferred...",
})

Developer experience issue: The error points to the Lock() line, but the fix is at the Unlock() line. Developers expect the diagnostic to point where the action is needed.

Example output:

file.go:42: mutex Unlock() should be deferred...

But line 42 is mu.Lock() — the unlock is on line 50.

Suggestion: Track unlockPos in mutexVarState and report diagnostics at the unlock location, or report both positions in the message:

Message: fmt.Sprintf("mutex Unlock() at line %d should be deferred (Lock at line %d)", unlockLine, lockLine),

"github.com/github/gh-aw/pkg/linters/manualmutexunlock"
)

func TestAnalyzer(t *testing.T) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] Test suite lacks negative cases and edge case coverage.

💡 Missing test scenarios

The test relies entirely on analysistest.Run() with fixture files. Strong test coverage should include:

  1. Struct field mutexes: type Server struct { mu sync.Mutex }s.mu.Lock(); s.mu.Unlock()
  2. Pointer receivers: Methods with func (s *Server) handle() { s.mu.Lock(); ... }
  3. Conditional unlock edge case: Defer declared after a conditional manual unlock
  4. Re-lock patterns: Multiple Lock() calls on the same variable in one function
  5. Goroutines: go func() { mu.Lock(); defer mu.Unlock() }() should not interfere

Current test file only validates:

  • ✅ Basic good/bad patterns
  • ✅ RWMutex variants
  • ✅ Multiple mutexes

Suggestion: Add test cases for the patterns listed above, especially struct field mutexes which appear in the PR's evidence section.

@github-actions
Copy link
Copy Markdown
Contributor Author

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (306 new lines under pkg/) but does not have a linked Architecture Decision Record (ADR).

📄 Draft ADR committed: docs/adr/34091-add-manual-mutex-unlock-linter.md — review and complete it before merging.

🔒 This PR cannot merge until an ADR is linked in the PR body.

📋 What to do next
  1. Review the draft ADR committed to your branch — it was generated from the PR diff and mirrors ADR-33834 (fileclosenotdeferred), which uses the same package pattern.
  2. Complete the missing sections — add context the AI couldn't infer (deciders, any additional alternatives weighed during design), refine the decision rationale, and confirm the listed consequences match your intent.
  3. Commit the finalized ADR to docs/adr/ on your branch (the file is already committed; further edits can replace the draft in place).
  4. Reference the ADR in this PR body by adding a line such as:

    ADR: ADR-34091: Add manual-mutex-unlock Linter

Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision.

❓ Why ADRs Matter

"AI made me procrastinate on key design decisions. Because refactoring was cheap, I could always say 'I'll deal with this later.' Deferring decisions corroded my ability to think clearly."

ADRs create a searchable, permanent record of why the codebase looks the way it does. Future contributors (and your future self) will thank you.

📋 Michael Nygard ADR Format Reference

An ADR must contain these four sections to be considered complete:

  • Context — What is the problem? What forces are at play?
  • Decision — What did you decide? Why?
  • Alternatives Considered — What else could have been done?
  • Consequences — What are the trade-offs (positive and negative)?

All ADRs are stored in docs/adr/ as Markdown files numbered by PR number (e.g., 34091-add-manual-mutex-unlock-linter.md for PR #34091).

References:

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · ● 5.2M ·

@github-actions
Copy link
Copy Markdown
Contributor Author

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100 — Excellent

Analyzed 1 test: 1 design test (behavioral contract), 0 implementation tests, 0 guideline violations.

📊 Metrics & Test Classification (1 test analyzed)
Metric Value
New/modified tests analyzed 1
✅ Design tests (behavioral contracts) 1 (100%)
⚠️ Implementation tests (low value) 0 (0%)
Tests with error/edge cases 1 (100%)
Duplicate test clusters 0
Test inflation detected No (55% ratio vs 200% threshold)
🚨 Coding-guideline violations 0

Test Classification Details

Test File Classification Notes
TestAnalyzer pkg/linters/manualmutexunlock/manualmutexunlock_test.go:13 ✅ Design Uses standard analysistest framework; verifies 4 negative cases + 2 positive cases via declarative // want comments

Language Support

Tests analyzed:

  • 🐹 Go (*_test.go): 1 test — unit test with //go:build !integration tag

Test Coverage Analysis

The test uses the analysistest framework (standard for Go linter testing), which validates expected diagnostics through:

  • Testdata file: testdata/src/manualmutexunlock/manualmutexunlock.go (93 lines)
  • Negative cases (should be flagged): 4 patterns testing non-deferred unlocks
    • sync.Mutex with manual Unlock()
    • sync.RWMutex with manual RUnlock() (read lock)
    • sync.RWMutex with manual Unlock() (write lock)
    • Multiple mutexes with one missing defer
  • Positive cases (should pass): 2 patterns testing correct usage
    • Proper deferred unlock immediately after lock
    • Multiple mutexes with all defers

Coverage assessment: ✅ Comprehensive

  • Both Mutex and RWMutex types covered
  • Both lock types (Lock/RLock) and unlock types (Unlock/RUnlock) covered
  • Edge cases: multiple mutexes, nested functions, closure handling

Verdict

Check passed. 0% of new tests are implementation tests (threshold: 30%). This is an exemplary linter test following Go best practices.

📖 Understanding Test Classifications

Design Tests (High Value) verify what the system does:

  • Assert on observable outputs, return values, or state changes
  • Cover error paths and boundary conditions
  • Would catch a behavioral regression if deleted
  • Remain valid even after internal refactoring

Implementation Tests (Low Value) verify how the system does it:

  • Assert on internal function calls (mocking internals)
  • Only test the happy path with typical inputs
  • Break during legitimate refactoring even when behavior is correct
  • Give false assurance: they pass even when the system is wrong

Goal: Shift toward tests that describe the system's behavioral contract — the promises it makes to its users and collaborators.

Why This Test Scores 100/100

  1. Uses standard Go linter testing pattern — The analysistest framework is the recommended approach for testing static analysis tools
  2. Behavioral verification — Tests observable diagnostics (what messages are reported), not internal implementation details
  3. Comprehensive coverage — 6 test cases covering both positive and negative scenarios, multiple mutex types, and edge cases
  4. No guideline violations — Correct build tag, no mock libraries, follows codebase conventions
  5. Proportional test size — Test code (16 lines + 93 testdata) is well-balanced against production code (197 lines)

References:

🧪 Test quality analysis by Test Quality Sentinel · ● 2.1M ·

Copy link
Copy Markdown
Contributor Author

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Test Quality Sentinel: 100/100. Test quality is excellent — 0% of new tests are implementation tests (threshold: 30%). This linter test follows Go best practices using the standard analysistest framework.

@github-actions
Copy link
Copy Markdown
Contributor Author

@copilot review all comments and address the unresolved review feedback on this PR.

Generated by 👨‍🍳 PR Sous Chef · ● 2.7M ·

@github-actions
Copy link
Copy Markdown
Contributor Author

@copilot please refresh the branch status and summarize the remaining blockers so maintainers can re-check quickly.

Generated by 👨‍🍳 PR Sous Chef · ● 2.7M ·

@pelikhan
Copy link
Copy Markdown
Collaborator

@copilot review all comments

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI commented May 22, 2026

@copilot review all comments

Done — I reviewed and addressed the actionable review feedback in commit 1484c62:

  • report unresolved prior mutex state before overwriting on repeated locks
  • add selector-based fixture coverage (g.mu.Lock()/Unlock())
  • add repeated-lock fixture to prevent regression

Copilot AI requested a review from pelikhan May 22, 2026 21:31
@pelikhan pelikhan merged commit a40aec4 into main May 22, 2026
16 of 18 checks passed
@pelikhan pelikhan deleted the linter-miner/manual-mutex-unlock-d7cb2952dc6202d1 branch May 22, 2026 21:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automation cookie Issue Monster Loves Cookies! go-linters

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants