22using System . Net . Http . Json ;
33using System . Runtime . CompilerServices ;
44using FwLiteShared . Auth ;
5- using FwLiteShared . Events ;
65using FwLiteShared . Services ;
76using FwLiteShared . Sync ;
87using LcmCrdt ;
1918
2019namespace FwLiteShared . Projects ;
2120
22- public class LexboxProjectService : IDisposable
21+ public class LexboxProjectService
2322{
2423 private readonly OAuthClientFactory clientFactory ;
2524 private readonly ILogger < LexboxProjectService > logger ;
@@ -28,9 +27,7 @@ public class LexboxProjectService : IDisposable
2827 private readonly BackgroundSyncService backgroundSyncService ;
2928 private readonly IOptions < AuthConfig > options ;
3029 private readonly IMemoryCache cache ;
31- private readonly GlobalEventBus globalEventBus ;
3230 private readonly INetworkStatus networkStatus ;
33- private readonly IDisposable onAuthChangedSubscription ;
3431 // Stable record of projects we've been asked to listen for, by project id. Survives connection
3532 // lifetimes (unlike _reconnectProjects) so we can resubscribe after a hub-connection rebuild.
3633 private readonly ConcurrentDictionary < Guid , ProjectData > _trackedProjects = new ( ) ;
@@ -43,7 +40,6 @@ public LexboxProjectService(
4340 BackgroundSyncService backgroundSyncService ,
4441 IOptions < AuthConfig > options ,
4542 IMemoryCache cache ,
46- GlobalEventBus globalEventBus ,
4743 INetworkStatus networkStatus )
4844 {
4945 this . clientFactory = clientFactory ;
@@ -53,19 +49,14 @@ public LexboxProjectService(
5349 this . backgroundSyncService = backgroundSyncService ;
5450 this . options = options ;
5551 this . cache = cache ;
56- this . globalEventBus = globalEventBus ;
5752 this . networkStatus = networkStatus ;
58- onAuthChangedSubscription = globalEventBus . OnAuthenticationChanged . Subscribe ( @event =>
59- {
60- InvalidateProjectsCache ( @event . Server ) ;
61- _ = OnAuthenticationChangedAsync ( @event . Server ) ;
62- } ) ;
6353 }
6454
65- private async Task OnAuthenticationChangedAsync ( LexboxServer server )
55+ public async Task HandleAuthChanged ( LexboxServer server )
6656 {
6757 try
6858 {
59+ InvalidateProjectsCache ( server ) ;
6960 // Tear down any cached hub connection — it may be holding stale auth, or belong to a
7061 // previously-signed-in identity. The next ListenForProjectChanges call will rebuild it
7162 // with the current token (or no-op if the user is now logged out).
@@ -135,11 +126,6 @@ private Dictionary<string, HubConnection> ConnectedServerConnections()
135126 return connected ;
136127 }
137128
138- public void Dispose ( )
139- {
140- onAuthChangedSubscription . Dispose ( ) ;
141- }
142-
143129 public LexboxServer [ ] Servers ( )
144130 {
145131 return options . Value . LexboxServers ;
@@ -374,7 +360,8 @@ public async Task ListenForProjectChanges(ProjectData projectData, CancellationT
374360 }
375361 }
376362
377- private static string HubConnectionCacheKey ( LexboxServer server ) => $ "LexboxProjectChangeListener|{ server . Authority . Authority } ";
363+ // Internal for unit tests.
364+ internal static string HubConnectionCacheKey ( LexboxServer server ) => $ "LexboxProjectChangeListener|{ server . Authority . Authority } ";
378365
379366 // Only for callers with strong evidence the network just returned (connectivity regained, app resume, a
380367 // successful sync to this server): SignalR has no retry-now API, so stop-and-rebuild is the only way to
@@ -464,7 +451,7 @@ internal static async Task EvictAndStopIfCached(string cacheKey, IMemoryCache ca
464451 // fetched right now: reconnecting happens precisely when the network is flaky, and fetching a token
465452 // can require a refresh round-trip that fails transiently — which would be a false logout. Account
466453 // presence is a local-only read and only flips on a real logout. Once the user signs back in,
467- // OnAuthenticationChangedAsync rebuilds the listener.
454+ // HandleAuthChanged rebuilds the listener.
468455 internal static async Task HandleReconnecting (
469456 string cacheKey ,
470457 IMemoryCache cache ,
@@ -519,20 +506,25 @@ internal class AdaptiveRetryPolicy(INetworkStatus networkStatus) : IRetryPolicy
519506 public async Task < HubConnection ? > StartLexboxProjectChangeListener ( LexboxServer server ,
520507 CancellationToken stoppingToken )
521508 {
522- // A null token means logged out for this server (OAuthClient's failure classifier owns the
523- // transient-vs-logout decision — not us). Drop any stale cached connection and stand down;
524- // sign-in fires AuthenticationChangedEvent and OnAuthenticationChangedAsync rebuilds the listener.
525- if ( await clientFactory . GetClient ( server ) . GetCurrentToken ( ) is null )
526- {
527- cache . Remove ( HubConnectionCacheKey ( server ) ) ;
528- logger . LogWarning ( "Unable to create signalR client, user is not authenticated to {OriginDomain}" , server . Authority ) ;
529- return null ;
530- }
509+ // Reuse an existing connection without consulting auth: it may be healthily auto-reconnecting, and a
510+ // transient token-refresh failure must not be mistaken for a logout and tear it down — the same
511+ // false-logout trap HandleReconnecting avoids. (Removing the cache entry disposes the connection via
512+ // the post-eviction callback, killing its retry loop and resubscribe set.)
531513 if ( cache . TryGetValue ( HubConnectionCacheKey ( server ) , out HubConnection ? connection ) && connection is not null )
532514 {
533515 return connection ;
534516 }
535517
518+ // No connection to reuse: build one only if actually signed in. Logged-out is tested by account
519+ // presence (IsSignedIn) — a local-only read that flips solely on a real logout — not GetCurrentToken,
520+ // which can be null transiently (expired token + flaky network) while the user is still signed in.
521+ // Once the user signs back in, HandleAuthChanged rebuilds the listener.
522+ if ( ! await clientFactory . GetClient ( server ) . IsSignedIn ( ) )
523+ {
524+ logger . LogWarning ( "Unable to create signalR client, user is not authenticated to {OriginDomain}" , server . Authority ) ;
525+ return null ;
526+ }
527+
536528 // Serialize creation per server: concurrent callers would otherwise each build a connection, and the
537529 // cache's last-writer-wins replacement disposes the loser mid-use and discards its resubscribe set.
538530 var gate = ConnectionGate ( server ) ;
0 commit comments