Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions DevProxy.Integration.Tests/EngineInactivityTimeoutTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Net;
using System.Text.RegularExpressions;
using DevProxy.Abstractions.Proxy;
using DevProxy.Proxy.Kestrel;
using DevProxy.Proxy.Kestrel.Internal;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;

namespace DevProxy.Integration.Tests;

/// <summary>
/// Regression coverage for <c>--timeout</c> / <c>timeoutSeconds</c>: Dev Proxy must
/// stop after the configured period without intercepted requests, and each request
/// must reset the countdown.
/// </summary>
public sealed class EngineInactivityTimeoutTests
{
[Fact]
public async Task ExecuteAsync_WithTimeout_StopsApplicationWhenIdle()
{
var configuration = CreateConfiguration(timeoutSeconds: 1);
var lifetime = new FakeLifetime();

await RunEngineAsync(configuration, lifetime, async () =>
{
var stopped = await Task.WhenAny(lifetime.StopRequested.Task, Task.Delay(10_000));
Assert.Same(lifetime.StopRequested.Task, stopped);
});
}

[Fact]
public async Task ExecuteAsync_WithoutTimeout_DoesNotStopApplication()
{
var configuration = CreateConfiguration(timeoutSeconds: null);
var lifetime = new FakeLifetime();

await RunEngineAsync(configuration, lifetime, async () =>
{
await Task.Delay(1_500);
Assert.False(lifetime.StopRequested.Task.IsCompleted);
});
}

[Fact]
public async Task ExecuteAsync_WithTimeout_RequestsResetCountdown()
{
var configuration = CreateConfiguration(timeoutSeconds: 2);
var lifetime = new FakeLifetime();

await RunEngineAsync(configuration, lifetime, async () =>
{
using var handler = new HttpClientHandler
{
Proxy = new WebProxy($"http://127.0.0.1:{configuration.Port}"),
UseProxy = true,
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(5) };

// Keep sending requests for longer than the timeout; each one must reset it.
var until = Environment.TickCount64 + 3_500;
while (Environment.TickCount64 < until)
{
try
{
using var _ = await client.GetAsync(new Uri("http://127.0.0.1:9/"));
}
catch (HttpRequestException)
{
// The origin is unreachable; only the proxy seeing the request matters.
}
Assert.False(lifetime.StopRequested.Task.IsCompleted, "Dev Proxy stopped despite ongoing requests.");
await Task.Delay(400);
}

var stopped = await Task.WhenAny(lifetime.StopRequested.Task, Task.Delay(10_000));
Assert.Same(lifetime.StopRequested.Task, stopped);
});
}

private static TestProxyConfiguration CreateConfiguration(long? timeoutSeconds) => new()
{
Port = 0,
IPAddress = "127.0.0.1",
AsSystemProxy = false,
TimeoutSeconds = timeoutSeconds,
};

private static async Task RunEngineAsync(
TestProxyConfiguration configuration, IHostApplicationLifetime lifetime, Func<Task> assert)
{
var urlsToWatch = new HashSet<UrlToWatch>
{
new(new Regex("^https?://example.com/.*$", RegexOptions.Compiled | RegexOptions.IgnoreCase)),
};

var engine = new KestrelProxyEngine(
CertificateAuthority.CreateDefault(),
[],
urlsToWatch,
configuration,
[],
NullLoggerFactory.Instance,
hostApplicationLifetime: lifetime);

using var cts = new CancellationTokenSource();
await engine.StartAsync(cts.Token);
try
{
var deadline = Environment.TickCount64 + 5_000;
while (configuration.Port == 0 && Environment.TickCount64 < deadline)
{
await Task.Delay(25);
}
Assert.True(configuration.Port > 0, "Engine did not bind a port.");

await assert();
}
finally
{
await cts.CancelAsync();
try
{
await engine.StopAsync(CancellationToken.None);
}
catch (OperationCanceledException)
{
// Expected on shutdown.
}

engine.Dispose();
}
}

private sealed class FakeLifetime : IHostApplicationLifetime
{
public TaskCompletionSource StopRequested { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public CancellationToken ApplicationStarted => CancellationToken.None;
public CancellationToken ApplicationStopping => CancellationToken.None;
public CancellationToken ApplicationStopped => CancellationToken.None;
public void StopApplication() => StopRequested.TrySetResult();
}
}
156 changes: 156 additions & 0 deletions DevProxy.Proxy.Kestrel.Tests/InactivityTimerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using DevProxy.Proxy.Kestrel.Internal;
using Xunit;

namespace DevProxy.Proxy.Kestrel.Tests;

public sealed class InactivityTimerTests
{
[Fact]
public void Constructor_DoesNotStartCountdown()
{
var clock = new ManualTimeProvider();
var expired = false;
using var timer = new InactivityTimer(2, () => expired = true, clock);

clock.Advance(TimeSpan.FromSeconds(10));
Assert.Equal(Timeout.InfiniteTimeSpan, clock.Timer.DueTime);
Assert.False(expired);

timer.Reset();
Assert.Equal(TimeSpan.FromSeconds(2), clock.Timer.DueTime);
clock.Advance(TimeSpan.FromSeconds(2));
clock.Timer.Fire();
Assert.True(expired);
}

[Fact]
public void Reset_QueuedCallbackChecksLatestActivity()
{
var clock = new ManualTimeProvider();
var calls = 0;
using var timer = new InactivityTimer(2, () => calls++, clock);

timer.Reset();
clock.Advance(TimeSpan.FromSeconds(2));
timer.Reset();
clock.Advance(TimeSpan.FromMilliseconds(500));
clock.Timer.Fire();

Assert.Equal(0, calls);
Assert.Equal(TimeSpan.FromMilliseconds(1_500), clock.Timer.DueTime);

clock.Advance(TimeSpan.FromMilliseconds(1_500));
clock.Timer.Fire();
Assert.Equal(1, calls);

timer.Reset();
clock.Timer.Fire();
Assert.Equal(1, calls);
}

[Fact]
public void Dispose_ResetAndQueuedCallbackAreIgnored()
{
var clock = new ManualTimeProvider();
var expired = false;
var timer = new InactivityTimer(2, () => expired = true, clock);

timer.Reset();
timer.Dispose();
clock.Advance(TimeSpan.FromSeconds(2));
timer.Reset();
clock.Timer.Fire();

Assert.False(expired);
Assert.True(clock.Timer.IsDisposed);
}

[Theory]
[InlineData(4_294_968L)]
[InlineData(long.MaxValue)]
public void Reset_LargeTimeoutIsAcceptedBySystemTimer(long timeoutSeconds)
{
using var timer = new InactivityTimer(timeoutSeconds, () => Assert.Fail("Timeout fired prematurely."));
timer.Reset();
}

[Fact]
public void Reset_TimeoutBeyondTimerLimitExpiresAfterFullInterval()
{
var clock = new ManualTimeProvider();
var expired = false;
using var timer = new InactivityTimer(5_000_000, () => expired = true, clock);
var maximumInterval = TimeSpan.FromMilliseconds(uint.MaxValue - 1);

timer.Reset();
Assert.Equal(maximumInterval, clock.Timer.DueTime);
clock.Advance(maximumInterval);
clock.Timer.Fire();

Assert.False(expired);
var remaining = TimeSpan.FromSeconds(5_000_000) - maximumInterval;
Assert.Equal(remaining, clock.Timer.DueTime);
clock.Advance(remaining);
clock.Timer.Fire();
Assert.True(expired);
}

[Fact]
public void Reset_MaximumLongTimeoutRearmsWithoutOverflow()
{
var clock = new ManualTimeProvider();
var expired = false;
using var timer = new InactivityTimer(long.MaxValue, () => expired = true, clock);
var maximumInterval = TimeSpan.FromMilliseconds(uint.MaxValue - 1);

timer.Reset();
clock.Advance(maximumInterval);
clock.Timer.Fire();

Assert.False(expired);
Assert.Equal(maximumInterval, clock.Timer.DueTime);
}

private sealed class ManualTimeProvider : TimeProvider
{
private long _timestamp;
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
public override long GetTimestamp() => _timestamp;
public ManualTimer Timer { get; private set; } = null!;

public void Advance(TimeSpan elapsed) => _timestamp += elapsed.Ticks;

public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
Timer = new ManualTimer(callback, state);
Timer.Change(dueTime, period);
return Timer;
}
}

private sealed class ManualTimer(TimerCallback callback, object? state) : ITimer
{
public TimeSpan DueTime { get; private set; }
public bool IsDisposed { get; private set; }

public bool Change(TimeSpan dueTime, TimeSpan period)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
DueTime = dueTime;
return true;
}

public void Fire() => callback(state);
public void Dispose() => IsDisposed = true;

public ValueTask DisposeAsync()
{
Dispose();
return ValueTask.CompletedTask;
}
}
}
80 changes: 80 additions & 0 deletions DevProxy.Proxy.Kestrel/Internal/InactivityTimer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

namespace DevProxy.Proxy.Kestrel.Internal;

/// <summary>
/// Starts counting down on the first <see cref="Reset"/> and invokes the timeout
/// action once no further activity is reported for the configured interval.
/// </summary>
internal sealed class InactivityTimer : IDisposable
{
private readonly object _lock = new();
private readonly long _timeoutSeconds;
private readonly Action _timeoutAction;
private readonly TimeProvider _timeProvider;
private readonly ITimer _timer;
private long _lastActivity;
private bool _started;
private bool _stopped;

public InactivityTimer(long timeoutSeconds, Action timeoutAction, TimeProvider? timeProvider = null)
{
_timeoutSeconds = timeoutSeconds;
_timeoutAction = timeoutAction;
_timeProvider = timeProvider ?? TimeProvider.System;
_timer = _timeProvider.CreateTimer(OnElapsed, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
}

public void Reset()
{
lock (_lock)
{
if (_stopped)
{
return;
}

_started = true;
_lastActivity = _timeProvider.GetTimestamp();
_ = _timer.Change(GetTimerInterval(_timeoutSeconds), Timeout.InfiniteTimeSpan);
}
}

private void OnElapsed(object? state)
{
lock (_lock)
{
if (!_started || _stopped)
{
return;
}

// Change cannot cancel a callback that has already been queued.
var elapsedSeconds = _timeProvider.GetElapsedTime(_lastActivity).Ticks / (decimal)TimeSpan.TicksPerSecond;
var remainingSeconds = _timeoutSeconds - elapsedSeconds;
if (remainingSeconds > 0)
{
_ = _timer.Change(GetTimerInterval(remainingSeconds), Timeout.InfiniteTimeSpan);
return;
}

_stopped = true;
_timeoutAction();
}
}

// System.Threading.Timer accepts at most uint.MaxValue - 1 milliseconds.
private static TimeSpan GetTimerInterval(decimal remainingSeconds) =>
TimeSpan.FromMilliseconds((double)Math.Min(remainingSeconds * 1_000, uint.MaxValue - 1));

public void Dispose()
{
lock (_lock)
{
_stopped = true;
_timer.Dispose();
}
}
}
Loading
Loading