From 89c55ec7c5d6e9f7f8f038c4e6a1b1bbd3024e99 Mon Sep 17 00:00:00 2001 From: Harlan Crystal Date: Sat, 25 Jul 2026 21:30:52 -0700 Subject: [PATCH 1/2] Fix inverted staleness guard on the /sync catch-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FetchAndProcessEventsSinceLastReceivedEvent has a guard meant to skip /sync when the last received event is older than 30 days, past which the server rejects LastSyncAt. It computed the age backwards: var diff = lastEventReceivedAt - _timeService.Now; // past - now => NEGATIVE if (diff.TotalDays > 30) { return; } // therefore never true lastEventReceivedAt is always in the past, so the branch is unreachable and /sync is issued regardless of age. The request then fails server-side, and because the only caller (StreamChatClient.RestoreStateLostDuringDisconnect) invokes it fire-and-forget through LogIfFailed, the exception reaches the logger and nothing else — the client silently gets no replay at all. Corrected to `_timeService.Now - lastEventReceivedAt`. DateTimeOffset subtraction compares UtcDateTime, so a local-offset vs server-offset mismatch is handled correctly. Also removes the dead `currentServerTime` local directly above it. It was never read, and was almost certainly the intended left operand — the tell that this comparison was written wrong and never exercised. Note this only skips a request that was certain to fail; it is not a recovery path. The SDK has no re-hydrate fallback of its own, so bridging a gap this large remains the consumer's job. --- Assets/Plugins/StreamChat/Changelog.txt | 1 + .../Core/LowLevelClient/StreamChatLowLevelClient.cs | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Assets/Plugins/StreamChat/Changelog.txt b/Assets/Plugins/StreamChat/Changelog.txt index 335bd26b..4157fdea 100644 --- a/Assets/Plugins/StreamChat/Changelog.txt +++ b/Assets/Plugins/StreamChat/Changelog.txt @@ -19,6 +19,7 @@ Features: Fixes: +* Fix the 30-day staleness guard on the reconnect /sync catch-up computing its age backwards (lastEventReceivedAt - now, which is negative for any past timestamp), so the guard never fired and /sync was called even with a LastSyncAt the server rejects. Also removes a dead local that was almost certainly the intended operand. * 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: diff --git a/Assets/Plugins/StreamChat/Core/LowLevelClient/StreamChatLowLevelClient.cs b/Assets/Plugins/StreamChat/Core/LowLevelClient/StreamChatLowLevelClient.cs index b45185e0..7000c00b 100644 --- a/Assets/Plugins/StreamChat/Core/LowLevelClient/StreamChatLowLevelClient.cs +++ b/Assets/Plugins/StreamChat/Core/LowLevelClient/StreamChatLowLevelClient.cs @@ -445,10 +445,10 @@ public async Task FetchAndProcessEventsSinceLastReceivedEvent(IEnumerable 30) { return; From 12ab6a13cab738fc3f30a43a9387caa0e0ed4e65 Mon Sep 17 00:00:00 2001 From: Harlan Crystal Date: Mon, 27 Jul 2026 17:21:16 -0700 Subject: [PATCH 2/2] Re-watch channels when /sync refuses the reconnect catch-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On reconnect the SDK catches up by calling /sync with the timestamp of the last event received before the disconnect. The server refuses the request when the gap is too large — code 4 / HTTP 400, "Too many events to sync, please use a more recent last_sync_at parameter" — which the ~1000-event limit reaches long before the 30-day bound the guard above it checks. That cap counts events, not messages, across every cid passed in the one call, so a single message can contribute a message.new plus a message.read per member. A player who leaves the app backgrounded on a busy channel and returns hours later hits it every time. The failure had no handler. FetchAndProcessEventsSinceLastReceivedEvent is called fire-and-forget through LogIfFailed, so the exception reached the logger and nothing else: the watched channels kept the state they had before the disconnect, missing every message since, until something unrelated happened to re-fetch them. And _disconnectionLastEventReceivedAt stayed stale, so the next reconnect failed exactly the same way. In one of our production titles this is 6k+ such warnings across 4.2k users in 30 days; for a live room or an open feed it is a silent correctness gap, not just noise. Now the low-level client drops the stale sync point and rethrows, and RestoreStateLostDuringDisconnect catches the input error and re-watches every watched channel — the same full state fetch the initial watch does, which is the only way to recover once the events are past replay. Each channel is attempted independently: this runs after the stale sync point has been dropped, so it is the only recovery this reconnect gets, and a single failure escaping the loop would leave every remaining channel silently stale for the rest of the session. Failures are expected here, not exotic — a channel torn down while offline returns 403 on every read, and a long watched list can trip a 429 part-way. Known limitation, flagged in a comment: GetOrCreateChannelWithIdAsync is get-OR-create, so re-watching a channel that was hard-deleted while offline recreates it server-side as an empty channel. Fixing that properly means consulting SyncResponse.InaccessibleCids — already returned by /sync and currently ignored — to skip channels the server says are gone, rather than discovering it one 403 at a time. Happy to take that on in this PR if you would rather not merge the get-or-create behavior. --- Assets/Plugins/StreamChat/Changelog.txt | 1 + .../StreamChatLowLevelClient.cs | 26 ++++++-- .../StreamChat/Core/StreamChatClient.cs | 60 ++++++++++++++++++- 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/Assets/Plugins/StreamChat/Changelog.txt b/Assets/Plugins/StreamChat/Changelog.txt index 4157fdea..c6c015a6 100644 --- a/Assets/Plugins/StreamChat/Changelog.txt +++ b/Assets/Plugins/StreamChat/Changelog.txt @@ -19,6 +19,7 @@ Features: Fixes: +* Fix watched channels silently staying stale after a reconnect whose /sync catch-up the server refuses as too large ("Too many events to sync", HTTP 400 / code 4 - reachable after a long disconnect on a busy channel, since the ~1000-event limit counts events across every cid in the call). The failure previously reached only the fire-and-forget logger, and the stale sync point was kept so every subsequent reconnect failed identically. The SDK now drops the stale sync point and re-watches every watched channel instead, which is the same full state fetch the initial watch performs. Each channel is restored independently so one failure (e.g. a 403 on a channel deleted while offline) does not abandon the rest. * Fix the 30-day staleness guard on the reconnect /sync catch-up computing its age backwards (lastEventReceivedAt - now, which is negative for any past timestamp), so the guard never fired and /sync was called even with a LastSyncAt the server rejects. Also removes a dead local that was almost certainly the intended operand. * 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. diff --git a/Assets/Plugins/StreamChat/Core/LowLevelClient/StreamChatLowLevelClient.cs b/Assets/Plugins/StreamChat/Core/LowLevelClient/StreamChatLowLevelClient.cs index 7000c00b..2ebc00d9 100644 --- a/Assets/Plugins/StreamChat/Core/LowLevelClient/StreamChatLowLevelClient.cs +++ b/Assets/Plugins/StreamChat/Core/LowLevelClient/StreamChatLowLevelClient.cs @@ -14,6 +14,7 @@ using StreamChat.Core.LowLevelClient.API.Internal; using StreamChat.Core.LowLevelClient.Events; using StreamChat.Core.LowLevelClient.Models; +using StreamChat.Core.LowLevelClient.Responses; using StreamChat.Core.Web; using StreamChat.Libs; using StreamChat.Libs.AppInfo; @@ -456,12 +457,27 @@ public async Task FetchAndProcessEventsSinceLastReceivedEvent(IEnumerable 1000 events - var response = await ChannelApi.SyncAsync(new SyncRequest + SyncResponse response; + try { - ChannelCids = channelCids.ToList(), - LastSyncAt = lastEventReceivedAt, - Watch = true, - }); + response = await ChannelApi.SyncAsync(new SyncRequest + { + ChannelCids = channelCids.ToList(), + LastSyncAt = lastEventReceivedAt, + Watch = true, + }); + } + catch (StreamApiException e) when (e.IsInputError()) + { + // The gap is too large for /sync — more than the ~1000 events the server + // will replay (the StreamTodo above), which a busy channel reaches long before the + // 30-day bound checked above. Drop the sync point so the next reconnect starts from + // a fresh one instead of failing the same way forever, and let the caller re-hydrate + // the channels: the missed events are gone either way, and only a full state fetch + // brings the watched channels back up to date. + _disconnectionLastEventReceivedAt = null; + throw; + } if (response.Events.Count == 0) { diff --git a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs index d05928fd..2accdd7c 100644 --- a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs +++ b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs @@ -1141,14 +1141,68 @@ private void OnConnected(HealthCheckEventInternalDTO dto) RestoreStateLostDuringDisconnect().LogIfFailed(); } - private Task RestoreStateLostDuringDisconnect() + private async Task RestoreStateLostDuringDisconnect() { if (!WatchedChannels.Any()) { - return Task.CompletedTask; + return; + } + + try + { + await LowLevelClient.FetchAndProcessEventsSinceLastReceivedEvent( + WatchedChannels.Select(c => c.Cid)); + } + catch (StreamApiException e) when (e.IsInputError()) + { + // /sync refused the catch-up because too much accumulated while we were + // disconnected (see FetchAndProcessEventsSinceLastReceivedEvent). Without this the + // exception only reached the fire-and-forget logger at the call site: the watched + // channels silently stayed as they were before the disconnect, missing every + // message since, until something else happened to re-fetch them. Re-watch instead — + // it is the same full state fetch the initial watch does. + _logs.Warning("The /sync catch-up was refused as too large; re-watching " + + $"{WatchedChannels.Count} channel(s) to restore their state instead."); + await RewatchChannelsAsync(); + } + } + + // Full state re-fetch of every watched channel, used when /sync cannot bridge the + // disconnect gap. Snapshotted because each re-watch writes the cache the list is built from. + // + // Every channel is attempted independently. This runs AFTER the stale sync point has been + // dropped, so it is the only recovery this reconnect gets and there is no later retry: a + // single failure escaping the loop would leave every remaining channel silently stale for the + // rest of the session. Failures are expected here, not exotic — a channel torn down while we + // were offline returns 403 on every read, and a long watched list can trip a 429 part-way. + // Log each one and keep going so the channels that CAN be restored are. + // + // Known limitation: GetOrCreateChannelWithIdAsync is get-OR-CREATE, so re-watching a channel + // that was hard-deleted while we were offline recreates it server-side as an empty channel. + // Fixing that properly means consulting SyncResponse.InaccessibleCids (already returned by + // /sync and currently ignored) to skip channels the server says are gone, rather than + // discovering it one 403 at a time. + private async Task RewatchChannelsAsync() + { + int failed = 0; + foreach (IStreamChannel channel in WatchedChannels.ToList()) + { + try + { + await GetOrCreateChannelWithIdAsync(channel.Type, channel.Id); + } + catch (Exception e) + { + failed++; + _logs.Warning($"Re-watch failed for channel {channel.Type}:{channel.Id}; " + + $"its local state stays as it was before the disconnect. {e.Message}"); + } } - return LowLevelClient.FetchAndProcessEventsSinceLastReceivedEvent(WatchedChannels.Select(c => c.Cid)); + if (failed > 0) + { + _logs.Warning($"Re-watch completed with {failed} channel(s) unrestored."); + } } private void OnDisconnected() => Disconnected?.Invoke();