Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
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
42 changes: 42 additions & 0 deletions docs/adr/39133-custom-linter-for-time-after-leaks-in-loops.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# ADR-39133: Ship a Custom `timeafterleak` Linter for `time.After` in Loop Selects

**Date**: 2026-06-13
**Status**: Draft

## Context

`time.After(d)` allocates a fresh `*time.Timer` (and its backing runtime timer) on every call; the timer is not reclaimed until it fires. When a `time.After` call is the channel-receive of a `select` case inside a `for`/`range` loop, every iteration where another case fires first leaves a timer alive until its full duration elapses, leaking goroutine/heap resources in hot loops. This is a documented Go gotcha but is not flagged by any commonly enabled linter: `staticcheck` SA6001 only covers `time.Tick`, not `time.After` in select cases. A code scan of this repository found three production occurrences of the pattern, so the project needs an automated guard that fits its existing in-repo linter suite (`pkg/linters/*` registered in `cmd/linters/main.go`).

## Decision

We will add a bespoke `go/analysis` analyzer, `timeafterleak`, to the project's custom linter suite rather than relying on an external linter. The analyzer uses `inspector.Cursor` parent-chain traversal to flag a `time.After` call only when it is the `Comm` receive expression of a `select` `CommClause` enclosed by a `for` or `range` loop, stopping at `FuncLit`/`FuncDecl` boundaries and verifying the `time` package identity via type information rather than a syntactic name match. It honors `//nolint:timeafterleak` suppression and skips test files, and is registered in the multichecker in `cmd/linters/main.go`.

## Alternatives Considered

### Alternative 1: Rely on `staticcheck` / `golangci-lint` defaults
The project already runs standard linters, so extending their configuration would avoid new code. Rejected because no default rule (including SA6001) detects `time.After` inside select cases; the specific leak pattern would go uncaught.

### Alternative 2: Document the gotcha and rely on code review
A contributor guideline plus manual review requires no tooling. Rejected because manual review is unreliable for an easily overlooked pattern — three instances already reached production — and provides no enforceable, repeatable guard.

### Alternative 3: Use a syntactic (string-match) detector instead of type-checked detection
A simpler analyzer could match the identifier text `time.After` directly. Rejected because it would produce false positives for shadowed identifiers or unrelated packages; type-based identity checking is more precise at modest extra complexity.

## Consequences

### Positive
- Catches a real, otherwise-undetected resource-leak pattern automatically in CI.
- Follows the established in-repo linter convention, so it composes with the existing `cmd/linters` multichecker and shared `internal` helpers.
- Precise: type-checked `time` identity and Comm-position scoping minimize false positives; `FuncLit` boundary handling avoids flagging per-iteration goroutine closures.

### Negative
- Adds a custom analyzer that the team must maintain, including keeping pace with `go/ast`/`golang.org/x/tools` API changes.
- Narrow scope: only flags the loop-select Comm position, so other `time.After` misuse patterns remain uncaught and may create a false sense of full coverage.

### Neutral
- Suppression is available via `//nolint:timeafterleak` for intentional cases.
- Test files are excluded from analysis, matching the suite's existing conventions.

---

*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/27475470275) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*
Binary file modified linters
Binary file not shown.
3 changes: 3 additions & 0 deletions pkg/linters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ This package currently provides custom Go analyzers in the following subpackages
- `sortslice` — reports `sort.Slice` / `sort.SliceStable` calls that should use `slices.SortFunc` / `slices.SortStableFunc`.
- `ssljson` — validates `ssl.json` skill artifacts found in `.github/skills/` against the SSL spec (enum membership, graph integrity, transition targets, entry pointer validity).
- `strconvparseignorederror` — reports `strconv` parsing calls (`Atoi`, `ParseInt`, etc.) where the error return is discarded with `_`.
- `timeafterleak` — reports `time.After` calls used as the channel-receive expression in a `select` case inside a `for` or `range` loop that leak a timer channel on each iteration when another case fires first.
- `tolowerequalfold` — reports case-insensitive string comparisons using `strings.ToLower`/`ToUpper` that should use `strings.EqualFold`.
- `uncheckedtypeassertion` — reports single-value type assertions where unchecked panics are possible.
- `internal` — shared helper packages for analyzers (file checks and `nolint` handling).
Expand Down Expand Up @@ -62,6 +63,7 @@ This package currently provides custom Go analyzers in the following subpackages
| `sortslice` | Custom `go/analysis` analyzer that flags `sort.Slice` / `sort.SliceStable` calls that should use `slices.SortFunc` / `slices.SortStableFunc` |
| `ssljson` | Custom `go/analysis` analyzer that validates SSL JSON skill artifacts in `.github/skills/` |
| `strconvparseignorederror` | Custom `go/analysis` analyzer that flags `strconv` parsing calls where the error return is discarded with `_` |
| `timeafterleak` | Custom `go/analysis` analyzer that flags `time.After` in `select` cases inside loops that leak a timer channel on each iteration when another case fires first |
| `tolowerequalfold` | Custom `go/analysis` analyzer that flags case-insensitive comparisons via `strings.ToLower`/`ToUpper` that should use `strings.EqualFold` |
| `uncheckedtypeassertion` | Custom `go/analysis` analyzer that flags unchecked single-value type assertions |
| `internal` | Shared helper subpackages used by analyzers (`internal/filecheck`, `internal/nolint`) |
Expand Down Expand Up @@ -139,6 +141,7 @@ _ = ssljson.Analyzer
- `github.com/github/gh-aw/pkg/linters/sortslice` — sort-slice analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/ssljson` — ssl-json analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/strconvparseignorederror` — strconv-parse-ignored-error analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/timeafterleak` — time-after-leak analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/tolowerequalfold` — to-lower-equal-fold analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/uncheckedtypeassertion` — unchecked-type-assertion analyzer subpackage

Expand Down
3 changes: 2 additions & 1 deletion pkg/linters/doc.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Package linters is a namespace for gh-aw's custom Go analysis linters.
//
// The actual analyzers are implemented in subpackages. All 24 active analyzers:
// The actual analyzers are implemented in subpackages. All 25 active analyzers:
//
// - contextcancelnotdeferred — flags context cancel functions called directly instead of deferred
// - ctxbackground — flags context.Background() inside functions that already receive a context
Expand All @@ -24,6 +24,7 @@
// - sortslice — flags sort.Slice / sort.SliceStable calls that should use slices.SortFunc / slices.SortStableFunc
// - ssljson — validates ssl.json skill artifacts in .github/skills/ against the SSL spec
// - strconvparseignorederror — flags strconv parsing calls where the error is discarded with _
// - timeafterleak — flags time.After in select cases inside loops that leak timer channels
// - tolowerequalfold — flags case-insensitive comparisons via ToLower/ToUpper that should use EqualFold
// - uncheckedtypeassertion — flags unchecked single-value type assertions
//
Expand Down
163 changes: 163 additions & 0 deletions pkg/linters/timeafterleak/testdata/src/timeafterleak/timeafterleak.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
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 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 that is not garbage collected until it fires; use time\.NewTimer with Reset and Stop instead`
_ = 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 that is not garbage collected until it fires; use time\.NewTimer with Reset and Stop instead`
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).

}

// BadNestedLoop: the inner select is still inside a loop regardless of nesting depth.
func BadNestedLoop(ctx context.Context) {
for {
for {
select {
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`
case <-ctx.Done():
return
}
}
}
}

// BadSingleCaseWithDefault: a default clause 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 that is not garbage collected until it fires; use time\.NewTimer with Reset and Stop instead`
default:
}
}
}

// 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.
// 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
}
}

// GoodSingleCaseSelect: the select has only one case and no default, so the
// timer must fire before the loop continues — no timer accumulation is possible.
func GoodSingleCaseSelect() {
for {
select {
case <-time.After(time.Second): // single case, no default — not flagged
doWork()
}
}
}

// GoodNolintPreviousLine: suppressed with a nolint directive on the previous line.
func GoodNolintPreviousLine(ctx context.Context) {
for {
select {
//nolint:timeafterleak
case <-time.After(time.Second):
doWork()
case <-ctx.Done():
return
}
}
}

// GoodNolintSameLine: suppressed with a nolint directive on the same line.
func GoodNolintSameLine(ctx context.Context) {
for {
select {
case <-time.After(time.Second): //nolint:timeafterleak
doWork()
case <-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).

Loading
Loading