diff --git a/DevProxy.Integration.Tests/EngineInactivityTimeoutTests.cs b/DevProxy.Integration.Tests/EngineInactivityTimeoutTests.cs new file mode 100644 index 000000000..5d0ff3714 --- /dev/null +++ b/DevProxy.Integration.Tests/EngineInactivityTimeoutTests.cs @@ -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; + +/// +/// Regression coverage for --timeout / timeoutSeconds: Dev Proxy must +/// stop after the configured period without intercepted requests, and each request +/// must reset the countdown. +/// +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 assert) + { + var urlsToWatch = new HashSet + { + 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(); + } +} diff --git a/DevProxy.Proxy.Kestrel.Tests/InactivityTimerTests.cs b/DevProxy.Proxy.Kestrel.Tests/InactivityTimerTests.cs new file mode 100644 index 000000000..9d96388be --- /dev/null +++ b/DevProxy.Proxy.Kestrel.Tests/InactivityTimerTests.cs @@ -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; + } + } +} diff --git a/DevProxy.Proxy.Kestrel/Internal/InactivityTimer.cs b/DevProxy.Proxy.Kestrel/Internal/InactivityTimer.cs new file mode 100644 index 000000000..1e3b9238b --- /dev/null +++ b/DevProxy.Proxy.Kestrel/Internal/InactivityTimer.cs @@ -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; + +/// +/// Starts counting down on the first and invokes the timeout +/// action once no further activity is reported for the configured interval. +/// +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(); + } + } +} diff --git a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs index 0da90330a..694865a8d 100644 --- a/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs +++ b/DevProxy.Proxy.Kestrel/Internal/ProxyConnectionHandler.cs @@ -55,7 +55,8 @@ internal sealed class ProxyConnectionHandler( PluginPipeline pipeline, HostWatchList watchList, ProcessFilter processFilter, - ILogger logger) : ConnectionHandler + ILogger logger, + Action? onRequest = null) : ConnectionHandler { // Largest streamed-response body retained in memory for read-only AfterResponse // inspectors (e.g. OpenAI telemetry). Beyond this, inspectors simply see no body; @@ -87,6 +88,8 @@ public override async Task OnConnectedAsync(ConnectionContext connection) return; } + onRequest?.Invoke(); + if (string.Equals(head.Method, "CONNECT", StringComparison.OrdinalIgnoreCase)) { await HandleConnectAsync(connection, clientStream, head, ct).ConfigureAwait(false); @@ -197,6 +200,7 @@ private async Task ServeConnectionAsync( var head = firstHead; while (head is not null) { + onRequest?.Invoke(); var url = httpsHost is null ? head.Target // plain HTTP proxy request: absolute-form target : httpsPort == 443 diff --git a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs index 20074f979..d32f39143 100644 --- a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs +++ b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs @@ -30,7 +30,8 @@ public sealed class KestrelProxyEngine( Dictionary globalData, ILoggerFactory loggerFactory, IRootCertificateTrust? rootCertificateTrust = null, - ISystemProxyManager? systemProxyManager = null) : BackgroundService + ISystemProxyManager? systemProxyManager = null, + IHostApplicationLifetime? hostApplicationLifetime = null) : BackgroundService { private readonly ILogger _logger = loggerFactory.CreateLogger(); @@ -65,7 +66,20 @@ configuration is IProcessTreeProxyConfiguration processTreeConfiguration && configuration, globalData, loggerFactory.CreateLogger()); - var handler = new ProxyConnectionHandler(ca, forwarder, pipeline, watchList, processFilter, _logger); + // --timeout / timeoutSeconds: stop Dev Proxy after a period without intercepted + // requests. Each incoming proxy request resets the countdown. + using var inactivityTimer = configuration.TimeoutSeconds is { } timeoutSeconds && hostApplicationLifetime is not null + ? new InactivityTimer(timeoutSeconds, () => + { + _logger.LogInformation( + "No requests received in {TimeoutSeconds} seconds. Stopping Dev Proxy...", + timeoutSeconds.ToString(CultureInfo.InvariantCulture)); + hostApplicationLifetime.StopApplication(); + }) + : null; + var handler = new ProxyConnectionHandler( + ca, forwarder, pipeline, watchList, processFilter, _logger, + inactivityTimer is null ? null : inactivityTimer.Reset); var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions()); _ = builder.WebHost.UseKestrelCore(); @@ -81,6 +95,7 @@ configuration is IProcessTreeProxyConfiguration processTreeConfiguration && await using var app = builder.Build(); await app.StartAsync(stoppingToken).ConfigureAwait(false); + inactivityTimer?.Reset(); // When --port 0 is used the OS assigns a free port; Kestrel rewrites the // ListenOptions endpoint to the bound port after StartAsync, so log THAT // (not the configured 0) so the user can actually connect. diff --git a/DevProxy/Extensions/IServiceCollectionExtensions.cs b/DevProxy/Extensions/IServiceCollectionExtensions.cs index 105f1a897..3f49809bd 100644 --- a/DevProxy/Extensions/IServiceCollectionExtensions.cs +++ b/DevProxy/Extensions/IServiceCollectionExtensions.cs @@ -60,7 +60,8 @@ static IServiceCollection AddProxyEngine(this IServiceCollection services) sp.GetRequiredService().GlobalData, sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService())); return services; } diff --git a/DevProxy/Proxy/InactivityTimer.cs b/DevProxy/Proxy/InactivityTimer.cs deleted file mode 100644 index 6d9ff16dc..000000000 --- a/DevProxy/Proxy/InactivityTimer.cs +++ /dev/null @@ -1,15 +0,0 @@ -// 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; - -internal sealed class InactivityTimer(long timeoutSeconds, Action timeoutAction) : IDisposable -{ - private readonly TimeSpan _timeout = TimeSpan.FromSeconds(timeoutSeconds); - private readonly Timer _timer = new(_ => timeoutAction.Invoke(), null, TimeSpan.FromSeconds(timeoutSeconds), Timeout.InfiniteTimeSpan); - - public void Dispose() => _timer.Dispose(); - public void Reset() => _timer.Change(_timeout, Timeout.InfiniteTimeSpan); - public void Stop() => _timer.Dispose(); -} \ No newline at end of file diff --git a/schemas/v4.0.0/rc.schema.json b/schemas/v4.0.0/rc.schema.json index 0ffcf7cf2..18c3545b7 100644 --- a/schemas/v4.0.0/rc.schema.json +++ b/schemas/v4.0.0/rc.schema.json @@ -212,7 +212,7 @@ "timeoutSeconds": { "type": "integer", "minimum": 1, - "description": "Timeout in seconds for requests passing through Dev Proxy." + "description": "Time in seconds after which Dev Proxy exits. Resets when Dev Proxy intercepts a request." } }, "required": [