Goのtesting/synctestでio.Pipeを使う場合はちゃんとCloseをする
Go 1.25(1.24でも環境変数を設定すれば使えます)から入ったtesting/synctestを使って一部のテストを移行しました。結果としてはかなり便利で、うまく動いています。ただ、実際に使っていてハマったポイントがあったので共有します。
io.Pipeを行単位で読む処理をテストしたときに、contextをキャンセルしてもデッドロックするという現象に遭遇しました。
具体的には、EOF(PipeWriterをClose)を送らないか、PipeReaderを明示的にCloseしない限り、goroutineが残ったままになり、テスト終了時にsynctestがデッドロックパニックを出します。
再現コード
実際に動かしてみたい人はこちらからやってみてください。
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)) {
// EOF以外では止まらないReader
go func() {
for {
_, _, err := ex.r.ReadLine()
if err != nil {
return
}
}
}()
select {
case <-interval:
flush("")
case <-ctx.Done():
done("")
// writerが閉じられていない場合、readerはここで残り続ける
}
}
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)
}()
pw.Write([]byte("abc\n"))
cancel()
<-done
// この状態でテスト終了するとデッドロックパニック
_ = pw
})
}
これを実行すると、テスト終了時に以下のようなエラーになります。
panic: deadlock: main bubble goroutine has exited but blocked goroutines remain
回避策
cancel()だけでは解除されません。io.Pipeの特性上、EOFを届けるにはwriter(*io.PipeWriter)をClose()する必要があります。
また、起動したgoroutineは必ず終了を待つ必要があります。
修正版の例(writerを閉じてEOFを送る+終了待ち):
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) // goroutine終了を通知
}()
pw.Write([]byte("abc\n"))
_ = pw.Close() // EOFを送ってreaderを終了させる
cancel()
<-done // goroutineの終了を待つ
})
}
実運用コードでの対応
自分のプロダクションコードでは、キャンセル時にPipeReader.CloseWithError(ctx.Err())を呼び出し、goroutineの終了を待ってから関数を返すようにしました。これで不安定さがなくなり、synctestも問題なく動作しています。
参考として、実際にこの対応を行ったPRはこちらです。
synctestでgoroutineの終了を制御できるように
goroutine依存の実装を作ると、挙動がruntime依存になりテストがflakyになることにずっと悩んでいました。
これまでは、time.Sleepでgoroutineの実行が終わることを祈るしかなく、Goのバージョンアップのたびに挙動が変わってテストが壊れ、そのたびにSleepの秒数を少しずつ伸ばしていました。
synctestを使うと、その中で実行されているgoroutineは「synctestの世界」で動くので、テスト側から制御可能になります。
さらに、Wait()を使えば goroutineの終了を明示的に待つことができます。これは https://pkg.go.dev/testing/synctest にも書かれています。
Wait will block until the goroutine above has finished.
「flushが呼ばれるまで待つ」といった挙動も明確に制御できるようになり、これまで困難だったテストが安定して書けるようになりました。今後も使える場面では積極的に利用したいと思っています。
Goのドキュメントを追記したい
割とハマったので、Goのドキュメント自体に追記してもらえないかと考えてissueに登録しました。
興味のある方はぜひWatchしてみてください。
まとめ
-
testing/synctestは便利だが、io.Pipeはcontextを渡せないのでキャンセルだけでは止まらない - writerをCloseしてEOFを送るか、readerをCloseWithErrorで強制終了する必要がある
- synctestならgoroutineの終了を制御できるので、テストが安定する
Discussion