diff --git a/DevProxy.Tests/InteractiveConsoleServiceTests.cs b/DevProxy.Tests/InteractiveConsoleServiceTests.cs
new file mode 100644
index 00000000..50536e30
--- /dev/null
+++ b/DevProxy.Tests/InteractiveConsoleServiceTests.cs
@@ -0,0 +1,69 @@
+// 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;
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace DevProxy.Tests;
+
+///
+/// Verifies that shutting down the proxy (Ctrl+C, devproxy stop) flushes an
+/// active recording so recording plugins still run (dotnet/dev-proxy#1945).
+///
+public sealed class InteractiveConsoleServiceTests
+{
+ private static (InteractiveConsoleService service, FakeProxyStateController controller) CreateService()
+ {
+ var controller = new FakeProxyStateController();
+ var service = new InteractiveConsoleService(
+ controller,
+ new FakeProxyConfiguration(),
+ new RecordingConsole { IsInputRedirected = true },
+ new FakeServer(),
+ new FakeLifetime(),
+ NullLogger.Instance);
+ return (service, controller);
+ }
+
+ [Fact]
+ public async Task StopAsync_WhileRecording_StopsRecording()
+ {
+ var (service, controller) = CreateService();
+ controller.ProxyState.IsRecording = true;
+
+ await service.StopAsync(CancellationToken.None);
+
+ Assert.Equal(1, controller.StopRecordingCalls);
+ }
+
+ [Fact]
+ public async Task StopAsync_NotRecording_DoesNotStopRecording()
+ {
+ var (service, controller) = CreateService();
+
+ await service.StopAsync(CancellationToken.None);
+
+ Assert.Equal(0, controller.StopRecordingCalls);
+ }
+
+ private sealed class FakeServer : IServer
+ {
+ public IFeatureCollection Features { get; } = new FeatureCollection();
+ public void Dispose() { }
+ public Task StartAsync(IHttpApplication application, CancellationToken cancellationToken) where TContext : notnull => Task.CompletedTask;
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+
+ private sealed class FakeLifetime : IHostApplicationLifetime
+ {
+ public CancellationToken ApplicationStarted => CancellationToken.None;
+ public CancellationToken ApplicationStopping => CancellationToken.None;
+ public CancellationToken ApplicationStopped => CancellationToken.None;
+ public void StopApplication() { }
+ }
+}
diff --git a/DevProxy/Proxy/InteractiveConsoleService.cs b/DevProxy/Proxy/InteractiveConsoleService.cs
index 9e4f7fb1..f963a0d6 100644
--- a/DevProxy/Proxy/InteractiveConsoleService.cs
+++ b/DevProxy/Proxy/InteractiveConsoleService.cs
@@ -130,6 +130,23 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
}
}
+ ///
+ /// Flushes an active recording on shutdown (Ctrl+C, devproxy stop, API stop)
+ /// so plugins like HarGeneratorPlugin still process the recorded requests.
+ /// Hosted services stop in reverse registration order, so normal shutdown
+ /// stops the proxy engine before this service flushes the recording.
+ ///
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ // Stop the hotkey loop first so a concurrent 'S' press can't race this flush.
+ await base.StopAsync(cancellationToken);
+
+ if (controller.ProxyState.IsRecording)
+ {
+ await controller.StopRecordingAsync(cancellationToken);
+ }
+ }
+
///
/// Waits for the host to finish starting so the banner is printed after the
/// engine's startup logs (and the bound port is known). Returns false