Make the non-blocking dispatch tests fail fast instead of hanging
2 files changed,
+86
−32
internal/host/restart_test.go+39 −13internal/host/sidecarwake_test.go+47 −19
diff --git a/internal/host/restart_test.go b/internal/host/restart_test.go| index 2991818ea..627691b6a 100644 |
| --- a/internal/host/restart_test.go |
| +++ b/internal/host/restart_test.go |
| @@ -813,17 +813,32 @@ func TestCycleWorkersReturnsBeforeASlowWakeCompletes(t *testing.T) { |
| bounce := func(h string) bool { return true } |
| wake := func(rt registry.Route) error { <-slow; return nil } |
| - start := time.Now() |
| - n := cycleWorkers(routes, moved, bounce, wake) |
| - elapsed := time.Since(start) |
| - close(slow) // release the parked wake goroutine so the test can exit cleanly |
| + // Run under a deadline rather than measuring elapsed time on the test's |
| + // own goroutine (fix round 3): under a REGRESSION cycleWorkers itself |
| + // never returns, so a synchronous call plus an elapsed-time check never |
| + // fails at all — it hangs until Go's 10-minute default test timeout, |
| + // which is exactly what forced fix round 2's mutation check to run |
| + // under an external `timeout` to see anything fail. Wrapping the call |
| + // in its own goroutine and racing it against time.After (the same |
| + // shape TestDispatchSidecarWakesBoundsConcurrency already uses) makes a |
| + // regression here fail cleanly and promptly, in this test alone. |
| + var n int |
| + done := make(chan struct{}) |
| + go func() { |
| + n = cycleWorkers(routes, moved, bounce, wake) |
| + close(done) |
| + }() |
| + select { |
| + case <-done: |
| + close(slow) // release the parked wake goroutine so the test can exit cleanly |
| + case <-time.After(time.Second): |
| + close(slow) |
| + t.Fatal("cycleWorkers did not return within 1s with a wake blocked indefinitely — the wake must be dispatched asynchronously, not awaited") |
| + } |
| if n != 1 { |
| t.Fatalf("cycleWorkers = %d, want 1 (the bounce itself is synchronous)", n) |
| } |
| - if elapsed > time.Second { |
| - t.Fatalf("cycleWorkers took %s to return with a wake blocked indefinitely — the wake must be dispatched asynchronously, not awaited", elapsed) |
| - } |
| } |
| // TestCycleWorkersWakesEveryBouncedSidecarEventually: dispatching wakes |
| @@ -968,15 +983,26 @@ func TestCycleWorkersReturnsPromptlyWithManySlowWakesQueued(t *testing.T) { |
| return nil |
| } |
| - start := time.Now() |
| - got := cycleWorkers(routes, moved, bounce, wake) |
| - elapsed := time.Since(start) |
| + // Same deadline-vs-elapsed-time correction as |
| + // TestCycleWorkersReturnsBeforeASlowWakeCompletes (fix round 3): a |
| + // synchronous call on the test's own goroutine never fails under a |
| + // regression, it hangs — this is the test fix round 2's own mutation |
| + // check needed an external `timeout` to see fail at all. |
| + var got int |
| + done := make(chan struct{}) |
| + go func() { |
| + got = cycleWorkers(routes, moved, bounce, wake) |
| + close(done) |
| + }() |
| + select { |
| + case <-done: |
| + case <-time.After(time.Second): |
| + close(slow) |
| + t.Fatalf("cycleWorkers did not return within 1s with %d wakes blocked (bound %d) — the whole bounded fan-out must run off the caller's goroutine, not just each individual wake", n, workerBootConcurrency) |
| + } |
| if got != n { |
| t.Fatalf("cycleWorkers = %d, want %d (every route bounced)", got, n) |
| } |
| - if elapsed > time.Second { |
| - t.Fatalf("cycleWorkers took %s to return with %d wakes blocked (bound %d) — the whole bounded fan-out must run off the caller's goroutine, not just each individual wake", elapsed, n, workerBootConcurrency) |
| - } |
| close(slow) |
| waitForCond(t, "every queued sidecar to be woken eventually", func() bool { |
diff --git a/internal/host/sidecarwake_test.go b/internal/host/sidecarwake_test.go| index bbedae5d5..3c082d2cc 100644 |
| --- a/internal/host/sidecarwake_test.go |
| +++ b/internal/host/sidecarwake_test.go |
| @@ -205,13 +205,24 @@ func TestDispatchSidecarWakesReturnsBeforeASlowWakeCompletes(t *testing.T) { |
| slow := make(chan struct{}) |
| wake := func(rt registry.Route) error { <-slow; return nil } |
| - start := time.Now() |
| - dispatchSidecarWakes(moved, wake) |
| - elapsed := time.Since(start) |
| - close(slow) // release the parked wake goroutine so the test can exit cleanly |
| - |
| - if elapsed > time.Second { |
| - t.Fatalf("dispatchSidecarWakes took %s to return with a wake blocked indefinitely — wakes must be dispatched asynchronously, not awaited", elapsed) |
| + // Run under a deadline rather than measuring elapsed time on the test's |
| + // own goroutine (fix round 3): under a REGRESSION dispatchSidecarWakes |
| + // itself never returns, so a synchronous call plus an elapsed-time |
| + // check never fails — it hangs until Go's 10-minute default test |
| + // timeout. Racing a goroutine against time.After (the same shape |
| + // TestDispatchSidecarWakesBoundsConcurrency already uses) makes a |
| + // regression here fail cleanly and promptly, in this test alone. |
| + done := make(chan struct{}) |
| + go func() { |
| + dispatchSidecarWakes(moved, wake) |
| + close(done) |
| + }() |
| + select { |
| + case <-done: |
| + close(slow) // release the parked wake goroutine so the test can exit cleanly |
| + case <-time.After(time.Second): |
| + close(slow) |
| + t.Fatal("dispatchSidecarWakes did not return within 1s with a wake blocked indefinitely — wakes must be dispatched asynchronously, not awaited") |
| } |
| } |
| @@ -370,17 +381,27 @@ func TestSidecarWakeLegReturnsBeforeASlowWakeCompletes(t *testing.T) { |
| slow := make(chan struct{}) |
| wake := func(rt registry.Route) error { <-slow; return nil } |
| - start := time.Now() |
| - moved, _ := sidecarWakeLeg(ctx, routes, s, cache, wake) |
| - elapsed := time.Since(start) |
| - close(slow) |
| + // Deadline-vs-elapsed-time correction, same as |
| + // TestDispatchSidecarWakesReturnsBeforeASlowWakeCompletes (fix round 3): |
| + // a synchronous call on the test's own goroutine never fails under a |
| + // regression, it hangs. |
| + var moved []registry.Route |
| + done := make(chan struct{}) |
| + go func() { |
| + moved, _ = sidecarWakeLeg(ctx, routes, s, cache, wake) |
| + close(done) |
| + }() |
| + select { |
| + case <-done: |
| + close(slow) |
| + case <-time.After(time.Second): |
| + close(slow) |
| + t.Fatal("sidecarWakeLeg did not return within 1s with a wake blocked indefinitely — the wake must be dispatched asynchronously") |
| + } |
| if len(moved) != 1 { |
| t.Fatalf("moved = %v, want exactly one (detection is synchronous)", moved) |
| } |
| - if elapsed > time.Second { |
| - t.Fatalf("sidecarWakeLeg took %s to return with a wake blocked indefinitely — the wake must be dispatched asynchronously", elapsed) |
| - } |
| } |
| // ---- wired into fastTick: rides the same serial move as the restart leg -- |
| @@ -457,11 +478,18 @@ func TestDispatchSidecarWakesReturnsPromptlyWithManySlowWakesQueued(t *testing.T |
| return nil |
| } |
| - start := time.Now() |
| - dispatchSidecarWakes(moved, wake) |
| - elapsed := time.Since(start) |
| - if elapsed > time.Second { |
| - t.Fatalf("dispatchSidecarWakes took %s to return with %d wakes blocked (bound %d) — the whole bounded fan-out must run off the caller's goroutine", elapsed, n, workerBootConcurrency) |
| + // Same deadline-vs-elapsed-time correction as |
| + // TestDispatchSidecarWakesReturnsBeforeASlowWakeCompletes (fix round 3). |
| + done := make(chan struct{}) |
| + go func() { |
| + dispatchSidecarWakes(moved, wake) |
| + close(done) |
| + }() |
| + select { |
| + case <-done: |
| + case <-time.After(time.Second): |
| + close(slow) |
| + t.Fatalf("dispatchSidecarWakes did not return within 1s with %d wakes blocked (bound %d) — the whole bounded fan-out must run off the caller's goroutine", n, workerBootConcurrency) |
| } |
| close(slow) |