-
Notifications
You must be signed in to change notification settings - Fork 424
[linter-miner] linter: add timeafterleak — flag time.After in for+select cases #39133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
62e4f37
b933cf3
c62860a
3eecc1c
89c7f44
94009ae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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` | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] The 💡 SuggestionPin the full diagnostic message in the 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All three |
||
| doWork() | ||
| case <-ctx.Done(): | ||
| return | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // BadForLoopAssign also leaks: the receive is the Comm of the case clause. | ||
|
Comment on lines
+19
to
+20
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in the latest commit: |
||
| 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 | ||
| } | ||
| } | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] Two fixture gaps: no 💡 Suggested additionsOther linters in this repo (e.g. // 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
}
}
}
}
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both added: |
||
| } | ||
|
|
||
| // 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. | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Misleading comment: the timer fires on every 💡 DetailsThe phrase "The timer fires exactly once here" is wrong in context. The function is inside a 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() {} | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing test case: single-case 💡 Suggested additionThe false positive described in the 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:
}
}
}
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both added: |
||
| 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", | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/grill-with-docs] The 💡 Suggested Doc stringConsider expanding the 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Expanded. The
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inaccurate 💡 DetailsThe string says "leak a timer goroutine on each iteration". 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in the latest commit. The |
||
|
|
||
| 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, | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. False positive: a single-case 💡 Explanation and suggested fixWhen for {
select {
case <-time.After(time.Second): // falsely flagged, but timer always fires
doWork()
}
}After confirming the // 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
}
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. |
||
| // 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), | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/grill-with-docs] 💡 DetailsIn Go, only Removing
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removed. |
||
| ) { | ||
| switch encl.Node().(type) { | ||
| case *ast.ForStmt, *ast.RangeStmt: | ||
| return true | ||
| case *ast.FuncLit, *ast.FuncDecl: | ||
| return false | ||
| } | ||
| } | ||
| return false | ||
| } | ||
| 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") | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
timeafterleakhas been added to bothpkg/linters/doc.go(count updated 24→25, alphabetical entry) andpkg/linters/README.md(overview list, subpackages table, and dependencies list).