Skip to content
gohacks.dev
Testing

Panic Test Timed Out After and How to Debug a Hung Test

Fix panic: test timed out after in Go. Read the goroutine dump, find the blocked test and what it waits on, and learn the usual causes and the timeout flag.

7 min read
Summarize in AI
Flat illustration of a test report with one failing test.

The error is this:

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

Text
panic: test timed out after 10m0s
	running tests:
		TestSomething (10m0s)

It means go test ran out of patience. Either one test is stuck, or the whole package has taken longer than the limit, which is ten minutes by default. The runtime kills the process and prints the stack of every goroutine, and that dump is long and looks hostile. It is also the most useful diagnostic Go gives you for a hang, if you know where to look.

This article reproduces the failure on Go 1.25.6, shows how to read the dump in four steps, lists the usual causes, and sets the flag so you find out in seconds, not minutes.

A test that hangs

main.goGo
package main

func main() {}
hang_test.goGo
package main

import (
	"sync"
	"testing"
)

func TestHangs(t *testing.T) {
	var mu sync.Mutex
	mu.Lock()
	done := make(chan struct{})
	go func() {
		mu.Lock() // never gets the lock
		close(done)
	}()
	<-done // the test waits for a goroutine that is stuck
}

TestHangs takes a lock in the test goroutine and never releases it. It then starts a goroutine that tries to take the same lock before closing a channel, and waits on that channel. The goroutine can never get the lock, so it never closes the channel, and the test waits forever. It is a small deadlock, and it is the shape of many real ones: two parties each waiting for the other.

Shell
go test -count=1 -timeout 1s . 2>&1 | sed -E 's/0x[0-9a-f]+/0xADDR/g; s/[0-9]+\.[0-9]+s/<t>/g; s#/[^ ]*/(hang_test\.go)#\1#; s/goroutine [0-9]+/goroutine N/; s#/opt/homebrew/Cellar/go/[0-9.]+/libexec#$GOROOT#; s/in [0-9]+ minutes/in N minutes/; s/\+0xADDR//; s/\t[0-9.]+s$//' | grep -vE '^\s*$' | head -34
Text
panic: test timed out after 1s
	running tests:
		TestHangs (1s)
goroutine N [running]:
testing.(*M).startAlarm.func1()
	$GOROOT/src/testing/testing.go:2682 
created by time.goFunc
	$GOROOT/src/time/sleep.go:215 
goroutine N [chan receive]:
testing.(*T).Run(0xADDR, {0xADDR?, 0xADDR?}, 0xADDR)
	$GOROOT/src/testing/testing.go:2005 
testing.runTests.func1(0xADDR)
	$GOROOT/src/testing/testing.go:2477 
testing.tRunner(0xADDR, 0xADDR)
	$GOROOT/src/testing/testing.go:1934 
testing.runTests(0xADDR, {0xADDR, 0xADDR, 0xADDR}, {0xADDR?, 0xADDR?, 0xADDR?})
	$GOROOT/src/testing/testing.go:2475 
testing.(*M).Run(0xADDR)
	$GOROOT/src/testing/testing.go:2337 
main.main()
	_testmain.go:45 
goroutine N [chan receive]:
example.test/lab.TestHangs(0xADDR?)
	hang_test.go:16 
testing.tRunner(0xADDR, 0xADDR)
	$GOROOT/src/testing/testing.go:1934 
created by testing.(*T).Run in goroutine N
	$GOROOT/src/testing/testing.go:1997 
goroutine N [sync.Mutex.Lock]:
internal/sync.runtime_SemacquireMutex(0xADDR?, 0xADDR?, 0xADDR?)
	$GOROOT/src/runtime/sema.go:95 
internal/sync.(*Mutex).lockSlow(0xADDR)
	$GOROOT/src/internal/sync/mutex.go:149 
internal/sync.(*Mutex).Lock(...)

The command uses -timeout 1s so the failure arrives immediately. Without it, the test would sit for ten minutes.

Reading the dump in four steps

Step 1: the first lines. The output starts with panic: test timed out after 1s, then running tests: and a list of the tests that were running when the timer fired: TestHangs (1s). That names the culprit. If several tests appear, they are running in parallel and any of them may be stuck. If none appears, the time was spent outside a test, in TestMain or in package initialisation.

Step 2: find the test goroutine. The dump lists goroutines separated by blank lines, each with a header like goroutine 23 [chan receive]:. Search for your test function's name. Here, the stack with example.test/lab.TestHangs and hang_test.go:16, headed [chan receive], is the test goroutine: it is blocked receiving from a channel, at line 16, which is the <-done.

Step 3: read the state. The text in square brackets is what the goroutine is waiting for. The common ones:

StateMeaning
chan receive, chan sendWaiting on a channel operation
select, select (no cases)Blocked in a select, or an empty one that blocks forever
sync.Mutex.Lock, sync.RWMutex.RLockWaiting for a lock
sync.WaitGroup.WaitWaiting for a group's counter to reach zero
sync.Cond.WaitWaiting for a signal
IO wait, syscallWaiting for the network or a system call
sleepIn time.Sleep
semacquireOlder name for lock and wait group waits

Our test goroutine is chan receive: it is waiting for someone to send or close.

Step 4: find the other side. Who was supposed to close done? The goroutine started by the test, whose header reads [sync.Mutex.Lock], with a stack through lockSlow and the test's anonymous function at the mu.Lock() line. It is blocked on the mutex. The mutex is held by the test goroutine, which is waiting for this one. Two goroutines, each waiting for the other: the deadlock, in plain view. The line created by testing.(*T).Run in goroutine N and similar lines tell you where each goroutine began.

The runtime's own deadlock detector, which prints fatal error: all goroutines are asleep - deadlock!, does not trigger here, because the testing package's timer goroutine is alive. Under go test a deadlock usually appears as this timeout.

The usual causes

  1. A channel nobody closes or sends on. A producer that returns on an error and never closes its output, and a consumer ranging over it. The fix is to close the channel with defer in the producer.
  2. A WaitGroup with a wrong count. Add called after the goroutine started, or Done skipped on an error path. Use defer wg.Done() first thing in the goroutine, and in Go 1.25 wg.Go(func() {...}).
  3. A lock taken twice or never released. As in the lab; check every Lock has a defer Unlock, and that no function called under a lock takes the same lock.
  4. A goroutine that ignores its context, so a test that cancels and waits for it waits forever.
  5. A test server that is never closed. srv.Close() blocks until outstanding requests finish; a handler that waits on something the test holds will hang it.
  6. Network or database calls with no deadline, against a service that is down. Give every external call a context with a timeout.
  7. A select with no default and no timeout on events that did not happen.
  8. An unbuffered channel send with no receiver, from the test goroutine.
  9. Real slowness. The suite grew and now exceeds ten minutes honestly. The dump will show a healthy test running, not a blocked one. Raise the limit or speed the suite up; see the slow suite guide.

Set the timeout so you find out sooner

The default of ten minutes is a ceiling for a whole package, and it is too generous for finding hangs. Set -timeout explicitly in CI, a few times the normal duration of the slowest package, such as -timeout 2m. A hang then fails the job in two minutes with the dump, and not in ten with the same dump. Keep the value on the command line or in GOFLAGS, so it applies everywhere.

For one test, give the test its own deadline: derive a context with context.WithTimeout, and wait on select with ctx.Done(). A test that fails with its own message after five seconds is easier to read than a package timeout. The pattern is in the goroutines and channels article and context cancellation tests.

To get the dump without waiting for a timeout, send the test process SIGQUIT (Ctrl and backslash in a terminal): the Go runtime prints every goroutine's stack and exits. That is how you inspect a test that has hung locally and which you do not want to wait for.

Getting more from the dump

  • GOTRACEBACK=all includes system goroutines, which is rarely needed. The default is already enough for user code.
  • Filter by your package. Search the output for your module path; goroutines with only runtime frames are rarely the cause.
  • Look for many identical goroutines. Four hundred goroutines blocked at the same line is a leak or a pool that is exhausted, which is a different bug from a deadlock of two.
  • Check created by. The creator frame names the code that spawned a stuck goroutine, which is often where the missing cancel or close belongs.
  • Use -race as well. A hang can be the result of a data race that corrupted a flag, and the detector reports it.

The behaviour of the flag is documented in the testing flags reference under -timeout, and the runtime's goroutine states are listed in the runtime package documentation on tracebacks. For preventing the problem in the first place, see flaky tests.

Also inTooling
Taggederror fix
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.