A Go static analyzer that flags iter.Pull/iter.Pull2 calls whose stop function can go uncalled, leaking the iterator's backing goroutine.
go build -o iterleak ./cmd/iterleak
go vet -vettool=$(pwd)/iterleak ./...iter.Pull and iter.Pull2 start a goroutine to run the wrapped sequence function and block it on the next call to next. That goroutine only exits on its own once the sequence is driven to exhaustion (next returns ok == false) or stop is called. Abandon the loop early — an error return, a break, a discarded result — without calling stop, and the goroutine sits blocked forever; nothing else can reach it to clean it up.
Measured: go run ./bench/leak_demo.go, 300 iterators each, comparing an abandoned pull against one driven fully to exhaustion, neither calling stop:
abandoned, stop never called: before=1 after=301 leaked=300
drained to exhaustion, stop never called: before=301 after=301 leaked=0
The first run leaks one goroutine per call. The second — see Limitations — does not, because iter.Pull's own goroutine exits once the wrapped sequence returns on its own.
go vet has no check for this. lostcancel catches the structurally identical case for context.WithCancel's cancel, but it only looks at context.With* calls. Checked directly:
$ go vet ./... # clean on the leaking snippet above
$ staticcheck ./... # staticcheck 2026.2.1 (0.8.1), clean on the same snippet
Neither flags it.
Checked against real code, not just the snippet above: 9 GitHub repositories found via GitHub code search for iter.Pull in Go files, covering both dedicated iterator-utility packages and inline uses in application code — pomerium/pomerium (pkg/iterutil), samber/lo (it/, ~40 call sites across tuple helpers), maypok86/otter (internal/xiter), openfga/openfga (internal/seq), opencost/opencost (core/pkg/util/iterutil), ahmetb/go-linq (whole module, 8 call sites), anacrolix/torrent (storage/file-piece.go), dop251/goja (runtime.go), shijuvar/gokit (examples/iterators), and BooleanCat/go-functional (it/). 7 of the 9 were clean. BooleanCat/go-functional's it/iter.go produced 2 findings, both true positives: Max and Min each call iter.Pull to peek at the first element, discard stop with _, then abandon that pull entirely to re-range over the original iterator from the start — the exact leak this analyzer exists to catch, in a real, currently-published library. No false positives were found in this sample.
go install github.com/kofiadeyemiq/iterleak/cmd/iterleak@latestOr build from a checkout: go build -o iterleak ./cmd/iterleak.
go vet -vettool=$(which iterleak) ./...iterleak ./...Import github.com/kofiadeyemiq/iterleak and add iterleak.Analyzer to a multichecker alongside other analyzers, or wire it into golangci-lint's module plugin system.
iterleak.Analyzer— the*analysis.Analyzer. Exported for embedding in amulticheckeror a linter that loads analyzers as a Go API.cmd/iterleak— asinglecheckerbinary wrappingiterleak.Analyzer, with its own exit-code remapping (see below).
Standalone CLI mode (iterleak ./...): 0 no findings, 1 findings reported, 2 the analysis itself could not run (build errors, bad flags, bad package patterns). singlechecker itself uses different codes — 0/1/3 for the same three cases (see go/analysis/internal/checker's exitCodeSuccess/exitCodeFailed/exitCodeDiagnostics in golang.org/x/tools@v0.35.0, and cmd/iterleak/main.go's own comments) — measured directly against the singlechecker binary before remapping:
clean: exit 0
findings: exit 3
broken: exit 1
Because that remapping function isn't part of singlechecker's public API, cmd/iterleak gets it by re-executing itself as a child process in CLI mode and translating the child's exit code before its own os.Exit. -vettool mode is untouched by this: go vet drives the binary through the unitchecker protocol (detected by a single *.cfg-file argument), which is a separate code path that calls singlechecker.Main directly, exactly as before.
go vet -vettool=./iterleak mode has its own exit code, set by go vet itself, not by this binary. Measured on go1.27.1: go vet -vettool exited 0 for both a clean package and one with findings (printing the tool's raw JSON protocol output rather than the pretty-printed diagnostics plain go vet produces), and exited 1 for a package that failed to build. This asymmetry — silently exiting 0 on real findings — is go vet's own handling of external vettools on this toolchain, not something cmd/iterleak controls; don't rely on go vet -vettool's exit code to detect findings; check its output, or use the standalone binary instead.
The analyzer is a close port of the standard library's lostcancel — reused deliberately, since the two problems have the same shape: a value returned alongside something more interesting must be referenced on every path out of the function, or a resource leaks.
- Fast path: skip the package if it doesn't import
iter. - Walk each function body (
FuncDecl/FuncLit) looking for calls whose callee resolves, throughtypes.Info, toiter.Pulloriter.Pull2— not by matching the identifier text "Pull", but by checking that the resolved*types.Func's package path isiter. This survives import aliases (import it "iter") and would not be fooled by an unrelated function that happens to be namedPull. - For each such call, find the second value it's assigned to (the
stopvar). If it's assigned to_, or the call's result isn't captured as a pair at all (used as a bare statement, or embedded in a larger expression), report immediately. - Otherwise, build the function's control-flow graph (
golang.org/x/tools/go/analysis/passes/ctrlflow) and search it, from the pointstopis defined, for a path to areturnthat never references thestopvariable.
A "use" is any identifier reference to the stop variable, anywhere in the function — a call (stop()), a defer stop(), a return stop, storage in a struct field, an argument passed to another function, or capture by a nested closure (even one that is itself returned or stored). This is the same trade lostcancel makes: once the value is handed to other code, that code is assumed responsible for it. It keeps the check to a single, auditable CFG search instead of tracking where a value ends up after it escapes the function, and it's why every "ownership transfer" case below is silent rather than flagged.
func main in package main is exempt: returning from it ends the process, which reclaims the goroutine regardless.
The CFG-search parts of iterleak.go are ported from lostcancel closely enough to carry its BSD-3-Clause license forward; see License for the exact terms and file.
- No dataflow across function boundaries.
stoppassed to a helper, stored in a struct, or returned is treated as used and never checked further — a helper that itself dropsstopon the floor is invisible to this analyzer. This is inherited directly fromlostcancel's design, not an oversight. - Panicking paths are out of scope. The CFG search follows normal control flow to
returnstatements; a path that leaves the function viapanicis not traced for an unusedstop. - Draining a sequence to exhaustion without calling
stopis a false positive, and the analyzer does not try to avoid it. Periter.Pull's doc comment,stop"must be called when ... next has not yet signaled that the sequence is over" — oncenexthas returnedok == false, callingstopis optional.bench/leak_demo.goconfirms this at runtime (see Why): 300 iterators driven to exhaustion,stopnever called,leaked=0. This analyzer still flags a discarded or unreferencedstopin that case, because it cannot statically prove a loop always drains its sequence to exhaustion.lostcancelmakes the same trade forcontext.WithTimeout, whosecancelit still requires even though that context would eventually expire on its own;iterleakfollows the same precedent for the same reason — a cheap, low-noise check over precisely modeling every case that happens not to leak today. - Not a general resource-leak checker. It only understands the two-result shape of
iter.Pull/iter.Pull2.
Built and tested with go1.27.1 darwin/arm64 against golang.org/x/tools@v0.35.0 (the oldest tagged release compatible with the go 1.23 floor iter.Pull itself requires). Not run on any other OS or architecture.
go build ./...
go vet ./...
go test -race -count=1 ./...
gofmt -l . # must print nothingTests use golang.org/x/tools/go/analysis/analysistest against testdata/src/a (positive cases and negative controls in one package) and testdata/src/main (the func main exemption).
MIT for this package. See LICENSE.
The CFG-search algorithm in iterleak.go (the isCall, preorderStack, and lostStopPath functions, and runFunc's traversal) is ported from golang.org/x/tools/go/analysis/passes/lostcancel, Copyright 2016 The Go Authors, under a separate BSD-3-Clause license reproduced verbatim in LICENSE-golang-x-tools.