From a0d1b3d0512c86c668d9b90345f651b76ec8dffa Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Wed, 21 Oct 2026 15:57:51 +0200 Subject: [PATCH 1/2] Restore --timeout / timeoutSeconds inactivity shutdown. Closes #1952 The inactivity timer lost its caller in the Kestrel engine migration, so --timeout / timeoutSeconds were parsed but had no effect. The Kestrel engine now starts the timer after binding, resets it on every incoming proxy request, and stops the host when it elapses. Aligns the v4.0.0 schema description with the CLI help. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e38816bc-97d1-40e5-adbb-4ef31390511d --- .../EngineInactivityTimeoutTests.cs | 147 ++++++++++++++++++ .../Internal}/InactivityTimer.cs | 44 ++++-- .../Internal/ProxyConnectionHandler.cs | 6 +- DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs | 18 ++- .../IServiceCollectionExtensions.cs | 3 +- schemas/v4.0.0/rc.schema.json | 2 +- 6 files changed, 200 insertions(+), 20 deletions(-) create mode 100644 DevProxy.Integration.Tests/EngineInactivityTimeoutTests.cs rename {DevProxy/Proxy => DevProxy.Proxy.Kestrel/Internal}/InactivityTimer.cs (53%) 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/InactivityTimer.cs b/DevProxy.Proxy.Kestrel/Internal/InactivityTimer.cs similarity index 53% rename from DevProxy/Proxy/InactivityTimer.cs rename to DevProxy.Proxy.Kestrel/Internal/InactivityTimer.cs index 6d9ff16dc..491ab82ce 100644 --- a/DevProxy/Proxy/InactivityTimer.cs +++ b/DevProxy.Proxy.Kestrel/Internal/InactivityTimer.cs @@ -1,15 +1,29 @@ -// 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 +// 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; + +/// +/// Invokes once no activity has been reported via +/// for seconds. +/// +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 Reset() + { + try + { + _ = _timer.Change(_timeout, Timeout.InfiniteTimeSpan); + } + catch (ObjectDisposedException) + { + // A request raced with shutdown; nothing left to reset. + } + } + + public void Dispose() => _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..e0aba02f6 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(); 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/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": [ From 41f007243b6094cc9843a01ab6044fffbd209abf Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Mon, 26 Oct 2026 11:33:42 +0100 Subject: [PATCH 2/2] Fix inactivity timer startup and expiry races Preserve accepted large timeout values with bounded timer intervals and add deterministic regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../InactivityTimerTests.cs | 156 ++++++++++++++++++ .../Internal/InactivityTimer.cs | 71 ++++++-- DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs | 1 + 3 files changed, 218 insertions(+), 10 deletions(-) create mode 100644 DevProxy.Proxy.Kestrel.Tests/InactivityTimerTests.cs 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 index 491ab82ce..1e3b9238b 100644 --- a/DevProxy.Proxy.Kestrel/Internal/InactivityTimer.cs +++ b/DevProxy.Proxy.Kestrel/Internal/InactivityTimer.cs @@ -5,25 +5,76 @@ namespace DevProxy.Proxy.Kestrel.Internal; /// -/// Invokes once no activity has been reported via -/// for seconds. +/// 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(long timeoutSeconds, Action timeoutAction) : IDisposable +internal sealed class InactivityTimer : IDisposable { - private readonly TimeSpan _timeout = TimeSpan.FromSeconds(timeoutSeconds); - private readonly Timer _timer = new(_ => timeoutAction.Invoke(), null, TimeSpan.FromSeconds(timeoutSeconds), Timeout.InfiniteTimeSpan); + 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() { - try + lock (_lock) { - _ = _timer.Change(_timeout, Timeout.InfiniteTimeSpan); + if (_stopped) + { + return; + } + + _started = true; + _lastActivity = _timeProvider.GetTimestamp(); + _ = _timer.Change(GetTimerInterval(_timeoutSeconds), Timeout.InfiniteTimeSpan); } - catch (ObjectDisposedException) + } + + private void OnElapsed(object? state) + { + lock (_lock) { - // A request raced with shutdown; nothing left to reset. + 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(); } } - public void Dispose() => _timer.Dispose(); + // 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/KestrelProxyEngine.cs b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs index e0aba02f6..d32f39143 100644 --- a/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs +++ b/DevProxy.Proxy.Kestrel/KestrelProxyEngine.cs @@ -95,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.