From d44b131658a47e12b45a83083c5e8169191466c0 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 27 Aug 2026 17:15:39 +0300 Subject: [PATCH] fix(core): guard the bash streaming test callback against concurrent append MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit executeBashStreaming drains stdout and stderr in two separate goroutines, so the ToolOutputCallback it invokes is called concurrently. The production code guards its own stdoutChunks/stderrChunks with a mutex, but the callback added in the streaming tests appended to a shared slice unsynchronised. Under -race this trips a genuine data race. It surfaces only under load — the full suite, or -cpu=1,2,4 — which is why CI on #110 went green and the package passes in isolation. The callback now locks around the append and publishes a snapshot under the same mutex. Test-only change; executeBashStreaming itself was already correct. Introduced by #110. --- internal/core/bash_streaming_test.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/internal/core/bash_streaming_test.go b/internal/core/bash_streaming_test.go index 61926c3c..4b3a0305 100644 --- a/internal/core/bash_streaming_test.go +++ b/internal/core/bash_streaming_test.go @@ -4,6 +4,7 @@ import ( "context" "os/exec" "strings" + "sync" "testing" "time" @@ -28,12 +29,29 @@ func runStreaming(t *testing.T, command string) (fantasy.ToolResponse, []string) go func() { ctx := context.Background() cmd := exec.CommandContext(ctx, "bash", "-c", command) + + // executeBashStreaming drains stdout and stderr in two separate + // goroutines, so the callback is invoked concurrently. The production + // code guards its own chunk slices with a mutex; the callback we pass + // must do the same or the append races. + var mu sync.Mutex var chunks []string cb := func(_, _, chunk string, _ bool) { + mu.Lock() chunks = append(chunks, chunk) + mu.Unlock() } + resp, err := executeBashStreaming(ctx, bashCall(command, 0), cmd, cb, "") - done <- result{resp, chunks, err} + + // executeBashStreaming has joined both stream goroutines by the time it + // returns, so no further callback can fire. Take the lock anyway to + // publish the final slice under the same mutex that guarded the writes. + mu.Lock() + snapshot := append([]string(nil), chunks...) + mu.Unlock() + + done <- result{resp, snapshot, err} }() select {