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": [