go-ci-test-flake-pipe-reads
TestMCP_ListTools)Root cause: the test did stdout.Read(buf) (raw, fixed-size) after time.Sleep(...). JSON-RPC responses are newline-delimited; when the server answers eagerly, two responses land in the pipe together, so read #1 returns both, and read #2 blocks until the context kills the child (EOF at 10s) — a CI-only timing flake.
Fix: one shared bufio.Reader for the process lifetime (it buffers, so coalesced pipe data is split back into lines), a pump goroutine feeding a channel, and a context-aware readMCPResponse that can never block past the deadline:
type mcpClient struct {
ctx context.Context
stdin io.WriteCloser
lines chan []byte
errs chan error
}
// One shared bufio.Reader for the whole process lifetime.
func newMCPClient(ctx context.Context, proc *exec.Cmd) (*mcpClient, error) {
stdin, _ := proc.StdinPipe()
stdout, _ := proc.StdoutPipe()
proc.Start()
c := &mcpClient{ctx: ctx, stdin: stdin, lines: make(chan []byte, 16), errs: make(chan error, 1)}
go c.pump(bufio.NewReader(stdout)) // shared reader
return c, nil
}
// pump reads newline-delimited JSON lines off the shared bufio.Reader.
func (c *mcpClient) pump(r *bufio.Reader) {
for {
line, err := r.ReadBytes('\n')
if len(line) > 0 {
c.lines <- line
}
if err != nil {
c.errs <- err
return
}
}
}
// readMCPResponse is ctx-aware: a hung child can never block the test.
func (c *mcpClient) readMCPResponse() ([]byte, error) {
select {
case <-c.ctx.Done():
return nil, fmt.Errorf("read MCP response: %w", c.ctx.Err())
case line := <-c.lines:
return bytes.TrimSpace(line), nil
case err := <-c.errs:
return nil, fmt.Errorf("read MCP response: %w", err)
}
}
The test then sends both requests eagerly and reads both responses — each arrives in ~1ms instead of blocking for 10s.
TestPathTraversalAttack helperRoot cause: the helper used filepath.Clean, which resolves separators with host-OS rules — on Windows \ becomes a separator and output uses \, so the /-based TrimPrefix/split logic broke and test outcomes varied per CI runner. The parameter was also named path, shadowing the path package.
Fix: use path.Clean (always /, deterministic on every OS) and rename the parameter:
import "path"
// p, not path: the old name shadowed the path package, forcing filepath.
func cleanTraversal(p string) string {
return strings.TrimPrefix(path.Clean("/"+p), "/")
}
path.Clean collapses a/../../etc/passwd → etc/passwd (no .. segment survives); backslash inputs stay opaque single segments (visible to the caller, never silently reinterpreted), identically on every OS.
Reproduction project at `~/mcptest` (fake MCP stdio server + client + tests). **1. The flake, reproduced deterministically** (`go test -tags flake`, original buggy reads): ``` flake_repro_test.go:67: first read got 291 bytes (both responses may have coalesced here) flake_repro_test.go:52: read: EOF --- FAIL: TestMCP_ListTools_Flaky (3.00s) ``` Exactly the described failure: both responses coalesced into read #1; read #2 blocked until the ctx kill produced EOF. **2. The fix, verified stable** — `go test -race -count=20 .` → `ok mcptest`, **20/20 passes**: - `TestMCP_ListTools`: two eager requests, coalesced responses read in **1.1–2.5ms** (not 10s); response JSON parsed, tool name asserted. - `TestPathTraversalAttack` (8 sub-cases): `../../etc/passwd`→`etc/passwd`, `a/../../etc/passwd`→`etc/passwd`, `/etc/passwd`→`etc/passwd`, `safe/dir/../file.txt`→`safe/file.txt`, `././safe/./file.txt`→`safe/file.txt`, `..\..\etc\passwd` and mixed-separator inputs kept byte-identical (opaque, deterministic). - `TestCleanTraversalIsOSIndependent`: emulated old helper on Windows → `etc\passwd` (backslash separators, `/`-split sees one segment) vs fixed `path.Clean` → `a\..\..\etc\passwd` — proving the old outcome was a function of `runtime.GOOS`. Edge cases covered: request pipelining (responses coalescing mid-flight), backslash-only and mixed-separator traversal inputs, absolute-path attempts, dot segments, empty/hung-child behavior (ctx-aware read can't deadlock).
{"model": "deepseek-v4-flash", "problem_class": "go-ci-test-flake-pipe-reads", "result": "passed", "tests": 3}