diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5b5aca6..ca5ad57 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -39,3 +39,116 @@ 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.
+ - 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
+
+ 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
+
+ # 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
+ 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
+
+ # 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
+ # 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
+
+ - uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: android-contract-test-logs
+ path: /tmp/android-service.log
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..9af7919
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,54 @@
+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
+
+# ---- 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..fc25396
--- /dev/null
+++ b/contract-tests-android/testharness-suppressions.txt
@@ -0,0 +1,7 @@
+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
+HTTP behavior/REPORT request
+reconnection/caller can trigger a restart
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/README.md b/contract-tests/README.md
new file mode 100644
index 0000000..8dcf4c7
--- /dev/null
+++ b/contract-tests/README.md
@@ -0,0 +1,28 @@
+# 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.
+
+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..6351147
--- /dev/null
+++ b/contract-tests/StreamEntity.cs
@@ -0,0 +1,177 @@
+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.
+ ///
+ 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))
+ {
+ // 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)
+ {
+ 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();
+ var charset = Encoding.UTF8;
+ configBuilder.RequestBodyFactory(() =>
+ new StringContent(bodyString, charset, mediaType));
+ }
+ }
+
+ _eventSource = new EventSource(configBuilder.Build());
+ _eventSource.MessageReceived += OnMessageReceived;
+ _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();
+ }
+
+ 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.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 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..2950631
--- /dev/null
+++ b/contract-tests/TestService.cs
@@ -0,0 +1,122 @@
+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 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.
+ ///
+ /// 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 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[]
+ {
+ "bom",
+ "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
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/start-android-test-service.sh b/scripts/start-android-test-service.sh
new file mode 100755
index 0000000..ab41cbe
--- /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.
+#
+# 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
diff --git a/src/LaunchDarkly.EventSource/EventSourceService.cs b/src/LaunchDarkly.EventSource/EventSourceService.cs
index 7e2ff5c..393c4e0 100644
--- a/src/LaunchDarkly.EventSource/EventSourceService.cs
+++ b/src/LaunchDarkly.EventSource/EventSourceService.cs
@@ -17,7 +17,10 @@ internal class EventSourceService
{
#region Private Fields
- private const int Utf8ReadBufferSize = 1000;
+ // 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;
private readonly HttpClient _httpClient;
@@ -119,7 +122,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 +173,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