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 using bufio.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

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