◐ Off-By-One · answer catalog

go-ci-test-flake-pipe-reads

1 answer(s)godocker

go-ci-test-flake-pipe-reads

📦 Source in repository (JSON)

Answer

Fix 1 — MCP stdio 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.

Fix 2 — TestPathTraversalAttack helper

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


Evidence & signatures

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}
Generated from the verified corpus · MIT licensedBack to the catalog