Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
cursor[bot] marked this conversation as resolved.
54 changes: 54 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions contract-tests-android/ContractTestService.Android.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net9.0-android</TargetFramework>
<SupportedOSPlatformVersion>21</SupportedOSPlatformVersion>
<OutputType>Exe</OutputType>
<RootNamespace>ContractTestService.Android</RootNamespace>
<AssemblyName>ContractTestService.Android</AssemblyName>
<ApplicationId>com.launchdarkly.contracttestservice</ApplicationId>
<ApplicationVersion>1</ApplicationVersion>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<GenerateAssemblyTitleAttribute>false</GenerateAssemblyTitleAttribute>
<GenerateAssemblyDescriptionAttribute>false</GenerateAssemblyDescriptionAttribute>
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
<GenerateAssemblyCopyrightAttribute>false</GenerateAssemblyCopyrightAttribute>
<Nullable>disable</Nullable>
<!-- Disable Fast Deployment and embed assemblies into the APK. Fast Deployment ships
.NET assemblies via adb sync into a __override__ directory at runtime; if the sync
hasn't happened when the app launches, it aborts with "No assemblies found in ...".
For CI (fresh APK, single-shot install-and-run), we want everything in the APK. -->
<AndroidFastDeployment>false</AndroidFastDeployment>
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="LaunchDarkly.TestHelpers" Version="2.0.0" />
<PackageReference Include="LaunchDarkly.Logging" Version="2.0.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\src\LaunchDarkly.EventSource\LaunchDarkly.EventSource.csproj" />
</ItemGroup>

<!-- The Android host and desktop host share the SSE contract-test service application
logic. Only the entry point (Program.cs desktop / MainActivity.cs Android) differs
between the two targets. -->
<ItemGroup>
<Compile Include="..\contract-tests\Representations.cs" Link="Representations.cs" />
<Compile Include="..\contract-tests\StreamEntity.cs" Link="StreamEntity.cs" />
<Compile Include="..\contract-tests\TestService.cs" Link="TestService.cs" />
</ItemGroup>

</Project>
54 changes: 54 additions & 0 deletions contract-tests-android/MainActivity.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
[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}");
}
}
}
}
9 changes: 9 additions & 0 deletions contract-tests-android/Properties/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- largeHeap gives the app the ~512 MB per-process heap limit instead of the default
~192 MB. Needed for the payload-size sweep in sse-contract-tests to exercise the
largest sizes (up to 128 MiB payloads inflate to ~500+ MB peak footprint through
UTF-16 decoding and JSON serialization). -->
<application android:label="ContractTestService" android:usesCleartextTraffic="true" android:largeHeap="true" />
</manifest>
7 changes: 7 additions & 0 deletions contract-tests-android/testharness-suppressions.txt
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions contract-tests/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.Threading;
using LaunchDarkly.TestHelpers.HttpTest;

namespace TestService
{
/// <summary>
/// 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.
/// </summary>
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();
}
}
}
28 changes: 28 additions & 0 deletions contract-tests/README.md
Original file line number Diff line number Diff line change
@@ -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 <pattern>` or `--skip <pattern>`.
46 changes: 46 additions & 0 deletions contract-tests/Representations.cs
Original file line number Diff line number Diff line change
@@ -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<string, string> 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; }
}
}
Loading
Loading