tachyurgy/tether

Static analyzer that finds goroutines which can outlive the function that started them. Compile-time goroutine-leak detection with a termination-witness model.

0

stars

1

commits

Go

primary language

Jul 28, 2026

updated

README

tether

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

Why this exists

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:

ToolWhen it worksWhat it needs
uber-go/goleaktest timea test that triggers the exact leaking path
runtime.NumGoroutine() metricsproductionthe leak to already be in production
pprof goroutine dumpproductionsomeone to notice and go look
goroutineleak profile (Go 1.26, experimental)runtimethe 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.

The idea: a termination witness

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.

WitnessMeaning
rendezvousevery path from the go to the caller's return receives the value
bufferedthe channel has room for every send the goroutine can make on one run
cancellableevery blocking operation sits in a select with a cancellation arm
siblinganother goroutine started here drains the channel
deferreda deferred closure receives, so it runs on every exit including panics
closedthe channel being ranged over is closed somewhere
boundedno 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.

The canonical leak

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.

What it checks

  1. Send with no receiver. Path-sensitive: it counts the maximum sends on any single run of the goroutine against the buffer capacity and the receives the caller actually performs, then asks the control-flow graph whether any path to a return skips the rendezvous.
  2. A loop that cannot be stopped, in a function that was handed a 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.
  3. Ranging over a channel nobody closes. for v := range ch ends exactly when ch is closed. If no close(ch) exists anywhere in the package, the goroutine is permanent.
  4. WaitGroup misuse that hangs WaitAdd inside the goroutine racing Wait, and a bare Done that some path skips.

Results on the Go standard library

Run across every package in Go 1.25's std:

  • 21 diagnostics.
  • Zero in non-test code.
  • All 21 in _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()
}()

Precision is the whole product

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 wrongWhyFix
flagged the canonical leak's safe twinthe CFG evaluates every select comm clause before the branch, so <-ch appears on the path that took a different armattribute the receive to the chosen case body
flagged if err != nil { ch <- err } else { ch <- ok }two send sites, but only one send happenscount the maximum sends over any single path, not syntactically
flagged testing.runExamplethe 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 thatdecline to judge when a receive sits in a loop
flagged every blocking selectthe CFG has a "no case was ready" fall-through edge, which a select without default can never takeprune that phantom edge
flagged net/tcpsock_unix_test.go's accept loopAdd 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 closuretreat 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.

Soundness, honestly

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:

  • the goroutine runs a named function rather than a literal (the body may be behind an interface)
  • the channel is a parameter, a struct field, a global, returned, or captured by an escaping closure
  • the make that created the channel isn't visible
  • a receive sits inside a loop

It would rather miss a leak than cry wolf.

Install

go install github.com/tachyurgy/tether/cmd/tether@latest

Use

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.

How it works

  • 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.

Tests

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.

License

MIT

Contributors

tachyurgy

1 commits

tachyurgy/tether

Static analyzer that finds goroutines which can outlive the function that started them. Compile-time goroutine-leak detection with a termination-witness model.

0

stars

1

commits

Go

primary language

Jul 28, 2026

updated

README

tether

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

Why this exists

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:

ToolWhen it worksWhat it needs
uber-go/goleaktest timea test that triggers the exact leaking path
runtime.NumGoroutine() metricsproductionthe leak to already be in production
pprof goroutine dumpproductionsomeone to notice and go look
goroutineleak profile (Go 1.26, experimental)runtimethe 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.

The idea: a termination witness

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.

WitnessMeaning
rendezvousevery path from the go to the caller's return receives the value
bufferedthe channel has room for every send the goroutine can make on one run
cancellableevery blocking operation sits in a select with a cancellation arm
siblinganother goroutine started here drains the channel
deferreda deferred closure receives, so it runs on every exit including panics
closedthe channel being ranged over is closed somewhere
boundedno 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.

The canonical leak

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.

What it checks

  1. Send with no receiver. Path-sensitive: it counts the maximum sends on any single run of the goroutine against the buffer capacity and the receives the caller actually performs, then asks the control-flow graph whether any path to a return skips the rendezvous.
  2. A loop that cannot be stopped, in a function that was handed a 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.
  3. Ranging over a channel nobody closes. for v := range ch ends exactly when ch is closed. If no close(ch) exists anywhere in the package, the goroutine is permanent.
  4. WaitGroup misuse that hangs WaitAdd inside the goroutine racing Wait, and a bare Done that some path skips.

Results on the Go standard library

Run across every package in Go 1.25's std:

  • 21 diagnostics.
  • Zero in non-test code.
  • All 21 in _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()
}()

Precision is the whole product

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 wrongWhyFix
flagged the canonical leak's safe twinthe CFG evaluates every select comm clause before the branch, so <-ch appears on the path that took a different armattribute the receive to the chosen case body
flagged if err != nil { ch <- err } else { ch <- ok }two send sites, but only one send happenscount the maximum sends over any single path, not syntactically
flagged testing.runExamplethe 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 thatdecline to judge when a receive sits in a loop
flagged every blocking selectthe CFG has a "no case was ready" fall-through edge, which a select without default can never takeprune that phantom edge
flagged net/tcpsock_unix_test.go's accept loopAdd 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 closuretreat 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.

Soundness, honestly

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:

  • the goroutine runs a named function rather than a literal (the body may be behind an interface)
  • the channel is a parameter, a struct field, a global, returned, or captured by an escaping closure
  • the make that created the channel isn't visible
  • a receive sits inside a loop

It would rather miss a leak than cry wolf.

Install

go install github.com/tachyurgy/tether/cmd/tether@latest

Use

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.

How it works

  • 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.

Tests

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.

License

MIT

Contributors

tachyurgy

1 commits

Languages

Go

100.0%