Skip to content

Commit 09c2677

Browse files
silverwindclaudewxiaoguang
authored
Fix flaky TestCatFileBatch/QueryTerminated test (go-gitea#37159)
`TestCatFileBatch/QueryTerminated` relied on timing to distinguish `os.ErrClosed` vs `io.EOF` error paths. Replace `time.Sleep`-based synchronization with a channel-based hook on pipe close, making both error paths fully deterministic regardless of CI runner speed. Ref: https://github.com/go-gitea/gitea/actions/runs/24193070536/job/70615366804 Co-authored-by: Claude (Opus 4.6) <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
1 parent 16d7817 commit 09c2677

2 files changed

Lines changed: 57 additions & 53 deletions

File tree

modules/git/catfile_batch_reader.go

Lines changed: 34 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,63 +13,45 @@ import (
1313
"strconv"
1414
"strings"
1515
"sync/atomic"
16-
"time"
1716

1817
"code.gitea.io/gitea/modules/git/gitcmd"
1918
"code.gitea.io/gitea/modules/log"
19+
"code.gitea.io/gitea/modules/util"
2020
)
2121

22-
var catFileBatchDebugWaitClose atomic.Int64
23-
2422
type catFileBatchCommunicator struct {
25-
closeFunc func(err error)
23+
closeFunc atomic.Pointer[func(err error)]
2624
reqWriter io.Writer
2725
respReader *bufio.Reader
2826
debugGitCmd *gitcmd.Command
2927
}
3028

31-
func (b *catFileBatchCommunicator) Close() {
32-
if b.closeFunc != nil {
33-
b.closeFunc(nil)
34-
b.closeFunc = nil
29+
func (b *catFileBatchCommunicator) Close(err ...error) {
30+
if fn := b.closeFunc.Swap(nil); fn != nil {
31+
(*fn)(util.OptionalArg(err))
3532
}
3633
}
3734

38-
// newCatFileBatch opens git cat-file --batch in the provided repo and returns a stdin pipe, a stdout reader and cancel function
39-
func newCatFileBatch(ctx context.Context, repoPath string, cmdCatFile *gitcmd.Command) (ret *catFileBatchCommunicator) {
35+
// newCatFileBatch opens git cat-file --batch/--batch-check/--batch-command command and prepares the stdin/stdout pipes for communication.
36+
func newCatFileBatch(ctx context.Context, repoPath string, cmdCatFile *gitcmd.Command) *catFileBatchCommunicator {
4037
ctx, ctxCancel := context.WithCancelCause(ctx)
41-
42-
// We often want to feed the commits in order into cat-file --batch, followed by their trees and subtrees as necessary.
4338
stdinWriter, stdoutReader, stdPipeClose := cmdCatFile.MakeStdinStdoutPipe()
44-
pipeClose := func() {
45-
if delay := catFileBatchDebugWaitClose.Load(); delay > 0 {
46-
time.Sleep(time.Duration(delay)) // for testing purpose only
47-
}
48-
stdPipeClose()
49-
}
50-
closeFunc := func(err error) {
51-
ctxCancel(err)
52-
pipeClose()
53-
}
54-
return newCatFileBatchWithCloseFunc(ctx, repoPath, cmdCatFile, stdinWriter, stdoutReader, closeFunc)
55-
}
56-
57-
func newCatFileBatchWithCloseFunc(ctx context.Context, repoPath string, cmdCatFile *gitcmd.Command,
58-
stdinWriter gitcmd.PipeWriter, stdoutReader gitcmd.PipeReader, closeFunc func(err error),
59-
) *catFileBatchCommunicator {
6039
ret := &catFileBatchCommunicator{
6140
debugGitCmd: cmdCatFile,
62-
closeFunc: closeFunc,
6341
reqWriter: stdinWriter,
6442
respReader: bufio.NewReaderSize(stdoutReader, 32*1024), // use a buffered reader for rich operations
6543
}
44+
ret.closeFunc.Store(new(func(err error) {
45+
ctxCancel(err)
46+
stdPipeClose()
47+
}))
6648

6749
err := cmdCatFile.WithDir(repoPath).StartWithStderr(ctx)
6850
if err != nil {
6951
log.Error("Unable to start git command %v: %v", cmdCatFile.LogString(), err)
7052
// ideally here it should return the error, but it would require refactoring all callers
7153
// so just return a dummy communicator that does nothing, almost the same behavior as before, not bad
72-
closeFunc(err)
54+
ret.Close(err)
7355
return ret
7456
}
7557

@@ -78,12 +60,33 @@ func newCatFileBatchWithCloseFunc(ctx context.Context, repoPath string, cmdCatFi
7860
if err != nil && !errors.Is(err, context.Canceled) {
7961
log.Error("cat-file --batch command failed in repo %s, error: %v", repoPath, err)
8062
}
81-
closeFunc(err)
63+
ret.Close(err)
8264
}()
8365

8466
return ret
8567
}
8668

69+
func (b *catFileBatchCommunicator) debugKill() (ret struct {
70+
beforeClose chan struct{}
71+
blockClose chan struct{}
72+
afterClose chan struct{}
73+
},
74+
) {
75+
ret.beforeClose = make(chan struct{})
76+
ret.blockClose = make(chan struct{})
77+
ret.afterClose = make(chan struct{})
78+
oldCloseFunc := b.closeFunc.Load()
79+
b.closeFunc.Store(new(func(err error) {
80+
b.closeFunc.Store(nil)
81+
close(ret.beforeClose)
82+
<-ret.blockClose
83+
(*oldCloseFunc)(err)
84+
close(ret.afterClose)
85+
}))
86+
b.debugGitCmd.DebugKill()
87+
return ret
88+
}
89+
8790
// catFileBatchParseInfoLine reads the header line from cat-file --batch
8891
// We expect: <oid> SP <type> SP <size> LF
8992
// then leaving the rest of the stream "<contents> LF" to be read

modules/git/catfile_batch_test.go

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,7 @@ import (
77
"io"
88
"os"
99
"path/filepath"
10-
"sync"
1110
"testing"
12-
"time"
1311

1412
"code.gitea.io/gitea/modules/test"
1513

@@ -39,13 +37,22 @@ func testCatFileBatch(t *testing.T) {
3937
require.Error(t, err)
4038
})
4139

42-
simulateQueryTerminated := func(pipeCloseDelay, pipeReadDelay time.Duration) (errRead error) {
43-
catFileBatchDebugWaitClose.Store(int64(pipeCloseDelay))
44-
defer catFileBatchDebugWaitClose.Store(0)
40+
simulateQueryTerminated := func(t *testing.T, errBeforePipeClose, errAfterPipeClose error) {
41+
readError := func(t *testing.T, r io.Reader, expectedErr error) {
42+
if expectedErr == nil {
43+
return // expectedErr == nil means this read should be skipped
44+
}
45+
n, err := r.Read(make([]byte, 100))
46+
assert.Zero(t, n)
47+
assert.ErrorIs(t, err, expectedErr)
48+
}
49+
4550
batch, err := NewBatch(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
4651
require.NoError(t, err)
4752
defer batch.Close()
48-
_, _ = batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449")
53+
_, err = batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449")
54+
require.NoError(t, err)
55+
4956
var c *catFileBatchCommunicator
5057
switch b := batch.(type) {
5158
case *catFileBatchLegacy:
@@ -58,24 +65,18 @@ func testCatFileBatch(t *testing.T) {
5865
t.FailNow()
5966
}
6067

61-
wg := sync.WaitGroup{}
62-
wg.Go(func() {
63-
time.Sleep(pipeReadDelay)
64-
var n int
65-
n, errRead = c.respReader.Read(make([]byte, 100))
66-
assert.Zero(t, n)
67-
})
68-
time.Sleep(10 * time.Millisecond)
69-
c.debugGitCmd.DebugKill()
70-
wg.Wait()
71-
return errRead
72-
}
68+
require.NotEqual(t, errBeforePipeClose == nil, errAfterPipeClose == nil, "must set exactly one of the expected errors")
7369

70+
inceptor := c.debugKill()
71+
<-inceptor.beforeClose // wait for the command's Close to be called, the pipe is not closed yet
72+
readError(t, c.respReader, errBeforePipeClose) // then caller will read on an open pipe which will be closed soon
73+
close(inceptor.blockClose) // continue to close the pipe
74+
<-inceptor.afterClose // wait for the pipe to be closed
75+
readError(t, c.respReader, errAfterPipeClose) // then caller will read on a closed pipe
76+
}
7477
t.Run("QueryTerminated", func(t *testing.T) {
75-
err := simulateQueryTerminated(0, 20*time.Millisecond)
76-
assert.ErrorIs(t, err, os.ErrClosed) // pipes are closed faster
77-
err = simulateQueryTerminated(40*time.Millisecond, 20*time.Millisecond)
78-
assert.ErrorIs(t, err, io.EOF) // reader is faster
78+
simulateQueryTerminated(t, io.EOF, nil) // reader is faster
79+
simulateQueryTerminated(t, nil, os.ErrClosed) // pipes are closed faster
7980
})
8081

8182
batch, err := NewBatch(t.Context(), filepath.Join(testReposDir, "repo1_bare"))

0 commit comments

Comments
 (0)