Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/linters/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import (
"github.com/github/gh-aw/pkg/linters/sortslice"
"github.com/github/gh-aw/pkg/linters/ssljson"
"github.com/github/gh-aw/pkg/linters/strconvparseignorederror"
"github.com/github/gh-aw/pkg/linters/timeafterleak"
"github.com/github/gh-aw/pkg/linters/timesleepnocontext"
"github.com/github/gh-aw/pkg/linters/tolowerequalfold"
"github.com/github/gh-aw/pkg/linters/uncheckedtypeassertion"
Expand Down Expand Up @@ -71,6 +72,7 @@ func main() {
strconvparseignorederror.Analyzer,
jsonmarshalignoredeerror.Analyzer,
lenstringzero.Analyzer,
timeafterleak.Analyzer,
timesleepnocontext.Analyzer,
Comment on lines 72 to 76

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.

Done. timeafterleak has been added to both pkg/linters/doc.go (count updated 24→25, alphabetical entry) and pkg/linters/README.md (overview list, subpackages table, and dependencies list).

tolowerequalfold.Analyzer,
uncheckedtypeassertion.Analyzer,
Expand Down
102 changes: 102 additions & 0 deletions pkg/linters/timeafterleak/testdata/src/timeafterleak/timeafterleak.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package timeafterleak

import (
"context"
"time"
)

// BadForLoop is the canonical timer-leak: time.After in a for+select Comm.
func BadForLoop(ctx context.Context) {
for {
select {
case <-time.After(time.Second): // want `time\.After creates a new timer on each loop iteration`

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] The want pattern matches only a prefix of the diagnostic message — if the message text changes in future, this test may silently continue passing.

💡 Suggestion

Pin the full diagnostic message in the want comment so that any message change is caught immediately:

case <-time.After(time.Second): // want `time\.After creates a new timer on each loop iteration that is not garbage collected until it fires; use time\.NewTimer with Reset and Stop instead`

Other linters in this repo (e.g. timesleepnocontext) use full-message patterns, making the intent explicit.

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.

All three want patterns updated to the full message: `time\.After creates a new timer on each loop iteration that is not garbage collected until it fires; use time\.NewTimer with Reset and Stop instead`

doWork()
case <-ctx.Done():
return
}
}
}

// BadForLoopAssign also leaks: the receive is the Comm of the case clause.
Comment on lines +19 to +20

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.

Added in the latest commit: GoodNolintPreviousLine (directive on the preceding line) and GoodNolintSameLine (directive on the same line as the case), both with a second channel case so the nolint suppression is what prevents the diagnostic rather than any other guard.

func BadForLoopAssign(ctx context.Context) {
for {
select {
case t := <-time.After(time.Second): // want `time\.After creates a new timer on each loop iteration`
_ = t
case <-ctx.Done():
return
}
}
}

// BadRangeLoop flags time.After in a range-based loop select.
func BadRangeLoop(items []string, ctx context.Context) {
for range items {
select {
case <-time.After(time.Millisecond): // want `time\.After creates a new timer on each loop iteration`
case <-ctx.Done():
return
}
}

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] Two fixture gaps: no //nolint:timeafterleak test case, and no nested-loop case.

💡 Suggested additions

Other linters in this repo (e.g. hardcodedfilepath, errstringmatch) include a Good*Nolint case in testdata. Without one, a regression in the nolint integration would go undetected.

// GoodNolintDirective: suppressed with a nolint directive — not flagged.
func GoodNolintDirective(ctx context.Context) {
	for {
		select {
		case <-time.After(time.Second): (nolint/redacted):timeafterleak
			doWork()
		case <-ctx.Done():
			return
		}
	}
}

A nested-loop case would also verify that the inner CommClause is correctly flagged regardless of nesting depth:

// BadNestedLoop: inner select is still inside a loop.
func BadNestedLoop(ctx context.Context) {
	for {
		for {
			select {
			case <-time.After(time.Second): // want `time\.After creates a new timer`
			case <-ctx.Done():
				return
			}
		}
	}
}

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.

Both added: BadNestedLoop (inner select inside a doubly-nested for loop — flagged) and nolint fixtures (GoodNolintPreviousLine, GoodNolintSameLine).

}

// GoodNoLoop is fine: time.After in a select that is not inside a loop.
func GoodNoLoop(ctx context.Context) {
select {
case <-time.After(time.Second):
doWork()
case <-ctx.Done():
return
}
}

// GoodNewTimer uses the correct pattern: a single timer reused each iteration.
func GoodNewTimer(ctx context.Context) {
t := time.NewTimer(time.Second)
defer t.Stop()
for {
if !t.Stop() {
select {
case <-t.C:
default:
}
}
t.Reset(time.Second)
select {
case <-t.C:
doWork()
case <-ctx.Done():
return
}
}
}

// GoodTimeAfterInBody calls time.After inside the case body, not as the Comm.
// The timer fires exactly once here, so there is no accumulation leak.

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.

Misleading comment: the timer fires on every <-ch receive, not "exactly once".

💡 Details

The phrase "The timer fires exactly once here" is wrong in context. The function is inside a for loop, so <-time.After(time.Second) executes on every iteration that selects case <-ch:. What matters is that it's a blocking receive in the body (not in Comm), so the timer is always drained before the loop can advance — there is no accumulation of abandoned timers.

Suggested replacement:

// GoodTimeAfterInBody calls time.After inside the case body, not as the Comm.
// Because <-time.After(...) is a blocking receive, the timer is always drained
// before the loop continues — no timers accumulate across iterations.

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.

Updated. The comment now reads: "When time.After is used in the case body rather than as the Comm expression, the goroutine blocks on the receive until the timer fires — each timer is fully consumed before the loop can continue, so no timers accumulate."

func GoodTimeAfterInBody(ctx context.Context, ch <-chan struct{}) {
for {
select {
case <-ch:
<-time.After(time.Second) // in Body, not Comm — not flagged
case <-ctx.Done():
return
}
}
}

// GoodFuncLitInsideLoop: the select is inside a goroutine closure launched
// per iteration; the for loop does not directly enclose the CommClause.
func GoodFuncLitInsideLoop(ctx context.Context) {
for {
go func() {
select {
case <-time.After(time.Second): // FuncLit boundary — not flagged
case <-ctx.Done():
}
}()
<-ctx.Done()
return
}
}

func doWork() {}

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.

Missing test case: single-case select with time.After must not be flagged.

💡 Suggested addition

The false positive described in the isInsideLoopSelectComm comment above is currently untested — no test case exercises a for { select { case <-time.After(...): } } loop. Without such a Good* case, the bug would pass all tests silently.

Add to this file:

// GoodSingleCaseSelect: the select has only one case, so the timer always fires
// and can never be preempted; no leak is possible.
func GoodSingleCaseSelect() {
	for {
		select {
		case <-time.After(time.Second): // single case — not flagged
			doWork()
		}
	}
}

// BadSingleCaseWithDefault: default can preempt the timer → still flagged.
func BadSingleCaseWithDefault(ctx context.Context) {
	for {
		select {
		case <-time.After(time.Second): // want `time\.After creates a new timer on each loop iteration`
		default:
		}
	}
}

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.

Both added: GoodSingleCaseSelect (single case, no default — not flagged) and BadSingleCaseWithDefault (default can preempt the timer — flagged, with full want pattern).

134 changes: 134 additions & 0 deletions pkg/linters/timeafterleak/timeafterleak.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Package timeafterleak implements a Go analysis linter that flags
// time.After calls used as select case channel receives inside loops,
// which allocate a new timer on every iteration that is not garbage
// collected until it fires when another case is selected first.
package timeafterleak

import (
"go/ast"
"go/token"
"go/types"

"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"

"github.com/github/gh-aw/pkg/linters/internal/astutil"
"github.com/github/gh-aw/pkg/linters/internal/filecheck"
"github.com/github/gh-aw/pkg/linters/internal/nolint"
)

// Analyzer is the time-after-leak analysis pass.
var Analyzer = &analysis.Analyzer{
Name: "timeafterleak",
Doc: "reports time.After calls in select cases inside loops that leak a timer goroutine on each iteration when another case fires first",

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.

[/grill-with-docs] The Doc string describes what is flagged but not what is intentionally excluded; users running go vet -help or browsing linter docs will see only half the picture.

💡 Suggested Doc string

Consider expanding the Doc field to capture the exclusion rules, matching the level of detail in the inline comments:

Doc: "reports time.After calls used as the channel-receive expression in a select CommClause "
     + "that is enclosed by a for or range loop; does not flag receives inside case bodies, "
     + "or selects enclosed only by a function literal boundary",

This makes the linter self-documenting for anyone who has not read the source, and aligns with Go analysis conventions where Doc is the primary user-facing description.

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.

Expanded. The Doc string now captures both what is flagged and what is excluded: "reports time.After calls used as the channel-receive expression in a select CommClause that is enclosed by a for or range loop; does not flag receives inside case bodies, single-case selects without a default, or selects enclosed only by a function literal boundary".

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.

Inaccurate Doc string: time.After leaks timer channels, not goroutines.

💡 Details

The string says "leak a timer goroutine on each iteration". time.After does not create a goroutine per call — the Go runtime uses a single internal timer heap. What leaks is the time.Timer object and its associated channel, which cannot be garbage-collected until the timer fires.

Suggested correction:

Doc: "reports time.After calls in select cases inside loops that leak a timer channel on each iteration when another case fires first",

The diagnostic message on line 59 already uses the correct phrasing ("not garbage collected until it fires"), so aligning the Doc string removes the contradiction.

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.

Fixed together with the other Doc-string thread — "goroutine" is gone, replaced with the accurate description of timer channel accumulation.

URL: "https://github.com/github/gh-aw/tree/main/pkg/linters/timeafterleak",
Requires: []*analysis.Analyzer{inspect.Analyzer},
Run: run,
}
Comment on lines +21 to +28

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.

Fixed in the latest commit. The Doc string now reads: "reports time.After calls used as the channel-receive expression in a select CommClause that is enclosed by a for or range loop; does not flag receives inside case bodies, single-case selects without a default, or selects enclosed only by a function literal boundary" — no goroutine claim, and the exclusion rules are explicit.


func run(pass *analysis.Pass) (any, error) {
insp, err := astutil.Inspector(pass)
if err != nil {
return nil, err
}
noLintLinesByFile := nolint.BuildLineIndex(pass, "timeafterleak")

for cur := range insp.Root().Preorder((*ast.CallExpr)(nil)) {
call, ok := cur.Node().(*ast.CallExpr)
if !ok {
continue
}
if !isTimeAfterCall(pass, call) {
continue
}

pos := pass.Fset.PositionFor(call.Pos(), false)
if filecheck.IsTestFile(pos.Filename) {
continue
}
if nolint.HasDirective(pos, noLintLinesByFile) {
continue
}

if !isInsideLoopSelectComm(cur) {
continue
}

pass.ReportRangef(call,
"time.After creates a new timer on each loop iteration that is not garbage collected until it fires; use time.NewTimer with Reset and Stop instead")
}

return nil, nil
}

// isTimeAfterCall reports whether call is an invocation of time.After.
func isTimeAfterCall(pass *analysis.Pass, call *ast.CallExpr) bool {
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "After" {
return false
}
ident, ok := sel.X.(*ast.Ident)
if !ok {
return false
}
obj := pass.TypesInfo.ObjectOf(ident)
if obj == nil {
return false
}
pkgName, ok := obj.(*types.PkgName)
if !ok {
return false
}
return pkgName.Imported().Path() == "time"
}

// isInsideLoopSelectComm reports whether cur is a time.After call used as the
// channel receive expression in the Comm field of a select CommClause that is
// enclosed by a for or range loop, without crossing a function literal boundary.
func isInsideLoopSelectComm(cur inspector.Cursor) bool {
// The immediate parent of time.After(...) must be a channel-receive UnaryExpr.
recvCur := cur.Parent()
unary, ok := recvCur.Node().(*ast.UnaryExpr)
if !ok || unary.Op != token.ARROW {
return false
}

// The parent of the receive expression must be the Comm statement of a CommClause.
// Comm is an ExprStmt (case <-ch:) or AssignStmt (case v := <-ch:).
commStmtCur := recvCur.Parent()
var commStmt ast.Stmt
switch s := commStmtCur.Node().(type) {
case *ast.ExprStmt:
commStmt = s
case *ast.AssignStmt:
commStmt = s
default:
return false
}

// The parent of the Comm statement must be a CommClause, and commStmt must
// be the Comm field (not a statement in the Body).
clauseCur := commStmtCur.Parent()
cc, ok := clauseCur.Node().(*ast.CommClause)
if !ok || cc.Comm != commStmt {
return false
}

// Walk up from the CommClause to find an enclosing for or range loop,

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.

False positive: a single-case select with no default cannot leak a timer.

💡 Explanation and suggested fix

When time.After is the only case in a select (no default, no other channel), there is no alternative path that can be taken instead — the timer must fire before the loop continues. The linter will incorrectly flag this valid pattern:

for {
    select {
    case <-time.After(time.Second): // falsely flagged, but timer always fires
        doWork()
    }
}

After confirming the CommClause at line 113-116, check that the enclosing SelectStmt has at least one other CommClause (or a default: case). A default: clause can preempt the timer and should still be flagged.

// Guard: single-case select (no default) cannot preempt the timer.
var sel *ast.SelectStmt
for selCur := range clauseCur.Enclosing((*ast.SelectStmt)(nil)) {
    sel, _ = selCur.Node().(*ast.SelectStmt)
    break
}
if sel == nil {
    return false
}
hasOtherCase := false
for _, stmt := range sel.Body.List {
    if other, ok2 := stmt.(*ast.CommClause); ok2 && other != cc {
        hasOtherCase = true
        break
    }
}
if !hasOtherCase {
    return false
}

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.

Fixed. isInsideLoopSelectComm now finds the enclosing SelectStmt and counts its CommClause children. If the time.After case is the only clause (no other channel case, no default), it returns false — the timer must fire so no accumulation is possible. A default clause (nil Comm) is counted as "another case" and still triggers the diagnostic.

// stopping at any function literal or declaration boundary.
for encl := range clauseCur.Enclosing(
(*ast.ForStmt)(nil),
(*ast.RangeStmt)(nil),
(*ast.FuncLit)(nil),
(*ast.FuncDecl)(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.

[/grill-with-docs] *ast.FuncDecl in the stop-set is unreachable dead code — named function declarations cannot appear inside function bodies in Go.

💡 Details

In Go, only FuncLit (anonymous function expressions) can be nested inside another function. A FuncDecl always lives at the package level, so the AST ancestor chain of any CommClause inside a function body will never contain a FuncDecl node between the CommClause and the enclosing loop.

Removing (*ast.FuncDecl)(nil) from the Enclosing call and the case *ast.FuncLit, *ast.FuncDecl: branch removes the confusion without changing behavior. If there is a reason to keep it (e.g. future-proofing for a hypothetical change in Go), a short comment explaining the intent would prevent future readers from questioning it.

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.

Removed. (*ast.FuncDecl)(nil) is gone from the Enclosing call and the case *ast.FuncLit, *ast.FuncDecl: branch is now just case *ast.FuncLit:. The function-level comment was updated to explain why only FuncLit is needed.

) {
switch encl.Node().(type) {
case *ast.ForStmt, *ast.RangeStmt:
return true
case *ast.FuncLit, *ast.FuncDecl:
return false
}
}
return false
}
16 changes: 16 additions & 0 deletions pkg/linters/timeafterleak/timeafterleak_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//go:build !integration

package timeafterleak_test

import (
"testing"

"golang.org/x/tools/go/analysis/analysistest"

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

func TestAnalyzer(t *testing.T) {
testdata := analysistest.TestData()
analysistest.Run(t, testdata, timeafterleak.Analyzer, "timeafterleak")
}
Loading