Skip to content
Open
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
2 changes: 2 additions & 0 deletions Assets/Plugins/StreamChat/Changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ Fixes:
Unreleased:
Features:

* Add IWebsocketClient.QueuedMessageCount, the number of received messages waiting to be dequeued. Receiving runs on a background timer thread while the SDK dequeues from Unity's main loop, so this reports how far behind the main loop is. Diagnostic only - the transport never drops messages. Note: if you supply your own IWebsocketClient implementation you will need to add this member.
* Add a public StreamApiException constructor (statusCode, code, errorMessage, moreInfo, duration, exceptionFields). StreamApiException is a public, catch-and-branch type (via the StreamApiExceptionExtensions.Is* helpers), but until now it could only be constructed inside the SDK from the internal APIErrorInternalDTO, so integrators could not build one to unit-test their own error handling (e.g. simulating a 403 / code 70 "no access to channels" response). The new constructor maps directly to the type's public properties and keeps APIErrorInternalDTO internal.
* Add IStreamClientConfig.OptimisticMessageInsert (default true). When true (the existing behavior), a message you send is inserted into the local channel state and raised via IStreamChannel.MessageReceived immediately, before the server's message.new echo arrives. Set it to false to skip the optimistic local insert and wait for the server echo instead, so every participant - including the sender - observes messages in the same server-defined order. Useful when consistent cross-client ordering matters more than instant local feedback (e.g. a shared, broadcast-ordered feed).

Fixes:

* Fix a multi-second frame stall when an app resumes from the background during chat traffic. Received websocket messages are handled only from Unity's main loop, which the OS stops while the app is backgrounded, so the whole accumulated backlog was handled in the first frame after resume - as was the entire /sync missed-event replay the reconnect fetches, which was handled inline in one loop. Both are now drained at up to 20 messages per Update, sharing one per-frame budget, with replayed (older) events going first so ordering is preserved. Nothing is dropped or reordered, and the cap does not bind in steady state. A one-off warning is logged if the combined backlog exceeds 500, which indicates the main loop stopped pumping.
* TaskUtils.LogIfFailed now logs connectivity/transport failures as warnings instead of errors/exceptions. The SDK fire-and-forgets its connect, reconnect, and state-restore operations through LogIfFailed; when the device is offline these fail with HttpRequestException / WebException / SocketException / IOException / TimeoutException, which the reconnect flow recovers from - so surfacing them at error severity flooded crash/error reporting (Sentry, Bugsnag, etc.) with handled, non-actionable noise. Genuine (non-connectivity) failures still log as exceptions. Complements the connection-attempt-timeout fix from PR #213.

v5.5.0:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,17 @@ private set
if (value == ConnectionState.Disconnected)
{
_disconnectionLastEventReceivedAt = _lastEventReceivedAt;
// Drop any replay events this connection never got through. They are
// stale and, worse, self-compounding if kept: _lastEventReceivedAt only advances
// as events are HANDLED, so the sync point captured on the line above excludes
// everything still queued here — the next /sync re-fetches exactly those events
// and enqueues them BEHIND the undrained remainder. Each reconnect flap would
// then add another duplicate copy to an unbounded queue, and the FIFO order would
// replay older events after newer ones had already been handled, dragging
// _lastEventReceivedAt backwards mid-drain so a drop in that window regressed the
// sync point again. Clearing is safe precisely because the sync point does not
// count them: whatever is discarded here is re-fetched on the next catch-up.
_pendingReplayEvents.Clear();
Disconnected?.Invoke();
}
}
Expand Down Expand Up @@ -415,12 +426,58 @@ public void Update(float deltaTime)

_websocketClient.Update();

while (_websocketClient.TryDequeueMessage(out var msg))
// Report-only. We deliberately do NOT discard a large backlog: there is no
// recovery path that would make that safe. Reconnecting does not re-hydrate channel
// state — the only catch-up is /sync (see FetchAndProcessEventsSinceLastReceivedEvent),
// which the server rejects past roughly 1000 missed events, so discarding would drop
// the events permanently and leave local state silently stale. Draining a large
// backlog under the per-frame budget below is slow but correct. The backlog is bounded
// in practice anyway: health-check pings only go out from this Update, so a stalled
// client stops pinging and the server closes the socket within ~30s.
// Counts the reconnect replay queue as well as the socket's. Both are drained by the same
// per-frame budget below, so both contribute to how far behind we are — and the replay
// queue is the one that actually gets large, since a /sync catch-up can hand back
// hundreds of events at once whereas the socket queue is bounded by its ~20/s arrival
// rate against a 20/frame drain. Measuring only the socket meant the backlog this warning
// exists to surface was the one it could not see.
int backlog = _websocketClient.QueuedMessageCount + _pendingReplayEvents.Count;
if (backlog > ReceiveBacklogWarningThreshold)
{
if (!_receiveBacklogWarned)
{
_receiveBacklogWarned = true;
_logs.Warning(
$"Websocket receive backlog of {backlog} exceeds {ReceiveBacklogWarningThreshold}; "
+ "draining it over multiple frames. Expect degraded frame time until it clears.");
}
}
else
{
_receiveBacklogWarned = false;
}

// Bound the per-frame drain. The websocket receives on a background
// timer thread (see WebsocketClient) while this pump only runs on Unity's main
// loop, which stops while the app is backgrounded. Draining the whole backlog in
// one frame after a long background stalls that frame; spreading it over frames
// keeps the catch-up interactive.
int drained = 0;

// Reconnect-replay events are older than anything still on the socket, so they go
// first to keep events in order. They share the per-frame budget below.
while (drained < MaxMessagesHandledPerUpdate && _pendingReplayEvents.Count > 0)
{
HandleNewWebsocketMessage(_pendingReplayEvents.Dequeue());
drained++;
}

while (drained < MaxMessagesHandledPerUpdate && _websocketClient.TryDequeueMessage(out string msg))
{
#if STREAM_DEBUG_ENABLED
_logs.Info(_authCredentials.UserId + " WS message: " + msg);
#endif
HandleNewWebsocketMessage(msg);
drained++;
}
}

Expand Down Expand Up @@ -473,8 +530,13 @@ public async Task FetchAndProcessEventsSinceLastReceivedEvent(IEnumerable<string
// StreamTodo: check if we can not serialized this again. Investigate adding a custom EventsJsonConverter that would populate the list as serialized strings
var serializedMsg = _serializer.Serialize(e);

//StreamTodo: try block?
HandleNewWebsocketMessage(serializedMsg);
// Queue instead of handling inline. This replay fires on every
// reconnect — including the reconnect right after an app returns from the
// background, when it is at its largest — and handling the whole response in one
// loop puts the entire cost in a single frame, the same stall the websocket
// drain cap exists to prevent. Update drains this under that shared budget.
// Same main-thread assumption as the previous direct call.
_pendingReplayEvents.Enqueue(serializedMsg);
}
}

Expand Down Expand Up @@ -557,6 +619,20 @@ internal async Task<OwnUserInternalDTO> ConnectUserAsync(string apiKey, string u
private const string DefaultStreamAuthType = "jwt";
private const int HealthCheckMaxWaitingTime = 30;

// Max websocket messages handled per Update (i.e. per frame). Under
// WebsocketClient (every platform but WebGL) the reader enqueues at most one message per
// UpdatesPerSecond tick — 20/second — so at 30-60fps this drains 30-60x faster than
// messages can arrive: the cap never binds in steady state and only meters catch-up after
// the main loop was stalled. WebGL's NativeWebSocketWrapper is browser-paced with no such
// ceiling, so there the cap does spread a burst over frames, which is the intent anyway.
private const int MaxMessagesHandledPerUpdate = 20;

// Backlog depth that gets a one-off warning so a stalled pump is visible in the
// field. Diagnostic only — nothing is discarded. Sized above any ordinary hitch: at the
// socket's 20 messages/second ceiling this is ~25 seconds of saturated traffic with the
// main loop stopped.
private const int ReceiveBacklogWarningThreshold = 500;

// For WebGL there is a slight delay when sending therefore we send HC event a bit sooner just in case
private const int HealthCheckSendInterval = HealthCheckMaxWaitingTime - 1;

Expand All @@ -575,6 +651,13 @@ internal async Task<OwnUserInternalDTO> ConnectUserAsync(string apiKey, string u
private readonly Dictionary<string, Action<string>> _eventKeyToHandler =
new Dictionary<string, Action<string>>();

// Missed events fetched on reconnect, awaiting the per-frame handling budget
// in Update. Main-thread only, like the direct handling it replaced.
private readonly Queue<string> _pendingReplayEvents = new Queue<string>();

// Latches the backlog warning so it logs once per episode, not every frame.
private bool _receiveBacklogWarned;

private readonly object _websocketConnectionFailedFlagLock = new object();

private TaskCompletionSource<OwnUserInternalDTO> _connectUserTaskSource;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ public bool TryDequeueMessage(out string message)
return message != null;
}

/// <inheritdoc/>
public int QueuedMessageCount => _messages.Count;

public async Task ConnectAsync(Uri serverUri, int timeout = 5)
{
if (_webSocket != null)
Expand Down
9 changes: 9 additions & 0 deletions Assets/Plugins/StreamChat/Libs/Websockets/IWebsocketClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ public interface IWebsocketClient : IDisposable

bool TryDequeueMessage(out string message);

/// <summary>
/// Number of received messages waiting to be dequeued. Receiving runs on a
/// background timer while consumers dequeue from Unity's main loop, so this reports how
/// far behind the consumer is. Diagnostic only: the transport never drops messages, and
/// consumers must not either — a discarded protocol event cannot be recovered, since the
/// only catch-up mechanism (/sync) is itself limited to roughly 1000 missed events.
/// </summary>
int QueuedMessageCount { get; }

Task ConnectAsync(Uri serverUri, int timeout = 3);

void Update();
Expand Down
3 changes: 3 additions & 0 deletions Assets/Plugins/StreamChat/Libs/Websockets/WebsocketClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ public WebsocketClient(ILogs logs, Encoding encoding = default, bool isDebugMode

public bool TryDequeueMessage(out string message) => _receiveQueue.TryDequeue(out message);

/// <inheritdoc/>
public int QueuedMessageCount => _receiveQueue.Count;

public async Task ConnectAsync(Uri serverUri, int timeout = 3)
{
if (IsConnected || IsConnecting)
Expand Down
Loading