Skip to content

Add goroutinemissingrecover linter: flag panic-unsafe goroutines - #48966

Merged
pelikhan merged 6 commits into
mainfrom
copilot/add-goroutinemissingrecover-linter
Jul 29, 2026
Merged

Add goroutinemissingrecover linter: flag panic-unsafe goroutines#48966
pelikhan merged 6 commits into
mainfrom
copilot/add-goroutinemissingrecover-linter

Conversation

Copilot AI commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Adds a new goroutinemissingrecover custom go/analysis linter that flags go func() { ... }() goroutine literals without a top-level defer func() { recover() }() guard. An unrecovered panic in a goroutine bypasses all caller recovery and kills the process — this linter enforces the pattern already used in pkg/console/spinner.go and pkg/cli/docker_images.go.

What it flags

// flagged — no recover guard
go func() {
    doRiskyWork()
}()

// safe — top-level defer/recover present
go func() {
    defer func() {
        if r := recover(); r != nil { ... }
    }()
    doRiskyWork()
}()

Named-function goroutines (go f()) are intentionally out of scope; the callee can install its own recovery. //nolint:goroutinemissingrecover suppresses individual sites.

Changes

  • pkg/linters/goroutinemissingrecover/ — analyzer (insp.Preorder over *ast.GoStmt, checks for top-level defer+recover in the literal body), analysistest-based test, and fixtures covering: safe goroutine, unsafe goroutine, named-function goroutine, defer-without-recover, and nolint-suppressed
  • pkg/linters/registry.go — registers the analyzer in All()
  • pkg/linters/doc.go — count 59 → 60, new bullet
  • pkg/linters/README.md — overview entry and subpackage table row
  • pkg/linters/spec_test.godocumentedAnalyzers() entry kept in sync

Run: https://github.com/github/gh-aw/actions/runs/30488631737

Generated by 👨‍🍳 PR Sous Chef · gpt54 · 9.5 AIC · ⌖ 6.31 AIC · ⊞ 7.8K ·
Comment /souschef to run again


Generated by 👨‍🍳 PR Sous Chef · gpt54 · 12.8 AIC · ⌖ 5.67 AIC · ⊞ 7.8K ·
Comment /souschef to run again

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Add goroutinemissingrecover linter for panic-unsafe goroutines Add goroutinemissingrecover linter: flag panic-unsafe goroutines Jul 29, 2026
Copilot AI requested a review from pelikhan July 29, 2026 19:16
@pelikhan
pelikhan marked this pull request as ready for review July 29, 2026 19:43
Copilot AI review requested due to automatic review settings July 29, 2026 19:43
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

Warning

threat detection engine error
The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.

Details

The threat detection engine failed to produce results.

Review the workflow run logs for details.

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

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

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 custom analyzer to detect anonymous goroutines lacking panic recovery.

Changes:

  • Implements and registers goroutinemissingrecover.
  • Adds analyzer fixtures and tests.
  • Updates linter documentation and registry synchronization.
Show a summary per file
File Description
pkg/linters/goroutinemissingrecover/goroutinemissingrecover.go Implements detection logic.
pkg/linters/goroutinemissingrecover/goroutinemissingrecover_test.go Runs analyzer fixtures.
pkg/linters/goroutinemissingrecover/testdata/src/a/a.go Defines test cases.
pkg/linters/registry.go Registers the analyzer.
pkg/linters/spec_test.go Adds documentation synchronization coverage.
pkg/linters/doc.go Documents the analyzer.
pkg/linters/README.md Adds public linter documentation.

Review details

Tip

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

Comments suppressed due to low confidence (4)

pkg/linters/goroutinemissingrecover/goroutinemissingrecover.go:124

  • Matching only the identifier text lets a shadowed function named recover satisfy the guard, e.g. recover := func() {}; recover(). That call is not the predeclared builtin and cannot contain a panic. Verify pass.TypesInfo.Uses[ident] is the builtin recover (as the existing builtin checks in mapclearloop and panic-in-library-code do), and add a shadowing fixture.
		ident, ok := call.Fun.(*ast.Ident)
		if ok && ident.Name == "recover" {
			found = true

pkg/linters/goroutinemissingrecover/goroutinemissingrecover.go:117

  • ast.Inspect counts calls inside immediately invoked nested literals, so this accepts defer func() { func() { recover() }() }() as a guard. That nested recover is not called directly by a deferred function and returns nil during a panic. Only count a recover whose enclosing function is executing as a deferred call (and add this false-negative fixture), rather than accepting every descendant call.
	found := false
	ast.Inspect(body, func(n ast.Node) bool {
		if found {
			return false
		}

pkg/linters/goroutinemissingrecover/goroutinemissingrecover.go:123

  • The effective builtin call can also be parenthesized as (recover)(). In that valid guard call.Fun is an *ast.ParenExpr, so this assertion misses it and emits a false positive. Unwrap call.Fun with astutil.UnwrapParenExpr before resolving the identifier, and cover this spelling in the fixture.
		ident, ok := call.Fun.(*ast.Ident)
		if ok && ident.Name == "recover" {

pkg/linters/goroutinemissingrecover/goroutinemissingrecover.go:102

  • A valid parenthesized recovery defer, defer (func() { recover() })(), has an *ast.ParenExpr in deferStmt.Call.Fun, so this check rejects it and reports an otherwise guarded goroutine. Use the existing astutil.UnwrapParenExpr helper before asserting *ast.FuncLit, and add the parenthesized form to the fixture.
		fn, ok := deferStmt.Call.Fun.(*ast.FuncLit)
		if !ok {
			continue
		}
  • Files reviewed: 7/7 changed files
  • Comments generated: 2
  • Review effort level: Medium

Comment thread pkg/linters/registry.go
fileclosenotdeferred.Analyzer,
fmterrorfnoverbs.Analyzer,
hardcodedfilepath.Analyzer,
goroutinemissingrecover.Analyzer,
Comment on lines +60 to +64
// Only flag goroutines started with a function literal, not named functions.
call, ok := goStmt.Call.Fun.(*ast.FuncLit)
if !ok {
return
}

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: goroutinemissingrecover linter

Good addition overall — structure, nolint support, and test coverage follow established patterns.

Two blocking issues found:

  1. False negative in containsRecoverCallast.Inspect descends into nested *ast.FuncLit bodies, so a recover() call inside a nested closure is incorrectly counted as a top-level guard for the enclosing goroutine. Fix by skipping descent into *ast.FuncLit nodes.

  2. Out-of-order entry in allAnalyzers (registry.go) — goroutinemissingrecover is placed after hardcodedfilepath instead of between fprintlnsprintf and hardcodedfilepath alphabetically.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · sonnet46 · 33.9 AIC · ⌖ 4.97 AIC · ⊞ 5.3K


// containsRecoverCall reports whether body contains a call to the built-in
// recover() function at any depth.
func containsRecoverCall(body *ast.BlockStmt) bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

False negative: recover() inside a nested function literal is counted as a guard.

ast.Inspect descends into all nested nodes, including nested *ast.FuncLit bodies. This means the following passes the check even though the outer goroutine body has no direct recover guard:

go func() {
    defer func() {
        _ = func() interface{} { return recover() }()
    }()
    panic("oops")
}()

Fix: stop descending into *ast.FuncLit nodes to avoid counting recover() calls inside nested closures:

ast.Inspect(body, func(n ast.Node) bool {
    if found { return false }
    // Do not descend into nested closures — their recover() only guards the inner func.
    if _, ok := n.(*ast.FuncLit); ok {
        return false
    }
    ...
})

Add a corresponding test fixture case to cover this.

@copilot please address this.

Comment thread pkg/linters/registry.go
fileclosenotdeferred.Analyzer,
fmterrorfnoverbs.Analyzer,
hardcodedfilepath.Analyzer,
goroutinemissingrecover.Analyzer,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Insertion order is not alphabetical.

The allAnalyzers slice adds goroutinemissingrecover after hardcodedfilepath (line 94) instead of before it, breaking the alphabetical order maintained by the rest of the slice. Move the entry between fprintlnsprintf and hardcodedfilepath.

@copilot please address this.

@github-actions

Copy link
Copy Markdown
Contributor

Warning

threat detection engine error
The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.

Details

The threat detection engine failed to produce results.

Review the workflow run logs for details.

🧪 Test Quality Sentinel Report

Test Quality Score: 85/100 — Excellent

Analyzed 1 test(s): 1 design, 0 implementation, 0 violation(s).

📊 Metrics (1 test)
Metric Value
Analyzed 1 (Go: 1, JS: 0)
✅ Design 1 (100%)
⚠️ Implementation 0 (0%)
Edge/error coverage 1 (100%)
Duplicate clusters 0
Inflation No (8.1:1 prod:test, well below 2:1 threshold)
🚨 Violations 0
Test File Classification Issues
TestGoroutineMissingRecover pkg/linters/goroutinemissingrecover/goroutinemissingrecover_test.go design_test, high_value None

Test Coverage Details:

The single behavioral test (TestGoroutineMissingRecover) uses the standard Go linter analysistest.Run() framework with test data fixtures in testdata/src/a/a.go. The test verifies:

  • Core contract: Linter correctly identifies unsafe goroutines (function literals without defer/recover)
  • Positive case: Safe goroutines with recover are not flagged
  • Negative cases: Multiple unsafe patterns are correctly flagged (missing recover, unrelated defer)
  • Edge case: Named function goroutines (out of scope) are correctly ignored
  • Suppression: nolint directives are respected

Build tag compliance: ✅ (has //go:build !integration on line 1)

Scoring Notes:

  • 40 points: 100% design tests (core behavioral contract verified)
  • 30 points: 100% edge/error coverage (5 test scenarios, multiple assertion outcomes)
  • 15 points: No duplicates, no test inflation
  • Total: 85/100

Score reduced from 100 because the single test function lacks explicit table-driven subtests (minor code organization; does not affect coverage).

Verdict

passed. 0% implementation tests (threshold: 30%). All tests verify behavioral contracts with comprehensive edge-case coverage.

🧪 Test quality analysis by Test Quality Sentinel · haiku45 · 24.7 AIC · ⊞ 8.4K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Test Quality Sentinel: 85/100. 0% implementation tests (threshold: 30%). All tests verify behavioral contracts with comprehensive edge-case coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🏗️ Design Decision Gate — ADR Required

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

📄 Draft ADR committed: docs/adr/48966-add-goroutinemissingrecover-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 at docs/adr/48966-add-goroutinemissingrecover-linter.md — it was generated from the PR diff
  2. Complete the missing sections — add context the AI couldn't infer, refine the decision rationale, and list real alternatives you considered
  3. Commit the finalized ADR to docs/adr/ on your branch
  4. Reference the ADR in this PR body by adding a line such as:

    ADR: ADR-48966: Add goroutinemissingrecover 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., 0042-use-postgresql.md for PR #42).

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · sonnet46 · 55.8 AIC · ⌖ 10.2 AIC · ⊞ 8.7K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: Request changes

The core detection logic has a false-negative bug that undermines the linter's entire purpose.

💡 Themes
  • containsRecoverCall uses an unrestricted ast.Inspect that descends into nested function literals inside the defer body. Per Go semantics, recover() only halts a panic when called directly by the deferred function itself -- a recover() buried in a nested closure (e.g. defer func(){ func(){ recover() }() }()) does not protect the goroutine, but this analyzer currently reports it as safe. This is a silent false negative in a linter whose sole job is catching exactly this class of bug.
  • The test fixture doesn't cover this nested-closure case, so the bug has no regression guard.
  • Everything else (nolint handling, generated-file skipping, named-function goroutine exclusion, doc/registry wiring) looks solid and consistent with the rest of the linters package.

🔎 Code quality review by PR Code Quality Reviewer · aut00 · 52.3 AIC · ⌖ 7.28 AIC · ⊞ 7.8K
Comment /review to run again

// recover() function at any depth.
func containsRecoverCall(body *ast.BlockStmt) bool {
found := false
ast.Inspect(body, func(n ast.Node) bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Unbounded ast.Inspect descends into nested function literals, causing false negatives for the exact bug class this linter exists to catch.

💡 Details

Go semantics: recover() only stops a panic when called directly by the deferred function itself. containsRecoverCall walks the entire subtree of the defer body without stopping at nested *ast.FuncLit boundaries, so a pattern like:

go func() {
    defer func() {
        func() { recover() }() // does NOT stop the panic -- wrong stack frame
    }()
    panic("oops")
}()

is incorrectly accepted as safe (no diagnostic), even though the panic still crashes the process. A linter whose whole purpose is flagging panic-unsafe goroutines producing a silent false negative here is worse than no linter at all.

Fix: stop descending into nested *ast.FuncLit nodes, mirroring the top-level-only restriction already applied in hasTopLevelRecoverDefer, and add a testdata case for defer func(){ func(){ recover() }() }() to lock in the fix.

go func() {
panic("oops")
}()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing test case for the highest-risk failure mode: a recover() reachable only via a nested closure inside the defer literal.

💡 Details

All four fixture cases test direct top-level patterns (direct recover, no recover, named-function goroutine, unrelated defer). None exercise containsRecoverCall's unrestricted recursive descent, which is exactly where the false-negative bug lives (see companion comment on goroutinemissingrecover.go). Add:

// nestedClosureRecover has recover() inside a nested closure -- still
// unsafe (recover only works one frame deep) -- should be flagged once fixed.
func nestedClosureRecover() {
    go func() { // want ...
        defer func() {
            func() { recover() }()
        }()
        panic("oops")
    }()
}

Without this, the fix for the false negative has no regression guard.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

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 /codebase-design — requesting changes on a correctness issue and a minor consistency gap.

📋 Key Themes & Highlights

Key Themes

  • False-negative risk (goroutinemissingrecover.go line 123): containsRecoverCall identifies the built-in recover by name only. A user-defined local function also named recover would satisfy the check and suppress the diagnostic incorrectly. The mapclearloop linter in this same codebase uses pass.TypesInfo.Uses[ident].(*types.Builtin) — the same pattern should be applied here.
  • Out-of-order registration (registry.go line 95): imports are alphabetical but the allAnalyzers slice entry is placed after hardcodedfilepath instead of before it, breaking the ordering convention.
  • Missing edge-case fixture (testdata/src/a/a.go): no test covers a goroutine whose only defer/recover is in a nested inner function literal — this is the scenario hasTopLevelRecoverDefer is explicitly designed to handle, and it should be exercised.

Positive Highlights

  • ✅ Clean use of the insp.Preorder / astutil / filecheck / nolint helper chain — consistent with every other linter in the package.
  • ✅ The scope decision (function literals only, named calls excluded) is well-reasoned and documented.
  • hasTopLevelRecoverDefer correctly avoids descending into nested function bodies, preventing false positives from inner recovers.
  • ✅ Full integration: registry, spec_test, doc.go, and README all updated in one PR.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 · 65.5 AIC · ⌖ 5.26 AIC · ⊞ 7K
Comment /matt to run again

return true
}
ident, ok := call.Fun.(*ast.Ident)
if ok && ident.Name == "recover" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] containsRecoverCall matches by name only — a user-defined function named recover would suppress the diagnostic, producing a false negative.

💡 Suggested fix: verify via type info

Use pass.TypesInfo.Uses to confirm the identifier resolves to the built-in recover, matching the pattern already used by mapclearloop for delete:

// Instead of:
if ok && ident.Name == "recover" {

// Prefer (requires passing pass.TypesInfo into containsRecoverCall):
if ok {
    if obj := pass.TypesInfo.Uses[ident]; obj != nil {
        if _, isBuiltin := obj.(*types.Builtin); isBuiltin && obj.Name() == "recover" {
            found = true
            return false
        }
    }
}

Also add a test fixture with a local function named recover() to lock this in.

@copilot please address this.

Comment thread pkg/linters/registry.go
fileclosenotdeferred.Analyzer,
fmterrorfnoverbs.Analyzer,
hardcodedfilepath.Analyzer,
goroutinemissingrecover.Analyzer,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/codebase-design] goroutinemissingrecover.Analyzer is inserted after hardcodedfilepath in the allAnalyzers slice, but the import block correctly places it alphabetically before hardcodedfilepath. The slice order is inconsistent with the import order and with the pattern used by other entries in this file.

💡 Move it before hardcodedfilepath
// before:
fmterrorfnoverbs.Analyzer,
hardcodedfilepath.Analyzer,
goroutinemissingrecover.Analyzer,  // ← out of order

// after:
fmterrorfnoverbs.Analyzer,
goroutinemissingrecover.Analyzer,  // ← alphabetically correct
hardcodedfilepath.Analyzer,

Keeping the slice in alphabetical order makes it easy to verify completeness and spot duplicates at a glance.

@copilot please address this.


// unsafeGoroutine has no recover — should be flagged.
func unsafeGoroutine() {
go func() { // want `goroutine launched via a function literal without a top-level defer/recover`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] Missing test fixture for a goroutine that contains a nested function literal with its own defer/recover — the linter should still flag the outer goroutine because the nested recover only protects the inner call.

💡 Suggested fixture to add
// nestedRecoverOnlyGoroutine — outer goroutine has no recover; inner func does.
// Should be flagged because the outer goroutine is unprotected.
func nestedRecoverOnlyGoroutine() {
    go func() { // want `goroutine launched via a function literal without a top-level defer/recover`
        func() {
            defer func() {
                if r := recover(); r != nil {}
            }()
            panic("inner")
        }()
        panic("outer") // this panic is unrecovered
    }()
}

This also validates that hasTopLevelRecoverDefer correctly restricts itself to the top level of body.List and does not descend into nested function bodies.

@copilot please address this.

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot please run the skill and address the blocking ADR gate. Review the generated ADR draft, complete it as needed, and link it from the PR body so the gate can pass.

Run: https://github.com/github/gh-aw/actions/runs/30488631737

Generated by 👨‍🍳 PR Sous Chef · gpt54 · 9.5 AIC · ⌖ 6.31 AIC · ⊞ 7.8K ·
Comment /souschef to run again

…es, type safety, ordering, production goroutines

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>

Copilot AI commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

@copilot please run the pr-finisher skill, address the unresolved review feedback, and rerun any needed checks. Unresolved review threads:

  • [github-actions: user-defined recover can suppress the ...

Addressed all unresolved threads in commit fix(goroutinemissingrecover):

  • Nested closure false negative (r3677623306, r3677632438): containsRecoverCall now stops descending at *ast.FuncLit boundaries so recover() inside a nested closure no longer satisfies the guard.
  • Nested closure fixture (r3677632442): added nestedClosureRecoverGoroutine in testdata/src/a/a.go — expect diagnostic.
  • Nested func with own recover fixture (r3677653020): added nestedRecoverOnlyGoroutine in testdata/src/a/a.go — outer goroutine has no top-level defer/recover so it is flagged.
  • User-defined recover (r3677653001): containsRecoverCall now verifies via pass.TypesInfo.Uses[ident].(*types.Builtin) that the identifier resolves to the built-in, matching the pattern in mapclearloop. Test fixture lives in new testdata/src/b/b.go.
  • Alphabetical ordering (r3677623317, r3677653012): goroutinemissingrecover.Analyzer moved before hardcodedfilepath.Analyzer in registry.go.
  • Parenthesized function literals (r3677596725): unwrapParens() helper added; both the goroutine check and the defer check unwrap *ast.ParenExpr. Added parenthesizedGoroutine fixture.
  • Production goroutines (r3677596683): forecast_compute.go (×2), bootstrap_profile_helpers.go, and bootstrap_profile_github_app.go all have top-level defer func() { if r := recover(); ... }() guards added.

@pelikhan
pelikhan merged commit ad83694 into main Jul 29, 2026
29 checks passed
@pelikhan
pelikhan deleted the copilot/add-goroutinemissingrecover-linter branch July 29, 2026 21:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[linter-miner] Add goroutinemissingrecover linter: flag panic-unsafe goroutines

4 participants