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")]