Files
dotnet__skills/eng/skill-validator/tests/Shared/RetryHelperTests.cs
Viktor Hofer 3fff1f2573 Skill-validator: perf, cancellation, and reliability improvements (#482)
* Skill-validator: perf, cancellation, and reliability improvements

Performance:
- Cache skill-validator-dist.tar.gz in CI (evaluation.yml and skill-check.yml)
  to skip .NET SDK setup, build, and tar on cache hit. Evaluate and comment-on-pr
  jobs restore from cache with artifact download fallback.
- Replace SemaphoreSlim with Lock in SessionDatabase for lighter-weight
  synchronous locking.

Cancellation:
- Thread CancellationToken from System.CommandLine entry point through the
  entire evaluate call chain: Run -> EvaluateTarget -> EvaluateSkill/EvaluateAgent
  -> ExecuteScenario/ExecuteAgentScenario -> ExecuteRun/ExecuteAgentRun, plus
  EvaluateSkillNoise -> ExecuteNoiseTest.
- Add CancellationToken parameter to AgentRunner.RunAgent, Judge.JudgeRun,
  PairwiseJudge.Judge, and OverfittingJudge.Analyze, forwarding to
  RetryHelper and LlmSession.SendAsync respectively.
- Pass CancellationToken to all ConcurrencyLimiter.RunAsync call sites so
  semaphore waits are cancellable.
- Re-throw OperationCanceledException at the top-level skill evaluation loop
  so Ctrl+C / CI cancellation propagates instead of being swallowed as a
  skill failure.

Reliability:
- Smart timeline truncation in Judge and PairwiseJudge: error events
  (session.error, runner.error) are always preserved during truncation
  regardless of position, with a summary marker showing event type breakdown.
- Add jitter (±25%) to RetryHelper exponential backoff to prevent thundering
  herd on rate-limit recovery.
- Add CancellationToken parameter to ConcurrencyLimiter.RunAsync<T>.
- Add ConcurrencyLimiter tests (6 tests) and RetryHelper jitter tests (2 tests).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review feedback

- RetryHelper: re-clamp delay after jitter so it never exceeds the
  remaining budget or MaxSingleDelayMs.
- Judge/PairwiseJudge: handle edge case where error count alone exceeds
  MaxTimelineEvents by trimming errors with a summary instead of
  unconditionally appending all of them.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add high-variance detection and refactor FailureKind to enum

- Add Statistics.CoefficientOfVariation() for measuring score consistency
- Flag scenarios with CV > 0.5 as high-variance in ScenarioComparison
- Show console warning and markdown footnote for high-variance scenarios
- Include high-variance scenario names in Comparator verdict reason
- Refactor FailureKind from magic strings to strongly-typed enum with
  JsonStringEnumMemberName attributes for backwards-compatible JSON output
- Add 6 new CV unit tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Viktor Hofer <vihofer@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 13:58:28 +02:00

253 lines
9.3 KiB
C#

using SkillValidator.Shared;
namespace SkillValidator.Tests;
public class RetryHelperTests
{
[Fact]
public async Task SucceedsOnFirstAttempt_NoRetries()
{
var callCount = 0;
var result = await RetryHelper.ExecuteWithRetry(
(_) => { callCount++; return Task.FromResult(42); },
"test",
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(42, result);
Assert.Equal(1, callCount);
}
[Fact]
public async Task RetriesOnTransientFailure_ThenSucceeds()
{
var callCount = 0;
var result = await RetryHelper.ExecuteWithRetry(
(_) =>
{
callCount++;
if (callCount < 2)
throw new InvalidOperationException("transient");
return Task.FromResult("ok");
},
"test",
maxRetries: 2,
baseDelayMs: 1,
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("ok", result);
Assert.Equal(2, callCount);
}
[Fact]
public async Task ThrowsAfterAllRetriesExhausted()
{
var callCount = 0;
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
RetryHelper.ExecuteWithRetry<int>(
(_) =>
{
callCount++;
throw new InvalidOperationException("always fails");
},
"test",
maxRetries: 2,
baseDelayMs: 1,
cancellationToken: TestContext.Current.CancellationToken));
Assert.Equal(3, callCount); // 1 initial + 2 retries
Assert.Contains("all attempts failed", ex.Message);
Assert.IsType<InvalidOperationException>(ex.InnerException);
}
[Fact]
public async Task RespectsTotal_TimeoutBudget()
{
var callCount = 0;
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
RetryHelper.ExecuteWithRetry<int>(
async (_) =>
{
callCount++;
// Simulate a slow operation that eats the budget
await Task.Delay(50);
throw new InvalidOperationException("timeout");
},
"test",
maxRetries: 10, // many retries but budget is tiny
baseDelayMs: 1,
totalTimeoutMs: 100,
cancellationToken: TestContext.Current.CancellationToken));
// Should have stopped before exhausting all 10 retries
Assert.True(callCount < 10, $"Expected fewer than 10 attempts but got {callCount}");
}
[Fact]
public async Task CancellationToken_StopsRetryLoop()
{
using var cts = new CancellationTokenSource();
var callCount = 0;
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
RetryHelper.ExecuteWithRetry<int>(
(_) =>
{
callCount++;
if (callCount == 1)
cts.Cancel(); // cancel after first failure
throw new InvalidOperationException("fail");
},
"test",
maxRetries: 5,
baseDelayMs: 1,
cancellationToken: cts.Token));
Assert.Equal(1, callCount);
}
[Fact]
public async Task ExponentialBackoff_DelaysIncrease()
{
// Uses injected clock/delay to avoid real-time sensitivity on Windows
// where JIT/scheduler jitter can inflate the first measured gap (see dotnet/skills#288).
var callCount = 0;
var fakeTimeMs = 0L;
var recordedDelays = new List<int>();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
RetryHelper.ExecuteWithRetryCore<int>(
(_) =>
{
callCount++;
fakeTimeMs += 5; // simulate 5ms per attempt
throw new InvalidOperationException("fail");
},
"test",
maxRetries: 2,
baseDelayMs: 200,
totalTimeoutMs: 60_000,
cancellationToken: TestContext.Current.CancellationToken,
clock: () => fakeTimeMs,
delayFunc: (ms, _) => { recordedDelays.Add(ms); fakeTimeMs += ms; return Task.CompletedTask; },
jitterFunc: () => 0.5)); // 0.5 → zero jitter offset ((0.5*2-1)*range = 0)
Assert.Equal(3, callCount);
// With baseDelayMs=200, expected delays are: 200ms (attempt 1), 400ms (attempt 2)
Assert.Equal(2, recordedDelays.Count);
Assert.True(recordedDelays[1] > recordedDelays[0],
$"Expected exponential increase: delay1={recordedDelays[0]}ms, delay2={recordedDelays[1]}ms");
Assert.Equal(200, recordedDelays[0]); // baseDelayMs * 2^0
Assert.Equal(400, recordedDelays[1]); // baseDelayMs * 2^1
}
[Fact]
public async Task DelayClampedToRemainingBudget()
{
// With a very large base delay but small total budget,
// the delay should be clamped to the remaining budget.
// Uses injected clock/delay to avoid real-time sensitivity (see dotnet/skills#168).
var callCount = 0;
var fakeTimeMs = 0L;
var recordedDelays = new List<int>();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
RetryHelper.ExecuteWithRetryCore<int>(
(_) =>
{
callCount++;
fakeTimeMs += 10; // simulate 10ms per attempt
throw new InvalidOperationException("fail");
},
"test",
maxRetries: 1,
baseDelayMs: 60_000, // 60s base delay - would be huge without clamping
totalTimeoutMs: 2000,
cancellationToken: TestContext.Current.CancellationToken,
clock: () => fakeTimeMs,
delayFunc: (ms, _) => { recordedDelays.Add(ms); return Task.CompletedTask; },
jitterFunc: () => 0.5)); // zero jitter offset
Assert.Equal(2, callCount);
// The retry delay should be clamped to remaining budget, not the raw 60s.
Assert.Single(recordedDelays);
Assert.True(recordedDelays[0] <= 2000,
$"Delay should be clamped to remaining budget, got {recordedDelays[0]}ms");
Assert.True(recordedDelays[0] >= 0,
$"Delay should be non-negative, got {recordedDelays[0]}ms");
}
[Fact]
public async Task Jitter_AdjustsDelayWithinRange()
{
var fakeTimeMs = 0L;
var recordedDelays = new List<int>();
// jitterFunc returns 1.0 → max positive jitter offset
await Assert.ThrowsAsync<InvalidOperationException>(() =>
RetryHelper.ExecuteWithRetryCore<int>(
(_) =>
{
fakeTimeMs += 5;
throw new InvalidOperationException("fail");
},
"test",
maxRetries: 1,
baseDelayMs: 1000,
totalTimeoutMs: 60_000,
cancellationToken: TestContext.Current.CancellationToken,
clock: () => fakeTimeMs,
delayFunc: (ms, _) => { recordedDelays.Add(ms); fakeTimeMs += ms; return Task.CompletedTask; },
jitterFunc: () => 1.0)); // max positive: (1.0*2-1)*250 = +250
Assert.Single(recordedDelays);
// baseDelay=1000, jitter=25% of 1000=250, offset=+250 → 1250
Assert.Equal(1250, recordedDelays[0]);
}
[Fact]
public async Task Jitter_NegativeOffset_ReducesDelay()
{
var fakeTimeMs = 0L;
var recordedDelays = new List<int>();
// jitterFunc returns 0.0 → max negative jitter offset
await Assert.ThrowsAsync<InvalidOperationException>(() =>
RetryHelper.ExecuteWithRetryCore<int>(
(_) =>
{
fakeTimeMs += 5;
throw new InvalidOperationException("fail");
},
"test",
maxRetries: 1,
baseDelayMs: 1000,
totalTimeoutMs: 60_000,
cancellationToken: TestContext.Current.CancellationToken,
clock: () => fakeTimeMs,
delayFunc: (ms, _) => { recordedDelays.Add(ms); fakeTimeMs += ms; return Task.CompletedTask; },
jitterFunc: () => 0.0)); // max negative: (0.0*2-1)*250 = -250
Assert.Single(recordedDelays);
// baseDelay=1000, jitter=-250 → 750
Assert.Equal(750, recordedDelays[0]);
}
[Fact]
public async Task BudgetToken_FlowsToAction()
{
// Verify that the CancellationToken passed to the action is functional.
CancellationToken capturedToken = default;
await RetryHelper.ExecuteWithRetry(
(ct) =>
{
capturedToken = ct;
return Task.FromResult(true);
},
"test",
cancellationToken: TestContext.Current.CancellationToken);
// The token should be a real linked token, not CancellationToken.None.
Assert.True(capturedToken.CanBeCanceled);
}
}