Go version
go version go1.25.0 darwin/arm64
Output of go env
in your module/workspace:
AR='ar'
CC='clang'
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_ENABLED='1'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
CXX='clang++'
GCCGO='gccgo'
GO111MODULE=''
GOARCH='arm64'
GOARM64='v8.0'
GOAUTH='netrc'
GOBIN=''
GOCACHE='/Users/catatsuy/Library/Caches/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='/Users/catatsuy/Library/Application Support/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -arch arm64 -pthread -fno-caret-diagnostics -Qunused-arguments -fmessage-length=0 -ffile-prefix-map=/var/folders/ys/lyy3yrnx07d9_2sdq_gb5ys00000gn/T/go-build3620988728=/tmp/go-build -gno-record-gcc-switches -fno-common'
GOHOSTARCH='arm64'
GOHOSTOS='darwin'
GOINSECURE=''
GOMOD='/Users/catatsuy/go/src/github.com/catatsuy/synctest_examples/go.mod'
GOMODCACHE='/Users/catatsuy/go/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='darwin'
GOPATH='/Users/catatsuy/go'
GOPRIVATE=''
GOPROXY='direct'
GOROOT='/usr/local/go'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='/Users/catatsuy/Library/Application Support/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN=''
GOTOOLDIR='/usr/local/go/pkg/tool/darwin_arm64'
GOVCS=''
GOVERSION='go1.25.0'
GOWORK=''
PKG_CONFIG='pkg-config'
What did you do?
I migrated some tests to testing/synctest
(Go 1.25). It works well for us.
One thing that surprised me: when using io.Pipe
with a line reader, canceling the context does not unblock the reader. If I don’t deliver EOF (close the writer) or explicitly close the reader, the reader goroutine remains and the bubble ends with a deadlock panic.
I’m sharing a tiny repro and a fix that helped me.
What did you see happen?
What I observed
- Implementation reads from
io.Pipe
usingbufio.Reader.ReadLine
in a background goroutine. - Test ends by calling
cancel()
(sometimes I close the writer, sometimes not). - With synctest, cancel-only leaves the reader blocked; at test end I get:
panic: deadlock: main bubble goroutine has exited but blocked goroutines remain
.
Minimal example (cancel-only → deadlock at bubble end)
package synctest_examples
import (
"bufio"
"context"
"io"
"testing"
"testing/synctest"
"time"
)
type Exec struct {
r *bufio.Reader
}
func NewExec(pr *io.PipeReader) *Exec {
return &Exec{r: bufio.NewReader(pr)}
}
func (ex *Exec) Start(ctx context.Context, interval <-chan time.Time, flush func(string), done func(string)) {
// Reader stops only on EOF.
go func() {
for {
_, _, err := ex.r.ReadLine()
if err != nil {
return
} // EOF only
}
}()
select {
case <-interval:
flush("")
case <-ctx.Done():
done("")
// returns, but the reader goroutine is still blocked if writer is not closed
}
}
func Test_IOPipe_cancel_deadlocks(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
pr, pw := io.Pipe()
ex := NewExec(pr)
ctx, cancel := context.WithCancel(context.Background())
interval := make(chan time.Time)
done := make(chan struct{})
go func() {
ex.Start(ctx, interval, func(string) {}, func(string) {})
close(done)
}()
// Write one line, then cancel WITHOUT closing pw:
pw.Write([]byte("abc\n"))
cancel()
<-done
// At test end, synctest panics:
// panic: deadlock: main bubble goroutine has exited but blocked goroutines remain
_ = pw
})
}
--- FAIL: Test_IOPipe_cancel_deadlocks (0.00s)
panic: deadlock: main bubble goroutine has exited but blocked goroutines remain [recovered, repanicked]
goroutine 5 [running]:
testing.tRunner.func1.2({0x104a69d80, 0x1400000c0c0})
/usr/local/go/src/testing/testing.go:1872 +0x190
testing.tRunner.func1()
/usr/local/go/src/testing/testing.go:1875 +0x31c
panic({0x104a69d80?, 0x1400000c0c0?})
/usr/local/go/src/runtime/panic.go:783 +0x120
internal/synctest.Run(0x140001061e0)
/usr/local/go/src/runtime/synctest.go:251 +0x2c4
testing/synctest.Test(0x14000003500, 0x104a81b18)
/usr/local/go/src/testing/synctest/synctest.go:282 +0x88
github.com/catatsuy/synctest_examples.Test_IOPipe_cancel_deadlocks(0x14000003500?)
/Users/catatsuy/go/src/github.com/catatsuy/synctest_examples/se_test.go:40 +0x24
testing.tRunner(0x14000003500, 0x104a81a80)
/usr/local/go/src/testing/testing.go:1934 +0xc8
created by testing.(*T).Run in goroutine 1
/usr/local/go/src/testing/testing.go:1997 +0x364
goroutine 33 [select (durable), synctest bubble 1]:
io.(*pipe).read(0x14000070300, {0x1400012d000, 0x1000, 0x0?})
/usr/local/go/src/io/pipe.go:57 +0x7c
io.(*PipeReader).Read(0x0?, {0x1400012d000?, 0x0?, 0x0?})
/usr/local/go/src/io/pipe.go:134 +0x24
bufio.(*Reader).fill(0x14000070360)
/usr/local/go/src/bufio/bufio.go:113 +0xe0
bufio.(*Reader).ReadSlice(0x14000070360, 0xa)
/usr/local/go/src/bufio/bufio.go:380 +0x30
bufio.(*Reader).ReadLine(0x14000070360)
/usr/local/go/src/bufio/bufio.go:409 +0x24
github.com/catatsuy/synctest_examples.(*Exec).Start.func1()
/Users/catatsuy/go/src/github.com/catatsuy/synctest_examples/se_test.go:24 +0x30
created by github.com/catatsuy/synctest_examples.(*Exec).Start in goroutine 8
/Users/catatsuy/go/src/github.com/catatsuy/synctest_examples/se_test.go:22 +0x70
FAIL github.com/catatsuy/synctest_examples 0.260s
FAIL
What worked for me (deliver EOF or close reader on cancel)
func Test_IOPipe_cancel_with_close_ok(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
pr, pw := io.Pipe()
ex := NewExec(pr)
ctx, cancel := context.WithCancel(context.Background())
interval := make(chan time.Time)
done := make(chan struct{})
go func() {
ex.Start(ctx, interval, func(string) {}, func(string) {})
close(done)
}()
pw.Write([]byte("abc\n"))
// Close to send EOF so the reader goroutine exits:
_ = pw.Close()
cancel()
<-done
})
}
refs: https://github.com/catatsuy/synctest_examples
In my production code, I also handled cancel by closing the reader when possible (keeping a *io.PipeReader
and calling CloseWithError(ctx.Err())
), then joining the reader goroutine before returning. That removed flakiness and made synctest happy.
Reference
My PR applying this idea: https://github.com/catatsuy/notify_slack/pull/223
It shows how I closed the reader on cancel and joined the goroutine. Sharing only as a real-world example; this may or may not fit other codebases.
What did you expect to see?
Suggestion
If maintainers think it’s useful, a short note like this in the docs would have helped me:
io.Pipe
readers do NOT unblock on context cancel. Close the writer (EOF) or close the reader (e.g.,CloseWithError
) to stop the reader goroutine before the bubble ends.
Thanks—synctest
has been great once I learned this edge.
Comment From: gabyhelp
Related Issues
- io: race condition with use of Pipe #67633 (closed)
- testing/synctest: bubble not terminating #74837 (closed)
- os: timeout in TestClosedPipeRace under race detector on Windows #37408 (closed)
- io: PipeWriter.Close causes deadlock since weekly.2012-03-27 #18401 (closed)
- cmd/go: tests timing out on linux-amd64 #21850 (closed)
- internal/poll: deadlock in Read on arm64 when an FD is closed #45211 (closed)
- testing: "panic: Log in goroutine after Test..." is unreliable due to lack of synchronization on t.done #67701
- os/exec: Current requirement for all reads to be completed before calling cmd.Wait can block indefinitely #60309 (closed)
- runtime: tests timing out #5025 (closed)
- compress/gzip causes deadlock when PipeReader is given. #13142 (closed)
(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)
Comment From: seankhliao
Currently the docs that describe this behaviour is under Blocking:
When every goroutine in a bubble is durably blocked: - Wait returns, if it has been called. - Otherwise, time advances to the next time that will unblock at least one goroutine, if there is such a time and the root goroutine of the bubble has not exited. - Otherwise, there is a deadlock and Test panics.
I think a section on the expectations of a well behaved test would be helpful rather than an explicit call out to io.Pipe.
Comment From: gopherbot
Change https://go.dev/cl/696736 mentions this issue: testing/synctest: call out common issues with tests
Comment From: gopherbot
Change https://go.dev/cl/696815 mentions this issue: testing/synctest: note that context cancel doesn’t unblock I/O
Comment From: TheCyberLocal
I’ve sent a CL to address this issue: CL 696815. The change clarifies that canceling a context does not unblock I/O operations in synctest.