diff --git a/Changelog.md b/Changelog.md index f558d18..a162488 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,6 +1,11 @@ # Changelog -## Version 1.0.2 +## Version 1.0.3.0 + +- Added `-t, --timeout` parameter to specify the timeout in milliseconds (default: -1 - infinity) +- Fixed handling of timeouts as HttpClient hides the exception under `TaskCanceledException` leading to same output and handling as manual user cancellation, which was incorrect + +## Version 1.0.2.0 - Added `-o, --output` parameter to specify the output folder (default: "results") - Updated automations to add ubuntu 20.04 support to address issues with glibc2.31 diff --git a/Readme.md b/Readme.md index ecdeeea..53ecd41 100644 --- a/Readme.md +++ b/Readme.md @@ -114,6 +114,7 @@ RequestFile: - If you don't have one use the "get-sample" command Options: -n, --number : number of total requests (default: 1) + -t, --timeout : timeout in milliseconds (default: -1 - infinity) -m, --mode : execution mode (default: parallel) * sequential = execute requests sequentially * parallel = execute requests using maximum resources diff --git a/src/Pulse/Configuration/Parameters.cs b/src/Pulse/Configuration/Parameters.cs index e66b79b..ab73df5 100644 --- a/src/Pulse/Configuration/Parameters.cs +++ b/src/Pulse/Configuration/Parameters.cs @@ -16,6 +16,11 @@ public record ParametersBase { /// public const int DefaultBatchSize = 1; + /// + /// Default timeout in milliseconds (infinity) + /// + public const int DefaultTimeoutInMs = -1; + /// /// Default execution mode /// @@ -26,6 +31,11 @@ public record ParametersBase { /// public int Requests { get; set; } = DefaultNumberOfRequests; + /// + /// Sets the timeout in milliseconds + /// + public int TimeoutInMs { get; set; } = DefaultTimeoutInMs; + /// /// Sets the execution mode (default = ) /// diff --git a/src/Pulse/Configuration/StrippedException.cs b/src/Pulse/Configuration/StrippedException.cs index 5b11555..1a79b57 100644 --- a/src/Pulse/Configuration/StrippedException.cs +++ b/src/Pulse/Configuration/StrippedException.cs @@ -49,6 +49,20 @@ private StrippedException(Exception exception) { Type = exception.GetType().Name; Message = exception.Message; StackTrace = exception.StackTrace ?? ""; + IsDefault = false; + } + + /// + /// Creates a stripped exception from a type, message and stack trace + /// + /// + /// + /// + public StrippedException(string type, string message, string stackTrace) { + Type = type; + Message = message; + StackTrace = stackTrace; + IsDefault = false; } [JsonConstructor] diff --git a/src/Pulse/Core/Pulse.cs b/src/Pulse/Core/Pulse.cs index bceffc4..123da35 100644 --- a/src/Pulse/Core/Pulse.cs +++ b/src/Pulse/Core/Pulse.cs @@ -26,7 +26,7 @@ public static Task RunAsync(Parameters parameters, RequestDetails requestDetails /// /// internal static async Task RunSequential(Parameters parameters, RequestDetails requestDetails) { - using var httpClient = PulseHttpClientFactory.Create(requestDetails.Proxy); + using var httpClient = PulseHttpClientFactory.Create(requestDetails.Proxy, parameters.TimeoutInMs); var monitor = new PulseMonitor { RequestCount = parameters.Requests, @@ -60,7 +60,7 @@ internal static async Task RunSequential(Parameters parameters, RequestDetails r /// /// internal static async Task RunBounded(Parameters parameters, RequestDetails requestDetails) { - using var httpClient = PulseHttpClientFactory.Create(requestDetails.Proxy); + using var httpClient = PulseHttpClientFactory.Create(requestDetails.Proxy, parameters.TimeoutInMs); var cancellationToken = parameters.CancellationToken; @@ -110,7 +110,7 @@ internal static async Task RunBounded(Parameters parameters, RequestDetails requ /// /// internal static async Task RunUnbounded(Parameters parameters, RequestDetails requestDetails) { - using var httpClient = PulseHttpClientFactory.Create(requestDetails.Proxy); + using var httpClient = PulseHttpClientFactory.Create(requestDetails.Proxy, parameters.TimeoutInMs); var cancellationToken = parameters.CancellationToken; diff --git a/src/Pulse/Core/PulseHttpClientFactory.cs b/src/Pulse/Core/PulseHttpClientFactory.cs index 3916e70..0deffd4 100644 --- a/src/Pulse/Core/PulseHttpClientFactory.cs +++ b/src/Pulse/Core/PulseHttpClientFactory.cs @@ -13,11 +13,11 @@ public static class PulseHttpClientFactory { /// /// /// An HttpClient - public static HttpClient Create(Proxy proxyDetails) { + public static HttpClient Create(Proxy proxyDetails, int TimeoutInMs) { SocketsHttpHandler handler = CreateHandler(proxyDetails); return new HttpClient(handler) { - Timeout = TimeSpan.FromMinutes(10) + Timeout = TimeSpan.FromMilliseconds(TimeoutInMs) }; } diff --git a/src/Pulse/Core/PulseMonitor.cs b/src/Pulse/Core/PulseMonitor.cs index f8e4033..c5bf913 100644 --- a/src/Pulse/Core/PulseMonitor.cs +++ b/src/Pulse/Core/PulseMonitor.cs @@ -89,7 +89,7 @@ public async Task SendAsync(int requestId) { internal static async Task SendRequest(int id, Request requestRecipe, HttpClient httpClient, bool saveContent, CancellationToken cancellationToken = default) { HttpStatusCode statusCode = 0; string content = ""; - Exception? exception = null; + StrippedException exception = StrippedException.Default; var headers = Enumerable.Empty>>(); int threadId = 0; using var message = requestRecipe.CreateMessage(); @@ -103,9 +103,13 @@ internal static async Task SendRequest(int id, Request requestRecipe, content = await response.Content.ReadAsStringAsync(cancellationToken); } } catch (Exception e) when (e is TaskCanceledException or OperationCanceledException) { - throw; + if (cancellationToken.IsCancellationRequested) { + throw; + } + var elapsed = Stopwatch.GetElapsedTime(start); + exception = new StrippedException(nameof(TimeoutException), $"Request {id} timeout after {elapsed.TotalMilliseconds} ms", ""); } catch (Exception e) { - exception = e; + exception = StrippedException.FromException(e); } finally { message?.Dispose(); } @@ -116,7 +120,7 @@ internal static async Task SendRequest(int id, Request requestRecipe, Headers = headers, Content = content, Duration = duration, - Exception = StrippedException.FromException(exception), + Exception = exception, ExecutingThreadId = threadId }; } diff --git a/src/Pulse/Core/SendCommand.cs b/src/Pulse/Core/SendCommand.cs index 315f984..f17458b 100644 --- a/src/Pulse/Core/SendCommand.cs +++ b/src/Pulse/Core/SendCommand.cs @@ -32,6 +32,7 @@ path to .json request details file - If you don't have one use the "get-sample" command Options: -n, --number : number of total requests (default: 1) + -t, --timeout : timeout in milliseconds (default: -1 - infinity) -m, --mode : execution mode (default: parallel) * sequential = execute requests sequentially * parallel = execute requests using maximum resources @@ -53,6 +54,7 @@ path to .json request details file internal static ParametersBase ParseParametersArgs(Arguments args) { args.TryGetValue(["n", "number"], ParametersBase.DefaultNumberOfRequests, out int requests); requests = Math.Max(requests, 1); + args.TryGetValue(["t", "timeout"], ParametersBase.DefaultTimeoutInMs, out int timeoutInMs); bool batchSizeModified = false; args.TryGetEnum(["m", "mode"], ParametersBase.DefaultExecutionMode, true, out ExecutionMode mode); if (args.TryGetValue(["b", "batch"], ParametersBase.DefaultBatchSize, out int batchSize)) { @@ -67,6 +69,7 @@ internal static ParametersBase ParseParametersArgs(Arguments args) { bool verbose = args.HasFlag("v") || args.HasFlag("verbose"); return new ParametersBase { Requests = requests, + TimeoutInMs = timeoutInMs, ExecutionMode = mode, BatchSize = batchSize, BatchSizeModified = batchSizeModified, @@ -169,11 +172,15 @@ public override async ValueTask ExecuteAsync(Arguments args) { if (result.IsFail) { return; } - var version = result.Message; - if (string.Compare(Program.VERSION, version) < 0) { + if (!Version.TryParse(result.Message, out Version? remoteVersion)) { + WriteLineError("Failed to parse remote version."); + return; + } + var currentVersion = Version.Parse(Program.VERSION); + if (currentVersion < remoteVersion) { WriteLine("A new version of Pulse is available!" * Color.Yellow); WriteLine(["Your version: ", Program.VERSION * Color.Yellow]); - WriteLine(["Latest version: ", version * Color.Green]); + WriteLine(["Latest version: ", remoteVersion.ToString() * Color.Green]); NewLine(); WriteLine("Download from https://github.com/dusrdev/Pulse/releases/latest"); } else { @@ -205,6 +212,7 @@ internal static void PrintConfiguration(Parameters parameters, RequestDetails re // Options WriteLine("Options:" * headerColor); WriteLine([" Request Count: " * property, $"{parameters.Requests}" * value]); + WriteLine([" Timeout: " * property, $"{parameters.TimeoutInMs}" * value]); WriteLine([" Execution Mode: " * property, $"{parameters.ExecutionMode}" * value]); #pragma warning disable IDE0002 if (parameters.BatchSize is not Parameters.DefaultBatchSize) { diff --git a/src/Pulse/Program.cs b/src/Pulse/Program.cs index 093bbc3..53d7ebe 100644 --- a/src/Pulse/Program.cs +++ b/src/Pulse/Program.cs @@ -7,7 +7,7 @@ using PrettyConsole; internal class Program { - internal const string VERSION = "1.0.2.0"; + internal const string VERSION = "1.0.3.0"; private static async Task Main(string[] args) { using CancellationTokenSource globalCTS = new(); diff --git a/src/Pulse/Pulse.csproj b/src/Pulse/Pulse.csproj index f21c901..4c1a79e 100644 --- a/src/Pulse/Pulse.csproj +++ b/src/Pulse/Pulse.csproj @@ -13,7 +13,7 @@ true true true - 1.0.2.0 + 1.0.3.0 true https://github.com/dusrdev/Pulse git diff --git a/tests/Pulse.Tests.Unit/HttpClientFactoryTests.cs b/tests/Pulse.Tests.Unit/HttpClientFactoryTests.cs index c178fab..c89abb1 100644 --- a/tests/Pulse.Tests.Unit/HttpClientFactoryTests.cs +++ b/tests/Pulse.Tests.Unit/HttpClientFactoryTests.cs @@ -1,17 +1,31 @@ using System.Net; +using Pulse.Configuration; + using Pulse.Core; namespace Pulse.Tests.Unit; public class HttpClientFactoryTests { + [Fact] + public void HttpClientFactory_DefaultTimeout_IsInfinite() { + // Arrange + var proxy = new Proxy(); + + // Act + using var httpClient = PulseHttpClientFactory.Create(proxy, Parameters.DefaultTimeoutInMs); + + // Assert + httpClient.Timeout.Should().Be(Timeout.InfiniteTimeSpan, "because the default timeout is infinite"); + } + [Fact] public void HttpClientFactory_WithoutProxy_ReturnsHttpClient() { // Arrange var proxy = new Proxy(); // Act - using var httpClient = PulseHttpClientFactory.Create(proxy); + using var httpClient = PulseHttpClientFactory.Create(proxy, Parameters.DefaultTimeoutInMs); // Assert httpClient.Should().NotBeNull("because a HttpClient is returned"); diff --git a/tests/Pulse.Tests.Unit/PulseMonitorTests.cs b/tests/Pulse.Tests.Unit/PulseMonitorTests.cs new file mode 100644 index 0000000..b43f34e --- /dev/null +++ b/tests/Pulse.Tests.Unit/PulseMonitorTests.cs @@ -0,0 +1,23 @@ +using Pulse.Core; + +namespace Pulse.Tests.Unit; + +public class PulseMonitorTests { + [Fact] + public async Task SendAsync_ReturnsTimeoutException_OnTimeout() { + // Arrange + var requestDetails = new RequestDetails() { + Proxy = new Proxy(), + Request = new Request() { + Url = "https://google.com", + Method = HttpMethod.Get + } + }; + + using var httpClient = PulseHttpClientFactory.Create(requestDetails.Proxy, 50); + + // Act + Assert + var result = await PulseMonitor.SendRequest(1, requestDetails.Request, httpClient, false, CancellationToken.None); + result.Exception.Type.Should().Be(nameof(TimeoutException)); + } +} \ No newline at end of file diff --git a/tests/Pulse.Tests.Unit/SencCommandParsingTests.cs b/tests/Pulse.Tests.Unit/SencCommandParsingTests.cs index 45f1f1e..923b7f2 100644 --- a/tests/Pulse.Tests.Unit/SencCommandParsingTests.cs +++ b/tests/Pulse.Tests.Unit/SencCommandParsingTests.cs @@ -17,6 +17,22 @@ public void Arguments_Flag_NoOp() { @params.NoOp.Should().BeTrue("because the flag is present"); } + [Theory] + [InlineData("Pulse -v", -1)] // default + [InlineData("Pulse -v -t -1", -1)] // set but default + [InlineData("Pulse --verbose -t 30000", 30000)] // custom + [InlineData("Pulse --verbose --timeout 30000", 30000)] // custom + public void Arguments_Timeout(string arguments, int expected) { + // Arrange + var args = Parser.ParseArguments(arguments)!; + + // Act + var @params = SendCommand.ParseParametersArgs(args); + + // Assert + @params.TimeoutInMs.Should().Be(expected, "because parsed or default"); + } + [Theory] [InlineData("Pulse -v")] [InlineData("Pulse --verbose")]