🙄

Goのtesting/synctestでio.Pipeを使う場合はちゃんとCloseをする

に公開

Go 1.25(1.24でも環境変数を設定すれば使えます)から入ったtesting/synctestを使って一部のテストを移行しました。結果としてはかなり便利で、うまく動いています。ただ、実際に使っていてハマったポイントがあったので共有します。

io.Pipeを行単位で読む処理をテストしたときに、contextをキャンセルしてもデッドロックするという現象に遭遇しました。

具体的には、EOF(PipeWriterをClose)を送らないか、PipeReaderを明示的にCloseしない限り、goroutineが残ったままになり、テスト終了時にsynctestがデッドロックパニックを出します。

再現コード

実際に動かしてみたい人はこちらからやってみてください。

https://github.com/catatsuy/synctest_examples

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はこちらです。

https://github.com/catatsuy/notify_slack/pull/223

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に登録しました。

https://github.com/golang/go/issues/75052

興味のある方はぜひWatchしてみてください。

まとめ

  • testing/synctestは便利だが、io.Pipeはcontextを渡せないのでキャンセルだけでは止まらない
  • writerをCloseしてEOFを送るか、readerをCloseWithErrorで強制終了する必要がある
  • synctestならgoroutineの終了を制御できるので、テストが安定する

Discussion