Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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