From fc46ea562c63146b5fe0a821c2e21d6f2dca9625 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Wed, 5 Aug 2026 17:01:28 -0400 Subject: [PATCH 01/14] chore: add SSE contract-tests service Adds a small HTTP service that wraps LaunchDarkly.EventSource and exposes it to the sse-contract-tests harness. The .NET SSE library was the only launchdarkly/*-eventsource repo without a contract-tests service; the Go, JavaScript, Java (okhttp), and C++ SSE libraries each have one. Adding one here lets us participate in cross-language SSE conformance testing and gives us a target to point the harness at when validating fixes. Structure and endpoints mirror the Java service in okhttp-eventsource's contract-tests. The main structural difference: .NET's EventSource is push-based (fires event handlers on its own internal thread) rather than pull-based via a blocking iterator, so StreamEntity subscribes handlers directly instead of spinning up a background reader thread. The service is not shipped as part of the LaunchDarkly.EventSource NuGet package -- it lives in contract-tests/ as internal tooling only. --- contract-tests/README.md | 32 ++++++ contract-tests/Representations.cs | 46 ++++++++ contract-tests/StreamEntity.cs | 175 ++++++++++++++++++++++++++++++ contract-tests/TestService.cs | 126 +++++++++++++++++++++ contract-tests/TestService.csproj | 27 +++++ 5 files changed, 406 insertions(+) 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 diff --git a/contract-tests/README.md b/contract-tests/README.md new file mode 100644 index 0000000..1b8655f --- /dev/null +++ b/contract-tests/README.md @@ -0,0 +1,32 @@ +# SSE contract-tests service + +This is a small HTTP service that wraps `LaunchDarkly.EventSource` and exposes it to +the [`sse-contract-tests`](https://github.com/launchdarkly/sse-contract-tests) harness. +It is the .NET analogue of the `contract-tests/` services in +[`eventsource`](https://github.com/launchdarkly/eventsource) (Go), +[`js-eventsource`](https://github.com/launchdarkly/js-eventsource) (JavaScript), and +[`okhttp-eventsource`](https://github.com/launchdarkly/okhttp-eventsource) (Java). + +The service is not shipped as part of any published NuGet package. It exists purely +as a test target for the `sse-contract-tests` harness. + +## Running + +``` +dotnet run --project TestService.csproj +``` + +The service listens on port 8000 by default. + +## Running the harness against it + +Either use the released harness binary via `sse-contract-tests`'s `downloader/run.sh` +script, or build the harness locally: + +``` +cd path/to/sse-contract-tests +go build -o sse-test-harness . +./sse-test-harness --url http://localhost:8000 +``` + +To exercise only a subset of tests, pass `--run ` or `--skip `. diff --git a/contract-tests/Representations.cs b/contract-tests/Representations.cs new file mode 100644 index 0000000..80d8679 --- /dev/null +++ b/contract-tests/Representations.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; + +// Note, in order for System.Text.Json serialization/deserialization to work correctly, the members of +// these classes must be properties with get/set, rather than fields. The property names are automatically +// camelCased by System.Text.Json. + +namespace TestService +{ + public class Status + { + public string[] Capabilities { get; set; } + } + + public class StreamOptions + { + public string StreamUrl { get; set; } + public string CallbackUrl { get; set; } + public string Tag { get; set; } + public Dictionary Headers { get; set; } + public int? InitialDelayMs { get; set; } + public int? ReadTimeoutMs { get; set; } + public string LastEventId { get; set; } + public string Method { get; set; } + public string Body { get; set; } + } + + public class Message + { + public string Kind { get; set; } + public EventMessage Event { get; set; } + public string Comment { get; set; } + public string Error { get; set; } + } + + public class EventMessage + { + public string Type { get; set; } + public string Data { get; set; } + public string Id { get; set; } + } + + public class CommandParams + { + public string Command { get; set; } + } +} diff --git a/contract-tests/StreamEntity.cs b/contract-tests/StreamEntity.cs new file mode 100644 index 0000000..25b78d4 --- /dev/null +++ b/contract-tests/StreamEntity.cs @@ -0,0 +1,175 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading; +using LaunchDarkly.EventSource; +using LaunchDarkly.Logging; + +namespace TestService +{ + /// + /// Wraps a single LaunchDarkly.EventSource.EventSource instance driven by the SSE contract-tests + /// harness. Subscribes to the SSE client's events and forwards each one to the harness callback + /// URL as a JSON message. + /// + /// + /// This is the .NET analogue of ssetest.StreamEntity in okhttp-eventsource's contract-tests + /// service. The main structural difference: .NET's EventSource is push-based (fires event + /// handlers on its own internal thread), so we don't need a background reader thread the way + /// the Java service does around its blocking event iterator. + /// + public class StreamEntity + { + private readonly StreamOptions _options; + private readonly Logger _logger; + private readonly HttpClient _callbackClient; + private readonly EventSource _eventSource; + private int _callbackMessageCounter; + private volatile bool _closed; + + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }; + + public StreamEntity(StreamOptions options, ILogAdapter logAdapter) + { + _options = options; + _logger = logAdapter.Logger(options.Tag ?? "stream"); + _callbackClient = new HttpClient(); + + _logger.Info("Opening stream to {0}", options.StreamUrl); + + var configBuilder = Configuration.Builder(new Uri(options.StreamUrl)); + + if (options.Headers != null) + { + foreach (var kv in options.Headers) + { + // Content-Type is a content header, not a request header; .NET's HttpHeaders + // throws if we try to add it as a request header. We consume this header + // separately below when constructing the request body. + if (string.Equals(kv.Key, "content-type", System.StringComparison.OrdinalIgnoreCase)) + { + continue; + } + configBuilder.RequestHeader(kv.Key, kv.Value); + } + } + if (options.InitialDelayMs.HasValue) + { + configBuilder.InitialRetryDelay(TimeSpan.FromMilliseconds(options.InitialDelayMs.Value)); + } + if (options.ReadTimeoutMs.HasValue) + { + configBuilder.ReadTimeout(TimeSpan.FromMilliseconds(options.ReadTimeoutMs.Value)); + } + if (!string.IsNullOrEmpty(options.LastEventId)) + { + configBuilder.LastEventId(options.LastEventId); + } + if (!string.IsNullOrEmpty(options.Method)) + { + configBuilder.Method(new HttpMethod(options.Method)); + if (!string.IsNullOrEmpty(options.Body)) + { + string contentType = "text/plain; charset=utf-8"; + if (options.Headers != null && options.Headers.TryGetValue("content-type", out var ct)) + { + contentType = ct; + } + var bodyString = options.Body; + var mediaType = contentType.Split(';')[0].Trim(); + var charset = Encoding.UTF8; + configBuilder.RequestBodyFactory(() => + new StringContent(bodyString, charset, mediaType)); + } + } + + _eventSource = new EventSource(configBuilder.Build()); + _eventSource.MessageReceived += OnMessageReceived; + _eventSource.CommentReceived += OnCommentReceived; + _eventSource.Error += OnError; + + // Fire-and-forget: EventSource fires events on its own internal thread as data arrives. + _ = _eventSource.StartAsync(); + } + + public bool DoCommand(string command) + { + _logger.Info("Test harness sent command: {0}", command); + if (command == "restart") + { + _eventSource.Restart(false); + return true; + } + return false; + } + + public void Close() + { + _closed = true; + _eventSource.MessageReceived -= OnMessageReceived; + _eventSource.CommentReceived -= OnCommentReceived; + _eventSource.Error -= OnError; + _eventSource.Close(); + _callbackClient.Dispose(); + _logger.Info("Test ended"); + } + + private void OnMessageReceived(object sender, MessageReceivedEventArgs e) + { + _logger.Info("Received event from stream ({0})", e.EventName); + var msg = new Message + { + Kind = "event", + Event = new EventMessage + { + Type = e.EventName, + Data = e.Message.Data, + Id = e.Message.LastEventId, + }, + }; + SendCallback(msg); + } + + private void OnCommentReceived(object sender, CommentReceivedEventArgs e) + { + SendCallback(new Message { Kind = "comment", Comment = e.Comment }); + } + + private void OnError(object sender, ExceptionEventArgs e) + { + _logger.Info("Received error from stream: {0}", e.Exception); + SendCallback(new Message { Kind = "error", Error = e.Exception.ToString() }); + } + + private void SendCallback(Message message) + { + if (_closed) + { + return; + } + var counter = Interlocked.Increment(ref _callbackMessageCounter); + var url = _options.CallbackUrl + "/" + counter; + var json = JsonSerializer.Serialize(message, JsonOptions); + try + { + using var content = new StringContent(json, Encoding.UTF8); + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + var resp = _callbackClient.PostAsync(url, content).GetAwaiter().GetResult(); + if ((int)resp.StatusCode >= 300) + { + _logger.Error("Callback to {0} returned HTTP {1}", url, (int)resp.StatusCode); + } + } + catch (Exception ex) + { + _logger.Error("Callback to {0} failed: {1}", url, ex.GetType().Name); + } + } + } +} diff --git a/contract-tests/TestService.cs b/contract-tests/TestService.cs new file mode 100644 index 0000000..63277d5 --- /dev/null +++ b/contract-tests/TestService.cs @@ -0,0 +1,126 @@ +using System.Collections.Concurrent; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.Logging; +using LaunchDarkly.TestHelpers.HttpTest; + +namespace TestService +{ + /// + /// HTTP entry point for the SSE contract-tests service. Implements the endpoints described in + /// launchdarkly/sse-contract-tests/docs/service_spec.md for the LaunchDarkly.EventSource + /// library. This is the .NET analogue of ssetest.TestService in okhttp-eventsource's + /// contract-tests service. + /// + public class Program + { + const int Port = 8000; + + public static void Main(string[] args) + { + var quitSignal = new EventWaitHandle(false, EventResetMode.AutoReset); + + var app = new Webapp(quitSignal); + var server = HttpServer.Start(Port, app.Handler); + server.Recorder.Enabled = false; + + System.Console.WriteLine("Listening on port {0}", Port); + + quitSignal.WaitOne(); + server.Dispose(); + } + } + + public class Webapp + { + private static readonly string[] Capabilities = new[] + { + "comments", + "headers", + "last-event-id", + "payload-size-stress-testable", + "post", + "read-timeout", + "report", + "restart", + }; + + public readonly Handler Handler; + + private readonly ILogAdapter _logging = Logs.ToConsole; + private readonly ConcurrentDictionary _streams = + new ConcurrentDictionary(); + private readonly EventWaitHandle _quitSignal; + private volatile int _lastStreamId = 0; + + public Webapp(EventWaitHandle quitSignal) + { + _quitSignal = quitSignal; + + var service = new SimpleJsonService(); + Handler = service.Handler; + + service.Route(HttpMethod.Get, "/", GetStatus); + service.Route(HttpMethod.Delete, "/", ForceQuit); + service.Route(HttpMethod.Post, "/", PostCreateStream); + service.Route(HttpMethod.Post, "/streams/(.*)", PostStreamCommand); + service.Route(HttpMethod.Delete, "/streams/(.*)", DeleteStream); + } + + SimpleResponse GetStatus(IRequestContext context) => + SimpleResponse.Of(200, new Status { Capabilities = Capabilities }); + + 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 PostCreateStream(IRequestContext context, StreamOptions opts) + { + var stream = new StreamEntity(opts, _logging); + + var id = Interlocked.Increment(ref _lastStreamId); + var streamId = id.ToString(); + _streams[streamId] = stream; + + var resourceUrl = "/streams/" + streamId; + return SimpleResponse.Of(201).WithHeader("Location", resourceUrl); + } + + SimpleResponse PostStreamCommand(IRequestContext context, CommandParams cmd) + { + var id = context.GetPathParam(0); + if (!_streams.TryGetValue(id, out var stream)) + { + return SimpleResponse.Of(404); + } + if (!stream.DoCommand(cmd.Command)) + { + return SimpleResponse.Of(400); + } + return SimpleResponse.Of(204); + } + + SimpleResponse DeleteStream(IRequestContext context) + { + var id = context.GetPathParam(0); + if (!_streams.TryGetValue(id, out var stream)) + { + 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..afdc706 --- /dev/null +++ b/contract-tests/TestService.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + portable + ContractTestService + Exe + ContractTestService + false + false + false + false + false + false + disable + + + + + + + + + + + + From cdd255a72c8fdecc174b19f9d7bd570d0f39365e Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Wed, 5 Aug 2026 17:18:42 -0400 Subject: [PATCH 02/14] fix: increase SSE read buffer to 8192 bytes to avoid Android streaming stall When LaunchDarkly.EventSource runs on MAUI Android, the response body Stream returned by HttpClient is silently wrapped in a System.IO.BufferedStream by Xamarin.Android.Net.AndroidMessageHandler (see dotnet/android's AndroidMessageHandler.GetContent). BufferedStream's default internal buffer is 4096 bytes, and its Read implementation takes a different code path when the caller's requested count is smaller than that internal buffer -- it attempts to fill its own 4096-byte buffer by issuing a 4096-byte read against the underlying Java InputStream. That aggressive prefetch drains the underlying stream on the first read. When the caller comes back for more bytes, BufferedStream must issue another read, which then blocks on Java's InputStream.read waiting for the next byte from the socket. On a LaunchDarkly SSE stream, the next byte doesn't arrive until the next server-side keepalive (~60 seconds), stalling the entire client for that duration. The bug reproduces for payload sizes in a specific window between the caller's buffer size and BufferedStream's internal size -- large enough to force a second read but small enough that the whole payload arrives in one TCP burst before the socket goes idle. Empirically that window was 1 KiB through ~4 KiB with the default 1024-byte StreamReader buffer. Payloads above ~4 KiB span enough TCP frames that the underlying stream never goes idle between reads and the bug doesn't manifest. Setting our read buffer to 8192 bytes takes the caller's Read count to a value >= BufferedStream._bufferSize (4096), which routes into BufferedStream's passthrough branch instead of its prefetching branch -- each caller Read maps 1:1 to a single Java read, with no over-drain. 8192 matches Okio's Segment.SIZE (which Android's OkHttp uses internally) and gives one doubling of headroom in case dotnet/runtime raises BufferedStream's default in a future release. Both the StreamReader (default text-mode path) and the ByteArrayLineScanner (PreferDataAsUtf8Bytes path) needed to be updated, since both were using sub-4096 buffers (1024 and 1000 respectively). Fixes SDK-2755. --- .../EventSourceService.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/LaunchDarkly.EventSource/EventSourceService.cs b/src/LaunchDarkly.EventSource/EventSourceService.cs index 7e2ff5c..1dfc3c7 100644 --- a/src/LaunchDarkly.EventSource/EventSourceService.cs +++ b/src/LaunchDarkly.EventSource/EventSourceService.cs @@ -17,7 +17,18 @@ internal class EventSourceService { #region Private Fields - private const int Utf8ReadBufferSize = 1000; + // Buffer size for both the StreamReader (text mode) and the ByteArrayLineScanner + // (PreferDataAsUtf8Bytes mode). Chosen as 8192 rather than a smaller value because + // Xamarin.Android.Net.AndroidMessageHandler silently wraps HTTP response bodies in a + // System.IO.BufferedStream whose default internal buffer is 4096 bytes. When a caller + // passes count < 4096 to Stream.Read, BufferedStream engages its internal-buffer + // branch and issues a 4096-byte read against the underlying Java InputStream. On an + // SSE keepalive-driven connection, once the first HTTP chunk is drained, the follow-up + // read blocks until the next server-side keepalive arrives (~60 s), stalling the SSE + // client for that duration. Any value >= 4096 bypasses that branch by taking + // BufferedStream's passthrough path; 8192 matches Okio's Segment.SIZE and gives one + // doubling of headroom against future BufferedStream default changes. See SDK-2755. + private const int ReadBufferSize = 8192; private readonly Configuration _configuration; private readonly HttpClient _httpClient; @@ -119,7 +130,8 @@ CancellationToken cancellationToken else { _logger.Debug("Reading stream with string conversion"); - using (var reader = new StreamReader(stream, Encoding.UTF8)) + using (var reader = new StreamReader(stream, Encoding.UTF8, + detectEncodingFromByteOrderMarks: true, bufferSize: ReadBufferSize)) { await ProcessResponseFromReaderAsync(processResponseLineString, reader, cancellationToken); } @@ -169,7 +181,7 @@ protected async Task ProcessResponseFromUtf8StreamAsync( CancellationToken cancellationToken ) { - var lineScanner = new ByteArrayLineScanner(Utf8ReadBufferSize); + var lineScanner = new ByteArrayLineScanner(ReadBufferSize); while (!cancellationToken.IsCancellationRequested) { // Note that even though Stream.ReadAsync has an overload that takes a CancellationToken, that From 81a51186e66b31a1075d6088cb98a2fb16eda457 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Thu, 6 Aug 2026 09:51:37 -0400 Subject: [PATCH 03/14] chore: audit contract-tests capabilities against harness Systematically ran the sse-contract-tests harness against the .NET test service and trimmed / retained capabilities based on actual pass/fail rather than a copy-paste from other language SDKs. Removed: * "comments" -- CommentReceived fires with the raw line including the leading colon (see EventParser.cs). Harness expects the colon stripped. Real .NET EventSource behavior gap; not fixed in this PR. * Corresponding CommentReceived subscription in StreamEntity so we don't leak comment callbacks into tests where the capability is not declared. Kept (each verified end-to-end against the harness): bom, headers, last-event-id, payload-size-stress-testable, post, read-timeout, report, restart. Also documented in a comment block on the Capabilities array the two other harness capabilities intentionally omitted: * "event-type-listeners" -- N/A, MessageReceived fires for all types * "server-directed-shutdown-request" -- .NET retries on 204 instead of halting; see EventSource.cs while-loop condition on Shutdown. Post-audit vs Java (okhttp-eventsource): .NET declares 8 capabilities including "bom" which Java does not; .NET is missing "comments" due to the real behavioral gap noted above. Net coverage is broader than Java, not narrower. --- contract-tests/StreamEntity.cs | 12 +++++------- contract-tests/TestService.cs | 14 +++++++++++++- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/contract-tests/StreamEntity.cs b/contract-tests/StreamEntity.cs index 25b78d4..2100b19 100644 --- a/contract-tests/StreamEntity.cs +++ b/contract-tests/StreamEntity.cs @@ -91,8 +91,12 @@ public StreamEntity(StreamOptions options, ILogAdapter logAdapter) _eventSource = new EventSource(configBuilder.Build()); _eventSource.MessageReceived += OnMessageReceived; - _eventSource.CommentReceived += OnCommentReceived; _eventSource.Error += OnError; + // Deliberately not subscribing to CommentReceived: the "comments" capability is + // not declared because .NET EventSource returns comment strings with the leading + // colon still attached, which does not match the harness's expected shape. If we + // forwarded comments anyway, the harness would receive unexpected comment messages + // in tests that assume no comment reporting. // Fire-and-forget: EventSource fires events on its own internal thread as data arrives. _ = _eventSource.StartAsync(); @@ -113,7 +117,6 @@ public void Close() { _closed = true; _eventSource.MessageReceived -= OnMessageReceived; - _eventSource.CommentReceived -= OnCommentReceived; _eventSource.Error -= OnError; _eventSource.Close(); _callbackClient.Dispose(); @@ -136,11 +139,6 @@ private void OnMessageReceived(object sender, MessageReceivedEventArgs e) SendCallback(msg); } - private void OnCommentReceived(object sender, CommentReceivedEventArgs e) - { - SendCallback(new Message { Kind = "comment", Comment = e.Comment }); - } - private void OnError(object sender, ExceptionEventArgs e) { _logger.Info("Received error from stream: {0}", e.Exception); diff --git a/contract-tests/TestService.cs b/contract-tests/TestService.cs index 63277d5..46c856d 100644 --- a/contract-tests/TestService.cs +++ b/contract-tests/TestService.cs @@ -34,9 +34,21 @@ public static void Main(string[] args) public class Webapp { + // Capabilities the LaunchDarkly.EventSource library implements correctly against the + // sse-contract-tests harness. Each entry here was verified end-to-end by running the + // harness's tests for that capability. Deliberately omitted: + // + // * "comments" -- CommentReceived fires with the raw line including the leading colon; + // the harness expects the colon stripped. See EventParser.cs where colonPos==0 stores + // `line` (with colon) as ValueString. Real .NET SDK behavior; not fixed here. + // * "event-type-listeners" -- not applicable; MessageReceived fires for all event types + // without explicit registration. + // * "server-directed-shutdown-request" -- .NET retries on 204 instead of halting. + // EventSourceService.ValidateResponse throws on 204, EventSource.cs top-level loop + // re-enters on Closed (not Shutdown) state. Real .NET SDK behavior; not fixed here. private static readonly string[] Capabilities = new[] { - "comments", + "bom", "headers", "last-event-id", "payload-size-stress-testable", From 3d0ff37b75c7987f53d92800a33169b7f4166e16 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Thu, 6 Aug 2026 10:42:17 -0400 Subject: [PATCH 04/14] ci: run sse-contract-tests harness against the new test service Wires the contract-tests service added earlier in this PR into CI so we actually validate LaunchDarkly.EventSource's SSE behavior against the sse-contract-tests harness on every push. Without this step the test service was inert -- the code existed but nothing ran the harness against it. Follows the pattern used by launchdarkly/eventsource (Go) and launchdarkly/okhttp-eventsource (Java): a Makefile at repo root with build-contract-tests / start-contract-test-service-bg / contract-tests targets, plus a GitHub Actions job that uses the shared launchdarkly/gh-actions/actions/contract-tests action to download and run a released harness (VERSION=v2, currently resolves to v2.31.0). Once the payload-size-stress-testable capability (defined in sse-contract-tests PR #42) lands in a released v2.x, this job will automatically pick up the sweep on its next run -- no additional CI changes needed. The extra_params skips four pre-existing dotnet-eventsource behavioral gaps unrelated to SDK-2755: null-byte ID handling, CR-only line terminator parsing, CRLF-split-across-chunks handling, and partial-message ID carry-over on reconnect. Filed a follow-up ticket to survey and fix these; once fixed, remove the corresponding --skip. --- .github/workflows/ci.yml | 43 ++++++++++++++++++++++++++++++++++++++++ Makefile | 23 +++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 Makefile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b5aca6..8c73c72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,46 @@ jobs: - name: build docs uses: ./.github/actions/build-docs + + contract-tests: + runs-on: ubuntu-latest + name: 'Contract Tests' + env: + TEST_SERVICE_PORT: 8000 + + steps: + - uses: actions/checkout@v4 + + - name: Setup dotnet build tools + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0 + + - name: Build test service + run: make build-contract-tests + + - name: Start test service in background + run: make start-contract-test-service-bg + + # Skips are for pre-existing dotnet-eventsource behavioral gaps unrelated to + # SDK-2755 (see contract-tests/TestService.cs for the omitted capabilities and + # docs/optional_features.md for what each capability tests). Filing a follow-up + # ticket to fix these in the library will let us remove these skips. + - uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.1.0 + with: + test_service_port: ${{ env.TEST_SERVICE_PORT }} + token: ${{ secrets.GITHUB_TOKEN }} + repo: sse-contract-tests + branch: main + extra_params: >- + -skip "basic parsing/ID field is ignored if it contains a null" + -skip "linefeeds/CR separator" + -skip "linefeeds/CRLF where CR is end of 1 chunk" + -skip "reconnection/discards partial messages on retry" + + - name: Upload test service logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: contract-test-service-logs + path: /tmp/sse-contract-test-service.log diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8d0310a --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +TEMP_TEST_OUTPUT=/tmp/sse-contract-test-service.log + +build-contract-tests: + @cd contract-tests && dotnet build TestService.csproj + +start-contract-test-service: + @cd contract-tests && dotnet run --project TestService.csproj --no-build + +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/main/downloader/run.sh \ + | VERSION=v2 PARAMS="-url http://localhost:8000 -stop-service-at-end \ + -skip 'basic parsing/ID field is ignored if it contains a null' \ + -skip 'linefeeds/CR separator' \ + -skip 'linefeeds/CRLF where CR is end of 1 chunk' \ + -skip 'reconnection/discards partial messages on retry'" sh + +contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests + +.PHONY: build-contract-tests start-contract-test-service start-contract-test-service-bg run-contract-tests contract-tests From 2091c9e07f4d4f41822842e9b4e82c0a04940e80 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Thu, 6 Aug 2026 10:51:03 -0400 Subject: [PATCH 05/14] chore: match case-insensitive comparison on Content-Type lookup The Content-Type key was being skipped from the request-header loop with a case-insensitive OrdinalIgnoreCase match, but retrieved for body construction with a case-sensitive Dictionary lookup. Practical impact is nil because the sse-contract-tests spec guarantees lowercase header keys, but the inconsistency is a code smell and would silently fall back to text/plain if the harness ever sent Content-Type with any capitals. Replace the case-sensitive TryGetValue with the same case-insensitive iteration pattern used for skipping, so both operations agree on what counts as the Content-Type key. --- contract-tests/StreamEntity.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/contract-tests/StreamEntity.cs b/contract-tests/StreamEntity.cs index 2100b19..8fd3f64 100644 --- a/contract-tests/StreamEntity.cs +++ b/contract-tests/StreamEntity.cs @@ -76,10 +76,20 @@ public StreamEntity(StreamOptions options, ILogAdapter logAdapter) configBuilder.Method(new HttpMethod(options.Method)); if (!string.IsNullOrEmpty(options.Body)) { + // Match the same case-insensitive comparison used above when skipping + // Content-Type from the request-header loop, so both operations agree on + // what counts as the Content-Type key. string contentType = "text/plain; charset=utf-8"; - if (options.Headers != null && options.Headers.TryGetValue("content-type", out var ct)) + if (options.Headers != null) { - contentType = ct; + foreach (var kv in options.Headers) + { + if (string.Equals(kv.Key, "content-type", System.StringComparison.OrdinalIgnoreCase)) + { + contentType = kv.Value; + break; + } + } } var bodyString = options.Body; var mediaType = contentType.Split(';')[0].Trim(); From 8bded2dc13293663d3400dc43b8e89456ce59572 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Fri, 7 Aug 2026 09:35:04 -0400 Subject: [PATCH 06/14] chore: split TestService.cs into Program + Webapp for platform reuse Moves Program.Main into its own file so the shared Webapp class (HTTP routing, capability declaration, StreamEntity management) can be hosted by both the existing desktop test service and the forthcoming Android test service. Behavior is unchanged; Webapp itself is untouched apart from a doc-comment refresh. The Android target added in the follow-up commit references TestService.cs, Representations.cs, and StreamEntity.cs by , so having the desktop entry point in a separate Program.cs prevents the Android target from picking up a conflicting Main. --- contract-tests/Program.cs | 29 +++++++++++++++++++++++++++++ contract-tests/TestService.cs | 31 ++++++++----------------------- 2 files changed, 37 insertions(+), 23 deletions(-) create mode 100644 contract-tests/Program.cs diff --git a/contract-tests/Program.cs b/contract-tests/Program.cs new file mode 100644 index 0000000..4f4b9d8 --- /dev/null +++ b/contract-tests/Program.cs @@ -0,0 +1,29 @@ +using System.Threading; +using LaunchDarkly.TestHelpers.HttpTest; + +namespace TestService +{ + /// + /// Desktop / server entry point for the SSE contract-tests service. The Webapp class + /// (in TestService.cs) is shared with the Android target under contract-tests-android/, + /// which has its own Activity-based entry point. + /// + public class Program + { + const int Port = 8000; + + public static void Main(string[] args) + { + var quitSignal = new EventWaitHandle(false, EventResetMode.AutoReset); + + var app = new Webapp(quitSignal); + var server = HttpServer.Start(Port, app.Handler); + server.Recorder.Enabled = false; + + System.Console.WriteLine("Listening on port {0}", Port); + + quitSignal.WaitOne(); + server.Dispose(); + } + } +} diff --git a/contract-tests/TestService.cs b/contract-tests/TestService.cs index 46c856d..e09b752 100644 --- a/contract-tests/TestService.cs +++ b/contract-tests/TestService.cs @@ -8,30 +8,15 @@ namespace TestService { /// - /// HTTP entry point for the SSE contract-tests service. Implements the endpoints described in - /// launchdarkly/sse-contract-tests/docs/service_spec.md for the LaunchDarkly.EventSource - /// library. This is the .NET analogue of ssetest.TestService in okhttp-eventsource's - /// contract-tests service. + /// HTTP application logic for the SSE contract-tests service. Implements the endpoints + /// described in launchdarkly/sse-contract-tests/docs/service_spec.md for the + /// LaunchDarkly.EventSource library. This is the .NET analogue of ssetest.TestService in + /// okhttp-eventsource's contract-tests service. + /// + /// The entry point that starts the HTTP server lives in Program.cs (desktop) and in + /// ../contract-tests-android/MainActivity.cs (Android). Both instantiate this Webapp and + /// hand its Handler to HttpServer.Start; the routing logic here is platform-agnostic. /// - public class Program - { - const int Port = 8000; - - public static void Main(string[] args) - { - var quitSignal = new EventWaitHandle(false, EventResetMode.AutoReset); - - var app = new Webapp(quitSignal); - var server = HttpServer.Start(Port, app.Handler); - server.Recorder.Enabled = false; - - System.Console.WriteLine("Listening on port {0}", Port); - - quitSignal.WaitOne(); - server.Dispose(); - } - } - public class Webapp { // Capabilities the LaunchDarkly.EventSource library implements correctly against the From ba7525ab1ed0db1b62788728a56ff08e31b49f2c Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Fri, 7 Aug 2026 09:35:34 -0400 Subject: [PATCH 07/14] ci: add Android contract-tests job Adds a .NET for Android build of the SSE contract-tests service and a new GitHub Actions job that runs the sse-contract-tests harness against it on an emulator. Fills a validation gap the desktop Contract Tests job cannot cover: the SDK-2755 fix defends against a bug specific to Xamarin.Android.Net.AndroidMessageHandler's BufferedStream wrap, which sits below the response-body Stream only on Android; desktop uses SocketsHttpHandler and cannot reproduce that code path. Structure follows launchdarkly/android-client-sdk's Android CI pattern: - Fragile adb glue (install / launch / port-forward / wait) lives in scripts/start-android-test-service.sh so the workflow's script: block stays a two-line call to make. - Emulator-runner options (google_apis target, KVM-friendly emulator flags, disable-animations, SHA-pinned action) copied from that repo. - runs-on: ubuntu-latest because GH Linux runners have /dev/kvm available for hardware acceleration; macOS mixed Intel/Apple-Silicon fleets would run x86_64 emulator images under slower nested virtualization. Emulator is booted with -memory 8192; the sweep exercises payloads up to 128 MiB which peak at ~500 MB of managed heap through UTF-16 decoding and JSON serialization, exceeding the 2 GB default emulator RAM. android:largeHeap=true in the manifest raises the per-app dalvik heap ceiling from ~192 MB to ~512 MB to accommodate this. Android-only test skips live in contract-tests-android/testharness-suppressions.txt, read by the Makefile via a plain while-loop into inline -skip arguments (the harness does not yet support -skip-from). The file records two categories of skip: the pre-existing LaunchDarkly.EventSource behavior gaps also skipped in the desktop job, and two Android-specific ones -- REPORT method (Java HttpURLConnection whitelist rejects it) and one reconnection test hitting a JNI ref-counting bug in Android.Runtime.InputStreamInvoker.Dispose. Both are documented in the PR description; neither is fixable inside LaunchDarkly.EventSource. Locally validated end-to-end against my Android emulator: all 80 payload-sweep sizes pass, 6 tests skipped per the suppressions file, 1 auto-skipped for the un-declared "comments" capability, zero failures. --- .github/workflows/ci.yml | 75 +++++++++++++++++++ Makefile | 33 +++++++- .../ContractTestService.Android.csproj | 45 +++++++++++ contract-tests-android/MainActivity.cs | 54 +++++++++++++ .../Properties/AndroidManifest.xml | 9 +++ .../testharness-suppressions.txt | 6 ++ scripts/start-android-test-service.sh | 72 ++++++++++++++++++ 7 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 contract-tests-android/ContractTestService.Android.csproj create mode 100644 contract-tests-android/MainActivity.cs create mode 100644 contract-tests-android/Properties/AndroidManifest.xml create mode 100644 contract-tests-android/testharness-suppressions.txt create mode 100755 scripts/start-android-test-service.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c73c72..a5fd10c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,3 +82,78 @@ jobs: with: name: contract-test-service-logs path: /tmp/sse-contract-test-service.log + + contract-tests-android: + # Runs the sse-contract-tests harness against a .NET for Android build of the same test + # service running on an emulator. This is the code path that reproduces the SDK-2755 + # streaming stall (HttpClient -> Xamarin.Android.Net.AndroidMessageHandler -> BufferedStream + # -> Java InputStream). The desktop Contract Tests job above cannot catch regressions in + # this class of bug because SocketsHttpHandler is on the response-body path on desktop, + # not AndroidMessageHandler. + # + # Runs on ubuntu-latest because GitHub Actions Linux runners have KVM support enabled, + # which gives us direct hardware acceleration of the x86_64 Android emulator. macOS + # runners in mixed Intel/Apple-Silicon fleets would run x86_64 emulator images under a + # slower nested-virtualization path. + runs-on: ubuntu-latest + name: 'Contract Tests (Android)' + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 9 SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0 + + - name: Install .NET Android workload + run: dotnet workload install android + + # GitHub's ubuntu-latest runners have /dev/kvm available but locked to root/the kvm + # group; the CI user can't access it out of the box. Without this permission, + # reactivecircus/android-emulator-runner can't hardware-accelerate the emulator via + # KVM and either fails or falls back to prohibitively slow software emulation. + # This udev rule opens /dev/kvm to all users, matching the pattern established in + # launchdarkly/android-client-sdk/.github/workflows/ci.yml. + - name: Enable KVM permissions + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Build Android test service + run: make build-contract-tests-android + + # Emulator-options and other flags follow the pattern established in + # launchdarkly/android-client-sdk/.github/actions/ci/action.yml, which is what LD's + # Android SDK uses for its own contract-test CI. The action version is SHA-pinned + # for supply-chain safety. + - name: Run harness against Android emulator + uses: reactivecircus/android-emulator-runner@6b0df4b0efb23bb0ec63d881db79aefbc976e4b2 # 2.30.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + api-level: 34 + target: google_apis + arch: x86_64 + emulator-boot-timeout: 900 + # -memory 8192 gives the emulator 8 GB of RAM so it can host the test service + # while processing the largest payloads in the sweep (up to 128 MiB, which peaks + # at ~500 MB of Java heap through UTF-16 decoding + JSON serialization). Default + # emulator RAM (2 GB) triggers the low-memory-killer on those sizes; empirically + # 8 GB is comfortable. GitHub's ubuntu-latest runners have 16 GB total RAM. + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -memory 8192 + disable-animations: true + script: | + make start-contract-test-service-android + make run-contract-tests-android + + - name: Upload Android service logs + if: failure() + run: adb logcat -d "DOTNET:V" "ContractTestService:V" "*:S" > /tmp/android-service.log 2>&1 || true + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: android-contract-test-logs + path: /tmp/android-service.log diff --git a/Makefile b/Makefile index 8d0310a..9af7919 100644 --- a/Makefile +++ b/Makefile @@ -20,4 +20,35 @@ run-contract-tests: contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests -.PHONY: build-contract-tests start-contract-test-service start-contract-test-service-bg run-contract-tests contract-tests +# ---- Android contract tests ---- +# Requires: an Android emulator or device connected via adb, and the .NET 9 SDK with the +# android workload installed. See ci.yml's contract-tests-android job for how CI does this. + +build-contract-tests-android: + @cd contract-tests-android && dotnet build ContractTestService.Android.csproj -c Debug + +# Installs the built APK, launches the MainActivity, waits for the app to be listening, +# and forwards the local port to the emulator. All adb glue is in scripts/ so the CI +# workflow's script: block can stay a two-line call to make. +start-contract-test-service-android: + @scripts/start-android-test-service.sh + +# -host 10.0.2.2 makes callback URLs the harness gives the test service point at the +# emulator's alias for the host machine (see Android emulator docs). Without this the +# service can't POST callbacks back through adb. +# +# The harness only supports inline -skip; we read the Android suppressions file line +# by line and translate each line into a `-skip 'X'` argument. +ANDROID_SUPPRESSIONS = contract-tests-android/testharness-suppressions.txt + +run-contract-tests-android: + @SKIP_ARGS=""; \ + while IFS= read -r line; do SKIP_ARGS="$$SKIP_ARGS -skip '$$line'"; done < $(ANDROID_SUPPRESSIONS); \ + curl $${GITHUB_TOKEN:+ -H "Authorization: Token $${GITHUB_TOKEN}"} \ + -s https://raw.githubusercontent.com/launchdarkly/sse-contract-tests/main/downloader/run.sh \ + | VERSION=v2 PARAMS="-url http://localhost:8000 -host 10.0.2.2 -stop-service-at-end $$SKIP_ARGS" sh + +contract-tests-android: build-contract-tests-android start-contract-test-service-android run-contract-tests-android + +.PHONY: build-contract-tests start-contract-test-service start-contract-test-service-bg run-contract-tests contract-tests \ + build-contract-tests-android start-contract-test-service-android run-contract-tests-android contract-tests-android diff --git a/contract-tests-android/ContractTestService.Android.csproj b/contract-tests-android/ContractTestService.Android.csproj new file mode 100644 index 0000000..5d1b74b --- /dev/null +++ b/contract-tests-android/ContractTestService.Android.csproj @@ -0,0 +1,45 @@ + + + + net9.0-android + 21 + Exe + ContractTestService.Android + ContractTestService.Android + com.launchdarkly.contracttestservice + 1 + 1.0 + false + false + false + false + false + false + disable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/contract-tests-android/MainActivity.cs b/contract-tests-android/MainActivity.cs new file mode 100644 index 0000000..44a0d96 --- /dev/null +++ b/contract-tests-android/MainActivity.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Android.App; +using Android.OS; +using LaunchDarkly.TestHelpers.HttpTest; +using TestService; + +namespace ContractTestService.Android +{ + /// + /// Android entry point for the SSE contract-tests service. Starts the same Webapp used by + /// the desktop test service (contract-tests/) on a background thread when the activity + /// launches. The HTTP endpoints, capabilities list, and routing logic are all defined by + /// the shared Webapp class in TestService.cs -- this Activity only takes care of process + /// hosting on Android. + /// + /// The purpose of exercising the contract tests through this Android app is to route SSE + /// stream reads through AndroidMessageHandler + BufferedStream + the + /// Java InputStream chain, which is the platform-specific path where SDK-2755's stall bug + /// manifests. Running the harness against this app on an emulator is how we protect that + /// fix from regression. + /// + [Activity(Label = "ContractTestService", MainLauncher = true)] + public class MainActivity : Activity + { + const int Port = 8000; + const string LogTag = "ContractTestService"; + + protected override void OnCreate(Bundle savedInstanceState) + { + base.OnCreate(savedInstanceState); + Task.Run(RunHttpServer); + } + + private void RunHttpServer() + { + try + { + var quitSignal = new EventWaitHandle(false, EventResetMode.AutoReset); + var app = new Webapp(quitSignal); + var server = HttpServer.Start(Port, app.Handler); + server.Recorder.Enabled = false; + global::Android.Util.Log.Info(LogTag, $"Listening on port {Port}"); + quitSignal.WaitOne(); + server.Dispose(); + } + catch (Exception e) + { + global::Android.Util.Log.Error(LogTag, $"HTTP server failed: {e}"); + } + } + } +} diff --git a/contract-tests-android/Properties/AndroidManifest.xml b/contract-tests-android/Properties/AndroidManifest.xml new file mode 100644 index 0000000..08c92b5 --- /dev/null +++ b/contract-tests-android/Properties/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/contract-tests-android/testharness-suppressions.txt b/contract-tests-android/testharness-suppressions.txt new file mode 100644 index 0000000..bf84274 --- /dev/null +++ b/contract-tests-android/testharness-suppressions.txt @@ -0,0 +1,6 @@ +basic parsing/ID field is ignored if it contains a null +linefeeds/CR separator +linefeeds/CRLF where CR is end of 1 chunk +reconnection/discards partial messages on retry +HTTP behavior/REPORT request +reconnection/caller can trigger a restart diff --git a/scripts/start-android-test-service.sh b/scripts/start-android-test-service.sh new file mode 100755 index 0000000..786e8ed --- /dev/null +++ b/scripts/start-android-test-service.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +# Starts the contract-test service in a connected Android emulator. +# +# This script assumes adb is on PATH and an emulator is already running. It installs the +# APK, launches the MainActivity, waits for the app process to appear, forwards the local +# port to the emulator, and exits. Modeled on android-client-sdk/scripts/start-test-service.sh. +# +# Optional environment variables: +# LOCAL_PORT: host-side port that will forward into the emulator (default 8000) +# ANDROID_PORT: emulator port for adb targeting (default 5554) +# APK_PATH: path to the debug APK to install +# APP_ID: Android application ID (must match the ApplicationId in the .csproj) + +set -eo pipefail + +LOCAL_PORT=${LOCAL_PORT:-8000} +ANDROID_PORT=${ANDROID_PORT:-5554} +SERIAL_NUMBER=emulator-${ANDROID_PORT} +APP_ID=${APP_ID:-com.launchdarkly.contracttestservice} +APK_PATH=${APK_PATH:-contract-tests-android/bin/Debug/net9.0-android/${APP_ID}-Signed.apk} + +if [ ! -f "$APK_PATH" ]; then + # Fall back to the unsigned APK if the signed one isn't present -- .NET for Android emits + # -Signed.apk under some configurations and a bare .apk under others. + APK_PATH=$(find contract-tests-android/bin/Debug/net9.0-android -name '*.apk' -not -name '*Signed*' | head -1) + if [ -z "$APK_PATH" ] || [ ! -f "$APK_PATH" ]; then + echo "No APK found under contract-tests-android/bin/Debug/net9.0-android/" >&2 + exit 1 + fi +fi + +echo "Installing $APK_PATH to $SERIAL_NUMBER" +adb -s "$SERIAL_NUMBER" install -t -r -d "$APK_PATH" + +# The MainActivity's fully qualified name includes a compiler-generated crc64 namespace +# hash, so we discover it via dumpsys rather than hardcoding. +ACTIVITY=$(adb -s "$SERIAL_NUMBER" shell dumpsys package "$APP_ID" | grep -oE "${APP_ID}/[a-zA-Z0-9._]+MainActivity" | head -1) +if [ -z "$ACTIVITY" ]; then + echo "Could not find MainActivity for $APP_ID via dumpsys" >&2 + exit 1 +fi +echo "Launching $ACTIVITY" +adb -s "$SERIAL_NUMBER" shell am start -n "$ACTIVITY" + +# Wait for the app process to be running before setting up port forwarding. +APP_PID="" +TIMEFORMAT='App started in %R seconds' +time { + for _ in $(seq 1 30); do + APP_PID=$(adb -s "$SERIAL_NUMBER" shell pidof -s "$APP_ID" 2>/dev/null || true) + if [ -n "$APP_PID" ]; then break; fi + sleep 1 + done +} +if [ -z "$APP_PID" ]; then + echo "App process $APP_ID did not appear within 30 seconds" >&2 + exit 1 +fi + +adb -s "$SERIAL_NUMBER" forward "tcp:$LOCAL_PORT" "tcp:$LOCAL_PORT" + +# Wait for the HTTP server inside the app to bind port 8000. +for _ in $(seq 1 30); do + if curl -sS "http://localhost:$LOCAL_PORT/" >/dev/null 2>&1; then + echo "Test service listening on localhost:$LOCAL_PORT (forwarded to emulator)" + exit 0 + fi + sleep 1 +done +echo "Test service did not respond on localhost:$LOCAL_PORT after app launch" >&2 +exit 1 From 18433535c11513bee68ffcf360a714fefa5d715e Mon Sep 17 00:00:00 2001 From: Todd Anderson <127344469+tanderson-ld@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:37:25 -0400 Subject: [PATCH 08/14] Apply suggestion from @jsonbailey Co-authored-by: Jason Bailey --- contract-tests/README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/contract-tests/README.md b/contract-tests/README.md index 1b8655f..8dcf4c7 100644 --- a/contract-tests/README.md +++ b/contract-tests/README.md @@ -2,10 +2,6 @@ This is a small HTTP service that wraps `LaunchDarkly.EventSource` and exposes it to the [`sse-contract-tests`](https://github.com/launchdarkly/sse-contract-tests) harness. -It is the .NET analogue of the `contract-tests/` services in -[`eventsource`](https://github.com/launchdarkly/eventsource) (Go), -[`js-eventsource`](https://github.com/launchdarkly/js-eventsource) (JavaScript), and -[`okhttp-eventsource`](https://github.com/launchdarkly/okhttp-eventsource) (Java). The service is not shipped as part of any published NuGet package. It exists purely as a test target for the `sse-contract-tests` harness. From 320e40a2254d69f6e2e371e48f0409f14ea4195d Mon Sep 17 00:00:00 2001 From: Todd Anderson <127344469+tanderson-ld@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:37:39 -0400 Subject: [PATCH 09/14] Apply suggestion from @jsonbailey Co-authored-by: Jason Bailey --- contract-tests/StreamEntity.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/contract-tests/StreamEntity.cs b/contract-tests/StreamEntity.cs index 8fd3f64..6351147 100644 --- a/contract-tests/StreamEntity.cs +++ b/contract-tests/StreamEntity.cs @@ -14,12 +14,6 @@ namespace TestService /// harness. Subscribes to the SSE client's events and forwards each one to the harness callback /// URL as a JSON message. /// - /// - /// This is the .NET analogue of ssetest.StreamEntity in okhttp-eventsource's contract-tests - /// service. The main structural difference: .NET's EventSource is push-based (fires event - /// handlers on its own internal thread), so we don't need a background reader thread the way - /// the Java service does around its blocking event iterator. - /// public class StreamEntity { private readonly StreamOptions _options; From d16e0776f95a6aedf315583062622ee544bf7af6 Mon Sep 17 00:00:00 2001 From: Todd Anderson <127344469+tanderson-ld@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:38:56 -0400 Subject: [PATCH 10/14] Update .github/workflows/ci.yml Co-authored-by: Jason Bailey --- .github/workflows/ci.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5fd10c..eb9d405 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,10 +60,7 @@ jobs: - name: Start test service in background run: make start-contract-test-service-bg - # Skips are for pre-existing dotnet-eventsource behavioral gaps unrelated to - # SDK-2755 (see contract-tests/TestService.cs for the omitted capabilities and - # docs/optional_features.md for what each capability tests). Filing a follow-up - # ticket to fix these in the library will let us remove these skips. + # Skips are for pre-existing dotnet-eventsource behavioral gaps. - uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.1.0 with: test_service_port: ${{ env.TEST_SERVICE_PORT }} From c85d1507a742c7b5a60020005a020a898ea2689d Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Fri, 7 Aug 2026 09:38:47 -0400 Subject: [PATCH 11/14] chore: tighten SDK-2755 buffer-size comment Trim the 11-line rationale to 4. The essentials are: (a) must be >= 4096, (b) reason is AndroidMessageHandler's BufferedStream wrap, (c) failure mode is ~60 s stalls, (d) SDK-2755 for context. Anyone wanting the full mechanism has the ticket link. --- src/LaunchDarkly.EventSource/EventSourceService.cs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/LaunchDarkly.EventSource/EventSourceService.cs b/src/LaunchDarkly.EventSource/EventSourceService.cs index 1dfc3c7..393c4e0 100644 --- a/src/LaunchDarkly.EventSource/EventSourceService.cs +++ b/src/LaunchDarkly.EventSource/EventSourceService.cs @@ -17,17 +17,9 @@ internal class EventSourceService { #region Private Fields - // Buffer size for both the StreamReader (text mode) and the ByteArrayLineScanner - // (PreferDataAsUtf8Bytes mode). Chosen as 8192 rather than a smaller value because - // Xamarin.Android.Net.AndroidMessageHandler silently wraps HTTP response bodies in a - // System.IO.BufferedStream whose default internal buffer is 4096 bytes. When a caller - // passes count < 4096 to Stream.Read, BufferedStream engages its internal-buffer - // branch and issues a 4096-byte read against the underlying Java InputStream. On an - // SSE keepalive-driven connection, once the first HTTP chunk is drained, the follow-up - // read blocks until the next server-side keepalive arrives (~60 s), stalling the SSE - // client for that duration. Any value >= 4096 bypasses that branch by taking - // BufferedStream's passthrough path; 8192 matches Okio's Segment.SIZE and gives one - // doubling of headroom against future BufferedStream default changes. See SDK-2755. + // Must be >= 4096 to bypass Xamarin.Android.Net.AndroidMessageHandler's + // BufferedStream-4096 small-count code path, which stalls SSE reads for + // ~60 s waiting for the next server keepalive. See SDK-2755. private const int ReadBufferSize = 8192; private readonly Configuration _configuration; From c2387723009d623f6cbb8bd524b2428480eaaa5e Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Fri, 7 Aug 2026 09:46:27 -0400 Subject: [PATCH 12/14] chore: drop cross-SDK pattern references from comments Removes comments that cited other LaunchDarkly repos (android-client-sdk, okhttp-eventsource) as inspiration or precedent. Cross-repo provenance is noise in the code and can rot if the referenced patterns change or disappear upstream. The parallels are still there for anyone who looks for them; the code should stand on its own. --- .github/workflows/ci.yml | 13 +++---------- contract-tests/TestService.cs | 3 +-- scripts/start-android-test-service.sh | 2 +- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb9d405..d4db1e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,12 +106,8 @@ jobs: - name: Install .NET Android workload run: dotnet workload install android - # GitHub's ubuntu-latest runners have /dev/kvm available but locked to root/the kvm - # group; the CI user can't access it out of the box. Without this permission, - # reactivecircus/android-emulator-runner can't hardware-accelerate the emulator via - # KVM and either fails or falls back to prohibitively slow software emulation. - # This udev rule opens /dev/kvm to all users, matching the pattern established in - # launchdarkly/android-client-sdk/.github/workflows/ci.yml. + # /dev/kvm is present on ubuntu-latest runners but locked to root/the kvm group; + # this udev rule opens it to all users so the emulator step can hardware-accelerate. - name: Enable KVM permissions run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules @@ -121,10 +117,7 @@ jobs: - name: Build Android test service run: make build-contract-tests-android - # Emulator-options and other flags follow the pattern established in - # launchdarkly/android-client-sdk/.github/actions/ci/action.yml, which is what LD's - # Android SDK uses for its own contract-test CI. The action version is SHA-pinned - # for supply-chain safety. + # Action version is SHA-pinned for supply-chain safety. - name: Run harness against Android emulator uses: reactivecircus/android-emulator-runner@6b0df4b0efb23bb0ec63d881db79aefbc976e4b2 # 2.30.1 env: diff --git a/contract-tests/TestService.cs b/contract-tests/TestService.cs index e09b752..2950631 100644 --- a/contract-tests/TestService.cs +++ b/contract-tests/TestService.cs @@ -10,8 +10,7 @@ namespace TestService /// /// HTTP application logic for the SSE contract-tests service. Implements the endpoints /// described in launchdarkly/sse-contract-tests/docs/service_spec.md for the - /// LaunchDarkly.EventSource library. This is the .NET analogue of ssetest.TestService in - /// okhttp-eventsource's contract-tests service. + /// LaunchDarkly.EventSource library. /// /// The entry point that starts the HTTP server lives in Program.cs (desktop) and in /// ../contract-tests-android/MainActivity.cs (Android). Both instantiate this Webapp and diff --git a/scripts/start-android-test-service.sh b/scripts/start-android-test-service.sh index 786e8ed..ab41cbe 100755 --- a/scripts/start-android-test-service.sh +++ b/scripts/start-android-test-service.sh @@ -4,7 +4,7 @@ # # This script assumes adb is on PATH and an emulator is already running. It installs the # APK, launches the MainActivity, waits for the app process to appear, forwards the local -# port to the emulator, and exits. Modeled on android-client-sdk/scripts/start-test-service.sh. +# port to the emulator, and exits. # # Optional environment variables: # LOCAL_PORT: host-side port that will forward into the emulator (default 8000) From dc1fc75285b8c0b13298479e50271d1b006c9955 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Fri, 7 Aug 2026 11:41:16 -0400 Subject: [PATCH 13/14] ci: fix Android job -- pre-create AVD dir, capture logs in-scope Two issues from the first CI runs: 1. avdmanager create avd fails on ubuntu-latest with "cannot create /home/runner/.android/avd/test.avd/config.ini: Directory nonexistent" because ~/.android/avd doesn't exist on a fresh runner. Add a mkdir -p step before the emulator-runner. 2. The failure-only 'adb logcat -d' step hangs indefinitely because adb-server has no device to talk to after the emulator-runner tears down its emulator. Move log capture inside the runner's script block (background adb logcat to a file); the file persists on the runner filesystem after emulator teardown and can be uploaded by upload-artifact without touching adb again. --- .github/workflows/ci.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4db1e2..ca5ad57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,6 +106,11 @@ jobs: - name: Install .NET Android workload run: dotnet workload install android + # avdmanager fails to create the AVD if this directory doesn't already exist: + # "cannot create /home/runner/.android/avd/test.avd/config.ini: Directory nonexistent" + - name: Ensure AVD directory exists + run: mkdir -p ~/.android/avd + # /dev/kvm is present on ubuntu-latest runners but locked to root/the kvm group; # this udev rule opens it to all users so the emulator step can hardware-accelerate. - name: Enable KVM permissions @@ -134,14 +139,14 @@ jobs: # 8 GB is comfortable. GitHub's ubuntu-latest runners have 16 GB total RAM. emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -memory 8192 disable-animations: true + # Log capture runs in the background inside the emulator-runner scope so it can + # reach the emulator via adb; the resulting file persists on the runner filesystem + # after emulator teardown and is uploaded as an artifact on failure. script: | + adb logcat "DOTNET:V" "ContractTestService:V" "*:S" > /tmp/android-service.log 2>&1 & make start-contract-test-service-android make run-contract-tests-android - - name: Upload Android service logs - if: failure() - run: adb logcat -d "DOTNET:V" "ContractTestService:V" "*:S" > /tmp/android-service.log 2>&1 || true - - uses: actions/upload-artifact@v4 if: failure() with: From 6f57f61c5fcc4e6d672c3aea525e6c5fbbab1a2d Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Fri, 7 Aug 2026 11:47:52 -0400 Subject: [PATCH 14/14] ci: skip basic parsing/large message in two chunks on Android The test sends a 5 MiB + 5 MiB payload and uses the harness's default 5-second RequireEvent timeout (not the size-scaled RequireEventWithin used by the payload-size sweep). At 10 MiB combined, GH Actions Android emulator's adb-tunneled throughput doesn't finish the round-trip within 5s, so a correct implementation times out. The single-chunk 5 MiB variant passes on the same runner, and the payload-size sweep covers up to 128 MiB independently with its own timeout formula. Losing this specific test on Android doesn't meaningfully reduce coverage. --- contract-tests-android/testharness-suppressions.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/contract-tests-android/testharness-suppressions.txt b/contract-tests-android/testharness-suppressions.txt index bf84274..fc25396 100644 --- a/contract-tests-android/testharness-suppressions.txt +++ b/contract-tests-android/testharness-suppressions.txt @@ -1,4 +1,5 @@ basic parsing/ID field is ignored if it contains a null +basic parsing/large message in two chunks linefeeds/CR separator linefeeds/CRLF where CR is end of 1 chunk reconnection/discards partial messages on retry