HttpStress: add randomized header operation
authorEirik Tsarpalis <eirik.tsarpalis@gmail.com>
Thu, 25 Jul 2019 17:21:30 +0000 (18:21 +0100)
committerEirik Tsarpalis <eirik.tsarpalis@gmail.com>
Thu, 25 Jul 2019 17:21:30 +0000 (18:21 +0100)
Aguments the stress suite with an "Echo Header" operation
which sends and receives a randomly generated set of headers.
Additionally expands random string to full alphanumeric or ascii range.
Fixes a bug where `n - 1` workers were being initialized.

Commit migrated from https://github.com/dotnet/corefx/commit/ac3e0d6f35dfccdd70bacfc09941fef791192da5

src/libraries/System.Net.Http/tests/StressTests/HttpStress/ClientOperations.cs
src/libraries/System.Net.Http/tests/StressTests/HttpStress/Program.cs
src/libraries/System.Net.Http/tests/StressTests/HttpStress/StressClient.cs
src/libraries/System.Net.Http/tests/StressTests/HttpStress/StressServer.cs

index 6263ecf..8c0d858 100644 (file)
@@ -8,15 +8,19 @@ using System.IO;
 using System.Linq;
 using System.Net;
 using System.Net.Http;
+using System.Net.Http.Headers;
 using System.Text;
 using System.Threading;
 using System.Threading.Tasks;
+using System.Web;
 
 namespace HttpStress
 {
     /// <summary>Client context containing information pertaining to a single request.</summary>
     public sealed class RequestContext
     {
+        private const string alphaNumeric = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
+
         private readonly Random _random;
         private readonly HttpClient _client;
         private readonly double _cancellationProbability;
@@ -75,22 +79,33 @@ namespace HttpStress
             }
         }
 
-        public string GetRandomString(int maxLength)
+        /// Gets a random ASCII string within specified length range
+        public string GetRandomString(int minLength, int maxLength, bool alphaNumericOnly = true)
         {
-            int length = _random.Next(0, maxLength);
+            int length = _random.Next(minLength, maxLength);
             var sb = new StringBuilder(length);
             for (int i = 0; i < length; i++)
             {
-                sb.Append((char)(_random.Next(0, 26) + 'a'));
+                if (alphaNumericOnly)
+                {
+                    // alpha character
+                    sb.Append(alphaNumeric[_random.Next(alphaNumeric.Length)]);
+                }
+                else
+                {
+                    // use a random ascii character
+                    sb.Append((char)_random.Next(0, 128));
+                }
             }
+
             return sb.ToString();
         }
 
-        public string GetRandomSubstring(string input)
+        public byte[] GetRandomBytes(int minBytes, int maxBytes)
         {
-            int offset = _random.Next(0, input.Length);
-            int length = _random.Next(0, input.Length - offset + 1);
-            return input.Substring(offset, length);
+            byte[] bytes = new byte[_random.Next(minBytes, maxBytes)];
+            _random.NextBytes(bytes);
+            return bytes;
         }
 
         public bool GetRandomBoolean(double probability = 0.5)
@@ -101,6 +116,18 @@ namespace HttpStress
             return _random.NextDouble() < probability;
         }
 
+        public void PopulateWithRandomHeaders(HttpRequestHeaders headers)
+        {
+            int numHeaders = _random.Next(maxValue: 100);
+
+            for (int i = 0; i < numHeaders; i++)
+            {
+                string name = $"Header-{i}";
+                IEnumerable<string> values = Enumerable.Range(0, _random.Next(0, 5)).Select(_ => HttpUtility.UrlEncode(GetRandomString(0, 30, alphaNumericOnly: false)));
+                headers.Add(name, values);
+            }
+        }
+
         public int GetRandomInt32(int minValueInclusive, int maxValueExclusive) => _random.Next(minValueInclusive, maxValueExclusive);
 
         public Version GetRandomHttpVersion() => GetRandomBoolean(_http2Probability) ? new Version(2, 0) : new Version(1, 1);
@@ -122,7 +149,8 @@ namespace HttpStress
                     using (var req = new HttpRequestMessage(HttpMethod.Get, "/") { Version = httpVersion })
                     using (HttpResponseMessage m = await ctx.SendAsync(req))
                     {
-                        ValidateResponse(m, httpVersion);
+                        ValidateHttpVersion(m, httpVersion);
+                        ValidateStatusCode(m);
                         ValidateContent(ctx.ContentSource, await m.Content.ReadAsStringAsync());
                     }
                 }),
@@ -134,7 +162,9 @@ namespace HttpStress
                     using (var req = new HttpRequestMessage(HttpMethod.Get, "/slow") { Version = httpVersion })
                     using (HttpResponseMessage m = await ctx.SendAsync(req, HttpCompletionOption.ResponseHeadersRead))
                     {
-                        ValidateResponse(m, httpVersion);
+                        ValidateHttpVersion(m, httpVersion);
+                        ValidateStatusCode(m);
+
                         using (Stream s = await m.Content.ReadAsStreamAsync())
                         {
                             s.ReadByte(); // read single byte from response and throw the rest away
@@ -149,21 +179,55 @@ namespace HttpStress
                     using (var req = new HttpRequestMessage(HttpMethod.Get, "/headers") { Version = httpVersion })
                     using (HttpResponseMessage m = await ctx.SendAsync(req))
                     {
-                        ValidateResponse(m, httpVersion);
+                        ValidateHttpVersion(m, httpVersion);
+                        ValidateStatusCode(m);
                         ValidateContent(ctx.ContentSource, await m.Content.ReadAsStringAsync());
                     }
                 }),
 
+                ("GET Echo Headers",
+                async ctx =>
+                {
+                    Version httpVersion = ctx.GetRandomHttpVersion();
+
+                    using (var req = new HttpRequestMessage(HttpMethod.Get, "/echoHeaders") { Version = httpVersion })
+                    {
+                        ctx.PopulateWithRandomHeaders(req.Headers);
+
+                        using (HttpResponseMessage res = await ctx.SendAsync(req))
+                        {
+                            ValidateHttpVersion(res, httpVersion);
+                            ValidateStatusCode(res);
+
+                            // Validate that response headers are being echoed
+                            foreach (var reqHeader in req.Headers)
+                            {
+                                if (!res.Headers.TryGetValues(reqHeader.Key, out var values))
+                                {
+                                    throw new Exception($"Expected response header name {reqHeader.Key} missing.");
+                                }
+                                else if (!reqHeader.Value.SequenceEqual(values))
+                                {
+                                    string FmtValues(IEnumerable<string> values) => $"{string.Join(", ", values.Select(x => $"\"{x}\""))}";
+                                    throw new Exception($"Unexpected values for header {reqHeader.Key}. Expected {FmtValues(reqHeader.Value)} but got {FmtValues(values)}");
+                                }
+                            }
+                        }
+
+                    }
+                }),
+
                 ("GET Parameters",
                 async ctx =>
                 {
                     Version httpVersion = ctx.GetRandomHttpVersion();
                     string uri = "/variables";
-                    string expectedResponse = GetGetQueryParameters(ref uri, ctx.MaxRequestLineSize, ctx.ContentSource, ctx, ctx.MaxRequestParameters);
+                    string expectedResponse = GetGetQueryParameters(ref uri, ctx.MaxRequestLineSize, ctx, ctx.MaxRequestParameters);
                     using (var req = new HttpRequestMessage(HttpMethod.Get, uri) { Version = httpVersion })
                     using (HttpResponseMessage m = await ctx.SendAsync(req))
                     {
-                        ValidateResponse(m, httpVersion);
+                        ValidateHttpVersion(m, httpVersion);
+                        ValidateStatusCode(m);
                         ValidateContent(expectedResponse, await m.Content.ReadAsStringAsync(), $"Uri: {uri}");
                     }
                 }),
@@ -216,13 +280,14 @@ namespace HttpStress
                 ("POST",
                 async ctx =>
                 {
-                    string content = ctx.GetRandomSubstring(ctx.ContentSource);
+                    string content = ctx.GetRandomString(0, ctx.MaxContentLength);
                     Version httpVersion = ctx.GetRandomHttpVersion();
 
                     using (var req = new HttpRequestMessage(HttpMethod.Post, "/") { Version = httpVersion, Content = new StringDuplexContent(content) })
                     using (HttpResponseMessage m = await ctx.SendAsync(req))
                     {
-                        ValidateResponse(m, httpVersion);
+                        ValidateHttpVersion(m, httpVersion);
+                        ValidateStatusCode(m);
                         ValidateContent(content, await m.Content.ReadAsStringAsync());;
                     }
                 }),
@@ -230,13 +295,14 @@ namespace HttpStress
                 ("POST Multipart Data",
                 async ctx =>
                 {
-                    (string expected, MultipartContent formDataContent) formData = GetMultipartContent(ctx.ContentSource, ctx, ctx.MaxRequestParameters);
+                    (string expected, MultipartContent formDataContent) formData = GetMultipartContent(ctx, ctx.MaxRequestParameters);
                     Version httpVersion = ctx.GetRandomHttpVersion();
 
                     using (var req = new HttpRequestMessage(HttpMethod.Post, "/") { Version = httpVersion, Content = formData.formDataContent })
                     using (HttpResponseMessage m = await ctx.SendAsync(req))
                     {
-                        ValidateResponse(m, httpVersion);
+                        ValidateHttpVersion(m, httpVersion);
+                        ValidateStatusCode(m);
                         ValidateContent($"{formData.expected}", await m.Content.ReadAsStringAsync());;
                     }
                 }),
@@ -244,13 +310,14 @@ namespace HttpStress
                 ("POST Duplex",
                 async ctx =>
                 {
-                    string content = ctx.GetRandomSubstring(ctx.ContentSource);
+                    string content = ctx.GetRandomString(0, ctx.MaxContentLength);
                     Version httpVersion = ctx.GetRandomHttpVersion();
 
                     using (var req = new HttpRequestMessage(HttpMethod.Post, "/duplex") { Version = httpVersion, Content = new StringDuplexContent(content) })
                     using (HttpResponseMessage m = await ctx.SendAsync(req, HttpCompletionOption.ResponseHeadersRead))
                     {
-                        ValidateResponse(m, httpVersion);
+                        ValidateHttpVersion(m, httpVersion);
+                        ValidateStatusCode(m);
                         ValidateContent(content, await m.Content.ReadAsStringAsync());
                     }
                 }),
@@ -258,13 +325,14 @@ namespace HttpStress
                 ("POST Duplex Slow",
                 async ctx =>
                 {
-                    string content = ctx.GetRandomSubstring(ctx.ContentSource);
+                    string content = ctx.GetRandomString(0, ctx.MaxContentLength);
                     Version httpVersion = ctx.GetRandomHttpVersion();
 
                     using (var req = new HttpRequestMessage(HttpMethod.Post, "/duplexSlow") { Version = httpVersion, Content = new ByteAtATimeNoLengthContent(Encoding.ASCII.GetBytes(content)) })
                     using (HttpResponseMessage m = await ctx.SendAsync(req, HttpCompletionOption.ResponseHeadersRead))
                     {
-                        ValidateResponse(m, httpVersion);
+                        ValidateHttpVersion(m, httpVersion);
+                        ValidateStatusCode(m);
                         ValidateContent(content, await m.Content.ReadAsStringAsync());
                     }
                 }),
@@ -272,7 +340,7 @@ namespace HttpStress
                 ("POST ExpectContinue",
                 async ctx =>
                 {
-                    string content = ctx.GetRandomSubstring(ctx.ContentSource);
+                    string content = ctx.GetRandomString(0, ctx.MaxContentLength);
                     Version httpVersion = ctx.GetRandomHttpVersion();
 
                     using (var req = new HttpRequestMessage(HttpMethod.Post, "/") { Version = httpVersion, Content = new StringContent(content) })
@@ -280,7 +348,8 @@ namespace HttpStress
                         req.Headers.ExpectContinue = true;
                         using (HttpResponseMessage m = await ctx.SendAsync(req, HttpCompletionOption.ResponseHeadersRead))
                         {
-                            ValidateResponse(m, httpVersion);
+                            ValidateHttpVersion(m, httpVersion);
+                            ValidateStatusCode(m);
                             ValidateContent(content, await m.Content.ReadAsStringAsync());
                         }
                     }
@@ -293,7 +362,9 @@ namespace HttpStress
                     using (var req = new HttpRequestMessage(HttpMethod.Head, "/") { Version = httpVersion })
                     using (HttpResponseMessage m = await ctx.SendAsync(req))
                     {
-                        ValidateResponse(m, httpVersion);
+                        ValidateHttpVersion(m, httpVersion);
+                        ValidateStatusCode(m);
+
                         if (m.Content.Headers.ContentLength != ctx.MaxContentLength)
                         {
                             throw new Exception($"Expected {ctx.MaxContentLength}, got {m.Content.Headers.ContentLength}");
@@ -306,13 +377,15 @@ namespace HttpStress
                 ("PUT",
                 async ctx =>
                 {
-                    string content = ctx.GetRandomSubstring(ctx.ContentSource);
+                    string content = ctx.GetRandomString(0, ctx.MaxContentLength);
                     Version httpVersion = ctx.GetRandomHttpVersion();
 
                     using (var req = new HttpRequestMessage(HttpMethod.Put, "/") { Version = httpVersion, Content = new StringContent(content) })
                     using (HttpResponseMessage m = await ctx.SendAsync(req))
                     {
-                        ValidateResponse(m, httpVersion);
+                        ValidateHttpVersion(m, httpVersion);
+                        ValidateStatusCode(m);
+
                         string r = await m.Content.ReadAsStringAsync();
                         if (r != "") throw new Exception($"Got unexpected response: {r}");
                     }
@@ -320,7 +393,7 @@ namespace HttpStress
             };
 
         // Validation of a response message
-        private static void ValidateResponse(HttpResponseMessage m, Version expectedVersion)
+        private static void ValidateHttpVersion(HttpResponseMessage m, Version expectedVersion)
         {
             if (m.Version != expectedVersion)
             {
@@ -328,6 +401,14 @@ namespace HttpStress
             }
         }
 
+        private static void ValidateStatusCode(HttpResponseMessage m, HttpStatusCode expectedStatus = HttpStatusCode.OK)
+        {
+            if (m.StatusCode != expectedStatus)
+            {
+                throw new Exception($"Expected status code {expectedStatus}, got {m.StatusCode}");
+            }
+        }
+
         private static void ValidateContent(string expectedContent, string actualContent, string details = null)
         {
             if (actualContent != expectedContent)
@@ -336,7 +417,7 @@ namespace HttpStress
             }
         }
 
-        private static string GetGetQueryParameters(ref string uri, int maxRequestLineSize, string contentSource, RequestContext clientContext, int numParameters)
+        private static string GetGetQueryParameters(ref string uri, int maxRequestLineSize, RequestContext clientContext, int numParameters)
         {
             if (maxRequestLineSize < uri.Length)
             {
@@ -366,7 +447,7 @@ namespace HttpStress
 
                 uriSb.Append(key);
 
-                string value = clientContext.GetRandomString(Math.Min(appxMaxValueLength, remainingLength));
+                string value = clientContext.GetRandomString(0, Math.Min(appxMaxValueLength, remainingLength));
                 expectedString.Append(value);
                 uriSb.Append(value);
             }
@@ -375,9 +456,9 @@ namespace HttpStress
             return expectedString.ToString();
         }
 
-        private static (string, MultipartContent) GetMultipartContent(string contentSource, RequestContext clientContext, int numFormFields)
+        private static (string, MultipartContent) GetMultipartContent(RequestContext clientContext, int numFormFields)
         {
-            var multipartContent = new MultipartContent("prefix" + clientContext.GetRandomSubstring(contentSource), "test_boundary");
+            var multipartContent = new MultipartContent("prefix" + clientContext.GetRandomString(0, clientContext.MaxContentLength, alphaNumericOnly: true), "test_boundary");
             StringBuilder sb = new StringBuilder();
 
             int num = clientContext.GetRandomInt32(1, numFormFields + 1);
@@ -385,7 +466,7 @@ namespace HttpStress
             for (int i = 0; i < num; i++)
             {
                 sb.Append("--test_boundary\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n");
-                string content = clientContext.GetRandomSubstring(contentSource);
+                string content = clientContext.GetRandomString(0, clientContext.MaxContentLength);
                 sb.Append(content);
                 sb.Append("\r\n");
                 multipartContent.Add(new StringContent(content));
index 1f59f03..0658180 100644 (file)
@@ -19,7 +19,7 @@ public class Program
     [Flags]
     enum RunMode { server = 1, client = 2, both = 3 }; 
 
-    public static void Main(string[] args)
+    public static async Task Main(string[] args)
     {
         var cmd = new RootCommand();
         cmd.AddOption(new Option("-n", "Max number of requests to make concurrently.") { Argument = new Argument<int>("numWorkers", Environment.ProcessorCount) });
@@ -30,6 +30,7 @@ public class Program
         cmd.AddOption(new Option("-http", "HTTP version (1.1 or 2.0)") { Argument = new Argument<Version>("version", HttpVersion.Version20) });
         cmd.AddOption(new Option("-connectionLifetime", "Max connection lifetime length (milliseconds).") { Argument = new Argument<int?>("connectionLifetime", null)});
         cmd.AddOption(new Option("-ops", "Indices of the operations to use") { Argument = new Argument<int[]>("space-delimited indices", null) });
+        cmd.AddOption(new Option("-xops", "Indices of the operations to exclude") { Argument = new Argument<int[]>("space-delimited indices", null) });
         cmd.AddOption(new Option("-trace", "Enable Microsoft-System-Net-Http tracing.") { Argument = new Argument<string>("\"console\" or path") });
         cmd.AddOption(new Option("-aspnetlog", "Enable ASP.NET warning and error logging.") { Argument = new Argument<bool>("enable", false) });
         cmd.AddOption(new Option("-listOps", "List available options.") { Argument = new Argument<bool>("enable", false) });
@@ -51,6 +52,7 @@ public class Program
             return;
         }
 
+        await
         Run(
             runMode                 : cmdline.ValueForOption<RunMode>("-runMode"),
             serverUri               : cmdline.ValueForOption<Uri>("-serverUri"),
@@ -61,6 +63,7 @@ public class Program
             httpVersion             : cmdline.ValueForOption<Version>("-http"),
             connectionLifetime      : cmdline.ValueForOption<int?>("-connectionLifetime"),
             opIndices               : cmdline.ValueForOption<int[]>("-ops"),
+            excludedOpIndices       : cmdline.ValueForOption<int[]>("-xops"),
             logPath                 : cmdline.HasOption("-trace") ? cmdline.ValueForOption<string>("-trace") : null,
             aspnetLog               : cmdline.ValueForOption<bool>("-aspnetlog"),
             listOps                 : cmdline.ValueForOption<bool>("-listOps"),
@@ -70,16 +73,35 @@ public class Program
             displayIntervalSeconds  : cmdline.ValueForOption<int>("-displayInterval"));
     }
 
-    private static void Run(RunMode runMode, Uri serverUri, bool httpSys, int concurrentRequests, int maxContentLength, int maxRequestLineSize, Version httpVersion, int? connectionLifetime, int[] opIndices, string logPath, bool aspnetLog, bool listOps, int seed, int numParameters, double cancellationProbability, int displayIntervalSeconds)
+    private static async Task Run(RunMode runMode, Uri serverUri, bool httpSys, int concurrentRequests, int maxContentLength, int maxRequestLineSize, Version httpVersion, int? connectionLifetime, int[] opIndices, int[] excludedOpIndices, string logPath, bool aspnetLog, bool listOps, int seed, int numParameters, double cancellationProbability, int displayIntervalSeconds)
     {
 
-        if (serverUri.Scheme != "https")
+        (string name, Func<RequestContext, Task> op)[] clientOperations = ClientOperations.Operations;
+
+        // handle operation index arguments
+        switch (opIndices, excludedOpIndices)
         {
-            Console.Error.WriteLine("Server uri must be https.");
-            return;
-        }
+            case (null, null):
+                break;
+            case (_, null):
+                clientOperations = opIndices.Select(i => clientOperations[i]).ToArray();
+                break;
+            case (null, _):
+                opIndices =
+                    Enumerable
+                        .Range(0, clientOperations.Length)
+                        .Concat(excludedOpIndices)
+                        .GroupBy(x => x)
+                        .Where(gp => gp.Count() < 2)
+                        .Select(gp => gp.Key)
+                        .ToArray();
 
-        (string name, Func<RequestContext, Task> op)[] clientOperations = ClientOperations.Operations;
+                clientOperations = opIndices.Select(i => clientOperations[i]).ToArray();
+                break;
+            default:
+                Console.Error.WriteLine("Cannot specify both -ops and -xops flags simultaneously");
+                return;
+        }
 
         Console.WriteLine("       .NET Core: " + Path.GetFileName(Path.GetDirectoryName(typeof(object).Assembly.Location)));
         Console.WriteLine("    ASP.NET Core: " + Path.GetFileName(Path.GetDirectoryName(typeof(WebHost).Assembly.Location)));
@@ -112,6 +134,12 @@ public class Program
             return;
         }
 
+        if (serverUri.Scheme != "https")
+        {
+            Console.Error.WriteLine("Server uri must be https.");
+            return;
+        }
+
         if (runMode.HasFlag(RunMode.server))
         {
             // Start the Kestrel web server in-proc.
@@ -125,10 +153,6 @@ public class Program
         {
             // Start the client.
             Console.WriteLine($"Starting {concurrentRequests} client workers.");
-            if (opIndices != null)
-            {
-                clientOperations = opIndices.Select(i => clientOperations[i]).ToArray();
-            }
 
             new StressClient(
                 serverUri: serverUri,
@@ -144,7 +168,7 @@ public class Program
                 displayInterval: TimeSpan.FromSeconds(displayIntervalSeconds));
         }
 
-        AwaitCancelKeyPress().Wait();
+        await AwaitCancelKeyPress();
     }
 
     private static async Task AwaitCancelKeyPress()
index 0aa3b57..8968baa 100644 (file)
@@ -174,7 +174,7 @@ namespace HttpStress
                     }
 
                     // Start N workers, each of which sits in a loop making requests.
-                    Task[] tasks = Enumerable.Range(0, concurrentRequests - 1).Select(RunWorker).ToArray();
+                    Task[] tasks = Enumerable.Range(0, concurrentRequests).Select(RunWorker).ToArray();
                     await Task.WhenAll(tasks);
                 }
             }
index 400d7d3..7bee0e6 100644 (file)
@@ -145,6 +145,21 @@ namespace HttpStress
                     }
                 }
             });
+            endpoints.MapGet("/echoHeaders", async context =>
+            {
+                foreach(KeyValuePair<string, StringValues> header in context.Request.Headers)
+                {
+                    // skip pseudo-headers surfaced by kestrel
+                    if (header.Key.StartsWith(':')) continue;
+
+                    // kestrel seems to not be splitting comma separated header values, handle here
+                    string[] values = header.Value.SelectMany(v => v.Split(',')).Select(x => x.Trim()).ToArray();
+                    context.Response.Headers.Add(header.Key, new StringValues(values));
+                }
+
+                await context.Response.WriteAsync("ok");
+
+            });
             endpoints.MapGet("/variables", async context =>
             {
                 string queryString = context.Request.QueryString.Value;