Skip to content

Enhance SignalR connection handling and listener recovery#2368

Open
hahn-kev wants to merge 43 commits into
developfrom
signalr-retry-improvements-v3
Open

Enhance SignalR connection handling and listener recovery#2368
hahn-kev wants to merge 43 commits into
developfrom
signalr-retry-improvements-v3

Conversation

@hahn-kev

Copy link
Copy Markdown
Collaborator

Builds off of #2317

I've introduced 2 new classes.

LexboxHubConnection

Owns the SignlaR connection and recreates/reconnects as needed. Also owns the listening project Ids and dispatching sync notifications.

LexboxProjectChangeListener

Owns the LexboxHubConnection, creates it based on the server and reuses it for future requests.

Note

Right now the connection takes a dependency on the sync service and triggers the sync itself. It might be better have that be handled in the LexboxProjectChangeListener by the connection returning an observable and the change listener deciding what to do in response to a project change notification. Something to consider for this PR or the future.

myieye and others added 30 commits June 16, 2026 14:01
ListenForProjectChanges previously registered a project in the
_reconnectProjects table only AFTER its state-based early returns. A
project subscribed while the underlying HubConnection was Reconnecting
would silently never be resubscribed when the connection came back.
Hoist the registration above the state checks so the comment ("Reconnected
handler will resubscribe...") is actually true.

Also extract the Reconnecting auth-loss decision into a static internal
method so the auth-stop behavior can be unit-tested without a real
HubConnection or full DI graph. Add two unit tests covering token-present
(no-op) and token-null (evict + stop) branches.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously OnAuthenticationChanged only invalidated the cached project
list — the hub connection stayed up with whatever token it was built
with. After a logout/login cycle the listener kept running with stale
auth until the next reconnect attempt happened to detect it.

Track each project we're asked to listen for in a stable dictionary
(separate from the per-connection _reconnectProjects table) so we have a
list to re-subscribe even after the underlying HubConnection has been
torn down. On every auth event, evict the cached connection and walk
that list to (re)start listeners; on logout the new connections fail
fast at the auth check, on login they come up with the fresh token —
auto-recovery without waiting for the user to manually sync or reopen
the project.

EvictAndStopIfCached is extracted as a static internal helper to match
the HandleReconnecting pattern; add two unit tests for its empty-cache
and cached-connection branches.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
If the initial StartLexboxProjectChangeListener call fails (e.g. user
was offline at project-open), the WithAutomaticReconnect policy never
kicks in — the listener stays dead until something re-triggers
ListenForProjectChanges. Today the only triggers are project open and
the FwLiteWeb hub's OnConnectedAsync; a long-running session can sit
indefinitely with no push notifications.

Fire-and-forget a ListenForProjectChanges call after every successful
SyncService.ExecuteSync. If the listener is already running the cache
short-circuits and it's effectively free; if it died (cold-start
failure, transient network) this is the recovery trigger. Wrap in
try/catch so a listener problem can't masquerade as a sync failure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When two ListenForProjectChanges callers race past the cache check (both
see it empty), both build a HubConnection. The second wins the cache
slot; the first is evicted via the IMemoryCache eviction callback and
disposed. Disposing fires its Closed event handler, which unconditionally
called cache.Remove — evicting the *replacement* connection that just
took its place. The next ListenForProjectChanges call finds an empty
cache and races again.

The race is exposed (not caused) by the post-sync piggyback firing
ListenForProjectChanges on every successful sync — observed during
two-instance manual testing as a cascade where the cache stayed empty
and connections kept cycling. Guard the cache eviction with a
ReferenceEquals check so an orphaned Closed event can't take down the
live cached connection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Dispose the HubConnection when StartAsync fails (was leaked on cold-start retry)
- Evict a dead cached connection if Disconnected-recovery StartAsync fails
- Broaden the subscribe SendAsync catch so a mid-call drop can't fail project-open
- Extract RegisterForResubscribe and unit-test the subscription-ordering invariant
- HandleReconnecting takes a bool; null token = logged out (OAuthClient owns transient-vs-logout)
- MAUI: ConnectivitySyncTrigger re-establishes listeners when connectivity returns

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PushListenerRecoveryService re-runs EnsureListenersForTrackedProjects
every 5 minutes so a listener that failed to start (e.g. offline at
project-open) recovers without a manual sync, on platforms with no
connectivity events (FwLiteWeb). Servers with a live connection are
skipped via a pre-loop snapshot so the periodic pass is a no-op once
connected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… recovery

Two paths could leave sibling projects silently unsubscribed while their
server reported Connected: a manual revive (StartAsync does not raise
Reconnected) resubscribed only the requesting project, and the recovery
pass skipped whole servers, so a connection rebuilt by a single-project
path (post-sync, project-open) never picked the siblings back up. Extract
the Reconnected handler body into ResubscribeRegisteredProjects, call it
after a successful revive, and make EnsureListenersForTrackedProjects skip
per project (connected AND registered) instead of per server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HandleReconnecting removed the cache entry by key, and the revive-failure
path evicted whatever was cached — either could race a rebuild and evict a
live replacement connection, orphaning it while callers believe the server
has no listener. Apply the same ReferenceEquals guard the Closed handler
uses: HandleReconnecting only evicts its own entry (still stops itself —
it has no token), and EvictAndStopIfCached takes an optional expected
instance for callers holding a specific dead connection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Concurrent ListenForProjectChanges callers that both missed the cache each
built and started a connection; the cache replacement then disposed the
loser mid-use and silently discarded its resubscribe registrations.
Concurrent revives of the same Disconnected connection were worse: the
losing StartAsync threw and evicted the winner. A per-server gate around
build-and-cache and revive (with re-checks inside) leaves one connection
per server by construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The HubConnection logged only to its own console-only factory, so
reconnect attempts and close reasons never reached the app log file --
production logs could not answer why a connection died. Register the app
logger factory in the builder''s service collection so SignalR client logs
flow through the app''s providers and filters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An OS-frozen app (Android Doze) can return to the foreground with its
push listener down and no connectivity transition for the connectivity
trigger to react to, leaving recovery to the 5-minute periodic backstop.
Resume is exactly when the user is watching, so recover immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fixed backoff topped out at 60s, and since SignalR cannot be hurried
mid-delay, that was the worst-case window where HTTP already works but
pushes are still down (e.g. after a server deploy, where no client-side
signal exists to trigger recovery). Retry every 10s while the device
reports online -- the failure is server-side and could end any moment --
and back off to 60s while offline, where every attempt is a pointless
local failure. Hosts without a connectivity API (FwLiteWeb) report
always-online.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A connection sleeping in its reconnect backoff cannot be hurried -- every
recovery mechanism found it Reconnecting and correctly stood down, so the
committed delay was the floor on recovery latency even when the network
was demonstrably back. Callers with strong evidence (connectivity
regained, app resume, a successful sync to that server) now stop the
mid-backoff connection and rebuild through the normal serialized path,
which also resubscribes every tracked project. Weak-signal callers (the
periodic backstop) never kick: a wrong kick trades the automatic retry
loop for the much slower backstops.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ndler

Remove the StartAsync retry TODO (post-sync, connectivity, resume and
periodic recovery now cover it), fix comments referencing the renamed
retry policy, and catch TriggerSync failures in the OnProjectUpdated
handler -- it throws when the background sync service is not running and
SignalR would swallow that into its own logger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Reconnecting handler decided "logged out" from whether a token could be
fetched right then -- but reconnecting happens precisely when the network is
flaky, and fetching a token can require a refresh round-trip that fails
transiently, which read as a false logout and tore down a listener that
should have kept retrying. Switch the signal to IsSignedIn (a local-only
account-presence read that flips only on a real logout), now available from
the merged OAuth fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move auth-change listener recovery out of LexboxProjectService into a dedicated
AuthChangeListenerTrigger hosted service, so the subscription lifecycle no longer
rides on the service's IDisposable. Report connectivity from the OS network-interface
table (NetworkInterfaceNetworkStatus) rather than assuming always-online, and add
NetworkChangeSyncTrigger so web hosts re-ensure listeners when connectivity returns
(the cross-platform counterpart to MAUI's ConnectivitySyncTrigger).

StartLexboxProjectChangeListener now checks the connection cache before any auth call
and gates a new build on IsSignedIn (account presence) instead of GetCurrentToken: a
transient token-refresh failure during a reconnect could otherwise be mistaken for a
logout and tear down a healthy auto-reconnecting connection (the same false-logout trap
HandleReconnecting already avoids). Adds a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each recovery path now announces itself: the auth-change, MAUI-connectivity and
web-network-availability triggers log at startup that they're watching, the MAUI
resume hook logs when it fires, and the periodic backstop logs a debug heartbeat
per check. The connectivity/network triggers already logged their fire events;
auth-change fire detail stays in HandleAuthChanged. Makes it easier to tell which
of the several recovery paths did (or didn't) run when debugging listener issues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ne verdict

A health check that failed while offline was cached as unhealthy for 30 minutes,
so a sync triggered by network recovery (this branch's reconnect-sync) returned the
stale "unhealthy" verdict and refused to sync until the cache expired or the app
restarted. On a genuine connect transition — where SignalR reaching the server proves
it's reachable — drop the cached health verdict (and the per-server projects list,
which has the same stale-offline-failure problem) so the catch-up sync re-probes.

Also quiet the expected ObjectDisposedException when stopping an evicted connection
that's mid-reconnect (it has already disposed itself; logged at Debug), and downgrade
the per-attempt reconnect log to Debug.

Verified on Android: cold-start-while-offline then online now syncs within seconds,
where it previously stayed "Offline" with pending downloads for the full cache window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
….user

Reframe the connectivity-change log (was a temporary diagnostic) as a permanent
event so real-device behaviour stays legible, and gitignore the local-harmony
*.props.user artifact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rror

When the device was offline the sync indicator/dialog stayed green and a manual
sync surfaced a raw UnknownHostException: the server-health cache can still report
healthy from an earlier online sync, so ExecuteSync sailed past the ShouldSync gate
and the actual transfer threw — which bubbled to the UI and skipped the offline
status update.

- ExecuteSync now trusts the device's network state: if offline, report Offline and
  return without attempting the transfer; also catch a mid-sync HttpRequestException
  and map it to Offline. Either path fires an Offline sync event, so the indicator
  dot and the sync dialog reflect offline.
- SyncDialog handles an un-synced result gracefully (gentle "you're offline" toast,
  no optimistic zeroing of pending counts) rather than letting it reach the global
  error handler.

Verified on Android: an offline sync now shows "Offline" + cloud-off with a friendly
toast; going back online still syncs normally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Widen the ExecuteSync catch to OperationCanceledException so an HttpClient
timeout reads as Offline instead of leaking a raw error. ExecuteSync takes no
cancellation token, so an OCE there is always a transport timeout, never a
caller abort.

The CRDT sync dialog showed "You're offline" for every non-synced outcome
(also NoServer/NotLoggedIn); use a status-neutral message and drop a dead
null-guard. The persistent status indicator still shows the specific status.
Re-extract catalogs and add translator context for the new string.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ExecuteSync now reports offline/connectivity failures as an unsynced result
instead of throwing, so UploadProject's catch no longer fired on a failed
upload: the project was left bound to a server it never reached and the
/upload route returned Ok(). Honor the unsynced result so the origin is rolled
back and the failure surfaces, restoring the pre-offline-handling contract.

Found by Devin review of #2317.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… too

The PushListenerRecoveryService comment claimed the connectivity trigger was
MAUI-only and that FwLiteWeb had none, but this branch added
NetworkChangeSyncTrigger for exactly that. Correct it, and state the real
reason the periodic backstop still matters: the OS network signal is optimistic
(a virtual adapter reads "available"), so a real-uplink return may not fire.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@hahn-kev hahn-kev requested a review from myieye June 22, 2026 04:32
@github-actions github-actions Bot added 💻 FW Lite issues related to the fw lite application, not miniLcm or crdt related 📦 Lexbox issues related to any server side code, fw-headless included labels Jun 22, 2026
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 64777018-b711-4692-8ca9-d7a0847d7fcc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds connectivity-aware push listener recovery to FwLite (MAUI and Web). Introduces INetworkStatus and IBackgroundSyncService abstractions, a new LexboxHubConnection SignalR manager with adaptive retry, LexboxProjectChangeListener for per-server connection routing, platform-specific connectivity triggers, periodic and auth-triggered recovery background services, and an offline guard in SyncService. The frontend sync dialog is updated to show a "local changes are safe" warning on sync failure.

Changes

Backend: Connectivity-Aware Sync and SignalR Push Listener Recovery

Layer / File(s) Summary
INetworkStatus and IBackgroundSyncService contracts
backend/FwLite/FwLiteShared/Services/INetworkStatus.cs, backend/FwLite/FwLiteShared/Sync/IBackgroundSyncService.cs, backend/FwLite/FwLiteShared/Sync/BackgroundSyncService.cs, backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs, backend/FwLite/FwLiteWeb/Hubs/CrdtMiniLcmApiHub.cs
Introduces INetworkStatus with IsOnline and IBackgroundSyncService with TriggerSync overloads. BackgroundSyncService implements the new interface. Consuming classes (MiniLcmJsInvokable, CrdtMiniLcmApiHub) are updated to depend on the interface.
Platform INetworkStatus implementations
backend/FwLite/FwLiteShared/Services/NetworkInterfaceNetworkStatus.cs, backend/FwLite/FwLiteMaui/Services/ConnectivityNetworkStatus.cs
Adds NetworkInterfaceNetworkStatus (desktop/web, uses NetworkInterface.GetIsNetworkAvailable) and ConnectivityNetworkStatus (MAUI, uses IConnectivity.NetworkAccess, treats constrained internet as offline).
LexboxHubConnection: SignalR manager and abstractions
backend/FwLite/FwLiteShared/Projects/LexboxHubConnection.cs, backend/FwLite/FwLiteShared/Utils/ConcurrentHashSet.cs
Introduces LexboxHubConnection with EnsureConnected, OnAuthChanged, ListenForProjectChanges, and DisposeAsync. Defines ILexboxHubConnectionAuth, ILexboxSignalRConnectionFactory, ILexboxSignalRConnection abstractions and their implementations. Adds AdaptiveRetryPolicy (online: 10s delay, offline: 60s delay) and ConcurrentHashSet<T> utility.
LexboxProjectChangeListener: per-server connection registry
backend/FwLite/FwLiteShared/Projects/LexboxProjectChangeListener.cs
Maintains a ConcurrentDictionary<LexboxServer, LexboxHubConnection>, lazily creates connections via ActivatorUtilities, and exposes HandleAuthChanged, EnsureListenersForTrackedProjects, and ListenForProjectChanges.
LexboxProjectService refactoring
backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs
Removes IDisposable and inline SignalR/event-bus logic. Constructor simplified to inject LexboxProjectChangeListener. Adds forwarding methods for auth-change and listener readiness. Renames CacheKey to internal ProjectListCacheKey.
Auth and periodic recovery services
backend/FwLite/FwLiteShared/Projects/AuthChangeListenerTrigger.cs, backend/FwLite/FwLiteShared/Projects/PushListenerRecoveryService.cs
AuthChangeListenerTrigger subscribes to GlobalEventBus.OnAuthenticationChanged and calls HandleAuthChanged. PushListenerRecoveryService calls EnsureListenersForTrackedProjects every 5 minutes via PeriodicTimer.
SyncService: offline guard and listener restart
backend/FwLite/FwLiteShared/Sync/SyncService.cs
Adds INetworkStatus to constructor. ExecuteSync short-circuits to SyncStatus.Offline when offline, and catches HttpRequestException/OperationCanceledException as offline. On success, calls TryEnsureProjectChangeListener(kickReconnecting: true). UploadProject throws if IsSynced is false.
CrdtHttpSyncService: health cache invalidation
backend/FwLite/LcmCrdt/RemoteSync/CrdtHttpSyncService.cs
Adds HealthCacheKey(authority) helper and InvalidateServerHealth(cache, authority) to explicitly remove cached server health verdicts.
Platform connectivity triggers (MAUI and Web)
backend/FwLite/FwLiteMaui/Services/ConnectivitySyncTrigger.cs, backend/FwLite/FwLiteMaui/App.xaml.cs, backend/FwLite/FwLiteWeb/Services/NetworkChangeSyncTrigger.cs
MAUI: ConnectivitySyncTrigger hosted service monitors IConnectivity.ConnectivityChanged and triggers recovery on internet regain (ShouldRecover). App.xaml.cs adds a window.Resumed handler. Web: NetworkChangeSyncTrigger listens to NetworkChange.NetworkAvailabilityChanged.
DI container wiring
backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs, backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs, backend/FwLite/FwLiteWeb/FwLiteWebKernel.cs, backend/FwLite/FwLiteShared/Properties/AssemblyInfo.cs
Registers all new services in their respective kernels; adds InternalsVisibleTo("FwLiteShared.Tests").
Unit tests
backend/FwLite/FwLiteShared.Tests/Projects/AdaptiveRetryPolicyTests.cs, backend/FwLite/FwLiteShared.Tests/Projects/LexboxHubConnectionTests.cs, backend/FwLite/FwLiteMaui.Tests/ConnectivitySyncTriggerTests.cs, backend/FwLite/LcmCrdt.Tests/CrdtHttpSyncServiceTests.cs
Covers AdaptiveRetryPolicy delay logic by network state, LexboxHubConnection full connection lifecycle with in-file fakes, ConnectivitySyncTrigger.ShouldRecover transition scenarios, and CrdtHttpSyncService health-check caching/invalidation.

Frontend: Sync Failure UX and i18n

Layer / File(s) Summary
SyncDialog sync failure guard and locale strings
frontend/viewer/src/project/SyncDialog.svelte, frontend/viewer/src/locales/*.po
syncLexboxToLocal now checks result.isSynced === false and shows "local changes are safe" warning. New msgid added across all 8 locale files (en, es, fr, id, ko, ms, sw, vi); source reference for "Failed to synchronize" updated to sync-status-service.ts.

Config and Tooling

Layer / File(s) Summary
Gitignore and editorconfig tweaks
.gitignore, backend/.editorconfig
Adds *.props.user and signalr-acceptance-tests.md to .gitignore. Adds IDE0061/IDE0022 silent diagnostics and a comment to .editorconfig.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • sillsdev/languageforge-lexbox#1668: Both modify frontend/viewer/src/project/SyncDialog.svelte sync handling — this PR changes the failure guard to use result.isSynced and adds the "local changes are safe" notification.
  • sillsdev/languageforge-lexbox#1759: Both touch the frontend sync UX in SyncDialog.svelte to change how sync failure/status is interpreted and displayed.
  • sillsdev/languageforge-lexbox#2174: Both modify LexboxProjectService.cs to change how project-change subscriptions are re-established after connectivity/websocket reconnects.

Suggested labels

💻 FW Lite, 📦 Lexbox

Suggested reviewers

  • myieye
  • rmunn

Poem

🐇 Hoppity-hop, the network came back,
No more lost syncs falling through the crack!
"Your local changes are safe," says the screen,
The rabbit re-listens where signals have been.
With periodic timers and auth-change hooks,
Push listeners recover — just check the code books! 📡

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.20% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Enhance SignalR connection handling and listener recovery' accurately summarizes the main changes in the changeset, which introduce LexboxHubConnection and LexboxProjectChangeListener classes for improved SignalR connection management and recovery mechanisms.
Description check ✅ Passed The description clearly explains the main additions (LexboxHubConnection and LexboxProjectChangeListener) and their responsibilities, along with context about building on #2317 and a thoughtful note about potential future refactoring of the sync triggering approach.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch signalr-retry-improvements-v3

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

# Conflicts:
#	.gitignore
#	backend/FwLite/FwLiteMaui/App.xaml.cs
@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

UI unit Tests

  1 files  ±0   62 suites  ±0   32s ⏱️ +4s
186 tests ±0  186 ✅ ±0  0 💤 ±0  0 ❌ ±0 
258 runs  ±0  258 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 1cb51ba. ± Comparison against base commit 2d6b20e.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

C# FwHeadless Unit Tests

48 tests   48 ✅  16s ⏱️
 5 suites   0 💤
 1 files     0 ❌

Results for commit 1cb51ba.

♻️ This comment has been updated with latest results.

@argos-ci

argos-ci Bot commented Jun 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Argos notifications ↗︎

Build Status Details Updated (UTC)
default (Inspect) ✅ No changes detected - Jun 23, 2026, 8:01 AM

@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

C# Unit Tests

165 tests   165 ✅  20s ⏱️
 23 suites    0 💤
  1 files      0 ❌

Results for commit 1cb51ba.

♻️ This comment has been updated with latest results.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
backend/FwLite/FwLiteShared/Utils/ConcurrentHashSet.cs (1)

1-5: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Scope this utility to a project namespace and internal visibility.

ConcurrentHashSet<T> is currently global and public. For this PR usage, keeping it namespaced (e.g., FwLiteShared.Utils) and internal avoids accidental API surface growth and type-name collisions.

Proposed refactor
+namespace FwLiteShared.Utils;
+
-public class ConcurrentHashSet<T> : IEnumerable<T> where T : notnull
+internal class ConcurrentHashSet<T> : IEnumerable<T> where T : notnull
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/FwLite/FwLiteShared/Utils/ConcurrentHashSet.cs` around lines 1 - 5,
The ConcurrentHashSet<T> class is currently defined without a namespace and with
public visibility, which creates an unnecessarily broad API surface and risks
type-name collisions. Add a namespace declaration wrapping the
ConcurrentHashSet<T> class in FwLiteShared.Utils, and change the class access
modifier from public to internal to restrict its visibility to the assembly and
prevent unintended external usage.
backend/FwLite/FwLiteShared/Projects/LexboxProjectChangeListener.cs (2)

9-11: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider implementing IAsyncDisposable to clean up SignalR connections on shutdown.

LexboxHubConnection implements IAsyncDisposable, but this class holds connections in a ConcurrentDictionary without a disposal mechanism. While the DI container disposes singletons, it cannot reach into the dictionary. For graceful shutdown (avoiding server-side connection leaks), consider implementing IAsyncDisposable:

♻️ Example disposal implementation
-public sealed class LexboxProjectChangeListener(IServiceProvider serviceProvider, IOptions<AuthConfig> options)
+public sealed class LexboxProjectChangeListener(IServiceProvider serviceProvider, IOptions<AuthConfig> options) : IAsyncDisposable
 {
     private readonly ConcurrentDictionary<LexboxServer, LexboxHubConnection> _connections = new();
 
+    public async ValueTask DisposeAsync()
+    {
+        foreach (var connection in _connections.Values)
+        {
+            await connection.DisposeAsync();
+        }
+        _connections.Clear();
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/FwLite/FwLiteShared/Projects/LexboxProjectChangeListener.cs` around
lines 9 - 11, The LexboxProjectChangeListener class holds LexboxHubConnection
objects in the _connections ConcurrentDictionary but has no disposal mechanism
to clean them up on shutdown. Implement the IAsyncDisposable interface on
LexboxProjectChangeListener and add a DisposeAsync method that iterates through
all LexboxHubConnection instances in the _connections dictionary and calls
DisposeAsync on each one to ensure proper cleanup of SignalR connections during
graceful shutdown.

29-35: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Potential orphaned connections from GetOrAdd race condition.

ConcurrentDictionary.GetOrAdd can invoke the factory delegate multiple times when concurrent threads access the same key simultaneously. Only one LexboxHubConnection is stored, but others may be created and orphaned without disposal, leaking SignalR connections.

Consider using Lazy<T> or a per-key lock/semaphore to ensure only one connection is ever created per server:

♻️ Example fix using Lazy<T> pattern
-    private readonly ConcurrentDictionary<LexboxServer, LexboxHubConnection> _connections = new();
+    private readonly ConcurrentDictionary<LexboxServer, Lazy<LexboxHubConnection>> _connections = new();
 
     // ...
 
     public async Task ListenForProjectChanges(ProjectData projectData, CancellationToken stoppingToken = default, bool kickReconnecting = false)
     {
         if (!options.Value.TryGetServer(projectData, out var server)) return;
-        var connection = _connections.GetOrAdd(server,
-            (s) => ActivatorUtilities.CreateInstance<LexboxHubConnection>(serviceProvider, s));
+        var connection = _connections.GetOrAdd(server,
+            (s) => new Lazy<LexboxHubConnection>(
+                () => ActivatorUtilities.CreateInstance<LexboxHubConnection>(serviceProvider, s))).Value;
         await connection.ListenForProjectChanges(projectData.Id, stoppingToken, kickReconnecting);
     }

Adjust HandleAuthChanged and EnsureListenersForTrackedProjects similarly to access .Value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/FwLite/FwLiteShared/Projects/LexboxProjectChangeListener.cs` around
lines 29 - 35, The _connections ConcurrentDictionary uses GetOrAdd with a
factory that can create multiple LexboxHubConnection instances concurrently,
leaving orphaned connections. Replace the direct LexboxHubConnection
instantiation with a Lazy<LexboxHubConnection> wrapper in the GetOrAdd factory
delegate so that only one connection is created per server key. Update the call
to connection.ListenForProjectChanges to access the lazy value first
(connection.Value), and apply the same Lazy<T> pattern to any other methods that
access _connections, such as HandleAuthChanged and
EnsureListenersForTrackedProjects, ensuring they also unwrap the lazy value
before use.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/FwLite/FwLiteShared.Tests/Projects/LexboxHubConnectionTests.cs`:
- Around line 362-391: The test method
EnsureConnected_WhenAlreadyConnected_DoesNotTriggerCatchupSyncAgain hardcodes
the server health cache key as "ServerHealth|" + Server.Authority.Authority
instead of using the CrdtHttpSyncService.HealthCacheKey helper method. Replace
both occurrences of this hardcoded cache key string (where serverHealthCacheKey
is assigned and set in the test) with calls to
CrdtHttpSyncService.HealthCacheKey(Server.Authority.Authority) to maintain
consistency with the actual cache key generation logic and ensure the test
remains correct if the key format changes.

In `@backend/FwLite/FwLiteShared/Projects/LexboxHubConnection.cs`:
- Around line 72-74: The OnConnected method is being invoked unconditionally
after NewConnection returns, but NewConnection can return null when the user is
signed out (on the signed-out path). Add a null check on the connection variable
to guard the OnConnected invocation, ensuring it is only called when
NewConnection successfully establishes a connection (when connection is not
null). This same guard pattern should be applied to all locations where
OnConnected is invoked after NewConnection, including the additional call sites
at lines 88-95 and 126-130.
- Around line 52-67: The restart logic that checks
HubConnectionState.Disconnected and calls StartAsync, OnConnected, and
CleanupConnection is executing before acquiring the connectionLock, creating a
race condition where concurrent callers can interfere with each other's restart
and cleanup operations. Move the await connectionLock.WaitAsync(stoppingToken)
call to before the if statement that checks the connection state, so that the
entire restart operation is protected by the lock from start to finish.

---

Nitpick comments:
In `@backend/FwLite/FwLiteShared/Projects/LexboxProjectChangeListener.cs`:
- Around line 9-11: The LexboxProjectChangeListener class holds
LexboxHubConnection objects in the _connections ConcurrentDictionary but has no
disposal mechanism to clean them up on shutdown. Implement the IAsyncDisposable
interface on LexboxProjectChangeListener and add a DisposeAsync method that
iterates through all LexboxHubConnection instances in the _connections
dictionary and calls DisposeAsync on each one to ensure proper cleanup of
SignalR connections during graceful shutdown.
- Around line 29-35: The _connections ConcurrentDictionary uses GetOrAdd with a
factory that can create multiple LexboxHubConnection instances concurrently,
leaving orphaned connections. Replace the direct LexboxHubConnection
instantiation with a Lazy<LexboxHubConnection> wrapper in the GetOrAdd factory
delegate so that only one connection is created per server key. Update the call
to connection.ListenForProjectChanges to access the lazy value first
(connection.Value), and apply the same Lazy<T> pattern to any other methods that
access _connections, such as HandleAuthChanged and
EnsureListenersForTrackedProjects, ensuring they also unwrap the lazy value
before use.

In `@backend/FwLite/FwLiteShared/Utils/ConcurrentHashSet.cs`:
- Around line 1-5: The ConcurrentHashSet<T> class is currently defined without a
namespace and with public visibility, which creates an unnecessarily broad API
surface and risks type-name collisions. Add a namespace declaration wrapping the
ConcurrentHashSet<T> class in FwLiteShared.Utils, and change the class access
modifier from public to internal to restrict its visibility to the assembly and
prevent unintended external usage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 199d8105-b611-4890-a3dc-074473eb0449

📥 Commits

Reviewing files that changed from the base of the PR and between 2d6b20e and 0906b8a.

📒 Files selected for processing (37)
  • .gitignore
  • backend/.editorconfig
  • backend/FwLite/FwLiteMaui.Tests/ConnectivitySyncTriggerTests.cs
  • backend/FwLite/FwLiteMaui/App.xaml.cs
  • backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs
  • backend/FwLite/FwLiteMaui/Services/ConnectivityNetworkStatus.cs
  • backend/FwLite/FwLiteMaui/Services/ConnectivitySyncTrigger.cs
  • backend/FwLite/FwLiteShared.Tests/Projects/AdaptiveRetryPolicyTests.cs
  • backend/FwLite/FwLiteShared.Tests/Projects/LexboxHubConnectionTests.cs
  • backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs
  • backend/FwLite/FwLiteShared/Projects/AuthChangeListenerTrigger.cs
  • backend/FwLite/FwLiteShared/Projects/LexboxHubConnection.cs
  • backend/FwLite/FwLiteShared/Projects/LexboxProjectChangeListener.cs
  • backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs
  • backend/FwLite/FwLiteShared/Projects/PushListenerRecoveryService.cs
  • backend/FwLite/FwLiteShared/Properties/AssemblyInfo.cs
  • backend/FwLite/FwLiteShared/Services/INetworkStatus.cs
  • backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs
  • backend/FwLite/FwLiteShared/Services/NetworkInterfaceNetworkStatus.cs
  • backend/FwLite/FwLiteShared/Sync/BackgroundSyncService.cs
  • backend/FwLite/FwLiteShared/Sync/IBackgroundSyncService.cs
  • backend/FwLite/FwLiteShared/Sync/SyncService.cs
  • backend/FwLite/FwLiteShared/Utils/ConcurrentHashSet.cs
  • backend/FwLite/FwLiteWeb/FwLiteWebKernel.cs
  • backend/FwLite/FwLiteWeb/Hubs/CrdtMiniLcmApiHub.cs
  • backend/FwLite/FwLiteWeb/Services/NetworkChangeSyncTrigger.cs
  • backend/FwLite/LcmCrdt.Tests/CrdtHttpSyncServiceTests.cs
  • backend/FwLite/LcmCrdt/RemoteSync/CrdtHttpSyncService.cs
  • frontend/viewer/src/locales/en.po
  • frontend/viewer/src/locales/es.po
  • frontend/viewer/src/locales/fr.po
  • frontend/viewer/src/locales/id.po
  • frontend/viewer/src/locales/ko.po
  • frontend/viewer/src/locales/ms.po
  • frontend/viewer/src/locales/sw.po
  • frontend/viewer/src/locales/vi.po
  • frontend/viewer/src/project/SyncDialog.svelte

Comment thread backend/FwLite/FwLiteShared/Projects/LexboxHubConnection.cs Outdated
Comment thread backend/FwLite/FwLiteShared/Projects/LexboxHubConnection.cs

@myieye myieye left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a commit, because LexboxHubConnection couldn't activate.
It seems to be behaving well and I recognize the concepts in play. 👍

I guess the deadlock needs a fix and the InternalsVisibleTo in the assembly file seems redundant to me.


namespace FwLiteShared.Projects;

public sealed class LexboxProjectChangeListener(IServiceProvider serviceProvider, IOptions<AuthConfig> options) : IAsyncDisposable

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class is practically a base class or component of LexboxProjectService. That's the only class using it and HandleOnChanged is supposed to go through LexboxProjectService, rather than come directly to this class so that cache management can happen. I just wonder if this class should be less global.

But whatever you prefer. Just having it registered as a global singleton is probably fine.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. I've gone ahead and changed most services to just use LexboxProjectChangeListener, a couple classes which were using LexboxProjectService were using it's other methods, and just ListenForProjectChanges so I've left those in place to keep the changes smaller, but most of the new classes only needed the methods in the change listener.

As for HandleAuthChange (which I think you were referring to above), I would probably prefer to just remove AuthChangeListenerTrigger and make both the project service and the change listener subscribe directly to the global event bus, the whole point is that any class can listen to events that it cares about, so having a central place to distribute notifications kinda goes against that design. We could easily meet in the middle and just modify the Auth listener trigger to call HandleAuthChange on the change listener and no longer make the project service notify the change listener.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. I think we should drop the AuthChangeListenerTrigger I added.
I guess I forced that into the same pattern as ConnectivitySyncTrigger and NetworkChangeSyncTrigger, which...perhaps also shouldn't be "triggers", but rather expose a simple "CameOnline" event that e.g. LexboxProjectChangeListener can subscribe to.

I'll let you decide what you want to do there.

Comment thread backend/FwLite/FwLiteShared/Properties/AssemblyInfo.cs Outdated
Comment thread backend/FwLite/FwLiteShared/Projects/LexboxHubConnection.cs
@hahn-kev

Copy link
Copy Markdown
Collaborator Author

@myieye you said you pushed a commit but I don't see it, I went ahead and fixed the issue I assume you were seeing, I didn't realize that I never actually tested a real app, oops.

hahn-kev and others added 2 commits June 23, 2026 13:42
And don't let platform connectivity predictions block syncing
@myieye

myieye commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

@hahn-kev You're right, I never pushed the commit I mentioned. Oops.

But, I just pushed a commit now:
I noticed that if you are offline + logged out, then the sync dialog was showing "offline" and didn't respond to coming online, because it's only subscribed to sync event. I swapped it around, so now users have a Log in button if they're not logged in regardless of whether they're online or not.

That button produces a terrible error toast if you're offline, but so does the log in button on the home page.
I've meant to fix that for about 1.5 years now 😆. Maybe now is the time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💻 FW Lite issues related to the fw lite application, not miniLcm or crdt related 📦 Lexbox issues related to any server side code, fw-headless included

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants