Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LaunchDarkly.EventSource 6.0 rewrite, part 1 #93

Open
wants to merge 26 commits into
base: 6.0
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b1a1798
implement contract tests
eli-darkly Nov 12, 2021
a1dad0f
run contract tests in CI
eli-darkly Nov 12, 2021
ed23d4f
fix Docker build
eli-darkly Nov 12, 2021
5e7eb22
fix CI script
eli-darkly Nov 12, 2021
cde64b5
install Docker in CI
eli-darkly Nov 12, 2021
3d550dc
add prerequisite for Docker install
eli-darkly Nov 12, 2021
328cfe2
set up Docker daemon in CI
eli-darkly Nov 12, 2021
17c53ee
pass in Docker image variable
eli-darkly Nov 12, 2021
b3c6108
use simpler non-Docker contract test runner
eli-darkly Nov 13, 2021
7427c93
add package dependency
eli-darkly Nov 13, 2021
61762bd
makefile DRY
eli-darkly Nov 13, 2021
fef2878
fix readme link
eli-darkly Nov 16, 2021
84c5a8c
update for change in test harness callback protocol
eli-darkly Nov 16, 2021
8f5d7e2
use v1 release of test harness
eli-darkly Nov 30, 2021
ef3b6b9
full rewrite to use a pull model like our Java implementation
eli-darkly Jan 18, 2023
9645ade
add BackgroundEventSource
eli-darkly Jan 18, 2023
7973917
misc refactoring/API cleanup/better lock usage
eli-darkly Jan 18, 2023
9b79c15
add contract tests
eli-darkly Jan 18, 2023
d721a0b
contract test service improvements
eli-darkly Jan 18, 2023
779bde4
indents
eli-darkly Jan 18, 2023
f84f4c5
comments
eli-darkly Jan 18, 2023
cc84b66
generated docs
eli-darkly Jan 18, 2023
d6fdac6
comments
eli-darkly Jan 18, 2023
5aea6ae
simplify tests
eli-darkly Jan 19, 2023
cdbce71
just use the immutable Configuration properties instead of copying th…
eli-darkly Jan 19, 2023
a08fed3
slight refactoring + more tests
eli-darkly Jan 19, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ local.properties
*.suo
*.user
*.sln.docstates
launchSettings.json

# Build results
[Dd]ebug/
Expand Down
2 changes: 1 addition & 1 deletion .ldrelease/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ jobs:

documentation:
gitHubPages: true
title: LaunchDarkly .NET EventSource Client
title: LaunchDarkly .NET SSE Client
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
30 changes: 30 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions contract-tests/README.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 43 additions & 0 deletions contract-tests/Representations.cs
Original file line number Diff line number Diff line change
@@ -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<string, string> 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 CallbackMessage
{
[JsonPropertyName("kind")] public string Kind { 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 CallbackEventMessage
{
[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; }
}
}
220 changes: 220 additions & 0 deletions contract-tests/StreamEntity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;
using LaunchDarkly.EventSource;
using LaunchDarkly.EventSource.Events;
using LaunchDarkly.EventSource.Exceptions;
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)
.ErrorStrategy(ErrorStrategy.AlwaysContinue) // see comments in RunAsync
.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());

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)
{
try
{
var e = await _stream.ReadAnyEventAsync();

if (e is MessageEvent me)
{
_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
});
}
}
catch (Exception ex)
{
// 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;
_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);
}
else
{
_log.Info("Callback to {0} succeeded", uri);
}
}
}
catch (Exception e)
{
_log.Error("Callback to {0} failed: {1}", uri, e.GetType());
}
}
}
}
}
Loading