Skip to content

Commit f99c4cb

Browse files
committed
test(remoteagent): de-flake TestA2ACleanupPropagation
Under CPU contention / low parallelism the test hit two problems: - The detached CancelTask goroutine was never joined (cancelResultChan was created but never read), so a slow RPC could still be in flight at teardown, fail on the cancelled t.Context(), and call t.Errorf on a finished test, which panics. Both detached goroutines are now joined via a WaitGroup and t.Cleanup. - The parent was cancelled right after the first status event, before the remote agent had received any subagent event. In that window the parent does not know the subagent's server-assigned task ID, so cancellation cannot propagate and subagent cleanup never fires. The test now waits until the subagent's own output round-trips to the client before cancelling. The single fixed 5s deadline shared across all cleanup waits is also replaced with a per-wait, contention-tolerant bound. Test-only change.
1 parent 1a5a649 commit f99c4cb

1 file changed

Lines changed: 57 additions & 21 deletions

File tree

agent/remoteagent/v2/a2a_e2e_test.go

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"net/http"
2727
"path/filepath"
2828
"strings"
29+
"sync"
2930
"testing"
3031
"time"
3132

@@ -380,6 +381,8 @@ func TestA2ACleanupPropagation(t *testing.T) {
380381
// Remote A2A server publishes a submitted task and start generating artifact updates
381382
// until it detects a context cancelation
382383
remoteTaskIDChan, remoteCleanupCalledChan := make(chan a2a.TaskID, 1), make(chan struct{}, 2)
384+
// Artifact text the mock subagent streams; the cancel step keys off it.
385+
const remoteArtifactText = "remote-subagent-working"
383386
serverB := startA2AServer(&mockA2AExecutor{
384387
cancelFn: func(ctx context.Context, reqCtx *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] {
385388
return func(yield func(a2a.Event, error) bool) {
@@ -393,7 +396,7 @@ func TestA2ACleanupPropagation(t *testing.T) {
393396
return
394397
}
395398
for ctx.Err() == nil {
396-
if !yield(a2a.NewArtifactEvent(reqCtx, a2a.NewTextPart("foo")), nil) {
399+
if !yield(a2a.NewArtifactEvent(reqCtx, a2a.NewTextPart(remoteArtifactText)), nil) {
397400
return
398401
}
399402
time.Sleep(1 * time.Millisecond)
@@ -427,27 +430,44 @@ func TestA2ACleanupPropagation(t *testing.T) {
427430

428431
client := newA2AClient(t, serverA)
429432

430-
// Send a streaming message in a detached goroutine, passing status update through chan
433+
// Join the detached streaming/cancel goroutines before teardown; a late
434+
// t.Errorf from an in-flight RPC on a finished test would panic.
435+
var wg sync.WaitGroup
436+
t.Cleanup(wg.Wait)
437+
438+
// remoteStreamingChan closes when the subagent's own output first reaches the client.
431439
statusUpdateEventChan := make(chan a2a.Event, 10)
440+
remoteStreamingChan := make(chan struct{})
441+
wg.Add(1)
432442
go func() {
443+
defer wg.Done()
433444
defer close(statusUpdateEventChan)
445+
remoteStreaming := false
434446
msg := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("work"))
435447
for event, err := range client.SendStreamingMessage(t.Context(), &a2a.SendMessageRequest{Message: msg}) {
436448
if err != nil {
437449
t.Errorf("client.SendStreamingMessage() error = %v", err)
438450
return
439451
}
440-
if _, ok := event.(*a2a.TaskArtifactUpdateEvent); ok {
452+
if tau, ok := event.(*a2a.TaskArtifactUpdateEvent); ok {
453+
if !remoteStreaming && artifactContainsText(tau, remoteArtifactText) {
454+
remoteStreaming = true
455+
close(remoteStreamingChan)
456+
}
441457
continue
442458
}
443459
statusUpdateEventChan <- event
444460
}
445461
}()
446462

447-
// Issue a task cancellation request
463+
// Cancel only after the subagent's output reaches the client: before that the
464+
// parent doesn't know the subagent task ID, so cancellation can't propagate.
448465
taskID := (<-statusUpdateEventChan).TaskInfo().TaskID
466+
awaitN(t, remoteStreamingChan, 1, "remote subagent streaming")
449467
cancelResultChan := make(chan *a2a.Task, 1)
468+
wg.Add(1)
450469
go func() {
470+
defer wg.Done()
451471
defer close(cancelResultChan)
452472
task, err := client.CancelTask(t.Context(), &a2a.CancelTaskRequest{ID: taskID})
453473
if err != nil {
@@ -470,25 +490,12 @@ func TestA2ACleanupPropagation(t *testing.T) {
470490
t.Fatalf("type(lastStreamingUpdate) = %T, want *a2a.TaskStatusUpdateEvent", lastStreamingUpdate)
471491
}
472492

473-
// Check subagent task got cancelled when the parent task was cancelled.
474-
// Reads from channel twice because cleanup gets called both for cancelation and execution.
475-
timeout := time.After(5 * time.Second)
476-
for range 2 {
477-
select {
478-
case <-remoteCleanupCalledChan:
479-
case <-timeout:
480-
t.Fatalf("remote cleanup was not called")
481-
}
482-
}
493+
// Subagent cleanup fires twice: once for cancelation, once for execution.
494+
// A generous per-wait deadline avoids flaking under CPU contention.
495+
awaitN(t, remoteCleanupCalledChan, 2, "remote cleanup")
483496
remoteTaskID := <-remoteTaskIDChan
497+
awaitN(t, executorCleanupCalledChan, 2, "executor cleanup")
484498

485-
for range 2 {
486-
select {
487-
case <-executorCleanupCalledChan:
488-
case <-timeout:
489-
t.Fatalf("executor cleanup was not called")
490-
}
491-
}
492499
remoteClient := newA2AClient(t, serverB)
493500
remoteTask, err := remoteClient.GetTask(t.Context(), &a2a.GetTaskRequest{ID: remoteTaskID})
494501
if err != nil {
@@ -497,6 +504,35 @@ func TestA2ACleanupPropagation(t *testing.T) {
497504
if remoteTask.Status.State != a2a.TaskStateCanceled {
498505
t.Errorf("remoteTask.Status.State = %q, want %q", remoteTask.Status.State, a2a.TaskStateCanceled)
499506
}
507+
508+
// Join the cancel RPC so it can't log on t after the test returns.
509+
awaitN(t, cancelResultChan, 1, "cancel task")
510+
}
511+
512+
// awaitN receives n values from ch or fails the test after a generous, contention-
513+
// tolerant deadline. A closed channel counts as a receive, so it also joins a
514+
// goroutine that closed ch without sending.
515+
func awaitN[T any](t *testing.T, ch <-chan T, n int, what string) {
516+
t.Helper()
517+
const deadline = 30 * time.Second
518+
timer := time.NewTimer(deadline)
519+
defer timer.Stop()
520+
for i := range n {
521+
select {
522+
case <-ch:
523+
case <-timer.C:
524+
t.Fatalf("%s: got %d of %d within %v", what, i, n, deadline)
525+
}
526+
}
527+
}
528+
529+
func artifactContainsText(tau *a2a.TaskArtifactUpdateEvent, substr string) bool {
530+
for _, p := range tau.Artifact.Parts {
531+
if strings.Contains(p.Text(), substr) {
532+
return true
533+
}
534+
}
535+
return false
500536
}
501537

502538
func TestA2ASingleHopFinalResponse(t *testing.T) {

0 commit comments

Comments
 (0)