From b1a179868ba8e7f0be517942592f8dc666a4b459 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 12 Nov 2021 10:53:46 -0800 Subject: [PATCH 01/25] implement contract tests --- .dockerignore | 1 + CONTRIBUTING.md | 5 + Makefile | 21 +++ contract-tests/Dockerfile | 15 ++ contract-tests/Properties/launchSettings.json | 16 ++ contract-tests/Representations.cs | 43 +++++ contract-tests/StreamEntity.cs | 163 ++++++++++++++++++ contract-tests/TestService.cs | 140 +++++++++++++++ contract-tests/TestService.csproj | 38 ++++ contract-tests/TestService.sln | 31 ++++ .../LaunchDarkly.EventSource.csproj | 3 +- 11 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 Makefile create mode 100644 contract-tests/Dockerfile create mode 100644 contract-tests/Properties/launchSettings.json create mode 100644 contract-tests/Representations.cs create mode 100644 contract-tests/StreamEntity.cs create mode 100644 contract-tests/TestService.cs create mode 100644 contract-tests/TestService.csproj create mode 100644 contract-tests/TestService.sln diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6b8710a --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.git diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 113d4a1..6c43316 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,4 +50,9 @@ Or, to run tests only for the .NET Standard 2.0 target (using the .NET Core 2.1 dotnet test test/LaunchDarkly.EventSource.Tests -f netcoreapp2.1 ``` +To run the standardized contract tests that are run against all LaunchDarkly SSE client implementations (this requires Docker): +``` +make contract-tests +``` + Note that the unit tests can only be run in Debug configuration. There is an `InternalsVisibleTo` directive that allows the test code to access internal members of the library, and assembly strong-naming in the Release configuration interferes with this. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e5440c4 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ + +build: + dotnet build + +clean: + dotnet clean + +test: + dotnet test + +DOCKER_IMAGE ?= "mcr.microsoft.com/dotnet/core/sdk:2.1-focal" +TESTFRAMEWORK ?= "netcoreapp2.1" + +contract-tests: + @echo "Building contract test service..." + @docker build --tag testservice -f contract-tests/Dockerfile \ + --build-arg DOCKER_IMAGE=$(DOCKER_IMAGE) --build-arg TESTFRAMEWORK=$(TESTFRAMEWORK) . + @docker run ldcircleci/sse-contract-tests:1 --output-docker-script 1 --url http://testservice:8000 \ + | bash + +.PHONY: build clean test contract-tests diff --git a/contract-tests/Dockerfile b/contract-tests/Dockerfile new file mode 100644 index 0000000..76566d2 --- /dev/null +++ b/contract-tests/Dockerfile @@ -0,0 +1,15 @@ +# Dockerfile for building and running the contract test service. +# This needs to be built from the parent directory rather than from + +ARG DOCKER_IMAGE +FROM $DOCKER_IMAGE + +COPY . . + +ENV BUILDFRAMEWORK=netstandard2.0 + +WORKDIR ./contract-tests + +RUN dotnet build + +ENTRYPOINT dotnet bin/Debug/$TESTFRAMEWORK/ContractTestService.dll diff --git a/contract-tests/Properties/launchSettings.json b/contract-tests/Properties/launchSettings.json new file mode 100644 index 0000000..9f8ef4c --- /dev/null +++ b/contract-tests/Properties/launchSettings.json @@ -0,0 +1,16 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true + }, + "profiles": { + "TestService": { + "commandName": "Project", + "launchBrowser": true, + "applicationUrl": "http://localhost:55015", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/contract-tests/Representations.cs b/contract-tests/Representations.cs new file mode 100644 index 0000000..d4d50c0 --- /dev/null +++ b/contract-tests/Representations.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace TestService +{ + public class Status + { + [JsonPropertyName("capabilities")] public string[] Capabilities { get; set; } + } + + public class StreamOptions + { + [JsonPropertyName("streamUrl")] public string StreamUrl { get; set; } + [JsonPropertyName("callbackUrl")] public string CallbackUrl { get; set; } + [JsonPropertyName("tag")] public string Tag { get; set; } + [JsonPropertyName("headers")] public Dictionary Headers { get; set; } + [JsonPropertyName("initialDelayMs")] public int? InitialDelayMs { get; set; } + [JsonPropertyName("readTimeoutMs")] public int? ReadTimeoutMs { get; set; } + [JsonPropertyName("lastEventId")] public string LastEventId { get; set; } + [JsonPropertyName("method")] public string Method { get; set; } + [JsonPropertyName("body")] public string Body { get; set; } + } + + public class Message + { + [JsonPropertyName("kind")] public string Kind { get; set; } + [JsonPropertyName("event")] public EventMessage Event { get; set; } + [JsonPropertyName("comment")] public string Comment { get; set; } + [JsonPropertyName("error")] public string Error { get; set; } + } + + public class EventMessage + { + [JsonPropertyName("type")] public string Type { get; set; } + [JsonPropertyName("data")] public string Data { get; set; } + [JsonPropertyName("id")] public string Id { get; set; } + } + + public class CommandParams + { + [JsonPropertyName("command")] public string Command { get; set; } + } +} diff --git a/contract-tests/StreamEntity.cs b/contract-tests/StreamEntity.cs new file mode 100644 index 0000000..fc90a42 --- /dev/null +++ b/contract-tests/StreamEntity.cs @@ -0,0 +1,163 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using LaunchDarkly.EventSource; +using LaunchDarkly.Logging; + +namespace TestService +{ + public class StreamEntity + { + private static HttpClient _httpClient = new HttpClient(); + + private readonly EventSource _stream; + private readonly StreamOptions _options; + private readonly Logger _log; + private volatile bool _closed; + + public StreamEntity( + StreamOptions options, + Logger log + ) + { + _options = options; + _log = log; + + var builder = Configuration.Builder(new Uri(options.StreamUrl)); + builder.Logger(log); + if (_options.Headers != null) + { + foreach (var kv in _options.Headers) + { + if (kv.Key.ToLower() != "content-type") + { + builder.RequestHeader(kv.Key, kv.Value); + } + } + } + if (_options.InitialDelayMs != null) + { + builder.InitialRetryDelay(TimeSpan.FromMilliseconds(_options.InitialDelayMs.Value)); + } + if (_options.LastEventId != null) + { + builder.LastEventId(_options.LastEventId); + } + if (_options.Method != null) + { + builder.Method(new System.Net.Http.HttpMethod(_options.Method)); + var contentType = "text/plain"; + if (_options.Headers != null) + { + foreach (var kv in _options.Headers) + { + if (kv.Key.ToLower() == "content-type") + { + contentType = kv.Value; + if (contentType.Contains(";")) + { + contentType = contentType.Substring(0, contentType.IndexOf(";")); + } + } + } + } + builder.RequestBody(_options.Body, contentType); + } + if (_options.ReadTimeoutMs != null) + { + builder.ReadTimeout(TimeSpan.FromMilliseconds(_options.ReadTimeoutMs.Value)); + } + + _log.Info("Opening stream from {0}", _options.StreamUrl); + + _stream = new EventSource(builder.Build()); + _stream.MessageReceived += (sender, args) => + { + _log.Info("Received event from stream ({0})", args.EventName); + Task.Run(() => SendMessage(new Message + { + Kind = "event", + Event = new EventMessage + { + Type = args.EventName, + Data = args.Message.Data, + Id = args.Message.LastEventId + } + })); + }; + _stream.CommentReceived += (sender, args) => + { + var comment = args.Comment; + if (comment.StartsWith(":")) + { + comment = comment.Substring(1); // this SSE client includes the colon in the comment + } + _log.Info("Received comment from stream: {0}", comment); + Task.Run(() => SendMessage(new Message + { + Kind = "comment", + Comment = comment + })); + }; + _stream.Error += (sender, args) => + { + var exDesc = LogValues.ExceptionSummary(args.Exception); + _log.Info("Received error from stream: {0}", exDesc); + Task.Run(() => SendMessage(new Message + { + Kind = "error", + Error = exDesc.ToString() + })); + }; + + Task.Run(() => _stream.StartAsync()); + } + + public void Close() + { + _closed = true; + _stream.Close(); + _log.Info("Test ended"); + } + + public bool DoCommand(string command) + { + if (command == "restart") + { + _stream.Restart(false); + return true; + } + return false; + } + + private async Task SendMessage(object message) + { + if (_closed) + { + return; + } + var json = JsonSerializer.Serialize(message); + using (var request = new HttpRequestMessage(HttpMethod.Post, new Uri(_options.CallbackUrl))) + using (var stringContent = new StringContent(json, Encoding.UTF8, "application/json")) + { + request.Content = stringContent; + try + { + using (var response = await _httpClient.SendAsync(request)) + { + if (!response.IsSuccessStatusCode) + { + _log.Error("Callback to {0} returned HTTP {1}", _options.CallbackUrl, response.StatusCode); + } + } + } + catch (Exception e) + { + _log.Error("Callback to {0} failed: {1}", _options.CallbackUrl, e.GetType()); + } + } + } + } +} diff --git a/contract-tests/TestService.cs b/contract-tests/TestService.cs new file mode 100644 index 0000000..a1d35c9 --- /dev/null +++ b/contract-tests/TestService.cs @@ -0,0 +1,140 @@ +using System.Collections.Concurrent; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.Logging; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace TestService +{ + public class Program + { + public static void Main(string[] args) + { + var host = new WebHostBuilder() + .UseKestrel() + .UseUrls("http://0.0.0.0:8000") + .UseStartup() + .Build(); + + host.Run(); + } + } + + public class Webapp + { + private readonly ILogAdapter _logging = Logs.ToConsole; + private readonly ConcurrentDictionary _streams = + new ConcurrentDictionary(); + private volatile int _lastStreamId = 0; + + public void ConfigureServices(IServiceCollection services) + { + services.AddRouting(); + } + + public void Configure(IApplicationBuilder app) + { + var routeBuilder = new RouteBuilder(app); + + routeBuilder.MapGet("", GetStatus); + routeBuilder.MapPost("", PostCreateStream); + routeBuilder.MapPost("/streams/{id}", PostStreamCommand); + routeBuilder.MapDelete("/streams/{id}", DeleteStream); + + var routes = routeBuilder.Build(); + app.UseRouter(routes); + } + + T ReadJson(HttpRequest req) + { + using (var bodyStream = new StreamReader(req.Body)) + { + return JsonSerializer.Deserialize(bodyStream.ReadToEnd()); + } + } + + async Task WriteJson(HttpResponse resp, T value) + { + resp.ContentType = "application/json"; + await resp.WriteAsync(JsonSerializer.Serialize(value)); + } + + async Task GetStatus(HttpContext context) + { + var status = new Status + { + Capabilities = new string[] + { + "comments", + "headers", + "last-event-id", + "post", + "read-timeout", + "report", + "restart" + } + }; + await WriteJson(context.Response, status); + } + + async Task PostCreateStream(HttpContext context) + { + var options = ReadJson(context.Request); + + var id = Interlocked.Increment(ref _lastStreamId); + var streamId = id.ToString(); + var stream = new StreamEntity(options, _logging.Logger(options.Tag)); + _streams[streamId] = stream; + + var resourceUrl = "/streams/" + streamId; + context.Response.Headers["Location"] = resourceUrl; + context.Response.StatusCode = StatusCodes.Status201Created; + + await Task.Yield(); + } + + async Task PostStreamCommand(HttpContext context) + { + var id = context.GetRouteValue("id").ToString(); + if (!_streams.TryGetValue(id, out var stream)) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + var command = ReadJson(context.Request); + if (stream.DoCommand(command.Command)) + { + context.Response.StatusCode = StatusCodes.Status202Accepted; + } + else + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + } + + await Task.Yield(); + } + + async Task DeleteStream(HttpContext context) + { + var id = context.GetRouteValue("id").ToString(); + if (!_streams.TryGetValue(id, out var stream)) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + stream.Close(); + _streams.TryRemove(id, out _); + + context.Response.StatusCode = StatusCodes.Status204NoContent; + + await Task.Yield(); + } + } +} diff --git a/contract-tests/TestService.csproj b/contract-tests/TestService.csproj new file mode 100644 index 0000000..d3eb20b --- /dev/null +++ b/contract-tests/TestService.csproj @@ -0,0 +1,38 @@ + + + + netcoreapp2.1 + $(TESTFRAMEWORK) + portable + ContractTestService + Exe + ContractTestService + false + false + false + false + false + false + + + + + + + + + + + + + + + + true + PreserveNewest + + + + + + diff --git a/contract-tests/TestService.sln b/contract-tests/TestService.sln new file mode 100644 index 0000000..662af3d --- /dev/null +++ b/contract-tests/TestService.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.810.13 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestService", "TestService.csproj", "{427B5AC1-5A5A-4506-B55A-09187EB85190}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.EventSource", "..\src\LaunchDarkly.EventSource\LaunchDarkly.EventSource.csproj", "{6AD8F034-7096-4420-953F-68F529F2B300}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {427B5AC1-5A5A-4506-B55A-09187EB85190}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {427B5AC1-5A5A-4506-B55A-09187EB85190}.Debug|Any CPU.Build.0 = Debug|Any CPU + {427B5AC1-5A5A-4506-B55A-09187EB85190}.Release|Any CPU.ActiveCfg = Release|Any CPU + {427B5AC1-5A5A-4506-B55A-09187EB85190}.Release|Any CPU.Build.0 = Release|Any CPU + {6AD8F034-7096-4420-953F-68F529F2B300}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6AD8F034-7096-4420-953F-68F529F2B300}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6AD8F034-7096-4420-953F-68F529F2B300}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6AD8F034-7096-4420-953F-68F529F2B300}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {934189FE-533F-429D-87E8-A2311F8730A6} + EndGlobalSection +EndGlobal diff --git a/src/LaunchDarkly.EventSource/LaunchDarkly.EventSource.csproj b/src/LaunchDarkly.EventSource/LaunchDarkly.EventSource.csproj index 22883a4..3cbdab5 100644 --- a/src/LaunchDarkly.EventSource/LaunchDarkly.EventSource.csproj +++ b/src/LaunchDarkly.EventSource/LaunchDarkly.EventSource.csproj @@ -1,7 +1,8 @@  4.1.3 - netstandard2.0;net452 + netstandard2.0;net452 + $(BUILDFRAMEWORK) Apache-2.0 LaunchDarkly.EventSource portable From a1dad0fd9a8b23f19ce3584fc268b697dbf206d9 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 12 Nov 2021 10:59:28 -0800 Subject: [PATCH 02/25] run contract tests in CI --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 722a64e..617262e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -45,6 +45,7 @@ jobs: - checkout - run: dotnet build src/LaunchDarkly.EventSource -f netstandard2.0 - run: dotnet test test/LaunchDarkly.EventSource.Tests/LaunchDarkly.EventSource.Tests.csproj + - run: make contract-test test-windows-netframework: parameters: From ed23d4f6ca9e31162fa7fab62e7fd0be9834bc1e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 12 Nov 2021 11:20:22 -0800 Subject: [PATCH 03/25] fix Docker build --- CONTRIBUTING.md | 4 ++-- Makefile | 21 --------------------- contract-tests/Dockerfile | 8 +++++++- contract-tests/run.sh | 15 +++++++++++++++ 4 files changed, 24 insertions(+), 24 deletions(-) delete mode 100644 Makefile create mode 100755 contract-tests/run.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c43316..6ec745a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,9 +50,9 @@ Or, to run tests only for the .NET Standard 2.0 target (using the .NET Core 2.1 dotnet test test/LaunchDarkly.EventSource.Tests -f netcoreapp2.1 ``` -To run the standardized contract tests that are run against all LaunchDarkly SSE client implementations (this requires Docker): +To run the standardized contract tests that are run against all LaunchDarkly SSE client implementations (this requires Docker, and is currently not supported on Windows): ``` -make contract-tests +./contract-tests/run.sh ``` Note that the unit tests can only be run in Debug configuration. There is an `InternalsVisibleTo` directive that allows the test code to access internal members of the library, and assembly strong-naming in the Release configuration interferes with this. diff --git a/Makefile b/Makefile deleted file mode 100644 index e5440c4..0000000 --- a/Makefile +++ /dev/null @@ -1,21 +0,0 @@ - -build: - dotnet build - -clean: - dotnet clean - -test: - dotnet test - -DOCKER_IMAGE ?= "mcr.microsoft.com/dotnet/core/sdk:2.1-focal" -TESTFRAMEWORK ?= "netcoreapp2.1" - -contract-tests: - @echo "Building contract test service..." - @docker build --tag testservice -f contract-tests/Dockerfile \ - --build-arg DOCKER_IMAGE=$(DOCKER_IMAGE) --build-arg TESTFRAMEWORK=$(TESTFRAMEWORK) . - @docker run ldcircleci/sse-contract-tests:1 --output-docker-script 1 --url http://testservice:8000 \ - | bash - -.PHONY: build clean test contract-tests diff --git a/contract-tests/Dockerfile b/contract-tests/Dockerfile index 76566d2..d045ae4 100644 --- a/contract-tests/Dockerfile +++ b/contract-tests/Dockerfile @@ -2,8 +2,12 @@ # This needs to be built from the parent directory rather than from ARG DOCKER_IMAGE + FROM $DOCKER_IMAGE +ARG DOCKER_IMAGE +ARG TESTFRAMEWORK + COPY . . ENV BUILDFRAMEWORK=netstandard2.0 @@ -12,4 +16,6 @@ WORKDIR ./contract-tests RUN dotnet build -ENTRYPOINT dotnet bin/Debug/$TESTFRAMEWORK/ContractTestService.dll +RUN mkdir app && mv bin/Debug/$TESTFRAMEWORK/* app + +CMD dotnet app/ContractTestService.dll diff --git a/contract-tests/run.sh b/contract-tests/run.sh new file mode 100755 index 0000000..14b618a --- /dev/null +++ b/contract-tests/run.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -ex + +DOCKER_IMAGE=${DOCKER_IMAGE:-mcr.microsoft.com/dotnet/core/sdk:2.1-focal} +TESTFRAMEWORK=${TESTFRAMEWORK:-netcoreapp2.1} + +cd $(dirname $0) +cd .. # must build Docker container from project root + +echo "Building contract test service..." +docker build --tag testservice -f contract-tests/Dockerfile \ + --build-arg DOCKER_IMAGE=${DOCKER_IMAGE} --build-arg TESTFRAMEWORK=${TESTFRAMEWORK} . +docker run ldcircleci/sse-contract-tests:1 --output-docker-script 1 --url http://testservice:8000 \ + | bash From 5e7eb22f1bf178fcd8af226ea7f413b0d100741f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 12 Nov 2021 11:22:58 -0800 Subject: [PATCH 04/25] fix CI script --- .circleci/config.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 617262e..2236451 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -45,7 +45,9 @@ jobs: - checkout - run: dotnet build src/LaunchDarkly.EventSource -f netstandard2.0 - run: dotnet test test/LaunchDarkly.EventSource.Tests/LaunchDarkly.EventSource.Tests.csproj - - run: make contract-test + - run: + name: run contract tests + command: ./contract-tests/run.sh test-windows-netframework: parameters: From cde64b5ac920dfb5c4fff6982768dafe89789ecd Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 12 Nov 2021 11:26:16 -0800 Subject: [PATCH 05/25] install Docker in CI --- .circleci/config.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2236451..9f37ab6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,6 +2,7 @@ version: 2.1 orbs: win: circleci/windows@1.0.0 + docker: circleci/docker@2.0.1 workflows: version: 2 @@ -45,6 +46,8 @@ jobs: - checkout - run: dotnet build src/LaunchDarkly.EventSource -f netstandard2.0 - run: dotnet test test/LaunchDarkly.EventSource.Tests/LaunchDarkly.EventSource.Tests.csproj + + - docker/install-docker-tools # needed for contract tests - run: name: run contract tests command: ./contract-tests/run.sh From 3d550dca670e1639c1494df9a4470fbfc468e3f7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 12 Nov 2021 11:29:26 -0800 Subject: [PATCH 06/25] add prerequisite for Docker install --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 9f37ab6..d14bcc2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -42,7 +42,7 @@ jobs: steps: - run: name: install packages - command: apt-get -q update && apt-get install -qy awscli + command: apt-get -q update && apt-get install -qy awscli jq - checkout - run: dotnet build src/LaunchDarkly.EventSource -f netstandard2.0 - run: dotnet test test/LaunchDarkly.EventSource.Tests/LaunchDarkly.EventSource.Tests.csproj From 328cfe251bd98d0af6c183501c1d98212f6b056b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 12 Nov 2021 11:31:55 -0800 Subject: [PATCH 07/25] set up Docker daemon in CI --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index d14bcc2..2acc2a9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -47,6 +47,7 @@ jobs: - run: dotnet build src/LaunchDarkly.EventSource -f netstandard2.0 - run: dotnet test test/LaunchDarkly.EventSource.Tests/LaunchDarkly.EventSource.Tests.csproj + - setup_remote_docker - docker/install-docker-tools # needed for contract tests - run: name: run contract tests From 17c53eead84409e63f7e34750a2c2ec8de3761d4 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 12 Nov 2021 11:35:15 -0800 Subject: [PATCH 08/25] pass in Docker image variable --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2acc2a9..16e4ddd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -51,7 +51,7 @@ jobs: - docker/install-docker-tools # needed for contract tests - run: name: run contract tests - command: ./contract-tests/run.sh + command: DOCKER_IMAGE=<> ./contract-tests/run.sh test-windows-netframework: parameters: From b3c6108c7a47c32f7dd9a20b12aa29703e2e9f1e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 12 Nov 2021 16:59:18 -0800 Subject: [PATCH 09/25] use simpler non-Docker contract test runner --- .circleci/config.yml | 9 ++++----- .dockerignore | 1 - Makefile | 30 ++++++++++++++++++++++++++++++ contract-tests/Dockerfile | 21 --------------------- contract-tests/TestService.cs | 27 +++++++++++++++++++-------- contract-tests/run.sh | 15 +++++++-------- 6 files changed, 60 insertions(+), 43 deletions(-) delete mode 100644 .dockerignore create mode 100644 Makefile delete mode 100644 contract-tests/Dockerfile diff --git a/.circleci/config.yml b/.circleci/config.yml index 16e4ddd..1d44a73 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,7 +2,6 @@ version: 2.1 orbs: win: circleci/windows@1.0.0 - docker: circleci/docker@2.0.1 workflows: version: 2 @@ -47,11 +46,11 @@ jobs: - run: dotnet build src/LaunchDarkly.EventSource -f netstandard2.0 - run: dotnet test test/LaunchDarkly.EventSource.Tests/LaunchDarkly.EventSource.Tests.csproj - - setup_remote_docker - - docker/install-docker-tools # needed for contract tests + - run: make build-contract-tests - run: - name: run contract tests - command: DOCKER_IMAGE=<> ./contract-tests/run.sh + command: make start-contract-test-service + background: true + - run: make run-contract-tests test-windows-netframework: parameters: diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 6b8710a..0000000 --- a/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -.git diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..debfca0 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ + +build: + dotnet build + +test: + dotnet test + +clean: + dotnet clean + +TEMP_TEST_OUTPUT=/tmp/sse-contract-test-service.log +TESTFRAMEWORK ?= netcoreapp2.1 + +build-contract-tests: + @cd contract-tests && BUILDFRAMEWORK=netstandard2.0 dotnet build TestService.csproj + +start-contract-test-service: + @cd contract-tests && dotnet bin/Debug/${TESTFRAMEWORK}/ContractTestService.dll + +start-contract-test-service-bg: + @echo "Test service output will be captured in $(TEMP_TEST_OUTPUT)" + @cd contract-tests && dotnet bin/Debug/${TESTFRAMEWORK}/ContractTestService.dll >$(TEMP_TEST_OUTPUT) & + +run-contract-tests: + @curl -s https://raw.githubusercontent.com/launchdarkly/sse-contract-tests/v0.0.3/downloader/run.sh \ + | VERSION=v0 PARAMS="-url http://localhost:8000 -debug -stop-service-at-end" sh + +contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests + +.PHONY: build test clean build-contract-tests start-contract-test-service run-contract-tests contract-tests diff --git a/contract-tests/Dockerfile b/contract-tests/Dockerfile deleted file mode 100644 index d045ae4..0000000 --- a/contract-tests/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -# Dockerfile for building and running the contract test service. -# This needs to be built from the parent directory rather than from - -ARG DOCKER_IMAGE - -FROM $DOCKER_IMAGE - -ARG DOCKER_IMAGE -ARG TESTFRAMEWORK - -COPY . . - -ENV BUILDFRAMEWORK=netstandard2.0 - -WORKDIR ./contract-tests - -RUN dotnet build - -RUN mkdir app && mv bin/Debug/$TESTFRAMEWORK/* app - -CMD dotnet app/ContractTestService.dll diff --git a/contract-tests/TestService.cs b/contract-tests/TestService.cs index a1d35c9..2081f7d 100644 --- a/contract-tests/TestService.cs +++ b/contract-tests/TestService.cs @@ -43,6 +43,7 @@ public void Configure(IApplicationBuilder app) var routeBuilder = new RouteBuilder(app); routeBuilder.MapGet("", GetStatus); + routeBuilder.MapDelete("", ForceQuit); routeBuilder.MapPost("", PostCreateStream); routeBuilder.MapPost("/streams/{id}", PostStreamCommand); routeBuilder.MapDelete("/streams/{id}", DeleteStream); @@ -83,7 +84,17 @@ async Task GetStatus(HttpContext context) await WriteJson(context.Response, status); } - async Task PostCreateStream(HttpContext context) + Task ForceQuit(HttpContext context) + { + _logging.Logger("").Info("Test harness has told us to exit"); + context.Response.StatusCode = StatusCodes.Status204NoContent; + + System.Environment.Exit(0); + + return Task.CompletedTask; // never reached + } + + Task PostCreateStream(HttpContext context) { var options = ReadJson(context.Request); @@ -96,16 +107,16 @@ async Task PostCreateStream(HttpContext context) context.Response.Headers["Location"] = resourceUrl; context.Response.StatusCode = StatusCodes.Status201Created; - await Task.Yield(); + return Task.CompletedTask; } - async Task PostStreamCommand(HttpContext context) + Task PostStreamCommand(HttpContext context) { var id = context.GetRouteValue("id").ToString(); if (!_streams.TryGetValue(id, out var stream)) { context.Response.StatusCode = StatusCodes.Status404NotFound; - return; + return Task.CompletedTask; } var command = ReadJson(context.Request); @@ -118,23 +129,23 @@ async Task PostStreamCommand(HttpContext context) context.Response.StatusCode = StatusCodes.Status400BadRequest; } - await Task.Yield(); + return Task.CompletedTask; } - async Task DeleteStream(HttpContext context) + Task DeleteStream(HttpContext context) { var id = context.GetRouteValue("id").ToString(); if (!_streams.TryGetValue(id, out var stream)) { context.Response.StatusCode = StatusCodes.Status404NotFound; - return; + return Task.CompletedTask; } stream.Close(); _streams.TryRemove(id, out _); context.Response.StatusCode = StatusCodes.Status204NoContent; - await Task.Yield(); + return Task.CompletedTask; } } } diff --git a/contract-tests/run.sh b/contract-tests/run.sh index 14b618a..37485dc 100755 --- a/contract-tests/run.sh +++ b/contract-tests/run.sh @@ -1,15 +1,14 @@ #!/bin/bash -set -ex +set -e -DOCKER_IMAGE=${DOCKER_IMAGE:-mcr.microsoft.com/dotnet/core/sdk:2.1-focal} TESTFRAMEWORK=${TESTFRAMEWORK:-netcoreapp2.1} +TEMP_TEST_OUTPUT=/tmp/sse-contract-test-service.log cd $(dirname $0) -cd .. # must build Docker container from project root -echo "Building contract test service..." -docker build --tag testservice -f contract-tests/Dockerfile \ - --build-arg DOCKER_IMAGE=${DOCKER_IMAGE} --build-arg TESTFRAMEWORK=${TESTFRAMEWORK} . -docker run ldcircleci/sse-contract-tests:1 --output-docker-script 1 --url http://testservice:8000 \ - | bash +BUILDFRAMEWORK=netstandard2.0 dotnet build TestService.csproj +dotnet bin/Debug/${TESTFRAMEWORK}/ContractTestService.dll >${TEMP_TEST_OUTPUT} & +curl -s https://raw.githubusercontent.com/launchdarkly/sse-contract-tests/v0.0.3/downloader/run.sh \ + | VERSION=v0 PARAMS="-url http://localhost:8000 -debug -stop-service-at-end" sh || \ + (echo "Tests failed; see ${TEMP_TEST_OUTPUT} for test service log"; exit 1) From 7427c93c135dc3b43b203f5aaa3fd8fc620714a9 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 12 Nov 2021 17:05:55 -0800 Subject: [PATCH 10/25] add package dependency --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 1d44a73..862a9a3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -41,7 +41,7 @@ jobs: steps: - run: name: install packages - command: apt-get -q update && apt-get install -qy awscli jq + command: apt-get -q update && apt-get install -qy make - checkout - run: dotnet build src/LaunchDarkly.EventSource -f netstandard2.0 - run: dotnet test test/LaunchDarkly.EventSource.Tests/LaunchDarkly.EventSource.Tests.csproj From 61762bd7dadb20321b3f69cda0ab248f50a4b836 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 12 Nov 2021 17:21:49 -0800 Subject: [PATCH 11/25] makefile DRY --- CONTRIBUTING.md | 4 ++-- Makefile | 2 +- contract-tests/README.md | 5 +++++ 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 contract-tests/README.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6ec745a..7934b8c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,9 +50,9 @@ Or, to run tests only for the .NET Standard 2.0 target (using the .NET Core 2.1 dotnet test test/LaunchDarkly.EventSource.Tests -f netcoreapp2.1 ``` -To run the standardized contract tests that are run against all LaunchDarkly SSE client implementations (this requires Docker, and is currently not supported on Windows): +To run the standardized contract tests that are run against all LaunchDarkly SSE client implementations (this is currently not supported on Windows): ``` -./contract-tests/run.sh +make contract-tests ``` Note that the unit tests can only be run in Debug configuration. There is an `InternalsVisibleTo` directive that allows the test code to access internal members of the library, and assembly strong-naming in the Release configuration interferes with this. diff --git a/Makefile b/Makefile index debfca0..eb3045d 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ start-contract-test-service: start-contract-test-service-bg: @echo "Test service output will be captured in $(TEMP_TEST_OUTPUT)" - @cd contract-tests && dotnet bin/Debug/${TESTFRAMEWORK}/ContractTestService.dll >$(TEMP_TEST_OUTPUT) & + @make start-contract-test-service >$(TEMP_TEST_OUTPUT) 2>&1 & run-contract-tests: @curl -s https://raw.githubusercontent.com/launchdarkly/sse-contract-tests/v0.0.3/downloader/run.sh \ diff --git a/contract-tests/README.md b/contract-tests/README.md new file mode 100644 index 0000000..a96fa19 --- /dev/null +++ b/contract-tests/README.md @@ -0,0 +1,5 @@ +# SSE client contract test service + +This directory contains an implementation of the cross-platform SSE testing protocol defined by https://github.com/launchdarkly/sse-contract-testing. See that project's `README` for details of this protocol, and the kinds of SSE client capabilities that are relevant to the contract tests. This code should not need to be updated unless the SSE client has added or removed such capabilities. + +To run these tests locally, run `make contract-tests` from the project root directory. This downloads the correct version of the test harness tool automatically. From fef28789a644400dedc0f47503b14f94c45c0567 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 15 Nov 2021 17:11:26 -0800 Subject: [PATCH 12/25] fix readme link --- contract-tests/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contract-tests/README.md b/contract-tests/README.md index a96fa19..37cc97c 100644 --- a/contract-tests/README.md +++ b/contract-tests/README.md @@ -1,5 +1,5 @@ # SSE client contract test service -This directory contains an implementation of the cross-platform SSE testing protocol defined by https://github.com/launchdarkly/sse-contract-testing. See that project's `README` for details of this protocol, and the kinds of SSE client capabilities that are relevant to the contract tests. This code should not need to be updated unless the SSE client has added or removed such capabilities. +This directory contains an implementation of the cross-platform SSE testing protocol defined by https://github.com/launchdarkly/sse-contract-tests. See that project's `README` for details of this protocol, and the kinds of SSE client capabilities that are relevant to the contract tests. This code should not need to be updated unless the SSE client has added or removed such capabilities. To run these tests locally, run `make contract-tests` from the project root directory. This downloads the correct version of the test harness tool automatically. From 84c5a8c5dc11039390f5ee8342fd5073fbc5799b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 15 Nov 2021 17:46:39 -0800 Subject: [PATCH 13/25] update for change in test harness callback protocol --- contract-tests/StreamEntity.cs | 45 ++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/contract-tests/StreamEntity.cs b/contract-tests/StreamEntity.cs index fc90a42..eb33cd5 100644 --- a/contract-tests/StreamEntity.cs +++ b/contract-tests/StreamEntity.cs @@ -2,6 +2,7 @@ using System.Net.Http; using System.Text; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using LaunchDarkly.EventSource; using LaunchDarkly.Logging; @@ -15,6 +16,7 @@ public class StreamEntity private readonly EventSource _stream; private readonly StreamOptions _options; private readonly Logger _log; + private volatile int _callbackMessageCounter; private volatile bool _closed; public StreamEntity( @@ -75,8 +77,9 @@ Logger log _stream = new EventSource(builder.Build()); _stream.MessageReceived += (sender, args) => { - _log.Info("Received event from stream ({0})", args.EventName); - Task.Run(() => SendMessage(new Message + _log.Info("Received event from stream (type: {0}, data: {1})", + args.EventName, args.Message.Data); + SendMessage(new Message { Kind = "event", Event = new EventMessage @@ -85,7 +88,7 @@ Logger log Data = args.Message.Data, Id = args.Message.LastEventId } - })); + }); }; _stream.CommentReceived += (sender, args) => { @@ -95,11 +98,11 @@ Logger log comment = comment.Substring(1); // this SSE client includes the colon in the comment } _log.Info("Received comment from stream: {0}", comment); - Task.Run(() => SendMessage(new Message + SendMessage(new Message { Kind = "comment", Comment = comment - })); + }); }; _stream.Error += (sender, args) => { @@ -124,6 +127,7 @@ public void Close() public bool DoCommand(string command) { + _log.Info("Test harness sent command: {0}", command); if (command == "restart") { _stream.Restart(false); @@ -132,32 +136,37 @@ public bool DoCommand(string command) return false; } - private async Task SendMessage(object message) + private void SendMessage(object message) { if (_closed) { return; } var json = JsonSerializer.Serialize(message); - using (var request = new HttpRequestMessage(HttpMethod.Post, new Uri(_options.CallbackUrl))) - using (var stringContent = new StringContent(json, Encoding.UTF8, "application/json")) + var counter = Interlocked.Increment(ref _callbackMessageCounter); + var uri = new Uri(_options.CallbackUrl + "/" + counter); + Task.Run(async () => { - request.Content = stringContent; - try + using (var request = new HttpRequestMessage(HttpMethod.Post, uri)) + using (var stringContent = new StringContent(json, Encoding.UTF8, "application/json")) { - using (var response = await _httpClient.SendAsync(request)) + request.Content = stringContent; + try { - if (!response.IsSuccessStatusCode) + using (var response = await _httpClient.SendAsync(request)) { - _log.Error("Callback to {0} returned HTTP {1}", _options.CallbackUrl, response.StatusCode); + if (!response.IsSuccessStatusCode) + { + _log.Error("Callback to {0} returned HTTP {1}", uri, response.StatusCode); + } } } + catch (Exception e) + { + _log.Error("Callback to {0} failed: {1}", uri, e.GetType()); + } } - catch (Exception e) - { - _log.Error("Callback to {0} failed: {1}", _options.CallbackUrl, e.GetType()); - } - } + }); } } } From 8f5d7e2be8c3810f547683338c2ee5768c411fba Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 29 Nov 2021 16:49:02 -0800 Subject: [PATCH 14/25] use v1 release of test harness --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index eb3045d..069f278 100644 --- a/Makefile +++ b/Makefile @@ -22,8 +22,8 @@ start-contract-test-service-bg: @make start-contract-test-service >$(TEMP_TEST_OUTPUT) 2>&1 & run-contract-tests: - @curl -s https://raw.githubusercontent.com/launchdarkly/sse-contract-tests/v0.0.3/downloader/run.sh \ - | VERSION=v0 PARAMS="-url http://localhost:8000 -debug -stop-service-at-end" sh + @curl -s https://raw.githubusercontent.com/launchdarkly/sse-contract-tests/v1.0.0/downloader/run.sh \ + | VERSION=v1 PARAMS="-url http://localhost:8000 -debug -stop-service-at-end" sh contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests From ef3b6b9008b2ba85d68a4057190132792bf4222e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 17 Jan 2023 16:56:14 -0800 Subject: [PATCH 15/25] full rewrite to use a pull model like our Java implementation --- .../Background/BackgroundEventSource.cs | 178 ++++++ .../CommentReceivedEventArgs.cs | 5 +- .../{ => Background}/ExceptionEventArgs.cs | 5 +- .../MessageReceivedEventArgs.cs | 9 +- .../{ => Background}/StateChangedEventArgs.cs | 6 +- .../ByteArrayLineScanner.cs | 179 ------ src/LaunchDarkly.EventSource/Configuration.cs | 178 ++---- .../ConfigurationBuilder.cs | 293 ++-------- .../ConnectStrategy.cs | 146 +++++ .../DefaultRetryDelayStrategy.cs | 163 ++++++ src/LaunchDarkly.EventSource/ErrorStrategy.cs | 152 +++++ src/LaunchDarkly.EventSource/EventParser.cs | 96 --- src/LaunchDarkly.EventSource/EventSource.cs | 553 ++++++++---------- .../EventSourceService.cs | 272 --------- .../EventSourceServiceCancelledException.cs | 34 -- ...rceServiceUnsuccessfulResponseException.cs | 34 -- .../Events/CommentEvent.cs | 37 ++ .../Events/FaultEvent.cs | 51 ++ src/LaunchDarkly.EventSource/Events/IEvent.cs | 12 + .../{ => Events}/MessageEvent.cs | 41 +- .../Events/StartedEvent.cs | 24 + .../{ => Events}/Utf8ByteSpan.cs | 2 +- .../{ => Exceptions}/ReadTimeoutException.cs | 13 +- .../StreamClosedByCallerException.cs | 24 + .../StreamClosedByServerException.cs | 17 + .../Exceptions/StreamContentException.cs | 55 ++ .../Exceptions/StreamException.cs | 30 + .../Exceptions/StreamHttpErrorException.cs | 36 ++ .../ExponentialBackoffWithDecorrelation.cs | 48 -- .../HttpConnectStrategy.cs | 476 +++++++++++++++ src/LaunchDarkly.EventSource/IEventSource.cs | 155 ++++- .../{ => Internal}/AsyncHelpers.cs | 2 +- .../Internal/BufferedLineParser.cs | 153 +++++ .../{ => Internal}/Constants.cs | 21 +- .../Internal/EventParser.cs | 307 ++++++++++ .../Internal/SetRetryDelayEvent.cs | 16 + .../Internal/ValueWithLock.cs | 43 ++ .../LaunchDarkly.EventSource.csproj | 12 + .../Resources.Designer.cs | 21 +- src/LaunchDarkly.EventSource/Resources.resx | 13 +- .../RetryDelayStrategy.cs | 62 ++ .../BaseTest.cs | 79 ++- .../ByteArrayLineScannerTest.cs | 126 ---- .../ConfigurationBuilderTest.cs | 240 +------- .../DefaultRetryDelayStrategyTest.cs | 156 +++++ .../ErrorStrategyTest.cs | 68 +++ .../EventParserTest.cs | 54 -- .../EventSink.cs | 145 ----- .../EventSourceConnectStrategyUsageTest.cs | 129 ++++ .../EventSourceEncodingTest.cs | 102 +--- .../EventSourceErrorStrategyUsageTest.cs | 157 +++++ .../EventSourceHttpBehaviorTest.cs | 304 ---------- .../EventSourceLoggingTest.cs | 98 ++-- .../EventSourceReconnectingTest.cs | 151 ++--- .../EventSourceStreamReadingTest.cs | 273 ++++----- .../Events/EventTypesTest.cs | 41 ++ .../{ => Events}/MessageEventTest.cs | 2 +- .../{ => Events}/Utf8ByteSpanTest.cs | 2 +- .../Exceptions/ExceptionTypesTest.cs | 55 ++ ...ExponentialBackoffWithDecorrelationTest.cs | 55 -- .../HttpConnectStrategyTest.cs | 211 +++++++ .../HttpConnectStrategyWithEventSource.cs | 88 +++ .../{ => Internal}/AsyncHelpersTest.cs | 22 +- .../Internal/BufferedLineParserTest.cs | 200 +++++++ .../Internal/EventParserTest.cs | 11 + .../LaunchDarkly.EventSource.Tests.csproj | 12 + .../MockConnectStrategy.cs | 173 ++++++ .../TestHelpers.cs | 66 ++- 68 files changed, 4249 insertions(+), 2745 deletions(-) create mode 100644 src/LaunchDarkly.EventSource/Background/BackgroundEventSource.cs rename src/LaunchDarkly.EventSource/{ => Background}/CommentReceivedEventArgs.cs (78%) rename src/LaunchDarkly.EventSource/{ => Background}/ExceptionEventArgs.cs (79%) rename src/LaunchDarkly.EventSource/{ => Background}/MessageReceivedEventArgs.cs (82%) rename src/LaunchDarkly.EventSource/{ => Background}/StateChangedEventArgs.cs (82%) delete mode 100644 src/LaunchDarkly.EventSource/ByteArrayLineScanner.cs create mode 100644 src/LaunchDarkly.EventSource/ConnectStrategy.cs create mode 100644 src/LaunchDarkly.EventSource/DefaultRetryDelayStrategy.cs create mode 100644 src/LaunchDarkly.EventSource/ErrorStrategy.cs delete mode 100644 src/LaunchDarkly.EventSource/EventParser.cs delete mode 100644 src/LaunchDarkly.EventSource/EventSourceService.cs delete mode 100644 src/LaunchDarkly.EventSource/EventSourceServiceCancelledException.cs delete mode 100644 src/LaunchDarkly.EventSource/EventSourceServiceUnsuccessfulResponseException.cs create mode 100644 src/LaunchDarkly.EventSource/Events/CommentEvent.cs create mode 100644 src/LaunchDarkly.EventSource/Events/FaultEvent.cs create mode 100644 src/LaunchDarkly.EventSource/Events/IEvent.cs rename src/LaunchDarkly.EventSource/{ => Events}/MessageEvent.cs (83%) create mode 100644 src/LaunchDarkly.EventSource/Events/StartedEvent.cs rename src/LaunchDarkly.EventSource/{ => Events}/Utf8ByteSpan.cs (99%) rename src/LaunchDarkly.EventSource/{ => Exceptions}/ReadTimeoutException.cs (61%) create mode 100644 src/LaunchDarkly.EventSource/Exceptions/StreamClosedByCallerException.cs create mode 100644 src/LaunchDarkly.EventSource/Exceptions/StreamClosedByServerException.cs create mode 100644 src/LaunchDarkly.EventSource/Exceptions/StreamContentException.cs create mode 100644 src/LaunchDarkly.EventSource/Exceptions/StreamException.cs create mode 100644 src/LaunchDarkly.EventSource/Exceptions/StreamHttpErrorException.cs delete mode 100644 src/LaunchDarkly.EventSource/ExponentialBackoffWithDecorrelation.cs create mode 100644 src/LaunchDarkly.EventSource/HttpConnectStrategy.cs rename src/LaunchDarkly.EventSource/{ => Internal}/AsyncHelpers.cs (99%) create mode 100644 src/LaunchDarkly.EventSource/Internal/BufferedLineParser.cs rename src/LaunchDarkly.EventSource/{ => Internal}/Constants.cs (59%) create mode 100644 src/LaunchDarkly.EventSource/Internal/EventParser.cs create mode 100644 src/LaunchDarkly.EventSource/Internal/SetRetryDelayEvent.cs create mode 100644 src/LaunchDarkly.EventSource/Internal/ValueWithLock.cs create mode 100644 src/LaunchDarkly.EventSource/RetryDelayStrategy.cs delete mode 100644 test/LaunchDarkly.EventSource.Tests/ByteArrayLineScannerTest.cs create mode 100644 test/LaunchDarkly.EventSource.Tests/DefaultRetryDelayStrategyTest.cs create mode 100644 test/LaunchDarkly.EventSource.Tests/ErrorStrategyTest.cs delete mode 100644 test/LaunchDarkly.EventSource.Tests/EventParserTest.cs delete mode 100644 test/LaunchDarkly.EventSource.Tests/EventSink.cs create mode 100644 test/LaunchDarkly.EventSource.Tests/EventSourceConnectStrategyUsageTest.cs create mode 100644 test/LaunchDarkly.EventSource.Tests/EventSourceErrorStrategyUsageTest.cs delete mode 100644 test/LaunchDarkly.EventSource.Tests/EventSourceHttpBehaviorTest.cs create mode 100644 test/LaunchDarkly.EventSource.Tests/Events/EventTypesTest.cs rename test/LaunchDarkly.EventSource.Tests/{ => Events}/MessageEventTest.cs (98%) rename test/LaunchDarkly.EventSource.Tests/{ => Events}/Utf8ByteSpanTest.cs (98%) create mode 100644 test/LaunchDarkly.EventSource.Tests/Exceptions/ExceptionTypesTest.cs delete mode 100644 test/LaunchDarkly.EventSource.Tests/ExponentialBackoffWithDecorrelationTest.cs create mode 100644 test/LaunchDarkly.EventSource.Tests/HttpConnectStrategyTest.cs create mode 100644 test/LaunchDarkly.EventSource.Tests/HttpConnectStrategyWithEventSource.cs rename test/LaunchDarkly.EventSource.Tests/{ => Internal}/AsyncHelpersTest.cs (84%) create mode 100644 test/LaunchDarkly.EventSource.Tests/Internal/BufferedLineParserTest.cs create mode 100644 test/LaunchDarkly.EventSource.Tests/Internal/EventParserTest.cs create mode 100644 test/LaunchDarkly.EventSource.Tests/MockConnectStrategy.cs diff --git a/src/LaunchDarkly.EventSource/Background/BackgroundEventSource.cs b/src/LaunchDarkly.EventSource/Background/BackgroundEventSource.cs new file mode 100644 index 0000000..a36bb92 --- /dev/null +++ b/src/LaunchDarkly.EventSource/Background/BackgroundEventSource.cs @@ -0,0 +1,178 @@ +using System; +using System.Threading.Tasks; +using LaunchDarkly.EventSource.Events; +using LaunchDarkly.Logging; + +namespace LaunchDarkly.EventSource.Background +{ + /// + /// A wrapper for that reads the stream on a long-running + /// asynchronous task, pushing events to event handlers that the caller provides. + /// + /// + /// + /// Event handlers are called asynchronously from the + /// task, which will await the that they return before reading another + /// event. Therefore, if an event handler wants to start long-running operations while + /// letting the stream continue to receive other events, it should explicitly create a + /// detached task with . + /// + /// + /// This is very similar to the asynchronous model that was used by EventSource prior to + /// the 6.0.0 release. Code that was written against earlier versions of EventSource + /// can be adapted to use BackgroundEventSource as follows: + /// + /// + /// // before (version 5.x) + /// var eventSource = new EventSource(myEventSourceConfiguration); + /// eventSource.MessageReceived += myMessageReceivedHandler; + /// Task.Run(() => eventSource.StartAsync()); + /// + /// // after (version 6.x) + /// var eventSource = new EventSource(myEventSourceConfiguration); + /// var backgroundEventSource = new BackgroundEventSource(eventSource); + /// backgroundEventSource.MessageReceived += myAsyncMessageReceivedHandler; + /// // note that myAsyncMessageReceivedHandler is an async function, unlike the + /// // previous myMessageReceivedHandler which was a synchronous void function + /// Task.Run(() => backgroundEventSource.RunAsync(); + /// + /// + public class BackgroundEventSource : IDisposable + { + #region Private Fields + + private readonly IEventSource _eventSource; + + #endregion + + #region Public Types + + /// + /// Equivalent to but returns a + /// , allowing event handlers to perform asynchronous + /// operations. + /// + /// the event argument type + /// the instance + /// the event argument + /// a + public delegate Task AsyncEventHandler(object sender, T value); + + #endregion + + #region Public Events + + /// + public event AsyncEventHandler Opened; + + /// + public event AsyncEventHandler Closed; + + /// + public event AsyncEventHandler MessageReceived; + + /// + public event AsyncEventHandler CommentReceived; + + /// + public event AsyncEventHandler Error; + + #endregion + + #region Public Properties + + /// + /// Returns the underlying that this + /// is wrapping. This allows access to + /// properties and methods like and + /// . + /// + public IEventSource EventSource => _eventSource; + + #endregion + + #region Public Constructor + + /// + /// Creates a new instance to wrap an already-constructed . + /// + /// the underlying SSE client + /// if the parameter is null + public BackgroundEventSource(IEventSource eventSource) + { + if (eventSource is null) + { + throw new ArgumentNullException(nameof(eventSource)); + } + _eventSource = eventSource; + } + + #endregion + + #region Public Methods + + /// + /// Reads messages and other events from the underlying SSE client and + /// dispatches them to the event handlers. + /// + /// an asynchronous task representing the read loop + public async Task RunAsync() + { + while (_eventSource.ReadyState != ReadyState.Shutdown) + { + try + { + var e = await _eventSource.ReadAnyEventAsync(); + if (e is MessageEvent me) + { + await MessageReceived.Invoke(this, new MessageReceivedEventArgs(me)); + } + else if (e is CommentEvent ce) + { + await CommentReceived.Invoke(this, new CommentReceivedEventArgs(ce.Text)); + } + else if (e is StartedEvent se) + { + await Opened.Invoke(this, new StateChangedEventArgs(_eventSource.ReadyState)); + } + else if (e is FaultEvent fe) + { + await Error.Invoke(this, new ExceptionEventArgs(fe.Exception)); + await Closed.Invoke(this, new StateChangedEventArgs(_eventSource.ReadyState)); + } + } + catch (Exception ex) + { + if (_eventSource.ReadyState != ReadyState.Shutdown) + { + _eventSource.Logger.Error("Unexpected error in BackgroundEventSource task: {0}", + LogValues.ExceptionSummary(ex)); + _eventSource.Logger.Debug(LogValues.ExceptionTrace(ex)); + } + } + } + } + + /// + /// Equivalent to calling Dispose on the underlying . + /// + public void Dispose() => + Dispose(true); + + #endregion + + #region Private Methods + + private void Dispose(bool disposing) + { + if (disposing) + { + _eventSource.Close(); + } + } + + #endregion + } + +} + diff --git a/src/LaunchDarkly.EventSource/CommentReceivedEventArgs.cs b/src/LaunchDarkly.EventSource/Background/CommentReceivedEventArgs.cs similarity index 78% rename from src/LaunchDarkly.EventSource/CommentReceivedEventArgs.cs rename to src/LaunchDarkly.EventSource/Background/CommentReceivedEventArgs.cs index 733e7eb..a71b1fc 100644 --- a/src/LaunchDarkly.EventSource/CommentReceivedEventArgs.cs +++ b/src/LaunchDarkly.EventSource/Background/CommentReceivedEventArgs.cs @@ -1,11 +1,10 @@ using System; -namespace LaunchDarkly.EventSource +namespace LaunchDarkly.EventSource.Background { /// - /// Provides data recieved in the EventSource event. + /// Parameter type for the event. /// - /// public class CommentReceivedEventArgs : EventArgs { /// diff --git a/src/LaunchDarkly.EventSource/ExceptionEventArgs.cs b/src/LaunchDarkly.EventSource/Background/ExceptionEventArgs.cs similarity index 79% rename from src/LaunchDarkly.EventSource/ExceptionEventArgs.cs rename to src/LaunchDarkly.EventSource/Background/ExceptionEventArgs.cs index 301a85d..39b5c55 100644 --- a/src/LaunchDarkly.EventSource/ExceptionEventArgs.cs +++ b/src/LaunchDarkly.EventSource/Background/ExceptionEventArgs.cs @@ -1,11 +1,10 @@ using System; -namespace LaunchDarkly.EventSource +namespace LaunchDarkly.EventSource.Background { /// - /// Provides exception data raised in the EventSource event. + /// Parameter type for the event. /// - /// public class ExceptionEventArgs : EventArgs { /// diff --git a/src/LaunchDarkly.EventSource/MessageReceivedEventArgs.cs b/src/LaunchDarkly.EventSource/Background/MessageReceivedEventArgs.cs similarity index 82% rename from src/LaunchDarkly.EventSource/MessageReceivedEventArgs.cs rename to src/LaunchDarkly.EventSource/Background/MessageReceivedEventArgs.cs index 43662e8..3cd6e9a 100644 --- a/src/LaunchDarkly.EventSource/MessageReceivedEventArgs.cs +++ b/src/LaunchDarkly.EventSource/Background/MessageReceivedEventArgs.cs @@ -1,11 +1,12 @@ using System; -namespace LaunchDarkly.EventSource +using LaunchDarkly.EventSource.Events; + +namespace LaunchDarkly.EventSource.Background { /// - /// The parameter type for a event handler. + /// Parameter type for the event. /// - /// public class MessageReceivedEventArgs : EventArgs { /// @@ -28,4 +29,4 @@ public MessageReceivedEventArgs(MessageEvent message) Message = message; } } -} \ No newline at end of file +} diff --git a/src/LaunchDarkly.EventSource/StateChangedEventArgs.cs b/src/LaunchDarkly.EventSource/Background/StateChangedEventArgs.cs similarity index 82% rename from src/LaunchDarkly.EventSource/StateChangedEventArgs.cs rename to src/LaunchDarkly.EventSource/Background/StateChangedEventArgs.cs index 1f5a8b4..78d7e4f 100644 --- a/src/LaunchDarkly.EventSource/StateChangedEventArgs.cs +++ b/src/LaunchDarkly.EventSource/Background/StateChangedEventArgs.cs @@ -1,11 +1,11 @@ using System; -namespace LaunchDarkly.EventSource +namespace LaunchDarkly.EventSource.Background { /// - /// Provides data for the state of the connection. + /// Parameter type for the and + /// events. /// - /// public class StateChangedEventArgs : EventArgs { /// diff --git a/src/LaunchDarkly.EventSource/ByteArrayLineScanner.cs b/src/LaunchDarkly.EventSource/ByteArrayLineScanner.cs deleted file mode 100644 index 7906f29..0000000 --- a/src/LaunchDarkly.EventSource/ByteArrayLineScanner.cs +++ /dev/null @@ -1,179 +0,0 @@ -using System; -using System.IO; - -namespace LaunchDarkly.EventSource -{ - /// - /// Internal implementation of a buffered text line parser for UTF-8 data. - /// - /// - /// - /// This is used as follows. 1. The caller puts some data into the byte buffer. 2. The caller - /// calls ScanToEndOfLine; if it returns true, the lineOut parameter is set to point - /// to the content of the line (not including the line ending character(s)). If it returns - /// false, put more data into the buffer and try again. - /// - /// - /// Since in UTF-8 all multi-byte characters use values greater than 127 for all of their - /// bytes, this logic doesn't need to do any UTF-8 decoding or even know how many bytes are - /// in a character; it just looks for the line-ending sequences CR, LF, or CR+LF. - /// - /// - internal struct ByteArrayLineScanner - { - private readonly byte[] _buffer; - private readonly int _capacity; - private int _count; - private int _startPos; - - public byte[] Buffer => _buffer; - public int Capacity => _capacity; - public int Count => _count; - public int Available => _capacity - _count; - - private MemoryStream _partialLine; - - public ByteArrayLineScanner(int capacity) - { - _buffer = new byte[capacity]; - _capacity = capacity; - _count = 0; - _partialLine = null; - _startPos = 0; - } - - /// - /// The caller calls this method after having already added count more bytes at the - /// end of the buffer. We do it this way instead of having an AddBytes(byte[], int) - /// method because we don't want to allocate a second buffer just to be the destination for - /// a read operation. - /// - /// number of bytes added - public void AddedBytes(int count) - { - _count += count; - } - - /// - /// Searches for the next line ending and, if successful, provides the line data. - /// - /// if successful, this is set to point to the bytes for the line - /// not including any CR/LF; whenever possible this is a reference to the underlying - /// buffer, not a copy, so the caller should read/copy it before doing anything else to the - /// buffer - /// true if a full line was read, false if we need more data first - public bool ScanToEndOfLine(out Utf8ByteSpan lineOut) - { - if (_startPos == _count) - { - _startPos = _count = 0; - lineOut = new Utf8ByteSpan(); - return false; - } - - if (_startPos == 0 && _partialLine != null && _partialLine.Position > 0 - && _partialLine.GetBuffer()[_partialLine.Position - 1] == '\r') - { - // This is an edge case where the very last byte we previously saw was a CR, and we didn't know - // whether the next byte would be LF or not, but we had to dump the buffer into _partialLine - // because it was completely full. So, now we can return the line that's already in _partialLine, - // but if the first byte in the buffer is LF we should skip past it. - if (_buffer[_startPos] == '\n') - { - _startPos++; - } - lineOut = new Utf8ByteSpan(_partialLine.GetBuffer(), 0, - (int)_partialLine.Position - 1); // don't include the CR - _partialLine = null; - return true; - } - - int startedAt = _startPos, pos = _startPos; - - while (pos < _count) - { - var b = _buffer[pos]; - if (b == '\n') // LF by itself terminates a line - { - _startPos = pos + 1; // next line will start after the LF - break; - } - if (b == '\r') - { - if (pos < (_count - 1)) - { - _startPos = pos + 1; // next line will start after the CR-- - if (_buffer[pos + 1] == '\n') // --unless there was an LF right after that - { - _startPos++; - } - break; - } - else - { - // CR by itself and CR+LF are both valid line endings in SSE, so if the very - // last character we saw was a CR, we can't know when the line is fully read - // until we've gotten more data. So we'll need to treat this as an incomplete - // line. - pos++; - break; - } - } - pos++; - } - - if (pos == _count) // we didn't find a line terminator - { - lineOut = new Utf8ByteSpan(); - if (_count < _capacity) - { - // There's still room in the buffer, so we'll re-scan the line once they add more bytes - return false; - } - // We need to dump the incomplete line into _partialLine so we can make room in the buffer - var partialCount = pos - _startPos; - if (_partialLine is null) - { - _partialLine = new MemoryStream(partialCount); - } - _partialLine.Write(_buffer, _startPos, partialCount); - - // Clear the main buffer - _startPos = _count = 0; - - return false; - } - - if (_partialLine != null && _partialLine.Position > 0) - { - _partialLine.Write(_buffer, startedAt, pos - startedAt); - lineOut = new Utf8ByteSpan(_partialLine.GetBuffer(), 0, (int)_partialLine.Position); - _partialLine = null; - - // If there are still bytes in the main buffer, move them over to make more room. It's - // safe for us to do this before the caller has looked at lineOut, because lineOut is now - // a reference to the separate _partialLine buffer, not to the main buffer. - if (_startPos < _count) - { - System.Buffer.BlockCopy(_buffer, _startPos, _buffer, 0, _count - _startPos); - } - _count -= _startPos; - _startPos = 0; - } - else - { - lineOut = new Utf8ByteSpan(_buffer, startedAt, pos - startedAt); - if (_startPos == _count) - { - // If we've scanned all the data in the buffer, reset _startPos and _count to indicate - // that the entire buffer is available for the next read. It's safe for us to do this - // before the caller has looked at lineOut, because we're not actually modifying any - // bytes in the buffer. It's the caller's responsibility not to modify the buffer - // until it has already done whatever needs to be done with the lineOut data. - _startPos = _count = 0; - } - } - return true; - } - } -} diff --git a/src/LaunchDarkly.EventSource/Configuration.cs b/src/LaunchDarkly.EventSource/Configuration.cs index cbee65d..2c356ca 100644 --- a/src/LaunchDarkly.EventSource/Configuration.cs +++ b/src/LaunchDarkly.EventSource/Configuration.cs @@ -1,7 +1,4 @@ using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Text; using LaunchDarkly.Logging; namespace LaunchDarkly.EventSource @@ -21,36 +18,6 @@ public sealed class Configuration /// public static readonly TimeSpan DefaultInitialRetryDelay = TimeSpan.FromSeconds(1); - /// - /// The default value for : - /// 30 seconds. - /// - public static readonly TimeSpan DefaultMaxRetryDelay = TimeSpan.FromSeconds(30); - - /// - /// The default value for : - /// 10 seconds. - /// - public static readonly TimeSpan DefaultResponseStartTimeout = TimeSpan.FromSeconds(10); - - /// - /// Obsolete name for . - /// - [Obsolete("Use DefaultResponseStartTimeout")] - public static readonly TimeSpan DefaultConnectionTimeout = DefaultResponseStartTimeout; - - /// - /// The default value for : - /// 5 minutes. - /// - public static readonly TimeSpan DefaultReadTimeout = TimeSpan.FromMinutes(5); - - /// - /// The default value for : - /// one minute. - /// - public static readonly TimeSpan DefaultBackoffResetThreshold = TimeSpan.FromMinutes(1); - /// /// The logger name that will be used if you specified a logging implementation but did not /// provide a specific logger instance. @@ -58,40 +25,28 @@ public sealed class Configuration /// public const string DefaultLoggerName = "EventSource"; - #endregion - - #region Public Properties - /// - /// The amount of time a connection must stay open before the EventSource resets its backoff delay. + /// The default value for : + /// one minute. /// - /// - public TimeSpan BackoffResetThreshold { get; } + public static readonly TimeSpan DefaultRetryDelayResetThreshold = TimeSpan.FromMinutes(1); - /// - /// Obsolete name for . - /// - [Obsolete("Use ResponseStartTimeout")] - public TimeSpan ConnectionTimeout => ResponseStartTimeout; + #endregion - /// - /// The HttpClient that will be used as the HTTP client, or null for a new HttpClient. - /// - /// - public HttpClient HttpClient { get; } + #region Public Properties /// - /// The HttpMessageHandler that will be used for the HTTP client, or null for the default handler. + /// The configured connection strategy. /// - /// - public HttpMessageHandler HttpMessageHandler { get; } + /// + public ConnectStrategy ConnectStrategy { get; } /// - /// Delegate hook invoked before an HTTP request has been performed. + /// The configured error strategy. /// - /// - public Action HttpRequestModifier { get; } - + /// + public ErrorStrategy ErrorStrategy { get; } + /// /// The initial amount of time to wait before attempting to reconnect to the EventSource API. /// @@ -114,55 +69,16 @@ public sealed class Configuration public Logger Logger { get; } /// - /// The maximum amount of time to wait before attempting to reconnect. + /// The configured retry delay strategy. /// - /// - public TimeSpan MaxRetryDelay { get; } + /// + public RetryDelayStrategy RetryDelayStrategy { get; } /// - /// The HTTP method that will be used when connecting to the EventSource API. + /// The amount of time a connection must stay open before the EventSource resets its retry delay. /// - /// - public HttpMethod Method { get; } - - /// - /// Whether to use UTF-8 byte arrays internally if possible when reading the stream. - /// - /// - public bool PreferDataAsUtf8Bytes { get; } - - /// - /// The timeout when reading from the EventSource API. - /// - /// - public TimeSpan ReadTimeout { get; } - - /// - /// A factory for HTTP request body content, if the HTTP method is one that allows a request body. - /// is one that allows a request body. This is in the form of a factory function because the request - /// may need to be sent more than once. - /// - /// - public Func RequestBodyFactory { get; } - - /// - /// The request headers to be sent with each EventSource HTTP request. - /// - /// - /// - public IDictionary RequestHeaders { get; } - - /// - /// The maximum amount of time to wait between starting an HTTP request and receiving the response - /// headers. - /// - /// - public TimeSpan ResponseStartTimeout { get; } - - /// - /// Gets the used when connecting to an EventSource API. - /// - public Uri Uri { get; } + /// + public TimeSpan RetryDelayResetThreshold { get; } #endregion @@ -170,26 +86,16 @@ public sealed class Configuration internal Configuration(ConfigurationBuilder builder) { - Uri = builder._uri; - var logger = builder._logger ?? (builder._logAdapter is null ? null : builder._logAdapter.Logger(Configuration.DefaultLoggerName)); - BackoffResetThreshold = builder._backoffResetThreshold; - HttpClient = builder._httpClient; - HttpMessageHandler = (builder._httpClient != null) ? null : builder._httpMessageHandler; + ConnectStrategy = builder._connectStrategy; + ErrorStrategy = builder._errorStrategy ?? ErrorStrategy.AlwaysThrow; InitialRetryDelay = builder._initialRetryDelay; LastEventId = builder._lastEventId; Logger = logger ?? Logs.None.Logger(""); - MaxRetryDelay = builder._maxRetryDelay; - Method = builder._method; - PreferDataAsUtf8Bytes = builder._preferDataAsUtf8Bytes; - ReadTimeout = builder._readTimeout; - RequestHeaders = new Dictionary(builder._requestHeaders); - ResponseStartTimeout = builder._responseStartTimeout; - RequestBodyFactory = builder._requestBodyFactory; - HttpRequestModifier = builder._httpRequestModifier; - + RetryDelayStrategy = builder._retryDelayStrategy ?? RetryDelayStrategy.Default; + RetryDelayResetThreshold = builder._retryDelayResetThreshold; } #endregion @@ -199,11 +105,49 @@ internal Configuration(ConfigurationBuilder builder) /// /// Provides a new for constructing a configuration. /// + /// + /// Use this method if you do not need to configure any HTTP-related properties + /// besides the URI. To specify a custom HTTP configuration instead, use + /// with + /// and the configuration methods. + /// /// the EventSource URI /// a new builder instance /// if the URI is null + /// public static ConfigurationBuilder Builder(Uri uri) => - new ConfigurationBuilder(uri); + new ConfigurationBuilder(ConnectStrategy.Http(uri)); + + /// + /// Provides a new for constructing a configuration, + /// specifying how EventSource will connect to a stream. + /// + /// + /// + /// The will handle all details + /// of how to obtain an input stream for the EventSource to consume. By default, this is + /// , which makes HTTP requests. To customize the + /// HTTP behavior, you can use methods of : + /// + /// + /// var config = Configuration.Builder( + /// ConnectStrategy.Http(streamUri) + /// .Header("name", "value") + /// .ReadTimeout(TimeSpan.FromMinutes(1)) + /// ); + /// + /// + /// Or, if you want to consume an input stream from some other source, you can + /// create your own subclass of . + /// + /// + /// the object that will manage the input stream; + /// must not be null + /// a new builder instance + /// if the parameter is null + /// + public static ConfigurationBuilder Builder(ConnectStrategy connectStrategy) => + new ConfigurationBuilder(connectStrategy); #endregion } diff --git a/src/LaunchDarkly.EventSource/ConfigurationBuilder.cs b/src/LaunchDarkly.EventSource/ConfigurationBuilder.cs index 947b4d4..e2ed5bb 100644 --- a/src/LaunchDarkly.EventSource/ConfigurationBuilder.cs +++ b/src/LaunchDarkly.EventSource/ConfigurationBuilder.cs @@ -1,8 +1,8 @@ using System; -using System.Collections.Generic; using System.Net.Http; using System.Text; using System.Threading; +using LaunchDarkly.EventSource.Events; using LaunchDarkly.Logging; namespace LaunchDarkly.EventSource @@ -12,10 +12,10 @@ namespace LaunchDarkly.EventSource /// /// /// - /// Initialize a builder by calling new ConfigurationBuilder(uri) or - /// Configuration.Builder(uri). The URI is always required; all other properties - /// are set to defaults. Use the builder's setter methods to modify any desired properties; - /// setter methods can be chained. Then call Build() to construct the final immutable + /// Initialize a builder by calling one of the factory methods + /// such as . All properties are initially set to + /// defaults. Use the builder's setter methods to modify any desired properties; setter + /// methods can be chained. Then call Build() to construct the final immutable /// Configuration. /// /// @@ -27,33 +27,26 @@ public class ConfigurationBuilder { #region Private Fields - internal readonly Uri _uri; internal TimeSpan _initialRetryDelay = Configuration.DefaultInitialRetryDelay; - internal TimeSpan _backoffResetThreshold = Configuration.DefaultBackoffResetThreshold; + internal ConnectStrategy _connectStrategy; + internal ErrorStrategy _errorStrategy; internal string _lastEventId; internal ILogAdapter _logAdapter; internal Logger _logger; - internal IDictionary _requestHeaders = new Dictionary(); - internal HttpMessageHandler _httpMessageHandler; - internal HttpClient _httpClient; - internal TimeSpan _maxRetryDelay = Configuration.DefaultMaxRetryDelay; - internal HttpMethod _method = HttpMethod.Get; - internal bool _preferDataAsUtf8Bytes = false; - internal TimeSpan _readTimeout = Configuration.DefaultReadTimeout; - internal Func _requestBodyFactory; - internal TimeSpan _responseStartTimeout = Configuration.DefaultResponseStartTimeout; - internal Action _httpRequestModifier; + internal RetryDelayStrategy _retryDelayStrategy; + internal TimeSpan _retryDelayResetThreshold = Configuration.DefaultRetryDelayResetThreshold; + #endregion #region Constructor - internal ConfigurationBuilder(Uri uri) + internal ConfigurationBuilder(ConnectStrategy connectStrategy) { - if (uri == null) + if (connectStrategy is null) { - throw new ArgumentNullException(nameof(uri)); + throw new ArgumentNullException("connectStrategy"); } - this._uri = uri; + _connectStrategy = connectStrategy; } #endregion @@ -68,23 +61,22 @@ public Configuration Build() => new Configuration(this); /// - /// Obsolete name for . + /// Specifies a strategy for determining whether to handle errors transparently + /// or throw them as exceptions. /// - /// the timeout - /// the builder - [Obsolete("Use ResponseStartTimeout")] - public ConfigurationBuilder ConnectionTimeout(TimeSpan responseStartTimeout) => - ResponseStartTimeout(responseStartTimeout); - - /// - /// Sets a delegate hook invoked before an HTTP request is performed. This may be useful if you - /// want to modify some properties of the request that EventSource doesn't already have an option for. - /// - /// code that will be called with the request before it is sent + /// + /// By default, any failed connection attempt, or failure of an existing connection, + /// will be thrown as a {@link StreamException} when you try to use the stream. You + /// may instead use alternate + /// implementations, such as , + /// or a custom implementation, to allow EventSource to continue after an error. + /// + /// the object that will control error handling; + /// if null, defaults to /// the builder - public ConfigurationBuilder HttpRequestModifier(Action httpRequestModifier) + public ConfigurationBuilder ErrorStrategy(ErrorStrategy errorStrategy) { - this._httpRequestModifier = httpRequestModifier; + this._errorStrategy = errorStrategy ?? LaunchDarkly.EventSource.ErrorStrategy.AlwaysThrow; return this; } @@ -107,104 +99,13 @@ public ConfigurationBuilder HttpRequestModifier(Action httpR /// /// the initial retry delay /// the builder - /// + /// public ConfigurationBuilder InitialRetryDelay(TimeSpan initialRetryDelay) { _initialRetryDelay = FiniteTimeSpan(initialRetryDelay); return this; } - /// - /// Sets the maximum amount of time to wait before attempting to reconnect. - /// - /// - /// - /// EventSource uses an exponential backoff algorithm (with random jitter) so that - /// the delay between reconnections starts at but - /// increases with each subsequent attempt. MaxRetryDelay sets a limit on how long - /// the delay can be. - /// - /// - /// The default value is . Negative values - /// are changed to zero. - /// - /// - /// the maximum retry delay - /// the builder - /// - public ConfigurationBuilder MaxRetryDelay(TimeSpan maxRetryDelay) - { - _maxRetryDelay = FiniteTimeSpan(maxRetryDelay); - return this; - } - - /// - /// Sets the amount of time a connection must stay open before the EventSource resets its backoff delay. - /// - /// - /// - /// If a connection fails before the threshold has elapsed, the delay before reconnecting will be greater - /// than the last delay; if it fails after the threshold, the delay will start over at the initial minimum - /// value. This prevents long delays from occurring on connections that are only rarely restarted. - /// - /// - /// The default value is . Negative - /// values are changed to zero. - /// - /// - /// the threshold time - /// the builder - public ConfigurationBuilder BackoffResetThreshold(TimeSpan backoffResetThreshold) - { - _backoffResetThreshold = FiniteTimeSpan(backoffResetThreshold); - return this; - } - - /// - /// Sets the maximum amount of time EventSource will wait between starting an HTTP request and - /// receiving the response headers. - /// - /// - /// - /// This is the same as the Timeout property in .NET's HttpClient. The default value is - /// . - /// - /// - /// It is not the same as a TCP connection timeout. A connection timeout would include only the - /// time of establishing the connection, not the time it takes for the server to prepare the beginning - /// of the response. .NET does not consistently support a connection timeout, but if you are using .NET - /// Core or .NET 5+ you can implement it by using SocketsHttpHandler as your - /// and setting the - /// ConnectTimeout property there. - /// - /// - /// the timeout - /// - public ConfigurationBuilder ResponseStartTimeout(TimeSpan responseStartTimeout) - { - _responseStartTimeout = TimeSpanCanBeInfinite(responseStartTimeout); - return this; - } - - /// - /// Sets the timeout when reading from the EventSource API. - /// - /// - /// - /// The connection will be automatically dropped and restarted if the server sends no data within - /// this interval. This prevents keeping a stale connection that may no longer be working. It is common - /// for SSE servers to send a simple comment line (":") as a heartbeat to prevent timeouts. - /// - /// - /// The default value is . - /// - /// - public ConfigurationBuilder ReadTimeout(TimeSpan readTimeout) - { - _readTimeout = TimeSpanCanBeInfinite(readTimeout); - return this; - } - /// /// Sets the last event identifier. /// @@ -296,144 +197,56 @@ public ConfigurationBuilder Logger(Logger logger) } /// - /// Specifies whether to use UTF-8 byte arrays internally if possible when - /// reading the stream. - /// - /// - /// As described in , in some applications it may be - /// preferable to store and process event data as UTF-8 byte arrays rather than - /// strings. By default, EventSource will use the string type when - /// processing the event stream; if you then use - /// to get the data, it will be converted to a byte array as needed. However, if - /// you set PreferDataAsUtf8Bytes to , the event data - /// will be stored internally as a UTF-8 byte array so that if you read - /// , you will get the same array with no - /// extra copying or conversion. Therefore, for greatest efficiency you should set - /// this to if you intend to process the data as UTF-8. Note - /// that Server-Sent Event streams always use UTF-8 encoding, as required by the - /// SSE specification. - /// - /// true if you intend to request the event - /// data as UTF-8 bytes - /// the builder - public ConfigurationBuilder PreferDataAsUtf8Bytes(bool preferDataAsUtf8Bytes) - { - _preferDataAsUtf8Bytes = preferDataAsUtf8Bytes; - return this; - } - - /// - /// Sets the request headers to be sent with each EventSource HTTP request. - /// - /// the headers (null is equivalent to an empty dictionary) - /// the builder - public ConfigurationBuilder RequestHeaders(IDictionary headers) - { - _requestHeaders = headers is null ? new Dictionary() : - new Dictionary(headers); - return this; - } - - /// - /// Adds a request header to be sent with each EventSource HTTP request. - /// - /// the header name - /// the header value - /// the builder - public ConfigurationBuilder RequestHeader(string name, string value) - { - if (name != null) - { - _requestHeaders[name] = value; - } - return this; - } - - /// - /// Sets the HttpMessageHandler that will be used for the HTTP client, or null for the default handler. + /// Specifies a strategy for determining the retry delay after an error. /// /// - /// If you have specified a custom HTTP client instance with , then - /// is ignored. + /// Whenever EventSource tries to start a new connection after a stream failure, + /// it delays for an amount of time that is determined by two parameters: the + /// base retry delay (), and the retry + /// delay strategy which transforms the base retry delay in some way. The default + /// behavior is to apply an exponential backoff and jitter. You may instead use a + /// modified version of to customize the + /// backoff and jitter, or a custom implementation with any other logic. /// - /// the message handler implementation + /// the object that will control retry delays; if + /// null, defaults to /// the builder - public ConfigurationBuilder HttpMessageHandler(HttpMessageHandler handler) + public ConfigurationBuilder RetryDelayStrategy(RetryDelayStrategy retryDelayStrategy) { - this._httpMessageHandler = handler; + _retryDelayStrategy = retryDelayStrategy; return this; } /// - /// Specifies that EventSource should use a specific HttpClient instance for HTTP requests. + /// Sets the amount of time a connection must stay open before the EventSource + /// resets its delay strategy. /// /// - /// - /// Normally, EventSource creates its own HttpClient and disposes of it when you dispose of the - /// EventSource. If you provide your own HttpClient using this method, you are responsible for - /// managing the HttpClient's lifecycle-- EventSource will not dispose of it. - /// - /// - /// EventSource will not modify this client's properties, so if you call - /// or , those methods will be ignored. - /// + /// When using the default strategy (see ), this means + /// that the delay before each reconnect attempt will be greater than the last delay + /// unless the current connection lasted longer than the threshold, in which case the + /// delay will start over at the initial minimum value. This prevents long delays from + /// occurring on connections that are only rarely restarted. /// - /// an HttpClient instance, or null to use the default behavior + /// the threshold time /// the builder - public ConfigurationBuilder HttpClient(HttpClient client) - { - this._httpClient = client; - return this; - } - - /// - /// Sets the HTTP method that will be used when connecting to the EventSource API. - /// - /// - /// By default, this is . - /// - public ConfigurationBuilder Method(HttpMethod method) + /// + public ConfigurationBuilder RetryDelayResetThreshold(TimeSpan retryDelayResetThreshold) { - this._method = method ?? throw new ArgumentNullException(nameof(method)); + _retryDelayResetThreshold = FiniteTimeSpan(retryDelayResetThreshold); return this; } - /// - /// Sets a factory for HTTP request body content, if the HTTP method is one that allows a request body. - /// - /// - /// This is in the form of a factory function because the request may need to be sent more than once. - /// - /// the factory function, or null for none - /// the builder - public ConfigurationBuilder RequestBodyFactory(Func factory) - { - this._requestBodyFactory = factory; - return this; - } - - /// - /// Equivalent , but for content - /// that is a simple string. - /// - /// the content - /// the Content-Type header - /// the builder - public ConfigurationBuilder RequestBody(string bodyString, string contentType) - { - return RequestBodyFactory(() => new StringContent(bodyString, Encoding.UTF8, contentType)); - } - #endregion #region Private methods // Canonicalizes the value so all negative numbers become InfiniteTimeSpan - private static TimeSpan TimeSpanCanBeInfinite(TimeSpan t) => + internal static TimeSpan TimeSpanCanBeInfinite(TimeSpan t) => t < TimeSpan.Zero ? Timeout.InfiniteTimeSpan : t; // Replaces all negative times with zero - private static TimeSpan FiniteTimeSpan(TimeSpan t) => + internal static TimeSpan FiniteTimeSpan(TimeSpan t) => t < TimeSpan.Zero ? TimeSpan.Zero : t; #endregion diff --git a/src/LaunchDarkly.EventSource/ConnectStrategy.cs b/src/LaunchDarkly.EventSource/ConnectStrategy.cs new file mode 100644 index 0000000..f78605b --- /dev/null +++ b/src/LaunchDarkly.EventSource/ConnectStrategy.cs @@ -0,0 +1,146 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.EventSource.Events; +using LaunchDarkly.EventSource.Exceptions; +using LaunchDarkly.Logging; + +using static System.Net.WebRequestMethods; + +namespace LaunchDarkly.EventSource +{ + /// + /// An abstraction of how EventSource should obtain an input stream. + /// + /// + /// + /// The default implementation is , which makes HTTP + /// requests. To customize the HTTP behavior, you can use methods of : + /// + /// + /// + /// > + /// + /// Or, if you want to consume an input stream from some other source, you can + /// create your own subclass of ConnectStrategy. + /// + /// + /// Instances of this class should be immutable and not contain any state that + /// is specific to one active stream.The {@link ConnectStrategy.Client} + /// that they produce is stateful and belongs to a single EventSource. + /// + /// + public abstract class ConnectStrategy + { + /// + /// The origin URI that should be included in every . + /// + /// + /// The SSE specification dictates that every message should have an origin + /// property representing the stream it came from. In the default HTTP + /// implementation, this is simply the stream URI; other implementations of + /// ConnectStrategy can set it to whatever they want. + /// + public abstract Uri Origin { get; } + + /// + /// Creates a client instance. + /// + /// + /// This is called once when an EventSource is created. The EventSource + /// retains the returned Client and uses it to perform all subsequent + /// connection attempts. + /// + /// the logger belonging to EventSource + /// a + public abstract Client CreateClient(Logger logger); + + /// + /// An object provided by that is retained by a + /// single EventSource instance to perform all connection attempts by that instance. + /// + public abstract class Client : IDisposable + { + /// + /// The parameter type of . + /// + public struct Params + { + /// + /// A CancellationToken to be used when making a connection. + /// + public CancellationToken CancellationToken { get; set; } + + /// + /// The current value of . This should + /// be sent to the server to support resuming an interrupted stream. + /// + public string LastEventId { get; set; } + } + + /// + /// The return type of . + /// + public struct Result + { + /// + /// The input stream that EventSource should read from. + /// + public Stream Stream { get; set; } + + /// + /// If non-null, indicates that EventSource should impose its own + /// timeout on reading the stream. + /// + /// + /// Due to platform limitations, it may not be possible to implement a + /// read timeout within the stream itself. Returning a non-null value + /// here tells EventSource to add its own timeout logic using a + /// CancellationToken. + /// + public TimeSpan? ReadTimeout { get; set; } + + /// + /// An object that EventSource can use to close the connection. + /// + /// + /// If this is not null, its method will be + /// called whenever the current connection is stopped either due to an + /// error or because the caller explicitly closed the stream. + /// + public IDisposable Closer { get; set; } + } + + /// + /// Attempts to connect to a stream. + /// + /// parameters for this connection attempt + /// the result if successful + /// if the connection fails + /// if there is some other type of error, + /// such as an invalid HTTP status + public abstract Task ConnectAsync(Params parameters); + + /// + public abstract void Dispose(); + } + + /// + /// Returns the default HTTP implementation, specifying a stream URI. + /// + /// + /// + /// To specify custom HTTP behavior, call methods + /// on the returned object to obtain a modified instance: + /// + /// + /// + /// > + /// + /// the stream URI + /// a configurable + public static HttpConnectStrategy Http(Uri uri) => new HttpConnectStrategy(uri); + } +} + diff --git a/src/LaunchDarkly.EventSource/DefaultRetryDelayStrategy.cs b/src/LaunchDarkly.EventSource/DefaultRetryDelayStrategy.cs new file mode 100644 index 0000000..b2ff73a --- /dev/null +++ b/src/LaunchDarkly.EventSource/DefaultRetryDelayStrategy.cs @@ -0,0 +1,163 @@ +using System; + +namespace LaunchDarkly.EventSource +{ + /// + /// Default implementation of the retry delay strategy, providing exponential + /// backoff and jitter. + /// + /// + /// + /// The algorithm is as follows: + /// + /// + /// + /// Start with the configured base delay as set by + /// . + /// + /// + /// On each subsequent attempt, multiply the base delay by the backoff multiplier, + /// giving the current base delay. + /// + /// + /// If there is a maximum delay, pin the current base delay to be no greater than + /// the maximum. + /// + /// + /// If the jitter multiplier is non-zero, the actual delay for each attempt is + /// equal to the current base delay minus a pseudo-random number equal to that + /// ratio times the current base delay. For instance, a jitter multiplier of + /// 0.25 would mean that a base delay of 1000ms is changed to a value in the range + /// [750ms, 1000ms]. + /// + /// + /// + /// This class is immutable. returns the + /// default instance. To change any parameters, call methods which return a modified + /// instance: + /// + /// + /// + /// + /// + public class DefaultRetryDelayStrategy : RetryDelayStrategy + { + /// + /// The default maximum delay: 30 seconds. + /// + public static readonly TimeSpan DefaultMaxDelay = TimeSpan.FromSeconds(30); + + /// + /// The default backoff multiplier: 2. + /// + public static readonly float DefaultBackoffMultiplier = 2; + + /// + /// The default jitter multiplier: 0.5. + /// + public static readonly float DefaultJitterMultiplier = 0.5f; + + internal static readonly DefaultRetryDelayStrategy Instance = + new DefaultRetryDelayStrategy(null, DefaultMaxDelay, + DefaultBackoffMultiplier, DefaultJitterMultiplier); + private static readonly Random _random = new Random(); + + private readonly TimeSpan? _lastBaseDelay; + private readonly TimeSpan? _maxDelay; + private readonly float _backoffMultiplier; + private readonly float _jitterMultiplier; + + private DefaultRetryDelayStrategy( + TimeSpan? lastBaseDelay, + TimeSpan? maxDelay, + float backoffMultiplier, + float jitterMultiplier + ) + { + _lastBaseDelay = lastBaseDelay; + _maxDelay = maxDelay; + _backoffMultiplier = backoffMultiplier == 0 ? 1 : backoffMultiplier; + _jitterMultiplier = jitterMultiplier; + } + + /// + /// Returns a modified strategy with a specific maximum delay, or with no maximum. + /// + /// the new maximum delay, if any + /// a new instance with the specified maximum delay + /// + public DefaultRetryDelayStrategy MaxDelay(TimeSpan? newMaxDelay) => + new DefaultRetryDelayStrategy( + _lastBaseDelay, + newMaxDelay, + _backoffMultiplier, + _jitterMultiplier + ); + + /// + /// Returns a modified strategy with a specific backoff multiplier. A multiplier of + /// 1 means the base delay never changes, 2 means it doubles each time, etc. A + /// value of zero is treated the same as 1. + /// + /// the new backoff multiplier + /// a new instance with the specified backoff multiplier + /// + public DefaultRetryDelayStrategy BackoffMultiplier(float newBackoffMultiplier) => + new DefaultRetryDelayStrategy( + _lastBaseDelay, + _maxDelay, + newBackoffMultiplier, + _jitterMultiplier + ); + + /// + /// Returns a modified strategy with a specific jitter multiplier. A multiplier of + /// 0.5 means each delay is reduced randomly by up to 50%, 0.25 means it is reduced + /// randomly by up to 25%, etc. Zero means there is no jitter. + /// + /// the new jitter multiplier + /// a new instance with the specified jitter multiplier + /// + public DefaultRetryDelayStrategy JitterMultiplier(float newJitterMultiplier) => + new DefaultRetryDelayStrategy( + _lastBaseDelay, + _maxDelay, + _backoffMultiplier, + newJitterMultiplier + ); + + /// + /// Called by EventSource to compute the next retry delay. + /// + /// the current base delay + /// the result + public override Result Apply(TimeSpan baseRetryDelay) + { + TimeSpan nextBaseDelay = _lastBaseDelay.HasValue ? + TimeSpan.FromTicks((long)(_lastBaseDelay.Value.Ticks * _backoffMultiplier)) : + baseRetryDelay; + if (_maxDelay.HasValue && nextBaseDelay > _maxDelay.Value) + { + nextBaseDelay = _maxDelay.Value; + } + var adjustedDelay = nextBaseDelay; + if (_jitterMultiplier > 0) + { + // 2^31 milliseconds is much longer than any reconnect time we would reasonably want to use, so we can pin this to int + int maxTimeInt = nextBaseDelay.TotalMilliseconds > int.MaxValue ? int.MaxValue : (int)nextBaseDelay.TotalMilliseconds; + int jitterRange = (int)(maxTimeInt * _jitterMultiplier); + if (jitterRange != 0) + { + lock (_random) + { + adjustedDelay -= TimeSpan.FromMilliseconds(_random.Next(jitterRange)); + } + } + } + RetryDelayStrategy updatedStrategy = + new DefaultRetryDelayStrategy(nextBaseDelay, _maxDelay, _backoffMultiplier, _jitterMultiplier); + return new Result { Delay = adjustedDelay, Next = updatedStrategy }; + } + } +} + diff --git a/src/LaunchDarkly.EventSource/ErrorStrategy.cs b/src/LaunchDarkly.EventSource/ErrorStrategy.cs new file mode 100644 index 0000000..62cdc10 --- /dev/null +++ b/src/LaunchDarkly.EventSource/ErrorStrategy.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using LaunchDarkly.EventSource.Events; +using LaunchDarkly.EventSource.Exceptions; + +namespace LaunchDarkly.EventSource +{ + /// + /// An abstraction of how to determine whether a stream failure should be thrown to the + /// caller as an exception, or treated as an event. + /// + /// + /// Instances of this class should be immutable. + /// + /// + public abstract class ErrorStrategy + { + /// + /// Specifies that EventSource should always throw an exception if there is an error. + /// This is the default behavior if you do not configure another. + /// + public static ErrorStrategy AlwaysThrow { get; } = new Invariant(Action.Throw); + + /// + /// Specifies that EventSource should never throw an exception, but should return all + /// errors as s. Be aware that using this mode could cause + /// to block indefinitely if connections never succeed. + /// + public static ErrorStrategy AlwaysContinue { get; } = new Invariant(Action.Continue); + + /// + /// Describes the possible actions EventSource could take after an error. + /// + public enum Action + { + /// + /// Indicates that EventSource should throw an exception from whatever reading + /// method was called (, + /// , etc.). + /// + Throw, + + /// + /// Indicates that EventSource should not throw an exception, but instead return a + /// to the caller. If the caller continues to read from the + /// failed stream after that point, EventSource will try to reconnect to the stream. + /// + Continue + } + + /// + /// The return type of . + /// + public struct Result + { + /// + /// The action that EventSource should take. + /// + public Action Action { get; set; } + + /// + /// The strategy instance to be used for the next retry, or null to use the + /// same instance as last time. + /// + public ErrorStrategy Next { get; set; } + } + + /// + /// Applies the strategy to determine whether to retry after a failure. + /// + /// describes the failure + /// the result + public abstract Result Apply(Exception exception); + + /// + /// Specifies that EventSource should automatically retry after a failure for up to this + /// number of consecutive attempts, but should throw an exception after that point. + /// + /// the maximum number of consecutive retries + /// a strategy to be passed to + /// + public static ErrorStrategy ContinueWithMaxAttempts(int maxAttempts) => + new MaxAttemptsImpl(maxAttempts, 0); + + /// + /// Specifies that EventSource should automatically retry after a failure and can retry + /// repeatedly until this amount of time has elapsed, but should throw an exception after + /// that point. + /// + /// the time limit + /// a strategy to be passed to + /// + public static ErrorStrategy ContinueWithTimeLimit(TimeSpan maxTime) => + new TimeLimitImpl(maxTime, null); + + internal class Invariant : ErrorStrategy + { + private readonly Action _action; + + internal Invariant(Action action) { _action = action; } + + public override Result Apply(Exception _) => + new Result { Action = _action }; + } + + internal class MaxAttemptsImpl : ErrorStrategy + { + private readonly int _maxAttempts; + private readonly int _counter; + + internal MaxAttemptsImpl(int maxAttempts, int counter) + { + _maxAttempts = maxAttempts; + _counter = counter; + } + + public override Result Apply(Exception _) => + _counter < _maxAttempts ? + new Result { Action = Action.Continue, Next = new MaxAttemptsImpl(_maxAttempts, _counter + 1) } : + new Result { Action = Action.Throw }; + } + + internal class TimeLimitImpl : ErrorStrategy + { + private readonly TimeSpan _maxTime; + private readonly DateTime? _startTime; + + internal TimeLimitImpl(TimeSpan maxTime, DateTime? startTime) + { + _maxTime = maxTime; + _startTime = startTime; + } + + public override Result Apply(Exception _) + { + if (!_startTime.HasValue) + { + return new Result + { + Action = Action.Continue, + Next = new TimeLimitImpl(_maxTime, DateTime.Now) + }; + } + return new Result + { + Action = _startTime.Value.Add(_maxTime) > DateTime.Now ? + Action.Continue : Action.Throw + }; + } + } + } +} diff --git a/src/LaunchDarkly.EventSource/EventParser.cs b/src/LaunchDarkly.EventSource/EventParser.cs deleted file mode 100644 index 1a7082b..0000000 --- a/src/LaunchDarkly.EventSource/EventParser.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Text.RegularExpressions; - -namespace LaunchDarkly.EventSource -{ - /// - /// An internal class containing helper methods to parse Server Sent Event data. - /// - internal static class EventParser - { - /// - /// The result returned by or . - /// - internal struct Result - { - internal string FieldName { get; set; } // null means it's a comment - internal string ValueString { get; set; } - internal Utf8ByteSpan ValueBytes { get; set; } - - internal string GetValueAsString() => ValueString is null ? ValueBytes.GetString() : ValueString; - - internal bool IsComment => FieldName is null; - internal bool IsDataField => FieldName == "data"; - internal bool IsEventField => FieldName == "event"; - internal bool IsIdField => FieldName == "id"; - internal bool IsRetryField => FieldName == "retry"; - } - - /// - /// Attempts to parse a single non-empty line of SSE content that was read as a string. Empty lines - /// should not be not passed to this method. - /// - /// a line that was read from the stream, not including any trailing CR/LF - /// a containing the parsed field or comment; ValueString will - /// be set rather than ValueBytes - internal static Result ParseLineString(string line) - { - var colonPos = line.IndexOf(':'); - if (colonPos == 0) // comment - { - return new Result { ValueString = line }; - } - if (colonPos < 0) // field name without a value - assume empty value - { - return new Result { FieldName = line, ValueString = "" }; - } - int valuePos = colonPos + 1; - if (valuePos < line.Length && line[valuePos] == ' ') - { - valuePos++; // trim a single leading space from the value, if present - } - return new Result - { - FieldName = line.Substring(0, colonPos), - ValueString = line.Substring(valuePos) - }; - } - - /// - /// Attempts to parse a single non-empty line of SSE content that was read as UTF-8 bytes. Empty lines - /// should not be not passed to this method. - /// - /// a line that was read from the stream, not including any trailing CR/LF - /// a containing the parsed field or comment; ValueBytes - /// will be set rather than ValueString - public static Result ParseLineUtf8Bytes(Utf8ByteSpan line) - { - if (line.Length > 0 && line.Data[line.Offset] == ':') // comment - { - return new Result { ValueBytes = line }; - } - int colonPos = 0; - for (; colonPos < line.Length && line.Data[line.Offset + colonPos] != ':'; colonPos++) { } - string fieldName = Encoding.UTF8.GetString(line.Data, line.Offset, colonPos); - if (colonPos == line.Length) // field name without a value - assume empty value - { - return new Result { - FieldName = fieldName, - ValueBytes = new Utf8ByteSpan() - }; - } - int valuePos = colonPos + 1; - if (valuePos < line.Length && line.Data[line.Offset + valuePos] == ' ') - { - valuePos++; // trim a single leading space from the value, if present - } - return new Result - { - FieldName = fieldName, - ValueBytes = new Utf8ByteSpan(line.Data, line.Offset + valuePos, line.Length - valuePos) - }; - } - } -} diff --git a/src/LaunchDarkly.EventSource/EventSource.cs b/src/LaunchDarkly.EventSource/EventSource.cs index 34204cd..c0aba90 100644 --- a/src/LaunchDarkly.EventSource/EventSource.cs +++ b/src/LaunchDarkly.EventSource/EventSource.cs @@ -1,9 +1,9 @@ using System; -using System.Collections.Generic; -using System.IO; -using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using LaunchDarkly.EventSource.Events; +using LaunchDarkly.EventSource.Exceptions; +using LaunchDarkly.EventSource.Internal; using LaunchDarkly.Logging; namespace LaunchDarkly.EventSource @@ -17,66 +17,51 @@ public class EventSource : IEventSource, IDisposable #region Private Fields private readonly Configuration _configuration; - private readonly HttpClient _httpClient; private readonly Logger _logger; - - private List _eventDataStringBuffer; - private MemoryStream _eventDataUtf8ByteBuffer; - private string _eventName; - private string _lastEventId; - private TimeSpan _retryDelay; - private readonly ExponentialBackoffWithDecorrelation _backOff; - private CancellationTokenSource _currentRequestToken; - private DateTime? _lastSuccessfulConnectionTime; - private ReadyState _readyState; + private readonly ConnectStrategy.Client _client; + private readonly ErrorStrategy _baseErrorStrategy; + private readonly RetryDelayStrategy _baseRetryDelayStrategy; + private readonly TimeSpan _retryDelayResetThreshold; + private readonly Uri _origin; + private readonly object _lock = new object(); + + private readonly ValueWithLock _readyState; + private readonly ValueWithLock _baseRetryDelay; + private readonly ValueWithLock _nextRetryDelay; + private readonly ValueWithLock _connectedTime; + private readonly ValueWithLock _disconnectedTime; + private readonly ValueWithLock _cancellationToken; + private volatile CancellationTokenSource _cancellationTokenSource; + private volatile IDisposable _request; + + private EventParser _parser; + private ErrorStrategy _currentErrorStrategy; + private RetryDelayStrategy _currentRetryDelayStrategy; + private volatile string _lastEventId; + private volatile bool _deliberatelyClosedConnection; #endregion - #region Public Events + + #region Public Properties /// - public event EventHandler Opened; + public ReadyState ReadyState => _readyState.Get(); /// - public event EventHandler Closed; + public TimeSpan BaseRetryDelay => _baseRetryDelay.Get(); /// - public event EventHandler MessageReceived; + public string LastEventId => _lastEventId; /// - public event EventHandler CommentReceived; + public TimeSpan? NextRetryDelay => _nextRetryDelay.Get(); /// - public event EventHandler Error; - - #endregion Public Events - - #region Public Properties + public Uri Origin => _origin; /// - public ReadyState ReadyState - { - get - { - lock (this) - { - return _readyState; - } - } - private set - { - lock (this) - { - _readyState = value; - } - } - } - - internal TimeSpan BackOffDelay - { - get; - private set; - } + public Logger Logger => _logger; #endregion @@ -88,17 +73,25 @@ internal TimeSpan BackOffDelay /// the configuration public EventSource(Configuration configuration) { - _readyState = ReadyState.Raw; - + _readyState = new ValueWithLock(_lock, ReadyState.Raw); _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); _logger = _configuration.Logger; - _retryDelay = _configuration.InitialRetryDelay; + _client = _configuration.ConnectStrategy.CreateClient(_logger); + _origin = _configuration.ConnectStrategy.Origin; - _backOff = new ExponentialBackoffWithDecorrelation(_retryDelay, _configuration.MaxRetryDelay); + _baseErrorStrategy = _currentErrorStrategy = _configuration.ErrorStrategy; + _baseRetryDelayStrategy = _currentRetryDelayStrategy = _configuration.RetryDelayStrategy; + _retryDelayResetThreshold = _configuration.RetryDelayResetThreshold; + _baseRetryDelay = new ValueWithLock(_lock, _configuration.InitialRetryDelay); + _nextRetryDelay = new ValueWithLock(_lock, null); + _lastEventId = _configuration.LastEventId; - _httpClient = _configuration.HttpClient ?? CreateHttpClient(); + _connectedTime = new ValueWithLock(_lock, null); + _disconnectedTime = new ValueWithLock(_lock, null); + + _cancellationToken = new ValueWithLock(_lock, null); } /// @@ -116,357 +109,273 @@ public EventSource(Uri uri) : this(Configuration.Builder(uri).Build()) {} /// public async Task StartAsync() { - bool firstTime = true; - while (ReadyState != ReadyState.Shutdown) + await TryStartAsync(false); + } + + /// + public async Task ReadMessageAsync() + { + while (true) { - if (!firstTime) + IEvent e = await ReadAnyEventAsync(); + if (e is MessageEvent m) { - if (_lastSuccessfulConnectionTime.HasValue) - { - if (DateTime.Now.Subtract(_lastSuccessfulConnectionTime.Value) >= _configuration.BackoffResetThreshold) - { - _backOff.ResetReconnectAttemptCount(); - } - _lastSuccessfulConnectionTime = null; - } - await MaybeWaitWithBackOff(); + return m; } - firstTime = false; + } + } - CancellationTokenSource newRequestTokenSource = null; - CancellationToken cancellationToken; - lock (this) + /// + public async Task ReadAnyEventAsync() + { + try + { + while (true) { - if (_readyState == ReadyState.Shutdown) + // Reading an event implies starting the stream if it isn't already started. + // We might also be restarting since we could have been interrupted at any time. + if (_parser is null) { - // in case Close() was called in between the previous ReadyState check and the creation of the new token - return; + var fault = await TryStartAsync(true); + return (IEvent)fault ?? (IEvent)(new StartedEvent()); } - newRequestTokenSource = new CancellationTokenSource(); - _currentRequestToken?.Dispose(); - _currentRequestToken = newRequestTokenSource; - } - - if (ReadyState == ReadyState.Connecting || ReadyState == ReadyState.Open) - { - throw new InvalidOperationException(string.Format(Resources.ErrorAlreadyStarted, ReadyState)); - } - - SetReadyState(ReadyState.Connecting); - cancellationToken = newRequestTokenSource.Token; - - try - { - await ConnectToEventSourceAsync(cancellationToken); - - // ConnectToEventSourceAsync normally doesn't return, unless it detects that the request has been cancelled. - Close(ReadyState.Closed); - } - catch (Exception e) - { - CancelCurrentRequest(); - - // If the user called Close(), ReadyState = Shutdown, so errors are irrelevant. - if (ReadyState != ReadyState.Shutdown) + var e = await _parser.NextEventAsync(); + if (e is SetRetryDelayEvent srde) { - Exception realException = e; - if (realException is AggregateException ae && ae.InnerExceptions.Count == 1) - { - realException = ae.InnerException; - } - if (realException is OperationCanceledException oe) - { - // This exception could either be the result of us explicitly cancelling a request, in which case we don't - // need to do anything else, or it could be that the request timed out. - if (oe.CancellationToken.IsCancellationRequested) - { - realException = null; - } - else - { - realException = new TimeoutException(); - } - } - - if (realException != null) + // SetRetryDelayEvent means the stream contained a "retry:" line. We don't + // surface this to the caller, we just apply the new delay and move on. + _baseRetryDelay.Set(srde.RetryDelay); + _currentRetryDelayStrategy = _baseRetryDelayStrategy; + continue; + } + if (e is MessageEvent me) + { + if (me.LastEventId != null) { - OnError(new ExceptionEventArgs(realException)); + _lastEventId = me.LastEventId; } - Close(ReadyState.Closed); } + return e; } } - } - - private async Task MaybeWaitWithBackOff() { - if (_retryDelay.TotalMilliseconds > 0) + catch (Exception ex) { - TimeSpan sleepTime = _backOff.GetNextBackOff(); - if (sleepTime.TotalMilliseconds > 0) { - _logger.Info("Waiting {0} milliseconds before reconnecting...", sleepTime.TotalMilliseconds); - BackOffDelay = sleepTime; - await Task.Delay(sleepTime); + if (_deliberatelyClosedConnection) + { + // If the stream was explicitly closed from another thread, that'll likely show up as + // an I/O error or an OperationCanceledException, but we don't want to report it as one. + ex = new StreamClosedByCallerException(); + _deliberatelyClosedConnection = false; } - } - } - - /// - public void Restart(bool resetBackoffDelay) - { - lock (this) - { - if (_readyState != ReadyState.Open) + else { - return; + _logger.Debug("Encountered exception: {0}", LogValues.ExceptionSummary(ex)); } - if (resetBackoffDelay) + _disconnectedTime.Set(DateTime.Now); + CloseCurrentStream(false, false); + _parser = null; + ComputeRetryDelay(); + if (ApplyErrorStrategy(ex) == ErrorStrategy.Action.Continue) { - _backOff.ResetReconnectAttemptCount(); + return new FaultEvent(ex); } + throw ex; } - CancelCurrentRequest(); } + /// + public void Interrupt() => + CloseCurrentStream(true, false); + /// - /// Closes the connection to the SSE server. The EventSource cannot be reopened after this. + /// Closes the connection to the SSE server. The EventSource cannot be reopened after this. /// public void Close() { - if (ReadyState != ReadyState.Raw && ReadyState != ReadyState.Shutdown) - { - Close(ReadyState.Shutdown); - } - CancelCurrentRequest(); - - // do not dispose httpClient if it is user provided - if (_configuration.HttpClient == null) + if (_readyState.GetAndSet(ReadyState.Shutdown) == ReadyState.Shutdown) { - _httpClient.Dispose(); + return; } + CloseCurrentStream(true, true); + _client?.Dispose(); } /// /// Equivalent to calling . /// - public void Dispose() - { + public void Dispose() => Dispose(true); - } - - private void Dispose(bool disposing) - { - if (disposing) - { - Close(); - } - } #endregion #region Private Methods - private HttpClient CreateHttpClient() - { - var client =_configuration.HttpMessageHandler is null ? - new HttpClient() : - new HttpClient(_configuration.HttpMessageHandler, false); - client.Timeout = _configuration.ResponseStartTimeout; - return client; - } - - private void CancelCurrentRequest() + private void Dispose(bool disposing) { - CancellationTokenSource requestTokenSource = null; - lock (this) - { - requestTokenSource = _currentRequestToken; - _currentRequestToken = null; - } - if (requestTokenSource != null) + if (disposing) { - _logger.Debug("Cancelling current request"); - requestTokenSource.Cancel(); - requestTokenSource.Dispose(); + Close(); } } - internal virtual EventSourceService GetEventSourceService(Configuration configuration) + private async Task TryStartAsync(bool canReturnFaultEvent) { - return new EventSourceService(configuration, _httpClient, _logger); - } - - private async Task ConnectToEventSourceAsync(CancellationToken cancellationToken) - { - _eventDataStringBuffer = null; - _eventDataUtf8ByteBuffer = null; - - var svc = GetEventSourceService(_configuration); - - svc.ConnectionOpened += (o, e) => { - _lastSuccessfulConnectionTime = DateTime.Now; - SetReadyState(ReadyState.Open, OnOpened); - }; - svc.ConnectionClosed += (o, e) => { SetReadyState(ReadyState.Closed, OnClosed); }; - - await svc.GetDataAsync( - ProcessResponseLineString, - ProcessResponseLineUtf8, - _lastEventId, - cancellationToken - ); - } - - private void Close(ReadyState state) - { - _logger.Debug("Close({0}) - state was {1}", state, ReadyState); - SetReadyState(state, OnClosed); - } - - private void ProcessResponseLineString(string content) - { - if (content == null) - { - // StreamReader may emit a null if the stream has been closed; there's nothing to - // be done at this level in that case - return; - } - if (content.Length == 0) + if (_parser != null) { - DispatchEvent(); + return null; } - else - { - HandleParsedLine(EventParser.ParseLineString(content)); - } - } - - private void ProcessResponseLineUtf8(Utf8ByteSpan content) - { - if (content.Length == 0) + if (ReadyState == ReadyState.Shutdown) { - DispatchEvent(); + throw new StreamClosedByCallerException(); } - else + while (true) { - HandleParsedLine(EventParser.ParseLineUtf8Bytes(content)); - } - } - + StreamException exception = null; - private void SetReadyState(ReadyState state, Action action = null) - { - lock (this) - { - if (_readyState == state || _readyState == ReadyState.Shutdown) + TimeSpan nextDelay = _nextRetryDelay.Get() ?? TimeSpan.Zero; + if (nextDelay > TimeSpan.Zero) { - return; + var disconnectedTime = _disconnectedTime.Get(); + TimeSpan delayNow = disconnectedTime.HasValue ? + (nextDelay - (DateTime.Now - disconnectedTime.Value)) : + nextDelay; + if (delayNow > TimeSpan.Zero) + { + _logger.Info("Waiting {0} milliseconds before reconnecting", delayNow.TotalMilliseconds); + await Task.Delay(delayNow); + } } - _readyState = state; - } - if (action != null) - { - action(new StateChangedEventArgs(state)); - } - } + _readyState.Set(ReadyState.Connecting); - private void HandleParsedLine(EventParser.Result result) - { - if (result.IsComment) - { - OnCommentReceived(new CommentReceivedEventArgs(result.GetValueAsString())); - } - else if (result.IsDataField) - { - if (result.ValueString != null) + var connectResult = new ConnectStrategy.Client.Result(); + CancellationToken newCancellationToken; + if (exception is null) { - if (_eventDataStringBuffer is null) + _connectedTime.Set(null); + _deliberatelyClosedConnection = false; + + CancellationTokenSource newRequestTokenSource = new CancellationTokenSource(); + lock (_lock) + { + if (_readyState.Get() == ReadyState.Shutdown) + { + // in case Close() was called in between the previous ReadyState check and the creation of the new token + return null; + } + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = newRequestTokenSource; + _cancellationToken.Set(newRequestTokenSource.Token); + newCancellationToken = newRequestTokenSource.Token; + } + + try + { + connectResult = await _client.ConnectAsync( + new ConnectStrategy.Client.Params + { + CancellationToken = newRequestTokenSource.Token, + LastEventId = _lastEventId + }); + } + catch (StreamException e) { - _eventDataStringBuffer = new List(2); + exception = e; } - _eventDataStringBuffer.Add(result.ValueString); - _eventDataStringBuffer.Add("\n"); } - else + + if (exception != null) { - if (_eventDataUtf8ByteBuffer is null) + _readyState.Set(ReadyState.Closed); + _logger.Debug("Encountered exception: {0}", LogValues.ExceptionSummary(exception)); + _disconnectedTime.Set(DateTime.Now); + ComputeRetryDelay(); + if (ApplyErrorStrategy(exception) == ErrorStrategy.Action.Continue) { - _eventDataUtf8ByteBuffer = new MemoryStream(result.ValueBytes.Length + 1); + // The ErrorStrategy told us to CONTINUE rather than throwing an exception. + if (canReturnFaultEvent) + { + return new FaultEvent(exception); + } + // If canReturnFaultEvent is false, it means the caller explicitly called start(), + // in which case there's no way to return a FaultEvent so we just keep retrying + // transparently. + continue; } - _eventDataUtf8ByteBuffer.Write(result.ValueBytes.Data, result.ValueBytes.Offset, result.ValueBytes.Length); - _eventDataUtf8ByteBuffer.WriteByte((byte)'\n'); + // The ErrorStrategy told us to THROW rather than CONTINUE. + throw exception; } - } - else if (result.IsEventField) - { - _eventName = result.GetValueAsString(); - } - else if (result.IsIdField) - { - _lastEventId = result.GetValueAsString(); - } - else if (result.IsRetryField) - { - if (long.TryParse(result.GetValueAsString(), out var retry)) + + lock (_lock) { - _retryDelay = TimeSpan.FromMilliseconds(retry); + _connectedTime.Set(DateTime.Now); + _readyState.Set(ReadyState.Open); + _request = connectResult.Closer; } + _logger.Debug("Connected to SSE stream"); + + _parser = new EventParser( + connectResult.Stream, + connectResult.ReadTimeout ?? Timeout.InfiniteTimeSpan, + _origin, + newCancellationToken, + _logger + ); + + _currentErrorStrategy = _baseErrorStrategy; + return null; } } - private void DispatchEvent() + private ErrorStrategy.Action ApplyErrorStrategy(Exception exception) { - var name = _eventName ?? Constants.MessageField; - _eventName = null; - MessageEvent message; + var result = _currentErrorStrategy.Apply(exception); + _currentErrorStrategy = result.Next ?? _currentErrorStrategy; + return result.Action; + } - if (_eventDataStringBuffer != null) + private void ComputeRetryDelay() + { + var connectedTime = _connectedTime.Get(); + if (_retryDelayResetThreshold > TimeSpan.Zero && connectedTime.HasValue) { - if (_eventDataStringBuffer.Count == 0) + TimeSpan connectionDuration = DateTime.Now.Subtract(connectedTime.Value); + if (connectionDuration >= _retryDelayResetThreshold) { - return; + _currentRetryDelayStrategy = _baseRetryDelayStrategy; } - // remove last item which is always a trailing newline - _eventDataStringBuffer.RemoveAt(_eventDataStringBuffer.Count - 1); - var dataString = string.Concat(_eventDataStringBuffer); - message = new MessageEvent(name, dataString, _lastEventId, _configuration.Uri); - - _eventDataStringBuffer.Clear(); } - else + var result = _currentRetryDelayStrategy.Apply(_baseRetryDelay.Get()); + _nextRetryDelay.Set(result.Delay); + _currentRetryDelayStrategy = result.Next ?? _currentRetryDelayStrategy; + } + + private void CloseCurrentStream(bool deliberatelyInterrupted, bool closingPermanently) + { + CancellationTokenSource oldTokenSource; + IDisposable oldRequest; + lock (_lock) { - if (_eventDataUtf8ByteBuffer is null || _eventDataUtf8ByteBuffer.Length == 0) + if (_cancellationTokenSource is null) { return; } - var dataSpan = new Utf8ByteSpan(_eventDataUtf8ByteBuffer.GetBuffer(), 0, - (int)_eventDataUtf8ByteBuffer.Length - 1); // remove trailing newline - message = new MessageEvent(name, dataSpan, _lastEventId, _configuration.Uri); - - // We've now taken ownership of the original buffer; null out the previous - // reference to it so a new one will be created next time - _eventDataUtf8ByteBuffer = null; + oldTokenSource = _cancellationTokenSource; + oldRequest = _request; + _cancellationTokenSource = null; + _request = null; + _deliberatelyClosedConnection = true; + if (_readyState.Get() != ReadyState.Shutdown) + { + _readyState.Set(ReadyState.Closed); + } } - - _logger.Debug("Received event \"{0}\"", name); - OnMessageReceived(new MessageReceivedEventArgs(message)); + _logger.Debug("Cancelling current request"); + oldTokenSource.Cancel(); + oldTokenSource.Dispose(); + oldRequest?.Dispose(); } - private void OnOpened(StateChangedEventArgs e) => - Opened?.Invoke(this, e); - - private void OnClosed(StateChangedEventArgs e) => - Closed?.Invoke(this, e); - - private void OnMessageReceived(MessageReceivedEventArgs e) => - MessageReceived?.Invoke(this, e); - - private void OnCommentReceived(CommentReceivedEventArgs e) => - CommentReceived?.Invoke(this, e); - - private void OnError(ExceptionEventArgs e) => - Error?.Invoke(this, e); - #endregion } } diff --git a/src/LaunchDarkly.EventSource/EventSourceService.cs b/src/LaunchDarkly.EventSource/EventSourceService.cs deleted file mode 100644 index 31c73f0..0000000 --- a/src/LaunchDarkly.EventSource/EventSourceService.cs +++ /dev/null @@ -1,272 +0,0 @@ -using System; -using System.IO; -using System.Net.Sockets; -using System.Text; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; -using LaunchDarkly.Logging; - -using static LaunchDarkly.EventSource.AsyncHelpers; - -namespace LaunchDarkly.EventSource -{ - internal class EventSourceService - { - #region Private Fields - - private const int Utf8ReadBufferSize = 1000; - - private readonly Configuration _configuration; - private readonly HttpClient _httpClient; - private readonly Logger _logger; - - private const string UserAgentProduct = "DotNetClient"; - internal static readonly string UserAgentVersion = ((AssemblyInformationalVersionAttribute)typeof(EventSource) - .GetTypeInfo() - .Assembly - .GetCustomAttribute(typeof(AssemblyInformationalVersionAttribute))) - .InformationalVersion; - - #endregion - - #region Public Events - - /// - /// Occurs when the connection to the EventSource API has been opened. - /// - public event EventHandler ConnectionOpened; - /// - /// Occurs when the connection to the EventSource API has been closed. - /// - public event EventHandler ConnectionClosed; - - #endregion - - #region Constructors - - internal EventSourceService(Configuration configuration, HttpClient httpClient, Logger logger) - { - _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - #endregion - - #region Public Methods - - /// - /// Initiates the request to the EventSource API and parses Server Sent Events received by the API. - /// - /// A A task that represents the work queued to execute in the ThreadPool. - public async Task GetDataAsync( - Action processResponseLineString, - Action processResponseLineUTF8, - string lastEventId, - CancellationToken cancellationToken - ) - { - cancellationToken.ThrowIfCancellationRequested(); - - await ConnectToEventSourceApi(processResponseLineString, processResponseLineUTF8, lastEventId, cancellationToken); - } - - #endregion - - #region Private Methods - - private async Task ConnectToEventSourceApi( - Action processResponseLineString, - Action processResponseLineUTF8, - string lastEventId, - CancellationToken cancellationToken - ) - { - _logger.Debug("Making {0} request to EventSource URI {1}", - _configuration.Method ?? HttpMethod.Get, - _configuration.Uri); - - var request = CreateHttpRequestMessage(_configuration.Uri, lastEventId); - - this._configuration.HttpRequestModifier?.Invoke(request); - - using (var response = await _httpClient.SendAsync(request, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken).ConfigureAwait(false)) - { - _logger.Debug("Response status: {0}", (int)response.StatusCode); - ValidateResponse(response); - - using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) - { - var encoding = DetectEncoding(response); - if (encoding != Encoding.UTF8) - { - throw new EventSourceServiceCancelledException( - string.Format(Resources.ErrorWrongEncoding, encoding.HeaderName)); - } - OnConnectionOpened(); - - if (_configuration.PreferDataAsUtf8Bytes) - { - _logger.Debug("Reading UTF-8 stream without string conversion"); - await ProcessResponseFromUtf8StreamAsync(processResponseLineUTF8, stream, cancellationToken); - } - else - { - _logger.Debug("Reading stream with string conversion"); - using (var reader = new StreamReader(stream, Encoding.UTF8)) - { - await ProcessResponseFromReaderAsync(processResponseLineString, reader, cancellationToken); - } - } - } - - OnConnectionClosed(); - } - } - - private Encoding DetectEncoding(HttpResponseMessage response) - { - var charset = response.Content.Headers.ContentType?.CharSet; - if (charset != null) - { - try - { - return Encoding.GetEncoding(charset); - } - catch (ArgumentException) { } - } - return Encoding.UTF8; - } - - protected virtual async Task ProcessResponseFromReaderAsync( - Action processResponse, - StreamReader reader, - CancellationToken cancellationToken - ) - { - while (!cancellationToken.IsCancellationRequested) - { - var line = await DoWithTimeout(_configuration.ReadTimeout, cancellationToken, - token => AllowCancellation(reader.ReadLineAsync(), token)); - if (line == null) - { - // this means the stream is done, i.e. the connection was closed - return; - } - processResponse(line); - } - } - - protected async Task ProcessResponseFromUtf8StreamAsync( - Action processResponseLine, - Stream stream, - CancellationToken cancellationToken - ) - { - var lineScanner = new ByteArrayLineScanner(Utf8ReadBufferSize); - while (!cancellationToken.IsCancellationRequested) - { - // Note that even though Stream.ReadAsync has an overload that takes a CancellationToken, that - // does not actually work for network sockets (https://stackoverflow.com/questions/12421989/networkstream-readasync-with-a-cancellation-token-never-cancels). - // So we must use AsyncHelpers.AllowCancellation to wrap it in a cancellable task. - int bytesRead = await DoWithTimeout(_configuration.ReadTimeout, cancellationToken, - token => AllowCancellation(stream.ReadAsync(lineScanner.Buffer, lineScanner.Count, lineScanner.Available), token)); - if (bytesRead == 0) - { - cancellationToken.ThrowIfCancellationRequested(); - return; - } - lineScanner.AddedBytes(bytesRead); - while (lineScanner.ScanToEndOfLine(out var lineSpan)) - { - processResponseLine(lineSpan); - } - } - } - - private HttpRequestMessage CreateHttpRequestMessage(Uri uri, string lastEventId) - { - var request = new HttpRequestMessage(_configuration.Method ?? HttpMethod.Get, uri); - - // Add all headers provided in the Configuration Headers. This allows a consumer to provide any request headers to the EventSource API - if (_configuration.RequestHeaders != null) - { - foreach (var item in _configuration.RequestHeaders) - { - request.Headers.Add(item.Key, item.Value); - } - } - - // Add the request body, if any. - if (_configuration.RequestBodyFactory != null) - { - HttpContent requestBody = _configuration.RequestBodyFactory(); - if (requestBody != null) - { - request.Content = requestBody; - } - } - - // If the lastEventId was provided, include it as a header to the API request. - if (!string.IsNullOrWhiteSpace(lastEventId)) - { - request.Headers.Remove(Constants.LastEventIdHttpHeader); - request.Headers.Add(Constants.LastEventIdHttpHeader, lastEventId); - } - - // If we haven't set the LastEventId header and if the EventSource Configuration was provided with a LastEventId, - // include it as a header to the API request. - if (!string.IsNullOrWhiteSpace(_configuration.LastEventId) && !request.Headers.Contains(Constants.LastEventIdHttpHeader)) - request.Headers.Add(Constants.LastEventIdHttpHeader, _configuration.LastEventId); - - if (request.Headers.UserAgent.Count == 0) - request.Headers.UserAgent.ParseAdd(UserAgentProduct + "/" + UserAgentVersion); - - // Add the Accept Header if it wasn't provided in the Configuration - if (!request.Headers.Contains(Constants.AcceptHttpHeader)) - request.Headers.Add(Constants.AcceptHttpHeader, Constants.EventStreamContentType); - - request.Headers.ExpectContinue = false; - request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true }; - - return request; - } - - private void ValidateResponse(HttpResponseMessage response) - { - // Any non-2xx response status is an error. A 204 (no content) is also an error. - if (!response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.NoContent) - { - throw new EventSourceServiceUnsuccessfulResponseException((int)response.StatusCode); - } - - if (response.Content == null) - { - throw new EventSourceServiceCancelledException(Resources.ErrorEmptyResponse); - } - - if (response.Content.Headers.ContentType.MediaType != Constants.EventStreamContentType) - { - throw new EventSourceServiceCancelledException( - string.Format(Resources.ErrorWrongContentType, response.Content.Headers.ContentType)); - } - } - - private void OnConnectionOpened() - { - ConnectionOpened?.Invoke(this, EventArgs.Empty); - } - - private void OnConnectionClosed() - { - ConnectionClosed?.Invoke(this, EventArgs.Empty); - } - - #endregion - } -} diff --git a/src/LaunchDarkly.EventSource/EventSourceServiceCancelledException.cs b/src/LaunchDarkly.EventSource/EventSourceServiceCancelledException.cs deleted file mode 100644 index dd8fdbd..0000000 --- a/src/LaunchDarkly.EventSource/EventSourceServiceCancelledException.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace LaunchDarkly.EventSource -{ - /// - /// General superclass for exceptions that caused the EventSource to disconnect or fail to establish - /// a connection. - /// - public class EventSourceServiceCancelledException : Exception - { - - #region Public Constructors - - /// - /// Creates a new instance. - /// - /// the exception message - public EventSourceServiceCancelledException(string message) : base(message) { } - - /// - /// Creates a new instance with an inner exception. - /// - /// the exception message - /// the inner exception - public EventSourceServiceCancelledException(string message, Exception innerException) : - base(message, innerException) - { } - - #endregion - - } -} diff --git a/src/LaunchDarkly.EventSource/EventSourceServiceUnsuccessfulResponseException.cs b/src/LaunchDarkly.EventSource/EventSourceServiceUnsuccessfulResponseException.cs deleted file mode 100644 index c511bc4..0000000 --- a/src/LaunchDarkly.EventSource/EventSourceServiceUnsuccessfulResponseException.cs +++ /dev/null @@ -1,34 +0,0 @@ - -namespace LaunchDarkly.EventSource -{ - /// - /// Indicates that the EventSource was able to establish an HTTP connection, but received a - /// non-successful status code. - /// - public class EventSourceServiceUnsuccessfulResponseException : EventSourceServiceCancelledException - { - #region Public Properties - - /// - /// The HTTP status code of the response. - /// - public int StatusCode { get; } - - #endregion - - #region Public Constructors - - /// - /// Creates a new instance. - /// - /// the HTTP status code of the response - public EventSourceServiceUnsuccessfulResponseException(int statusCode) : - base(string.Format(Resources.ErrorHttpStatus, statusCode)) - { - StatusCode = statusCode; - } - - #endregion - - } -} diff --git a/src/LaunchDarkly.EventSource/Events/CommentEvent.cs b/src/LaunchDarkly.EventSource/Events/CommentEvent.cs new file mode 100644 index 0000000..d12d226 --- /dev/null +++ b/src/LaunchDarkly.EventSource/Events/CommentEvent.cs @@ -0,0 +1,37 @@ +using System; +namespace LaunchDarkly.EventSource.Events +{ + /// + /// Describes a comment line received from the stream. + /// + /// + /// An SSE comment is a line that starts with a colon. There is no defined meaning for this + /// in the SSE specification, and most clients ignore it. It may be used to provide a + /// periodic heartbeat from the server to keep connections from timing out. + /// + public class CommentEvent : IEvent + { + /// + /// The comment text, not including the leading colon. + /// + public string Text { get; } + + /// + /// Creates an instance. + /// + /// the comment text, not including the leading colon + public CommentEvent(string text) { Text = text; } + + /// + public override bool Equals(object o) => + o is CommentEvent oc && Text == oc.Text; + + /// + public override int GetHashCode() => + Text?.GetHashCode() ?? 0; + + /// + public override string ToString() => + string.Format("CommentEvent({0})", Text); + } +} diff --git a/src/LaunchDarkly.EventSource/Events/FaultEvent.cs b/src/LaunchDarkly.EventSource/Events/FaultEvent.cs new file mode 100644 index 0000000..e28edb1 --- /dev/null +++ b/src/LaunchDarkly.EventSource/Events/FaultEvent.cs @@ -0,0 +1,51 @@ +using System; +using System.Drawing; +using LaunchDarkly.EventSource.Exceptions; + +using static System.Net.Mime.MediaTypeNames; + +namespace LaunchDarkly.EventSource.Events +{ + /// + /// Describes a failure in the stream. + /// + /// + /// + /// When an error occurs, if the configured returns + /// , + /// will return a FaultEvent. Otherwise, the error would instead be thrown as a + /// . + /// + /// + /// If you receive a FaultEvent, the EventSource is now in an inactive state since + /// either a connection attempt has failed or an existing connection has been closed. + /// EventSource will attempt to reconnect if you either call + /// or simply continue reading events after this point. + /// + /// + public class FaultEvent : IEvent + { + /// + /// The cause of the failure. + /// + public Exception Exception { get; } + + /// + /// Creates an instance. + /// + /// the cause of the failure + public FaultEvent(Exception exception) { Exception = exception; } + + /// + public override bool Equals(object o) => + o is FaultEvent of && object.Equals(Exception, of.Exception); + + /// + public override int GetHashCode() => + Exception?.GetHashCode() ?? 0; + + /// + public override string ToString() => + string.Format("FaultEvent({0})", Exception); + } +} diff --git a/src/LaunchDarkly.EventSource/Events/IEvent.cs b/src/LaunchDarkly.EventSource/Events/IEvent.cs new file mode 100644 index 0000000..d2b9fc7 --- /dev/null +++ b/src/LaunchDarkly.EventSource/Events/IEvent.cs @@ -0,0 +1,12 @@ +using System; + +namespace LaunchDarkly.EventSource.Events +{ + /// + /// A marker interface for all types of stream information that can be returned by + /// . + /// + public interface IEvent + { + } +} diff --git a/src/LaunchDarkly.EventSource/MessageEvent.cs b/src/LaunchDarkly.EventSource/Events/MessageEvent.cs similarity index 83% rename from src/LaunchDarkly.EventSource/MessageEvent.cs rename to src/LaunchDarkly.EventSource/Events/MessageEvent.cs index a1c7332..012dbb7 100644 --- a/src/LaunchDarkly.EventSource/MessageEvent.cs +++ b/src/LaunchDarkly.EventSource/Events/MessageEvent.cs @@ -1,6 +1,6 @@ using System; -namespace LaunchDarkly.EventSource +namespace LaunchDarkly.EventSource.Events { /// /// Represents the Server-Sent Event message received from a stream. @@ -11,22 +11,19 @@ namespace LaunchDarkly.EventSource /// a data string, and an optional "ID" string that the server may provide. /// /// - /// The event name and ID properties are always stored as strings. By default, the - /// data property is also stored as a string. However, in some applications, it may - /// be desirable to represent the data as a UTF-8 byte array (for instance, if you are - /// using the System.Text.Json API to parse JSON data). + /// The event name and ID properties are always stored as strings. The data property + /// is available as either a string or a (); for efficiency, + /// it is always read as UTF-8 data first, and only converted to a string if you + /// access the property. /// /// - /// Since strings in .NET use two-byte UTF-16 characters, if you have a large block of - /// UTF-8 data it is considerably more efficient to process it in its original form - /// rather than converting it to or from a string. stores - /// data as strings by default, but you set - /// it can store the raw UTF-8 data instead. In either case, MessageEvent will - /// convert types transparently so that you can read either - /// or . + /// When returns a , it is in an + /// ephemeral state where the data may be referencing an internal buffer; this buffer + /// will be overwritten if you read another event. If you want the event to be usable + /// past that point, you must call to get a copy. /// /// - public struct MessageEvent + public class MessageEvent : IEvent { /// /// The default value of if the SSE stream did not specify an @@ -173,6 +170,24 @@ public MessageEvent(string name, string data, Uri origin) : this(name, data, nul #region Public Methods + /// + /// Returns a MessageEvent instance that is guaranteed to contain its own data as a string, + /// without any reference to an internal buffer. + /// + /// + /// For efficiency, when returns a it + /// may be referencing a temporary buffer that will be reused for the next event. Therefore, + /// storing the original past that point may not be safe. + /// returns a copy of the event with the data fully read into its + /// own string buffer; or, if the original event was already in that state, it simply + /// returns the original event. + /// + /// an instance that is safe to store independently + public MessageEvent ReadFully() => + _dataString is null ? + new MessageEvent(_name, Data, _lastEventId, _origin) : + this; + /// /// Determines whether the specified is equal to this instance. /// diff --git a/src/LaunchDarkly.EventSource/Events/StartedEvent.cs b/src/LaunchDarkly.EventSource/Events/StartedEvent.cs new file mode 100644 index 0000000..6720a02 --- /dev/null +++ b/src/LaunchDarkly.EventSource/Events/StartedEvent.cs @@ -0,0 +1,24 @@ +using System; + +namespace LaunchDarkly.EventSource.Events +{ + /// + /// Represents the beginning of a stream. + /// + /// + /// This event will be returned by + /// if the stream started as a side effect of calling that method, rather than + /// from calling . You will also get a new + /// StartedEvent if the stream was closed and then reconnected. + /// + public class StartedEvent : IEvent + { + /// + public override bool Equals(object o) => + o is StartedEvent; + + /// + public override int GetHashCode() => + typeof(StartedEvent).GetHashCode(); + } +} diff --git a/src/LaunchDarkly.EventSource/Utf8ByteSpan.cs b/src/LaunchDarkly.EventSource/Events/Utf8ByteSpan.cs similarity index 99% rename from src/LaunchDarkly.EventSource/Utf8ByteSpan.cs rename to src/LaunchDarkly.EventSource/Events/Utf8ByteSpan.cs index 52f0aa3..4d1cbde 100644 --- a/src/LaunchDarkly.EventSource/Utf8ByteSpan.cs +++ b/src/LaunchDarkly.EventSource/Events/Utf8ByteSpan.cs @@ -1,7 +1,7 @@ using System; using System.Text; -namespace LaunchDarkly.EventSource +namespace LaunchDarkly.EventSource.Events { /// /// Points to a span of UTF-8-encoded text in a buffer. diff --git a/src/LaunchDarkly.EventSource/ReadTimeoutException.cs b/src/LaunchDarkly.EventSource/Exceptions/ReadTimeoutException.cs similarity index 61% rename from src/LaunchDarkly.EventSource/ReadTimeoutException.cs rename to src/LaunchDarkly.EventSource/Exceptions/ReadTimeoutException.cs index ef27b57..3a05d89 100644 --- a/src/LaunchDarkly.EventSource/ReadTimeoutException.cs +++ b/src/LaunchDarkly.EventSource/Exceptions/ReadTimeoutException.cs @@ -1,9 +1,10 @@ using System; +using System.IO; namespace LaunchDarkly.EventSource { /// - /// An exception that indicates that the configured read timeout elapsed without receiving + /// An exception indicating that the configured read timeout elapsed without receiving /// any new data from the server. /// /// @@ -12,9 +13,17 @@ namespace LaunchDarkly.EventSource /// stream connection in this case. The server can send periodic comment lines (":\n") to /// keep the client from timing out if the connection is still working. /// - public class ReadTimeoutException : Exception + public class ReadTimeoutException : IOException { /// public override string Message => Resources.ErrorReadTimeout; + + /// + public override bool Equals(object o) => + o != null && o.GetType() == this.GetType(); + + /// + public override int GetHashCode() => + GetType().GetHashCode(); } } diff --git a/src/LaunchDarkly.EventSource/Exceptions/StreamClosedByCallerException.cs b/src/LaunchDarkly.EventSource/Exceptions/StreamClosedByCallerException.cs new file mode 100644 index 0000000..b12c9a0 --- /dev/null +++ b/src/LaunchDarkly.EventSource/Exceptions/StreamClosedByCallerException.cs @@ -0,0 +1,24 @@ +using System; + +namespace LaunchDarkly.EventSource.Exceptions +{ + /// + /// An exception indicating that the stream stopped because you explicitly stopped it. + /// + /// + /// + /// This exception only happens if you are trying to read from one thread while + /// or + /// is called from another thread. + /// + /// + public class StreamClosedByCallerException : StreamException + { + /// + /// Creates an instance. + /// + public StreamClosedByCallerException() : + base(Resources.StreamClosedByCaller) + { } + } +} diff --git a/src/LaunchDarkly.EventSource/Exceptions/StreamClosedByServerException.cs b/src/LaunchDarkly.EventSource/Exceptions/StreamClosedByServerException.cs new file mode 100644 index 0000000..c65d18a --- /dev/null +++ b/src/LaunchDarkly.EventSource/Exceptions/StreamClosedByServerException.cs @@ -0,0 +1,17 @@ +using System; + +namespace LaunchDarkly.EventSource.Exceptions +{ + /// + /// An exception indicating that the stream stopped because the server closed + /// the connection. + /// + public class StreamClosedByServerException : StreamException + { + /// + /// Creates an instance. + /// + public StreamClosedByServerException() : + base(Resources.StreamClosedByServer) { } + } +} diff --git a/src/LaunchDarkly.EventSource/Exceptions/StreamContentException.cs b/src/LaunchDarkly.EventSource/Exceptions/StreamContentException.cs new file mode 100644 index 0000000..326c960 --- /dev/null +++ b/src/LaunchDarkly.EventSource/Exceptions/StreamContentException.cs @@ -0,0 +1,55 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.NetworkInformation; +using System.Text; + +namespace LaunchDarkly.EventSource.Exceptions +{ + /// + /// An exception indicating that the server returned a response with an + /// invalid content type and/or content encoding. + /// + /// + /// The SSE specification requires that all stream responses have a content + /// type of "text/event-stream" and an encoding of UTF-8. + /// + public class StreamContentException : Exception + { + /// + /// The content type of the response. + /// + public MediaTypeHeaderValue ContentType { get; private set; } + + /// + /// The content encoding of the response. + /// + public Encoding ContentEncoding { get; private set; } + + /// + /// Creates an instance. + /// + /// the content type + /// the content encoding + public StreamContentException( + MediaTypeHeaderValue contentType, + Encoding contentEncoding + ) : base(string.Format(Resources.ErrorWrongContentTypeOrEncoding, + contentType, contentEncoding)) + { + ContentType = contentType; + ContentEncoding = contentEncoding; + } + + /// + public override bool Equals(object o) => + o is StreamContentException e && + object.Equals(ContentType, e.ContentType) && + object.Equals(ContentEncoding, e.ContentEncoding); + + /// + public override int GetHashCode() => + ContentType?.GetHashCode() ?? 0 * 17 + + ContentEncoding?.GetHashCode() ?? 0; + } +} diff --git a/src/LaunchDarkly.EventSource/Exceptions/StreamException.cs b/src/LaunchDarkly.EventSource/Exceptions/StreamException.cs new file mode 100644 index 0000000..19a9258 --- /dev/null +++ b/src/LaunchDarkly.EventSource/Exceptions/StreamException.cs @@ -0,0 +1,30 @@ +using System; +using System.Net.NetworkInformation; + +namespace LaunchDarkly.EventSource.Exceptions +{ + /// + /// Base class for all exceptions thrown by EventSource. + /// + public class StreamException : Exception + { + /// + /// Empty constructor. + /// + public StreamException() { } + + /// + /// Constructor with a message. + /// + /// the exception message + public StreamException(string message) : base(message) { } + + /// + public override bool Equals(object o) => + o != null && o.GetType() == this.GetType(); + + /// + public override int GetHashCode() => + GetType().GetHashCode(); + } +} diff --git a/src/LaunchDarkly.EventSource/Exceptions/StreamHttpErrorException.cs b/src/LaunchDarkly.EventSource/Exceptions/StreamHttpErrorException.cs new file mode 100644 index 0000000..1bafa5e --- /dev/null +++ b/src/LaunchDarkly.EventSource/Exceptions/StreamHttpErrorException.cs @@ -0,0 +1,36 @@ +using System; + +namespace LaunchDarkly.EventSource.Exceptions +{ + /// + /// An exception indicating that the remote server returned an HTTP error. + /// + /// + /// The SSE specification defines an HTTP error as any non-2xx status, or 204. + /// + public class StreamHttpErrorException : StreamException + { + /// + /// The HTTP status code. + /// + public int Status { get; } + + /// + /// Creates an instance. + /// + /// the HTTP status code + public StreamHttpErrorException(int status) : + base(string.Format(Resources.ErrorHttpStatus, status)) + { + Status = status; + } + + /// + public override bool Equals(object o) => + o is StreamHttpErrorException e && Status == e.Status; + + /// + public override int GetHashCode() => + Status.GetHashCode(); + } +} diff --git a/src/LaunchDarkly.EventSource/ExponentialBackoffWithDecorrelation.cs b/src/LaunchDarkly.EventSource/ExponentialBackoffWithDecorrelation.cs deleted file mode 100644 index cc6d559..0000000 --- a/src/LaunchDarkly.EventSource/ExponentialBackoffWithDecorrelation.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; - -namespace LaunchDarkly.EventSource -{ - internal class ExponentialBackoffWithDecorrelation - { - private readonly TimeSpan _minimumDelay; - private readonly TimeSpan _maximumDelay; - private readonly Random _jitterer = new Random(); - private int _reconnectAttempts; - - public ExponentialBackoffWithDecorrelation(TimeSpan minimumDelay, TimeSpan maximumDelay) - { - _minimumDelay = minimumDelay; - _maximumDelay = maximumDelay; - } - - /// - /// Gets the next backoff duration and increments the reconnect attempt count - /// - public TimeSpan GetNextBackOff() - { - int nextDelay = GetMaximumMillisecondsForAttempt(_reconnectAttempts); - nextDelay = nextDelay / 2 + _jitterer.Next(nextDelay) / 2; - _reconnectAttempts++; - return TimeSpan.FromMilliseconds(nextDelay); - } - - internal int GetMaximumMillisecondsForAttempt(int attempt) - { - return Convert.ToInt32(Math.Min(_maximumDelay.TotalMilliseconds, - _minimumDelay.TotalMilliseconds * Math.Pow(2, attempt))); - } - - public int GetReconnectAttemptCount() { - return _reconnectAttempts; - } - - public void ResetReconnectAttemptCount() { - _reconnectAttempts = 0; - } - - [Obsolete("IncrementReconnectAttemptCount is deprecated, use GetNextBackOff instead.")] - public void IncrementReconnectAttemptCount() { - _reconnectAttempts++; - } - } -} diff --git a/src/LaunchDarkly.EventSource/HttpConnectStrategy.cs b/src/LaunchDarkly.EventSource/HttpConnectStrategy.cs new file mode 100644 index 0000000..7dd6322 --- /dev/null +++ b/src/LaunchDarkly.EventSource/HttpConnectStrategy.cs @@ -0,0 +1,476 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using LaunchDarkly.EventSource.Exceptions; +using LaunchDarkly.EventSource.Internal; +using LaunchDarkly.Logging; + +namespace LaunchDarkly.EventSource +{ + /// + /// Allows configuration of HTTP request behavior for . + /// + /// + /// + /// EventSource uses this as its default implementation of how to connect to a + /// stream. + /// + /// + /// If you do not need to specify any options other than the stream URI, then you + /// do not need to reference this class directly; you can just call + /// to specify a URI. + /// + /// + /// To configure additional options, obtain an instance of this class by calling + /// , then call any of the methods of this + /// class to specify your options. The class is immutable, so each of these methods + /// returns a new modified instance, and an EventSource created with this + /// configuration will not be affected by any subsequent changes you make. + /// + /// + /// Once you have configured all desired options, pass the object to + /// : + /// + /// + /// var config = Configuration.Builder( + /// ConnectStrategy.Http(streamUri) + /// .Header("name", "value") + /// .ReadTimeout(TimeSpan.FromMinutes(1)) + /// ); + /// + /// + public sealed class HttpConnectStrategy : ConnectStrategy + { + /// + /// The default value for : 5 minutes. + /// + public static readonly TimeSpan DefaultReadTimeout = TimeSpan.FromMinutes(5); + + /// + /// The default value for : 10 seconds. + /// + public static readonly TimeSpan DefaultResponseStartTimeout = TimeSpan.FromSeconds(10); + + private readonly Uri _uri; + private readonly ChainableTransform _clientTransform; + private readonly ChainableTransform _requestTransform; + private readonly HttpClient _httpClient; + private readonly HttpMessageHandler _httpMessageHandler; + private readonly TimeSpan? _readTimeout; + + /// + public override Uri Origin => _uri; + + private sealed class ChainableTransform + { + private readonly Action _action; + + public ChainableTransform() : this(null) { } + + private ChainableTransform(Action action) { _action = action; } + + public void Apply(T target) => + _action?.Invoke(target); + + public ChainableTransform AndThen(Action nextAction) + { + if (nextAction is null) + { + return this; + } + return new ChainableTransform( + _action is null ? nextAction : + target => + { + _action(target); + nextAction(target); + }); + } + } + + internal HttpConnectStrategy(Uri uri) : + this(uri, null, null, null, null, null) { } + + private HttpConnectStrategy( + Uri uri, + ChainableTransform clientTransform, + ChainableTransform requestTransform, + HttpClient httpClient, + HttpMessageHandler httpMessageHandler, + TimeSpan? readTimeout + ) + { + if (uri is null) + { + throw new ArgumentNullException("uri"); + } + _uri = uri; + _clientTransform = clientTransform ?? new ChainableTransform(); + _requestTransform = requestTransform ?? new ChainableTransform(); + _httpClient = httpClient; + _httpMessageHandler = httpMessageHandler; + _readTimeout = readTimeout; + } + + /// + /// Called by EventSource to set up the client. + /// + /// the configured logger + /// the client implementation + public override Client CreateClient(Logger logger) => + new ClientImpl(this, logger); + + /// + /// Sets a custom header to be sent in each request. + /// + /// + /// Any existing headers with the same name are overwritten. + /// + /// the header name + /// the header value + /// a new HttpConnectStrategy instance with this property modified + public HttpConnectStrategy Header(string name, string value) => + AddRequestTransform(r => + { + r.Headers.Remove(name); + r.Headers.Add(name, value); + }); + + /// + /// Sets request headers to be sent in each request. + /// + /// + /// Any existing headers with the same names are overwritten. + /// + /// the headers (null is equivalent to an empty dictionary) + /// a new HttpConnectStrategy instance with this property modified + public HttpConnectStrategy Headers(IDictionary headers) => + headers is null || headers.Count == 0 ? this : + AddRequestTransform(r => + { + foreach (var item in headers) + { + r.Headers.Remove(item.Key); + r.Headers.Add(item.Key, item.Value); + } + }); + + /// + /// Specifies that EventSource should use a specific HttpClient instance for HTTP requests. + /// + /// + /// + /// Normally, EventSource creates its own HttpClient and disposes of it when you dispose of the + /// EventSource. If you provide your own HttpClient using this method, you are responsible for + /// managing the HttpClient's lifecycle-- EventSource will not dispose of it. + /// + /// + /// EventSource will not modify this client's properties, so if you call + /// or , those methods will be ignored. + /// + /// + /// an HttpClient instance, or null to use the default behavior + /// a new HttpConnectStrategy instance with this property modified + public HttpConnectStrategy HttpClient(HttpClient client) => + new HttpConnectStrategy( + _uri, + _clientTransform, + _requestTransform, + client, + _httpMessageHandler, + _readTimeout + ); + + /// + /// Sets a delegate hook invoked after an HTTP client is created. + /// + /// + /// + /// Use this if you need to set client properties other than the ones that are + /// already supported by methods. + /// + /// + /// If you call this method multiple times, the transformations are applied in + /// the same order as the calls. + /// + /// + /// This method is ignored if you specified your own client instance with + /// . + /// + /// + /// code that will be called to modify the client + /// a new HttpConnectStrategy instance with this property modified + public HttpConnectStrategy HttpClientModifier(Action httpClientModifier) => + AddClientTransform(httpClientModifier); + + /// + /// Sets the HttpMessageHandler that will be used for the HTTP client. + /// + /// + /// If you have specified a custom HTTP client instance with , then + /// is ignored. + /// + /// the message handler implementation, or null for the + /// default handler + /// a new HttpConnectStrategy instance with this property modified + public HttpConnectStrategy HttpMessageHandler(HttpMessageHandler handler) => + new HttpConnectStrategy( + _uri, + _clientTransform, + _requestTransform, + _httpClient, + handler, + _readTimeout + ); + + /// + /// Sets a delegate hook invoked before an HTTP request is performed. + /// + /// + /// + /// Use this if you need to set request properties other than the ones that are + /// already supported by methods, or if you + /// need to determine the request properties dynamically rather than setting them + /// to fixed values initially. + /// + /// + /// If you call this method multiple times, the transformations are applied in + /// the same order as the calls. + /// + /// + /// code that will be called with the request + /// before it is sent + /// a new HttpConnectStrategy instance with this property modified + public HttpConnectStrategy HttpRequestModifier(Action httpRequestModifier) => + AddRequestTransform(httpRequestModifier); + + /// + /// Sets the HTTP method that will be used when connecting to the EventSource API. + /// + /// + /// By default, this is . + /// + /// the method; defaults to Get if null + /// a new HttpConnectStrategy instance with this property modified + public HttpConnectStrategy Method(HttpMethod method) => + AddRequestTransform(r => r.Method = method ?? HttpMethod.Get); + + /// + /// Sets a timeout that will cause the stream to be closed if the timeout is + /// exceeded when reading data. + /// + /// + /// + /// The connection will be automatically dropped and restarted if the server sends + /// no data within this interval. This prevents keeping a stale connection that may + /// no longer be working. It is common for SSE servers to send a simple comment line + /// (":") as a heartbeat to prevent timeouts. + /// + /// + /// The default value is . + /// + /// + /// the timeout + /// a new HttpConnectStrategy instance with this property modified + public HttpConnectStrategy ReadTimeout(TimeSpan readTimeout) => + new HttpConnectStrategy( + _uri, + _clientTransform, + _requestTransform, + _httpClient, + _httpMessageHandler, + ConfigurationBuilder.TimeSpanCanBeInfinite(readTimeout) + ); + + /// + /// Sets a factory for HTTP request body content, if the HTTP method is one that allows a request body. + /// + /// + /// This is in the form of a factory function because the request may need to be sent more than once. + /// + /// the factory function + /// a new HttpConnectStrategy instance with this property modified + public HttpConnectStrategy RequestBodyFactory(Func factory) => + AddRequestTransform(r => r.Content = factory()); + + /// + /// Sets the maximum amount of time EventSource will wait between starting an HTTP request and + /// receiving the response headers. + /// + /// + /// + /// This is the same as the Timeout property in .NET's HttpClient. The default value is + /// . + /// + /// + /// It is not the same as a TCP connection timeout. A connection timeout would include only the + /// time of establishing the connection, not the time it takes for the server to prepare the beginning + /// of the response. .NET does not consistently support a connection timeout, but if you are using .NET + /// Core or .NET 5+ you can implement it by using SocketsHttpHandler as your + /// and setting the + /// ConnectTimeout property there. + /// + /// + /// the timeout + /// a new HttpConnectStrategy instance with this property modified + public HttpConnectStrategy ResponseStartTimeout(TimeSpan timeout) => + AddClientTransform(c => c.Timeout = ConfigurationBuilder.TimeSpanCanBeInfinite(timeout)); + + /// + /// Specifies a different stream URI. + /// + /// the stream URI; must not be null + /// a new HttpConnectStrategy instance with this property modified + public HttpConnectStrategy Uri(Uri uri) => + new HttpConnectStrategy( + uri, + _clientTransform, + _requestTransform, + _httpClient, + _httpMessageHandler, + _readTimeout + ); + + private HttpConnectStrategy AddClientTransform(Action addedAction) => + addedAction is null ? this : + new HttpConnectStrategy(_uri, _clientTransform.AndThen(addedAction), _requestTransform, + _httpClient, _httpMessageHandler, _readTimeout); + + // This method is used to chain together all actions that affect the HTTP request + private HttpConnectStrategy AddRequestTransform(Action addedAction) => + addedAction is null ? this : + new HttpConnectStrategy(_uri, _clientTransform, _requestTransform.AndThen(addedAction), + _httpClient, _httpMessageHandler, _readTimeout); + + internal sealed class ClientImpl : Client + { + private readonly HttpConnectStrategy _config; + private readonly HttpClient _httpClient; + private readonly bool _disposeClient; + private readonly Logger _logger; + + // visible for testing + internal HttpClient HttpClient => _httpClient; + + public ClientImpl(HttpConnectStrategy config, Logger logger) + { + _config = config; + _logger = logger; + + if (_config._httpClient is null) + { + _httpClient = _config._httpMessageHandler is null ? + new HttpClient() : + new HttpClient(_config._httpMessageHandler, false); + _config._clientTransform.Apply(_httpClient); + _disposeClient = true; + } + else + { + _httpClient = _config._httpClient; + _disposeClient = false; + } + } + + public override void Dispose() + { + if (_disposeClient) + { + _httpClient.Dispose(); + } + } + + public override async Task ConnectAsync(Params p) + { + var request = CreateRequest(p); + _logger.Debug("Making {0} request to EventSource URI {1}", + request.Method, _config._uri); + HttpResponseMessage response; + response = await _httpClient.SendAsync(request, + HttpCompletionOption.ResponseHeadersRead, + p.CancellationToken).ConfigureAwait(false); + var valid = false; + try + { + ValidateResponse(response); + valid = true; + } + finally + { + if (!valid) + { + response.Dispose(); + } + } + return new Result + { + Stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false), + ReadTimeout = _config._readTimeout, + Closer = response + }; + } + + private HttpRequestMessage CreateRequest(Params p) + { + var request = new HttpRequestMessage(HttpMethod.Get, _config._uri); + + if (!string.IsNullOrWhiteSpace(p.LastEventId)) + { + request.Headers.Remove(Constants.LastEventIdHttpHeader); + request.Headers.Add(Constants.LastEventIdHttpHeader, p.LastEventId); + } + + _config._requestTransform.Apply(request); + + // The Accept header must always be sent by SSE clients + if (!request.Headers.Contains(Constants.AcceptHttpHeader)) + { + request.Headers.Add(Constants.AcceptHttpHeader, Constants.EventStreamContentType); + } + + request.Headers.ExpectContinue = false; + request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true }; + + return request; + } + + private void ValidateResponse(HttpResponseMessage response) + { + // Any non-2xx response status is an error. A 204 (no content) is also an error. + if (!response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.NoContent) + { + throw new StreamHttpErrorException((int)response.StatusCode); + } + + if (response.Content is null) + { + throw new StreamClosedByServerException(); + } + + var contentType = response.Content.Headers.ContentType; + var encoding = DetectEncoding(response); + if (contentType.MediaType != Constants.EventStreamContentType || encoding != Encoding.UTF8) + { + throw new StreamContentException(contentType, encoding); + } + } + + private static Encoding DetectEncoding(HttpResponseMessage response) + { + var charset = response.Content.Headers.ContentType?.CharSet; + if (charset != null) + { + try + { + return Encoding.GetEncoding(charset); + } + catch (ArgumentException) { } + } + return Encoding.UTF8; + } + } + } +} diff --git a/src/LaunchDarkly.EventSource/IEventSource.cs b/src/LaunchDarkly.EventSource/IEventSource.cs index fbeb03f..8441447 100644 --- a/src/LaunchDarkly.EventSource/IEventSource.cs +++ b/src/LaunchDarkly.EventSource/IEventSource.cs @@ -1,5 +1,8 @@ using System; using System.Threading.Tasks; +using LaunchDarkly.EventSource.Events; +using LaunchDarkly.EventSource.Exceptions; +using LaunchDarkly.Logging; namespace LaunchDarkly.EventSource { @@ -8,37 +11,66 @@ namespace LaunchDarkly.EventSource /// public interface IEventSource { - #region Public Events + #region Public Properties /// - /// Occurs when the connection to the EventSource API has been opened. + /// Gets the state of the EventSource connection. /// - event EventHandler Opened; + ReadyState ReadyState { get; } + /// - /// Occurs when the connection to the EventSource API has been closed. + /// Returns the current base retry delay. /// - event EventHandler Closed; + /// + /// + /// This is initially set by , + /// or if not specified. + /// It can be overridden by the stream provider if the stream contains a + /// "retry:" line. + /// + /// + /// The actual retry delay for any given reconnection is computed by applying the + /// backoff/jitter algorithm to this value. + /// + /// + TimeSpan BaseRetryDelay { get; } + /// - /// Occurs when a Server Sent Event from the EventSource API has been received. + /// The ID value, if any, of the last known event. /// - event EventHandler MessageReceived; + /// + /// This can be set initially with , + /// and is updated whenever an event is received that has an ID. Whether event IDs + /// are supported depends on the server; it may ignore this value. + /// + string LastEventId { get; } + /// - /// Occurs when a comment has been received from the EventSource API. + /// The retry delay that will be used for the next reconnection, if the + /// stream has failed. /// - event EventHandler CommentReceived; + /// + /// + /// If you have just received a or + /// , this value tells you how long EventSource will + /// sleep before reconnecting, if you tell it to reconnect by calling + /// or by trying to read another event. The value + /// is computed by applying the backoff/jitter algorithm to the current + /// value of . If there has not been a stream + /// failure, the value is null. + /// + /// + TimeSpan? NextRetryDelay { get; } + /// - /// Occurs when an error has happened when the EventSource is open and processing Server Sent Events. + /// The stream URI. /// - event EventHandler Error; - - #endregion Public Events - - #region Public Properties + Uri Origin { get; } /// - /// Gets the state of the EventSource connection. + /// The configured logging destination. /// - ReadyState ReadyState { get; } + Logger Logger { get; } #endregion @@ -54,25 +86,90 @@ public interface IEventSource Task StartAsync(); /// - /// Triggers the same "close and retry" behavior as if an error had been encountered on the stream. + /// Attempts to receive a message from the stream. + /// + /// + /// + /// If the stream is not already active, this calls to + /// establish a connection. + /// + /// + /// As long as the stream is active, the method waits until a message is available. + /// If the stream fails, the default behavior is to throw a , + /// but you can configure an to allow the client to retry + /// transparently instead. However, the client will never retry if you called + /// or ; in that case, trying to + /// read will always throw a . + /// + /// + /// The returned may contain references to temporary internal + /// data that will be overwritten if you read another event. If you are going to store + /// the event somewhere past the point where you read another event, you should call + /// to obtain a long-lived copy. + /// + /// + /// This method must be called from the same thread that first started using the + /// stream (that is, the thread that called or read the + /// first event). + /// + /// + /// an SSE message + /// + Task ReadMessageAsync(); + + /// + /// Attempts to receive an event of any kind from the stream. + /// + /// + /// + /// This is similar to , except that instead of + /// specifically requesting a it also applies to the + /// other classes: , + /// , and . Use this method + /// if you want to be informed of any of those occurrences. + /// + /// + /// The error behavior is the same as , except + /// that if the is configured to let the client + /// continue, you will receive a describing the error + /// first, and then a once the stream is reconnected. + /// However, the client will never retry if you called or + /// ; in that case, trying to read will always + /// throw a . + /// + /// + /// If the returned event is a , it may contain references + /// to temporary internal data that will be overwritten if you read another event. + /// If you are going to store the event somewhere past the point where you read + /// another event, you should call to obtain + /// a long-lived copy. + /// + /// + /// This method must be called from the same thread that first started using the + /// stream (that is, the thread that called or read the + /// first event). + /// + /// + /// an event + Task ReadAnyEventAsync(); + + /// + /// Stops the stream connection if it is currently active. /// /// /// - /// If the stream is currently active, this closes the connection, waits for some amount of time - /// as determined by the usual backoff behavior (and ), and - /// then attempts to reconnect. If the stream is not yet connected, is already waiting to - /// reconnect, or has been permanently shut down, this has no effect. + /// Unlike the reading methods, you are allowed to call this method from any + /// thread. If you are reading events on a different thread, and automatic + /// retries are not enabled by an , the other thread + /// will receive a . /// /// - /// The method returns immediately without waiting for the reconnection to happen. You will - /// receive and events when it does happen (or an - /// event if the new connection attempt fails). + /// Calling or trying to read more events after this + /// will cause the stream to reconnect, using the same retry delay logic as if + /// the stream had been closed due to an error. /// /// - /// true if the delay before reconnection should be reset to - /// the lowest level (); false if it - /// should increase according to the usual exponential backoff logic - void Restart(bool resetBackoffDelay); + void Interrupt(); /// /// Closes the connection to the SSE server. The EventSource cannot be reopened after this. diff --git a/src/LaunchDarkly.EventSource/AsyncHelpers.cs b/src/LaunchDarkly.EventSource/Internal/AsyncHelpers.cs similarity index 99% rename from src/LaunchDarkly.EventSource/AsyncHelpers.cs rename to src/LaunchDarkly.EventSource/Internal/AsyncHelpers.cs index 340b295..8e7e6b3 100644 --- a/src/LaunchDarkly.EventSource/AsyncHelpers.cs +++ b/src/LaunchDarkly.EventSource/Internal/AsyncHelpers.cs @@ -2,7 +2,7 @@ using System.Threading; using System.Threading.Tasks; -namespace LaunchDarkly.EventSource +namespace LaunchDarkly.EventSource.Internal { internal static class AsyncHelpers { diff --git a/src/LaunchDarkly.EventSource/Internal/BufferedLineParser.cs b/src/LaunchDarkly.EventSource/Internal/BufferedLineParser.cs new file mode 100644 index 0000000..6c50863 --- /dev/null +++ b/src/LaunchDarkly.EventSource/Internal/BufferedLineParser.cs @@ -0,0 +1,153 @@ +using System.Threading.Tasks; +using LaunchDarkly.EventSource.Events; +using LaunchDarkly.EventSource.Exceptions; + +namespace LaunchDarkly.EventSource.Internal +{ + /// + /// Buffers a byte stream and returns it in chunks while scanning for line endings, which + /// may be any of CR (\r), LF (\n), or CRLF. The SSE specification allows any of these line + /// endings for any line in the stream. + /// + /// + /// + /// To use this class, we repeatedly call to obtain a piece of data. + /// Rather than copying this back into a buffer provided by the caller, BufferedLineParser + /// exposes its own fixed-size buffer directly and marks the portion being read; the caller + /// is responsible for inspecting this data before the next call to . + /// + /// + /// This class is not thread-safe. + /// + /// + internal class BufferedLineParser + { + // This abstraction is used instead of Stream because EventParser needs to wrap the + // stream read in its own timeout logic, but ByteArrayLineScanner doesn't need to + // know about the details of that. + internal delegate Task ReadFunc(byte[] b, int offset, int size); + + private ReadFunc _readFunc; + private readonly byte[] _readBuffer; + private int _readBufferCount, _scanPos, _chunkStart, _chunkEnd; + private bool _lastCharWasCr; + + internal struct Chunk + { + /// + /// A reference to the span of parsed data in the buffer. This is only valid + /// until the next time ReadAsync is called. The span never includes a line + /// terminator. + /// + public Utf8ByteSpan Span; + + /// + /// True if a line terminator followed this span. + /// + public bool EndOfLine; + } + + public BufferedLineParser( + ReadFunc readFunc, + int capacity + ) + { + _readFunc = readFunc; + _readBuffer = new byte[capacity]; + _scanPos = _readBufferCount = 0; + } + + /// + /// Attempts to read the next chunk. A chunk is terminated either by a line ending, or + /// by reaching the end of the buffer before the next read from the underlying stream. + /// + /// + public async Task ReadAsync() + { + if (_scanPos > 0 && _readBufferCount > _scanPos) + { + // Shift the data left to the start of the buffer to make room + System.Buffer.BlockCopy(_readBuffer, _scanPos, _readBuffer, 0, _readBufferCount - _scanPos); + } + _readBufferCount -= _scanPos; + _scanPos = _chunkStart = _chunkEnd = 0; + while (true) + { + if (_scanPos < _readBufferCount && ScanForTerminator()) + { + return new Chunk { Span = CurrentSpan, EndOfLine = true }; + } + if (_readBufferCount == _readBuffer.Length) + { + return new Chunk { Span = CurrentSpan, EndOfLine = false }; + } + if (!await ReadMoreIntoBuffer()) + { + throw new StreamClosedByServerException(); + } + } + } + + private Utf8ByteSpan CurrentSpan => + new Utf8ByteSpan(_readBuffer, _chunkStart, _chunkEnd - _chunkStart); + + private bool ScanForTerminator() + { + if (_lastCharWasCr) + { + // This handles the case where the previous reads ended in CR, so we couldn't tell + // at that time whether it was just a plain CR or part of a CRLF. We know that the + // previous line has ended either way, we just need to ensure that if the next byte + // is LF, we skip it. + _lastCharWasCr = false; + if (_readBuffer[_scanPos] == '\n') + { + _scanPos++; + _chunkStart++; + } + } + + while (_scanPos < _readBufferCount) + { + byte b = _readBuffer[_scanPos]; + if (b == '\n' || b == '\r') + { + break; + } + _scanPos++; + } + _chunkEnd = _scanPos; + if (_scanPos == _readBufferCount) + { + // We haven't found a terminator yet; we'll need to read more from the stream. + return false; + } + + _scanPos++; + if (_readBuffer[_chunkEnd] == '\r') + { + if (_scanPos == _readBufferCount) + { + _lastCharWasCr = true; + } + else if (_readBuffer[_scanPos] == '\n') + { + _scanPos++; + } + } + return true; + } + + private async Task ReadMoreIntoBuffer() + { + int readCount = await _readFunc(_readBuffer, _readBufferCount, + _readBuffer.Length - _readBufferCount); + if (readCount <= 0) + { + return false; // stream was closed + } + _readBufferCount += readCount; + return true; + } + } +} diff --git a/src/LaunchDarkly.EventSource/Constants.cs b/src/LaunchDarkly.EventSource/Internal/Constants.cs similarity index 59% rename from src/LaunchDarkly.EventSource/Constants.cs rename to src/LaunchDarkly.EventSource/Internal/Constants.cs index fb60edf..5d06411 100644 --- a/src/LaunchDarkly.EventSource/Constants.cs +++ b/src/LaunchDarkly.EventSource/Internal/Constants.cs @@ -1,4 +1,4 @@ -namespace LaunchDarkly.EventSource +namespace LaunchDarkly.EventSource.Internal { /// /// An internal class used to hold static values used when processing Server Sent Events. @@ -8,41 +8,36 @@ internal static class Constants /// /// The HTTP header name for Accept. /// - internal static string AcceptHttpHeader = "Accept"; + internal const string AcceptHttpHeader = "Accept"; /// /// The HTTP header name for the last event identifier. /// - internal static string LastEventIdHttpHeader = "Last-Event-ID"; + internal const string LastEventIdHttpHeader = "Last-Event-ID"; /// /// The HTTP header value for the Content Type. /// - internal static string EventStreamContentType = "text/event-stream"; + internal const string EventStreamContentType = "text/event-stream"; /// /// The event type name for a Retry in a Server Sent Event. /// - internal static string RetryField = "retry"; + internal const string RetryField = "retry"; /// /// The identifier field name in a Server Sent Event. /// - internal static string IdField = "id"; + internal const string IdField = "id"; /// /// The event type field name in a Server Sent Event. /// - internal static string EventField = "event"; + internal const string EventField = "event"; /// /// The data field name in a Server Sent Event. /// - internal static string DataField = "data"; - - /// - /// The message field name in a Server Sent Event. - /// - internal static string MessageField = "message"; + internal const string DataField = "data"; } } diff --git a/src/LaunchDarkly.EventSource/Internal/EventParser.cs b/src/LaunchDarkly.EventSource/Internal/EventParser.cs new file mode 100644 index 0000000..0c7b0e3 --- /dev/null +++ b/src/LaunchDarkly.EventSource/Internal/EventParser.cs @@ -0,0 +1,307 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.EventSource.Events; +using LaunchDarkly.Logging; + +using static LaunchDarkly.EventSource.Internal.AsyncHelpers; + +namespace LaunchDarkly.EventSource.Internal +{ + /// + /// An internal class containing helper methods to parse Server Sent Event data. + /// + internal sealed class EventParser + { + private const int ReadBufferSize = 1000; + private const int ValueBufferInitialCapacity = 1000; + + private readonly Stream _stream; + private readonly BufferedLineParser _lineParser; + private readonly TimeSpan _readTimeout; + private readonly Uri _origin; + private readonly CancellationToken _cancellationToken; + private readonly Logger _logger; + + private Utf8ByteSpan _chunk; + private bool _lineEnded; + private int _currentLineLength; + + private MemoryStream _dataBuffer; // accumulates "data" lines + private MemoryStream _valueBuffer; // used whenever a field other than "data" has a value longer than one chunk + + private bool _haveData; // true if we have seen at least one "data" line so far in this event + private bool _dataLineEnded; // true if the previous chunk of "data" ended in a line terminator + private string _fieldName; // name of the field we are currently parsing (might be spread across multiple chunks) + private string _eventName; // value of "event:" field in this event, if any + private string _lastEventId; // value of "id:" field in this event, if any + private bool _skipRestOfLine; // true if we are skipping over an invalid line + + public EventParser( + Stream stream, + TimeSpan readTimeout, + Uri origin, + CancellationToken cancellationToken, + Logger logger + ) + { + _stream = stream; + _lineParser = new BufferedLineParser( + ReadFromStream, + ReadBufferSize + ); + _readTimeout = readTimeout; + _origin = origin; + _cancellationToken = cancellationToken; + _logger = logger; + + _dataBuffer = new MemoryStream(ValueBufferInitialCapacity); + } + + /// + /// Synchronously obtains the next event from the stream-- either parsing + /// already-read data from the read buffer, or reading more data if necessary, + /// until an event is available. + /// + /// + /// + /// This method always either returns an event or throws an exception. If it + /// throws an exception, the stream should be considered invalid/closed. + /// + /// + /// The return value is always a MessageEvent, a CommentEvent, or a + /// SetRetryDelayEvent. StartedEvent and FaultEvent are not returned by this + /// method because they are by-products of state changes in EventSource. + /// + /// + /// the next event + public async Task NextEventAsync() + { + while (true) + { + var e = await TryNextEventAsync(); + if (e != null) + { + return e; + } + } + } + + // This inner method exists just to simplify control flow: whenever we need to + // obtain some more data before continuing, we can just return null so NextEvent + // will call us again. + private async Task TryNextEventAsync() + { + await GetNextChunk(); // throws exception if stream has closed + + if (_skipRestOfLine) + { + // If we're in this state, it means we already know we want to ignore this line and + // not bother buffering or parsing the rest of it - just keep reading till we see a + // line terminator. We do this if we couldn't find a colon in the first chunk we read, + // meaning that the field name can't possibly be valid even if there is a colon later. + _skipRestOfLine = !_lineEnded; + return null; + } + + if (_lineEnded && _currentLineLength == 0) + { + // Blank line means end of message-- if we're currently reading a message. + if (!_haveData) + { + ResetState(); + return null; // no event available yet, loop for more data + } + + var name = _eventName ?? MessageEvent.DefaultName; + _eventName = null; + var dataSpan = new Utf8ByteSpan(_dataBuffer.GetBuffer(), 0, (int)_dataBuffer.Length); + MessageEvent message = new MessageEvent(name, dataSpan, _lastEventId, _origin); + // We've now taken ownership of the original buffer; ResetState will null out the + // previous reference to it so a new one will be created next time + ResetState(); + _logger.Debug("Received event \"{0}\"", message.Name); + return message; + } + + if (_fieldName is null) + { // we haven't yet parsed the field name + _fieldName = ParseFieldName(); + if (_fieldName is null) + { + // We didn't find a colon. Since the capacity of our line buffer is always greater + // than the length of the longest valid SSE field name plus a colon, the chunk that + // we have now is either a field name with no value... or, if we haven't yet hit a + // line terminator, it could be an extremely long field name that didn't fit in the + // buffer, but in that case it is definitely not a real SSE field since those all + // have short names, so then we know we can skip the rest of this line. + _skipRestOfLine = !_lineEnded; + return null; // no event available yet, loop for more data + } + } + + if (_fieldName == Constants.DataField) + { + // Accumulate this data in a buffer until we've seen the end of the event. + if (_dataLineEnded) + { + _dataBuffer.WriteByte((byte)'\n'); + } + if (_chunk.Length != 0) + { + _dataBuffer.Write(_chunk.Data, _chunk.Offset, _chunk.Length); + } + _dataLineEnded = _lineEnded; + _haveData = true; + if (_lineEnded) + { + _fieldName = null; + } + return null; // no event available yet, loop for more data + } + + // For any field other than "data:", there can only be a single line of a value and + // we just get the whole value as a string. If the whole line fits into the buffer + // then we can do this in one step; otherwise we'll accumulate chunks in another + // buffer until the line is done. + if (!_lineEnded) + { + if (_valueBuffer is null) + { + _valueBuffer = new MemoryStream(ValueBufferInitialCapacity); + } + _valueBuffer.Write(_chunk.Data, _chunk.Offset, _chunk.Length); + return null; // Don't have a full event yet + } + + var completedFieldName = _fieldName; + _fieldName = null; // next line will need a field name + string fieldValue; + if (_valueBuffer is null || _valueBuffer.Length == 0) + { + fieldValue = _chunk.GetString(); + } + else + { + // we had accumulated a partial value in a previous read + _valueBuffer.Write(_chunk.Data, _chunk.Offset, _chunk.Length); + fieldValue = Encoding.UTF8.GetString(_valueBuffer.GetBuffer(), 0, (int)_valueBuffer.Length); + ResetValueBuffer(); + } + + switch (completedFieldName) + { + case "": + _logger.Debug("Received comment: {0}", fieldValue); + return new CommentEvent(fieldValue); + case Constants.EventField: + _eventName = fieldValue; + break; + case Constants.IdField: + if (!fieldValue.Contains("\x00")) // per SSE spec, id field cannot contain a null character + { + _lastEventId = fieldValue; + } + break; + case Constants.RetryField: + if (long.TryParse(fieldValue, out var millis)) + { + return new SetRetryDelayEvent(TimeSpan.FromMilliseconds(millis)); + } + // ignore any non-numeric value + break; + default: + // For an unrecognized field name, we do nothing. + break; + } + return null; + } + + private async Task GetNextChunk() + { + var chunk = await _lineParser.ReadAsync(); // throws exception if stream has closed + _chunk = chunk.Span; + var previousLineEnded = _lineEnded; + _lineEnded = chunk.EndOfLine; + if (previousLineEnded) + { + _currentLineLength = 0; + } + _currentLineLength += _chunk.Length; + } + + private string ParseFieldName() + { + int offset = _chunk.Offset, length = _chunk.Length; + int nameLength = 0; + for (; nameLength < length && _chunk.Data[offset + nameLength] != ':'; nameLength++) { } + ResetValueBuffer(); + if (nameLength == length && !_lineEnded) + { + // The line was longer than the buffer, and we did not find a colon. Since no valid + // SSE field name would be longer than our buffer, we can consider this line invalid. + // (But if lineEnded is true, that's OK-- a line consisting of nothing but a field + // name is OK in SSE-- so we'll fall through below in that case.) + return null; + } + String name = nameLength == 0 ? "" : Encoding.UTF8.GetString(_chunk.Data, offset, nameLength); + if (nameLength < length) + { + nameLength++; + if (nameLength < length && _chunk.Data[offset + nameLength] == ' ') + { + // Skip exactly one leading space at the start of the value, if any + nameLength++; + } + } + _chunk = new Utf8ByteSpan(_chunk.Data, offset + nameLength, length - nameLength); + return name; + } + + private void ResetState() + { + _haveData = _dataLineEnded = false; + _eventName = _fieldName = null; + ResetValueBuffer(); + if (_dataBuffer.Length != 0) + { + if (_dataBuffer.Length > ValueBufferInitialCapacity) + { + _dataBuffer = null; // don't want it to grow indefinitely + } + else + { + _dataBuffer.SetLength(0); + } + } + } + + private void ResetValueBuffer() + { + if (_valueBuffer != null) + { + if (_valueBuffer.Length > ValueBufferInitialCapacity) + { + _valueBuffer = null; // don't want it to grow indefinitely, and might not ever need it again + } + else + { + _valueBuffer.SetLength(0); + } + } + } + + private Task ReadFromStream(byte[] b, int offset, int size) + { + // Note that even though Stream.ReadAsync has an overload that takes a CancellationToken, that + // does not actually work for network sockets (https://stackoverflow.com/questions/12421989/networkstream-readasync-with-a-cancellation-token-never-cancels). + // So we must use AsyncHelpers.AllowCancellation to wrap it in a cancellable task. + return DoWithTimeout(_readTimeout, _cancellationToken, + token => AllowCancellation( + _stream.ReadAsync(b, offset, size), + token)); + } + } +} diff --git a/src/LaunchDarkly.EventSource/Internal/SetRetryDelayEvent.cs b/src/LaunchDarkly.EventSource/Internal/SetRetryDelayEvent.cs new file mode 100644 index 0000000..c1b6e64 --- /dev/null +++ b/src/LaunchDarkly.EventSource/Internal/SetRetryDelayEvent.cs @@ -0,0 +1,16 @@ +using System; +using LaunchDarkly.EventSource.Events; + +namespace LaunchDarkly.EventSource.Internal +{ + internal class SetRetryDelayEvent : IEvent + { + public TimeSpan RetryDelay { get; } + + public SetRetryDelayEvent(TimeSpan retryDelay) + { + RetryDelay = retryDelay; + } + } +} + diff --git a/src/LaunchDarkly.EventSource/Internal/ValueWithLock.cs b/src/LaunchDarkly.EventSource/Internal/ValueWithLock.cs new file mode 100644 index 0000000..898c965 --- /dev/null +++ b/src/LaunchDarkly.EventSource/Internal/ValueWithLock.cs @@ -0,0 +1,43 @@ +using System; + +namespace LaunchDarkly.EventSource.Internal +{ + /// + /// Simple concurrency helper for a property that should always be read or + /// written under lock. In some cases we can simply use a volatile field + /// instead, but volatile can't be used for some types. + /// + /// the value type + internal sealed class ValueWithLock + { + private readonly object _lockObject; + private T _value; + + public ValueWithLock(object lockObject, T initialValue) + { + _lockObject = lockObject; + _value = initialValue; + } + + public T Get() + { + lock (_lockObject) { return _value; } + } + + public void Set(T value) + { + lock (_lockObject) { _value = value; } + } + + public T GetAndSet(T newValue) + { + lock (_lockObject) + { + var oldValue = _value; + _value = newValue; + return oldValue; + } + } + } +} + diff --git a/src/LaunchDarkly.EventSource/LaunchDarkly.EventSource.csproj b/src/LaunchDarkly.EventSource/LaunchDarkly.EventSource.csproj index 6853041..19ca693 100644 --- a/src/LaunchDarkly.EventSource/LaunchDarkly.EventSource.csproj +++ b/src/LaunchDarkly.EventSource/LaunchDarkly.EventSource.csproj @@ -47,6 +47,18 @@ + + + + + + + + + + + + bin\$(Configuration)\$(TargetFramework)\LaunchDarkly.EventSource.xml diff --git a/src/LaunchDarkly.EventSource/Resources.Designer.cs b/src/LaunchDarkly.EventSource/Resources.Designer.cs index 8c722f6..74033d1 100644 --- a/src/LaunchDarkly.EventSource/Resources.Designer.cs +++ b/src/LaunchDarkly.EventSource/Resources.Designer.cs @@ -94,20 +94,29 @@ internal static string ErrorReadTimeout { } /// - /// Looks up a localized string similar to Unexpected HTTP content type "{0}"; should be "text/event-stream". + /// Looks up a localized string similar to HTTP content type was "{0}" with encoding {1}; should be "text/event-stream" and UTF-8. /// - internal static string ErrorWrongContentType { + internal static string ErrorWrongContentTypeOrEncoding { get { - return ResourceManager.GetString("ErrorWrongContentType", resourceCulture); + return ResourceManager.GetString("ErrorWrongContentTypeOrEncoding", resourceCulture); } } /// - /// Looks up a localized string similar to Unexpected character encoding {0}; should be UTF-8. + /// Looks up a localized string similar to the stream was closed from the client side. /// - internal static string ErrorWrongEncoding { + internal static string StreamClosedByCaller { get { - return ResourceManager.GetString("ErrorWrongEncoding", resourceCulture); + return ResourceManager.GetString("StreamClosedByCaller", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to the stream was closed from the server side. + /// + internal static string StreamClosedByServer { + get { + return ResourceManager.GetString("StreamClosedByServer", resourceCulture); } } } diff --git a/src/LaunchDarkly.EventSource/Resources.resx b/src/LaunchDarkly.EventSource/Resources.resx index 82bf32d..658378d 100644 --- a/src/LaunchDarkly.EventSource/Resources.resx +++ b/src/LaunchDarkly.EventSource/Resources.resx @@ -126,13 +126,16 @@ Unexpected HTTP status code {0} from server - - Unexpected HTTP content type "{0}"; should be "text/event-stream" - - - Unexpected character encoding {0}; should be UTF-8 + + HTTP content type was "{0}" with encoding {1}; should be "text/event-stream" and UTF-8 HTTP response had no body + + the stream was closed from the server side + + + the stream was closed from the client side + \ No newline at end of file diff --git a/src/LaunchDarkly.EventSource/RetryDelayStrategy.cs b/src/LaunchDarkly.EventSource/RetryDelayStrategy.cs new file mode 100644 index 0000000..2616dd9 --- /dev/null +++ b/src/LaunchDarkly.EventSource/RetryDelayStrategy.cs @@ -0,0 +1,62 @@ +using System; +namespace LaunchDarkly.EventSource +{ + /// + /// An abstraction of how should determine the delay + /// between retry attempts when a stream fails. + /// + /// + /// + /// The default behavior, provided by {@link DefaultRetryDelayStrategy}, provides + /// customizable exponential backoff and jitter. Applications may also create their own + /// implementations of RetryDelayStrategy if they desire different behavior. It is + /// generally a best practice to use backoff and jitter, to avoid a reconnect storm + /// during a service interruption. + /// + /// + /// Subclasses should be immutable. To implement strategies where the delay uses + /// different parameters on each subsequent retry (such as exponential backoff), + /// the strategy should return a new instance of its own class in + /// , rather than modifying the state of the existing + /// instance. This makes it easy for EventSource to reset to the original delay + /// state when appropriate by simply reusing the original instance. + /// + /// + public abstract class RetryDelayStrategy + { + /// + /// The return type of . + /// + public struct Result + { + /// + /// The action that EventSource should take. + /// + public TimeSpan Delay { get; set; } + + /// + /// The strategy instance to be used for the next retry, or null to use the + /// same instance as last time. + /// + public RetryDelayStrategy Next { get; set; } + } + + /// + /// Applies the strategy to compute the appropriate retry delay. + /// + /// the initial configured base delay + /// the result + public abstract Result Apply(TimeSpan baseRetryDelay); + + /// + /// Returns the default implementation, configured to use the default backoff + /// and jitter. + /// + /// + /// You can call methods on this object + /// to configure a strategy with different parameters. + /// + public static DefaultRetryDelayStrategy Default => + DefaultRetryDelayStrategy.Instance; + } +} diff --git a/test/LaunchDarkly.EventSource.Tests/BaseTest.cs b/test/LaunchDarkly.EventSource.Tests/BaseTest.cs index b11e870..21b1c88 100644 --- a/test/LaunchDarkly.EventSource.Tests/BaseTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/BaseTest.cs @@ -1,12 +1,14 @@ using System; using System.Net.Http; using System.Threading; +using System.Threading.Tasks; using LaunchDarkly.Logging; using LaunchDarkly.TestHelpers.HttpTest; using Xunit; using Xunit.Abstractions; +using static System.Net.WebRequestMethods; -namespace LaunchDarkly.EventSource.Tests +namespace LaunchDarkly.EventSource { // [Collection] causes all the tests to be grouped together so they're not parallelized. Running // tests in parallel would make task scheduling very unpredictable, increasing the chances of @@ -16,6 +18,8 @@ public abstract class BaseTest { public static readonly Uri _uri = new Uri("http://test-uri"); + public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(1); + /// /// Tests can use this object wherever an is needed, to /// direct log output to both 1. the Xunit test output buffer and 2. . @@ -75,26 +79,78 @@ protected EventSource MakeEventSource(Uri uri, Action modC return new EventSource(builder.Build()); } - protected EventSource MakeEventSource(HttpMessageHandler httpHandler, Action modConfig = null) => - MakeEventSource(_uri, builder => - { - builder.HttpMessageHandler(httpHandler); - modConfig?.Invoke(builder); - }); + protected EventSource MakeEventSource( + Uri uri, + Func modHttp = null, + Action modConfig = null + ) + { + var http = ConnectStrategy.Http(uri); + http = modHttp is null ? http : modHttp(http); + var builder = Configuration.Builder(http) + .LogAdapter(_testLogging); + AddBaseConfig(builder); + modConfig?.Invoke(builder); + return new EventSource(builder.Build()); + } protected void WithServerAndEventSource(Handler handler, Action action) => - WithServerAndEventSource(handler, null, action); + WithServerAndEventSource(handler, null, null, action); - protected void WithServerAndEventSource(Handler handler, Action modConfig, Action action) + protected void WithServerAndEventSource( + Handler handler, + Func modHttp, + Action modConfig, + Action action + ) { using (var server = HttpServer.Start(handler)) { - using (var es = MakeEventSource(server.Uri, modConfig)) + using (var es = MakeEventSource(server.Uri, modHttp, modConfig)) { action(server, es); } } } + + protected async Task WithServerAndEventSource(Handler handler, Func action) => + await WithServerAndEventSource(handler, null, null, action); + + protected async Task WithServerAndEventSource( + Handler handler, + Func modHttp, + Action modConfig, + Func action + ) + { + using (var server = HttpServer.Start(handler)) + { + using (var es = MakeEventSource(server.Uri, modHttp, modConfig)) + { + await action(server, es); + } + } + } + + protected Task WithMockConnectEventSource( + Action configureMockConnect, + Action configureEventSource, + Func action + ) + { + var mock = new MockConnectStrategy(); + configureMockConnect?.Invoke(mock); + var builder = Configuration.Builder(mock) + .LogAdapter(_testLogging); + AddBaseConfig(builder); + configureEventSource?.Invoke(builder); + return action(mock, new EventSource(builder.Build())); + } + + protected Task WithMockConnectEventSource( + Action configureMockConnect, + Func action + ) => WithMockConnectEventSource(configureMockConnect, null, action); /// /// Override this method to add configuration defaults to the behavior of @@ -103,6 +159,9 @@ protected void WithServerAndEventSource(Handler handler, Action protected virtual void AddBaseConfig(ConfigurationBuilder builder) { } + protected static Handler StreamWithCommentThatStaysOpen => + Handlers.SSE.Start().Then(Handlers.SSE.Comment("")).Then(Handlers.SSE.LeaveOpen()); + protected static Handler EmptyStreamThatStaysOpen => Handlers.SSE.Start().Then(Handlers.SSE.LeaveOpen()); } diff --git a/test/LaunchDarkly.EventSource.Tests/ByteArrayLineScannerTest.cs b/test/LaunchDarkly.EventSource.Tests/ByteArrayLineScannerTest.cs deleted file mode 100644 index 3958452..0000000 --- a/test/LaunchDarkly.EventSource.Tests/ByteArrayLineScannerTest.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System; -using System.Text; -using Xunit; - -namespace LaunchDarkly.EventSource.Tests -{ - public class ByteArrayLineScannerTest - { - [Fact] - public void EmptyBufferReturnsFalse() - { - var ls = new ByteArrayLineScanner(100); - Assert.False(ls.ScanToEndOfLine(out _)); - } - - [Fact] - public void ScannerParsesLinesWithLF() - { - var ls = new ByteArrayLineScanner(100); - - AddBytes(ref ls, "first line"); - Assert.False(ls.ScanToEndOfLine(out _)); - - AddBytes(ref ls, " is good\nsecond"); - Assert.True(ls.ScanToEndOfLine(out var line1)); - Assert.Equal("first line is good", line1.GetString()); - - AddBytes(ref ls, " line is better\nthird line's the charm\n"); - Assert.True(ls.ScanToEndOfLine(out var line2)); - Assert.Equal("second line is better", line2.GetString()); - Assert.True(ls.ScanToEndOfLine(out var line3)); - Assert.Equal("third line's the charm", line3.GetString()); - - // The last character in the buffer was a line ending, so it should have reset the buffer - Assert.Equal(0, ls.Count); - Assert.Equal(ls.Capacity, ls.Available); - } - - [Fact] - public void ScannerParsesLinesWithCRLF() - { - var ls = new ByteArrayLineScanner(100); - - AddBytes(ref ls, "first line"); - Assert.False(ls.ScanToEndOfLine(out _)); - - AddBytes(ref ls, " is good\r\nsecond"); - Assert.True(ls.ScanToEndOfLine(out var line1)); - Assert.Equal("first line is good", line1.GetString()); - - AddBytes(ref ls, " line is better\r\nthird line's the charm\r\n"); - Assert.True(ls.ScanToEndOfLine(out var line2)); - Assert.Equal("second line is better", line2.GetString()); - Assert.True(ls.ScanToEndOfLine(out var line3)); - Assert.Equal("third line's the charm", line3.GetString()); - - // The last character in the buffer was a line ending, so it should have reset the buffer - Assert.Equal(0, ls.Count); - Assert.Equal(ls.Capacity, ls.Available); - } - - [Fact] - public void ScannerParsesLinesWithCR() - { - var ls = new ByteArrayLineScanner(100); - - AddBytes(ref ls, "first line"); - Assert.False(ls.ScanToEndOfLine(out _)); - - AddBytes(ref ls, " is good\rsecond"); - Assert.True(ls.ScanToEndOfLine(out var line1)); - Assert.Equal("first line is good", line1.GetString()); - - AddBytes(ref ls, " line is better\rthird line's the charm\r"); - Assert.True(ls.ScanToEndOfLine(out var line2)); - Assert.Equal("second line is better", line2.GetString()); - - // The last character in the buffer was a CR, so it can't say the line is done till it sees another byte. - Assert.False(ls.ScanToEndOfLine(out var _)); - - AddBytes(ref ls, "x"); - Assert.True(ls.ScanToEndOfLine(out var line3)); - Assert.Equal("third line's the charm", line3.GetString()); - - // The last character in the buffer was not a line ending, so it should not have reset the buffer - Assert.NotEqual(0, ls.Count); - Assert.NotEqual(ls.Capacity, ls.Available); - } - - [Fact] - public void ScannerAccumulatesPartialLines() - { - var ls = new ByteArrayLineScanner(10); // deliberately small - - AddBytes(ref ls, "012345"); - Assert.False(ls.ScanToEndOfLine(out _)); - Assert.Equal(6, ls.Count); - Assert.Equal(4, ls.Available); - - AddBytes(ref ls, "6789"); - Assert.False(ls.ScanToEndOfLine(out _)); - Assert.Equal(0, ls.Count); // it moved the data elsewhere and cleared the buffer to make room for more - Assert.Equal(10, ls.Available); - - AddBytes(ref ls, "abcd"); - Assert.False(ls.ScanToEndOfLine(out _)); - - AddBytes(ref ls, "ef\ngh"); - Assert.True(ls.ScanToEndOfLine(out var line1)); - Assert.Equal("0123456789abcdef", line1.GetString()); - Assert.Equal(2, ls.Count); - Assert.Equal(8, ls.Available); - - AddBytes(ref ls, "\n"); - Assert.True(ls.ScanToEndOfLine(out var line2)); - Assert.Equal("gh", line2.GetString()); - } - - private static void AddBytes(ref ByteArrayLineScanner ls, string s) - { - var bytes = Encoding.UTF8.GetBytes(s); - Buffer.BlockCopy(bytes, 0, ls.Buffer, ls.Count, bytes.Length); - ls.AddedBytes(bytes.Length); - } - } -} diff --git a/test/LaunchDarkly.EventSource.Tests/ConfigurationBuilderTest.cs b/test/LaunchDarkly.EventSource.Tests/ConfigurationBuilderTest.cs index a00e201..89a144d 100644 --- a/test/LaunchDarkly.EventSource.Tests/ConfigurationBuilderTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/ConfigurationBuilderTest.cs @@ -1,66 +1,57 @@ using System; using System.Collections.Generic; -using System.Net.Http; using System.Threading; -using Xunit; using LaunchDarkly.Logging; +using Xunit; -namespace LaunchDarkly.EventSource.Tests +namespace LaunchDarkly.EventSource { public class ConfigurationBuilderTests { private static readonly Uri uri = new Uri("http://test"); [Fact] - public void BuilderSetsUri() + public void BuilderRejectsNullParameter() { - var b = Configuration.Builder(uri); - Assert.Equal(uri, b.Build().Uri); + Assert.ThrowsAny(() => + Configuration.Builder((Uri)null)); + Assert.ThrowsAny(() => + Configuration.Builder((ConnectStrategy)null)); } [Fact] - public void BuilderRejectsNullUri() + public void CanSetConnectStrategy() { - var e = Record.Exception(() => Configuration.Builder(null)); - Assert.IsType(e); + var cs = ConnectStrategy.Http(uri); + Assert.Same(cs, Configuration.Builder(cs).Build().ConnectStrategy); } -#pragma warning disable 0618 [Fact] - public void DeprecatedConnectionTimeoutHasDefault() + public void CanSetConnectStrategyWithUri() { - var b = Configuration.Builder(uri); - Assert.Equal(Configuration.DefaultConnectionTimeout, b.Build().ConnectionTimeout); - Assert.Equal(Configuration.DefaultConnectionTimeout, b.Build().ResponseStartTimeout); + var cs = Configuration.Builder(uri).Build(); + Assert.Equal(uri, Assert.IsType(cs.ConnectStrategy).Origin); } [Fact] - public void BuilderSetsDeprecatedConnectionTimeout() + public void CanSetErrorStrategy() { - var ts = TimeSpan.FromSeconds(9); - var b = Configuration.Builder(uri).ConnectionTimeout(ts); - Assert.Equal(ts, b.Build().ConnectionTimeout); - Assert.Equal(ts, b.Build().ResponseStartTimeout); - } + Assert.Same(ErrorStrategy.AlwaysThrow, + Configuration.Builder(uri).Build().ErrorStrategy); - [Fact] - public void DeprecatedConnectionTimeoutCanBeInfinite() - { - var ts = Timeout.InfiniteTimeSpan; - var b = Configuration.Builder(uri).ConnectionTimeout(ts); - Assert.Equal(ts, b.Build().ConnectionTimeout); - Assert.Equal(ts, b.Build().ResponseStartTimeout); + var es = ErrorStrategy.AlwaysContinue; + Assert.Same(es, Configuration.Builder(uri).ErrorStrategy(es).Build().ErrorStrategy); } [Fact] - public void AnyNegativeDeprecatedConnectionTimeoutIsInfinite() + public void CanSetRetryDelayStrategy() { - var ts = TimeSpan.FromSeconds(-9); - var b = Configuration.Builder(uri).ConnectionTimeout(ts); - Assert.Equal(Timeout.InfiniteTimeSpan, b.Build().ConnectionTimeout); - Assert.Equal(Timeout.InfiniteTimeSpan, b.Build().ResponseStartTimeout); + Assert.Same(RetryDelayStrategy.Default, + Configuration.Builder(uri).Build().RetryDelayStrategy); + + var rs = RetryDelayStrategy.Default.BackoffMultiplier(4); + Assert.Same(rs, Configuration.Builder(uri).RetryDelayStrategy(rs).Build().RetryDelayStrategy); } -#pragma warning restore 0618 [Fact] public void InitialRetryDelayRetryHasDefault() @@ -85,90 +76,6 @@ public void NegativeInitialRetryDelayBecomesZero() Assert.Equal(TimeSpan.Zero, b.Build().InitialRetryDelay); } - [Fact] - public void MaxRetryDelayHasDefault() - { - var b = Configuration.Builder(uri); - Assert.Equal(Configuration.DefaultMaxRetryDelay, b.Build().MaxRetryDelay); - } - - [Fact] - public void BuilderSetsMaxRetryDelay() - { - var ts = TimeSpan.FromSeconds(9); - var b = Configuration.Builder(uri).MaxRetryDelay(ts); - Assert.Equal(ts, b.Build().MaxRetryDelay); - } - - [Fact] - public void NegativeMaxRetryDelayBecomesZero() - { - var ts = Timeout.InfiniteTimeSpan; - var b = Configuration.Builder(uri).MaxRetryDelay(TimeSpan.FromSeconds(-9)); - Assert.Equal(TimeSpan.Zero, b.Build().MaxRetryDelay); - } - - [Fact] - public void ReadTimeoutHasDefault() - { - var b = Configuration.Builder(uri); - Assert.Equal(Configuration.DefaultReadTimeout, b.Build().ReadTimeout); - } - - [Fact] - public void BuilderSetsReadTimeout() - { - var ts = TimeSpan.FromSeconds(9); - var b = Configuration.Builder(uri).ReadTimeout(ts); - Assert.Equal(ts, b.Build().ReadTimeout); - } - - [Fact] - public void ReadTimeoutCanBeInfinite() - { - var ts = Timeout.InfiniteTimeSpan; - var b = Configuration.Builder(uri).ReadTimeout(ts); - Assert.Equal(ts, b.Build().ReadTimeout); - } - - [Fact] - public void AnyNegativeReadTimeoutBecomesInfinite() - { - var ts = TimeSpan.FromSeconds(-9); - var b = Configuration.Builder(uri).ReadTimeout(ts); - Assert.Equal(Timeout.InfiniteTimeSpan, b.Build().ReadTimeout); - } - [Fact] - public void ResponseStartTimeoutHasDefault() - { - var b = Configuration.Builder(uri); - Assert.Equal(Configuration.DefaultResponseStartTimeout, b.Build().ResponseStartTimeout); - } - - [Fact] - public void BuilderSetsResponseStartTimeout() - { - var ts = TimeSpan.FromSeconds(9); - var b = Configuration.Builder(uri).ResponseStartTimeout(ts); - Assert.Equal(ts, b.Build().ResponseStartTimeout); - } - - [Fact] - public void ResponseStartTimeoutCanBeInfinite() - { - var ts = Timeout.InfiniteTimeSpan; - var b = Configuration.Builder(uri).ResponseStartTimeout(ts); - Assert.Equal(ts, b.Build().ResponseStartTimeout); - } - - [Fact] - public void AnyNegativeResponseStartTimeoutIsInfinite() - { - var ts = TimeSpan.FromSeconds(-9); - var b = Configuration.Builder(uri).ResponseStartTimeout(ts); - Assert.Equal(Timeout.InfiniteTimeSpan, b.Build().ResponseStartTimeout); - } - [Fact] public void LastEventIdDefaultsToNull() { @@ -210,104 +117,5 @@ public void BuilderSetsLogger() var b = Configuration.Builder(uri).Logger(logger); Assert.Same(logger, b.Build().Logger); } - - [Fact] - public void RequestHeadersDefaultToEmptyDictionary() - { - var b = Configuration.Builder(uri); - Assert.Equal(new Dictionary(), b.Build().RequestHeaders); - } - - [Fact] - public void BuilderSetsRequestHeaders() - { - var h = new Dictionary(); - h["a"] = "1"; - var b = Configuration.Builder(uri).RequestHeaders(h); - Assert.Equal(h, b.Build().RequestHeaders); - } - - [Fact] - public void BuilderSetsIndividualRequestHeaders() - { - var h = new Dictionary(); - h["a"] = "1"; - h["b"] = "2"; - var b = Configuration.Builder(uri).RequestHeader("a", "1").RequestHeader("b", "2"); - Assert.Equal(h, b.Build().RequestHeaders); - } - - [Fact] - public void MessageHandlerDefaultsToNull() - { - Assert.Null(Configuration.Builder(uri).Build().HttpMessageHandler); - } - - [Fact] - public void BuilderSetsMessageHandler() - { - var h = new HttpClientHandler(); - var b = Configuration.Builder(uri).HttpMessageHandler(h); - Assert.Same(h, b.Build().HttpMessageHandler); - } - - [Fact] - public void HttpClientDefaultsToNull() - { - Assert.Null(Configuration.Builder(uri).Build().HttpClient); - } - - [Fact] - public void BuilderSetsHttpClient() - { - var h = new HttpClient(); - var b = Configuration.Builder(uri).HttpClient(h); - Assert.Same(h, b.Build().HttpClient); - } - - [Fact] - public void MethodDefaultsToGet() - { - var b = Configuration.Builder(uri); - Assert.Equal(HttpMethod.Get, b.Build().Method); - } - - [Fact] - public void BuilderSetsMethod() - { - var b = Configuration.Builder(uri).Method(HttpMethod.Post); - Assert.Equal(HttpMethod.Post, b.Build().Method); - } - - [Fact] - public void RequestBodyFactoryDefaultsToNull() - { - var b = Configuration.Builder(uri); - Assert.Null(b.Build().RequestBodyFactory); - } - - [Fact] - public void BuilderSetsRequestBodyFactory() - { - Func f = () => new StringContent("x"); - var b = Configuration.Builder(uri).RequestBodyFactory(f); - Assert.Same(f, b.Build().RequestBodyFactory); - } - - [Fact] - public void BuilderSetsRequestBodyString() - { - var b = Configuration.Builder(uri).RequestBody("x", "text/plain"); - var c = b.Build().RequestBodyFactory(); - Assert.IsType(c); - } - - [Fact] - public void BuilderSetsHttpRequestModifier() - { - Action action = request => { }; - var b = Configuration.Builder(uri).HttpRequestModifier(action); - Assert.Equal(action, b.Build().HttpRequestModifier); - } } } diff --git a/test/LaunchDarkly.EventSource.Tests/DefaultRetryDelayStrategyTest.cs b/test/LaunchDarkly.EventSource.Tests/DefaultRetryDelayStrategyTest.cs new file mode 100644 index 0000000..5770d31 --- /dev/null +++ b/test/LaunchDarkly.EventSource.Tests/DefaultRetryDelayStrategyTest.cs @@ -0,0 +1,156 @@ +using System; +using Xunit; + +namespace LaunchDarkly.EventSource +{ + public class DefaultRetryDelayStrategyTest + { + [Fact] + public void BackoffWithNoJitterAndNoMax() + { + var baseDelay = TimeSpan.FromMilliseconds(4); + + var s = RetryDelayStrategy.Default. + BackoffMultiplier(2).JitterMultiplier(0). + MaxDelay(null); + + var r1 = s.Apply(baseDelay); + Assert.Equal(baseDelay, r1.Delay); + + var last = baseDelay; + var nextStrategy = r1.Next; + for (int i = 0; i < 4; i++) + { + var r = nextStrategy.Apply(baseDelay); + Assert.Equal(last.Multiply(2), r.Delay); + last = r.Delay; + nextStrategy = r.Next; + } + } + + [Fact] + public void BackoffWithNoJitterAndMax() + { + var baseDelay = TimeSpan.FromMilliseconds(4); + var max = baseDelay.Multiply(4) + TimeSpan.FromMilliseconds(3); + + var s = RetryDelayStrategy.Default. + BackoffMultiplier(2).JitterMultiplier(0). + MaxDelay(max); + + var r1 = s.Apply(baseDelay); + Assert.Equal(baseDelay, r1.Delay); + + var r2 = r1.Next.Apply(baseDelay); + Assert.Equal(baseDelay.Multiply(2), r2.Delay); + + var r3 = r2.Next.Apply(baseDelay); + Assert.Equal(baseDelay.Multiply(4), r3.Delay); + + var r4 = r3.Next.Apply(baseDelay); + Assert.Equal(max, r4.Delay); + } + + [Fact] + public void NoBackoffAndNoJitter() + { + var baseDelay = TimeSpan.FromMilliseconds(4); + + RetryDelayStrategy s = RetryDelayStrategy.Default. + BackoffMultiplier(1).JitterMultiplier(0); + + var r1 = s.Apply(baseDelay); + Assert.Equal(baseDelay, r1.Delay); + + var r2 = r1.Next.Apply(baseDelay); + Assert.Equal(baseDelay, r2.Delay); + + var r3 = r2.Next.Apply(baseDelay); + Assert.Equal(baseDelay, r3.Delay); + } + + [Fact] + public void BackoffWithJitter() + { + var baseDelay = TimeSpan.FromMilliseconds(4); + var specifiedBackoff = 2; + TimeSpan max = baseDelay.Multiply(specifiedBackoff).Multiply(specifiedBackoff) + + TimeSpan.FromMilliseconds(3); + float specifiedJitter = 0.25f; + + var s = RetryDelayStrategy.Default + .BackoffMultiplier(specifiedBackoff) + .JitterMultiplier(specifiedJitter) + .MaxDelay(max); + + var r1 = VerifyJitter(s, baseDelay, baseDelay, specifiedJitter); + var r2 = VerifyJitter(r1.Next, baseDelay, baseDelay.Multiply(2), specifiedJitter); + var r3 = VerifyJitter(r2.Next, baseDelay, baseDelay.Multiply(4), specifiedJitter); + VerifyJitter(r3.Next, baseDelay, max, specifiedJitter); + } + + [Fact] + public void DefaultBackoff() + { + var baseDelay = TimeSpan.FromMilliseconds(4); + + var s = RetryDelayStrategy.Default + .JitterMultiplier(0); + + var r1 = s.Apply(baseDelay); + Assert.Equal(baseDelay, r1.Delay); + + var r2 = r1.Next.Apply(baseDelay); + Assert.Equal(baseDelay.Multiply(DefaultRetryDelayStrategy.DefaultBackoffMultiplier), + r2.Delay); + } + + [Fact] + public void DefaultJitter() + { + var baseDelay = TimeSpan.FromMilliseconds(4); + + var s = RetryDelayStrategy.Default; + + VerifyJitter(s, baseDelay, baseDelay, DefaultRetryDelayStrategy.DefaultJitterMultiplier); + } + + private RetryDelayStrategy.Result VerifyJitter( + RetryDelayStrategy strategy, + TimeSpan baseDelay, + TimeSpan baseWithBackoff, + float specifiedJitter + ) + { + // We can't 100% prove that it's using the expected jitter ratio, since the result + // is pseudo-random, but we can at least prove that repeated computations don't + // fall outside the expected range and aren't all equal. + RetryDelayStrategy.Result? lastResult = null; + var atLeastOneWasDifferent = false; + for (int i = 0; i < 100; i++) + { + var result = strategy.Apply(baseDelay); + Assert.InRange( + result.Delay, + baseWithBackoff - (baseWithBackoff.Multiply(specifiedJitter)), + baseWithBackoff + ); + if (lastResult.HasValue && !atLeastOneWasDifferent) + { + atLeastOneWasDifferent = result.Delay != lastResult.Value.Delay; + } + lastResult = result; + } + return lastResult.Value; + } + + } + + static class TimeSpanExtensions + { + // The built-in TimeSpan.Multiply() is not available in .NET Framework + public static TimeSpan Multiply(this TimeSpan t, double d) => + TimeSpan.FromTicks((long)(t.Ticks * d)); + } +} + diff --git a/test/LaunchDarkly.EventSource.Tests/ErrorStrategyTest.cs b/test/LaunchDarkly.EventSource.Tests/ErrorStrategyTest.cs new file mode 100644 index 0000000..c6123c8 --- /dev/null +++ b/test/LaunchDarkly.EventSource.Tests/ErrorStrategyTest.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.EventSource.Exceptions; +using Xunit; + +namespace LaunchDarkly.EventSource +{ + public class ErrorStrategyTest + { + private static readonly StreamException _exception = new StreamException(); + + [Fact] + public void AlwaysThrow() + { + var result = ErrorStrategy.AlwaysThrow.Apply(_exception); + Assert.Equal(ErrorStrategy.Action.Throw, result.Action); + Assert.Null(result.Next); + } + + [Fact] + public void AlwaysContinue() + { + var result = ErrorStrategy.AlwaysContinue.Apply(_exception); + Assert.Equal(ErrorStrategy.Action.Continue, result.Action); + Assert.Null(result.Next); + } + + [Fact] + public void MaxAttempts() + { + int max = 3; + var strategy = ErrorStrategy.ContinueWithMaxAttempts(max); + for (var i = 0; i < max; i++) + { + var result = strategy.Apply(_exception); + Assert.Equal(ErrorStrategy.Action.Continue, result.Action); + strategy = result.Next ?? strategy; + } + Assert.Equal(ErrorStrategy.Action.Throw, strategy.Apply(_exception).Action); + } + + [Fact] + public void MaxTime() + { + TimeSpan maxTime = TimeSpan.FromMilliseconds(50); + var strategy = ErrorStrategy.ContinueWithTimeLimit(maxTime); + + var result = strategy.Apply(_exception); + Assert.Equal(ErrorStrategy.Action.Continue, result.Action); + strategy = result.Next ?? strategy; + + result = strategy.Apply(_exception); + Assert.Equal(ErrorStrategy.Action.Continue, result.Action); + strategy = result.Next ?? strategy; + + Thread.Sleep(maxTime.Add(TimeSpan.FromMilliseconds(1))); + + result = strategy.Apply(_exception); + Assert.Equal(ErrorStrategy.Action.Throw, result.Action); + strategy = result.Next ?? strategy; + + result = strategy.Apply(_exception); + Assert.Equal(ErrorStrategy.Action.Throw, result.Action); + } + } +} diff --git a/test/LaunchDarkly.EventSource.Tests/EventParserTest.cs b/test/LaunchDarkly.EventSource.Tests/EventParserTest.cs deleted file mode 100644 index 209d309..0000000 --- a/test/LaunchDarkly.EventSource.Tests/EventParserTest.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Text; -using Xunit; - -namespace LaunchDarkly.EventSource.Tests -{ - public class EventParserTest - { - private static Utf8ByteSpan ToBytes(string s) - { - // Use a non-zero offset here and add some characters to skip at the beginning, - // to make sure the parser is taking the offset into account. - return new Utf8ByteSpan(Encoding.UTF8.GetBytes("xx" + s), 2, s.Length); - } - - [Theory] - [InlineData(":")] - [InlineData(": ")] - [InlineData(": some data")] - public void IsCommentIsTrueForComment(string data) - { - Assert.True(EventParser.ParseLineString(data).IsComment); - Assert.True(EventParser.ParseLineUtf8Bytes(ToBytes(data)).IsComment); - } - - [Theory] - [InlineData("id: 123")] - [InlineData(" : ")] - [InlineData("some data")] - [InlineData("event: put")] - public void IsCommentIsFalseForNonComment(string data) - { - Assert.False(EventParser.ParseLineString(data).IsComment); - Assert.False(EventParser.ParseLineUtf8Bytes(ToBytes(data)).IsComment); - } - - [Theory] - [InlineData("data:", "data", "")] - [InlineData("data: something", "data", "something")] - [InlineData("data", "data", "")] - public void ParsesFieldNameAndValue(string data, string expectedName, string expectedValue) - { - var result1 = EventParser.ParseLineString(data); - Assert.Equal(expectedName, result1.FieldName); - Assert.Equal(expectedValue, result1.ValueString); - Assert.Equal(expectedValue, result1.GetValueAsString()); - - var result2 = EventParser.ParseLineUtf8Bytes(ToBytes(data)); - Assert.Equal(expectedName, result2.FieldName); - Assert.Null(result2.ValueString); - Assert.True(new Utf8ByteSpan(expectedValue).Equals(result2.ValueBytes)); - Assert.Equal(expectedValue, result2.GetValueAsString()); - } - } -} diff --git a/test/LaunchDarkly.EventSource.Tests/EventSink.cs b/test/LaunchDarkly.EventSource.Tests/EventSink.cs deleted file mode 100644 index 45940e3..0000000 --- a/test/LaunchDarkly.EventSource.Tests/EventSink.cs +++ /dev/null @@ -1,145 +0,0 @@ -using System; -using System.Collections.Concurrent; -using LaunchDarkly.Logging; -using Xunit; - -namespace LaunchDarkly.EventSource.Tests -{ - public class EventSink - { - private static readonly TimeSpan WaitForActionTimeout = TimeSpan.FromSeconds(10); - - public bool? ExpectUtf8Data { get; set; } - public Action Output { get; set; } - - private readonly BlockingCollection _actions = new BlockingCollection(); - - public struct Action - { - public string Kind { get; set; } - public string Comment { get; set; } - public MessageEvent Message { get; set; } - public ReadyState ReadyState { get; set; } - public Exception Exception { get; set; } - - public override bool Equals(object o) => - o is Action a && - Kind == a.Kind && - Comment == a.Comment && - object.Equals(Message, a.Message) && - ReadyState == a.ReadyState && - (Exception is null ? a.Exception is null : Exception.GetType() == a.Exception.GetType()); - - public override int GetHashCode() => 0; - - public override string ToString() - { - switch (Kind) - { - case "Opened": - case "Closed": - return Kind + "(" + ReadyState + ")"; - case "CommentReceived": - return Kind + "(" + Comment + ")"; - case "MessageReceived": - return Kind + "(" + Message.Name + "," + Message.Data + ")"; - case "Error": - return Kind + "(" + Exception + ")"; - default: - return Kind; - } - } - } - - public EventSink(EventSource es) - { - es.Opened += OnOpened; - es.Closed += OnClosed; - es.CommentReceived += OnCommentReceived; - es.MessageReceived += OnMessageReceived; - es.Error += OnError; - } - - public EventSink(EventSource es, ILogAdapter logging) : this(es) - { - Output = logging.Logger("EventSink").Info; - } - - public static Action OpenedAction(ReadyState state = ReadyState.Open) => - new Action { Kind = "Opened", ReadyState = state }; - - public static Action ClosedAction(ReadyState state = ReadyState.Closed) => - new Action { Kind = "Closed", ReadyState = state }; - - public static Action CommentReceivedAction(string comment) => - new Action { Kind = "CommentReceived", Comment = comment }; - - public static Action MessageReceivedAction(MessageEvent message) => - new Action { Kind = "MessageReceived", Message = message }; - - public static Action ErrorAction(Exception e) => - new Action { Kind = "Error", Exception = e }; - - public void OnOpened(object sender, StateChangedEventArgs args) => - Add(OpenedAction(args.ReadyState)); - - public void OnClosed(object sender, StateChangedEventArgs args) => - Add(ClosedAction(args.ReadyState)); - - public void OnCommentReceived(object sender, CommentReceivedEventArgs args) => - Add(CommentReceivedAction(args.Comment)); - - public void OnMessageReceived(object sender, MessageReceivedEventArgs args) => - Add(MessageReceivedAction(args.Message)); - - public void OnError(object sender, ExceptionEventArgs args) => - Add(ErrorAction(args.Exception)); - - private void Add(Action a) - { - Output?.Invoke("handler received: " + a); - _actions.Add(a); - } - - public Action ExpectAction() - { - Assert.True(_actions.TryTake(out var ret, WaitForActionTimeout)); - return ret; - } - - public void ExpectActions(params Action[] expectedActions) - { - int i = 0; - foreach (var a in expectedActions) - { - Assert.True(_actions.TryTake(out var actual, WaitForActionTimeout), - "timed out waiting for action " + i + " (" + a + ")"); - - // The MessageEvent.Equals method takes Origin into account, which is inconvenient for - // our tests because the origin will vary for each embedded test server. So, ignore it. - var expected = a; - if (expected.Message.Origin != null) - { - expected.Message = new MessageEvent(expected.Message.Name, - expected.Message.Data, expected.Message.LastEventId, - actual.Message.Origin); - } - - if (!actual.Equals(expected)) - { - Assert.True(false, "action " + i + " should have been " + expected + ", was " + actual); - } - if (actual.Kind == "MessageReceived" && ExpectUtf8Data.HasValue) - { - if (actual.Message.IsDataUtf8Bytes != ExpectUtf8Data.Value) - { - Assert.True(false, "action " + i + "(" + actual + ") - data should have been read as " - + (ExpectUtf8Data.Value ? "UTF8 bytes" : "string")); - } - Assert.True(actual.Message.DataUtf8Bytes.Equals(expected.Message.DataUtf8Bytes)); - } - i++; - } - } - } -} diff --git a/test/LaunchDarkly.EventSource.Tests/EventSourceConnectStrategyUsageTest.cs b/test/LaunchDarkly.EventSource.Tests/EventSourceConnectStrategyUsageTest.cs new file mode 100644 index 0000000..477d52a --- /dev/null +++ b/test/LaunchDarkly.EventSource.Tests/EventSourceConnectStrategyUsageTest.cs @@ -0,0 +1,129 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using LaunchDarkly.Logging; +using Xunit; + +namespace LaunchDarkly.EventSource +{ + public class EventSourceConnectStrategyUsageTest + { + public class ConnectStrategyFromLambda : ConnectStrategy + { + private readonly Func _fn; + + public override Uri Origin => new Uri("http://test/origin"); + + public ConnectStrategyFromLambda(Func fn) { _fn = fn; } + + public override Client CreateClient(Logger logger) => + _fn(logger); + } + + public class ClientFromLambda : ConnectStrategy.Client + { + private readonly Func> _fn; + public volatile bool Closed = false; + + public ClientFromLambda(Func> fn) { _fn = fn; } + + public override Task ConnectAsync(Params parameters) => + _fn(parameters); + + public override void Dispose() { Closed = true; } + } + + public class Disposable : IDisposable + { + public volatile bool Closed = false; + + public void Dispose() + { + if (Closed) + { + throw new InvalidOperationException("should not have been closed twice"); + } + Closed = true; + } + } + + private static Stream MakeEmptyStream() => + new MemoryStream(new byte[0]); + + [Fact] + public void ConnectStrategyIsCalledImmediatelyToCreateClient() + { + ClientFromLambda created = null; + Logger receivedLogger = null; + + var strategy = new ConnectStrategyFromLambda(logger => + { + var c = new ClientFromLambda(_ => + throw new Exception("ConnectAsync should not be called")); + created = c; + receivedLogger = logger; + return c; + }); + + var testLogger = Logger.WithAdapter(Logs.None, ""); + + using (var es = new EventSource( + new ConfigurationBuilder(strategy).Logger(testLogger).Build())) + { + Assert.NotNull(created); + Assert.Same(receivedLogger, testLogger); + Assert.False(created.Closed); + } + + Assert.True(created.Closed); + } + + [Fact] + public async void ConnectIsCalledOnStart() + { + Stream createdStream = null; + var strategy = new ConnectStrategyFromLambda(logger => + new ClientFromLambda(_ => + { + createdStream = MakeEmptyStream(); + return Task.FromResult(new ConnectStrategy.Client.Result + { + Stream = createdStream + }); + })); + + using (var es = new EventSource( + new ConfigurationBuilder(strategy).Build())) + { + Assert.Null(createdStream); + await es.StartAsync(); + Assert.NotNull(createdStream); + } + } + + [Fact] + public async void ConnectionCloserIsCalledOnClose() + { + Disposable closer = new Disposable(); + var strategy = new ConnectStrategyFromLambda(logger => + new ClientFromLambda(_ => + { + return Task.FromResult(new ConnectStrategy.Client.Result + { + Stream = MakeEmptyStream(), + Closer = closer + }); + })); + + using (var es = new EventSource( + new ConfigurationBuilder(strategy).Build())) + { + await es.StartAsync(); + Assert.False(closer.Closed); + } + + Assert.True(closer.Closed); + } + } +} + diff --git a/test/LaunchDarkly.EventSource.Tests/EventSourceEncodingTest.cs b/test/LaunchDarkly.EventSource.Tests/EventSourceEncodingTest.cs index 96999c2..165d498 100644 --- a/test/LaunchDarkly.EventSource.Tests/EventSourceEncodingTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/EventSourceEncodingTest.cs @@ -1,11 +1,11 @@ -using System.Text; +using System; +using System.Text; using System.Threading.Tasks; -using LaunchDarkly.TestHelpers; -using LaunchDarkly.TestHelpers.HttpTest; +using LaunchDarkly.EventSource.Events; using Xunit; using Xunit.Abstractions; -namespace LaunchDarkly.EventSource.Tests +namespace LaunchDarkly.EventSource { public class EventSourceEncodingTest : BaseTest { @@ -23,16 +23,15 @@ public EventSourceEncodingTest(ITestOutputHelper testOutput) : base(testOutput) MakeLongString(500, 1000) + "\n\n" }; - private static readonly EventSink.Action[] expectedEventActions = new EventSink.Action[] + private static async Task ExpectEvents(EventSource es, Uri uri) { - EventSink.OpenedAction(ReadyState.Open), - EventSink.CommentReceivedAction(": hello"), - EventSink.MessageReceivedAction(new MessageEvent("message", "value1", null, _uri)), - EventSink.MessageReceivedAction(new MessageEvent("event2", "ça\nqué", null, _uri)), - EventSink.MessageReceivedAction(new MessageEvent("message", - MakeLongString(0, 500) + MakeLongString(500, 1000), null, _uri)) - }; - + Assert.Equal(new CommentEvent("hello"), await es.ReadAnyEventAsync()); + Assert.Equal(new MessageEvent("message", "value1", null, uri), await es.ReadAnyEventAsync()); + Assert.Equal(new MessageEvent("event2", "ça\nqué", null, uri), await es.ReadAnyEventAsync()); + Assert.Equal(new MessageEvent("message", MakeLongString(0, 500) + MakeLongString(500, 1000), null, uri), + await es.ReadAnyEventAsync()); + } + private static string MakeLongString(int startNum, int endNum) { // This is meant to verify that we're able to read event data from the stream @@ -45,77 +44,18 @@ private static string MakeLongString(int startNum, int endNum) return ret.ToString(); } - private static Handler MakeStreamHandler(Encoding encoding) - { - var ret = Handlers.StartChunks("text/event-stream", encoding); - foreach (var chunk in streamChunks) - { - ret = ret.Then(Handlers.WriteChunkString(chunk, encoding)); - } - return ret.Then(Handlers.Hang()); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void CanReceiveUtf8EventDataAsStrings(bool setExplicitEncoding) - { - using (var server = HttpServer.Start(MakeStreamHandler(setExplicitEncoding ? Encoding.UTF8 : null))) - { - var config = Configuration.Builder(server.Uri) - .LogAdapter(_testLogging) - .Build(); - using (var es = new EventSource(config)) - { - var eventSink = new EventSink(es, _testLogging) { ExpectUtf8Data = false }; - - _ = Task.Run(es.StartAsync); - - eventSink.ExpectActions(expectedEventActions); - } - } - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void CanReceiveUtf8EventDataAsBytes(bool setExplicitEncoding) - { - using (var server = HttpServer.Start(MakeStreamHandler(setExplicitEncoding ? Encoding.UTF8 : null))) - { - var config = Configuration.Builder(server.Uri) - .LogAdapter(_testLogging) - .PreferDataAsUtf8Bytes(true) - .Build(); - using (var es = new EventSource(config)) - { - var eventSink = new EventSink(es, _testLogging) { ExpectUtf8Data = true }; - - _ = Task.Run(es.StartAsync); - - eventSink.ExpectActions(expectedEventActions); - } - } - } - [Fact] - public void NonUtf8EncodingIsRejected() + public async Task CanReceiveUtf8EventDataAsBytes() { - using (var server = HttpServer.Start(MakeStreamHandler(Encoding.GetEncoding("iso-8859-1")))) - { - var config = Configuration.Builder(server.Uri) - .LogAdapter(_testLogging) - .Build(); - using (var es = new EventSource(config)) + await WithMockConnectEventSource( + mock => mock.ConfigureRequests( + MockConnectStrategy.RespondWithDataAndStayOpen(streamChunks) + ), + async (mock, es) => { - var sink = new EventSink(es, _testLogging) { ExpectUtf8Data = false }; - _ = Task.Run(es.StartAsync); - - var errorAction = sink.ExpectAction(); - var ex = Assert.IsType(errorAction.Exception); - Assert.Matches(".*encoding.*8859.*", ex.Message); - } - } + await es.StartAsync(); + await ExpectEvents(es, MockConnectStrategy.MockOrigin); + }); } } } diff --git a/test/LaunchDarkly.EventSource.Tests/EventSourceErrorStrategyUsageTest.cs b/test/LaunchDarkly.EventSource.Tests/EventSourceErrorStrategyUsageTest.cs new file mode 100644 index 0000000..0adbb8a --- /dev/null +++ b/test/LaunchDarkly.EventSource.Tests/EventSourceErrorStrategyUsageTest.cs @@ -0,0 +1,157 @@ +using System; +using System.Threading.Tasks; +using LaunchDarkly.EventSource.Exceptions; +using LaunchDarkly.EventSource.Events; +using Xunit; +using Xunit.Abstractions; + +using static LaunchDarkly.EventSource.TestHelpers; + +namespace LaunchDarkly.EventSource +{ + public class EventSourceErrorStrategyUsageTest : BaseTest + { + private static readonly Exception FakeHttpError = new StreamHttpErrorException(503); + + public EventSourceErrorStrategyUsageTest(ITestOutputHelper testOutput) : base(testOutput) { } + + [Fact] + public async Task TaskStartThrowsIfConnectFailsAndStrategyReturnsThrow() + { + await WithMockConnectEventSource( + mock => mock.ConfigureRequests(MockConnectStrategy.RejectConnection(FakeHttpError)), + c => c.ErrorStrategy(ErrorStrategy.AlwaysThrow) + .InitialRetryDelay(TimeSpan.Zero), + async (mock, es) => + { + var ex = await Assert.ThrowsAnyAsync(() => es.StartAsync()); + Assert.Equal(FakeHttpError, ex); + } + ); + } + + [Fact] + public async Task TaskStartRetriesIfConnectFailsAndStrategyReturnsContinue() + { + await WithMockConnectEventSource( + mock => mock.ConfigureRequests( + MockConnectStrategy.RejectConnection(FakeHttpError), + MockConnectStrategy.RejectConnection(FakeHttpError), + MockConnectStrategy.RespondWithStream() + ), + c => c.ErrorStrategy(ErrorStrategy.AlwaysContinue) + .InitialRetryDelay(TimeSpan.Zero), + async (mock, es) => + { + await es.StartAsync(); + } + ); + } + + [Fact] + public async Task ImplicitStartFromReadAnyEventReturnsFaultEventIfStrategyReturnsContinue() + { + await WithMockConnectEventSource( + mock => mock.ConfigureRequests( + MockConnectStrategy.RejectConnection(FakeHttpError), + MockConnectStrategy.RespondWithStream() + ), + c => c.ErrorStrategy(ErrorStrategy.AlwaysContinue) + .InitialRetryDelay(TimeSpan.Zero), + async (mock, es) => + { + Assert.Equal(new FaultEvent(FakeHttpError), + await es.ReadAnyEventAsync()); + Assert.Equal(new StartedEvent(), await es.ReadAnyEventAsync()); + } + ); + } + + [Fact] + public async Task ErrorStrategyIsUpdatedForEachRetryDuringStart() + { + var fakeError2 = new Exception("the final error"); + var continueFirstTimeButThenThrow = new ContinueFirstTimeStrategy(); + await WithMockConnectEventSource( + mock => mock.ConfigureRequests( + MockConnectStrategy.RejectConnection(FakeHttpError), + MockConnectStrategy.RejectConnection(fakeError2), + MockConnectStrategy.RespondWithStream() + ), + c => c.ErrorStrategy(continueFirstTimeButThenThrow) + .InitialRetryDelay(TimeSpan.Zero), + async (mock, es) => + { + var ex = await Assert.ThrowsAnyAsync(() => es.StartAsync()); + Assert.Equal(fakeError2, ex); + } + ); + } + + class ContinueFirstTimeStrategy : ErrorStrategy + { + public override Result Apply(Exception exception) => + new Result { Action = Action.Continue, Next = ErrorStrategy.AlwaysThrow }; + } + + [Fact] + public async Task ReadThrowsIfReadFailsAndStrategyReturnsThrow() + { + await WithMockConnectEventSource( + mock => mock.ConfigureRequests( + MockConnectStrategy.RespondWithDataAndThenEnd("data:\n\n") + ), + c => c.ErrorStrategy(ErrorStrategy.AlwaysThrow) + .InitialRetryDelay(TimeSpan.Zero), + async (mock, es) => + { + await es.StartAsync(); + await es.ReadMessageAsync(); + await Assert.ThrowsAnyAsync(() => es.ReadMessageAsync()); + } + ); + } + + [Fact] + public async Task ReadRetriesIfReadFailsAndStrategyReturnsContinue() + { + await WithMockConnectEventSource( + mock => mock.ConfigureRequests( + MockConnectStrategy.RespondWithDataAndThenEnd("data:a\n\n"), + MockConnectStrategy.RespondWithDataAndThenEnd("data:b\n\n") + ), + c => c.ErrorStrategy(ErrorStrategy.AlwaysContinue) + .InitialRetryDelay(TimeSpan.Zero), + async (mock, es) => + { + await es.StartAsync(); + await es.ReadMessageAsync(); + Assert.Equal(new MessageEvent(MessageEvent.DefaultName, "b", MockConnectStrategy.MockOrigin), + await es.ReadMessageAsync()); + } + ); + } + + [Fact] + public async Task ErrorStrategyIsUpdatedForEachRetryDuringRead() + { + var continueFirstTimeButThenThrow = new ContinueFirstTimeStrategy(); + await WithMockConnectEventSource( + mock => mock.ConfigureRequests( + MockConnectStrategy.RespondWithDataAndThenEnd("data:a\n\n"), + MockConnectStrategy.RejectConnection(FakeHttpError), + MockConnectStrategy.RespondWithDataAndThenEnd("data:b\n\n") + ), + c => c.ErrorStrategy(continueFirstTimeButThenThrow) + .InitialRetryDelay(TimeSpan.Zero), + async (mock, es) => + { + await es.ReadMessageAsync(); + var ex = await Assert.ThrowsAnyAsync(() => es.ReadMessageAsync()); + Assert.Equal(FakeHttpError, ex); + } + ); + } + } +} + diff --git a/test/LaunchDarkly.EventSource.Tests/EventSourceHttpBehaviorTest.cs b/test/LaunchDarkly.EventSource.Tests/EventSourceHttpBehaviorTest.cs deleted file mode 100644 index 0357ed0..0000000 --- a/test/LaunchDarkly.EventSource.Tests/EventSourceHttpBehaviorTest.cs +++ /dev/null @@ -1,304 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using LaunchDarkly.TestHelpers.HttpTest; -using Xunit; -using Xunit.Abstractions; - -namespace LaunchDarkly.EventSource.Tests -{ - public class EventSourceHttpBehaviorTest : BaseTest - { - public EventSourceHttpBehaviorTest(ITestOutputHelper testOutput) : base(testOutput) { } - - [Fact] - public async Task CustomHttpClientIsNotClosedWhenEventSourceCloses() - { - using (var server = HttpServer.Start(Handlers.Status(200))) - { - using (var client = new HttpClient()) - { - var es = new EventSource(Configuration.Builder(server.Uri).HttpClient(client).Build()); - es.Close(); - - await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, server.Uri)); - } - } - } - - [Fact] - public void DefaultMethod() - { - using (var server = HttpServer.Start(EmptyStreamThatStaysOpen)) - { - using (var es = MakeEventSource(server.Uri)) - { - _ = Task.Run(es.StartAsync); - - var req = server.Recorder.RequireRequest(); - Assert.Equal("GET", req.Method); - } - } - } - - [Fact] - public void CustomMethod() - { - using (var server = HttpServer.Start(EmptyStreamThatStaysOpen)) - { - using (var es = MakeEventSource(server.Uri, builder => builder.Method(HttpMethod.Post))) - { - _ = Task.Run(es.StartAsync); - - var req = server.Recorder.RequireRequest(); - Assert.Equal("POST", req.Method); - } - } - } - - [Fact] - public void CustomMethodWithRequestBody() - { - using (var server = HttpServer.Start(EmptyStreamThatStaysOpen)) - { - var content = "{}"; - Func contentFn = () => new StringContent(content); - - using (var es = MakeEventSource(server.Uri, builder => - builder.Method(HttpMethod.Post).RequestBodyFactory(contentFn))) - { - _ = Task.Run(es.StartAsync); - - var req = server.Recorder.RequireRequest(); - Assert.Equal(content, req.Body); - } - } - } - - [Fact] - public void AcceptHeaderIsAlwaysPresent() - { - using (var server = HttpServer.Start(EmptyStreamThatStaysOpen)) - { - using (var es = MakeEventSource(server.Uri)) - { - _ = Task.Run(es.StartAsync); - - var req = server.Recorder.RequireRequest(); - Assert.Contains(Constants.EventStreamContentType, req.Headers.GetValues(Constants.AcceptHttpHeader)); - } - } - } - - [Fact] - public void LastEventIdHeaderIsNotSetByDefault() - { - using (var server = HttpServer.Start(EmptyStreamThatStaysOpen)) - { - using (var es = MakeEventSource(server.Uri)) - { - _ = Task.Run(es.StartAsync); - - var req = server.Recorder.RequireRequest(); - Assert.Null(req.Headers.Get(Constants.LastEventIdHttpHeader)); - } - } - } - - [Fact] - public void LastEventIdHeaderIsSetIfConfigured() - { - using (var server = HttpServer.Start(EmptyStreamThatStaysOpen)) - { - var lastEventId = "abc123"; - - using (var es = MakeEventSource(server.Uri, builder => builder.LastEventId(lastEventId))) - { - _ = Task.Run(es.StartAsync); - - var req = server.Recorder.RequireRequest(); - Assert.Equal(lastEventId, req.Headers.Get(Constants.LastEventIdHttpHeader)); - } - } - } - - [Fact] - public void CustomRequestHeaders() - { - using (var server = HttpServer.Start(EmptyStreamThatStaysOpen)) - { - var headers = new Dictionary { { "User-Agent", "mozilla" }, { "Authorization", "testing" } }; - - using (var es = MakeEventSource(server.Uri, builder => builder.RequestHeaders(headers))) - { - _ = Task.Run(es.StartAsync); - - var req = server.Recorder.RequireRequest(); - Assert.True(headers.All( - item => req.Headers.Get(item.Key) == item.Value - )); - } - } - } - - [Fact] - public void HttpRequestModifier() - { - using (var server = HttpServer.Start(EmptyStreamThatStaysOpen)) - { - var headers = new Dictionary { { "User-Agent", "mozilla" }, { "Authorization", "testing" } }; - - Action modifier = request => - { - request.RequestUri = new Uri(request.RequestUri.ToString() + "-modified"); - }; - using (var es = MakeEventSource(server.Uri, builder => builder.HttpRequestModifier(modifier))) - { - _ = Task.Run(es.StartAsync); - - var req = server.Recorder.RequireRequest(); - Assert.EndsWith("-modified", req.Path); - } - } - } - - [Fact] - public void ReceiveEventStreamInChunks() - { - // This simply verifies that chunked streaming works as expected and that events are being - // parsed correctly regardless of how the chunks line up with the events. - - var eventData = new List(); - for (var i = 0; i < 200; i++) - { - eventData.Add(string.Format("data{0}", i) + new string('x', i % 7)); - } - var allBody = string.Concat(eventData.Select(data => "data:" + data + "\n\n")); - var allEventsReceived = new TaskCompletionSource(); - - IEnumerable MakeChunks() - { - var i = 0; - for (var pos = 0; ;) - { - int chunkSize = i % 20 + 1; - if (pos + chunkSize >= allBody.Length) - { - yield return allBody.Substring(pos); - break; - } - yield return allBody.Substring(pos, chunkSize); - pos += chunkSize; - i++; - } - } - - try - { - Handler streamHandler = Handlers.StartChunks("text/event-stream") - .Then(async ctx => - { - foreach (var s in MakeChunks()) - { - await Handlers.WriteChunkString(s)(ctx); - } - await allEventsReceived.Task; - }); - using (var server = HttpServer.Start(streamHandler)) - { - var expectedActions = new List(); - expectedActions.Add(EventSink.OpenedAction()); - foreach (var data in eventData) - { - expectedActions.Add(EventSink.MessageReceivedAction(new MessageEvent(MessageEvent.DefaultName, data, server.Uri))); - } - - var config = Configuration.Builder(server.Uri).LogAdapter(_testLogging).Build(); - using (var es = new EventSource(config)) - { - var sink = new EventSink(es); - _ = es.StartAsync(); - sink.ExpectActions(expectedActions.ToArray()); - } - } - } - finally - { - allEventsReceived.SetResult(true); - } - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public void ReadTimeoutIsDetected(bool utf8Mode) - { - TimeSpan readTimeout = TimeSpan.FromMilliseconds(200); - var streamHandler = Handlers.StartChunks("text/event-stream") - .Then(Handlers.WriteChunkString("data: event1\n\ndata: e")) - .Then(Handlers.Delay(readTimeout + readTimeout)) - .Then(Handlers.WriteChunkString("vent2\n\n")); - using (var server = HttpServer.Start(streamHandler)) - { - var config = Configuration.Builder(server.Uri) - .LogAdapter(_testLogging) - .ReadTimeout(readTimeout) - .PreferDataAsUtf8Bytes(utf8Mode) - .Build(); - using (var es = new EventSource(config)) - { - var sink = new EventSink(es) { Output = _testLogger.Debug }; - _ = es.StartAsync(); - sink.ExpectActions( - EventSink.OpenedAction(), - EventSink.MessageReceivedAction(new MessageEvent(MessageEvent.DefaultName, "event1", server.Uri)), - EventSink.ErrorAction(new ReadTimeoutException()), - EventSink.ClosedAction() - ); - } - } - } - [Fact] - public void ErrorForIncorrectContentType() - { - var response = Handlers.Status(200).Then(Handlers.BodyString("text/html", "testing")); - using (var server = HttpServer.Start(response)) - { - using (var es = MakeEventSource(server.Uri)) - { - var eventSink = new EventSink(es, _testLogging); - _ = Task.Run(es.StartAsync); - - var errorAction = eventSink.ExpectAction(); - var ex = Assert.IsType(errorAction.Exception); - Assert.Matches(".*content type.*text/html", ex.Message); - } - } - } - - [Theory] - [InlineData(HttpStatusCode.NoContent)] - [InlineData(HttpStatusCode.InternalServerError)] - [InlineData(HttpStatusCode.BadRequest)] - [InlineData(HttpStatusCode.RequestTimeout)] - [InlineData(HttpStatusCode.Unauthorized)] - public void ErrorForInvalidHttpStatus(HttpStatusCode statusCode) - { - using (var server = HttpServer.Start(Handlers.Status(statusCode))) - { - using (var es = MakeEventSource(server.Uri)) - { - var eventSink = new EventSink(es, _testLogging); - _ = Task.Run(es.StartAsync); - - var errorAction = eventSink.ExpectAction(); - var ex = Assert.IsType(errorAction.Exception); - Assert.Equal((int)statusCode, ex.StatusCode); - } - } - } - } -} diff --git a/test/LaunchDarkly.EventSource.Tests/EventSourceLoggingTest.cs b/test/LaunchDarkly.EventSource.Tests/EventSourceLoggingTest.cs index 4d56f63..49cd28e 100644 --- a/test/LaunchDarkly.EventSource.Tests/EventSourceLoggingTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/EventSourceLoggingTest.cs @@ -1,63 +1,61 @@ using System.Linq; using System.Threading.Tasks; +using LaunchDarkly.EventSource.Events; using LaunchDarkly.Logging; using LaunchDarkly.TestHelpers.HttpTest; using Xunit; using Xunit.Abstractions; -using static LaunchDarkly.EventSource.Tests.TestHelpers; +using static LaunchDarkly.EventSource.TestHelpers; -namespace LaunchDarkly.EventSource.Tests +namespace LaunchDarkly.EventSource { public class EventSourceLoggingTest : BaseTest { - private static readonly MessageEvent BasicEvent = new MessageEvent("thing", "test", _uri); - - private static Handler HandlerWithBasicEvent() => - StartStream().Then(WriteEvent(BasicEvent)).Then(LeaveStreamOpen()); - public EventSourceLoggingTest(ITestOutputHelper testOutput) : base(testOutput) { } [Fact] - public void UsesDefaultLoggerNameWhenLogAdapterIsSpecified() + public async Task UsesDefaultLoggerNameWhenLogAdapterIsSpecified() { - WithServerAndEventSource(HandlerWithBasicEvent(), (server, es) => - { - var eventSink = new EventSink(es); - _ = Task.Run(es.StartAsync); - - eventSink.ExpectActions(EventSink.OpenedAction()); - - Assert.NotEmpty(_logCapture.GetMessages()); - Assert.True(_logCapture.GetMessages().All(m => m.LoggerName == Configuration.DefaultLoggerName), - _logCapture.ToString()); - }); + await WithMockConnectEventSource( + mock => mock.ConfigureRequests( + MockConnectStrategy.RespondWithDataAndThenEnd("data:\n\n") + ), + async (mock, es) => + { + await es.StartAsync(); + + Assert.NotEmpty(_logCapture.GetMessages()); + Assert.True(_logCapture.GetMessages().All(m => m.LoggerName == Configuration.DefaultLoggerName), + _logCapture.ToString()); + }); } [Fact] - public void CanSpecifyLoggerInstance() + public async Task CanSpecifyLoggerInstance() { - WithServerAndEventSource(HandlerWithBasicEvent(), c => c.Logger(_logCapture.Logger("special")), (server, es) => - { - var eventSink = new EventSink(es); - _ = Task.Run(es.StartAsync); - - eventSink.ExpectActions(EventSink.OpenedAction()); - - Assert.NotEmpty(_logCapture.GetMessages()); - Assert.True(_logCapture.GetMessages().All(m => m.LoggerName == "special"), _logCapture.ToString()); - }); + await WithMockConnectEventSource( + mock => mock.ConfigureRequests( + MockConnectStrategy.RespondWithDataAndThenEnd("data:\n\n") + ), + c => c.Logger(_logCapture.Logger("special")), + async (mock, es) => + { + await es.StartAsync(); + + Assert.NotEmpty(_logCapture.GetMessages()); + Assert.True(_logCapture.GetMessages().All(m => m.LoggerName == "special"), _logCapture.ToString()); + }); } [Fact] - public void ConnectingLogMessage() + public async Task ConnectingLogMessage() { - WithServerAndEventSource(HandlerWithBasicEvent(), (server, es) => + // This one is specific to HttpConnectStrategy so we must use real HTTP + var handler = StartStream().Then(WriteComment("")); + await WithServerAndEventSource(handler, async (server, es) => { - var eventSink = new EventSink(es); - _ = Task.Run(es.StartAsync); - - eventSink.ExpectActions(EventSink.OpenedAction()); + await es.StartAsync(); Assert.True(_logCapture.HasMessageWithText(LogLevel.Debug, "Making GET request to EventSource URI " + server.Uri), @@ -66,21 +64,21 @@ public void ConnectingLogMessage() } [Fact] - public void EventReceivedLogMessage() + public async Task EventReceivedLogMessage() { - WithServerAndEventSource(HandlerWithBasicEvent(), (server, es) => - { - var eventSink = new EventSink(es, _testLogging); - _ = Task.Run(es.StartAsync); - - eventSink.ExpectActions( - EventSink.OpenedAction(), - EventSink.MessageReceivedAction(BasicEvent) - ); - - Assert.True(_logCapture.HasMessageWithText(LogLevel.Debug, - string.Format(@"Received event ""{0}""", BasicEvent.Name))); - }); + await WithMockConnectEventSource( + mock => mock.ConfigureRequests( + MockConnectStrategy.RespondWithDataAndThenEnd("event:abc\ndata:\n\n") + ), + async (mock, es) => + { + await es.StartAsync(); + + await es.ReadMessageAsync(); + + Assert.True(_logCapture.HasMessageWithText(LogLevel.Debug, + string.Format(@"Received event ""abc"""))); + }); } } } diff --git a/test/LaunchDarkly.EventSource.Tests/EventSourceReconnectingTest.cs b/test/LaunchDarkly.EventSource.Tests/EventSourceReconnectingTest.cs index 263e9a8..be1765d 100644 --- a/test/LaunchDarkly.EventSource.Tests/EventSourceReconnectingTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/EventSourceReconnectingTest.cs @@ -2,132 +2,109 @@ using System.Collections.Generic; using System.Net; using System.Threading.Tasks; +using LaunchDarkly.EventSource.Events; +using LaunchDarkly.EventSource.Exceptions; using LaunchDarkly.TestHelpers.HttpTest; using Xunit; using Xunit.Abstractions; -using static LaunchDarkly.EventSource.Tests.TestHelpers; +using static LaunchDarkly.EventSource.TestHelpers; -namespace LaunchDarkly.EventSource.Tests +namespace LaunchDarkly.EventSource { public class EventSourceReconnectingTest : BaseTest { public EventSourceReconnectingTest(ITestOutputHelper testOutput) : base(testOutput) { } [Fact] - public void ReconnectAfterHttpError() - { - HttpStatusCode error1 = HttpStatusCode.BadRequest, error2 = HttpStatusCode.InternalServerError; - var message = new MessageEvent("put", "hello", _uri); - - var handler = Handlers.Sequential( - Handlers.Status((int)error1), - Handlers.Status((int)error2), - StartStream().Then(WriteEvent(message)).Then(LeaveStreamOpen()) - ); - - WithServerAndEventSource(handler, c => c.InitialRetryDelay(TimeSpan.FromMilliseconds(20)), (server, es) => - { - var eventSink = new EventSink(es, _testLogging); - _ = Task.Run(es.StartAsync); - - var action1 = eventSink.ExpectAction(); - var ex1 = Assert.IsType(action1.Exception); - Assert.Equal((int)error1, ex1.StatusCode); - - eventSink.ExpectActions(EventSink.ClosedAction()); - - var action2 = eventSink.ExpectAction(); - var ex2 = Assert.IsType(action2.Exception); - Assert.Equal((int)error2, ex2.StatusCode); - - eventSink.ExpectActions( - EventSink.ClosedAction(), - EventSink.OpenedAction(), - EventSink.MessageReceivedAction(message) - ); - }); - } - - [Fact] - public void SendMostRecentEventIdOnReconnect() + public async Task SendMostRecentEventIdOnReconnect() { var initialEventId = "abc123"; var eventId1 = "xyz456"; - var message1 = new MessageEvent("put", "this is a test message", eventId1, _uri); + var message1 = new MessageEvent("put", "hello", eventId1, _uri); + + await WithMockConnectEventSource( + mock => mock.ConfigureRequests( + MockConnectStrategy.RespondWithDataAndThenEnd(message1.AsSSEData()), + MockConnectStrategy.RespondWithStream() + ), + c => c.LastEventId(initialEventId) + .ErrorStrategy(ErrorStrategy.AlwaysContinue), + async (mock, es) => + { + Assert.Equal(new StartedEvent(), await es.ReadAnyEventWithTimeoutAsync(DefaultTimeout)); - var handler = Handlers.Sequential( - StartStream().Then(WriteEvent(message1)), - StartStream().Then(LeaveStreamOpen()) - ); + Assert.Equal(new MessageEvent("put", "hello", eventId1, mock.Origin), + await es.ReadAnyEventWithTimeoutAsync(DefaultTimeout)); - WithServerAndEventSource(handler, c => c.InitialRetryDelay(TimeSpan.FromMilliseconds(20)).LastEventId(initialEventId), (server, es) => - { - var eventSink = new EventSink(es, _testLogging); - _ = Task.Run(es.StartAsync); + var p1 = mock.ReceivedConnections.Take(); + Assert.Equal(initialEventId, p1.LastEventId); - var req1 = server.Recorder.RequireRequest(); - Assert.Equal(initialEventId, req1.Headers["Last-Event-Id"]); + Assert.Equal(new FaultEvent(new StreamClosedByServerException()), + await es.ReadAnyEventWithTimeoutAsync(DefaultTimeout)); - var req2 = server.Recorder.RequireRequest(); - Assert.Equal(eventId1, req2.Headers["Last-Event-Id"]); - }); + Assert.Equal(new StartedEvent(), await es.ReadAnyEventWithTimeoutAsync(DefaultTimeout)); + + var p2 = mock.ReceivedConnections.Take(); + Assert.Equal(eventId1, p2.LastEventId); + }); } [Fact] - public void RetryDelayDurationsShouldIncrease() + public async Task RetryDelayStrategyIsAppliedEachTime() { + var baseDelay = TimeSpan.FromMilliseconds(10); + var increment = TimeSpan.FromMilliseconds(3); var nAttempts = 3; - var steps = new List(); - steps.Add(StartStream()); + var requestHandlers = new List(); for (var i = 0; i < nAttempts; i++) { - steps.Add(Handlers.WriteChunkString(":hi\n")); + requestHandlers.Add(MockConnectStrategy.RespondWithDataAndThenEnd(":hi\n")); } - steps.Add(LeaveStreamOpen()); - var handler = Handlers.Sequential(steps.ToArray()); + requestHandlers.Add(MockConnectStrategy.RespondWithDataAndStayOpen(":abc\n")); - var backoffs = new List(); + var expectedDelay = baseDelay; - WithServerAndEventSource(handler, c => c.InitialRetryDelay(TimeSpan.FromMilliseconds(100)), (server, es) => - { - _ = new EventSink(es, _testLogging); - es.Closed += (_, state) => + await WithMockConnectEventSource( + mock => mock.ConfigureRequests(requestHandlers.ToArray()), + c => c.ErrorStrategy(ErrorStrategy.AlwaysContinue) + .InitialRetryDelay(baseDelay) + .RetryDelayStrategy(new ArithmeticallyIncreasingDelayStrategy(increment, TimeSpan.Zero)), + async (mock, es) => { - backoffs.Add(es.BackOffDelay); - }; - - _ = Task.Run(es.StartAsync); + await es.StartAsync(); - for (int i = 0; i <= nAttempts; i++) + for (int i = 0; i < nAttempts; i++) { - _ = server.Recorder.RequireRequest(); + Assert.Equal(new CommentEvent("hi"), await es.ReadAnyEventWithTimeoutAsync(DefaultTimeout)); + Assert.Equal(new FaultEvent(new StreamClosedByServerException()), + await es.ReadAnyEventWithTimeoutAsync(DefaultTimeout)); + + Assert.Equal(expectedDelay, es.NextRetryDelay); + expectedDelay += increment; + + Assert.Equal(new StartedEvent(), await es.ReadAnyEventWithTimeoutAsync(DefaultTimeout)); } }); - - AssertBackoffsAlwaysIncrease(backoffs, nAttempts); } - [Fact] - public async Task NoReconnectAttemptIsMadeIfErrorHandlerClosesEventSource() + public class ArithmeticallyIncreasingDelayStrategy : RetryDelayStrategy { - var handler = Handlers.Sequential( - Handlers.Status((int)HttpStatusCode.Unauthorized), - StartStream().Then(LeaveStreamOpen()) - ); + private readonly TimeSpan _increment, _cumulative; - using (var server = HttpServer.Start(handler)) + public ArithmeticallyIncreasingDelayStrategy(TimeSpan increment, TimeSpan cumulative) { - using (var es = MakeEventSource(server.Uri)) - { - es.Error += (_, e) => es.Close(); - - await es.StartAsync(); - - server.Recorder.RequireRequest(); - server.Recorder.RequireNoRequests(TimeSpan.FromMilliseconds(100)); - } + _increment = increment; + _cumulative = cumulative; } + + public override Result Apply(TimeSpan baseRetryDelay) => + new Result + { + Delay = baseRetryDelay + _cumulative, + Next = new ArithmeticallyIncreasingDelayStrategy(_increment, + _cumulative + _increment) + }; } } } diff --git a/test/LaunchDarkly.EventSource.Tests/EventSourceStreamReadingTest.cs b/test/LaunchDarkly.EventSource.Tests/EventSourceStreamReadingTest.cs index 9c9c4d7..1eb5853 100644 --- a/test/LaunchDarkly.EventSource.Tests/EventSourceStreamReadingTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/EventSourceStreamReadingTest.cs @@ -2,121 +2,105 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using LaunchDarkly.TestHelpers.HttpTest; +using LaunchDarkly.EventSource.Events; +using LaunchDarkly.EventSource.Exceptions; using Xunit; using Xunit.Abstractions; -using static LaunchDarkly.EventSource.Tests.TestHelpers; +using static LaunchDarkly.EventSource.TestHelpers; -namespace LaunchDarkly.EventSource.Tests +namespace LaunchDarkly.EventSource { - public abstract class EventSourceStreamReadingTestBase : BaseTest + public class EventSourceStreamReadingTest : BaseTest { - // There are two subclasses of this test class because the stream reading logic has two - // code paths, one for reading raw UTF-8 byte data and one for everything else, so we want - // to make sure we have the same test coverage in both cases. - - protected EventSourceStreamReadingTestBase(ITestOutputHelper testOutput) : base(testOutput) { } - - protected abstract bool IsRawUtf8Mode { get; } - - protected override void AddBaseConfig(ConfigurationBuilder builder) - { - builder.PreferDataAsUtf8Bytes(IsRawUtf8Mode); - } - - protected void WithServerAndStartedEventSource(Handler handler, Action action) => - WithServerAndStartedEventSource(handler, null, action); - - protected void WithServerAndStartedEventSource(Handler handler, Action modConfig, Action action) - { - using (var server = HttpServer.Start(handler)) - { - using (var es = MakeEventSource(server.Uri, modConfig)) - { - var eventSink = new EventSink(es, _testLogging) { ExpectUtf8Data = IsRawUtf8Mode }; - _ = Task.Run(es.StartAsync); - action(es, eventSink); - } - } - } + public EventSourceStreamReadingTest(ITestOutputHelper testOutput) : base(testOutput) { } [Fact] - public void ReceiveComment() + public async Task ReceiveComment() { - var commentSent = ": hello"; - - var handler = StartStream().Then(Handlers.WriteChunkString(commentSent + "\n")) - .Then(LeaveStreamOpen()); - - WithServerAndStartedEventSource(handler, (_, eventSink) => - { - eventSink.ExpectActions( - EventSink.OpenedAction(), - EventSink.CommentReceivedAction(commentSent) - ); - }); + await WithMockConnectEventSource( + mock => mock.ConfigureRequests( + MockConnectStrategy.RespondWithDataAndStayOpen(":hello\n") + ), + async (mock, es) => + { + await es.StartAsync(); + var e = await es.ReadAnyEventAsync(); + Assert.Equal(new CommentEvent("hello"), e); + }); } [Fact] - public void ReceiveEventWithOnlyData() + public async Task ReceiveEventWithOnlyData() { var eventData = "this is a test message"; - var sse = "data: " + eventData + "\n\n"; + var streamData = "data: " + eventData + "\n\n"; - var handler = StartStream().Then(Handlers.WriteChunkString(sse)) - .Then(LeaveStreamOpen()); - - WithServerAndStartedEventSource(handler, (_, eventSink) => - { - eventSink.ExpectActions( - EventSink.OpenedAction(), - EventSink.MessageReceivedAction(new MessageEvent(MessageEvent.DefaultName, eventData, _uri)) - ); - }); + await WithMockConnectEventSource( + mock => mock.ConfigureRequests( + MockConnectStrategy.RespondWithDataAndStayOpen(streamData) + ), + async (mock, es) => + { + await es.StartAsync(); + var e = await es.ReadAnyEventAsync(); + var m = Assert.IsType(e); + Assert.Equal(MessageEvent.DefaultName, m.Name); + Assert.Equal(eventData, m.Data); + Assert.Equal(MockConnectStrategy.MockOrigin, m.Origin); + Assert.Null(m.LastEventId); + }); } [Fact] - public void ReceiveEventWithEventNameAndData() + public async Task ReceiveEventWithEventNameAndData() { var eventName = "test event"; var eventData = "this is a test message"; - var sse = "event: " + eventName + "\ndata: " + eventData + "\n\n"; - - var handler = StartStream().Then(Handlers.WriteChunkString(sse)) - .Then(LeaveStreamOpen()); + var streamData = "event: " + eventName + "\ndata: " + eventData + "\n\n"; - WithServerAndStartedEventSource(handler, (_, eventSink) => - { - eventSink.ExpectActions( - EventSink.OpenedAction(), - EventSink.MessageReceivedAction(new MessageEvent(eventName, eventData, _uri)) - ); - }); + await WithMockConnectEventSource( + mock => mock.ConfigureRequests( + MockConnectStrategy.RespondWithDataAndStayOpen(streamData) + ), + async (mock, es) => + { + await es.StartAsync(); + var e = await WithTimeout(OneSecond, es.ReadAnyEventAsync); + var m = Assert.IsType(e); + Assert.Equal(eventName, m.Name); + Assert.Equal(eventData, m.Data); + Assert.Equal(MockConnectStrategy.MockOrigin, m.Origin); + Assert.Null(m.LastEventId); + }); } [Fact] - public void ReceiveEventWithID() + public async Task ReceiveEventWithID() { var eventName = "test event"; var eventData = "this is a test message"; var eventId = "123abc"; - var sse = "event: " + eventName + "\ndata: " + eventData + "\nid: " + eventId + "\n\n"; - - var handler = StartStream().Then(Handlers.WriteChunkString(sse)) - .Then(LeaveStreamOpen()); + var streamData = "event: " + eventName + "\ndata: " + eventData + "\nid: " + eventId + "\n\n"; - WithServerAndStartedEventSource(handler, (_, eventSink) => - { - eventSink.ExpectActions( - EventSink.OpenedAction(), - EventSink.MessageReceivedAction(new MessageEvent(eventName, eventData, eventId, _uri)) - ); - }); + await WithMockConnectEventSource( + mock => mock.ConfigureRequests( + MockConnectStrategy.RespondWithDataAndStayOpen(streamData) + ), + async (mock, es) => + { + await es.StartAsync(); + var e = await WithTimeout(OneSecond, es.ReadAnyEventAsync); + var m = Assert.IsType(e); + Assert.Equal(eventName, m.Name); + Assert.Equal(eventData, m.Data); + Assert.Equal(MockConnectStrategy.MockOrigin, m.Origin); + Assert.Equal(eventId, m.LastEventId); + }); } [Fact] - public void ReceiveEventStreamInChunks() + public async Task ReceiveEventStreamInChunks() { // This simply verifies that chunked streaming works as expected and that events are being // parsed correctly regardless of how the chunks line up with the events. @@ -141,120 +125,59 @@ public void ReceiveEventStreamInChunks() pos += chunkSize; } - var handler = StartStream().Then(async ctx => - { - foreach (var s in chunks) + await WithMockConnectEventSource( + mock => mock.ConfigureRequests( + MockConnectStrategy.RespondWithDataAndStayOpen(chunks.ToArray()) + ), + async (mock, es) => { - await Handlers.WriteChunkString(s)(ctx); - } - }).Then(LeaveStreamOpen()); - - var expectedActions = new List(); - expectedActions.Add(EventSink.OpenedAction()); - foreach (var data in eventData) - { - expectedActions.Add(EventSink.MessageReceivedAction(new MessageEvent(MessageEvent.DefaultName, data, _uri))); - } - - WithServerAndStartedEventSource(handler, (_, eventSink) => - { - eventSink.ExpectActions(expectedActions.ToArray()); - }); + await es.StartAsync(); + foreach (var data in eventData) + { + var e = await es.ReadAnyEventAsync(); + var m = Assert.IsType(e); + Assert.Equal(MessageEvent.DefaultName, m.Name); + Assert.Equal(data, m.Data); + Assert.Equal(MockConnectStrategy.MockOrigin, m.Origin); + } + }); } [Fact] - public void DetectReadTimeout() - { - TimeSpan readTimeout = TimeSpan.FromMilliseconds(300); - TimeSpan timeToWait = readTimeout + readTimeout; - - var handler = StartStream() - .Then(Handlers.WriteChunkString(":comment1\n")) - .Then(Handlers.WriteChunkString(":comment2\n")) - .Then(Handlers.Delay(timeToWait)) - .Then(Handlers.WriteChunkString(":comment3\n")); - - WithServerAndStartedEventSource(handler, config => config.ReadTimeout(readTimeout), (_, eventSink) => - { - eventSink.ExpectActions( - EventSink.OpenedAction(), - EventSink.CommentReceivedAction(":comment1"), - EventSink.CommentReceivedAction(":comment2"), - EventSink.ErrorAction(new ReadTimeoutException()), - EventSink.ClosedAction() - ); - }); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public void CanRestartStream(bool resetBackoff) + public async Task CanRestartStream() { // This test is in EventSourceStreamReadingTest rather than EventSourceReconnectingTest // because the important thing here is that the stream reading logic can be interrupted. int nAttempts = 3; var initialDelay = TimeSpan.FromMilliseconds(50); - var anEvent = new MessageEvent("put", "x", _uri); - var handler = Handlers.Sequential( + var anEvent = new MessageEvent("put", "x", MockConnectStrategy.MockOrigin); + var handlers = Enumerable.Range(0, nAttempts + 1).Select(_ => - StartStream().Then(WriteEvent(anEvent)).Then(LeaveStreamOpen()) - ).ToArray()); + MockConnectStrategy.RespondWithDataAndStayOpen("event:put\ndata:x\n\n") + ).ToArray(); var backoffs = new List(); - using (var server = HttpServer.Start(handler)) - { - using (var es = MakeEventSource(server.Uri, config => config.InitialRetryDelay(initialDelay))) + await WithMockConnectEventSource( + mock => mock.ConfigureRequests(handlers), + c => c.InitialRetryDelay(initialDelay).ErrorStrategy(ErrorStrategy.AlwaysContinue), + async (mock, es) => { - var sink = new EventSink(es, _testLogging); - es.Closed += (_, ex) => - { - backoffs.Add(es.BackOffDelay); - }; - _ = Task.Run(es.StartAsync); + await es.StartAsync(); - sink.ExpectActions( - EventSink.OpenedAction(), - EventSink.MessageReceivedAction(anEvent) - ); + Assert.Equal(anEvent, await es.ReadAnyEventAsync()); for (var i = 0; i < nAttempts; i++) { - es.Restart(resetBackoff); + es.Interrupt(); - sink.ExpectActions( - EventSink.ClosedAction(), - EventSink.OpenedAction(), - EventSink.MessageReceivedAction(anEvent) - ); + Assert.Equal(new FaultEvent(new StreamClosedByCallerException()), + await es.ReadAnyEventAsync()); + Assert.Equal(new StartedEvent(), await es.ReadAnyEventAsync()); + Assert.Equal(anEvent, await es.ReadAnyEventAsync()); } - } - } - - if (resetBackoff) - { - Assert.All(backoffs, delay => Assert.InRange(delay, TimeSpan.Zero, initialDelay)); - } - else - { - AssertBackoffsAlwaysIncrease(backoffs, nAttempts); - } + }); } } - - public class EventSourceStreamReadingDefaultModeTest : EventSourceStreamReadingTestBase - { - protected override bool IsRawUtf8Mode => false; - - public EventSourceStreamReadingDefaultModeTest(ITestOutputHelper testOutput) : base(testOutput) { } - } - - public class EventSourceStreamReadingRawUtf8ModeTest : EventSourceStreamReadingTestBase - { - protected override bool IsRawUtf8Mode => true; - - public EventSourceStreamReadingRawUtf8ModeTest(ITestOutputHelper testOutput) : base(testOutput) { } - } } diff --git a/test/LaunchDarkly.EventSource.Tests/Events/EventTypesTest.cs b/test/LaunchDarkly.EventSource.Tests/Events/EventTypesTest.cs new file mode 100644 index 0000000..8a78e47 --- /dev/null +++ b/test/LaunchDarkly.EventSource.Tests/Events/EventTypesTest.cs @@ -0,0 +1,41 @@ +using System; +using LaunchDarkly.EventSource.Exceptions; +using LaunchDarkly.TestHelpers; +using Xunit; + +namespace LaunchDarkly.EventSource.Events +{ + public class EventTypesTest + { + [Fact] + public void TestCommentEvent() + { + Assert.Equal("a", new CommentEvent("a").Text); + + TypeBehavior.CheckEqualsAndHashCode( + () => new CommentEvent("a"), + () => new CommentEvent("b") + ); + } + + [Fact] + public void TestFaultEvent() + { + var ex = new ReadTimeoutException(); + Assert.Same(ex, new FaultEvent(ex).Exception); + + TypeBehavior.CheckEqualsAndHashCode( + () => new FaultEvent(new ReadTimeoutException()), + () => new FaultEvent(new StreamClosedByCallerException()) + ); + } + + [Fact] + public void TestStartedEvent() + { + TypeBehavior.CheckEqualsAndHashCode( + () => new StartedEvent() + ); + } + } +} diff --git a/test/LaunchDarkly.EventSource.Tests/MessageEventTest.cs b/test/LaunchDarkly.EventSource.Tests/Events/MessageEventTest.cs similarity index 98% rename from test/LaunchDarkly.EventSource.Tests/MessageEventTest.cs rename to test/LaunchDarkly.EventSource.Tests/Events/MessageEventTest.cs index 74c799f..0885bd7 100644 --- a/test/LaunchDarkly.EventSource.Tests/MessageEventTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/Events/MessageEventTest.cs @@ -1,7 +1,7 @@ using System; using Xunit; -namespace LaunchDarkly.EventSource.Tests +namespace LaunchDarkly.EventSource.Events { public class MessageEventTest { diff --git a/test/LaunchDarkly.EventSource.Tests/Utf8ByteSpanTest.cs b/test/LaunchDarkly.EventSource.Tests/Events/Utf8ByteSpanTest.cs similarity index 98% rename from test/LaunchDarkly.EventSource.Tests/Utf8ByteSpanTest.cs rename to test/LaunchDarkly.EventSource.Tests/Events/Utf8ByteSpanTest.cs index 5ec7f07..fe7636f 100644 --- a/test/LaunchDarkly.EventSource.Tests/Utf8ByteSpanTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/Events/Utf8ByteSpanTest.cs @@ -2,7 +2,7 @@ using System.Text; using Xunit; -namespace LaunchDarkly.EventSource.Tests +namespace LaunchDarkly.EventSource.Events { public class Utf8ByteSpanTest { diff --git a/test/LaunchDarkly.EventSource.Tests/Exceptions/ExceptionTypesTest.cs b/test/LaunchDarkly.EventSource.Tests/Exceptions/ExceptionTypesTest.cs new file mode 100644 index 0000000..1f3f10e --- /dev/null +++ b/test/LaunchDarkly.EventSource.Tests/Exceptions/ExceptionTypesTest.cs @@ -0,0 +1,55 @@ +using System; +using System.Net.Http.Headers; +using System.Text; +using LaunchDarkly.EventSource.Exceptions; +using LaunchDarkly.TestHelpers; +using Xunit; + +namespace LaunchDarkly.EventSource.Exceptions +{ + public class ExceptionTypesTest + { + [Fact] + public void TestNonParameterizedExceptions() + { + TypeBehavior.CheckEqualsAndHashCode( + () => new ReadTimeoutException(), + () => new StreamClosedByCallerException(), + () => new StreamClosedByServerException() + ); + } + + [Fact] + public void TestStreamContentException() + { + var ex = new StreamContentException( + new MediaTypeHeaderValue("text/html"), + Encoding.UTF32); + Assert.Equal(new MediaTypeHeaderValue("text/html"), ex.ContentType); + Assert.Equal(Encoding.UTF32, ex.ContentEncoding); + + TypeBehavior.CheckEqualsAndHashCode( + () => new StreamContentException( + new MediaTypeHeaderValue("text/event-stream"), + Encoding.UTF32), + () => new StreamContentException( + new MediaTypeHeaderValue("text/html"), + Encoding.UTF32), + () => new StreamContentException( + new MediaTypeHeaderValue("text/html"), + Encoding.UTF8) + ); + } + + [Fact] + public void TestStreamHttpErrorException() + { + Assert.Equal(400, new StreamHttpErrorException(400).Status); + + TypeBehavior.CheckEqualsAndHashCode( + () => new StreamHttpErrorException(400), + () => new StreamHttpErrorException(500) + ); + } + } +} diff --git a/test/LaunchDarkly.EventSource.Tests/ExponentialBackoffWithDecorrelationTest.cs b/test/LaunchDarkly.EventSource.Tests/ExponentialBackoffWithDecorrelationTest.cs deleted file mode 100644 index 071b119..0000000 --- a/test/LaunchDarkly.EventSource.Tests/ExponentialBackoffWithDecorrelationTest.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using Xunit; - -namespace LaunchDarkly.EventSource.Tests -{ - public class ExponentialBackoffWithDecorrelationTest - { - [Fact] - public void Exponential_backoff_should_not_exceed_maximum() - { - TimeSpan max = TimeSpan.FromMilliseconds(30000); - ExponentialBackoffWithDecorrelation expo = - new ExponentialBackoffWithDecorrelation(TimeSpan.FromMilliseconds(1000), max); - - var backoff = expo.GetNextBackOff(); - - Assert.True(backoff <= max); - } - - [Fact] - public void Exponential_backoff_should_not_exceed_maximum_in_test_loop() - { - TimeSpan max = TimeSpan.FromMilliseconds(30000); - - ExponentialBackoffWithDecorrelation expo = - new ExponentialBackoffWithDecorrelation(TimeSpan.FromMilliseconds(1000), max); - - for (int i = 0; i < 100; i++) - { - - var backoff = expo.GetNextBackOff(); - - Assert.True(backoff <= max); - } - - } - - [Fact] - public void Exponential_backoff_should_reset_when_reconnect_count_resets() - { - TimeSpan max = TimeSpan.FromMilliseconds(30000); - - ExponentialBackoffWithDecorrelation expo = - new ExponentialBackoffWithDecorrelation(TimeSpan.FromMilliseconds(1000), max); - - for (int i = 0; i < 100; i++) - { - var backoff = expo.GetNextBackOff(); - } - expo.ResetReconnectAttemptCount(); - // Backoffs use jitter, so assert that the reset backoff time isn't more than double the minimum - Assert.True(expo.GetNextBackOff() <= TimeSpan.FromMilliseconds(2000)); - } - } -} diff --git a/test/LaunchDarkly.EventSource.Tests/HttpConnectStrategyTest.cs b/test/LaunchDarkly.EventSource.Tests/HttpConnectStrategyTest.cs new file mode 100644 index 0000000..cb2b8e7 --- /dev/null +++ b/test/LaunchDarkly.EventSource.Tests/HttpConnectStrategyTest.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.NetworkInformation; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.EventSource.Exceptions; +using LaunchDarkly.TestHelpers.HttpTest; +using Xunit; + +using static LaunchDarkly.EventSource.TestHelpers; + +namespace LaunchDarkly.EventSource +{ + /// + /// Tests of the basic client/request configuration methods and HTTP functionality + /// in HttpConnectStrategy, using an embedded HTTP server as a target, but without + /// using EventSource. + /// + public class HttpConnectStrategyTest : BaseTest + { + private static readonly Uri uri = new Uri("http://test"); + + private static readonly HttpConnectStrategy baseStrategy = ConnectStrategy.Http(uri); + + [Fact] + public void HttpClient() + { + using (var client = new HttpClient()) + { + Assert.Same(client, MakeClientFrom(ConnectStrategy.Http(uri).HttpClient(client))); + } + } + + [Fact] + public void HttpClientModifier() + { + using (var client = MakeClientFrom(ConnectStrategy.Http(uri) + .HttpClientModifier(c => c.MaxResponseContentBufferSize = 999))) + { + Assert.Equal(999, client.MaxResponseContentBufferSize); + } + } + + [Fact] + public void ResponseStartTimeout() + { + TimeSpan timeout = TimeSpan.FromMilliseconds(100); + using (var client = MakeClientFrom(ConnectStrategy.Http(uri) + .ResponseStartTimeout(timeout))) + { + Assert.Equal(timeout, client.Timeout); + } + } + + [Fact] + public async Task RequestDefaultProperties() + { + var r = await DoRequestFrom(baseStrategy); + + Assert.Equal("GET", r.Method); + Assert.Equal("", r.Body); + Assert.Equal("text/event-stream", r.Headers["accept"]); + Assert.Null(r.Headers["last-event-id"]); + } + + [Fact] + public async Task RequestCustomHeaders() + { + var headers = new Dictionary { { "name1", "value1" }, { "name2", "value2" } }; + + var r = await DoRequestFrom(baseStrategy + .Headers(headers) + .Header("name3", "value3") + ); + + Assert.Equal("value1", r.Headers["name1"]); + Assert.Equal("value2", r.Headers["name2"]); + Assert.Equal("value3", r.Headers["name3"]); + } + + [Fact] + public async Task RequestLastEventId() + { + var r = await DoRequestFrom(baseStrategy, "abc123"); + + Assert.Equal("abc123", r.Headers["last-event-id"]); + } + + [Fact] + public async Task RequestCustomMethodWithBody() + { + var r = await DoRequestFrom(baseStrategy + .Method(HttpMethod.Post) + .RequestBodyFactory(() => new StringContent("{}")) + ); + + Assert.Equal("POST", r.Method); + Assert.Equal("{}", r.Body); + } + + [Fact] + public async Task RequestModifier() + { + var r = await DoRequestFrom(baseStrategy + .HttpRequestModifier(req => req.RequestUri = new Uri(req.RequestUri, "abc")) + ); + + Assert.Equal("/abc", r.Path); + } + + [Fact] + public async Task CanReadFromChunkedResponseStream() + { + var fakeStream = Handlers.SSE.Start().Then(Handlers.WriteChunkString("hello ")) + .Then(Handlers.WriteChunkString("world")); + using (var server = HttpServer.Start(fakeStream)) + { + using (var client = baseStrategy.Uri(server.Uri).CreateClient(_testLogger)) + { + var result = await client.ConnectAsync(new ConnectStrategy.Client.Params()); + try + { + var stream = result.Stream; + var b = new byte[100]; + Assert.Equal(6, await stream.ReadAsync(b, 0, 6)); + Assert.Equal(5, await stream.ReadAsync(b, 6, 5)); + Assert.Equal("hello world", Encoding.UTF8.GetString(b, 0, 11)); + } + finally + { + result.Closer.Dispose(); + } + } + } + } + + [Theory] + [InlineData(HttpStatusCode.NoContent)] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.BadRequest)] + [InlineData(HttpStatusCode.RequestTimeout)] + [InlineData(HttpStatusCode.Unauthorized)] + public async Task RequestFailsWithHttpError(HttpStatusCode status) + { + var response = Handlers.Status(status); + using (var server = HttpServer.Start(response)) + { + using (var client = baseStrategy.Uri(server.Uri).CreateClient(_testLogger)) + { + var ex = await Assert.ThrowsAnyAsync( + async () => await client.ConnectAsync(new ConnectStrategy.Client.Params())); + Assert.Equal((int)status, ex.Status); + } + } + } + + [Fact] + public async Task RequestFailsWithIncorrectContentType() + { + var response = Handlers.Status(200).Then(Handlers.BodyString("text/html", "testing")); + using (var server = HttpServer.Start(response)) + { + using (var client = baseStrategy.Uri(server.Uri).CreateClient(_testLogger)) + { + var ex = await Assert.ThrowsAnyAsync( + async () => await client.ConnectAsync(new ConnectStrategy.Client.Params())); + Assert.Equal("text/html", ex.ContentType.ToString()); + } + } + } + + [Fact] + public async Task RequestFailsWithIncorrectContentEncoding() + { + var badEncoding = Encoding.GetEncoding("iso-8859-1"); + var response = Handlers.StartChunks("text/event-stream", badEncoding) + .Then(WriteComment("")); + using (var server = HttpServer.Start(response)) + { + using (var client = baseStrategy.Uri(server.Uri).CreateClient(_testLogger)) + { + var ex = await Assert.ThrowsAnyAsync( + async () => await client.ConnectAsync(new ConnectStrategy.Client.Params())); + Assert.Equal(badEncoding, ex.ContentEncoding); + } + } + } + + private HttpClient MakeClientFrom(HttpConnectStrategy hcs) => + ((HttpConnectStrategy.ClientImpl)hcs.CreateClient(_testLogger)).HttpClient; + + private async Task DoRequestFrom(HttpConnectStrategy hcs, string lastEventId = null) + { + var fakeStream = StartStream().Then(WriteComment("")); + using (var server = HttpServer.Start(fakeStream)) + { + using (var client = hcs.Uri(server.Uri).CreateClient(_testLogger)) + { + var p = new ConnectStrategy.Client.Params { LastEventId = lastEventId }; + var result = await client.ConnectAsync(p); + result.Closer.Dispose(); + return server.Recorder.RequireRequest(); + } + } + } + } +} diff --git a/test/LaunchDarkly.EventSource.Tests/HttpConnectStrategyWithEventSource.cs b/test/LaunchDarkly.EventSource.Tests/HttpConnectStrategyWithEventSource.cs new file mode 100644 index 0000000..27cfae5 --- /dev/null +++ b/test/LaunchDarkly.EventSource.Tests/HttpConnectStrategyWithEventSource.cs @@ -0,0 +1,88 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using LaunchDarkly.EventSource.Events; +using LaunchDarkly.EventSource.Internal; +using LaunchDarkly.TestHelpers.HttpTest; +using Xunit; +using Xunit.Abstractions; + +using static LaunchDarkly.EventSource.TestHelpers; + +namespace LaunchDarkly.EventSource +{ + /// + /// Tests of basic EventSource behavior using real HTTP requests. + /// + public class HttpConnectStrategyWithEventSource : BaseTest + { + public HttpConnectStrategyWithEventSource(ITestOutputHelper testOutput) : base(testOutput) { } + + [Fact] + public async Task CustomHttpClientIsNotClosedWhenEventSourceCloses() + { + using (var server = HttpServer.Start(Handlers.Status(200))) + { + using (var client = new HttpClient()) + { + var es = new EventSource( + Configuration.Builder( + ConnectStrategy.Http(server.Uri).HttpClient(client) + ).Build()); + es.Close(); + + await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, server.Uri)); + } + } + } + + [Fact] + public async Task LastEventIdHeaderIsNotSetByDefault() + { + await WithServerAndEventSource(StreamWithCommentThatStaysOpen, + async (server, es) => + { + await es.StartAsync(); + var req = server.Recorder.RequireRequest(); + Assert.Null(req.Headers.Get(Constants.LastEventIdHttpHeader)); + }); + } + + [Fact] + public async Task LastEventIdHeaderIsSetIfConfigured() + { + var lastEventId = "abc123"; + + await WithServerAndEventSource(StreamWithCommentThatStaysOpen, + null, + config => config.LastEventId(lastEventId), + async (server, es) => + { + await es.StartAsync(); + + var req = server.Recorder.RequireRequest(); + Assert.Equal(lastEventId, req.Headers.Get(Constants.LastEventIdHttpHeader)); + }); + } + + [Fact] + public async Task ReadTimeoutIsDetected() + { + TimeSpan readTimeout = TimeSpan.FromMilliseconds(200); + var streamHandler = StartStream() + .Then(Handlers.WriteChunkString("data: event1\n\ndata: e")) + .Then(Handlers.Delay(readTimeout + readTimeout)) + .Then(Handlers.WriteChunkString("vent2\n\n")); + await WithServerAndEventSource(streamHandler, + http => http.ReadTimeout(readTimeout), + null, + async (server, es) => + { + await es.StartAsync(); + Assert.Equal(new MessageEvent(MessageEvent.DefaultName, "event1", server.Uri), + await es.ReadMessageAsync()); + await Assert.ThrowsAnyAsync(() => es.ReadMessageAsync()); + }); + } + } +} diff --git a/test/LaunchDarkly.EventSource.Tests/AsyncHelpersTest.cs b/test/LaunchDarkly.EventSource.Tests/Internal/AsyncHelpersTest.cs similarity index 84% rename from test/LaunchDarkly.EventSource.Tests/AsyncHelpersTest.cs rename to test/LaunchDarkly.EventSource.Tests/Internal/AsyncHelpersTest.cs index c77a18e..7b3c99c 100644 --- a/test/LaunchDarkly.EventSource.Tests/AsyncHelpersTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/Internal/AsyncHelpersTest.cs @@ -1,10 +1,12 @@ using System; +using System.Collections.Concurrent; +using System.IO; using System.Threading; using System.Threading.Tasks; using Xunit; using Xunit.Abstractions; -namespace LaunchDarkly.EventSource.Tests +namespace LaunchDarkly.EventSource.Internal { public class AsyncHelpersTest : BaseTest { @@ -45,7 +47,7 @@ await AsyncHelpers.DoWithTimeout( [Fact] public async void DoWithTimeoutCanThrowExceptionFromTask() { - await Assert.ThrowsAnyAsync(async () => + await Assert.ThrowsAnyAsync(async () => { await AsyncHelpers.DoWithTimeout( TimeSpan.FromSeconds(1), @@ -53,7 +55,7 @@ await AsyncHelpers.DoWithTimeout( async (token) => { await Task.Yield(); - throw new EventSourceServiceUnsuccessfulResponseException(401); + throw new IOException(); }); }); } @@ -86,6 +88,7 @@ public async void AllowCancellationReturnsEarlyWhenCancelled() var signal1 = new EventWaitHandle(false, EventResetMode.ManualReset); var signal2 = new EventWaitHandle(false, EventResetMode.ManualReset); + var deliberateException = new Exception("this should NOT become an unobserved exception"); Func> taskFn = async () => { signal1.Set(); @@ -93,14 +96,14 @@ public async void AllowCancellationReturnsEarlyWhenCancelled() Assert.True(signal2.WaitOne(TimeSpan.FromSeconds(1))); await Task.Yield(); await Task.Delay(TimeSpan.FromMilliseconds(50)); - throw new Exception("this should NOT become an unobserved exception"); + throw deliberateException; }; - UnobservedTaskExceptionEventArgs receivedUnobservedException = null; + var receivedExceptions = new BlockingCollection(); EventHandler exceptionHandler = (object sender, UnobservedTaskExceptionEventArgs e) => { e.SetObserved(); - receivedUnobservedException = e; + receivedExceptions.Add(e.Exception); }; TaskScheduler.UnobservedTaskException += exceptionHandler; @@ -121,12 +124,15 @@ await Assert.ThrowsAnyAsync(async () => // Wait a little while and then run the finalizer so that if the task did not // get cleaned up properly, the exception it threw will show up as an unobserved - // exception. + // exception. (There might be some other exceptions in receivedExceptions that + // are unrelated to this test, which could happen as a side effect of another + // test running a task that doesn't get immediately cleaned up. We can ignore + // those here.) await Task.Delay(TimeSpan.FromMilliseconds(500)); GC.Collect(); GC.WaitForPendingFinalizers(); - Assert.Null(receivedUnobservedException); + Assert.DoesNotContain(deliberateException, receivedExceptions); } finally { diff --git a/test/LaunchDarkly.EventSource.Tests/Internal/BufferedLineParserTest.cs b/test/LaunchDarkly.EventSource.Tests/Internal/BufferedLineParserTest.cs new file mode 100644 index 0000000..d3b6e4f --- /dev/null +++ b/test/LaunchDarkly.EventSource.Tests/Internal/BufferedLineParserTest.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using LaunchDarkly.EventSource.Exceptions; +using Xunit; + +namespace LaunchDarkly.EventSource.Internal +{ + public class BufferedLineParserTest + { + // This test uses many permutations of how long the input lines are, how long are + // the chunks that are returned by each read of the (fake) stream, how big the + // read buffer is, and what the line terminators are, with a mix of single-byte + // and multi-byte UTF8 characters, to verify that our parsing logic doesn't have + // edge cases that fail. + + public struct LineTerminatorType + { + public string Name, Value; + } + + private static readonly LineTerminatorType[] AllLineTerminators = + { + new LineTerminatorType { Name = "CR", Value = "\r" }, + new LineTerminatorType { Name = "LF", Value = "\n" }, + new LineTerminatorType { Name = "CRLF", Value = "\r\n" } + }; + + public static IEnumerable AllParameters() + { + yield return new object[] { 100, AllLineTerminators[1] }; + //foreach (var chunkSize in new int[] { 1, 2, 3, 200 }) + //{ + // foreach (LineTerminatorType lt in AllLineTerminators) + // { + // yield return new object[] { chunkSize, lt }; + // } + //} + } + + [Theory] + [MemberData(nameof(AllParameters))] + public void ParseLinesShorterThanBuffer(int chunkSize, LineTerminatorType lt) + { + ParseLinesWithLengthsAndBufferSize(20, 25, 100, chunkSize, lt); + } + + [Theory] + [MemberData(nameof(AllParameters))] + public void ParseLinesLongerThanBuffer(int chunkSize, LineTerminatorType lt) + { + ParseLinesWithLengthsAndBufferSize(20, 25, 100, chunkSize, lt); + } + + private void ParseLinesWithLengthsAndBufferSize(int minLength, int maxLength, int bufferSize, + int chunkSize, LineTerminatorType lt) + { + var lines = MakeLines(20, minLength, maxLength); + var linesWithEnds = lines.Select(line => line + lt.Value); + ParseLinesWithBufferSize(lines, linesWithEnds, bufferSize, chunkSize); + } + + private async void ParseLinesWithBufferSize( + IEnumerable lines, + IEnumerable linesWithEnds, + int bufferSize, + int chunkSize + ) + { + IEnumerable chunks; + if (chunkSize == 0) + { + chunks = linesWithEnds.Select(line => Encoding.UTF8.GetBytes(line)); + } + else + { + var allBytes = Encoding.UTF8.GetBytes(string.Join("", linesWithEnds)); + chunks = allBytes + .Select((x, i) => new { Index = i, Value = x }) + .GroupBy(x => x.Index / chunkSize) + .Select(x => x.Select(v => v.Value).ToArray()); + } + + var input = new FakeInputStream(chunks.ToArray()); + var parser = new BufferedLineParser(input.ReadAsync, bufferSize); + + var actualLines = new List(); + var buf = new MemoryStream(); + while (true) + { + try + { + var chunk = await parser.ReadAsync(); + buf.Write(chunk.Span.Data, chunk.Span.Offset, chunk.Span.Length); + if (chunk.EndOfLine) + { + actualLines.Add(Encoding.UTF8.GetString(buf.GetBuffer(), 0, (int)buf.Length)); + buf.SetLength(0); + } + } + catch (StreamClosedByServerException) + { + break; + } + } + Assert.Equal(lines.ToList(), actualLines); + } + + + private static List MakeLines(int count, int minLength, int maxLength) + { + String allChars = makeUtf8CharacterSet(); + var ret = new List(); + for (int i = 0; i < count; i++) + { + int length = minLength + ((maxLength - minLength) * i) / count; + StringBuilder s = new StringBuilder(); + for (int j = 0; j < length; j++) + { + char ch = allChars[(i + j) % allChars.Length]; + s.Append(ch); + } + ret.Add(s.ToString()); + } + return ret; + } + + private static string makeUtf8CharacterSet() + { + // Here we're mixing in some multi-byte characters so that we will sometimes end up + // dividing a character across chunks. + string singleByteCharacters = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + string multiByteCharacters = "ØĤǶȵ϶ӾﺯᜅጺᏡ"; + StringBuilder s = new StringBuilder(); + int mi = 0; + for (int si = 0; si < singleByteCharacters.Length; si++) + { + s.Append(singleByteCharacters[si]); + if (si % 5 == 4) + { + s.Append(multiByteCharacters[(mi++) % multiByteCharacters.Length]); + } + } + return s.ToString(); + } + + public class FakeInputStream : Stream + { + private byte[][] _chunks; + private int _curChunk = 0; + private int _posInChunk = 0; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + + public override long Length => throw new NotImplementedException(); + + public override long Position { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + public FakeInputStream(byte[][] chunks) + { + _chunks = chunks; + } + + public override void Flush() { } + + public override int Read(byte[] buffer, int offset, int count) + { + if (_curChunk >= _chunks.Length) + { + return -1; + } + int remaining = _chunks[_curChunk].Length - _posInChunk; + if (remaining <= count) + { + System.Buffer.BlockCopy(_chunks[_curChunk], _posInChunk, buffer, offset, remaining); + _curChunk++; + _posInChunk = 0; + return remaining; + } + System.Buffer.BlockCopy(_chunks[_curChunk], _posInChunk, buffer, offset, count); + _posInChunk += count; + return count; + } + + public new Task ReadAsync(byte[] buffer, int offset, int count) + { + return Task.FromResult(Read(buffer, offset, count)); + } + + public override long Seek(long offset, SeekOrigin origin) { return 0; } + public override void SetLength(long value) { } + public override void Write(byte[] buffer, int offset, int count) { } + } + } +} diff --git a/test/LaunchDarkly.EventSource.Tests/Internal/EventParserTest.cs b/test/LaunchDarkly.EventSource.Tests/Internal/EventParserTest.cs new file mode 100644 index 0000000..11362c3 --- /dev/null +++ b/test/LaunchDarkly.EventSource.Tests/Internal/EventParserTest.cs @@ -0,0 +1,11 @@ +using System.Text; +using LaunchDarkly.EventSource.Events; +using Xunit; + +namespace LaunchDarkly.EventSource.Internal +{ + public class EventParserTest + { + + } +} diff --git a/test/LaunchDarkly.EventSource.Tests/LaunchDarkly.EventSource.Tests.csproj b/test/LaunchDarkly.EventSource.Tests/LaunchDarkly.EventSource.Tests.csproj index 193d77d..b640f12 100644 --- a/test/LaunchDarkly.EventSource.Tests/LaunchDarkly.EventSource.Tests.csproj +++ b/test/LaunchDarkly.EventSource.Tests/LaunchDarkly.EventSource.Tests.csproj @@ -17,6 +17,7 @@ + @@ -29,9 +30,20 @@ + + + + + + PreserveNewest + + + + + diff --git a/test/LaunchDarkly.EventSource.Tests/MockConnectStrategy.cs b/test/LaunchDarkly.EventSource.Tests/MockConnectStrategy.cs new file mode 100644 index 0000000..0042ea5 --- /dev/null +++ b/test/LaunchDarkly.EventSource.Tests/MockConnectStrategy.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.IO.Pipelines; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.Logging; + +namespace LaunchDarkly.EventSource +{ + /// + /// A test implementation of ConnectStrategy that provides input streams to simulate server + /// responses without running an HTTP server. We still do use an embedded HTTP server for + /// tests that are specifically about HTTP behavior, but otherwise this class is better. + /// There are some conditions that the embedded HTTP server cannot properly recreate, that + /// could happen in real life with an external server: for instance, due to implementation + /// details of the test server, it cannot send response headers until there is non-empty + /// stream data. + /// + public class MockConnectStrategy : ConnectStrategy + { + public static readonly Uri MockOrigin = new Uri("http://test/origin"); + + public override Uri Origin => MockOrigin; + + private readonly List _requestConfigs = new List(); + private volatile int _requestCount = 0; + + public readonly BlockingCollection ReceivedConnections = + new BlockingCollection(); + public volatile bool Closed; + + public override Client CreateClient(Logger logger) => + new DelegatingClientImpl(this); + + public void ConfigureRequests(params RequestHandler[] requestConfigs) + { + foreach (RequestHandler r in requestConfigs) + { + _requestConfigs.Add(r); + } + } + + public abstract class RequestHandler : IDisposable + { + public abstract Task ConnectAsync(Client.Params parameters); + + public virtual void Dispose() { } + } + + public static RequestHandler RejectConnection(Exception ex) => + new ConnectionFailureHandler { Exception = ex }; + + public static RequestHandler RespondWithDataAndThenEnd(string data) => + new StreamRequestHandler(new MemoryStream(Encoding.UTF8.GetBytes(data))); + + public static PipedStreamRequestHandler RespondWithStream() => + new PipedStreamRequestHandler(); + + public static PipedStreamRequestHandler RespondWithDataAndStayOpen(params string[] chunks) + { + var s = RespondWithStream(); + s.ProvideData(chunks); + return s; + } + + private class ConnectionFailureHandler : RequestHandler + { + public Exception Exception { get; set; } + + public override Task ConnectAsync(Client.Params p) => + throw Exception; + } + + public class StreamRequestHandler : RequestHandler + { + private readonly Stream _stream; + + public StreamRequestHandler(Stream stream) { _stream = stream; } + + public override Task ConnectAsync(Client.Params p) => + Task.FromResult(new Client.Result + { + Stream = _stream, + Closer = this + }); + } + + public class PipedStreamRequestHandler : RequestHandler, IDisposable + { + private readonly Stream _readStream; + private readonly Stream _writeStream; + private readonly BlockingCollection _chunks = new BlockingCollection(); + private volatile bool _closed = false; + + public PipedStreamRequestHandler() + { + var pipe = new Pipe(); + _readStream = pipe.Reader.AsStream(); + _writeStream = pipe.Writer.AsStream(); + } + + public override Task ConnectAsync(Client.Params p) + { + var thread = new Thread(() => + { + while (true) + { + try + { + var chunk = _chunks.Take(p.CancellationToken); + if (_closed) + { + return; + } + _writeStream.Write(chunk, 0, chunk.Length); + } + catch (Exception) { } + } + }); + thread.Start(); + return Task.FromResult(new Client.Result + { + Stream = _readStream, + Closer = this + }); + } + + public void ProvideData(params string[] chunks) + { + foreach (var chunk in chunks) + { + _chunks.Add(Encoding.UTF8.GetBytes(chunk)); + } + } + + public override void Dispose() + { + _closed = true; + } + } + + private class DelegatingClientImpl : Client + { + private readonly MockConnectStrategy _owner; + + public DelegatingClientImpl(MockConnectStrategy owner) { _owner = owner; } + + public override Task ConnectAsync(Params parameters) + { + if (_owner._requestConfigs.Count == 0) + { + throw new InvalidOperationException("MockConnectStrategy was not configured for any requests"); + } + _owner.ReceivedConnections.Add(parameters); + var handler = _owner._requestConfigs[_owner._requestCount]; + if (_owner._requestCount < _owner._requestConfigs.Count - 1) + { + _owner._requestCount++; // reuse last entry for all subsequent requests + } + return handler.ConnectAsync(parameters); + } + + public override void Dispose() + { + _owner.Closed = true; + } + } + } +} + diff --git a/test/LaunchDarkly.EventSource.Tests/TestHelpers.cs b/test/LaunchDarkly.EventSource.Tests/TestHelpers.cs index 162550c..c366da9 100644 --- a/test/LaunchDarkly.EventSource.Tests/TestHelpers.cs +++ b/test/LaunchDarkly.EventSource.Tests/TestHelpers.cs @@ -1,18 +1,26 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.EventSource.Events; +using LaunchDarkly.EventSource.Internal; using LaunchDarkly.TestHelpers.HttpTest; using Xunit; -namespace LaunchDarkly.EventSource.Tests +namespace LaunchDarkly.EventSource { public static class TestHelpers { + public static readonly TimeSpan OneSecond = TimeSpan.FromSeconds(1); + public static Handler StartStream() => Handlers.SSE.Start(); public static Handler LeaveStreamOpen() => Handlers.SSE.LeaveOpen(); - public static Handler WriteEvent(string s) => Handlers.WriteChunkString(s + "\n\n"); + public static Handler WriteComment(string s) => Handlers.SSE.Comment(s); + + public static Handler WriteEvent(string s) => Handlers.SSE.Event(s); public static Handler WriteEvent(MessageEvent e) { @@ -32,13 +40,57 @@ public static Handler WriteEvent(MessageEvent e) return Handlers.WriteChunkString(s.ToString() + "\n"); } - public static void AssertBackoffsAlwaysIncrease(List backoffs, int desiredCount) + public static string AsSSEData(this MessageEvent e) + { + var sb = new StringBuilder(); + sb.Append("event:").Append(e.Name).Append("\n"); + foreach (var s in e.Data.Split('\n')) + { + sb.Append("data:").Append(s).Append("\n"); + } + if (e.LastEventId != null) + { + sb.Append("id:").Append(e.LastEventId).Append("\n"); + } + sb.Append("\n"); + return sb.ToString(); + } + + // This is defined as a helper extension method for tests only, because the timeout + // behavior is not what we would want in a real application: if it times out, the + // underlying task is still trying to parse an event so the EventSource is no longer + // in a valid state. A real timeout method would require different logic in EventParser, + // because currently EventParser is not able to resume reading a partially-read event. + public static Task ReadAnyEventWithTimeoutAsync(this EventSource es, + TimeSpan timeout) => + AsyncHelpers.DoWithTimeout(timeout, (new CancellationTokenSource()).Token, + token => AsyncHelpers.AllowCancellation(es.ReadAnyEventAsync(), token)); + + public static async Task WithTimeout(TimeSpan timeout, Func> action) + { + try + { + return await AsyncHelpers.DoWithTimeout(timeout, new CancellationToken(), _ => action()); + } + catch (ReadTimeoutException) + { + throw new Exception("timed out"); + } + } + + public static async Task WithTimeout(TimeSpan timeout, Func action) { - Assert.InRange(backoffs.Count, desiredCount, 100); - for (var i = 0; i < desiredCount - 1; i++) + try + { + await AsyncHelpers.DoWithTimeout(timeout, new CancellationToken(), async _ => + { + await action(); + return true; + }); + } + catch (ReadTimeoutException) { - Assert.NotEqual(backoffs[i], backoffs[i + 1]); - Assert.True(backoffs[i + 1] > backoffs[i]); + throw new Exception("timed out"); } } } From 7973917d365de027654164d3ac7c54a9887bfa4d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 17 Jan 2023 22:49:21 -0800 Subject: [PATCH 16/25] misc refactoring/API cleanup/better lock usage --- .../Background/BackgroundEventSource.cs | 13 +- .../ConnectStrategy.cs | 38 ++- src/LaunchDarkly.EventSource/EventSource.cs | 222 ++++++++++-------- .../Exceptions/StreamHttpErrorException.cs | 7 +- .../HttpConnectStrategy.cs | 18 +- .../Internal/ValueWithLock.cs | 43 ---- .../BaseTest.cs | 2 - .../EventSourceConnectStrategyUsageTest.cs | 12 +- .../EventSourceEncodingTest.cs | 10 +- .../EventSourceErrorStrategyUsageTest.cs | 4 +- .../EventSourceReconnectingTest.cs | 33 +-- .../EventSourceStreamReadingTest.cs | 30 +-- .../Exceptions/ExceptionTypesTest.cs | 8 +- .../HttpConnectStrategyTest.cs | 72 +++++- .../HttpConnectStrategyWithEventSource.cs | 8 +- .../MockConnectStrategy.cs | 21 +- .../TestHelpers.cs | 26 +- 17 files changed, 321 insertions(+), 246 deletions(-) delete mode 100644 src/LaunchDarkly.EventSource/Internal/ValueWithLock.cs diff --git a/src/LaunchDarkly.EventSource/Background/BackgroundEventSource.cs b/src/LaunchDarkly.EventSource/Background/BackgroundEventSource.cs index 8d0b728..7678237 100644 --- a/src/LaunchDarkly.EventSource/Background/BackgroundEventSource.cs +++ b/src/LaunchDarkly.EventSource/Background/BackgroundEventSource.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using LaunchDarkly.EventSource.Events; +using LaunchDarkly.EventSource.Exceptions; using LaunchDarkly.Logging; namespace LaunchDarkly.EventSource.Background @@ -151,7 +152,6 @@ public async Task RunAsync() } catch (Exception ex) { - await InvokeErrorHandler(ex); } } @@ -161,22 +161,29 @@ private async Task InvokeHandler(AsyncEventHandler handler, T args) { try { - await handler.Invoke(this, args); + await (handler?.Invoke(this, args) ?? Task.CompletedTask); } catch (Exception ex) { _eventSource.Logger.Error( "BackgroundEventSource caught an exception while calling an event handler: {0}", LogValues.ExceptionSummary(ex)); + _eventSource.Logger.Debug(LogValues.ExceptionTrace(ex)); await InvokeErrorHandler(ex); } } private async Task InvokeErrorHandler(Exception ex) { + if (ex is StreamClosedByCallerException) + { + // This exception isn't very useful in the push event model, and didn't have + // an equivalent in the older EventSource API, so we'll swallow it + return; + } try { - await Error.Invoke(this, new ExceptionEventArgs(ex)); + await (Error?.Invoke(this, new ExceptionEventArgs(ex)) ?? Task.CompletedTask); } catch (Exception anotherEx) { diff --git a/src/LaunchDarkly.EventSource/ConnectStrategy.cs b/src/LaunchDarkly.EventSource/ConnectStrategy.cs index f78605b..804b9c7 100644 --- a/src/LaunchDarkly.EventSource/ConnectStrategy.cs +++ b/src/LaunchDarkly.EventSource/ConnectStrategy.cs @@ -82,12 +82,12 @@ public struct Params /// /// The return type of . /// - public struct Result + public class Result : IDisposable { /// /// The input stream that EventSource should read from. /// - public Stream Stream { get; set; } + public Stream Stream { get; } /// /// If non-null, indicates that EventSource should impose its own @@ -99,17 +99,35 @@ public struct Result /// here tells EventSource to add its own timeout logic using a /// CancellationToken. /// - public TimeSpan? ReadTimeout { get; set; } + public TimeSpan? ReadTimeout { get; } + + private IDisposable _closer; /// - /// An object that EventSource can use to close the connection. + /// Creates an instance. /// - /// - /// If this is not null, its method will be - /// called whenever the current connection is stopped either due to an - /// error or because the caller explicitly closed the stream. - /// - public IDisposable Closer { get; set; } + /// see + /// see + /// if non-null, this object's + /// method will be called whenever the current connection is stopped + public Result( + Stream stream, + TimeSpan? readTimeout = null, + IDisposable closer = null + ) + { + Stream = stream; + ReadTimeout = readTimeout; + _closer = closer; + } + + /// + /// Releases any resources related to this connection. + /// + public void Dispose() + { + _closer?.Dispose(); + } } /// diff --git a/src/LaunchDarkly.EventSource/EventSource.cs b/src/LaunchDarkly.EventSource/EventSource.cs index 2afdbcd..8024cee 100644 --- a/src/LaunchDarkly.EventSource/EventSource.cs +++ b/src/LaunchDarkly.EventSource/EventSource.cs @@ -47,14 +47,14 @@ public class EventSource : IEventSource, IDisposable private readonly Uri _origin; private readonly object _lock = new object(); - private readonly ValueWithLock _readyState; - private readonly ValueWithLock _baseRetryDelay; - private readonly ValueWithLock _nextRetryDelay; - private readonly ValueWithLock _connectedTime; - private readonly ValueWithLock _disconnectedTime; - private readonly ValueWithLock _cancellationToken; + private ReadyState _readyState; + private TimeSpan _baseRetryDelay; + private TimeSpan? _nextRetryDelay; + private DateTime? _connectedTime; + private DateTime? _disconnectedTime; + private CancellationToken? _cancellationToken; private volatile CancellationTokenSource _cancellationTokenSource; - private volatile IDisposable _request; + private volatile IDisposable _requestCloser; private EventParser _parser; private ErrorStrategy _currentErrorStrategy; @@ -67,16 +67,16 @@ public class EventSource : IEventSource, IDisposable #region Public Properties /// - public ReadyState ReadyState => _readyState.Get(); + public ReadyState ReadyState => WithLock(() => _readyState); /// - public TimeSpan BaseRetryDelay => _baseRetryDelay.Get(); + public TimeSpan BaseRetryDelay => WithLock(() => _baseRetryDelay); /// public string LastEventId => _lastEventId; /// - public TimeSpan? NextRetryDelay => _nextRetryDelay.Get(); + public TimeSpan? NextRetryDelay => WithLock(() => _nextRetryDelay); /// public Uri Origin => _origin; @@ -94,25 +94,21 @@ public class EventSource : IEventSource, IDisposable /// the configuration public EventSource(Configuration configuration) { - _readyState = new ValueWithLock(_lock, ReadyState.Raw); _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - _logger = _configuration.Logger; _client = _configuration.ConnectStrategy.CreateClient(_logger); _origin = _configuration.ConnectStrategy.Origin; + _readyState = ReadyState.Raw; _baseErrorStrategy = _currentErrorStrategy = _configuration.ErrorStrategy; _baseRetryDelayStrategy = _currentRetryDelayStrategy = _configuration.RetryDelayStrategy; _retryDelayResetThreshold = _configuration.RetryDelayResetThreshold; - _baseRetryDelay = new ValueWithLock(_lock, _configuration.InitialRetryDelay); - _nextRetryDelay = new ValueWithLock(_lock, null); + _baseRetryDelay = _configuration.InitialRetryDelay; + _nextRetryDelay = null; _lastEventId = _configuration.LastEventId; - - _connectedTime = new ValueWithLock(_lock, null); - _disconnectedTime = new ValueWithLock(_lock, null); - - _cancellationToken = new ValueWithLock(_lock, null); + _connectedTime = _disconnectedTime = null; + _cancellationToken = null; } /// @@ -149,58 +145,76 @@ public async Task ReadMessageAsync() /// public async Task ReadAnyEventAsync() { - try + while (true) { - while (true) + Exception exception = null; + + // Reading an event implies starting the stream if it isn't already started. + // We might also be restarting since we could have been interrupted at any time. + if (_parser is null) { - // Reading an event implies starting the stream if it isn't already started. - // We might also be restarting since we could have been interrupted at any time. - if (_parser is null) + try { var fault = await TryStartAsync(true); return (IEvent)fault ?? (IEvent)(new StartedEvent()); } - var e = await _parser.NextEventAsync(); - if (e is SetRetryDelayEvent srde) + catch (Exception ex) { - // SetRetryDelayEvent means the stream contained a "retry:" line. We don't - // surface this to the caller, we just apply the new delay and move on. - _baseRetryDelay.Set(srde.RetryDelay); - _currentRetryDelayStrategy = _baseRetryDelayStrategy; - continue; + exception = ex; } - if (e is MessageEvent me) + } + if (exception is null) + { + try { - if (me.LastEventId != null) + var e = await _parser.NextEventAsync(); + + if (e is SetRetryDelayEvent srde) { - _lastEventId = me.LastEventId; + // SetRetryDelayEvent means the stream contained a "retry:" line. We don't + // surface this to the caller, we just apply the new delay and move on. + lock (_lock) + { + _baseRetryDelay = srde.RetryDelay; + } + _currentRetryDelayStrategy = _baseRetryDelayStrategy; + continue; } + if (e is MessageEvent me) + { + if (me.LastEventId != null) + { + _lastEventId = me.LastEventId; + } + } + return e; + } + catch (Exception ex) + { + exception = ex; + if (!_deliberatelyClosedConnection) + { + _logger.Debug("Encountered exception: {0}", LogValues.ExceptionSummary(ex)); + } + // fall through to next block } - return e; } - } - catch (Exception ex) - { if (_deliberatelyClosedConnection) { // If the stream was explicitly closed from another thread, that'll likely show up as // an I/O error or an OperationCanceledException, but we don't want to report it as one. - ex = new StreamClosedByCallerException(); + exception = new StreamClosedByCallerException(); _deliberatelyClosedConnection = false; } - else - { - _logger.Debug("Encountered exception: {0}", LogValues.ExceptionSummary(ex)); - } - _disconnectedTime.Set(DateTime.Now); + WithLock(() => _disconnectedTime = DateTime.Now); CloseCurrentStream(); _parser = null; ComputeRetryDelay(); - if (ApplyErrorStrategy(ex) == ErrorStrategy.Action.Continue) + if (ApplyErrorStrategy(exception) == ErrorStrategy.Action.Continue) { - return new FaultEvent(ex); + return new FaultEvent(exception); } - throw ex; + throw exception; } } @@ -211,9 +225,13 @@ public void Interrupt() => /// public void Close() { - if (_readyState.GetAndSet(ReadyState.Shutdown) == ReadyState.Shutdown) + lock (_lock) { - return; + if (_readyState == ReadyState.Shutdown) + { + return; + } + _readyState = ReadyState.Shutdown; } CloseCurrentStream(); _client?.Dispose(); @@ -237,57 +255,63 @@ private void Dispose(bool disposing) } } + private T WithLock(Func func) + { + lock (_lock) { return func(); } + } + private async Task TryStartAsync(bool canReturnFaultEvent) { if (_parser != null) { return null; } - if (ReadyState == ReadyState.Shutdown) - { - throw new StreamClosedByCallerException(); - } while (true) { StreamException exception = null; - - TimeSpan nextDelay = _nextRetryDelay.Get() ?? TimeSpan.Zero; - if (nextDelay > TimeSpan.Zero) + TimeSpan delayNow = TimeSpan.Zero; + lock (_lock) { - var disconnectedTime = _disconnectedTime.Get(); - TimeSpan delayNow = disconnectedTime.HasValue ? - (nextDelay - (DateTime.Now - disconnectedTime.Value)) : - nextDelay; - if (delayNow > TimeSpan.Zero) + if (_readyState == ReadyState.Shutdown) { - _logger.Info("Waiting {0} milliseconds before reconnecting", delayNow.TotalMilliseconds); - await Task.Delay(delayNow); + throw new StreamClosedByCallerException(); } - } + _readyState = ReadyState.Connecting; - _readyState.Set(ReadyState.Connecting); + var nextDelay = _nextRetryDelay ?? TimeSpan.Zero; + if (nextDelay > TimeSpan.Zero) + { + delayNow = _disconnectedTime.HasValue ? + (nextDelay - (DateTime.Now - _disconnectedTime.Value)) : + nextDelay; + } + } + if (delayNow > TimeSpan.Zero) + { + _logger.Info("Waiting {0} milliseconds before reconnecting", delayNow.TotalMilliseconds); + await Task.Delay(delayNow); + } - var connectResult = new ConnectStrategy.Client.Result(); + ConnectStrategy.Client.Result connectResult = null; CancellationToken newCancellationToken; if (exception is null) { - _connectedTime.Set(null); - _deliberatelyClosedConnection = false; - CancellationTokenSource newRequestTokenSource = new CancellationTokenSource(); lock (_lock) { - if (_readyState.Get() == ReadyState.Shutdown) + if (_readyState == ReadyState.Shutdown) { - // in case Close() was called in between the previous ReadyState check and the creation of the new token + // in case Close() was called since the last time we checked return null; } + + _connectedTime = null; + _deliberatelyClosedConnection = false; + _cancellationTokenSource?.Dispose(); _cancellationTokenSource = newRequestTokenSource; - _cancellationToken.Set(newRequestTokenSource.Token); - newCancellationToken = newRequestTokenSource.Token; + _cancellationToken = newRequestTokenSource.Token; } - try { connectResult = await _client.ConnectAsync( @@ -305,10 +329,17 @@ private async Task TryStartAsync(bool canReturnFaultEvent) if (exception != null) { - _readyState.Set(ReadyState.Closed); + lock (_lock) + { + if (_readyState == ReadyState.Shutdown) + { + return null; + } + _readyState = ReadyState.Closed; + _disconnectedTime = DateTime.Now; + ComputeRetryDelay(); + } _logger.Debug("Encountered exception: {0}", LogValues.ExceptionSummary(exception)); - _disconnectedTime.Set(DateTime.Now); - ComputeRetryDelay(); if (ApplyErrorStrategy(exception) == ErrorStrategy.Action.Continue) { // The ErrorStrategy told us to CONTINUE rather than throwing an exception. @@ -327,9 +358,9 @@ private async Task TryStartAsync(bool canReturnFaultEvent) lock (_lock) { - _connectedTime.Set(DateTime.Now); - _readyState.Set(ReadyState.Open); - _request = connectResult.Closer; + _connectedTime = DateTime.Now; + _readyState = ReadyState.Open; + _requestCloser = connectResult; } _logger.Debug("Connected to SSE stream"); @@ -355,44 +386,47 @@ private ErrorStrategy.Action ApplyErrorStrategy(Exception exception) private void ComputeRetryDelay() { - var connectedTime = _connectedTime.Get(); - if (_retryDelayResetThreshold > TimeSpan.Zero && connectedTime.HasValue) + lock (_lock) { - TimeSpan connectionDuration = DateTime.Now.Subtract(connectedTime.Value); - if (connectionDuration >= _retryDelayResetThreshold) + if (_retryDelayResetThreshold > TimeSpan.Zero && _connectedTime.HasValue) { - _currentRetryDelayStrategy = _baseRetryDelayStrategy; + TimeSpan connectionDuration = DateTime.Now.Subtract(_connectedTime.Value); + if (connectionDuration >= _retryDelayResetThreshold) + { + _currentRetryDelayStrategy = _baseRetryDelayStrategy; + } + var result = _currentRetryDelayStrategy.Apply(_baseRetryDelay); + _nextRetryDelay = result.Delay; + _currentRetryDelayStrategy = result.Next ?? _currentRetryDelayStrategy; } } - var result = _currentRetryDelayStrategy.Apply(_baseRetryDelay.Get()); - _nextRetryDelay.Set(result.Delay); - _currentRetryDelayStrategy = result.Next ?? _currentRetryDelayStrategy; } private void CloseCurrentStream() { CancellationTokenSource oldTokenSource; - IDisposable oldRequest; + IDisposable oldRequestCloser; lock (_lock) { if (_cancellationTokenSource is null) { return; } + _disconnectedTime = DateTime.Now; oldTokenSource = _cancellationTokenSource; - oldRequest = _request; + oldRequestCloser = _requestCloser; _cancellationTokenSource = null; - _request = null; + _requestCloser = null; _deliberatelyClosedConnection = true; - if (_readyState.Get() != ReadyState.Shutdown) + if (_readyState != ReadyState.Shutdown) { - _readyState.Set(ReadyState.Closed); + _readyState = ReadyState.Closed; } } _logger.Debug("Cancelling current request"); oldTokenSource.Cancel(); oldTokenSource.Dispose(); - oldRequest?.Dispose(); + oldRequestCloser?.Dispose(); } #endregion diff --git a/src/LaunchDarkly.EventSource/Exceptions/StreamHttpErrorException.cs b/src/LaunchDarkly.EventSource/Exceptions/StreamHttpErrorException.cs index 1bafa5e..6799b47 100644 --- a/src/LaunchDarkly.EventSource/Exceptions/StreamHttpErrorException.cs +++ b/src/LaunchDarkly.EventSource/Exceptions/StreamHttpErrorException.cs @@ -1,4 +1,5 @@ using System; +using System.Net; namespace LaunchDarkly.EventSource.Exceptions { @@ -13,14 +14,14 @@ public class StreamHttpErrorException : StreamException /// /// The HTTP status code. /// - public int Status { get; } + public HttpStatusCode Status { get; } /// /// Creates an instance. /// /// the HTTP status code - public StreamHttpErrorException(int status) : - base(string.Format(Resources.ErrorHttpStatus, status)) + public StreamHttpErrorException(HttpStatusCode status) : + base(string.Format(Resources.ErrorHttpStatus, (int)status)) { Status = status; } diff --git a/src/LaunchDarkly.EventSource/HttpConnectStrategy.cs b/src/LaunchDarkly.EventSource/HttpConnectStrategy.cs index 44c2ce8..1ae0ec4 100644 --- a/src/LaunchDarkly.EventSource/HttpConnectStrategy.cs +++ b/src/LaunchDarkly.EventSource/HttpConnectStrategy.cs @@ -41,6 +41,11 @@ namespace LaunchDarkly.EventSource /// .ReadTimeout(TimeSpan.FromMinutes(1)) /// ); /// + /// + /// Following of HTTP redirects is controlled by the standard behavior of the .NET + /// HttpClient, which has a default limit on the number of consecutive redirects; + /// to change this, you can set a custom . + /// /// public sealed class HttpConnectStrategy : ConnectStrategy { @@ -416,12 +421,11 @@ public override async Task ConnectAsync(Params p) response.Dispose(); } } - return new Result - { - Stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false), - ReadTimeout = _config._readTimeout, - Closer = response - }; + return new Result( + await response.Content.ReadAsStreamAsync().ConfigureAwait(false), + _config._readTimeout, + response + ); } private HttpRequestMessage CreateRequest(Params p) @@ -453,7 +457,7 @@ private void ValidateResponse(HttpResponseMessage response) // Any non-2xx response status is an error. A 204 (no content) is also an error. if (!response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.NoContent) { - throw new StreamHttpErrorException((int)response.StatusCode); + throw new StreamHttpErrorException(response.StatusCode); } if (response.Content is null) diff --git a/src/LaunchDarkly.EventSource/Internal/ValueWithLock.cs b/src/LaunchDarkly.EventSource/Internal/ValueWithLock.cs deleted file mode 100644 index 898c965..0000000 --- a/src/LaunchDarkly.EventSource/Internal/ValueWithLock.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; - -namespace LaunchDarkly.EventSource.Internal -{ - /// - /// Simple concurrency helper for a property that should always be read or - /// written under lock. In some cases we can simply use a volatile field - /// instead, but volatile can't be used for some types. - /// - /// the value type - internal sealed class ValueWithLock - { - private readonly object _lockObject; - private T _value; - - public ValueWithLock(object lockObject, T initialValue) - { - _lockObject = lockObject; - _value = initialValue; - } - - public T Get() - { - lock (_lockObject) { return _value; } - } - - public void Set(T value) - { - lock (_lockObject) { _value = value; } - } - - public T GetAndSet(T newValue) - { - lock (_lockObject) - { - var oldValue = _value; - _value = newValue; - return oldValue; - } - } - } -} - diff --git a/test/LaunchDarkly.EventSource.Tests/BaseTest.cs b/test/LaunchDarkly.EventSource.Tests/BaseTest.cs index 21b1c88..6bc1376 100644 --- a/test/LaunchDarkly.EventSource.Tests/BaseTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/BaseTest.cs @@ -18,8 +18,6 @@ public abstract class BaseTest { public static readonly Uri _uri = new Uri("http://test-uri"); - public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(1); - /// /// Tests can use this object wherever an is needed, to /// direct log output to both 1. the Xunit test output buffer and 2. . diff --git a/test/LaunchDarkly.EventSource.Tests/EventSourceConnectStrategyUsageTest.cs b/test/LaunchDarkly.EventSource.Tests/EventSourceConnectStrategyUsageTest.cs index 477d52a..89eb7a0 100644 --- a/test/LaunchDarkly.EventSource.Tests/EventSourceConnectStrategyUsageTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/EventSourceConnectStrategyUsageTest.cs @@ -86,10 +86,7 @@ public async void ConnectIsCalledOnStart() new ClientFromLambda(_ => { createdStream = MakeEmptyStream(); - return Task.FromResult(new ConnectStrategy.Client.Result - { - Stream = createdStream - }); + return Task.FromResult(new ConnectStrategy.Client.Result(createdStream)); })); using (var es = new EventSource( @@ -108,11 +105,8 @@ public async void ConnectionCloserIsCalledOnClose() var strategy = new ConnectStrategyFromLambda(logger => new ClientFromLambda(_ => { - return Task.FromResult(new ConnectStrategy.Client.Result - { - Stream = MakeEmptyStream(), - Closer = closer - }); + return Task.FromResult(new ConnectStrategy.Client.Result( + MakeEmptyStream(), null, closer)); })); using (var es = new EventSource( diff --git a/test/LaunchDarkly.EventSource.Tests/EventSourceEncodingTest.cs b/test/LaunchDarkly.EventSource.Tests/EventSourceEncodingTest.cs index 165d498..43a5719 100644 --- a/test/LaunchDarkly.EventSource.Tests/EventSourceEncodingTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/EventSourceEncodingTest.cs @@ -5,6 +5,8 @@ using Xunit; using Xunit.Abstractions; +using static LaunchDarkly.EventSource.TestHelpers; + namespace LaunchDarkly.EventSource { public class EventSourceEncodingTest : BaseTest @@ -25,9 +27,9 @@ public EventSourceEncodingTest(ITestOutputHelper testOutput) : base(testOutput) private static async Task ExpectEvents(EventSource es, Uri uri) { - Assert.Equal(new CommentEvent("hello"), await es.ReadAnyEventAsync()); - Assert.Equal(new MessageEvent("message", "value1", null, uri), await es.ReadAnyEventAsync()); - Assert.Equal(new MessageEvent("event2", "ça\nqué", null, uri), await es.ReadAnyEventAsync()); + Assert.Equal(new CommentEvent("hello"), await es.ReadAnyEventAsync().WithTimeout()); + Assert.Equal(new MessageEvent("message", "value1", null, uri), await es.ReadAnyEventAsync().WithTimeout()); + Assert.Equal(new MessageEvent("event2", "ça\nqué", null, uri), await es.ReadAnyEventAsync().WithTimeout()); Assert.Equal(new MessageEvent("message", MakeLongString(0, 500) + MakeLongString(500, 1000), null, uri), await es.ReadAnyEventAsync()); } @@ -53,7 +55,7 @@ await WithMockConnectEventSource( ), async (mock, es) => { - await es.StartAsync(); + await es.StartAsync().WithTimeout(); await ExpectEvents(es, MockConnectStrategy.MockOrigin); }); } diff --git a/test/LaunchDarkly.EventSource.Tests/EventSourceErrorStrategyUsageTest.cs b/test/LaunchDarkly.EventSource.Tests/EventSourceErrorStrategyUsageTest.cs index 0adbb8a..e34be7b 100644 --- a/test/LaunchDarkly.EventSource.Tests/EventSourceErrorStrategyUsageTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/EventSourceErrorStrategyUsageTest.cs @@ -1,4 +1,5 @@ using System; +using System.Net; using System.Threading.Tasks; using LaunchDarkly.EventSource.Exceptions; using LaunchDarkly.EventSource.Events; @@ -11,7 +12,8 @@ namespace LaunchDarkly.EventSource { public class EventSourceErrorStrategyUsageTest : BaseTest { - private static readonly Exception FakeHttpError = new StreamHttpErrorException(503); + private static readonly Exception FakeHttpError = new StreamHttpErrorException( + HttpStatusCode.InternalServerError); public EventSourceErrorStrategyUsageTest(ITestOutputHelper testOutput) : base(testOutput) { } diff --git a/test/LaunchDarkly.EventSource.Tests/EventSourceReconnectingTest.cs b/test/LaunchDarkly.EventSource.Tests/EventSourceReconnectingTest.cs index be1765d..9c1a7c8 100644 --- a/test/LaunchDarkly.EventSource.Tests/EventSourceReconnectingTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/EventSourceReconnectingTest.cs @@ -32,18 +32,19 @@ await WithMockConnectEventSource( .ErrorStrategy(ErrorStrategy.AlwaysContinue), async (mock, es) => { - Assert.Equal(new StartedEvent(), await es.ReadAnyEventWithTimeoutAsync(DefaultTimeout)); + await es.StartAsync().WithTimeout(); Assert.Equal(new MessageEvent("put", "hello", eventId1, mock.Origin), - await es.ReadAnyEventWithTimeoutAsync(DefaultTimeout)); + await es.ReadAnyEventAsync().WithTimeout()); var p1 = mock.ReceivedConnections.Take(); Assert.Equal(initialEventId, p1.LastEventId); Assert.Equal(new FaultEvent(new StreamClosedByServerException()), - await es.ReadAnyEventWithTimeoutAsync(DefaultTimeout)); + await es.ReadAnyEventAsync().WithTimeout()); - Assert.Equal(new StartedEvent(), await es.ReadAnyEventWithTimeoutAsync(DefaultTimeout)); + Assert.Equal(new StartedEvent(), + await es.ReadAnyEventAsync().WithTimeout()); var p2 = mock.ReceivedConnections.Take(); Assert.Equal(eventId1, p2.LastEventId); @@ -72,20 +73,22 @@ await WithMockConnectEventSource( .RetryDelayStrategy(new ArithmeticallyIncreasingDelayStrategy(increment, TimeSpan.Zero)), async (mock, es) => { - await es.StartAsync(); + await es.StartAsync().WithTimeout(); - for (int i = 0; i < nAttempts; i++) - { - Assert.Equal(new CommentEvent("hi"), await es.ReadAnyEventWithTimeoutAsync(DefaultTimeout)); - Assert.Equal(new FaultEvent(new StreamClosedByServerException()), - await es.ReadAnyEventWithTimeoutAsync(DefaultTimeout)); + for (int i = 0; i < nAttempts; i++) + { + Assert.Equal(new CommentEvent("hi"), + await es.ReadAnyEventAsync().WithTimeout()); + Assert.Equal(new FaultEvent(new StreamClosedByServerException()), + await es.ReadAnyEventAsync().WithTimeout()); - Assert.Equal(expectedDelay, es.NextRetryDelay); - expectedDelay += increment; + Assert.Equal(expectedDelay, es.NextRetryDelay); + expectedDelay += increment; - Assert.Equal(new StartedEvent(), await es.ReadAnyEventWithTimeoutAsync(DefaultTimeout)); - } - }); + Assert.Equal(new StartedEvent(), + await es.ReadAnyEventAsync().WithTimeout()); + } + }); } public class ArithmeticallyIncreasingDelayStrategy : RetryDelayStrategy diff --git a/test/LaunchDarkly.EventSource.Tests/EventSourceStreamReadingTest.cs b/test/LaunchDarkly.EventSource.Tests/EventSourceStreamReadingTest.cs index 1eb5853..3ed6a49 100644 --- a/test/LaunchDarkly.EventSource.Tests/EventSourceStreamReadingTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/EventSourceStreamReadingTest.cs @@ -24,8 +24,8 @@ await WithMockConnectEventSource( ), async (mock, es) => { - await es.StartAsync(); - var e = await es.ReadAnyEventAsync(); + await es.StartAsync().WithTimeout(); + var e = await es.ReadAnyEventAsync().WithTimeout(); Assert.Equal(new CommentEvent("hello"), e); }); } @@ -42,8 +42,8 @@ await WithMockConnectEventSource( ), async (mock, es) => { - await es.StartAsync(); - var e = await es.ReadAnyEventAsync(); + await es.StartAsync().WithTimeout(); + var e = await es.ReadAnyEventAsync().WithTimeout(); var m = Assert.IsType(e); Assert.Equal(MessageEvent.DefaultName, m.Name); Assert.Equal(eventData, m.Data); @@ -65,8 +65,8 @@ await WithMockConnectEventSource( ), async (mock, es) => { - await es.StartAsync(); - var e = await WithTimeout(OneSecond, es.ReadAnyEventAsync); + await es.StartAsync().WithTimeout(); + var e = await es.ReadAnyEventAsync().WithTimeout(); var m = Assert.IsType(e); Assert.Equal(eventName, m.Name); Assert.Equal(eventData, m.Data); @@ -89,8 +89,8 @@ await WithMockConnectEventSource( ), async (mock, es) => { - await es.StartAsync(); - var e = await WithTimeout(OneSecond, es.ReadAnyEventAsync); + await es.StartAsync().WithTimeout(); + var e = await es.ReadAnyEventAsync().WithTimeout(); var m = Assert.IsType(e); Assert.Equal(eventName, m.Name); Assert.Equal(eventData, m.Data); @@ -131,10 +131,10 @@ await WithMockConnectEventSource( ), async (mock, es) => { - await es.StartAsync(); + await es.StartAsync().WithTimeout(); foreach (var data in eventData) { - var e = await es.ReadAnyEventAsync(); + var e = await es.ReadAnyEventAsync().WithTimeout(); var m = Assert.IsType(e); Assert.Equal(MessageEvent.DefaultName, m.Name); Assert.Equal(data, m.Data); @@ -164,18 +164,18 @@ await WithMockConnectEventSource( c => c.InitialRetryDelay(initialDelay).ErrorStrategy(ErrorStrategy.AlwaysContinue), async (mock, es) => { - await es.StartAsync(); + await es.StartAsync().WithTimeout(); - Assert.Equal(anEvent, await es.ReadAnyEventAsync()); + Assert.Equal(anEvent, await es.ReadAnyEventAsync().WithTimeout()); for (var i = 0; i < nAttempts; i++) { es.Interrupt(); Assert.Equal(new FaultEvent(new StreamClosedByCallerException()), - await es.ReadAnyEventAsync()); - Assert.Equal(new StartedEvent(), await es.ReadAnyEventAsync()); - Assert.Equal(anEvent, await es.ReadAnyEventAsync()); + await es.ReadAnyEventAsync().WithTimeout()); + Assert.Equal(new StartedEvent(), await es.ReadAnyEventAsync().WithTimeout()); + Assert.Equal(anEvent, await es.ReadAnyEventAsync().WithTimeout()); } }); } diff --git a/test/LaunchDarkly.EventSource.Tests/Exceptions/ExceptionTypesTest.cs b/test/LaunchDarkly.EventSource.Tests/Exceptions/ExceptionTypesTest.cs index 1f3f10e..bd4a0d6 100644 --- a/test/LaunchDarkly.EventSource.Tests/Exceptions/ExceptionTypesTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/Exceptions/ExceptionTypesTest.cs @@ -1,4 +1,5 @@ using System; +using System.Net; using System.Net.Http.Headers; using System.Text; using LaunchDarkly.EventSource.Exceptions; @@ -44,11 +45,12 @@ public void TestStreamContentException() [Fact] public void TestStreamHttpErrorException() { - Assert.Equal(400, new StreamHttpErrorException(400).Status); + Assert.Equal(HttpStatusCode.BadRequest, + new StreamHttpErrorException(HttpStatusCode.BadRequest).Status); TypeBehavior.CheckEqualsAndHashCode( - () => new StreamHttpErrorException(400), - () => new StreamHttpErrorException(500) + () => new StreamHttpErrorException(HttpStatusCode.BadRequest), + () => new StreamHttpErrorException(HttpStatusCode.InternalServerError) ); } } diff --git a/test/LaunchDarkly.EventSource.Tests/HttpConnectStrategyTest.cs b/test/LaunchDarkly.EventSource.Tests/HttpConnectStrategyTest.cs index cb2b8e7..e974ab9 100644 --- a/test/LaunchDarkly.EventSource.Tests/HttpConnectStrategyTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/HttpConnectStrategyTest.cs @@ -121,8 +121,7 @@ public async Task CanReadFromChunkedResponseStream() { using (var client = baseStrategy.Uri(server.Uri).CreateClient(_testLogger)) { - var result = await client.ConnectAsync(new ConnectStrategy.Client.Params()); - try + using (var result = await client.ConnectAsync(new ConnectStrategy.Client.Params())) { var stream = result.Stream; var b = new byte[100]; @@ -130,10 +129,6 @@ public async Task CanReadFromChunkedResponseStream() Assert.Equal(5, await stream.ReadAsync(b, 6, 5)); Assert.Equal("hello world", Encoding.UTF8.GetString(b, 0, 11)); } - finally - { - result.Closer.Dispose(); - } } } } @@ -153,7 +148,7 @@ public async Task RequestFailsWithHttpError(HttpStatusCode status) { var ex = await Assert.ThrowsAnyAsync( async () => await client.ConnectAsync(new ConnectStrategy.Client.Params())); - Assert.Equal((int)status, ex.Status); + Assert.Equal(status, ex.Status); } } } @@ -190,6 +185,67 @@ public async Task RequestFailsWithIncorrectContentEncoding() } } + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] + [InlineData(HttpStatusCode.TemporaryRedirect)] + public async Task FollowsRedirects(HttpStatusCode status) + { + var responses = Handlers.Sequential( + Handlers.Status((int)status).Then(Handlers.Header("Location", "/b")), + Handlers.Status((int)status).Then(Handlers.Header("Location", "/c")), + Handlers.SSE.Start() + ); + using (var server = HttpServer.Start(responses)) + { + using (var client = baseStrategy.Uri(server.Uri).CreateClient(_testLogger)) + { + using (var result = await client.ConnectAsync(new ConnectStrategy.Client.Params())) + { + var req1 = server.Recorder.RequireRequest(); + Assert.Equal("/", req1.Path); + + var req2 = server.Recorder.RequireRequest(); + Assert.Equal("/b", req2.Path); + + var req3 = server.Recorder.RequireRequest(); + Assert.Equal("/c", req3.Path); + } + } + } + } + + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] + [InlineData(HttpStatusCode.TemporaryRedirect)] + public async Task StopsFollowingRedirectsAtLimit(HttpStatusCode status) + { + var responses = Handlers.Sequential( + Handlers.Status((int)status).Then(Handlers.Header("Location", "/b")), + Handlers.Status((int)status).Then(Handlers.Header("Location", "/c")), + Handlers.SSE.Start() + ); + using (var server = HttpServer.Start(responses)) + { + var customHttpHandler = new HttpClientHandler + { + MaxAutomaticRedirections = 1 + }; + using (var client = baseStrategy.Uri(server.Uri) + .HttpMessageHandler(customHttpHandler) + .CreateClient(_testLogger)) + { + var ex = await Assert.ThrowsAnyAsync( + () => client.ConnectAsync(new ConnectStrategy.Client.Params())); + + var req1 = server.Recorder.RequireRequest(); + Assert.Equal("/", req1.Path); + + var req2 = server.Recorder.RequireRequest(); + Assert.Equal("/b", req2.Path); + } + } + } + private HttpClient MakeClientFrom(HttpConnectStrategy hcs) => ((HttpConnectStrategy.ClientImpl)hcs.CreateClient(_testLogger)).HttpClient; @@ -202,7 +258,7 @@ private async Task DoRequestFrom(HttpConnectStrategy hcs, string la { var p = new ConnectStrategy.Client.Params { LastEventId = lastEventId }; var result = await client.ConnectAsync(p); - result.Closer.Dispose(); + result.Dispose(); return server.Recorder.RequireRequest(); } } diff --git a/test/LaunchDarkly.EventSource.Tests/HttpConnectStrategyWithEventSource.cs b/test/LaunchDarkly.EventSource.Tests/HttpConnectStrategyWithEventSource.cs index 27cfae5..fe7261b 100644 --- a/test/LaunchDarkly.EventSource.Tests/HttpConnectStrategyWithEventSource.cs +++ b/test/LaunchDarkly.EventSource.Tests/HttpConnectStrategyWithEventSource.cs @@ -42,7 +42,7 @@ public async Task LastEventIdHeaderIsNotSetByDefault() await WithServerAndEventSource(StreamWithCommentThatStaysOpen, async (server, es) => { - await es.StartAsync(); + await es.StartAsync().WithTimeout(); var req = server.Recorder.RequireRequest(); Assert.Null(req.Headers.Get(Constants.LastEventIdHttpHeader)); }); @@ -58,7 +58,7 @@ await WithServerAndEventSource(StreamWithCommentThatStaysOpen, config => config.LastEventId(lastEventId), async (server, es) => { - await es.StartAsync(); + await es.StartAsync().WithTimeout(); var req = server.Recorder.RequireRequest(); Assert.Equal(lastEventId, req.Headers.Get(Constants.LastEventIdHttpHeader)); @@ -78,9 +78,9 @@ await WithServerAndEventSource(streamHandler, null, async (server, es) => { - await es.StartAsync(); + await es.StartAsync().WithTimeout(); Assert.Equal(new MessageEvent(MessageEvent.DefaultName, "event1", server.Uri), - await es.ReadMessageAsync()); + await es.ReadMessageAsync().WithTimeout()); await Assert.ThrowsAnyAsync(() => es.ReadMessageAsync()); }); } diff --git a/test/LaunchDarkly.EventSource.Tests/MockConnectStrategy.cs b/test/LaunchDarkly.EventSource.Tests/MockConnectStrategy.cs index 0042ea5..c023115 100644 --- a/test/LaunchDarkly.EventSource.Tests/MockConnectStrategy.cs +++ b/test/LaunchDarkly.EventSource.Tests/MockConnectStrategy.cs @@ -80,12 +80,8 @@ public class StreamRequestHandler : RequestHandler public StreamRequestHandler(Stream stream) { _stream = stream; } - public override Task ConnectAsync(Client.Params p) => - Task.FromResult(new Client.Result - { - Stream = _stream, - Closer = this - }); + public override Task ConnectAsync(Client.Params p) => + Task.FromResult(new Client.Result(_stream, null, this)); } public class PipedStreamRequestHandler : RequestHandler, IDisposable @@ -113,19 +109,20 @@ public PipedStreamRequestHandler() var chunk = _chunks.Take(p.CancellationToken); if (_closed) { - return; + break; } _writeStream.Write(chunk, 0, chunk.Length); } + catch (OperationCanceledException) + { + break; + } catch (Exception) { } } + _writeStream.Close(); }); thread.Start(); - return Task.FromResult(new Client.Result - { - Stream = _readStream, - Closer = this - }); + return Task.FromResult(new Client.Result(_readStream, null, this)); } public void ProvideData(params string[] chunks) diff --git a/test/LaunchDarkly.EventSource.Tests/TestHelpers.cs b/test/LaunchDarkly.EventSource.Tests/TestHelpers.cs index 23e8a12..4ded920 100644 --- a/test/LaunchDarkly.EventSource.Tests/TestHelpers.cs +++ b/test/LaunchDarkly.EventSource.Tests/TestHelpers.cs @@ -66,21 +66,17 @@ public static string AsSSEData(this MessageEvent e) return sb.ToString(); } - // This is defined as a helper extension method for tests only, because the timeout - // behavior is not what we would want in a real application: if it times out, the - // underlying task is still trying to parse an event so the EventSource is no longer - // in a valid state. A real timeout method would require different logic in EventParser, - // because currently EventParser is not able to resume reading a partially-read event. - public static Task ReadAnyEventWithTimeoutAsync(this EventSource es, - TimeSpan timeout) => - AsyncHelpers.DoWithTimeout(timeout, (new CancellationTokenSource()).Token, - token => AsyncHelpers.AllowCancellation(es.ReadAnyEventAsync(), token)); + // The WithTimeout methods are used in tests only, because the timeout behavior is + // is not what we would want in a real application: if it times out, the underlying + // task is still trying to parse an event so the EventSource is no longer in a valid + // state. A real timeout method would require different logic in EventParser, because + // currently EventParser is not able to resume reading a partially-read event. - public static async Task WithTimeout(TimeSpan timeout, Func> action) + public static async Task WithTimeout(this Task task, TimeSpan timeout) { try { - return await AsyncHelpers.DoWithTimeout(timeout, new CancellationToken(), _ => action()); + return await AsyncHelpers.DoWithTimeout(timeout, new CancellationToken(), _ => task); } catch (ReadTimeoutException) { @@ -88,13 +84,13 @@ public static async Task WithTimeout(TimeSpan timeout, Func> actio } } - public static async Task WithTimeout(TimeSpan timeout, Func action) + public static async Task WithTimeout(this Task task, TimeSpan timeout) { try { await AsyncHelpers.DoWithTimeout(timeout, new CancellationToken(), async _ => { - await action(); + await task; return true; }); } @@ -103,5 +99,9 @@ public static async Task WithTimeout(TimeSpan timeout, Func action) throw new Exception("timed out"); } } + + public static Task WithTimeout(this Task task) => task.WithTimeout(OneSecond); + + public static Task WithTimeout(this Task task) => task.WithTimeout(OneSecond); } } From 9b79c150d5d2f1a1699e444a6390097966430e91 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 17 Jan 2023 22:49:46 -0800 Subject: [PATCH 17/25] add contract tests --- .circleci/config.yml | 8 +- CONTRIBUTING.md | 5 + Makefile | 30 +++++ contract-tests/README.md | 5 + contract-tests/Representations.cs | 43 ++++++++ contract-tests/StreamEntity.cs | 178 ++++++++++++++++++++++++++++++ contract-tests/TestService.cs | 125 +++++++++++++++++++++ contract-tests/TestService.csproj | 37 +++++++ contract-tests/TestService.sln | 31 ++++++ contract-tests/run.sh | 14 +++ 10 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 Makefile create mode 100644 contract-tests/README.md create mode 100644 contract-tests/Representations.cs create mode 100644 contract-tests/StreamEntity.cs create mode 100644 contract-tests/TestService.cs create mode 100644 contract-tests/TestService.csproj create mode 100644 contract-tests/TestService.sln create mode 100755 contract-tests/run.sh diff --git a/.circleci/config.yml b/.circleci/config.yml index 46df88c..453d1e8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -34,7 +34,7 @@ jobs: steps: - run: name: install packages - command: apt-get -q update && apt-get install -qy awscli + command: apt-get -q update && apt-get install -qy make - checkout - run: name: build @@ -48,6 +48,12 @@ jobs: - store_test_results: path: /tmp/circle-reports + - run: make build-contract-tests + - run: + command: make start-contract-test-service + background: true + - run: make run-contract-tests + test_windows: parameters: test-target-framework: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c8adc3..39440b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,4 +50,9 @@ Or, to run tests only for the .NET Standard 2.0 target (using the .NET Core 3.1 dotnet test test/LaunchDarkly.EventSource.Tests -f netcoreapp3.1 ``` +To run the standardized contract tests that are run against all LaunchDarkly SSE client implementations (this is currently not supported on Windows): +``` +make contract-tests +``` + Note that the unit tests can only be run in Debug configuration. There is an `InternalsVisibleTo` directive that allows the test code to access internal members of the library, and assembly strong-naming in the Release configuration interferes with this. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..30e7699 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ + +build: + dotnet build + +test: + dotnet test + +clean: + dotnet clean + +TEMP_TEST_OUTPUT=/tmp/sse-contract-test-service.log +TESTFRAMEWORK ?= netcoreapp3.1 + +build-contract-tests: + @cd contract-tests && BUILDFRAMEWORK=netstandard2.0 dotnet build TestService.csproj + +start-contract-test-service: + @cd contract-tests && dotnet bin/Debug/${TESTFRAMEWORK}/ContractTestService.dll + +start-contract-test-service-bg: + @echo "Test service output will be captured in $(TEMP_TEST_OUTPUT)" + @make start-contract-test-service >$(TEMP_TEST_OUTPUT) 2>&1 & + +run-contract-tests: + @curl -s https://raw.githubusercontent.com/launchdarkly/sse-contract-tests/v1.0.0/downloader/run.sh \ + | VERSION=v1 PARAMS="-url http://localhost:8000 -debug -stop-service-at-end" sh + +contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests + +.PHONY: build test clean build-contract-tests start-contract-test-service run-contract-tests contract-tests diff --git a/contract-tests/README.md b/contract-tests/README.md new file mode 100644 index 0000000..37cc97c --- /dev/null +++ b/contract-tests/README.md @@ -0,0 +1,5 @@ +# SSE client contract test service + +This directory contains an implementation of the cross-platform SSE testing protocol defined by https://github.com/launchdarkly/sse-contract-tests. See that project's `README` for details of this protocol, and the kinds of SSE client capabilities that are relevant to the contract tests. This code should not need to be updated unless the SSE client has added or removed such capabilities. + +To run these tests locally, run `make contract-tests` from the project root directory. This downloads the correct version of the test harness tool automatically. diff --git a/contract-tests/Representations.cs b/contract-tests/Representations.cs new file mode 100644 index 0000000..d4d50c0 --- /dev/null +++ b/contract-tests/Representations.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace TestService +{ + public class Status + { + [JsonPropertyName("capabilities")] public string[] Capabilities { get; set; } + } + + public class StreamOptions + { + [JsonPropertyName("streamUrl")] public string StreamUrl { get; set; } + [JsonPropertyName("callbackUrl")] public string CallbackUrl { get; set; } + [JsonPropertyName("tag")] public string Tag { get; set; } + [JsonPropertyName("headers")] public Dictionary Headers { get; set; } + [JsonPropertyName("initialDelayMs")] public int? InitialDelayMs { get; set; } + [JsonPropertyName("readTimeoutMs")] public int? ReadTimeoutMs { get; set; } + [JsonPropertyName("lastEventId")] public string LastEventId { get; set; } + [JsonPropertyName("method")] public string Method { get; set; } + [JsonPropertyName("body")] public string Body { get; set; } + } + + public class Message + { + [JsonPropertyName("kind")] public string Kind { get; set; } + [JsonPropertyName("event")] public EventMessage Event { get; set; } + [JsonPropertyName("comment")] public string Comment { get; set; } + [JsonPropertyName("error")] public string Error { get; set; } + } + + public class EventMessage + { + [JsonPropertyName("type")] public string Type { get; set; } + [JsonPropertyName("data")] public string Data { get; set; } + [JsonPropertyName("id")] public string Id { get; set; } + } + + public class CommandParams + { + [JsonPropertyName("command")] public string Command { get; set; } + } +} diff --git a/contract-tests/StreamEntity.cs b/contract-tests/StreamEntity.cs new file mode 100644 index 0000000..09fd2b7 --- /dev/null +++ b/contract-tests/StreamEntity.cs @@ -0,0 +1,178 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.EventSource; +using LaunchDarkly.EventSource.Background; +using LaunchDarkly.Logging; + +namespace TestService +{ + public class StreamEntity + { + private static HttpClient _httpClient = new HttpClient(); + + private readonly EventSource _stream; + private readonly StreamOptions _options; + private readonly Logger _log; + private volatile int _callbackMessageCounter; + private volatile bool _closed; + + public StreamEntity( + StreamOptions options, + Logger log + ) + { + _options = options; + _log = log; + + var httpConfig = ConnectStrategy.Http(new Uri(options.StreamUrl)); + if (_options.Headers != null) + { + foreach (var kv in _options.Headers) + { + if (kv.Key.ToLower() != "content-type") + { + + httpConfig = httpConfig.Header(kv.Key, kv.Value); + } + } + } + if (_options.Method != null) + { + httpConfig = httpConfig.Method(new HttpMethod(_options.Method)); + var contentType = "text/plain"; + if (_options.Headers != null) + { + foreach (var kv in _options.Headers) + { + if (kv.Key.ToLower() == "content-type") + { + contentType = kv.Value; + if (contentType.Contains(";")) + { + contentType = contentType.Substring(0, contentType.IndexOf(";")); + } + } + } + } + httpConfig = httpConfig.RequestBody(_options.Body, contentType); + } + if (_options.ReadTimeoutMs != null) + { + httpConfig = httpConfig.ReadTimeout(TimeSpan.FromMilliseconds(_options.ReadTimeoutMs.Value)); + } + + var builder = Configuration.Builder(httpConfig); + builder.Logger(log); + if (_options.InitialDelayMs != null) + { + builder.InitialRetryDelay(TimeSpan.FromMilliseconds(_options.InitialDelayMs.Value)); + } + if (_options.LastEventId != null) + { + builder.LastEventId(_options.LastEventId); + } + + _log.Info("Opening stream from {0}", _options.StreamUrl); + + _stream = new EventSource(builder.Build()); + + var backgroundEventSource = new BackgroundEventSource(_stream); + backgroundEventSource.MessageReceived += async (sender, args) => + { + _log.Info("Received event from stream (type: {0}, data: {1})", + args.EventName, args.Message.Data); + await SendMessage(new Message + { + Kind = "event", + Event = new EventMessage + { + Type = args.EventName, + Data = args.Message.Data, + Id = args.Message.LastEventId + } + }); + }; + backgroundEventSource.CommentReceived += async (sender, args) => + { + var comment = args.Comment; + if (comment.StartsWith(":")) + { + comment = comment.Substring(1); // this SSE client includes the colon in the comment + } + _log.Info("Received comment from stream: {0}", comment); + await SendMessage(new Message + { + Kind = "comment", + Comment = comment + }); + }; + backgroundEventSource.Error += async (sender, args) => + { + var exDesc = LogValues.ExceptionSummary(args.Exception); + _log.Info("Received error from stream: {0}", exDesc); + await SendMessage(new Message + { + Kind = "error", + Error = exDesc.ToString() + }); + }; + + Task.Run(backgroundEventSource.RunAsync); + } + + public void Close() + { + _closed = true; + _log.Info("Closing"); + _stream.Close(); + _log.Info("Test ended"); + } + + public bool DoCommand(string command) + { + _log.Info("Test harness sent command: {0}", command); + if (command == "restart") + { + _stream.Interrupt(); + return true; + } + return false; + } + + private async Task SendMessage(object message) + { + if (_closed) + { + return; + } + var json = JsonSerializer.Serialize(message); + _log.Info("Sending: {0}", json); + var counter = Interlocked.Increment(ref _callbackMessageCounter); + var uri = new Uri(_options.CallbackUrl + "/" + counter); + + using (var request = new HttpRequestMessage(HttpMethod.Post, uri)) + using (var stringContent = new StringContent(json, Encoding.UTF8, "application/json")) + { + request.Content = stringContent; + try + { + using (var response = await _httpClient.SendAsync(request)) + { + if (!response.IsSuccessStatusCode) + { + _log.Error("Callback to {0} returned HTTP {1}", uri, response.StatusCode); + } + } + } + catch (Exception e) + { + _log.Error("Callback to {0} failed: {1}", uri, e.GetType()); + } + } + } + } +} diff --git a/contract-tests/TestService.cs b/contract-tests/TestService.cs new file mode 100644 index 0000000..b6c00fa --- /dev/null +++ b/contract-tests/TestService.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Concurrent; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.Logging; +using LaunchDarkly.TestHelpers.HttpTest; + +namespace TestService +{ + public class Program + { + public static void Main(string[] args) + { + var quitSignal = new EventWaitHandle(false, EventResetMode.AutoReset); + var app = new Webapp(quitSignal); + var server = HttpServer.Start(8000, app.Handler); + server.Recorder.Enabled = false; + quitSignal.WaitOne(); + server.Dispose(); + } + } + + public class Webapp + { + public readonly Handler Handler; + + private readonly ILogAdapter _logging = Logs.ToConsole; + private readonly Logger _baseLogger; + private readonly ConcurrentDictionary _streams = + new ConcurrentDictionary(); + private readonly EventWaitHandle _quitSignal; + private volatile int _lastStreamId = 0; + + public Webapp(EventWaitHandle quitSignal) + { + _quitSignal = quitSignal; + _baseLogger = _logging.Logger("service"); + + var service = new SimpleJsonService(); + Handler = service.Handler; + + service.Route(HttpMethod.Get, "/", GetStatus); + service.Route(HttpMethod.Delete, "/", ForceQuit); + service.Route(HttpMethod.Post, "/", PostCreateClient); + service.Route(HttpMethod.Post, "/streams/(.*)", PostStreamCommand); + service.Route(HttpMethod.Delete, "/streams/(.*)", DeleteClient); + } + + SimpleResponse GetStatus(IRequestContext context) => + SimpleResponse.Of(200, new Status + { + Capabilities = new string[] + { + "comments", + "headers", + "last-event-id", + "post", + "read-timeout", + "report", + "restart" + } + }); + + SimpleResponse ForceQuit(IRequestContext context) + { + _logging.Logger("").Info("Test harness has told us to exit"); + + // The web server won't send the response till we return, so we'll defer the actual shutdown + _ = Task.Run(async () => + { + await Task.Delay(100); + _quitSignal.Set(); + }); + + return SimpleResponse.Of(204); + } + + SimpleResponse PostCreateClient(IRequestContext context, StreamOptions options) + { + var testLogger = _logging.Logger(options.Tag); + testLogger.Info("Starting SSE client"); + + var id = Interlocked.Increment(ref _lastStreamId); + var streamId = id.ToString(); + var stream = new StreamEntity(options, testLogger); + _streams[streamId] = stream; + + var resourceUrl = "/streams/" + streamId; + return SimpleResponse.Of(201).WithHeader("Location", resourceUrl); + } + + SimpleResponse PostStreamCommand(IRequestContext context, CommandParams command) + { + var id = context.GetPathParam(0); + if (!_streams.TryGetValue(id, out var stream)) + { + return SimpleResponse.Of(404, null); + } + + if (stream.DoCommand(command.Command)) + { + return SimpleResponse.Of(202, null); + } + else + { + return SimpleResponse.Of(400, null); + } + } + + SimpleResponse DeleteClient(IRequestContext context) + { + var id = context.GetPathParam(0); + if (!_streams.TryGetValue(id, out var stream)) + { + _baseLogger.Error("Got delete request for unknown stream ID: {0}", id); + return SimpleResponse.Of(404); + } + stream.Close(); + _streams.TryRemove(id, out _); + + return SimpleResponse.Of(204); + } + } +} diff --git a/contract-tests/TestService.csproj b/contract-tests/TestService.csproj new file mode 100644 index 0000000..fdc9cb3 --- /dev/null +++ b/contract-tests/TestService.csproj @@ -0,0 +1,37 @@ + + + + netcoreapp3.1 + $(TESTFRAMEWORK) + portable + ContractTestService + Exe + ContractTestService + false + false + false + false + false + false + + + + + + + + + + + + + + + true + PreserveNewest + + + + + + diff --git a/contract-tests/TestService.sln b/contract-tests/TestService.sln new file mode 100644 index 0000000..662af3d --- /dev/null +++ b/contract-tests/TestService.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.810.13 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestService", "TestService.csproj", "{427B5AC1-5A5A-4506-B55A-09187EB85190}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.EventSource", "..\src\LaunchDarkly.EventSource\LaunchDarkly.EventSource.csproj", "{6AD8F034-7096-4420-953F-68F529F2B300}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {427B5AC1-5A5A-4506-B55A-09187EB85190}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {427B5AC1-5A5A-4506-B55A-09187EB85190}.Debug|Any CPU.Build.0 = Debug|Any CPU + {427B5AC1-5A5A-4506-B55A-09187EB85190}.Release|Any CPU.ActiveCfg = Release|Any CPU + {427B5AC1-5A5A-4506-B55A-09187EB85190}.Release|Any CPU.Build.0 = Release|Any CPU + {6AD8F034-7096-4420-953F-68F529F2B300}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6AD8F034-7096-4420-953F-68F529F2B300}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6AD8F034-7096-4420-953F-68F529F2B300}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6AD8F034-7096-4420-953F-68F529F2B300}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {934189FE-533F-429D-87E8-A2311F8730A6} + EndGlobalSection +EndGlobal diff --git a/contract-tests/run.sh b/contract-tests/run.sh new file mode 100755 index 0000000..37485dc --- /dev/null +++ b/contract-tests/run.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +TESTFRAMEWORK=${TESTFRAMEWORK:-netcoreapp2.1} +TEMP_TEST_OUTPUT=/tmp/sse-contract-test-service.log + +cd $(dirname $0) + +BUILDFRAMEWORK=netstandard2.0 dotnet build TestService.csproj +dotnet bin/Debug/${TESTFRAMEWORK}/ContractTestService.dll >${TEMP_TEST_OUTPUT} & +curl -s https://raw.githubusercontent.com/launchdarkly/sse-contract-tests/v0.0.3/downloader/run.sh \ + | VERSION=v0 PARAMS="-url http://localhost:8000 -debug -stop-service-at-end" sh || \ + (echo "Tests failed; see ${TEMP_TEST_OUTPUT} for test service log"; exit 1) From d721a0b88d0a23fdd098680f742f461e8c5eee37 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 18 Jan 2023 11:34:45 -0800 Subject: [PATCH 18/25] contract test service improvements --- contract-tests/Representations.cs | 6 +- contract-tests/StreamEntity.cs | 122 ++++++++++++++++++++---------- contract-tests/TestService.cs | 10 +++ 3 files changed, 95 insertions(+), 43 deletions(-) diff --git a/contract-tests/Representations.cs b/contract-tests/Representations.cs index d4d50c0..f9f5274 100644 --- a/contract-tests/Representations.cs +++ b/contract-tests/Representations.cs @@ -21,15 +21,15 @@ public class StreamOptions [JsonPropertyName("body")] public string Body { get; set; } } - public class Message + public class CallbackMessage { [JsonPropertyName("kind")] public string Kind { get; set; } - [JsonPropertyName("event")] public EventMessage Event { get; set; } + [JsonPropertyName("event")] public CallbackEventMessage Event { get; set; } [JsonPropertyName("comment")] public string Comment { get; set; } [JsonPropertyName("error")] public string Error { get; set; } } - public class EventMessage + public class CallbackEventMessage { [JsonPropertyName("type")] public string Type { get; set; } [JsonPropertyName("data")] public string Data { get; set; } diff --git a/contract-tests/StreamEntity.cs b/contract-tests/StreamEntity.cs index 09fd2b7..50737c0 100644 --- a/contract-tests/StreamEntity.cs +++ b/contract-tests/StreamEntity.cs @@ -4,8 +4,10 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using System.Xml.Linq; using LaunchDarkly.EventSource; -using LaunchDarkly.EventSource.Background; +using LaunchDarkly.EventSource.Events; +using LaunchDarkly.EventSource.Exceptions; using LaunchDarkly.Logging; namespace TestService @@ -65,8 +67,10 @@ Logger log httpConfig = httpConfig.ReadTimeout(TimeSpan.FromMilliseconds(_options.ReadTimeoutMs.Value)); } - var builder = Configuration.Builder(httpConfig); - builder.Logger(log); + var builder = Configuration.Builder(httpConfig) + .ErrorStrategy(ErrorStrategy.AlwaysContinue) // see comments in RunAsync + .Logger(log); + if (_options.InitialDelayMs != null) { builder.InitialRetryDelay(TimeSpan.FromMilliseconds(_options.InitialDelayMs.Value)); @@ -80,50 +84,84 @@ Logger log _stream = new EventSource(builder.Build()); - var backgroundEventSource = new BackgroundEventSource(_stream); - backgroundEventSource.MessageReceived += async (sender, args) => + Task.Run(RunAsync); + } + + private async Task RunAsync() + { + // A typical SSE-based application would only be interested in the events that + // are represented by EventMessage, so it could more simply call ReadMessageAsync() + // and receive only that type. But for the contract tests, we want to know more + // details such as comments and error conditions. + + while (_stream.ReadyState != ReadyState.Shutdown) { - _log.Info("Received event from stream (type: {0}, data: {1})", - args.EventName, args.Message.Data); - await SendMessage(new Message + try { - Kind = "event", - Event = new EventMessage + var e = await _stream.ReadAnyEventAsync(); + + if (e is MessageEvent me) { - Type = args.EventName, - Data = args.Message.Data, - Id = args.Message.LastEventId + _log.Info("Received event from stream (type: {0}, data: {1})", + me.Name, me.Data); + await SendMessage(new CallbackMessage + { + Kind = "event", + Event = new CallbackEventMessage + { + Type = me.Name, + Data = me.Data, + Id = me.LastEventId + } + }); + } + else if (e is CommentEvent ce) + { + _log.Info("Received comment from stream: {0}", ce.Text); + await SendMessage(new CallbackMessage + { + Kind = "comment", + Comment = ce.Text + }); + } + else if (e is FaultEvent fe) + { + if (fe.Exception is StreamClosedByCallerException) + { + // This one is special because it simply means we deliberately + // closed the stream ourselves, so we don't need to report it + // to the test harness. + continue; + } + var exDesc = LogValues.ExceptionSummary(fe.Exception).ToString(); + _log.Info("Received error from stream: {0}", exDesc); + await SendMessage(new CallbackMessage + { + Kind = "error", + Error = exDesc + }); } - }); - }; - backgroundEventSource.CommentReceived += async (sender, args) => - { - var comment = args.Comment; - if (comment.StartsWith(":")) - { - comment = comment.Substring(1); // this SSE client includes the colon in the comment } - _log.Info("Received comment from stream: {0}", comment); - await SendMessage(new Message - { - Kind = "comment", - Comment = comment - }); - }; - backgroundEventSource.Error += async (sender, args) => - { - var exDesc = LogValues.ExceptionSummary(args.Exception); - _log.Info("Received error from stream: {0}", exDesc); - await SendMessage(new Message + catch (Exception ex) { - Kind = "error", - Error = exDesc.ToString() - }); - }; - - Task.Run(backgroundEventSource.RunAsync); + // Because we specified ErrorStrategy.AlwaysContinue, EventSource + // will normally report any errors on the stream as just part of the + // stream, so we will get them as FaultEvents and then it will + // transparently reconnect. Any exception that is thrown here + // probably means there is a bug, so we'll report it to the test + // harness (likely causing test to fail) and also log a detailed + // stacktrace. + _log.Error("Unexpected exception: {0} {1}", LogValues.ExceptionSummary(ex), + LogValues.ExceptionTrace(ex)); + await SendMessage(new CallbackMessage + { + Kind = "error", + Error = LogValues.ExceptionSummary(ex).ToString() + }); + } + } } - + public void Close() { _closed = true; @@ -166,6 +204,10 @@ private async Task SendMessage(object message) { _log.Error("Callback to {0} returned HTTP {1}", uri, response.StatusCode); } + else + { + _log.Info("Callback to {0} succeeded", uri); + } } } catch (Exception e) diff --git a/contract-tests/TestService.cs b/contract-tests/TestService.cs index b6c00fa..5122ae3 100644 --- a/contract-tests/TestService.cs +++ b/contract-tests/TestService.cs @@ -8,6 +8,16 @@ namespace TestService { + // This file contains the basic web service implementation providing the API endpoints + // that the SSE test harness expects. All of the actual SSE operations are implemented + // in StreamEntity. + // + // For portability and simplicity, instead of using ASP.NET Core for the web layer, + // we're using the LaunchDarkly.TestHelpers.HttpTest classes which are based on + // System.Net.HttpListener. This is the same approach we use for the similar SDK test + // service in the LaunchDarkly .NET SDKs. See: + // https://launchdarkly.github.io/dotnet-test-helpers/api/LaunchDarkly.TestHelpers.HttpTest.html + public class Program { public static void Main(string[] args) From 779bde4ac283fa553c8016321e8c0c34b5876046 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 18 Jan 2023 11:37:40 -0800 Subject: [PATCH 19/25] indents --- .gitignore | 1 + .../Background/BackgroundEventSource.cs | 7 +- .../ConnectStrategy.cs | 5 +- src/LaunchDarkly.EventSource/ErrorStrategy.cs | 108 +++++++++--------- .../Internal/SetRetryDelayEvent.cs | 17 ++- 5 files changed, 68 insertions(+), 70 deletions(-) diff --git a/.gitignore b/.gitignore index 2a2eec5..f5f6e08 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ local.properties *.suo *.user *.sln.docstates +launchSettings.json # Build results [Dd]ebug/ diff --git a/src/LaunchDarkly.EventSource/Background/BackgroundEventSource.cs b/src/LaunchDarkly.EventSource/Background/BackgroundEventSource.cs index 7678237..03546cb 100644 --- a/src/LaunchDarkly.EventSource/Background/BackgroundEventSource.cs +++ b/src/LaunchDarkly.EventSource/Background/BackgroundEventSource.cs @@ -38,7 +38,7 @@ namespace LaunchDarkly.EventSource.Background /// /// public class BackgroundEventSource : IDisposable - { + { #region Private Fields private readonly IEventSource _eventSource; @@ -99,13 +99,13 @@ public class BackgroundEventSource : IDisposable /// the underlying SSE client /// if the parameter is null public BackgroundEventSource(IEventSource eventSource) - { + { if (eventSource is null) { throw new ArgumentNullException(nameof(eventSource)); } _eventSource = eventSource; - } + } /// /// Creates a new instance to wrap a new . @@ -216,4 +216,3 @@ private void Dispose(bool disposing) } } - diff --git a/src/LaunchDarkly.EventSource/ConnectStrategy.cs b/src/LaunchDarkly.EventSource/ConnectStrategy.cs index 804b9c7..a9c0ebf 100644 --- a/src/LaunchDarkly.EventSource/ConnectStrategy.cs +++ b/src/LaunchDarkly.EventSource/ConnectStrategy.cs @@ -32,7 +32,7 @@ namespace LaunchDarkly.EventSource /// /// public abstract class ConnectStrategy - { + { /// /// The origin URI that should be included in every . /// @@ -159,6 +159,5 @@ public void Dispose() /// the stream URI /// a configurable public static HttpConnectStrategy Http(Uri uri) => new HttpConnectStrategy(uri); - } + } } - diff --git a/src/LaunchDarkly.EventSource/ErrorStrategy.cs b/src/LaunchDarkly.EventSource/ErrorStrategy.cs index 62cdc10..59954d5 100644 --- a/src/LaunchDarkly.EventSource/ErrorStrategy.cs +++ b/src/LaunchDarkly.EventSource/ErrorStrategy.cs @@ -7,17 +7,17 @@ namespace LaunchDarkly.EventSource { /// /// An abstraction of how to determine whether a stream failure should be thrown to the - /// caller as an exception, or treated as an event. + /// caller as an exception, or treated as an event. /// /// /// Instances of this class should be immutable. /// - /// + /// public abstract class ErrorStrategy - { + { /// /// Specifies that EventSource should always throw an exception if there is an error. - /// This is the default behavior if you do not configure another. + /// This is the default behavior if you do not configure another. /// public static ErrorStrategy AlwaysThrow { get; } = new Invariant(Action.Throw); @@ -33,37 +33,37 @@ public abstract class ErrorStrategy /// public enum Action { - /// - /// Indicates that EventSource should throw an exception from whatever reading - /// method was called (, - /// , etc.). - /// - Throw, - - /// - /// Indicates that EventSource should not throw an exception, but instead return a - /// to the caller. If the caller continues to read from the - /// failed stream after that point, EventSource will try to reconnect to the stream. + /// + /// Indicates that EventSource should throw an exception from whatever reading + /// method was called (, + /// , etc.). + /// + Throw, + + /// + /// Indicates that EventSource should not throw an exception, but instead return a + /// to the caller. If the caller continues to read from the + /// failed stream after that point, EventSource will try to reconnect to the stream. /// Continue - } + } - /// - /// The return type of . - /// - public struct Result - { + /// + /// The return type of . + /// + public struct Result + { /// /// The action that EventSource should take. /// public Action Action { get; set; } - /// - /// The strategy instance to be used for the next retry, or null to use the - /// same instance as last time. - /// - public ErrorStrategy Next { get; set; } - } + /// + /// The strategy instance to be used for the next retry, or null to use the + /// same instance as last time. + /// + public ErrorStrategy Next { get; set; } + } /// /// Applies the strategy to determine whether to retry after a failure. @@ -74,13 +74,13 @@ public struct Result /// /// Specifies that EventSource should automatically retry after a failure for up to this - /// number of consecutive attempts, but should throw an exception after that point. + /// number of consecutive attempts, but should throw an exception after that point. /// /// the maximum number of consecutive retries /// a strategy to be passed to - /// + /// public static ErrorStrategy ContinueWithMaxAttempts(int maxAttempts) => - new MaxAttemptsImpl(maxAttempts, 0); + new MaxAttemptsImpl(maxAttempts, 0); /// /// Specifies that EventSource should automatically retry after a failure and can retry @@ -89,39 +89,39 @@ public static ErrorStrategy ContinueWithMaxAttempts(int maxAttempts) => /// /// the time limit /// a strategy to be passed to - /// - public static ErrorStrategy ContinueWithTimeLimit(TimeSpan maxTime) => - new TimeLimitImpl(maxTime, null); + /// + public static ErrorStrategy ContinueWithTimeLimit(TimeSpan maxTime) => + new TimeLimitImpl(maxTime, null); internal class Invariant : ErrorStrategy - { - private readonly Action _action; + { + private readonly Action _action; - internal Invariant(Action action) { _action = action; } + internal Invariant(Action action) { _action = action; } - public override Result Apply(Exception _) => - new Result { Action = _action }; + public override Result Apply(Exception _) => + new Result { Action = _action }; } internal class MaxAttemptsImpl : ErrorStrategy - { - private readonly int _maxAttempts; - private readonly int _counter; - - internal MaxAttemptsImpl(int maxAttempts, int counter) - { - _maxAttempts = maxAttempts; - _counter = counter; - } - - public override Result Apply(Exception _) => - _counter < _maxAttempts ? - new Result { Action = Action.Continue, Next = new MaxAttemptsImpl(_maxAttempts, _counter + 1) } : - new Result { Action = Action.Throw }; + { + private readonly int _maxAttempts; + private readonly int _counter; + + internal MaxAttemptsImpl(int maxAttempts, int counter) + { + _maxAttempts = maxAttempts; + _counter = counter; + } + + public override Result Apply(Exception _) => + _counter < _maxAttempts ? + new Result { Action = Action.Continue, Next = new MaxAttemptsImpl(_maxAttempts, _counter + 1) } : + new Result { Action = Action.Throw }; } - internal class TimeLimitImpl : ErrorStrategy - { + internal class TimeLimitImpl : ErrorStrategy + { private readonly TimeSpan _maxTime; private readonly DateTime? _startTime; diff --git a/src/LaunchDarkly.EventSource/Internal/SetRetryDelayEvent.cs b/src/LaunchDarkly.EventSource/Internal/SetRetryDelayEvent.cs index c1b6e64..82e6daf 100644 --- a/src/LaunchDarkly.EventSource/Internal/SetRetryDelayEvent.cs +++ b/src/LaunchDarkly.EventSource/Internal/SetRetryDelayEvent.cs @@ -3,14 +3,13 @@ namespace LaunchDarkly.EventSource.Internal { - internal class SetRetryDelayEvent : IEvent - { - public TimeSpan RetryDelay { get; } + internal class SetRetryDelayEvent : IEvent + { + public TimeSpan RetryDelay { get; } - public SetRetryDelayEvent(TimeSpan retryDelay) - { - RetryDelay = retryDelay; - } - } + public SetRetryDelayEvent(TimeSpan retryDelay) + { + RetryDelay = retryDelay; + } + } } - From f84f4c58590a6d9eb2b9025f37ff33f63a507d6e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 18 Jan 2023 11:40:02 -0800 Subject: [PATCH 20/25] comments --- .../Background/BackgroundEventSource.cs | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/LaunchDarkly.EventSource/Background/BackgroundEventSource.cs b/src/LaunchDarkly.EventSource/Background/BackgroundEventSource.cs index 03546cb..028f06f 100644 --- a/src/LaunchDarkly.EventSource/Background/BackgroundEventSource.cs +++ b/src/LaunchDarkly.EventSource/Background/BackgroundEventSource.cs @@ -62,19 +62,34 @@ public class BackgroundEventSource : IDisposable #region Public Events - /// + /// + /// Occurs when the stream connection has been opened. + /// public event AsyncEventHandler Opened; - /// + /// + /// Occurs when the stream connection has been closed. + /// public event AsyncEventHandler Closed; - /// + /// + /// Occurs when a has been received. + /// public event AsyncEventHandler MessageReceived; - /// + /// + /// Occurs when an SSE comment line has been received. + /// + /// + /// An SSE comment is a line that starts with a colon. There is no defined meaning for this + /// in the SSE specification, and most clients ignore it. It may be used to provide a + /// periodic heartbeat from the server to keep connections from timing out. + /// public event AsyncEventHandler CommentReceived; - /// + /// + /// Occurs when an error is detected. + /// public event AsyncEventHandler Error; #endregion From cc84b66889e368bfdfe4638ce8c059d488d6d879 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 18 Jan 2023 11:46:54 -0800 Subject: [PATCH 21/25] generated docs --- .ldrelease/config.yml | 2 +- docs-src/README.md | 12 ++++++++++++ docs-src/index.md | 4 ++++ .../LaunchDarkly.EventSource.Background.md | 1 + .../namespaces/LaunchDarkly.EventSource.Events.md | 1 + .../LaunchDarkly.EventSource.Exceptions.md | 1 + docs-src/namespaces/LaunchDarkly.EventSource.md | 5 +++++ 7 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 docs-src/README.md create mode 100644 docs-src/index.md create mode 100644 docs-src/namespaces/LaunchDarkly.EventSource.Background.md create mode 100644 docs-src/namespaces/LaunchDarkly.EventSource.Events.md create mode 100644 docs-src/namespaces/LaunchDarkly.EventSource.Exceptions.md create mode 100644 docs-src/namespaces/LaunchDarkly.EventSource.md diff --git a/.ldrelease/config.yml b/.ldrelease/config.yml index a58dc66..0b31db2 100644 --- a/.ldrelease/config.yml +++ b/.ldrelease/config.yml @@ -21,4 +21,4 @@ jobs: documentation: gitHubPages: true - title: LaunchDarkly .NET EventSource Client + title: LaunchDarkly .NET SSE Client diff --git a/docs-src/README.md b/docs-src/README.md new file mode 100644 index 0000000..93d715a --- /dev/null +++ b/docs-src/README.md @@ -0,0 +1,12 @@ +# Notes on in-code documentation for this project + +All public types, methods, and properties should have documentation comments in the standard C# XML comment format. These will be automatically included in the [HTML documentation](https://launchdarkly.github.io/dotnet-eventsource) that is generated on release. + +Non-public items may have documentation comments as well, since those may be helpful to other developers working on this project, but they will not be included in the HTML documentation. + +The `docs-src` subdirectory contains additional Markdown content that is included in the documentation build, as follows: + +* `index.md`: This text appears on the landing page of the documentation. +* `namespaces/.md`: A file that is used as the description of a specific namespace. The first line is the summary, which will appear on both the landing page and the API page for the namespace; the rest of the file is the full description, which will appear on the API page for the namespace. + +Markdown text can include hyperlinks to namespaces, types, etc. using the syntax ``. diff --git a/docs-src/index.md b/docs-src/index.md new file mode 100644 index 0000000..4f83e01 --- /dev/null +++ b/docs-src/index.md @@ -0,0 +1,4 @@ + +This site contains the full API reference for the [`LaunchDarkly.EventSource`](https://www.nuget.org/packages/LaunchDarkly.EventSource) package. + +For source code, see the [GitHub repository](https://github.com/launchdarkly/dotnet-eventsource). diff --git a/docs-src/namespaces/LaunchDarkly.EventSource.Background.md b/docs-src/namespaces/LaunchDarkly.EventSource.Background.md new file mode 100644 index 0000000..a3b5aca --- /dev/null +++ b/docs-src/namespaces/LaunchDarkly.EventSource.Background.md @@ -0,0 +1 @@ +A wrapper for EventSource that provides an event listener pattern. diff --git a/docs-src/namespaces/LaunchDarkly.EventSource.Events.md b/docs-src/namespaces/LaunchDarkly.EventSource.Events.md new file mode 100644 index 0000000..8d5a746 --- /dev/null +++ b/docs-src/namespaces/LaunchDarkly.EventSource.Events.md @@ -0,0 +1 @@ +Types representing data or state changes in an SSE stream. diff --git a/docs-src/namespaces/LaunchDarkly.EventSource.Exceptions.md b/docs-src/namespaces/LaunchDarkly.EventSource.Exceptions.md new file mode 100644 index 0000000..55ff587 --- /dev/null +++ b/docs-src/namespaces/LaunchDarkly.EventSource.Exceptions.md @@ -0,0 +1 @@ +Types representing errors in an SSE stream. diff --git a/docs-src/namespaces/LaunchDarkly.EventSource.md b/docs-src/namespaces/LaunchDarkly.EventSource.md new file mode 100644 index 0000000..269aa43 --- /dev/null +++ b/docs-src/namespaces/LaunchDarkly.EventSource.md @@ -0,0 +1,5 @@ +The basic API for SSE client functionality. + +Normal usage is to construct a , pass it to the constructor, and then read from the EventSource with or . + +If you prefer to use a push model where events are delivered to event handlers that you specify, see . From d6fdac6c75b094c0378ddad3ea1cbb2c1201b2b1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 18 Jan 2023 11:59:34 -0800 Subject: [PATCH 22/25] comments --- src/LaunchDarkly.EventSource/ConnectStrategy.cs | 6 +++++- src/LaunchDarkly.EventSource/RetryDelayStrategy.cs | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/LaunchDarkly.EventSource/ConnectStrategy.cs b/src/LaunchDarkly.EventSource/ConnectStrategy.cs index a9c0ebf..d81b2ea 100644 --- a/src/LaunchDarkly.EventSource/ConnectStrategy.cs +++ b/src/LaunchDarkly.EventSource/ConnectStrategy.cs @@ -19,7 +19,11 @@ namespace LaunchDarkly.EventSource /// requests. To customize the HTTP behavior, you can use methods of : /// /// - /// + /// var config = Configuration.Builder( + /// ConnectStrategy.Http(streamUri) + /// .Header("name", "value") + /// .ReadTimeout(TimeSpan.FromMinutes(1)) + /// ); /// > /// /// Or, if you want to consume an input stream from some other source, you can diff --git a/src/LaunchDarkly.EventSource/RetryDelayStrategy.cs b/src/LaunchDarkly.EventSource/RetryDelayStrategy.cs index 2616dd9..b845539 100644 --- a/src/LaunchDarkly.EventSource/RetryDelayStrategy.cs +++ b/src/LaunchDarkly.EventSource/RetryDelayStrategy.cs @@ -1,4 +1,5 @@ using System; + namespace LaunchDarkly.EventSource { /// From 5aea6aeb8809d748d2110574769e2809fd342bb5 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 18 Jan 2023 17:13:40 -0800 Subject: [PATCH 23/25] simplify tests --- .../Events/MessageEventTest.cs | 87 ++++++------------- 1 file changed, 27 insertions(+), 60 deletions(-) diff --git a/test/LaunchDarkly.EventSource.Tests/Events/MessageEventTest.cs b/test/LaunchDarkly.EventSource.Tests/Events/MessageEventTest.cs index 0885bd7..2dfb680 100644 --- a/test/LaunchDarkly.EventSource.Tests/Events/MessageEventTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/Events/MessageEventTest.cs @@ -1,77 +1,44 @@ using System; +using System.IO; +using System.Text; +using LaunchDarkly.TestHelpers; using Xunit; namespace LaunchDarkly.EventSource.Events { public class MessageEventTest { - [Theory] - [InlineData("http://test.com", null, null)] - [InlineData("http://test.com", "testing", null)] - [InlineData("http://test1.com", "something", "200")] - [InlineData("http://test2.com", "various", "125")] - [InlineData("http://test3.com", "testing", "1")] - public void MessageEventEqual(string url, string data, string lastEventId) - { - var uri = new Uri(url); - - MessageEvent event1 = new MessageEvent("message", data, lastEventId, uri); - - MessageEvent event2 = new MessageEvent("message", data, lastEventId, uri); + private static readonly Uri Uri1 = new Uri("http://uri1"), + Uri2 = new Uri("http://uri2"); - Assert.Equal(event1, event2); - } - [Fact] - public void MessageEventNotEqual() - { - var uri = new Uri("http://test.com"); - - Assert.NotEqual(new MessageEvent("name1", "data", "id", uri), - new MessageEvent("name2", "data", "id", uri)); - Assert.NotEqual(new MessageEvent("name", "data1", "id", uri), - new MessageEvent("name", "data2", "id", uri)); - Assert.NotEqual(new MessageEvent("name", "data", "id1", uri), - new MessageEvent("name", "data", "id2", uri)); - Assert.NotEqual(new MessageEvent("name", "data", "id", uri), - new MessageEvent("name", "data", "id", new Uri("http://other"))); - } - - [Theory] - [InlineData("http://test.com", null, null)] - [InlineData("http://test.com", "testing", null)] - [InlineData("http://test1.com", "something", "200")] - [InlineData("http://test2.com", "various", "125")] - [InlineData("http://test3.com", "testing", "1")] - public void Message_event_hashcode_returns_the_same_value_when_called_twice(string url, string data, string lastEventId) + public void BasicProperties() { - - MessageEvent event1 = new MessageEvent("message", data, lastEventId, new Uri(url)); - - var hash1 = event1.GetHashCode(); - - var hash2 = event1.GetHashCode(); - - Assert.Equal(hash1, hash2); - + var e1 = new MessageEvent("name1", "data1", "id1", Uri1); + Assert.Equal("name1", e1.Name); + Assert.Equal("data1", e1.Data); + Assert.Equal("id1", e1.LastEventId); + Assert.Equal(Uri1, e1.Origin); + + var e2 = new MessageEvent("name1", "data1", Uri2); + Assert.Equal("name1", e2.Name); + Assert.Equal("data1", e2.Data); + Assert.Null(e2.LastEventId); + Assert.Equal(Uri2, e2.Origin); } - [Fact] - public void Message_event_hashcode_returns_different_values_when_property_values_changed() + public void EqualityAndHashCode() { - var uri = new Uri("http://test.com"); - - MessageEvent event1 = new MessageEvent("message", "test", uri); - - var hash1 = event1.GetHashCode(); - - var event2 = new MessageEvent("message", "test2", uri); - - var hash2 = event2.GetHashCode(); - - Assert.NotEqual(hash1, hash2); - + TypeBehavior.CheckEqualsAndHashCode( + () => new MessageEvent("name1", "data1", "id1", Uri1), + () => new MessageEvent("name2", "data1", "id1", Uri1), + () => new MessageEvent("name1", "data2", "id1", Uri1), + () => new MessageEvent("name1", "data1", "id2", Uri1), + () => new MessageEvent("name1", "data1", "id1", Uri2), + () => new MessageEvent("name1", "data1", null, Uri1), + () => new MessageEvent("name1", "data1", "id1", null) + ); } } } From cdbce714842e8d1c851dc796ade9995c8135c59f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 18 Jan 2023 17:14:22 -0800 Subject: [PATCH 24/25] just use the immutable Configuration properties instead of copying them to other immutable fields --- src/LaunchDarkly.EventSource/EventSource.cs | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/LaunchDarkly.EventSource/EventSource.cs b/src/LaunchDarkly.EventSource/EventSource.cs index 8024cee..17cbe29 100644 --- a/src/LaunchDarkly.EventSource/EventSource.cs +++ b/src/LaunchDarkly.EventSource/EventSource.cs @@ -41,9 +41,6 @@ public class EventSource : IEventSource, IDisposable private readonly Configuration _configuration; private readonly Logger _logger; private readonly ConnectStrategy.Client _client; - private readonly ErrorStrategy _baseErrorStrategy; - private readonly RetryDelayStrategy _baseRetryDelayStrategy; - private readonly TimeSpan _retryDelayResetThreshold; private readonly Uri _origin; private readonly object _lock = new object(); @@ -101,9 +98,8 @@ public EventSource(Configuration configuration) _origin = _configuration.ConnectStrategy.Origin; _readyState = ReadyState.Raw; - _baseErrorStrategy = _currentErrorStrategy = _configuration.ErrorStrategy; - _baseRetryDelayStrategy = _currentRetryDelayStrategy = _configuration.RetryDelayStrategy; - _retryDelayResetThreshold = _configuration.RetryDelayResetThreshold; + _currentErrorStrategy = _configuration.ErrorStrategy; + _currentRetryDelayStrategy = _configuration.RetryDelayStrategy; _baseRetryDelay = _configuration.InitialRetryDelay; _nextRetryDelay = null; _lastEventId = _configuration.LastEventId; @@ -117,7 +113,7 @@ public EventSource(Configuration configuration) /// /// the stream URI /// if the URI is null - public EventSource(Uri uri) : this(Configuration.Builder(uri).Build()) {} + public EventSource(Uri uri) : this(Configuration.Builder(uri).Build()) { } #endregion @@ -177,7 +173,7 @@ public async Task ReadAnyEventAsync() { _baseRetryDelay = srde.RetryDelay; } - _currentRetryDelayStrategy = _baseRetryDelayStrategy; + _currentRetryDelayStrategy = _configuration.RetryDelayStrategy; continue; } if (e is MessageEvent me) @@ -372,7 +368,7 @@ private async Task TryStartAsync(bool canReturnFaultEvent) _logger ); - _currentErrorStrategy = _baseErrorStrategy; + _currentErrorStrategy = _configuration.ErrorStrategy; return null; } } @@ -388,12 +384,12 @@ private void ComputeRetryDelay() { lock (_lock) { - if (_retryDelayResetThreshold > TimeSpan.Zero && _connectedTime.HasValue) + if (_configuration.RetryDelayResetThreshold > TimeSpan.Zero && _connectedTime.HasValue) { TimeSpan connectionDuration = DateTime.Now.Subtract(_connectedTime.Value); - if (connectionDuration >= _retryDelayResetThreshold) + if (connectionDuration >= _configuration.RetryDelayResetThreshold) { - _currentRetryDelayStrategy = _baseRetryDelayStrategy; + _currentRetryDelayStrategy = _configuration.RetryDelayStrategy; } var result = _currentRetryDelayStrategy.Apply(_baseRetryDelay); _nextRetryDelay = result.Delay; From a08fed342e8a36fdbfefa3fa602f710c8174b234 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 18 Jan 2023 23:49:28 -0800 Subject: [PATCH 25/25] slight refactoring + more tests --- src/LaunchDarkly.EventSource/EventSource.cs | 3 + .../Internal/EventParser.cs | 6 +- .../Internal/SetRetryDelayEvent.cs | 5 + .../FakeInputStream.cs | 63 ++++++ .../Internal/BufferedLineParserTest.cs | 50 ----- .../Internal/EventParserTest.cs | 201 +++++++++++++++++- .../TestHelpers.cs | 13 ++ 7 files changed, 285 insertions(+), 56 deletions(-) create mode 100644 test/LaunchDarkly.EventSource.Tests/FakeInputStream.cs diff --git a/src/LaunchDarkly.EventSource/EventSource.cs b/src/LaunchDarkly.EventSource/EventSource.cs index 17cbe29..2c732c8 100644 --- a/src/LaunchDarkly.EventSource/EventSource.cs +++ b/src/LaunchDarkly.EventSource/EventSource.cs @@ -38,6 +38,8 @@ public class EventSource : IEventSource, IDisposable { #region Private Fields + private const int ReadBufferSize = 1000; + private readonly Configuration _configuration; private readonly Logger _logger; private readonly ConnectStrategy.Client _client; @@ -362,6 +364,7 @@ private async Task TryStartAsync(bool canReturnFaultEvent) _parser = new EventParser( connectResult.Stream, + ReadBufferSize, connectResult.ReadTimeout ?? Timeout.InfiniteTimeSpan, _origin, newCancellationToken, diff --git a/src/LaunchDarkly.EventSource/Internal/EventParser.cs b/src/LaunchDarkly.EventSource/Internal/EventParser.cs index 0c7b0e3..d87a7f3 100644 --- a/src/LaunchDarkly.EventSource/Internal/EventParser.cs +++ b/src/LaunchDarkly.EventSource/Internal/EventParser.cs @@ -15,8 +15,7 @@ namespace LaunchDarkly.EventSource.Internal /// internal sealed class EventParser { - private const int ReadBufferSize = 1000; - private const int ValueBufferInitialCapacity = 1000; + internal const int ValueBufferInitialCapacity = 1000; private readonly Stream _stream; private readonly BufferedLineParser _lineParser; @@ -41,6 +40,7 @@ internal sealed class EventParser public EventParser( Stream stream, + int readBufferSize, TimeSpan readTimeout, Uri origin, CancellationToken cancellationToken, @@ -50,7 +50,7 @@ Logger logger _stream = stream; _lineParser = new BufferedLineParser( ReadFromStream, - ReadBufferSize + readBufferSize ); _readTimeout = readTimeout; _origin = origin; diff --git a/src/LaunchDarkly.EventSource/Internal/SetRetryDelayEvent.cs b/src/LaunchDarkly.EventSource/Internal/SetRetryDelayEvent.cs index 82e6daf..5142871 100644 --- a/src/LaunchDarkly.EventSource/Internal/SetRetryDelayEvent.cs +++ b/src/LaunchDarkly.EventSource/Internal/SetRetryDelayEvent.cs @@ -11,5 +11,10 @@ public SetRetryDelayEvent(TimeSpan retryDelay) { RetryDelay = retryDelay; } + + public override bool Equals(object obj) => + obj is SetRetryDelayEvent srde && srde.RetryDelay == RetryDelay; + + public override int GetHashCode() => RetryDelay.GetHashCode(); } } diff --git a/test/LaunchDarkly.EventSource.Tests/FakeInputStream.cs b/test/LaunchDarkly.EventSource.Tests/FakeInputStream.cs new file mode 100644 index 0000000..845b2f9 --- /dev/null +++ b/test/LaunchDarkly.EventSource.Tests/FakeInputStream.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LaunchDarkly.EventSource +{ + public class FakeInputStream : Stream + { + private byte[][] _chunks; + private int _curChunk = 0; + private int _posInChunk = 0; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotImplementedException(); + public override long Position { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public Logging.Logger Logger; + + public FakeInputStream(params byte[][] chunks) + { + _chunks = chunks; + } + + public FakeInputStream(params string[] chunks) : this( + chunks.Select(s => Encoding.UTF8.GetBytes(s)).ToArray()) { } + + public override void Flush() { } + + public override int Read(byte[] buffer, int offset, int count) + { + Logger?.Debug("cur={0}, chunks.Length={1}", _curChunk, _chunks.Length); + if (_curChunk >= _chunks.Length) + { + return -1; + } + int remaining = _chunks[_curChunk].Length - _posInChunk; + if (remaining <= count) + { + System.Buffer.BlockCopy(_chunks[_curChunk], _posInChunk, buffer, offset, remaining); + _curChunk++; + _posInChunk = 0; + Logger?.Debug("read {0} bytes", remaining); + return remaining; + } + System.Buffer.BlockCopy(_chunks[_curChunk], _posInChunk, buffer, offset, count); + _posInChunk += count; + Logger?.Debug("read {0} bytes", count); + return count; + } + + public new Task ReadAsync(byte[] buffer, int offset, int count) + { + return Task.FromResult(Read(buffer, offset, count)); + } + + public override long Seek(long offset, SeekOrigin origin) { return 0; } + public override void SetLength(long value) { } + public override void Write(byte[] buffer, int offset, int count) { } + } +} diff --git a/test/LaunchDarkly.EventSource.Tests/Internal/BufferedLineParserTest.cs b/test/LaunchDarkly.EventSource.Tests/Internal/BufferedLineParserTest.cs index d3b6e4f..8c4852b 100644 --- a/test/LaunchDarkly.EventSource.Tests/Internal/BufferedLineParserTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/Internal/BufferedLineParserTest.cs @@ -146,55 +146,5 @@ private static string makeUtf8CharacterSet() } return s.ToString(); } - - public class FakeInputStream : Stream - { - private byte[][] _chunks; - private int _curChunk = 0; - private int _posInChunk = 0; - - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => false; - - public override long Length => throw new NotImplementedException(); - - public override long Position { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } - - public FakeInputStream(byte[][] chunks) - { - _chunks = chunks; - } - - public override void Flush() { } - - public override int Read(byte[] buffer, int offset, int count) - { - if (_curChunk >= _chunks.Length) - { - return -1; - } - int remaining = _chunks[_curChunk].Length - _posInChunk; - if (remaining <= count) - { - System.Buffer.BlockCopy(_chunks[_curChunk], _posInChunk, buffer, offset, remaining); - _curChunk++; - _posInChunk = 0; - return remaining; - } - System.Buffer.BlockCopy(_chunks[_curChunk], _posInChunk, buffer, offset, count); - _posInChunk += count; - return count; - } - - public new Task ReadAsync(byte[] buffer, int offset, int count) - { - return Task.FromResult(Read(buffer, offset, count)); - } - - public override long Seek(long offset, SeekOrigin origin) { return 0; } - public override void SetLength(long value) { } - public override void Write(byte[] buffer, int offset, int count) { } - } } } diff --git a/test/LaunchDarkly.EventSource.Tests/Internal/EventParserTest.cs b/test/LaunchDarkly.EventSource.Tests/Internal/EventParserTest.cs index 11362c3..3694128 100644 --- a/test/LaunchDarkly.EventSource.Tests/Internal/EventParserTest.cs +++ b/test/LaunchDarkly.EventSource.Tests/Internal/EventParserTest.cs @@ -1,11 +1,206 @@ -using System.Text; +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; using LaunchDarkly.EventSource.Events; +using LaunchDarkly.EventSource.Exceptions; using Xunit; +using Xunit.Abstractions; + +using static LaunchDarkly.EventSource.TestHelpers; namespace LaunchDarkly.EventSource.Internal { - public class EventParserTest + public class EventParserTest : BaseTest { - + private readonly Uri Origin = new Uri("http://origin"); + private const int DefaultBufferSize = 200; + + private EventParser _parser; + + public EventParserTest(ITestOutputHelper testOutput) : base(testOutput) { } + + [Fact] + public async Task ReadsSingleLineMessage() + { + InitParser(DefaultBufferSize, new FakeInputStream("data: hello\n\n")); + + Assert.Equal(new MessageEvent("message", "hello", null, Origin), + await RequireEvent()); + await AssertEof(); + } + + [Fact] + public async Task ReadsSingleLineMessageWithEventName() + { + InitParser(DefaultBufferSize, new FakeInputStream( + "event: hello\n", + "data: world\n\n")); + + Assert.Equal(new MessageEvent("hello", "world", null, Origin), + await RequireEvent()); + await AssertEof(); + } + + [Fact] + public async Task ReadsSingleLineMessageWithid() + { + InitParser(DefaultBufferSize, new FakeInputStream("data: hello\n", "id: abc\n\n")); + + Assert.Equal(new MessageEvent("message", "hello", "abc", Origin), + await RequireEvent()); + await AssertEof(); + } + + [Fact] + public async Task DoesNotFireMultipleTimesIfSeveralEmptyLines() + { + InitParser(DefaultBufferSize, new FakeInputStream("data: hello\n\n")); + + Assert.Equal(new MessageEvent("message", "hello", null, Origin), + await RequireEvent()); + await AssertEof(); + } + + [Fact] + public async Task SendsCommentsForLinesStartingWithColon() + { + InitParser(DefaultBufferSize, new FakeInputStream( + ": first comment\n", + "data: hello\n", + ":second comment\n\n" + )); + + Assert.Equal(new CommentEvent("first comment"), await RequireEvent()); + Assert.Equal(new CommentEvent("second comment"), await RequireEvent()); + Assert.Equal(new MessageEvent("message", "hello", null, Origin), + await RequireEvent()); + // The message is received after the two comments, rather than interleaved between + // them, because we can't know that the message is actually finished until we see + // the blank line after the second comment. + await AssertEof(); + } + + [Fact] + public async Task ReadsSingleLineMessageWithoutColon() + { + InitParser(DefaultBufferSize, new FakeInputStream("data\n\n")); + + Assert.Equal(new MessageEvent("message", "", null, Origin), + await RequireEvent()); + await AssertEof(); + } + + [Fact] + public async Task PropertiesAreResetBetweenMessages() + { + InitParser(DefaultBufferSize, new FakeInputStream( + "event: hello\n", + "data: data1\n", + "\n", + "data: data2\n", + "\n" + )); + + Assert.Equal(new MessageEvent("hello", "data1", null, Origin), + await RequireEvent()); + Assert.Equal(new MessageEvent("message", "data2", null, Origin), + await RequireEvent()); + await AssertEof(); + } + + [Fact] + public async Task ReadsRetryDirective() + { + InitParser(DefaultBufferSize, new FakeInputStream( + "retry: 7000L\n", // ignored because it's not a numeric string + "retry: 7000\n" + )); + + Assert.Equal(new SetRetryDelayEvent(TimeSpan.FromSeconds(7)), + await RequireEvent()); + } + + [Fact] + public async Task IgnoresUnknownFieldName() + { + InitParser(DefaultBufferSize, new FakeInputStream( + "data: hello\n", + "badfield: whatever\n", + "id: 1\n", + "\n" + )); + + Assert.Equal(new MessageEvent("message", "hello", "1", Origin), + await RequireEvent()); + await AssertEof(); + } + + [Fact] + public async Task UsesEventIdOfPreviousEventIfNoneSet() + { + InitParser(DefaultBufferSize, new FakeInputStream( + "data: hello\n", + "id: reused\n", + "\n", + "data: world\n", + "\n" + )); + + Assert.Equal(new MessageEvent("message", "hello", "reused", Origin), + await RequireEvent()); + Assert.Equal(new MessageEvent("message", "world", "reused", Origin), + await RequireEvent()); + await AssertEof(); + } + + [Fact] + public async Task FieldsCanBeSplitAcrossChunks() + { + // This field starts in one chunk and finishes in the next + var eventName = MakeStringOfLength(DefaultBufferSize + (DefaultBufferSize / 5)); + + // This field spans multiple chunks and is also longer than ValueBufferInitialCapacity, + // so we're verifying that we correctly recreate the buffer afterward + var id = MakeStringOfLength(EventParser.ValueBufferInitialCapacity + (DefaultBufferSize / 5)); + + // Same idea as above, because we know there is a separate buffer for the data field + var data = MakeStringOfLength(EventParser.ValueBufferInitialCapacity + (DefaultBufferSize / 5)); + + // Here we have a field whose name is longer than the buffer, to test our "skip rest of line" logic + var longInvalidFieldName = MakeStringOfLength(DefaultBufferSize * 2 + (DefaultBufferSize / 5)) + .Replace(':', '_'); // ensure there isn't a colon within the name + var longInvalidFieldValue = MakeStringOfLength(DefaultBufferSize * 2 + (DefaultBufferSize / 5)); + + // This one tests the logic where we are able to parse the field name right away, but the value is long + var shortInvalidFieldName = "whatever"; + + InitParser(DefaultBufferSize, new FakeInputStream( + "event: " + eventName + "\n", + "data: " + data + "\n", + "id: " + id + "\n", + shortInvalidFieldName + ": " + longInvalidFieldValue + "\n", + longInvalidFieldName + ": " + longInvalidFieldValue + "\n", + "\n" + )); + + Assert.Equal(new MessageEvent(eventName, data, id, Origin), + await RequireEvent()); + await AssertEof(); + } + + private void InitParser(int bufferSize, FakeInputStream stream) + { + stream.Logger = _testLogger; + _parser = new EventParser(stream, bufferSize, TimeSpan.FromDays(1), + Origin, new CancellationTokenSource().Token, _testLogger); + } + + private Task RequireEvent() => _parser.NextEventAsync(); + + private async Task AssertEof() => + await Assert.ThrowsAnyAsync( + () => _parser.NextEventAsync()); } } diff --git a/test/LaunchDarkly.EventSource.Tests/TestHelpers.cs b/test/LaunchDarkly.EventSource.Tests/TestHelpers.cs index 4ded920..7d7b2da 100644 --- a/test/LaunchDarkly.EventSource.Tests/TestHelpers.cs +++ b/test/LaunchDarkly.EventSource.Tests/TestHelpers.cs @@ -13,6 +13,8 @@ namespace LaunchDarkly.EventSource { public static class TestHelpers { + private static int _generatedStringCounter = 0; + public static readonly TimeSpan OneSecond = TimeSpan.FromSeconds(1); public static Handler StartStream() => Handlers.SSE.Start(); @@ -66,6 +68,17 @@ public static string AsSSEData(this MessageEvent e) return sb.ToString(); } + public static string MakeStringOfLength(int n) + { + int offset = _generatedStringCounter++; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < n; i++) + { + sb.Append((char)('!' + (i + offset) % ('~' - '!' + 1))); + } + return sb.ToString(); + } + // The WithTimeout methods are used in tests only, because the timeout behavior is // is not what we would want in a real application: if it times out, the underlying // task is still trying to parse an event so the EventSource is no longer in a valid