-
Notifications
You must be signed in to change notification settings - Fork 19
fix: SSE streaming stall on MAUI Android (SDK-2755) #124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
fc46ea5
chore: add SSE contract-tests service
tanderson-ld cdd255a
fix: increase SSE read buffer to 8192 bytes to avoid Android streamin…
tanderson-ld 81a5118
chore: audit contract-tests capabilities against harness
tanderson-ld 3d0ff37
ci: run sse-contract-tests harness against the new test service
tanderson-ld 2091c9e
chore: match case-insensitive comparison on Content-Type lookup
tanderson-ld 8bded2d
chore: split TestService.cs into Program + Webapp for platform reuse
tanderson-ld ba7525a
ci: add Android contract-tests job
tanderson-ld 1843353
Apply suggestion from @jsonbailey
tanderson-ld 320e40a
Apply suggestion from @jsonbailey
tanderson-ld d16e077
Update .github/workflows/ci.yml
tanderson-ld c85d150
chore: tighten SDK-2755 buffer-size comment
tanderson-ld c238772
chore: drop cross-SDK pattern references from comments
tanderson-ld dc1fc75
ci: fix Android job -- pre-create AVD dir, capture logs in-scope
tanderson-ld 6f57f61
ci: skip basic parsing/large message in two chunks on Android
tanderson-ld File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <pattern>` or `--skip <pattern>`. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| 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 | ||
| { | ||
| /// <summary> | ||
| /// 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. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// 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. | ||
| /// </remarks> | ||
|
tanderson-ld marked this conversation as resolved.
Outdated
|
||
| 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; | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| 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); | ||
| } | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| using System.Collections.Concurrent; | ||
| using System.Net.Http; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
| using LaunchDarkly.Logging; | ||
| using LaunchDarkly.TestHelpers.HttpTest; | ||
|
|
||
| namespace TestService | ||
| { | ||
| /// <summary> | ||
| /// 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. | ||
| /// </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(); | ||
| } | ||
| } | ||
|
|
||
| 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<string, StreamEntity> _streams = | ||
| new ConcurrentDictionary<string, StreamEntity>(); | ||
| 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<StreamOptions>(HttpMethod.Post, "/", PostCreateStream); | ||
| service.Route<CommandParams>(HttpMethod.Post, "/streams/(.*)", PostStreamCommand); | ||
| service.Route(HttpMethod.Delete, "/streams/(.*)", DeleteStream); | ||
| } | ||
|
|
||
| SimpleResponse<Status> 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); | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.