From ae6fc8c5a057d6c4d652ad9b33ccbaf6e5e8e10e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?=
<33436839+sierpinskid@users.noreply.github.com>
Date: Tue, 25 Aug 2026 23:19:33 +0200
Subject: [PATCH 1/3] Add time budget for draining WS events. If needed, the
load will be split across multiple frames
---
.../LowLevelClient/EventDrainStopwatch.cs | 24 ++
.../EventDrainStopwatch.cs.meta | 3 +
.../IStreamChatLowLevelClient.cs | 5 +
.../StreamChatLowLevelClient.cs | 208 +++++++++++-
...StreamChatLowLevelClientEventDrainTests.cs | 306 ++++++++++++++++++
...mChatLowLevelClientEventDrainTests.cs.meta | 3 +
.../Tests/StateSync/StateSyncCatchUpTests.cs | 21 +-
7 files changed, 551 insertions(+), 19 deletions(-)
create mode 100644 Assets/Plugins/StreamChat/Core/LowLevelClient/EventDrainStopwatch.cs
create mode 100644 Assets/Plugins/StreamChat/Core/LowLevelClient/EventDrainStopwatch.cs.meta
create mode 100644 Assets/Plugins/StreamChat/Tests/LowLevelClient/StreamChatLowLevelClientEventDrainTests.cs
create mode 100644 Assets/Plugins/StreamChat/Tests/LowLevelClient/StreamChatLowLevelClientEventDrainTests.cs.meta
diff --git a/Assets/Plugins/StreamChat/Core/LowLevelClient/EventDrainStopwatch.cs b/Assets/Plugins/StreamChat/Core/LowLevelClient/EventDrainStopwatch.cs
new file mode 100644
index 00000000..062cf971
--- /dev/null
+++ b/Assets/Plugins/StreamChat/Core/LowLevelClient/EventDrainStopwatch.cs
@@ -0,0 +1,24 @@
+using System.Diagnostics;
+
+namespace StreamChat.Core.LowLevelClient
+{
+ ///
+ /// Elapsed-time source for the per-frame event drain budget. Production uses
+ /// ; tests inject a fake so pacing is deterministic.
+ ///
+ internal interface IElapsedStopwatch
+ {
+ void Restart();
+
+ double ElapsedMilliseconds { get; }
+ }
+
+ internal sealed class DiagnosticsElapsedStopwatch : IElapsedStopwatch
+ {
+ public void Restart() => _stopwatch.Restart();
+
+ public double ElapsedMilliseconds => _stopwatch.Elapsed.TotalMilliseconds;
+
+ private readonly Stopwatch _stopwatch = new Stopwatch();
+ }
+}
diff --git a/Assets/Plugins/StreamChat/Core/LowLevelClient/EventDrainStopwatch.cs.meta b/Assets/Plugins/StreamChat/Core/LowLevelClient/EventDrainStopwatch.cs.meta
new file mode 100644
index 00000000..8b7e1da9
--- /dev/null
+++ b/Assets/Plugins/StreamChat/Core/LowLevelClient/EventDrainStopwatch.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: e4d8a91c7b2f4e6a9d1c3b5f708294a6
+timeCreated: 1756152000
diff --git a/Assets/Plugins/StreamChat/Core/LowLevelClient/IStreamChatLowLevelClient.cs b/Assets/Plugins/StreamChat/Core/LowLevelClient/IStreamChatLowLevelClient.cs
index a9709bd4..9d1c035e 100644
--- a/Assets/Plugins/StreamChat/Core/LowLevelClient/IStreamChatLowLevelClient.cs
+++ b/Assets/Plugins/StreamChat/Core/LowLevelClient/IStreamChatLowLevelClient.cs
@@ -103,6 +103,11 @@ void SetReconnectStrategySettings(ReconnectStrategy reconnectStrategy, float? ex
[Obsolete("Use DisconnectAsync(DisconnectCause). true maps to UserLogout, false to ConnectionReleased.")]
Task DisconnectAsync(bool permanent);
+ ///
+ /// Fetch missed events via /sync and apply them. The returned task completes when
+ /// those events have been processed, which may span several calls
+ /// after a large catch-up. Keep calling while awaiting.
+ ///
Task FetchAndProcessEventsSinceLastReceivedEvent(IEnumerable channelCids);
///
diff --git a/Assets/Plugins/StreamChat/Core/LowLevelClient/StreamChatLowLevelClient.cs b/Assets/Plugins/StreamChat/Core/LowLevelClient/StreamChatLowLevelClient.cs
index 8d12e583..db00148e 100644
--- a/Assets/Plugins/StreamChat/Core/LowLevelClient/StreamChatLowLevelClient.cs
+++ b/Assets/Plugins/StreamChat/Core/LowLevelClient/StreamChatLowLevelClient.cs
@@ -218,6 +218,8 @@ private set
if (value == ConnectionState.Disconnected)
{
_disconnectionLastEventReceivedAt = _lastEventReceivedAt;
+ ClearPendingHistoryEvents();
+ _heldLiveMessage = null;
RaiseDisconnected();
}
}
@@ -430,13 +432,7 @@ public void Update(float deltaTime)
_websocketClient.Update();
- while (_websocketClient.TryDequeueMessage(out var msg))
- {
-#if STREAM_DEBUG_ENABLED
- _logs.Info(_authCredentials.UserId + " WS message: " + msg);
-#endif
- HandleNewWebsocketMessage(msg);
- }
+ DrainPendingEvents();
}
public bool IsLocalUser(User user) => user.Id == _authCredentials.UserId;
@@ -469,8 +465,9 @@ public async Task FetchAndProcessEventsSinceLastReceivedEvent(IEnumerable 1000 events
+ //StreamTodo: according to Android SDK there's an error if there are > 1000 events
+ var generation = _historyDrainGeneration;
var response = await ChannelApi.SyncAsync(new SyncRequest
{
ChannelCids = channelCids.ToList(),
@@ -478,24 +475,24 @@ public async Task FetchAndProcessEventsSinceLastReceivedEvent(IEnumerable
+ /// Per-frame time budget for applying live WS + /sync history events. Tests may lower this.
+ ///
+ internal int EventDrainTimeBudgetMs { get; set; } = 3;
+
+ ///
+ /// Secondary bound so a batch of cheap events cannot still dump a huge list in one frame.
+ ///
+ internal int EventDrainCountCap { get; set; } = 64;
+
+ internal IElapsedStopwatch EventDrainStopwatch { get; set; } = new DiagnosticsElapsedStopwatch();
+
internal async Task ConnectUserAsync(string apiKey, string userId,
ITokenProvider tokenProvider, CancellationToken cancellationToken = default)
{
@@ -574,6 +583,7 @@ internal async Task ConnectUserAsync(string apiKey, string u
private const string DefaultStreamAuthType = "jwt";
private const int HealthCheckMaxWaitingTime = 30;
+ private const int EventDrainHealthGrace = 8;
// 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;
@@ -595,6 +605,8 @@ internal async Task ConnectUserAsync(string apiKey, string u
private readonly object _websocketConnectionFailedFlagLock = new object();
private readonly object _websocketDisconnectedFlagLock = new object();
+ private readonly object _pendingHistoryEventsLock = new object();
+ private readonly Queue