Skip to content

Commit c498cb3

Browse files
myieyeclaude
andcommitted
Detect reconnect-time logout by account presence, not token fetch
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>
1 parent c9cae2f commit c498cb3

2 files changed

Lines changed: 18 additions & 16 deletions

File tree

backend/FwLite/FwLiteShared.Tests/Projects/LexboxProjectServiceTests.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ public class LexboxProjectServiceTests
1111
private readonly MemoryCache _cache = new(new MemoryCacheOptions());
1212

1313
[Fact]
14-
public async Task HandleReconnecting_WithValidToken_KeepsCacheAndDoesNotStop()
14+
public async Task HandleReconnecting_WhenSignedIn_KeepsCacheAndDoesNotStop()
1515
{
1616
var connection = new object();
1717
_cache.Set(CacheKey, connection);
@@ -23,15 +23,15 @@ await LexboxProjectService.HandleReconnecting(
2323
NullLogger.Instance,
2424
connection,
2525
() => { stopCalled = true; return Task.CompletedTask; },
26-
hasValidToken: true,
26+
isSignedIn: true,
2727
exception: null);
2828

2929
_cache.TryGetValue(CacheKey, out _).Should().BeTrue();
3030
stopCalled.Should().BeFalse();
3131
}
3232

3333
[Fact]
34-
public async Task HandleReconnecting_WithNoToken_EvictsCacheAndStopsConnection()
34+
public async Task HandleReconnecting_WhenLoggedOut_EvictsCacheAndStopsConnection()
3535
{
3636
var connection = new object();
3737
_cache.Set(CacheKey, connection);
@@ -43,18 +43,18 @@ await LexboxProjectService.HandleReconnecting(
4343
NullLogger.Instance,
4444
connection,
4545
() => { stopCalled = true; return Task.CompletedTask; },
46-
hasValidToken: false,
46+
isSignedIn: false,
4747
exception: null);
4848

4949
_cache.TryGetValue(CacheKey, out _).Should().BeFalse();
5050
stopCalled.Should().BeTrue();
5151
}
5252

5353
[Fact]
54-
public async Task HandleReconnecting_WithNoToken_DoesNotEvictAReplacementConnection()
54+
public async Task HandleReconnecting_WhenLoggedOut_DoesNotEvictAReplacementConnection()
5555
{
5656
// A replacement can be cached between this connection entering Reconnecting and the handler running;
57-
// evicting it would orphan a live connection. The handler must still stop ITSELF (it has no token).
57+
// evicting it would orphan a live connection. The handler must still stop ITSELF (it's logged out).
5858
var replacement = new object();
5959
_cache.Set(CacheKey, replacement);
6060
var stopCalled = false;
@@ -65,7 +65,7 @@ await LexboxProjectService.HandleReconnecting(
6565
NullLogger.Instance,
6666
connection: new object(),
6767
() => { stopCalled = true; return Task.CompletedTask; },
68-
hasValidToken: false,
68+
isSignedIn: false,
6969
exception: null);
7070

7171
_cache.TryGetValue(CacheKey, out object? cached).Should().BeTrue();

backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -460,31 +460,33 @@ internal static async Task EvictAndStopIfCached(string cacheKey, IMemoryCache ca
460460
}
461461

462462
// Internal for unit tests. The retry policy never gives up; this breaks the loop when the user is
463-
// logged out. We treat "no token" as "logged out" — distinguishing a transient token failure from a
464-
// real logout is OAuthClient's job (see its failure classifier), not ours. Once the user signs back
465-
// in, OnAuthenticationChangedAsync rebuilds the listener.
463+
// logged out. Logged-out is tested by account presence (IsSignedIn), not by whether a token can be
464+
// fetched right now: reconnecting happens precisely when the network is flaky, and fetching a token
465+
// can require a refresh round-trip that fails transiently — which would be a false logout. Account
466+
// presence is a local-only read and only flips on a real logout. Once the user signs back in,
467+
// OnAuthenticationChangedAsync rebuilds the listener.
466468
internal static async Task HandleReconnecting(
467469
string cacheKey,
468470
IMemoryCache cache,
469471
ILogger logger,
470472
object connection,
471473
Func<Task> stopConnection,
472-
bool hasValidToken,
474+
bool isSignedIn,
473475
Exception? exception)
474476
{
475477
if (exception is not null)
476478
logger.LogWarning(exception, "SignalR connection reconnecting");
477479
else
478480
logger.LogInformation("SignalR connection reconnecting");
479481

480-
if (hasValidToken) return;
482+
if (isSignedIn) return;
481483

482484
// Same guard as the Closed handler: a replacement connection may have been cached since this one
483485
// started reconnecting; only evict the entry if it is still ours. Stopping ourselves is right
484-
// regardless — we are the connection that has no token.
486+
// regardless — this connection belongs to a signed-out user.
485487
if (cache.TryGetValue(cacheKey, out object? cached) && ReferenceEquals(cached, connection))
486488
cache.Remove(cacheKey);
487-
logger.LogWarning("SignalR reconnect aborted: no auth token (logged out); stopping connection");
489+
logger.LogWarning("SignalR reconnect aborted: user is logged out; stopping connection");
488490
try
489491
{
490492
await stopConnection();
@@ -593,8 +595,8 @@ internal class AdaptiveRetryPolicy(INetworkStatus networkStatus) : IRetryPolicy
593595
var cacheKey = HubConnectionCacheKey(server);
594596
connection.Reconnecting += async exception =>
595597
{
596-
var hasValidToken = await clientFactory.GetClient(server).GetCurrentToken() is not null;
597-
await HandleReconnecting(cacheKey, cache, logger, connection, () => connection.StopAsync(), hasValidToken, exception);
598+
var isSignedIn = await clientFactory.GetClient(server).IsSignedIn();
599+
await HandleReconnecting(cacheKey, cache, logger, connection, () => connection.StopAsync(), isSignedIn, exception);
598600
};
599601
await connection.StartAsync(stoppingToken);
600602
// intentionally AFTER StartAsync (see catch comment)

0 commit comments

Comments
 (0)