Skip to content

Commit 29c91a8

Browse files
myieyeclaude
andcommitted
Move listener recovery to hosted services; fix false-logout teardown
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. Bumps harmony for the DataModel.Add concurrency fix (adds IChange.commitId). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2f8463a commit 29c91a8

14 files changed

Lines changed: 158 additions & 45 deletions

File tree

backend/.editorconfig

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ csharp_style_deconstructed_variable_declaration = true:suggestion
9696
csharp_prefer_simple_default_expression = true:suggestion
9797
csharp_style_prefer_local_over_anonymous_function = true:suggestion
9898
csharp_style_inlined_variable_declaration = true:suggestion
99-
# Remove unnecessary using directives
99+
# Remove unnecessary using directives
100100
dotnet_diagnostic.IDE0005.severity = warning
101101
###############################
102102
# C# Formatting Rules #
@@ -134,6 +134,11 @@ dotnet_diagnostic.VSTHRD200.severity = none
134134
# "Expression value is never used"
135135
dotnet_diagnostic.IDE0058.severity = suggestion
136136

137+
# IDE0061: Use block body for local function
138+
dotnet_diagnostic.IDE0061.severity = silent
139+
# IDE0022: Use block body for method
140+
dotnet_diagnostic.IDE0022.severity = silent
141+
137142
[{*Kernel}.cs]
138143
dotnet_diagnostic.IDE0058.severity = none
139144

@@ -146,4 +151,4 @@ indent_size = unset
146151
indent_style = unset
147152
insert_final_newline = false
148153
tab_width = unset
149-
trim_trailing_whitespace = false
154+
trim_trailing_whitespace = false

backend/FwLite/FwLiteMaui/App.xaml.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public App(MainPage mainPage, IPreferencesService preferences, LexboxProjectServ
2626
protected override Window CreateWindow(IActivationState? activationState)
2727
{
2828
var window = CreateWindow(_mainPage);
29-
// An OS-frozen app (e.g. Android Doze) can come back with its push listener down and no connectivity
29+
// An OS-frozen app (e.g. Android Doze/Standby) can come back with its push listener down and no connectivity
3030
// transition to recover on; resume is when the user is watching, so recover immediately instead of
3131
// waiting for the periodic backstop.
3232
window.Resumed += (_, _) => _ = EnsureListenersAfterResume();

backend/FwLite/FwLiteMaui/Services/ConnectivitySyncTrigger.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,7 @@
44

55
namespace FwLiteMaui.Services;
66

7-
// When the device regains internet access, a push listener that failed to start while offline (cold start)
8-
// won't recover on its own — there's no connection for SignalR's automatic reconnect to retry. This nudges
9-
// LexboxProjectService to (re)establish listeners for tracked projects when connectivity returns. Recovery
10-
// is idempotent: a healthy cached connection short-circuits and a logged-out server no-ops on the token check.
7+
// Primary use case: app started offline should start syncing if the device comes online
118
public sealed class ConnectivitySyncTrigger(
129
IConnectivity connectivity,
1310
LexboxProjectService lexboxProjectService,

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using FwLiteShared.Auth;
12
using FwLiteShared.Projects;
23
using Microsoft.AspNetCore.SignalR.Client;
34
using Microsoft.Extensions.Caching.Memory;
@@ -10,6 +11,34 @@ public class LexboxProjectServiceTests
1011
private const string CacheKey = "test-cache-key";
1112
private readonly MemoryCache _cache = new(new MemoryCacheOptions());
1213

14+
[Fact]
15+
public async Task StartLexboxProjectChangeListener_ReusesCachedConnection_WithoutConsultingAuth()
16+
{
17+
// Guards the false-logout teardown: an auth check ahead of the cache lookup tore down a healthy,
18+
// auto-reconnecting connection whenever GetCurrentToken was transiently null (expired token + flaky
19+
// network) while the user was still signed in. Reuse must be independent of auth — encoded here by a
20+
// null clientFactory, so any auth access sneaking ahead of the cache lookup throws and fails the test.
21+
var server = new LexboxServer(new Uri("https://example.test/"), "Test");
22+
var cacheKey = LexboxProjectService.HubConnectionCacheKey(server);
23+
await using var connection = new HubConnectionBuilder().WithUrl("http://localhost/test").Build();
24+
_cache.Set(cacheKey, connection);
25+
var service = new LexboxProjectService(
26+
clientFactory: null!,
27+
logger: NullLogger<LexboxProjectService>.Instance,
28+
loggerFactory: null!,
29+
httpMessageHandlerFactory: null!,
30+
backgroundSyncService: null!,
31+
options: null!,
32+
cache: _cache,
33+
networkStatus: null!);
34+
35+
var result = await service.StartLexboxProjectChangeListener(server, CancellationToken.None);
36+
37+
result.Should().BeSameAs(connection);
38+
_cache.TryGetValue(cacheKey, out HubConnection? cached).Should().BeTrue("the connection must not be evicted");
39+
cached.Should().BeSameAs(connection);
40+
}
41+
1342
[Fact]
1443
public async Task HandleReconnecting_WhenSignedIn_KeepsCacheAndDoesNotStop()
1544
{

backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,11 @@ public static IServiceCollection AddFwLiteShared(this IServiceCollection service
4545
services.AddSingleton<BackgroundSyncService>();
4646
services.AddSingleton<IHostedService>(s => s.GetRequiredService<BackgroundSyncService>());
4747
services.AddSingleton<IHostedService, PushListenerRecoveryService>();
48+
services.AddSingleton<IHostedService, AuthChangeListenerTrigger>();
4849
services.AddSingleton<UpdateChecker>();
4950
services.AddSingleton<IHostedService>(s => s.GetRequiredService<UpdateChecker>());
5051
services.TryAddSingleton<IPlatformUpdateService, CorePlatformUpdateService>();
51-
services.TryAddSingleton<INetworkStatus, AlwaysOnlineNetworkStatus>();
52+
services.TryAddSingleton<INetworkStatus, NetworkInterfaceNetworkStatus>();
5253
services.AddSingleton<UpdateService>();
5354
services.AddSingleton<TestingService>();
5455
services.AddOptions<FwLiteConfig>().BindConfiguration("FwLite");
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using FwLiteShared.Events;
2+
using Microsoft.Extensions.Hosting;
3+
4+
namespace FwLiteShared.Projects;
5+
6+
// Re-establishes push listeners when authentication changes (sign-in restores them; sign-out evicts stale
7+
// connections). Auth is a host-agnostic GlobalEventBus event, so — unlike the host-specific connectivity
8+
// triggers — this registers once in the shared kernel and runs everywhere. HandleAuthChanged owns the
9+
// reaction and its own error handling; this owns only the subscription lifecycle.
10+
public sealed class AuthChangeListenerTrigger(
11+
GlobalEventBus globalEventBus,
12+
LexboxProjectService lexboxProjectService) : IHostedService
13+
{
14+
private IDisposable? _subscription;
15+
16+
public Task StartAsync(CancellationToken cancellationToken)
17+
{
18+
_subscription = globalEventBus.OnAuthenticationChanged
19+
.Subscribe(@event => _ = lexboxProjectService.HandleAuthChanged(@event.Server));
20+
return Task.CompletedTask;
21+
}
22+
23+
public Task StopAsync(CancellationToken cancellationToken)
24+
{
25+
_subscription?.Dispose();
26+
_subscription = null;
27+
return Task.CompletedTask;
28+
}
29+
}

backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs

Lines changed: 20 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
using System.Net.Http.Json;
33
using System.Runtime.CompilerServices;
44
using FwLiteShared.Auth;
5-
using FwLiteShared.Events;
65
using FwLiteShared.Services;
76
using FwLiteShared.Sync;
87
using LcmCrdt;
@@ -19,7 +18,7 @@
1918

2019
namespace 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);
Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
namespace FwLiteShared.Services;
22

33
/// <summary>
4-
/// What the device believes about its own connectivity. Hosts without a connectivity API
5-
/// (e.g. FwLiteWeb) use <see cref="AlwaysOnlineNetworkStatus"/>.
4+
/// What the device believes about its own connectivity. Hosts without a richer connectivity API
5+
/// (e.g. FwLiteWeb) use <see cref="NetworkInterfaceNetworkStatus"/>.
66
/// </summary>
77
public interface INetworkStatus
88
{
99
bool IsOnline { get; }
1010
}
11-
12-
public class AlwaysOnlineNetworkStatus : INetworkStatus
13-
{
14-
public bool IsOnline => true;
15-
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using System.Net.NetworkInformation;
2+
3+
namespace FwLiteShared.Services;
4+
5+
/// <summary>
6+
/// Reports connectivity from the OS network-interface table via
7+
/// <see cref="NetworkInterface.GetIsNetworkAvailable"/>. The signal is asymmetric: <see cref="IsOnline"/>
8+
/// false is authoritative (no non-loopback interface is up, so the device is offline), but true only means
9+
/// an interface is up — not that the internet is reachable. A virtual adapter (VPN, WSL, Hyper-V) or a LAN
10+
/// with a dead uplink still reads as online. Suitable for hosts without a richer connectivity API.
11+
/// </summary>
12+
public class NetworkInterfaceNetworkStatus : INetworkStatus
13+
{
14+
public bool IsOnline => NetworkInterface.GetIsNetworkAvailable();
15+
}

backend/FwLite/FwLiteShared/Sync/SyncService.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System.Diagnostics;
21
using FwLiteShared.Auth;
32
using FwLiteShared.Events;
43
using FwLiteShared.Projects;

0 commit comments

Comments
 (0)