Files
kandev/apps/backend/internal/agent/runtime/agentctl/client_close_test.go
T
João Salavisa 3652f0fd79 fix(agentctl): order workspace stream wg.Add before publish to fix Close race (#1272)
* fix(agentctl): order workspace stream wg.Add before publish to fix Close race

Client.StreamWorkspace published the new WorkspaceStream pointer to
c.workspaceStream under the lock, then released the lock and called
stream.wg.Add(2) afterwards. A concurrent Client.Close captured
c.workspaceStream under the same lock and then called ws.Wait() outside
of it, racing the subsequent Add(2) on a WaitGroup whose counter was
still 0. The race detector flagged this as Add-after-Wait on the same
counter address — reproduced in CI in
TestInitializeAndPrompt_StreamBeforeInitialize and
TestManager_RestartAgentProcess_StopErrorIsNonFatal, both of which use a
deferred client.Close after the StreamManager spawns the workspace
stream goroutine.

Move stream.wg.Add(2) under the same lock that publishes the pointer.
The unlock/lock pair now carries the Add into Close's view, so any
caller that observes c.workspaceStream is guaranteed to see the counter
already bumped before invoking Wait.

Adds TestClientClose_StreamWorkspaceAddWaitRace as a deterministic
regression: it waits for the server to observe the WS upgrade, then
fires Close in parallel with StreamWorkspace — pre-fix this triggers
the race within a handful of iterations under -race.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(agentctl): defer ctx cancel in StreamWorkspaceAddWaitRace iter

Wrap the per-iteration body in a runIter closure so `defer cancel()`
covers every exit path (timeout fatals as well as the normal flow),
replacing the explicit cancel() calls. Pure test hygiene — addresses
Claude review comment on PR #1272.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 12:19:02 +01:00

182 lines
5.5 KiB
Go

package client
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/common/logger"
)
// closeBarrierMockServer is a minimal agentctl mock that exposes the two
// WebSocket endpoints needed for Close/drain regression coverage. Handlers
// stay open until the client tears down, mirroring the behaviour real
// agentctl exhibits when the manager hasn't asked it to exit.
type closeBarrierMockServer struct {
server *httptest.Server
mu sync.Mutex
wsConns []*websocket.Conn
connected chan struct{}
once sync.Once
}
func newCloseBarrierMockServer(t *testing.T) *closeBarrierMockServer {
t.Helper()
m := &closeBarrierMockServer{connected: make(chan struct{})}
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
handler := func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
m.mu.Lock()
m.wsConns = append(m.wsConns, conn)
m.mu.Unlock()
m.once.Do(func() { close(m.connected) })
// Block until client closes.
for {
if _, _, err := conn.ReadMessage(); err != nil {
_ = conn.Close()
return
}
}
}
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/agent/stream", handler)
mux.HandleFunc("/api/v1/workspace/stream", handler)
m.server = httptest.NewServer(mux)
t.Cleanup(func() {
m.mu.Lock()
for _, c := range m.wsConns {
_ = c.Close()
}
m.mu.Unlock()
m.server.Close()
})
return m
}
func newCloseBarrierTestClient(t *testing.T, serverURL string) *Client {
t.Helper()
url := strings.TrimPrefix(serverURL, "http://")
parts := strings.SplitN(url, ":", 2)
host := parts[0]
var port int
_, _ = fmt.Sscanf(parts[1], "%d", &port)
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
return NewClient(host, port, log)
}
// TestClientClose_DrainsWorkspaceStream is the regression test for the
// CI-only goleak flake around StreamManager and WorkspaceStream goroutines
// surviving Close. After Close returns, the workspace read/write loops must
// have fully unwound — otherwise tests with `defer client.Close()` see
// lingering goroutines and goleak.VerifyTestMain fails. The agent (updates)
// stream is closed but not drained synchronously: the cascade flow legitimately
// stops + restarts the updates stream on the same client.
func TestClientClose_DrainsWorkspaceStream(t *testing.T) {
mock := newCloseBarrierMockServer(t)
client := newCloseBarrierTestClient(t, mock.server.URL)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
ws, err := client.StreamWorkspace(ctx, WorkspaceStreamCallbacks{})
if err != nil {
t.Fatalf("StreamWorkspace failed: %v", err)
}
if ws == nil {
t.Fatal("nil WorkspaceStream")
}
select {
case <-mock.connected:
case <-time.After(2 * time.Second):
t.Fatal("mock server never observed a WS connection")
}
// Close must return promptly and have drained the workspace stream. A hung
// goroutine here would block Close forever (or, pre-fix, return early and
// leave the goroutine running past goleak's check).
done := make(chan struct{})
go func() {
client.Close()
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Client.Close did not return within 2s — workspace drain is stuck")
}
// Post-Close, StreamWorkspace must error so a racy second close path
// doesn't strand a new dial past the barrier.
if _, err := client.StreamWorkspace(context.Background(), WorkspaceStreamCallbacks{}); err == nil {
t.Error("StreamWorkspace after Close should return error, got nil")
}
}
// TestClientClose_StreamWorkspaceAddWaitRace exercises the window where one
// goroutine has just published c.workspaceStream from StreamWorkspace and is
// about to call stream.wg.Add(2), while another goroutine fires Close —
// captures the same pointer and calls ws.Wait(). Pre-fix, the race detector
// flagged Add(2) vs Wait() on the same WaitGroup because Add ran after the
// publication unlock. With Add moved under the publication lock, the
// unlock/lock pair carries the Add into Close's view.
//
// The test waits for the mock server to observe the WS upgrade before
// firing Close — that is the same window the failing CI traces showed
// (test code observes the connection, returns, deferred Close runs while
// the StreamWorkspace goroutine has not yet reached Add(2)).
func TestClientClose_StreamWorkspaceAddWaitRace(t *testing.T) {
const iterations = 100
runIter := func(i int) {
mock := newCloseBarrierMockServer(t)
client := newCloseBarrierTestClient(t, mock.server.URL)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
streamErr := make(chan error, 1)
go func() {
_, err := client.StreamWorkspace(ctx, WorkspaceStreamCallbacks{})
streamErr <- err
}()
// Wait until the server side has observed the upgrade — at this point
// the client has either just unlocked after publishing the stream or
// is about to. Firing Close immediately maximises the chance of
// landing in the Add(2)-vs-Wait window.
select {
case <-mock.connected:
case <-time.After(2 * time.Second):
t.Fatalf("iteration %d: mock never observed WS upgrade", i)
}
closeDone := make(chan struct{})
go func() {
client.Close()
close(closeDone)
}()
select {
case <-closeDone:
case <-time.After(3 * time.Second):
t.Fatalf("iteration %d: Close did not return within 3s", i)
}
<-streamErr
}
for i := 0; i < iterations; i++ {
runIter(i)
}
}