skip to content

A file-watching package's `New` starts a goroutine still visible in the goroutine profile after your tests finish — what is wrong with its exported API?

level: seniorimportance: should knowfreq 38%

answer

  1. the constructor did more than allocate
  2. no method whose return means it exited
  3. stacks dumped after m.Run identify the package
  4. signalled to stop is not the same as stopped
  5. the fix is a signature change, not a patch

basics

~20 s

The API started a lifetime it gave no way to end: work began in a constructor rather than a call the caller drives, and nothing exported signals that the goroutine has exited. The fix is a signature change.

solid answer

~60 s

Two separate defects show up here. First, work started in `New`: the caller allocated a value and got a running goroutine, so there was never a moment where they could decide not to run it. Second, there is no exit signal — even if the package has a `Stop`, if it only sets a flag or closes a done channel and returns immediately, the caller cannot know the goroutine has finished, which is exactly what a test at teardown and a daemon at shutdown both need. I would confirm it with the goroutine profile, dumping stacks after `m.Run` returns so I can see which function the leftover goroutines are parked in. The API-level fix is to make the blocking form the exported one — `Run(ctx) error` that returns when the watching is over — so the caller writes `go` and owns the join. If the package must keep starting it, then `New` allocates only, a separate call starts the work, and stopping returns only after the goroutine has exited.

code

go · 9 lines
go
func TestMain(m *testing.M) {
	before := runtime.NumGoroutine()
	code := m.Run()
	if runtime.NumGoroutine() > before {
		// debug=1 prints one stack per goroutine
		_ = pprof.Lookup("goroutine").WriteTo(os.Stderr, 1)
	}
	os.Exit(code)
}

go deeper

for a junior

Know that a goroutine keeps running until its function returns, and that nothing about dropping the value that started it will stop it. If a package starts one, look for the method that ends it.

for a middle

Be able to take a goroutine profile with stacks and read which function the leftovers are parked in, and to explain why work started in a constructor gives the caller no point at which to decline.

for a senior

Diagnose from the exported signatures, not just the stack: constructor-started work, a stop that only signals, a context captured at construction, an undocumented goroutine count. Propose the signature change and say what it costs existing callers.

for a principal

Weigh the migration: whether to fork, wrap, or push a fix upstream, and how you would stage a signature change that several embedded teams already build shutdown ordering around.

## The symptom Your daemon embeds a third package. Its tests pass, but a goroutine dump taken after the test binary's `m.Run` returns still shows goroutines parked inside that package. Nothing failed. Nothing panicked. In the daemon itself the same goroutines simply accumulate — one per constructed value — and the process's memory floor rises over days. This is not a concurrency bug in the ordinary sense. There is no race and no deadlock in your code. It is an API defect: the package made a lifetime promise it gave the caller no way to end. ## Reading the goroutine profile The plain `goroutine` profile lists every live goroutine with its stack, which is exactly what you want here — it shows *where* the leftovers are parked, and that stack usually names the offending package's internal loop function. From a test binary, take it after the tests are done: A count comparison is enough to detect the problem; the stacks are what identify it. Compare the goroutine count before `m.Run` with the count after, and when it has grown, write the profile with stacks so you can see the function names. Two cautions. A count taken immediately after `m.Run` can be noisy — runtime and test-framework goroutines exist too, and a goroutine that is on its way out may still be counted — so treat a small difference as a prompt to look at stacks rather than a verdict. And the goroutine profile is a snapshot of what is alive, not an analysis of what is stuck: a package goroutine spinning on a ticker is just as much a leak from your point of view, but it is not blocked, so a leak-specific analysis would not flag it while the plain profile shows it plainly. ## Reading the API instead Once you know which package it is, the diagnosis is in its exported signatures. Look for these: **Work in the constructor.** `New` that starts anything means the caller never had a point at which they could decline. It also means the goroutine's lifetime is tied to a value's construction rather than to a call, so the natural question "when does this stop?" has no natural answer. **No exit signal.** The strongest form is a method whose *return* means the goroutine has exited. A `Stop()` that closes a done channel and returns immediately is a request, not a guarantee — the caller who then asserts on the goroutine count still fails, intermittently, which is worse than failing consistently. **A context taken at construction.** A `New(ctx, ...)` that stores the context to give its background goroutine a lifetime is the same defect wearing a hat. Callers pass `context.Background()` because that is what `main` has, and nothing ever cancels. **No documented goroutine count.** If it is one per value, that is a promise. If it is one per subscription, one per watched directory, or one per reconnect, the caller's leak scales with their usage in a way nothing at the call site suggests. ## The fix, expressed as signatures ### Preferred: export the blocking form ``` func (w *Watcher) Run(ctx context.Context) error ``` The caller writes `go w.Run(ctx)` in their own code, keeps whatever they use to join, and cancels the context at shutdown. Now the goroutine belongs to the program that started it, it appears in their shutdown sequence naturally, and their test can wait for `Run` to return before asserting anything. The package has stopped owning a lifetime it cannot see. ### Acceptable: split construction from start, and make stop mean exited If the type genuinely cannot work without owning its goroutine — it multiplexes one inotify-style handle among many subscribers, say — then: 1. `New` allocates and validates, and starts nothing. 2. A separate call starts the work, so the call site shows where concurrency begins. 3. Exactly one stop, whose contract is written as "returns after the background goroutine has exited" — not "signals the watcher to stop". 4. The goroutine count is documented and bounded. ### What does not fix it Documenting the goroutine without exporting a way to end it. Adding a finalizer — goroutines are roots, so a running goroutine keeps its value alive rather than the other way round, and a value with a live goroutine is never collected. Telling callers to "just leak it, the process exits eventually", which is true for a command and false for the daemon this package was written to be embedded in. ## What to say in the review The useful framing for the package's author is not "you leaked a goroutine". It is: your exported API promises a lifetime and exports no way to observe it ending, so every embedder must either accept the leak or fork you. The change that fixes it is a signature change, and it is much cheaper now than after three teams have built shutdown code around the current shape.

  • Why is a `Stop()` that returns immediately not good enough?
    Because it reports that a stop was requested, not that anything has finished. A daemon shutting down needs to know the goroutine has released its handles before the next stage runs, and a test needs it before asserting. The fix is to make the return the guarantee: Stop waits for the goroutine to exit, or returns an error if it does not within a documented bound.
  • Would a leak-specific analysis have caught this goroutine?
    Only if it was permanently blocked. A goroutine parked forever on a receive nobody will send to is a classic leak and shows up in leak-oriented analysis. A goroutine happily looping on a ticker is not blocked at all, so only the plain goroutine profile, which lists everything alive, reveals it — and from the embedder's point of view both are the same problem.
  • Could a finalizer clean this up when the caller drops the value?
    No, and the direction is backwards. A running goroutine is a root: it keeps everything it references reachable, so a value with a live goroutine is never collected and the finalizer never runs. Lifetime for background work has to be explicit in the API; garbage collection deliberately says nothing about it.
  • How would you raise this with the package's author?
    As an API problem rather than a bug report: the exported surface promises a lifetime and offers nothing that observes it ending, so every embedder must accept the leak or fork. Propose the concrete signature — a blocking Run the caller starts, or New that allocates only plus a stop that returns after exit — and point out that the change gets more expensive with each team that builds shutdown code on the current shape.

saying these in an interview costs you the question

  • Blames the caller for not reading the source
  • Adds a finalizer and expects it to stop the goroutine
  • Claims the garbage collector reclaims goroutines that become unreachable
  • Treats a Stop that only signals as a complete shutdown story
  • Documents the goroutine instead of exporting a way to end it
  • Says it is fine because the process exits eventually