diff --git a/eng/pipelines/libraries/stress/http-linux.yml b/eng/pipelines/libraries/stress/http-linux.yml index e2a8cb3057bcc7..17cba8ebd91b1f 100644 --- a/eng/pipelines/libraries/stress/http-linux.yml +++ b/eng/pipelines/libraries/stress/http-linux.yml @@ -30,8 +30,7 @@ steps: displayName: Build Corefx - bash: | - cd '$(HttpStressProject)' - docker build -t $(httpStressImage) --build-arg SDK_BASE_IMAGE=$(sdkBaseImage) --build-arg CONFIGURATION=$(BUILD_CONFIGURATION) . + docker build -t $(httpStressImage) --build-arg SDK_BASE_IMAGE=$(sdkBaseImage) --build-arg CONFIGURATION=$(BUILD_CONFIGURATION) -f src/libraries/System.Net.Http/tests/StressTests/HttpStress/Dockerfile src/libraries displayName: Build HttpStress - bash: | diff --git a/eng/pipelines/libraries/stress/ssl-linux.yml b/eng/pipelines/libraries/stress/ssl-linux.yml new file mode 100644 index 00000000000000..c56c7df225a943 --- /dev/null +++ b/eng/pipelines/libraries/stress/ssl-linux.yml @@ -0,0 +1,43 @@ +trigger: none + +schedules: +- cron: "0 13 * * *" # 1PM UTC => 5 AM PST + displayName: HttpStress nightly run + branches: + include: + - master + +pool: + name: Hosted Ubuntu 1604 + +variables: + - template: ../variables.yml + - name: httpStressProject + value: $(sourcesRoot)/System.Net.Http/tests/StressTests/HttpStress/ + - name: sslStressProject + value: $(sourcesRoot)/System.Net.Security/tests/StressTests/SslStress/ + - name: sdkBaseImage + value: sdk-corefx-current + - name: sslStressImage + value: sslstress + +steps: +- checkout: self + clean: true + fetchDepth: 0 + lfs: false + +- bash: | + docker build -t $(sdkBaseImage) --build-arg CONFIGURATION=$(BUILD_CONFIGURATION) --build-arg BUILD_SCRIPT_NAME=$(buildScriptFileName) -f $(HttpStressProject)corefx.Dockerfile . + displayName: Build Corefx + +- bash: | + docker build -t $(sslStressImage) --build-arg SDK_BASE_IMAGE=$(sdkBaseImage) --build-arg CONFIGURATION=$(BUILD_CONFIGURATION) -f $(sslStressProject)/Dockerfile src/libraries + displayName: Build HttpStress + +- bash: | + cd '$(HttpStressProject)' + docker-compose up --abort-on-container-exit --no-color + displayName: Run HttpStress + env: + HTTPSTRESS_IMAGE: $(httpStressImage) diff --git a/eng/pipelines/libraries/stress/ssl-windows.yml b/eng/pipelines/libraries/stress/ssl-windows.yml new file mode 100644 index 00000000000000..cb857b44b6580b --- /dev/null +++ b/eng/pipelines/libraries/stress/ssl-windows.yml @@ -0,0 +1,45 @@ +trigger: none + +schedules: +- cron: "0 13 * * *" # 1PM UTC => 5 AM PST + displayName: SslStress nightly run + branches: + include: + - master + +pool: + name: Hosted VS2017 + +variables: + - template: ../variables.yml + - name: httpStressProject + value: $(sourcesRoot)/System.Net.Http/tests/StressTests/HttpStress/ + - name: sslStressProject + value: $(sourcesRoot)/System.Net.Security/tests/StressTests/SslStress/ + +steps: +- checkout: self + clean: true + fetchDepth: 0 + lfs: false + +- powershell: | + .\$(buildScriptFileName).cmd -ci -c $(BUILD_CONFIGURATION) + displayName: Build Corefx + +- powershell: | + # Load testhost sdk in environment + . '$(httpStressProject)\load-corefx-testhost.ps1' -c $(BUILD_CONFIGURATION) -b + # Run the stress suite + cd '$(sslStressProject)' + dotnet run -c $(BUILD_CONFIGURATION) -- $(SSLSTRESS_ARGS) + displayName: Run HttpStress + +- task: PublishBuildArtifacts@1 + displayName: Publish Logs + inputs: + PathtoPublish: '$(Build.SourcesDirectory)/artifacts/log/$(BUILD_CONFIGURATION)' + PublishLocation: Container + ArtifactName: 'httpstress_$(Agent.Os)_$(Agent.JobName)' + continueOnError: true + condition: always() diff --git a/src/libraries/Common/tests/System/IO/Compression/CRC.cs b/src/libraries/Common/tests/System/IO/Compression/CRC.cs index f4d3003c3f4af0..1e753685b06608 100644 --- a/src/libraries/Common/tests/System/IO/Compression/CRC.cs +++ b/src/libraries/Common/tests/System/IO/Compression/CRC.cs @@ -2,6 +2,8 @@ // 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; + public class CRC { // Table of CRCs of all 8-bit messages. @@ -32,27 +34,23 @@ private static void make_crc_table() s_crc_table_computed = true; } - // Update a running CRC with the bytes buf[0..len-1]--the CRC + // Update a running CRC with the bytes --the CRC // should be initialized to all 1's, and the transmitted value // is the 1's complement of the final running CRC (see the // crc() routine below)). - private static ulong update_crc(ulong crc, byte[] buf, int len) + public static ulong UpdateCRC(ulong crc, ReadOnlySpan buf) { ulong c = crc; int n; if (!s_crc_table_computed) make_crc_table(); - for (n = 0; n < len; n++) + for (n = 0; n < buf.Length; n++) { c = s_crc_table[(c ^ buf[n]) & 0xff] ^ (c >> 8); } return c; } - internal static string CalculateCRC(byte[] buf) => CalculateCRC(buf, buf.Length); - - // Return the CRC of the bytes buf[0..len-1]. - internal static string CalculateCRC(byte[] buf, int len) => - (update_crc(0xffffffffL, buf, len) ^ 0xffffffffL).ToString(); + public static ulong CalculateCRC(ReadOnlySpan buf) => (UpdateCRC(0xffffffffL, buf) ^ 0xffffffffL); } diff --git a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs index a0b254739072b8..3a8828ae451f63 100644 --- a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs +++ b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs @@ -174,7 +174,7 @@ public static void IsZipSameAsDir(Stream archiveFile, string directory, ZipArchi entrystream.Read(buffer, 0, buffer.Length); #if NETCOREAPP uint zipcrc = entry.Crc32; - Assert.Equal(CRC.CalculateCRC(buffer), zipcrc.ToString()); + Assert.Equal(CRC.CalculateCRC(buffer), zipcrc); #endif if (file.Length != givenLength) @@ -183,8 +183,8 @@ public static void IsZipSameAsDir(Stream archiveFile, string directory, ZipArchi } Assert.Equal(file.Length, buffer.Length); - string crc = CRC.CalculateCRC(buffer); - Assert.Equal(file.CRC, crc); + ulong crc = CRC.CalculateCRC(buffer); + Assert.Equal(file.CRC, crc.ToString()); } if (checkTimes) diff --git a/src/libraries/System.Net.Http/tests/StressTests/HttpStress/CRCHelpers.cs b/src/libraries/System.Net.Http/tests/StressTests/HttpStress/CRCHelpers.cs new file mode 100644 index 00000000000000..5c4c574703ca03 --- /dev/null +++ b/src/libraries/System.Net.Http/tests/StressTests/HttpStress/CRCHelpers.cs @@ -0,0 +1,39 @@ +// 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.Collections.Generic; +using System.Text; + +namespace HttpStress +{ + public static class CRCHelpers + { + public const ulong InitialCrc = 0xffffffffL; + + public static ulong UpdateCrC(ulong crc, string text, Encoding? encoding = null) + { + encoding = encoding ?? Encoding.ASCII; + byte[] bytes = encoding.GetBytes(text); + return CRC.UpdateCRC(crc, bytes); + } + + public static ulong CalculateCRC(string text, Encoding? encoding = null) => UpdateCrC(InitialCrc, text, encoding) ^ InitialCrc; + + public static ulong CalculateHeaderCrc(IEnumerable<(string name, T)> headers, Encoding? encoding = null) where T : IEnumerable + { + ulong checksum = InitialCrc; + + foreach ((string name, IEnumerable values) in headers) + { + checksum = UpdateCrC(checksum, name); + foreach (string value in values) + { + checksum = UpdateCrC(checksum, value); + } + } + + return checksum ^ InitialCrc; + } + } +} diff --git a/src/libraries/System.Net.Http/tests/StressTests/HttpStress/ChecksumHelpers.cs b/src/libraries/System.Net.Http/tests/StressTests/HttpStress/ChecksumHelpers.cs deleted file mode 100644 index aecf288d2e939d..00000000000000 --- a/src/libraries/System.Net.Http/tests/StressTests/HttpStress/ChecksumHelpers.cs +++ /dev/null @@ -1,86 +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. - -using System.Collections.Generic; -using System.Text; - -namespace HttpStress -{ - // Adapted from https://github.com/dotnet/corefx/blob/41cd99d051102be4ed83f4f9105ae9e73aa48b7c/src/Common/tests/System/IO/Compression/CRC.cs - public static class CRC - { - // Table of CRCs of all 8-bit messages. - private static ulong[] s_crc_table = new ulong[256]; - public const ulong InitialCrc = 0xffffffffL; - - // Flag: has the table been computed? Initially false. - private static bool s_crc_table_computed = false; - - // Make the table for a fast CRC. - // Derivative work of zlib -- https://github.com/madler/zlib/blob/master/crc32.c (hint: L108) - private static void make_crc_table() - { - ulong c; - int n, k; - - for (n = 0; n < 256; n++) - { - c = (ulong)n; - for (k = 0; k < 8; k++) - { - if ((c & 1) > 0) - c = 0xedb88320L ^ (c >> 1); - else - c = c >> 1; - } - s_crc_table[n] = c; - } - s_crc_table_computed = true; - } - - // Update a running CRC with the bytes buf[0..len-1]--the CRC - // should be initialized to all 1's, and the transmitted value - // is the 1's complement of the final running CRC (see the - // crc() routine below)). - public static ulong update_crc(ulong crc, byte[] buf, int len) - { - ulong c = crc; - int n; - - if (!s_crc_table_computed) - make_crc_table(); - for (n = 0; n < len; n++) - { - c = s_crc_table[(c ^ buf[n]) & 0xff] ^ (c >> 8); - } - return c; - } - - public static ulong update_crc(ulong crc, string text, Encoding? encoding = null) - { - encoding = encoding ?? Encoding.ASCII; - byte[] bytes = encoding.GetBytes(text); - return update_crc(crc, bytes, bytes.Length); - } - - public static ulong CalculateCRC(byte[] buf) => update_crc(InitialCrc, buf, buf.Length) ^ InitialCrc; - public static ulong CalculateCRC(string text, Encoding? encoding = null) => update_crc(InitialCrc, text, encoding) ^ InitialCrc; - - public static ulong CalculateHeaderCrc(IEnumerable<(string name, T)> headers, Encoding? encoding = null) where T : IEnumerable - { - ulong checksum = InitialCrc; - - foreach ((string name, IEnumerable values) in headers) - { - checksum = update_crc(checksum, name); - foreach (string value in values) - { - checksum = update_crc(checksum, value); - } - } - - return checksum ^ InitialCrc; - } - } -} diff --git a/src/libraries/System.Net.Http/tests/StressTests/HttpStress/ClientOperations.cs b/src/libraries/System.Net.Http/tests/StressTests/HttpStress/ClientOperations.cs index 61fdd902f74088..43f179146a7a71 100644 --- a/src/libraries/System.Net.Http/tests/StressTests/HttpStress/ClientOperations.cs +++ b/src/libraries/System.Net.Http/tests/StressTests/HttpStress/ClientOperations.cs @@ -213,7 +213,7 @@ public static (string name, Func operation)[] Operations = { using var req = new HttpRequestMessage(HttpMethod.Get, "/headers"); ctx.PopulateWithRandomHeaders(req.Headers); - ulong expectedChecksum = CRC.CalculateHeaderCrc(req.Headers.Select(x => (x.Key, x.Value))); + ulong expectedChecksum = CRCHelpers.CalculateHeaderCrc(req.Headers.Select(x => (x.Key, x.Value))); using HttpResponseMessage res = await ctx.SendAsync(req); @@ -323,7 +323,7 @@ public static (string name, Func operation)[] Operations = async ctx => { string content = ctx.GetRandomString(0, ctx.MaxContentLength); - ulong checksum = CRC.CalculateCRC(content); + ulong checksum = CRCHelpers.CalculateCRC(content); using var req = new HttpRequestMessage(HttpMethod.Post, "/") { Content = new StringDuplexContent(content) }; using HttpResponseMessage m = await ctx.SendAsync(req); @@ -337,7 +337,7 @@ public static (string name, Func operation)[] Operations = async ctx => { (string expected, MultipartContent formDataContent) formData = GetMultipartContent(ctx, ctx.MaxRequestParameters); - ulong checksum = CRC.CalculateCRC(formData.expected); + ulong checksum = CRCHelpers.CalculateCRC(formData.expected); using var req = new HttpRequestMessage(HttpMethod.Post, "/") { Content = formData.formDataContent }; using HttpResponseMessage m = await ctx.SendAsync(req); @@ -351,7 +351,7 @@ public static (string name, Func operation)[] Operations = async ctx => { string content = ctx.GetRandomString(0, ctx.MaxContentLength); - ulong checksum = CRC.CalculateCRC(content); + ulong checksum = CRCHelpers.CalculateCRC(content); using var req = new HttpRequestMessage(HttpMethod.Post, "/duplex") { Content = new StringDuplexContent(content) }; using HttpResponseMessage m = await ctx.SendAsync(req, HttpCompletionOption.ResponseHeadersRead); @@ -405,7 +405,7 @@ public static (string name, Func operation)[] Operations = async ctx => { string content = ctx.GetRandomString(0, ctx.MaxContentLength); - ulong checksum = CRC.CalculateCRC(content); + ulong checksum = CRCHelpers.CalculateCRC(content); using var req = new HttpRequestMessage(HttpMethod.Post, "/") { Content = new StringContent(content) }; diff --git a/src/libraries/System.Net.Http/tests/StressTests/HttpStress/Dockerfile b/src/libraries/System.Net.Http/tests/StressTests/HttpStress/Dockerfile index 34cd87999c7646..0ce733b85b667f 100644 --- a/src/libraries/System.Net.Http/tests/StressTests/HttpStress/Dockerfile +++ b/src/libraries/System.Net.Http/tests/StressTests/HttpStress/Dockerfile @@ -3,6 +3,7 @@ FROM $SDK_BASE_IMAGE WORKDIR /app COPY . . +WORKDIR /app/System.Net.Http/tests/StressTests/HttpStress ARG CONFIGURATION=Release RUN dotnet build -c $CONFIGURATION diff --git a/src/libraries/System.Net.Http/tests/StressTests/HttpStress/HttpStress.csproj b/src/libraries/System.Net.Http/tests/StressTests/HttpStress/HttpStress.csproj index 40a4a32118ed3b..1bd6af3537ae96 100644 --- a/src/libraries/System.Net.Http/tests/StressTests/HttpStress/HttpStress.csproj +++ b/src/libraries/System.Net.Http/tests/StressTests/HttpStress/HttpStress.csproj @@ -7,6 +7,10 @@ enable + + + + diff --git a/src/libraries/System.Net.Http/tests/StressTests/HttpStress/StressServer.cs b/src/libraries/System.Net.Http/tests/StressTests/HttpStress/StressServer.cs index a722b44029c0c3..19e1d33a96e9b5 100644 --- a/src/libraries/System.Net.Http/tests/StressTests/HttpStress/StressServer.cs +++ b/src/libraries/System.Net.Http/tests/StressTests/HttpStress/StressServer.cs @@ -182,7 +182,7 @@ private static void MapRoutes(IEndpointRouteBuilder endpoints) } // send back a checksum of all the echoed headers - ulong checksum = CRC.CalculateHeaderCrc(headersToEcho); + ulong checksum = CRCHelpers.CalculateHeaderCrc(headersToEcho); AppendChecksumHeader(context.Response.Headers, checksum); await context.Response.WriteAsync("ok"); @@ -235,14 +235,14 @@ private static void MapRoutes(IEndpointRouteBuilder endpoints) ArrayPool bufferPool = ArrayPool.Shared; byte[] buffer = bufferPool.Rent(512); - ulong hashAcc = CRC.InitialCrc; + ulong hashAcc = CRCHelpers.InitialCrc; int read; try { while ((read = await context.Request.Body.ReadAsync(buffer)) != 0) { - hashAcc = CRC.update_crc(hashAcc, buffer, read); + hashAcc = CRC.UpdateCRC(hashAcc, new ReadOnlySpan(buffer, 0, read)); await context.Response.Body.WriteAsync(buffer, 0, read); } } @@ -251,7 +251,7 @@ private static void MapRoutes(IEndpointRouteBuilder endpoints) bufferPool.Return(buffer); } - hashAcc = CRC.InitialCrc ^ hashAcc; + hashAcc = CRCHelpers.InitialCrc ^ hashAcc; if (context.Response.SupportsTrailers()) { @@ -262,14 +262,14 @@ private static void MapRoutes(IEndpointRouteBuilder endpoints) { // Echos back the requested content in a full duplex manner, but one byte at a time. var buffer = new byte[1]; - ulong hashAcc = CRC.InitialCrc; + ulong hashAcc = CRCHelpers.InitialCrc; while ((await context.Request.Body.ReadAsync(buffer)) != 0) { - hashAcc = CRC.update_crc(hashAcc, buffer, buffer.Length); + hashAcc = CRC.UpdateCRC(hashAcc, buffer); await context.Response.Body.WriteAsync(buffer); } - hashAcc = CRC.InitialCrc ^ hashAcc; + hashAcc = CRCHelpers.InitialCrc ^ hashAcc; if (context.Response.SupportsTrailers()) { diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/Configuration.cs b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Configuration.cs new file mode 100644 index 00000000000000..c2a8bc1a69b822 --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Configuration.cs @@ -0,0 +1,26 @@ +// 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; +using System.Net; + +namespace SslStress +{ + [Flags] + public enum RunMode { server = 1, client = 2, both = server | client }; + + public class Configuration + { + public IPEndPoint ServerEndpoint { get; set; } = new IPEndPoint(IPAddress.Loopback, 0); + public RunMode RunMode { get; set; } + public int RandomSeed { get; set; } + public int MaxConnections { get; set; } + public int MaxBufferLength { get; set; } + public TimeSpan? MaxExecutionTime { get; set; } + public TimeSpan DisplayInterval { get; set; } + public TimeSpan MinConnectionLifetime { get; set; } + public TimeSpan MaxConnectionLifetime { get; set; } + public bool LogServer { get; set; } + } +} diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/Directory.Build.props b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Directory.Build.props new file mode 100644 index 00000000000000..8998bf4546770d --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Directory.Build.props @@ -0,0 +1 @@ + diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/Directory.Build.targets b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Directory.Build.targets new file mode 100644 index 00000000000000..8998bf4546770d --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Directory.Build.targets @@ -0,0 +1 @@ + diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/Dockerfile b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Dockerfile new file mode 100644 index 00000000000000..682c1707e4de44 --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Dockerfile @@ -0,0 +1,15 @@ +ARG SDK_BASE_IMAGE=mcr.microsoft.com/dotnet/core/sdk:3.0.100-buster +FROM $SDK_BASE_IMAGE + +WORKDIR /app +COPY . . +WORKDIR /app/System.Net.Security/tests/StressTests/SslStress + +ARG CONFIGURATION=Release +RUN dotnet build -c $CONFIGURATION + +EXPOSE 5001 + +ENV CONFIGURATION=$CONFIGURATION +ENV SSLSTRESS_ARGS='' +CMD dotnet run --no-build -c $CONFIGURATION -- $SSLSTRESS_ARGS diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/Program.cs b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Program.cs new file mode 100644 index 00000000000000..95e1c592775e0f --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Program.cs @@ -0,0 +1,189 @@ +// 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; +using System.CommandLine; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Net; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using SslStress.Utils; + +namespace SslStress +{ + public static class Program + { + public enum ExitCode { Success = 0, StressError = 1, CliError = 2 }; + + public static async Task Main(string[] args) + { + if (!TryParseCli(args, out Configuration? config)) + { + return (int)ExitCode.CliError; + } + + return (int)await Run(config); + } + + private static async Task Run(Configuration config) + { + if ((config.RunMode & RunMode.both) == 0) + { + Console.Error.WriteLine("Must specify a valid run mode"); + return ExitCode.CliError; + } + + static string GetAssemblyInfo(Assembly assembly) => $"{assembly.Location}, modified {new FileInfo(assembly.Location).LastWriteTime}"; + + Console.WriteLine(" .NET Core: " + GetAssemblyInfo(typeof(object).Assembly)); + Console.WriteLine(" System.Net.Security: " + GetAssemblyInfo(typeof(System.Net.Security.SslStream).Assembly)); + Console.WriteLine(" Server Endpoint: " + config.ServerEndpoint); + Console.WriteLine(" Concurrency: " + config.MaxConnections); + Console.WriteLine(" Max Execution Time: " + ((config.MaxExecutionTime != null) ? config.MaxExecutionTime.Value.ToString() : "infinite")); + Console.WriteLine(" Min Conn. Lifetime: " + config.MinConnectionLifetime); + Console.WriteLine(" Max Conn. Lifetime: " + config.MaxConnectionLifetime); + Console.WriteLine(" Random Seed: " + config.RandomSeed); + Console.WriteLine(); + + StressServer? server = null; + if (config.RunMode.HasFlag(RunMode.server)) + { + // Start the SSL web server in-proc. + Console.WriteLine($"Starting SSL server."); + server = new StressServer(config); + server.Start(); + + Console.WriteLine($"Server listening to {server.ServerEndpoint}"); + } + + StressClient? client = null; + if (config.RunMode.HasFlag(RunMode.client)) + { + // Start the client. + Console.WriteLine($"Starting {config.MaxConnections} client workers."); + Console.WriteLine(); + + client = new StressClient(config); + client.Start(); + } + + await WaitUntilMaxExecutionTimeElapsedOrKeyboardInterrupt(config.MaxExecutionTime); + + try + { + if (client != null) await client.StopAsync(); + if (server != null) await server.StopAsync(); + } + finally + { + client?.PrintFinalReport(); + } + + return client?.TotalErrorCount == 0 ? ExitCode.Success : ExitCode.StressError; + + static async Task WaitUntilMaxExecutionTimeElapsedOrKeyboardInterrupt(TimeSpan? maxExecutionTime = null) + { + var tcs = new TaskCompletionSource(); + Console.CancelKeyPress += (sender, args) => { Console.Error.WriteLine("Keyboard interrupt"); args.Cancel = true; tcs.TrySetResult(false); }; + if (maxExecutionTime.HasValue) + { + Console.WriteLine($"Running for a total of {maxExecutionTime.Value.TotalMinutes:0.##} minutes"); + var cts = new System.Threading.CancellationTokenSource(delay: maxExecutionTime.Value); + cts.Token.Register(() => { Console.WriteLine("Max execution time elapsed"); tcs.TrySetResult(false); }); + } + + await tcs.Task; + } + } + + private static bool TryParseCli(string[] args, [NotNullWhen(true)] out Configuration? config) + { + var cmd = new RootCommand(); + cmd.AddOption(new Option(new[] { "--help", "-h" }, "Display this help text.")); + cmd.AddOption(new Option(new[] { "--mode", "-m" }, "Stress suite execution mode. Defaults to 'both'.") { Argument = new Argument("runMode", RunMode.both) }); + cmd.AddOption(new Option(new[] { "--num-connections", "-n" }, "Max number of connections to open concurrently.") { Argument = new Argument("connections", Environment.ProcessorCount) }); + cmd.AddOption(new Option(new[] { "--server-endpoint", "-e" }, "Endpoint to bind to if server, endpoint to listen to if client.") { Argument = new Argument("ipEndpoint", "127.0.0.1:5002") }); + cmd.AddOption(new Option(new[] { "--max-execution-time", "-t" }, "Maximum stress suite execution time, in minutes. Defaults to infinity.") { Argument = new Argument("minutes", null) }); + cmd.AddOption(new Option(new[] { "--max-buffer-length", "-b" }, "Maximum buffer length to write on ssl stream. Defaults to 8192.") { Argument = new Argument("bytes", 8192) }); + cmd.AddOption(new Option(new[] { "--min-connection-lifetime", "-l" }, "Minimum duration for a single connection, in seconds. Defaults to 5 seconds.") { Argument = new Argument("minutes", 5) }); + cmd.AddOption(new Option(new[] { "--max-connection-lifetime", "-L" }, "Maximum duration for a single connection, in seconds. Defaults to 120 seconds.") { Argument = new Argument("minutes", 120) }); + cmd.AddOption(new Option(new[] { "--display-interval", "-i" }, "Client stats display interval, in seconds. Defaults to 5 seconds.") { Argument = new Argument("seconds", 5) }); + cmd.AddOption(new Option(new[] { "--log-server", "-S" }, "Print server logs to stdout.")); + cmd.AddOption(new Option(new[] { "--seed", "-s" }, "Seed for generating pseudo-random parameters. Also depends on the -n argument.") { Argument = new Argument("seed", (new Random().Next())) }); + + ParseResult parseResult = cmd.Parse(args); + if (parseResult.Errors.Count > 0 || parseResult.HasOption("-h")) + { + foreach (ParseError error in parseResult.Errors) + { + Console.WriteLine(error); + } + WriteHelpText(); + config = null; + return false; + } + + config = new Configuration() + { + RunMode = parseResult.ValueForOption("-m"), + MaxConnections = parseResult.ValueForOption("-n"), + ServerEndpoint = ParseEndpoint(parseResult.ValueForOption("-e")), + MaxExecutionTime = parseResult.ValueForOption("-t")?.Pipe(TimeSpan.FromMinutes), + MaxBufferLength = parseResult.ValueForOption("-b"), + MinConnectionLifetime = TimeSpan.FromSeconds(parseResult.ValueForOption("-l")), + MaxConnectionLifetime = TimeSpan.FromSeconds(parseResult.ValueForOption("-L")), + DisplayInterval = TimeSpan.FromSeconds(parseResult.ValueForOption("-i")), + LogServer = parseResult.HasOption("-S"), + RandomSeed = parseResult.ValueForOption("-s"), + }; + + if (config.MaxConnectionLifetime < config.MinConnectionLifetime) + { + Console.WriteLine("Max connection lifetime should be greater than or equal to min connection lifetime"); + WriteHelpText(); + config = null; + return false; + } + + return true; + + void WriteHelpText() + { + Console.WriteLine(); + new HelpBuilder(new SystemConsole()).Write(cmd); + } + + static IPEndPoint ParseEndpoint(string value) + { + try + { + return IPEndPoint.Parse(value); + } + catch (FormatException) + { + // support hostname:port endpoints + Match match = Regex.Match(value, "^([^:]+):([0-9]+)$"); + if (match.Success) + { + string hostname = match.Groups[1].Value; + int port = int.Parse(match.Groups[2].Value); + switch(hostname) + { + case "+": + case "*": + return new IPEndPoint(IPAddress.Any, port); + default: + IPAddress[] addresses = Dns.GetHostAddresses(hostname); + return new IPEndPoint(addresses[0], port); + } + } + + throw; + } + } + } + } +} diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/Readme.md b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Readme.md new file mode 100644 index 00000000000000..8193c18fb7da7b --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Readme.md @@ -0,0 +1,3 @@ +## SslStress + +Stress testing suite for SslStream diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/SslClientBase.StressResultAggregator.cs b/src/libraries/System.Net.Security/tests/StressTests/SslStress/SslClientBase.StressResultAggregator.cs new file mode 100644 index 00000000000000..5fa43257a460e4 --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/SslClientBase.StressResultAggregator.cs @@ -0,0 +1,179 @@ +// 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; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using SslStress.Utils; + +namespace SslStress +{ + public abstract partial class SslClientBase + { + private class StressResultAggregator + { + private long _totalConnections = 0; + private readonly long[] _successes, _failures; + private readonly ErrorAggregator _errors = new ErrorAggregator(); + private readonly StreamCounter[] _currentCounters; + private readonly StreamCounter[] _aggregateCounters; + + public StressResultAggregator(int workerCount) + { + _currentCounters = Enumerable.Range(0, workerCount).Select(_ => new StreamCounter()).ToArray(); + _aggregateCounters = Enumerable.Range(0, workerCount).Select(_ => new StreamCounter()).ToArray(); + _successes = new long[workerCount]; + _failures = new long[workerCount]; + } + + public long TotalConnections => _totalConnections; + public long TotalFailures => _failures.Sum(); + + public StreamCounter GetCounters(int workerId) => _currentCounters[workerId]; + + public void RecordSuccess(int workerId) + { + _successes[workerId]++; + Interlocked.Increment(ref _totalConnections); + UpdateCounters(workerId); + } + + public void RecordFailure(int workerId, Exception exn) + { + _failures[workerId]++; + Interlocked.Increment(ref _totalConnections); + _errors.RecordError(exn); + UpdateCounters(workerId); + + lock (Console.Out) + { + Console.ForegroundColor = ConsoleColor.DarkRed; + Console.WriteLine($"Worker #{workerId}: unhandled exception: {exn}"); + Console.WriteLine(); + Console.ResetColor(); + } + } + + private void UpdateCounters(int workerId) + { + // need to synchronize with GetCounterView to avoid reporting bad data + lock (_aggregateCounters) + { + _aggregateCounters[workerId].Append(_currentCounters[workerId]); + _currentCounters[workerId].Reset(); + } + } + + private (StreamCounter total, StreamCounter current)[] GetCounterView() + { + // generate a coherent view of counter state + lock (_aggregateCounters) + { + var view = new (StreamCounter total, StreamCounter current)[_aggregateCounters.Length]; + for (int i = 0; i < _aggregateCounters.Length; i++) + { + StreamCounter current = _currentCounters[i].Clone(); + StreamCounter total = _aggregateCounters[i].Clone().Append(current); + view[i] = (total, current); + } + + return view; + } + } + + public void PrintFailureTypes() => _errors.PrintFailureTypes(); + + public void PrintCurrentResults(TimeSpan elapsed, bool showAggregatesOnly) + { + (StreamCounter total, StreamCounter current)[] counters = GetCounterView(); + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write($"[{DateTime.Now}]"); + Console.ResetColor(); + Console.WriteLine(" Elapsed: " + elapsed.ToString(@"hh\:mm\:ss")); + Console.ResetColor(); + + for (int i = 0; i < _currentCounters.Length; i++) + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write($"\tWorker #{i.ToString("N0")}:"); + Console.ResetColor(); + + Console.ForegroundColor = ConsoleColor.Green; + Console.Write($"\tPass: "); + Console.ResetColor(); + Console.Write(_successes[i].ToString("N0")); + Console.ForegroundColor = ConsoleColor.DarkRed; + Console.Write("\tFail: "); + Console.ResetColor(); + Console.Write(_failures[i].ToString("N0")); + + if (!showAggregatesOnly) + { + Console.ForegroundColor = ConsoleColor.DarkBlue; + Console.Write($"\tTx: "); + Console.ResetColor(); + Console.Write(FmtBytes(counters[i].current.BytesWritten)); + Console.ForegroundColor = ConsoleColor.DarkMagenta; + Console.Write($"\tRx: "); + Console.ResetColor(); + Console.Write(FmtBytes(counters[i].current.BytesRead)); + } + + Console.ForegroundColor = ConsoleColor.DarkBlue; + Console.Write($"\tTotal Tx: "); + Console.ResetColor(); + Console.Write(FmtBytes(counters[i].total.BytesWritten)); + Console.ForegroundColor = ConsoleColor.DarkMagenta; + Console.Write($"\tTotal Rx: "); + Console.ResetColor(); + Console.Write(FmtBytes(counters[i].total.BytesRead)); + + Console.WriteLine(); + } + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write("\tTOTAL : "); + + Console.ForegroundColor = ConsoleColor.Green; + Console.Write($"\tPass: "); + Console.ResetColor(); + Console.Write(_successes.Sum().ToString("N0")); + Console.ForegroundColor = ConsoleColor.DarkRed; + Console.Write("\tFail: "); + Console.ResetColor(); + Console.Write(_failures.Sum().ToString("N0")); + + if (!showAggregatesOnly) + { + Console.ForegroundColor = ConsoleColor.DarkBlue; + Console.Write("\tCurr. Tx: "); + Console.ResetColor(); + Console.Write(FmtBytes(counters.Select(c => c.current.BytesWritten).Sum())); + Console.ForegroundColor = ConsoleColor.DarkMagenta; + Console.Write($"\tCurr. Rx: "); + Console.ResetColor(); + Console.Write(FmtBytes(counters.Select(c => c.current.BytesRead).Sum())); + } + + Console.ForegroundColor = ConsoleColor.DarkBlue; + Console.Write("\tTotal Tx: "); + Console.ResetColor(); + Console.Write(FmtBytes(counters.Select(c => c.total.BytesWritten).Sum())); + Console.ForegroundColor = ConsoleColor.DarkMagenta; + Console.Write($"\tTotal Rx: "); + Console.ResetColor(); + Console.Write(FmtBytes(counters.Select(c => c.total.BytesRead).Sum())); + + Console.WriteLine(); + Console.WriteLine(); + + static string FmtBytes(long value) => HumanReadableByteSizeFormatter.Format(value); + } + } + } +} diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/SslClientBase.cs b/src/libraries/System.Net.Security/tests/StressTests/SslStress/SslClientBase.cs new file mode 100644 index 00000000000000..3fe5ddfc8cbf36 --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/SslClientBase.cs @@ -0,0 +1,151 @@ +// 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; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Security; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using SslStress.Utils; + +namespace SslStress +{ + + public abstract partial class SslClientBase : IAsyncDisposable + { + protected readonly Configuration _config; + + private readonly CancellationTokenSource _cts = new CancellationTokenSource(); + private readonly StressResultAggregator _aggregator; + private readonly Lazy _clientTask; + private readonly Stopwatch _stopwatch = new Stopwatch(); + + public SslClientBase(Configuration config) + { + if (config.MaxConnections < 1) throw new ArgumentOutOfRangeException(nameof(config.MaxConnections)); + + _config = config; + _aggregator = new StressResultAggregator(config.MaxConnections); + _clientTask = new Lazy(StartCore); + } + + protected abstract Task HandleConnection(int workerId, SslStream stream, TcpClient client, Random random, CancellationToken token); + + protected virtual async Task EstablishSslStream(Stream networkStream, Random random, CancellationToken token) + { + var sslStream = new SslStream(networkStream, leaveInnerStreamOpen: false); + var clientOptions = new SslClientAuthenticationOptions + { + ApplicationProtocols = new List { SslApplicationProtocol.Http11 }, + RemoteCertificateValidationCallback = ((x, y, z, w) => true), + TargetHost = SslServerBase.Hostname, + }; + + await sslStream.AuthenticateAsClientAsync(clientOptions, token); + return sslStream; + } + + public ValueTask DisposeAsync() => StopAsync(); + + public void Start() + { + if (_cts.IsCancellationRequested) throw new ObjectDisposedException(nameof(SslClientBase)); + _ = _clientTask.Value; + } + + public async ValueTask StopAsync() + { + _cts.Cancel(); + await _clientTask.Value; + } + + public Task Task + { + get + { + if (!_clientTask.IsValueCreated) throw new InvalidOperationException("Client has not been started yet"); + return _clientTask.Value; + } + } + + public long TotalErrorCount => _aggregator.TotalFailures; + + private Task StartCore() + { + _stopwatch.Start(); + + // Spin up a thread dedicated to outputting stats for each defined interval + new Thread(() => + { + while (!_cts.IsCancellationRequested) + { + Thread.Sleep(_config.DisplayInterval); + lock (Console.Out) { _aggregator.PrintCurrentResults(_stopwatch.Elapsed, showAggregatesOnly: false); } + } + }) + { IsBackground = true }.Start(); + + IEnumerable workers = CreateWorkerSeeds().Select(x => RunSingleWorker(x.workerId, x.random)); + return Task.WhenAll(workers); + + async Task RunSingleWorker(int workerId, Random random) + { + StreamCounter counter = _aggregator.GetCounters(workerId); + + for (long testId = 0; !_cts.IsCancellationRequested; testId++) + { + TimeSpan duration = _config.MinConnectionLifetime + random.NextDouble() * (_config.MaxConnectionLifetime - _config.MinConnectionLifetime); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token); + cts.CancelAfter(duration); + + try + { + using var client = new TcpClient(); + await client.ConnectAsync(_config.ServerEndpoint.Address, _config.ServerEndpoint.Port); + var stream = new CountingStream(client.GetStream(), counter); + using SslStream sslStream = await EstablishSslStream(stream, random, cts.Token); + await HandleConnection(workerId, sslStream, client, random, cts.Token); + + _aggregator.RecordSuccess(workerId); + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + _aggregator.RecordSuccess(workerId); + } + catch (Exception e) + { + _aggregator.RecordFailure(workerId, e); + } + } + } + + IEnumerable<(int workerId, Random random)> CreateWorkerSeeds() + { + // deterministically generate random instance for each individual worker + Random random = new Random(_config.RandomSeed); + for (int workerId = 0; workerId < _config.MaxConnections; workerId++) + { + yield return (workerId, random.NextRandom()); + } + } + } + + public void PrintFinalReport() + { + lock (Console.Out) + { + Console.ForegroundColor = ConsoleColor.Magenta; + Console.WriteLine("SslStress Run Final Report"); + Console.WriteLine(); + + _aggregator.PrintCurrentResults(_stopwatch.Elapsed, showAggregatesOnly: true); + _aggregator.PrintFailureTypes(); + } + } + } +} diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/SslServerBase.cs b/src/libraries/System.Net.Security/tests/StressTests/SslStress/SslServerBase.cs new file mode 100644 index 00000000000000..bd4dee8e046037 --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/SslServerBase.cs @@ -0,0 +1,157 @@ +// 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; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Net.Security; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Threading; +using System.Security.Cryptography.X509Certificates; +using System.Security.Cryptography; +using System.Runtime.InteropServices; + +namespace SslStress +{ + public abstract class SslServerBase : IAsyncDisposable + { + public const string Hostname = "contoso.com"; + + protected readonly Configuration _config; + + private readonly X509Certificate2 _certificate; + private readonly TcpListener _listener; + private readonly CancellationTokenSource _cts = new CancellationTokenSource(); + private readonly Lazy _serverTask; + + public EndPoint ServerEndpoint => _listener.LocalEndpoint; + + public SslServerBase(Configuration config) + { + if (config.MaxConnections < 1) throw new ArgumentOutOfRangeException(nameof(config.MaxConnections)); + + _config = config; + _certificate = CreateSelfSignedCertificate(); + _listener = new TcpListener(config.ServerEndpoint) { ExclusiveAddressUse = (config.MaxConnections == 1) }; + _serverTask = new Lazy(StartCore); + } + + protected abstract Task HandleConnection(SslStream sslStream, TcpClient client, CancellationToken token); + + protected virtual async Task EstablishSslStream(Stream networkStream, CancellationToken token) + { + var sslStream = new SslStream(networkStream, leaveInnerStreamOpen: false); + var serverOptions = new SslServerAuthenticationOptions + { + ApplicationProtocols = new List { SslApplicationProtocol.Http11, SslApplicationProtocol.Http2 }, + ServerCertificate = _certificate, + }; + + await sslStream.AuthenticateAsServerAsync(serverOptions, token); + return sslStream; + } + + public void Start() + { + if (_cts.IsCancellationRequested) throw new ObjectDisposedException(nameof(SslServerBase)); + _ = _serverTask.Value; + } + + public async ValueTask StopAsync() + { + _cts.Cancel(); + await _serverTask.Value; + } + + public Task Task + { + get + { + if (!_serverTask.IsValueCreated) throw new InvalidOperationException("Server has not been started yet"); + return _serverTask.Value; + } + } + + public ValueTask DisposeAsync() => StopAsync(); + + private async Task StartCore() + { + _listener.Start(); + IEnumerable workers = Enumerable.Range(1, _config.MaxConnections).Select(_ => RunSingleWorker()); + try + { + await Task.WhenAll(workers); + } + finally + { + _listener.Stop(); + } + + async Task RunSingleWorker() + { + while(!_cts.IsCancellationRequested) + { + try + { + using TcpClient client = await AcceptTcpClientAsync(_cts.Token); + using SslStream stream = await EstablishSslStream(client.GetStream(), _cts.Token); + await HandleConnection(stream, client, _cts.Token); + } + catch (OperationCanceledException) when (_cts.IsCancellationRequested) + { + + } + catch (Exception e) + { + if (_config.LogServer) + { + lock (Console.Out) + { + Console.ForegroundColor = ConsoleColor.DarkRed; + Console.WriteLine($"Server: unhandled exception: {e}"); + Console.WriteLine(); + Console.ResetColor(); + } + } + } + } + } + + async Task AcceptTcpClientAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + if (_listener.Pending()) + { + return await _listener.AcceptTcpClientAsync(); + } + + await Task.Delay(20); + } + + token.ThrowIfCancellationRequested(); + throw new Exception("internal error"); + } + } + + protected virtual X509Certificate2 CreateSelfSignedCertificate() + { + using var rsa = RSA.Create(); + var certReq = new CertificateRequest($"CN={Hostname}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + certReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); + certReq.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false)); + certReq.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false)); + X509Certificate2 cert = certReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddMonths(-1), DateTimeOffset.UtcNow.AddMonths(1)); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + cert = new X509Certificate2(cert.Export(X509ContentType.Pfx)); + } + + return cert; + } + } +} diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/SslStress.csproj b/src/libraries/System.Net.Security/tests/StressTests/SslStress/SslStress.csproj new file mode 100644 index 00000000000000..cfd23be4fc6ab7 --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/SslStress.csproj @@ -0,0 +1,14 @@ + + + Exe + netcoreapp3.0 + enable + + + + + + + + + diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/SslStress.sln b/src/libraries/System.Net.Security/tests/StressTests/SslStress/SslStress.sln new file mode 100644 index 00000000000000..ee4b67b7614b23 --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/SslStress.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.27428.2002 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SslStress", "SslStress.csproj", "{802E12E4-7E4C-493D-B767-A69223AE7FB2}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {802E12E4-7E4C-493D-B767-A69223AE7FB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {802E12E4-7E4C-493D-B767-A69223AE7FB2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {802E12E4-7E4C-493D-B767-A69223AE7FB2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {802E12E4-7E4C-493D-B767-A69223AE7FB2}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {264DF521-D85C-41B0-B753-AB4C5C7505FB} + EndGlobalSection +EndGlobal diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/StressOperations.cs b/src/libraries/System.Net.Security/tests/StressTests/SslStress/StressOperations.cs new file mode 100644 index 00000000000000..860abd3bf9a348 --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/StressOperations.cs @@ -0,0 +1,305 @@ +// 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. + +// SslStream stress scenario +// +// * Client sends sequences of random data, accompanied with length and checksum information. +// * Server echoes back the same data. Both client and server validate integrity of received data. +// * Data is written using randomized combinations of the SslStream.Write* methods. +// * Data is ingested using System.IO.Pipelines. + +using System; +using System.Buffers; +using System.IO; +using System.Linq; +using System.Net.Security; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using SslStress.Utils; + +namespace SslStress +{ + public struct DataSegment + { + private readonly byte[] _buffer; + + public DataSegment(int length) + { + _buffer = ArrayPool.Shared.Rent(length); + Length = length; + } + + public int Length { get; } + public Memory AsMemory() => new Memory(_buffer, 0, Length); + public Span AsSpan() => new Span(_buffer, 0, Length); + + public ulong Checksum => CRC.CalculateCRC(AsSpan()); + public void Return() => ArrayPool.Shared.Return(_buffer); + + /// Create and populate a segment with random data + public static DataSegment CreateRandom(Random random, int maxLength) + { + int size = random.Next(0, maxLength); + var chunk = new DataSegment(size); + foreach (ref byte b in chunk.AsSpan()) + { + b = s_bytePool[random.Next(255)]; + } + + return chunk; + } + + private static readonly byte[] s_bytePool = + Enumerable + .Range(0, 256) + .Select(i => (byte)i) + .Where(b => b != (byte)'\n') + .ToArray(); + } + + public class DataMismatchException : Exception + { + public DataMismatchException(string message) : base(message) { } + } + + // Serializes data segment using the following format: ,, + public class DataSegmentSerializer + { + private static readonly Encoding s_encoding = Encoding.ASCII; + + private readonly byte[] _buffer = new byte[32]; + private readonly char[] _charBuffer = new char[32]; + + public async Task SerializeAsync(Stream stream, DataSegment segment, Random? random = null, CancellationToken token = default) + { + // length + int numsize = s_encoding.GetBytes(segment.Length.ToString(), _buffer); + await stream.WriteAsync(_buffer.AsMemory().Slice(0, numsize), token); + stream.WriteByte((byte)','); + // checksum + numsize = s_encoding.GetBytes(segment.Checksum.ToString(), _buffer); + await stream.WriteAsync(_buffer.AsMemory().Slice(0, numsize), token); + stream.WriteByte((byte)','); + // payload + Memory source = segment.AsMemory(); + // write the entire segment outright if not given random instance + if (random == null) + { + await stream.WriteAsync(source, token); + return; + } + // randomize chunking otherwise + while (source.Length > 0) + { + if (random.NextBoolean(probability: 0.05)) + { + stream.WriteByte(source.Span[0]); + source = source.Slice(1); + } + else + { + // TODO consider non-uniform distribution for chunk sizes + int chunkSize = random.Next(source.Length); + Memory chunk = source.Slice(0, chunkSize); + source = source.Slice(chunkSize); + + if (random.NextBoolean(probability: 0.9)) + { + await stream.WriteAsync(chunk, token); + } + else + { + stream.Write(chunk.Span); + } + } + + if (random.NextBoolean(probability: 0.3)) + { + await stream.FlushAsync(token); + } + } + } + + public DataSegment Deserialize(ReadOnlySequence buffer) + { + // length + SequencePosition? pos = buffer.PositionOf((byte)','); + if (pos == null) + { + throw new DataMismatchException("should contain comma-separated values"); + } + + ReadOnlySequence lengthBytes = buffer.Slice(0, pos.Value); + int numSize = s_encoding.GetChars(lengthBytes.ToArray(), _charBuffer); + int length = int.Parse(_charBuffer.AsSpan().Slice(0, numSize)); + buffer = buffer.Slice(buffer.GetPosition(1, pos.Value)); + + // checksum + pos = buffer.PositionOf((byte)','); + if (pos == null) + { + throw new DataMismatchException("should contain comma-separated values"); + } + + ReadOnlySequence checksumBytes = buffer.Slice(0, pos.Value); + numSize = s_encoding.GetChars(checksumBytes.ToArray(), _charBuffer); + ulong checksum = ulong.Parse(_charBuffer.AsSpan().Slice(0, numSize)); + buffer = buffer.Slice(buffer.GetPosition(1, pos.Value)); + + // payload + if (length != (int)buffer.Length) + { + throw new DataMismatchException("declared length does not match payload length"); + } + + var chunk = new DataSegment((int)buffer.Length); + buffer.CopyTo(chunk.AsSpan()); + + if (checksum != chunk.Checksum) + { + chunk.Return(); + throw new DataMismatchException("declared checksum doesn't match payload checksum"); + } + + return chunk; + } + } + + // Client implementation: + // + // Sends randomly generated data segments and validates data echoed back by the server. + // Applies backpressure if the difference between sent and received segments is too large. + public sealed class StressClient : SslClientBase + { + public StressClient(Configuration config) : base(config) { } + + protected override async Task HandleConnection(int workerId, SslStream stream, TcpClient client, Random random, CancellationToken token) + { + long messagesInFlight = 0; + + await Utils.TaskExtensions.WhenAllThrowOnFirstException(token, Sender, Receiver); + + async Task Sender(CancellationToken taskToken) + { + var serializer = new DataSegmentSerializer(); + + try + { + while (!taskToken.IsCancellationRequested) + { + await ApplyBackpressure(taskToken); + + DataSegment chunk = DataSegment.CreateRandom(random, _config.MaxBufferLength); + try + { + await serializer.SerializeAsync(stream, chunk, random, token); + stream.WriteByte((byte)'\n'); + await stream.FlushAsync(token); + Interlocked.Increment(ref messagesInFlight); + } + finally + { + chunk.Return(); + } + } + } + finally + { + // write an empty line to signal completion to the server + stream.WriteByte((byte)'\n'); + stream.WriteByte((byte)'\n'); + await stream.FlushAsync(); + await Task.Delay(1000); + } + } + + async Task Receiver(CancellationToken token) + { + var serializer = new DataSegmentSerializer(); + await stream.ReadLinesUsingPipesAsync(Callback, token, separator: '\n'); + + Task Callback(ReadOnlySequence buffer) + { + // deserialize to validate the checksum, then discard + DataSegment chunk = serializer.Deserialize(buffer); + chunk.Return(); + Interlocked.Decrement(ref messagesInFlight); + return Task.CompletedTask; + } + } + + async Task ApplyBackpressure(CancellationToken token) + { + if (Volatile.Read(ref messagesInFlight) > 5000) + { + lock (Console.Out) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"worker #{workerId}: applying backpressure"); + Console.WriteLine(); + Console.ResetColor(); + } + + while (!token.IsCancellationRequested && Volatile.Read(ref messagesInFlight) > 2000) + { + await Task.Delay(20); + } + } + } + } + } + + // Server implementation: + // + // Sets up a pipeline reader which validates checksums and echoes back data. + public sealed class StressServer : SslServerBase + { + public StressServer(Configuration config) : base(config) { } + + protected override async Task HandleConnection(SslStream sslStream, TcpClient client, CancellationToken token) + { + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(token); + + var serializer = new DataSegmentSerializer(); + await sslStream.ReadLinesUsingPipesAsync(Callback, cts.Token, separator: '\n'); + + async Task Callback(ReadOnlySequence buffer) + { + // got an empty line, client is closing the connection + if (buffer.Length == 0) + { + cts.Cancel(); + return; + } + + DataSegment? chunk = null; + try + { + chunk = serializer.Deserialize(buffer); + await serializer.SerializeAsync(sslStream, chunk.Value, token: token); + sslStream.WriteByte((byte)'\n'); + await sslStream.FlushAsync(token); + } + catch (DataMismatchException e) + { + if (_config.LogServer) + { + lock (Console.Out) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"Server: {e.Message}"); + Console.ResetColor(); + } + } + } + finally + { + chunk?.Return(); + } + } + } + } +} diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/CountingStream.cs b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/CountingStream.cs new file mode 100644 index 00000000000000..80f9d305225848 --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/CountingStream.cs @@ -0,0 +1,90 @@ +// 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; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace SslStress.Utils +{ + public class StreamCounter + { + public long BytesWritten = 0L; + public long BytesRead = 0L; + + public void Reset() + { + BytesWritten = 0L; + BytesRead = 0L; + } + + public StreamCounter Append(StreamCounter that) + { + BytesRead += that.BytesRead; + BytesWritten += that.BytesWritten; + return this; + } + + public StreamCounter Clone() => new StreamCounter() { BytesRead = BytesRead, BytesWritten = BytesWritten }; + } + + public class CountingStream : Stream + { + private readonly Stream _stream; + private readonly StreamCounter _counter; + + public CountingStream(Stream stream, StreamCounter counters) + { + _stream = stream; + _counter = counters; + } + + public override void Write(byte[] buffer, int offset, int count) + { + _stream.Write(buffer, offset, count); + Interlocked.Add(ref _counter.BytesWritten, count); + } + + public override int Read(byte[] buffer, int offset, int count) + { + int read = _stream.Read(buffer, offset, count); + Interlocked.Add(ref _counter.BytesRead, read); + return read; + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + await _stream.WriteAsync(buffer, cancellationToken); + Interlocked.Add(ref _counter.BytesWritten, buffer.Length); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + int read = await _stream.ReadAsync(buffer, cancellationToken); + Interlocked.Add(ref _counter.BytesRead, read); + return read; + } + + // route everything else to the inner stream + + public override bool CanRead => _stream.CanRead; + + public override bool CanSeek => _stream.CanSeek; + + public override bool CanWrite => _stream.CanWrite; + + public override long Length => _stream.Length; + + public override long Position { get => _stream.Position; set => _stream.Position = value; } + + public override void Flush() => _stream.Flush(); + + public override long Seek(long offset, SeekOrigin origin) => _stream.Seek(offset, origin); + + public override void SetLength(long value) => _stream.SetLength(value); + + public override void Close() => _stream.Close(); + } +} diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/ErrorAggregator.cs b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/ErrorAggregator.cs new file mode 100644 index 00000000000000..7f83c4b0462656 --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/ErrorAggregator.cs @@ -0,0 +1,120 @@ +// 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; +using System.Linq; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; + +namespace SslStress.Utils +{ + public interface IErrorType + { + string ErrorMessage { get; } + + IReadOnlyCollection<(DateTime timestamp, string? metadata)> Occurrences { get; } + } + + public sealed class ErrorAggregator + { + private readonly ConcurrentDictionary<(Type exception, string message, string callSite)[], ErrorType> _failureTypes; + + public ErrorAggregator() + { + _failureTypes = new ConcurrentDictionary<(Type, string, string)[], ErrorType>(new StructuralEqualityComparer<(Type, string, string)[]>()); + } + + public int TotalErrorTypes => _failureTypes.Count; + public IReadOnlyCollection ErrorTypes => ErrorTypes.ToArray(); + public long TotalErrorCount => _failureTypes.Values.Select(c => (long)c.Occurrences.Count).Sum(); + + public void RecordError(Exception exception, string? metadata = null, DateTime? timestamp = null) + { + timestamp ??= DateTime.Now; + + (Type, string, string)[] key = ClassifyFailure(exception); + + ErrorType failureType = _failureTypes.GetOrAdd(key, _ => new ErrorType(exception.ToString())); + failureType.OccurencesQueue.Enqueue((timestamp.Value, metadata)); + + // classify exception according to type, message and callsite of itself and any inner exceptions + static (Type exception, string message, string callSite)[] ClassifyFailure(Exception exn) + { + var acc = new List<(Type exception, string message, string callSite)>(); + + for (Exception? e = exn; e != null;) + { + acc.Add((e.GetType(), e.Message ?? "", new StackTrace(e, true).GetFrame(0)?.ToString() ?? "")); + e = e.InnerException; + } + + return acc.ToArray(); + } + } + + public void PrintFailureTypes() + { + if (_failureTypes.Count == 0) + return; + + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"There were a total of {TotalErrorCount} failures classified into {TotalErrorTypes} different types:"); + Console.WriteLine(); + Console.ResetColor(); + + int i = 0; + foreach (ErrorType failure in _failureTypes.Values.OrderByDescending(x => x.Occurrences.Count)) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"Failure Type {++i}/{_failureTypes.Count}:"); + Console.ResetColor(); + Console.WriteLine(failure.ErrorMessage); + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Yellow; + foreach (IGrouping grouping in failure.Occurrences.GroupBy(o => o.metadata)) + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write($"\t{(grouping.Key ?? "").PadRight(30)}"); + Console.ResetColor(); + Console.ForegroundColor = ConsoleColor.Red; + Console.Write("Fail: "); + Console.ResetColor(); + Console.Write(grouping.Count()); + Console.WriteLine($"\tTimestamps: {string.Join(", ", grouping.Select(x => x.timestamp.ToString("HH:mm:ss")))}"); + } + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write("\t TOTAL".PadRight(31)); + Console.ResetColor(); + Console.ForegroundColor = ConsoleColor.Red; + Console.Write($"Fail: "); + Console.ResetColor(); + Console.WriteLine(TotalErrorTypes); + Console.WriteLine(); + } + } + + /// Aggregate view of a particular stress failure type + private sealed class ErrorType : IErrorType + { + public string ErrorMessage { get; } + public ConcurrentQueue<(DateTime, string?)> OccurencesQueue = new ConcurrentQueue<(DateTime, string?)>(); + + public ErrorType(string errorText) + { + ErrorMessage = errorText; + } + + public IReadOnlyCollection<(DateTime timestamp, string? metadata)> Occurrences => OccurencesQueue; + } + + private class StructuralEqualityComparer : IEqualityComparer where T : IStructuralEquatable + { + public bool Equals(T left, T right) => left.Equals(right, StructuralComparisons.StructuralEqualityComparer); + public int GetHashCode(T value) => value.GetHashCode(StructuralComparisons.StructuralEqualityComparer); + } + } +} diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/HumanReadableByteSizeFormatter.cs b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/HumanReadableByteSizeFormatter.cs new file mode 100644 index 00000000000000..864f335361abc3 --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/HumanReadableByteSizeFormatter.cs @@ -0,0 +1,26 @@ +// 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; + +namespace SslStress.Utils +{ + public static class HumanReadableByteSizeFormatter + { + private static readonly string[] s_suffixes = { "B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB" }; + + public static string Format(long byteCount) + { + // adapted from https://stackoverflow.com/a/4975942 + if (byteCount == 0) + { + return $"0{s_suffixes[0]}"; + } + + int position = (int)Math.Floor(Math.Log(Math.Abs(byteCount), 1024)); + double renderedValue = byteCount / Math.Pow(1024, position); + return $"{renderedValue:0.#}{s_suffixes[position]}"; + } + } +} diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/MiscHelpers.cs b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/MiscHelpers.cs new file mode 100644 index 00000000000000..9ec11ce6073f2e --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/MiscHelpers.cs @@ -0,0 +1,11 @@ +using System; + +namespace SslStress.Utils +{ + public static class MiscHelpers + { + // help transform `(foo != null) ? Bar(foo) : null` expressions into `foo?.Select(Bar)` + public static S Pipe(this T value, Func mapper) => mapper(value); + public static void Pipe(this T value, Action body) => body(value); + } +} diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/PipeExtensions.cs b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/PipeExtensions.cs new file mode 100644 index 00000000000000..48cfb88e88e762 --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/PipeExtensions.cs @@ -0,0 +1,66 @@ +// 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; +using System.Buffers; +using System.IO; +using System.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; + +namespace SslStress.Utils +{ + public static class PipeExtensions + { + // Adapted from https://devblogs.microsoft.com/dotnet/system-io-pipelines-high-performance-io-in-net/ + public static async Task ReadLinesUsingPipesAsync(this Stream stream, Func, Task> callback, CancellationToken token = default, char separator = '\n') + { + var pipe = new Pipe(); + + try + { + await TaskExtensions.WhenAllThrowOnFirstException(token, FillPipeAsync, ReadPipeAsync); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + + } + + async Task FillPipeAsync(CancellationToken token) + { + await stream.CopyToAsync(pipe.Writer, token); + pipe.Writer.Complete(); + } + + async Task ReadPipeAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + ReadResult result = await pipe.Reader.ReadAsync(token); + ReadOnlySequence buffer = result.Buffer; + SequencePosition? position; + + do + { + position = buffer.PositionOf((byte)separator); + + if (position != null) + { + await callback(buffer.Slice(0, position.Value)); + buffer = buffer.Slice(buffer.GetPosition(1, position.Value)); + } + } + while (position != null); + + pipe.Reader.AdvanceTo(buffer.Start, buffer.End); + + if (result.IsCompleted) + { + break; + } + } + } + } + } +} diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/RandomHelpers.cs b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/RandomHelpers.cs new file mode 100644 index 00000000000000..ba686a9635f899 --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/RandomHelpers.cs @@ -0,0 +1,21 @@ +// 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; + +namespace SslStress.Utils +{ + public static class RandomHelpers + { + public static Random NextRandom(this Random random) => new Random(Seed: random.Next()); + + public static bool NextBoolean(this Random random, double probability = 0.5) + { + if (probability < 0 || probability > 1) + throw new ArgumentOutOfRangeException(nameof(probability)); + + return random.NextDouble() < probability; + } + } +} diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/TaskExtensions.cs b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/TaskExtensions.cs new file mode 100644 index 00000000000000..5aa25423d6a979 --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/TaskExtensions.cs @@ -0,0 +1,52 @@ +// 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; +using System.Linq; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; + +namespace SslStress.Utils +{ + public static class TaskExtensions + { + + /// + /// Starts and awaits a collection of cancellable tasks. + /// Will surface the first exception that has occured (instead of AggregateException) + /// and trigger cancellation for all sibling tasks. + /// + /// + /// + /// + public static async Task WhenAllThrowOnFirstException(CancellationToken token, params Func[] tasks) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(token); + Exception? firstException = null; + + await Task.WhenAll(tasks.Select(RunOne)); + + if (firstException != null) + { + ExceptionDispatchInfo.Capture(firstException).Throw(); + } + + async Task RunOne(Func task) + { + try + { + await Task.Run(() => task(cts.Token)); + } + catch (Exception e) + { + if (Interlocked.CompareExchange(ref firstException, e, null) == null) + { + cts.Cancel(); + } + } + } + } + } +} diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/docker-compose.yml b/src/libraries/System.Net.Security/tests/StressTests/SslStress/docker-compose.yml new file mode 100644 index 00000000000000..3f00e34d7d02f9 --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3' +services: + client: + image: ${SSLSTRESS_IMAGE:-sslstress} + links: + - server + environment: + - SSLSTRESS_ARGS=--mode client --server-endpoint server:5001 ${SSLSTRESS_CLIENT_ARGS} + server: + image: ${SSLSTRESS_IMAGE:-sslstress} + ports: + - "5001:5001" + environment: + - SSLSTRESS_ARGS=--mode server --server-endpoint 0.0.0.0:5001 ${SSLTRESS_SERVER_ARGS} diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/global.json b/src/libraries/System.Net.Security/tests/StressTests/SslStress/global.json new file mode 100644 index 00000000000000..0db3279e44b0dc --- /dev/null +++ b/src/libraries/System.Net.Security/tests/StressTests/SslStress/global.json @@ -0,0 +1,3 @@ +{ + +}