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