-
Notifications
You must be signed in to change notification settings - Fork 18.4k
Description
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: catatsuy/notify_slack#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.