A static analyzer that finds goroutines which can outlive the function that started them — at compile time, with no runtime, no instrumentation, and no test that has to happen to trigger the leak.
$ tether ./...
server.go:42:2: goroutine can block forever: it sends on unbuffered channel "results"
but the caller can return without receiving
server.go:43:3: this send parks until someone receives
server.go:49:3: caller leaves here without receiving, stranding the goroutine
Goroutine leaks are the concurrency bug Go tooling is worst at.
A data race gets you the race detector. A deadlock in main gets you a runtime panic. A leaked goroutine gets you nothing: no stack trace, no panic, no error, no failing test. The process just holds a little more memory after every request until, days later, something falls over and the trace tells you nothing about the code that caused it.
The existing answers all run at runtime and all require the leak to actually happen:
| Tool | When it works | What it needs |
|---|---|---|
uber-go/goleak | test time | a test that triggers the exact leaking path |
runtime.NumGoroutine() metrics | production | the leak to already be in production |
pprof goroutine dump | production | someone to notice and go look |
goroutineleak profile (Go 1.26, experimental) | runtime | the leak to be live in the profiled window |
Every one of those is a smoke detector. tether is a code reading.
The 2025 Go Developer Survey found that 58% of teams cannot identify performance issues in a running Go program and 57% cannot identify resource-usage inefficiencies. Goroutine leaks are the sharp edge of that: the failure mode is invisible by construction. The cheapest place to catch one is before it merges.
Every go statement quietly creates a proof obligation — this goroutine must eventually finish. Nothing in Go checks it.
tether tries to discharge that obligation. For each go statement it searches for a termination witness: a concrete, checkable reason the goroutine ends.
| Witness | Meaning |
|---|---|
| rendezvous | every path from the go to the caller's return receives the value |
| buffered | the channel has room for every send the goroutine can make on one run |
| cancellable | every blocking operation sits in a select with a cancellation arm |
| sibling | another goroutine started here drains the channel |
| deferred | a deferred closure receives, so it runs on every exit including panics |
| closed | the channel being ranged over is closed somewhere |
| bounded | no unbounded loop, no blocking operation |
When no witness holds, tether reports the goroutine and points at the statement that lets the parent walk away — usually an early return or a competing select arm. That last part is the difference between a linter and a bug report.
func Fetch(ctx context.Context) (Result, error) {
ch := make(chan Result)
go func() { ch <- expensive() }() // <- tether reports this
select {
case r := <-ch:
return r, nil
case <-ctx.Done():
return Result{}, ctx.Err() // <- and points here
}
}
When the context wins the race, nothing ever receives. The worker is parked on that send for the life of the process. Every timed-out request leaks one goroutine and everything expensive() captured.
The fix is one character — make(chan Result, 1) — and tether goes quiet, because the buffered witness now holds.
context.Context and never wired it up. The context parameter is the signal that cancellation was intended; a bare for {} in main is a server loop, not a bug, and tether has no opinion about it.for v := range ch ends exactly when ch is closed. If no close(ch) exists anywhere in the package, the goroutine is permanent.Wait — Add inside the goroutine racing Wait, and a bare Done that some path skips.Run across every package in Go 1.25's std:
_test.go files, which is exactly where this bug lives: test helpers race a worker against a timeout and abandon it when the timeout wins.Every finding I hand-checked was real. A few examples:
bufio/bufio_test.go:188 — the canonical shape. If the one-second timeout wins, nobody ever receives from c and the ReadByte goroutine is parked forever.
c := make(chan error)
go func() { _, err := r.ReadByte(); c <- err }()
select {
case err := <-c: ...
case <-time.After(time.Second):
t.Error("test timed out (endless loop in ReadByte?)")
}
crypto/tls/handshake_client_test.go:2894 — an unbuffered done channel that the subtest never receives from at all. One leaked goroutine per subtest.
database/sql/sql_test.go:674 — a genuine WaitGroup hang. On the error path the goroutine returns before saturateDone.Done(), so saturate.Wait() would block forever:
go func() {
rows, err := db.Query("SELECT|people|name,photo|")
if err != nil {
t.Errorf("Query: %v", err)
return // <- Done() never runs
}
rows.Close()
saturateDone.Done()
}()
A leak detector that flags working code gets switched off in a week, and then it detects nothing. So roughly half of tether's test suite is correct concurrent Go that must produce no output — buffered escapes, sends under select, producer/consumer pairs, done-channel workers, textbook WaitGroup use, channels whose ownership leaves the function.
Getting the false-positive rate down was most of the work, and each fix came from a real disagreement with real code:
| What tether got wrong | Why | Fix |
|---|---|---|
| flagged the canonical leak's safe twin | the CFG evaluates every select comm clause before the branch, so <-ch appears on the path that took a different arm | attribute the receive to the chosen case body |
flagged if err != nil { ch <- err } else { ch <- ok } | two send sites, but only one send happens | count the maximum sends over any single path, not syntactically |
flagged testing.runExample | the receive was inside a defer func(){...}() | defer runs on every exit — a deferred receive is a witness on all paths at once |
flagged for i := 0; i < N; i++ { <-done } | a counted loop drains exactly N values; no path analysis can see that | decline to judge when a receive sits in a loop |
flagged every blocking select | the CFG has a "no case was ready" fall-through edge, which a select without default can never take | prune that phantom edge |
flagged net/tcpsock_unix_test.go's accept loop | Add inside a goroutine is safe when that goroutine already holds a count via defer Done() | check the real invariant, not the folk rule |
flagged return func() { <-c } | the receive was captured by a returned closure | treat a channel used by a non-launched closure as escaping |
That sequence took the standard library from 91 diagnostics to 21, without losing a single true positive.
tether is neither sound nor complete, on purpose. Deciding whether a goroutine terminates is the halting problem; every tool here picks a side. tether is tuned for precision — it reports only when it can exhibit a concrete escape path, and stays quiet whenever analysis is inconclusive:
make that created the channel isn't visibleIt would rather miss a leak than cry wolf.
go install github.com/tachyurgy/tether/cmd/tether@latest
tether ./... # standalone
go vet -vettool=$(which tether) ./... # inside go vet
As a library, in a custom multichecker or a golangci-lint plugin:
import "github.com/tachyurgy/tether/tether"
multichecker.Main(tether.Analyzer, /* ... */)
tether.Analyzer is a standard golang.org/x/tools/go/analysis analyzer, so it composes with anything that speaks that interface.
analyzer.go — the analyzer, and the pre-passes that record which channels get closed, which nodes are select comm clauses, and which select fall-through edges are phantoms.checks.go — the four checks, the witness search, the longest-path send count, the channel-escape analysis, and the CFG reachability core. The whole engine is one question — does some path reach a return without satisfying this predicate? — asked about different predicates.util.go — AST helpers, including a walk that deliberately does not descend into nested function literals. Code inside a closure runs on another goroutine's stack at another time; folding it into the current function's reasoning is how analyzers invent bugs that do not exist.Built on go/ast, go/types, and golang.org/x/tools/go/cfg. No dependencies beyond x/tools.
go test ./...
Uses analysistest, which fails on both a missing diagnostic and an unexpected one — so the single test run pins recall and precision together.
MIT
1 commits
Go
100.0%
A static analyzer that finds goroutines which can outlive the function that started them — at compile time, with no runtime, no instrumentation, and no test that has to happen to trigger the leak.
$ tether ./...
server.go:42:2: goroutine can block forever: it sends on unbuffered channel "results"
but the caller can return without receiving
server.go:43:3: this send parks until someone receives
server.go:49:3: caller leaves here without receiving, stranding the goroutine
Goroutine leaks are the concurrency bug Go tooling is worst at.
A data race gets you the race detector. A deadlock in main gets you a runtime panic. A leaked goroutine gets you nothing: no stack trace, no panic, no error, no failing test. The process just holds a little more memory after every request until, days later, something falls over and the trace tells you nothing about the code that caused it.
The existing answers all run at runtime and all require the leak to actually happen:
| Tool | When it works | What it needs |
|---|---|---|
uber-go/goleak | test time | a test that triggers the exact leaking path |
runtime.NumGoroutine() metrics | production | the leak to already be in production |
pprof goroutine dump | production | someone to notice and go look |
goroutineleak profile (Go 1.26, experimental) | runtime | the leak to be live in the profiled window |
Every one of those is a smoke detector. tether is a code reading.
The 2025 Go Developer Survey found that 58% of teams cannot identify performance issues in a running Go program and 57% cannot identify resource-usage inefficiencies. Goroutine leaks are the sharp edge of that: the failure mode is invisible by construction. The cheapest place to catch one is before it merges.
Every go statement quietly creates a proof obligation — this goroutine must eventually finish. Nothing in Go checks it.
tether tries to discharge that obligation. For each go statement it searches for a termination witness: a concrete, checkable reason the goroutine ends.
| Witness | Meaning |
|---|---|
| rendezvous | every path from the go to the caller's return receives the value |
| buffered | the channel has room for every send the goroutine can make on one run |
| cancellable | every blocking operation sits in a select with a cancellation arm |
| sibling | another goroutine started here drains the channel |
| deferred | a deferred closure receives, so it runs on every exit including panics |
| closed | the channel being ranged over is closed somewhere |
| bounded | no unbounded loop, no blocking operation |
When no witness holds, tether reports the goroutine and points at the statement that lets the parent walk away — usually an early return or a competing select arm. That last part is the difference between a linter and a bug report.
func Fetch(ctx context.Context) (Result, error) {
ch := make(chan Result)
go func() { ch <- expensive() }() // <- tether reports this
select {
case r := <-ch:
return r, nil
case <-ctx.Done():
return Result{}, ctx.Err() // <- and points here
}
}
When the context wins the race, nothing ever receives. The worker is parked on that send for the life of the process. Every timed-out request leaks one goroutine and everything expensive() captured.
The fix is one character — make(chan Result, 1) — and tether goes quiet, because the buffered witness now holds.
context.Context and never wired it up. The context parameter is the signal that cancellation was intended; a bare for {} in main is a server loop, not a bug, and tether has no opinion about it.for v := range ch ends exactly when ch is closed. If no close(ch) exists anywhere in the package, the goroutine is permanent.Wait — Add inside the goroutine racing Wait, and a bare Done that some path skips.Run across every package in Go 1.25's std:
_test.go files, which is exactly where this bug lives: test helpers race a worker against a timeout and abandon it when the timeout wins.Every finding I hand-checked was real. A few examples:
bufio/bufio_test.go:188 — the canonical shape. If the one-second timeout wins, nobody ever receives from c and the ReadByte goroutine is parked forever.
c := make(chan error)
go func() { _, err := r.ReadByte(); c <- err }()
select {
case err := <-c: ...
case <-time.After(time.Second):
t.Error("test timed out (endless loop in ReadByte?)")
}
crypto/tls/handshake_client_test.go:2894 — an unbuffered done channel that the subtest never receives from at all. One leaked goroutine per subtest.
database/sql/sql_test.go:674 — a genuine WaitGroup hang. On the error path the goroutine returns before saturateDone.Done(), so saturate.Wait() would block forever:
go func() {
rows, err := db.Query("SELECT|people|name,photo|")
if err != nil {
t.Errorf("Query: %v", err)
return // <- Done() never runs
}
rows.Close()
saturateDone.Done()
}()
A leak detector that flags working code gets switched off in a week, and then it detects nothing. So roughly half of tether's test suite is correct concurrent Go that must produce no output — buffered escapes, sends under select, producer/consumer pairs, done-channel workers, textbook WaitGroup use, channels whose ownership leaves the function.
Getting the false-positive rate down was most of the work, and each fix came from a real disagreement with real code:
| What tether got wrong | Why | Fix |
|---|---|---|
| flagged the canonical leak's safe twin | the CFG evaluates every select comm clause before the branch, so <-ch appears on the path that took a different arm | attribute the receive to the chosen case body |
flagged if err != nil { ch <- err } else { ch <- ok } | two send sites, but only one send happens | count the maximum sends over any single path, not syntactically |
flagged testing.runExample | the receive was inside a defer func(){...}() | defer runs on every exit — a deferred receive is a witness on all paths at once |
flagged for i := 0; i < N; i++ { <-done } | a counted loop drains exactly N values; no path analysis can see that | decline to judge when a receive sits in a loop |
flagged every blocking select | the CFG has a "no case was ready" fall-through edge, which a select without default can never take | prune that phantom edge |
flagged net/tcpsock_unix_test.go's accept loop | Add inside a goroutine is safe when that goroutine already holds a count via defer Done() | check the real invariant, not the folk rule |
flagged return func() { <-c } | the receive was captured by a returned closure | treat a channel used by a non-launched closure as escaping |
That sequence took the standard library from 91 diagnostics to 21, without losing a single true positive.
tether is neither sound nor complete, on purpose. Deciding whether a goroutine terminates is the halting problem; every tool here picks a side. tether is tuned for precision — it reports only when it can exhibit a concrete escape path, and stays quiet whenever analysis is inconclusive:
make that created the channel isn't visibleIt would rather miss a leak than cry wolf.
go install github.com/tachyurgy/tether/cmd/tether@latest
tether ./... # standalone
go vet -vettool=$(which tether) ./... # inside go vet
As a library, in a custom multichecker or a golangci-lint plugin:
import "github.com/tachyurgy/tether/tether"
multichecker.Main(tether.Analyzer, /* ... */)
tether.Analyzer is a standard golang.org/x/tools/go/analysis analyzer, so it composes with anything that speaks that interface.
analyzer.go — the analyzer, and the pre-passes that record which channels get closed, which nodes are select comm clauses, and which select fall-through edges are phantoms.checks.go — the four checks, the witness search, the longest-path send count, the channel-escape analysis, and the CFG reachability core. The whole engine is one question — does some path reach a return without satisfying this predicate? — asked about different predicates.util.go — AST helpers, including a walk that deliberately does not descend into nested function literals. Code inside a closure runs on another goroutine's stack at another time; folding it into the current function's reasoning is how analyzers invent bugs that do not exist.Built on go/ast, go/types, and golang.org/x/tools/go/cfg. No dependencies beyond x/tools.
go test ./...
Uses analysistest, which fails on both a missing diagnostic and an unexpected one — so the single test run pins recall and precision together.
MIT
1 commits
Go
100.0%