Initial SslStream stress app work (#290)
authorEirik Tsarpalis <eirik.tsarpalis@gmail.com>
Fri, 6 Dec 2019 22:48:36 +0000 (22:48 +0000)
committerGitHub <noreply@github.com>
Fri, 6 Dec 2019 22:48:36 +0000 (22:48 +0000)
27 files changed:
eng/pipelines/libraries/stress/http-windows.yml
eng/pipelines/libraries/stress/ssl-linux.yml [new file with mode: 0644]
eng/pipelines/libraries/stress/ssl-windows.yml [new file with mode: 0644]
src/libraries/Common/tests/System/IO/Compression/CRC.cs
src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs
src/libraries/System.Net.Security/tests/StressTests/SslStress/Configuration.cs [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/Directory.Build.props [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/Directory.Build.targets [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/Dockerfile [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/Program.cs [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/Readme.md [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/SslClientBase.StressResultAggregator.cs [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/SslClientBase.cs [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/SslServerBase.cs [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/SslStress.csproj [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/SslStress.sln [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/StressOperations.cs [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/CountingStream.cs [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/ErrorAggregator.cs [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/HumanReadableByteSizeFormatter.cs [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/MiscHelpers.cs [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/PipeExtensions.cs [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/RandomHelpers.cs [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/StressTaskExtensions.cs [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/docker-compose.yml [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/global.json [new file with mode: 0644]
src/libraries/System.Net.Security/tests/StressTests/SslStress/windows.Dockerfile [new file with mode: 0644]

index 83936e8..41a5513 100644 (file)
@@ -45,7 +45,7 @@ steps:
     docker build -t $(httpStressImage) --build-arg SDK_BASE_IMAGE=$(sdkBaseImage) --build-arg CONFIGURATION=$(BUILD_CONFIGURATION) -f windows.Dockerfile .
   displayName: Build HttpStress
 
-- bash: |
+- powershell: |
     cd '$(HttpStressProject)'
     docker-compose up --abort-on-container-exit --no-color
   displayName: Run HttpStress
diff --git a/eng/pipelines/libraries/stress/ssl-linux.yml b/eng/pipelines/libraries/stress/ssl-linux.yml
new file mode 100644 (file)
index 0000000..a06e6fc
--- /dev/null
@@ -0,0 +1,51 @@
+trigger: none
+
+pr:
+  branches:
+    include:
+    - "*"
+
+schedules:
+- cron: "0 13 * * *" # 1PM UTC => 5 AM PST
+  displayName: SslStress nightly run
+  branches:
+    include:
+    - master
+
+pool:
+  name: Hosted Ubuntu 1604
+
+variables:
+  - template: ../variables.yml
+  - name: sslStressProject
+    value: $(sourcesRoot)/System.Net.Security/tests/StressTests/SslStress/
+    # Avoid duplication by referencing corefx build infrastructure hosted in HttpStress
+    # TODO move to eng/ folder.
+  - name: httpStressProject
+    value: $(sourcesRoot)/System.Net.Http/tests/StressTests/HttpStress/
+  - name: sdkBaseImage
+    value: sdk-corefx-current
+  - name: sslStressImage
+    value: sslstress
+
+steps:
+- checkout: self
+  clean: true
+  fetchDepth: 1
+  lfs: false
+
+- bash: |
+    docker build -t $(sdkBaseImage) --build-arg CONFIGURATION=$(BUILD_CONFIGURATION) --build-arg BUILD_SCRIPT_NAME=$(buildScriptFileName) -f $(httpStressProject)corefx.Dockerfile .
+  displayName: Build Libraries
+
+- bash: |
+    # we need to include the entire src/libraries folder in the build context due to Common/ dependencies
+    docker build -t $(sslStressImage) --build-arg SDK_BASE_IMAGE=$(sdkBaseImage) --build-arg CONFIGURATION=$(BUILD_CONFIGURATION) -f $(sslStressProject)/Dockerfile src/libraries
+  displayName: Build SslStress
+
+- bash: |
+    cd '$(sslStressProject)'
+    docker-compose up --abort-on-container-exit --no-color
+  displayName: Run SslStress
+  env:
+    HTTPSTRESS_IMAGE: $(sslStressImage)
diff --git a/eng/pipelines/libraries/stress/ssl-windows.yml b/eng/pipelines/libraries/stress/ssl-windows.yml
new file mode 100644 (file)
index 0000000..a97d99b
--- /dev/null
@@ -0,0 +1,66 @@
+trigger: none
+
+pr:
+  branches:
+    include:
+    - "*"
+
+schedules:
+- cron: "0 13 * * *" # 1PM UTC => 5 AM PST
+  displayName: SslStress nightly run
+  branches:
+    include:
+    - master
+
+pool:
+  vmImage: 'windows-latest'
+
+variables:
+  - template: ../variables.yml
+  - name: sslStressProject
+    value: $(sourcesRoot)/System.Net.Security/tests/StressTests/SslStress/
+    # Avoid duplication by referencing corefx build infrastructure hosted in HttpStress
+    # TODO move to eng/ folder.
+  - name: httpStressProject
+    value: $(sourcesRoot)/System.Net.Http/tests/StressTests/HttpStress/
+  - name: sdkBaseImage
+    value: sdk-corefx-current
+  - name: sslStressImage
+    value: sslstress
+
+steps:
+- checkout: self
+  clean: true
+  fetchDepth: 1
+  lfs: false
+
+- powershell: |
+    .\libraries.cmd -ci -c $(BUILD_CONFIGURATION)
+    docker build -t $(sdkBaseImage) `
+        --build-arg CONFIGURATION=$(BUILD_CONFIGURATION) `
+        --build-arg TESTHOST_LOCATION=. `
+        -f src/libraries/System.Net.Http/tests/StressTests/HttpStress/corefx.windows.Dockerfile `
+        artifacts/bin/testhost
+
+  displayName: Build Libraries
+
+- powershell: |
+    # we need to include the entire src/libraries folder in the build context due to Common/ dependencies
+    docker build -t $(sslStressImage) --build-arg SDK_BASE_IMAGE=$(sdkBaseImage) --build-arg CONFIGURATION=$(BUILD_CONFIGURATION) -f $(sslStressProject)/windows.Dockerfile src/libraries
+  displayName: Build SslStress
+
+- powershell: |
+    cd '$(sslStressProject)'
+    docker-compose up --abort-on-container-exit --no-color
+  displayName: Run SslStress
+  env:
+    SSLTRESS_IMAGE: $(sslStressImage)
+
+- task: PublishBuildArtifacts@1
+  displayName: Publish Logs
+  inputs:
+    PathtoPublish: '$(Build.SourcesDirectory)/artifacts/log/$(BUILD_CONFIGURATION)'
+    PublishLocation: Container
+    ArtifactName: 'sslstress_$(Agent.Os)_$(Agent.JobName)'
+  continueOnError: true
+  condition: always()
index f4d3003..1e75368 100644 (file)
@@ -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 @@ public class CRC
         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<byte> 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<byte> buf) => (UpdateCRC(0xffffffffL, buf) ^ 0xffffffffL);
 }
index a0b2547..3a8828a 100644 (file)
@@ -174,7 +174,7 @@ namespace System.IO.Compression.Tests
                             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 @@ namespace System.IO.Compression.Tests
                             }
 
                             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.Security/tests/StressTests/SslStress/Configuration.cs b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Configuration.cs
new file mode 100644 (file)
index 0000000..0a02261
--- /dev/null
@@ -0,0 +1,27 @@
+// 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 double CancellationProbability { 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 (file)
index 0000000..8998bf4
--- /dev/null
@@ -0,0 +1 @@
+<Project/>
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 (file)
index 0000000..8998bf4
--- /dev/null
@@ -0,0 +1 @@
+<Project/>
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 (file)
index 0000000..682c170
--- /dev/null
@@ -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 (file)
index 0000000..e126375
--- /dev/null
@@ -0,0 +1,201 @@
+// 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<int> Main(string[] args)
+        {
+            if (!TryParseCli(args, out Configuration? config))
+            {
+                return (int)ExitCode.CliError;
+            }
+
+            return (int)await Run(config);
+        }
+
+        private static async Task<ExitCode> 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("     Cancellation Pb: " + 100 * config.CancellationProbability + "%");
+            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();
+                    Console.WriteLine("client stopped");
+                }
+
+                if (server != null) 
+                {
+                    await server.StopAsync();
+                    Console.WriteLine("server stopped");
+                }
+            }
+            finally
+            {
+                client?.PrintFinalReport();
+            }
+
+            return client?.TotalErrorCount == 0 ? ExitCode.Success : ExitCode.StressError;
+
+            static async Task WaitUntilMaxExecutionTimeElapsedOrKeyboardInterrupt(TimeSpan? maxExecutionTime = null)
+            {
+                var tcs = new TaskCompletionSource<bool>();
+                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", RunMode.both) });
+            cmd.AddOption(new Option(new[] { "--cancellation-probability", "-p"}, "Cancellation probability 0 <= p <= 1 for a given connection. Defaults to 0.1") { Argument = new Argument<double>("probability", 0.1)});
+            cmd.AddOption(new Option(new[] { "--num-connections", "-n" }, "Max number of connections to open concurrently.") { Argument = new Argument<int>("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<string>("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<double?>("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<int>("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<double>("seconds", 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<double>("seconds", 120) });
+            cmd.AddOption(new Option(new[] { "--display-interval", "-i" }, "Client stats display interval, in seconds. Defaults to 5 seconds.") { Argument = new Argument<double>("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<int>("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<RunMode>("-m"),
+                MaxConnections = parseResult.ValueForOption<int>("-n"),
+                CancellationProbability = Math.Max(0, Math.Min(1, parseResult.ValueForOption<double>("-p"))),
+                ServerEndpoint = ParseEndpoint(parseResult.ValueForOption<string>("-e")),
+                MaxExecutionTime = parseResult.ValueForOption<double?>("-t")?.Pipe(TimeSpan.FromMinutes),
+                MaxBufferLength = parseResult.ValueForOption<int>("-b"),
+                MinConnectionLifetime = TimeSpan.FromSeconds(parseResult.ValueForOption<double>("-l")),
+                MaxConnectionLifetime = TimeSpan.FromSeconds(parseResult.ValueForOption<double>("-L")),
+                DisplayInterval = TimeSpan.FromSeconds(parseResult.ValueForOption<double>("-i")),
+                LogServer = parseResult.HasOption("-S"),
+                RandomSeed = parseResult.ValueForOption<int>("-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 (file)
index 0000000..8193c18
--- /dev/null
@@ -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 (file)
index 0000000..43b4deb
--- /dev/null
@@ -0,0 +1,195 @@
+// 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, _cancellations;
+            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];
+                _cancellations = 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 RecordCancellation(int workerId)
+            {
+                _cancellations[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.DarkYellow;
+                    Console.Write("\tCancel: ");
+                    Console.ResetColor();
+                    Console.Write(_cancellations[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($"\tCurr. Tx: ");
+                        Console.ResetColor();
+                        Console.Write(FmtBytes(counters[i].current.BytesWritten));
+                        Console.ForegroundColor = ConsoleColor.DarkMagenta;
+                        Console.Write($"\tCurr. Rx: ");
+                        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.DarkYellow;
+                Console.Write("\tCancel: ");
+                Console.ResetColor();
+                Console.Write(_cancellations.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 (file)
index 0000000..bddf366
--- /dev/null
@@ -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.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<Task> _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<Task>(Task.Run(StartCore));
+        }
+
+        protected abstract Task HandleConnection(int workerId, long jobId, SslStream stream, TcpClient client, Random random, TimeSpan duration, CancellationToken token);
+
+        protected virtual async Task<SslStream> EstablishSslStream(Stream networkStream, Random random, CancellationToken token)
+        {
+            var sslStream = new SslStream(networkStream, leaveInnerStreamOpen: false);
+            var clientOptions = new SslClientAuthenticationOptions
+            {
+                ApplicationProtocols = new List<SslApplicationProtocol> { 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<Task> 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 jobId = 0; !_cts.IsCancellationRequested; jobId++)
+                {
+                    TimeSpan connectionLifetime = _config.MinConnectionLifetime + random.NextDouble() * (_config.MaxConnectionLifetime - _config.MinConnectionLifetime);
+                    TimeSpan cancellationDelay =
+                        (random.NextBoolean(probability: _config.CancellationProbability)) ?
+                        connectionLifetime * random.NextDouble() : // cancel in a random interval within the lifetime
+                        connectionLifetime + TimeSpan.FromSeconds(10); // otherwise trigger cancellation 10 seconds after expected expiry
+
+                    using var cts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token);
+                    cts.CancelAfter(cancellationDelay);
+
+                    bool isTestCompleted = false;
+                    using var _ = cts.Token.Register(CheckForStalledConnection);
+
+                    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, jobId, sslStream, client, random, connectionLifetime, cts.Token);
+
+                        _aggregator.RecordSuccess(workerId);
+                    }
+                    catch (OperationCanceledException) when (cts.IsCancellationRequested)
+                    {
+                        _aggregator.RecordCancellation(workerId);
+                    }
+                    catch (Exception e)
+                    {
+                        _aggregator.RecordFailure(workerId, e);
+                    }
+                    finally
+                    {
+                        isTestCompleted = true;
+                    }
+
+                    async void CheckForStalledConnection()
+                    {
+                        await Task.Delay(10_000);
+                        if(!isTestCompleted)
+                        {
+                            lock (Console.Out)
+                            {
+                                Console.ForegroundColor = ConsoleColor.Red;
+                                Console.WriteLine($"Worker #{workerId} test #{jobId} has stalled, terminating the stress app.");
+                                Console.WriteLine();
+                                Console.ResetColor();
+                            }
+                            Environment.Exit(1);
+                        }
+                    }
+                }
+            }
+
+            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 (file)
index 0000000..b1632f2
--- /dev/null
@@ -0,0 +1,170 @@
+// 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 SemaphoreSlim _acceptConnectionLatch = new SemaphoreSlim(initialCount: 1);
+        private readonly Lazy<Task> _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);
+            _serverTask = new Lazy<Task>(Task.Run(StartCore));
+        }
+
+        protected abstract Task HandleConnection(SslStream sslStream, TcpClient client, CancellationToken token);
+
+        protected virtual async Task<SslStream> EstablishSslStream(Stream networkStream, CancellationToken token)
+        {
+            var sslStream = new SslStream(networkStream, leaveInnerStreamOpen: false);
+            var serverOptions = new SslServerAuthenticationOptions
+            {
+                ApplicationProtocols = new List<SslApplicationProtocol> { 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<Task> workers = Enumerable.Range(1, 2 * _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();
+                            }
+                        }
+                    }
+                }
+            }
+
+            // workaround for TcpListener not accepting cancellation tokens
+            async Task<TcpClient> AcceptTcpClientAsync(CancellationToken token)
+            {
+                while (!token.IsCancellationRequested)
+                {
+                    if (_listener.Pending())
+                    {
+                        // Need to ensure AcceptTcpClientAsync() returns immediately,
+                        // so we synchronize here to avoid races between workers
+                        await _acceptConnectionLatch.WaitAsync(token);
+                        try
+                        {
+                            if (_listener.Pending())
+                                return await _listener.AcceptTcpClientAsync();
+                        }
+                        finally
+                        {
+                            _acceptConnectionLatch.Release();
+                        }
+                    }
+
+                    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 (file)
index 0000000..cfd23be
--- /dev/null
@@ -0,0 +1,14 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>netcoreapp3.0</TargetFramework>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+  <ItemGroup>
+    <Compile Include="..\..\..\..\Common\tests\System\IO\Compression\CRC.cs" Link="Utils\CRC.cs" />
+  </ItemGroup>
+  <ItemGroup>
+    <PackageReference Include="System.CommandLine.Experimental" Version="0.3.0-alpha.19405.1" />
+    <PackageReference Include="System.IO.Pipelines" Version="4.6.0" />
+  </ItemGroup>
+</Project>
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 (file)
index 0000000..ee4b67b
--- /dev/null
@@ -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 (file)
index 0000000..394f3fd
--- /dev/null
@@ -0,0 +1,370 @@
+// 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.Diagnostics;
+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<byte>.Shared.Rent(length);
+            Length = length;
+        }
+
+        public int Length { get; }
+        public Memory<byte> AsMemory() => new Memory<byte>(_buffer, 0, Length);
+        public Span<byte> AsSpan() => new Span<byte>(_buffer, 0, Length);
+
+        public ulong Checksum => CRC.CalculateCRC(AsSpan());
+        public void Return() => ArrayPool<byte>.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: <length>,<checksum>,<data>
+    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<byte> 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<byte> 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<byte> buffer)
+        {
+            // length
+            SequencePosition? pos = buffer.PositionOf((byte)',');
+            if (pos == null)
+            {
+                throw new DataMismatchException("should contain comma-separated values");
+            }
+
+            ReadOnlySequence<byte> 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<byte> 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, long jobId, SslStream stream, TcpClient client, Random random, TimeSpan duration, CancellationToken token)
+        {
+            // token used for signalling cooperative cancellation; do not pass this to SslStream methods
+            using var connectionLifetimeToken = new CancellationTokenSource(duration);
+
+            long messagesInFlight = 0;
+            DateTime lastWrite = DateTime.Now;
+            DateTime lastRead = DateTime.Now;
+
+            await StressTaskExtensions.WhenAllThrowOnFirstException(token, Sender, Receiver, Monitor);
+
+            async Task Sender(CancellationToken token)
+            {
+                var serializer = new DataSegmentSerializer();
+
+                while (!token.IsCancellationRequested && !connectionLifetimeToken.IsCancellationRequested)
+                {
+                    await ApplyBackpressure();
+
+                    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);
+                        lastWrite = DateTime.Now;
+                    }
+                    finally
+                    {
+                        chunk.Return();
+                    }
+                }
+
+                // write an empty line to signal completion to the server
+                stream.WriteByte((byte)'\n');
+                await stream.FlushAsync(token);
+
+                /// Polls until number of in-flight messages falls below threshold
+                async Task ApplyBackpressure()
+                {
+                    if (Volatile.Read(ref messagesInFlight) > 5000)
+                    {
+                        Stopwatch stopwatch = Stopwatch.StartNew();
+                        bool isLogged = false;
+
+                        while (!token.IsCancellationRequested && !connectionLifetimeToken.IsCancellationRequested && Volatile.Read(ref messagesInFlight) > 2000)
+                        {
+                            // only log if tx has been suspended for a while
+                            if (!isLogged && stopwatch.ElapsedMilliseconds >= 1000)
+                            {
+                                isLogged = true;
+                                lock (Console.Out)
+                                {
+                                    Console.ForegroundColor = ConsoleColor.Yellow;
+                                    Console.WriteLine($"worker #{workerId}: applying backpressure");
+                                    Console.WriteLine();
+                                    Console.ResetColor();
+                                }
+                            }
+
+                            await Task.Delay(20);
+                        }
+
+                        if(isLogged)
+                        {
+                            Console.WriteLine($"worker #{workerId}: resumed tx after {stopwatch.Elapsed}");
+                        }
+                    }
+                }
+            }
+
+            async Task Receiver(CancellationToken token)
+            {
+                var serializer = new DataSegmentSerializer();
+                using var cts = CancellationTokenSource.CreateLinkedTokenSource(token);
+                await stream.ReadLinesUsingPipesAsync(Callback, cts.Token, separator: '\n');
+
+                Task Callback(ReadOnlySequence<byte> buffer)
+                {
+                    if (buffer.Length == 0 && connectionLifetimeToken.IsCancellationRequested)
+                    {
+                        // server echoed back empty buffer sent by client,
+                        // signal cancellation and complete the connection.
+                        cts.Cancel();
+                        return Task.CompletedTask;
+                    }
+
+                    // deserialize to validate the checksum, then discard
+                    DataSegment chunk = serializer.Deserialize(buffer);
+                    chunk.Return();
+                    Interlocked.Decrement(ref messagesInFlight);
+                    lastRead = DateTime.Now;
+                    return Task.CompletedTask;
+                }
+            }
+            
+            async Task Monitor(CancellationToken token)
+            {
+                do 
+                {
+                    await Task.Delay(500);
+
+                    if((DateTime.Now - lastWrite) >= TimeSpan.FromSeconds(10))
+                    {
+                        throw new Exception($"worker #{workerId} job #{jobId} has stopped writing bytes to server");
+                    }
+
+                    if((DateTime.Now - lastRead) >= TimeSpan.FromSeconds(10))
+                    {
+                        throw new Exception($"worker #{workerId} job #{jobId} has stopped receiving bytes from server");
+                    }
+                }
+                while(!token.IsCancellationRequested && !connectionLifetimeToken.IsCancellationRequested);
+            }
+        }
+    }
+
+    // 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);
+            DateTime lastReadTime = DateTime.Now;
+
+            var serializer = new DataSegmentSerializer();
+
+            _ = Task.Run(Monitor);
+            await sslStream.ReadLinesUsingPipesAsync(Callback, cts.Token, separator: '\n');
+
+            async Task Callback(ReadOnlySequence<byte> buffer)
+            {
+                lastReadTime = DateTime.Now;
+
+                if (buffer.Length == 0)
+                {
+                    // got an empty line, client is closing the connection
+                    // echo back the empty line and tear down.
+                    sslStream.WriteByte((byte)'\n');
+                    await sslStream.FlushAsync(token);
+                    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();
+                }
+            }
+
+            async Task Monitor()
+            {
+                do
+                {
+                    await Task.Delay(1000);
+
+                    if (DateTime.Now - lastReadTime >= TimeSpan.FromSeconds(10))
+                    {
+                        cts.Cancel();
+                    }
+
+                } while (!cts.IsCancellationRequested);
+            }
+        }
+    }
+}
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 (file)
index 0000000..9f80c5c
--- /dev/null
@@ -0,0 +1,96 @@
+// 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<byte> buffer, CancellationToken cancellationToken = default)
+        {
+            await _stream.WriteAsync(buffer, cancellationToken);
+            Interlocked.Add(ref _counter.BytesWritten, buffer.Length);
+        }
+
+        public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
+        {
+            int read = await _stream.ReadAsync(buffer, cancellationToken);
+            Interlocked.Add(ref _counter.BytesRead, read);
+            return read;
+        }
+
+        public override void WriteByte(byte value)
+        {
+            _stream.WriteByte(value);
+            Interlocked.Increment(ref _counter.BytesRead);
+        }
+
+        // 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 (file)
index 0000000..7f83c4b
--- /dev/null
@@ -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<IErrorType> 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<string?, (DateTime timestamp, string? metadata)> 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();
+            }
+        }
+
+        /// <summary>Aggregate view of a particular stress failure type</summary>
+        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<T> : IEqualityComparer<T> 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 (file)
index 0000000..864f335
--- /dev/null
@@ -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 (file)
index 0000000..9ec11ce
--- /dev/null
@@ -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<T, S>(this T value, Func<T, S> mapper) => mapper(value);
+        public static void Pipe<T>(this T value, Action<T> 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 (file)
index 0000000..00dda77
--- /dev/null
@@ -0,0 +1,75 @@
+// 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<ReadOnlySequence<byte>, Task> callback, CancellationToken token = default, char separator = '\n')
+        {
+            var pipe = new Pipe();
+
+            try
+            {
+                await StressTaskExtensions.WhenAllThrowOnFirstException(token, FillPipeAsync, ReadPipeAsync);
+            }
+            catch (OperationCanceledException) when (token.IsCancellationRequested)
+            {
+
+            }
+
+            async Task FillPipeAsync(CancellationToken token)
+            {
+                try 
+                {
+                    await stream.CopyToAsync(pipe.Writer, token);
+                }
+                catch (Exception e)
+                {
+                    pipe.Writer.Complete(e);
+                    throw;
+                }
+
+                pipe.Writer.Complete();
+            }
+
+            async Task ReadPipeAsync(CancellationToken token)
+            {
+                while (!token.IsCancellationRequested)
+                {
+                    ReadResult result = await pipe.Reader.ReadAsync(token);
+                    ReadOnlySequence<byte> 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 (file)
index 0000000..ba686a9
--- /dev/null
@@ -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/StressTaskExtensions.cs b/src/libraries/System.Net.Security/tests/StressTests/SslStress/Utils/StressTaskExtensions.cs
new file mode 100644 (file)
index 0000000..5bb1050
--- /dev/null
@@ -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 StressTaskExtensions
+    {
+
+        /// <summary>
+        /// 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.
+        /// </summary>
+        /// <param name="token"></param>
+        /// <param name="tasks"></param>
+        /// <returns></returns>
+        public static async Task WhenAllThrowOnFirstException(CancellationToken token, params Func<CancellationToken, Task>[] 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<CancellationToken, Task> 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 (file)
index 0000000..ddf9b8c
--- /dev/null
@@ -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 ${SSLSTRESS_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 (file)
index 0000000..0db3279
--- /dev/null
@@ -0,0 +1,3 @@
+{
+
+}
diff --git a/src/libraries/System.Net.Security/tests/StressTests/SslStress/windows.Dockerfile b/src/libraries/System.Net.Security/tests/StressTests/SslStress/windows.Dockerfile
new file mode 100644 (file)
index 0000000..7cf0808
--- /dev/null
@@ -0,0 +1,19 @@
+# escape=`
+ARG SDK_BASE_IMAGE=mcr.microsoft.com/dotnet/core/sdk:3.0.100-nanoserver-1809
+FROM $SDK_BASE_IMAGE
+
+# Use powershell as the default shell
+SHELL ["pwsh", "-Command"]
+
+WORKDIR /app
+COPY . .
+WORKDIR /app/System.Net.Security/tests/StressTests/SslStress 
+
+ARG CONFIGURATION=Release
+RUN dotnet build -c $env:CONFIGURATION
+
+EXPOSE 5001
+
+ENV CONFIGURATION=$CONFIGURATION
+ENV SSLSTRESS_ARGS=""
+CMD dotnet run --no-build -c $env:CONFIGURATION -- $env:SSLSTRESS_ARGS.Split()