Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
51af45a
More robust signal-r connection handling
myieye Mar 18, 2026
d54dcfe
Fix subscription ordering and extract HandleReconnecting for testing
myieye May 29, 2026
d5fa33d
Auto-recover the SignalR listener on auth changes
myieye May 29, 2026
47063dc
Re-establish push listener after a successful sync
myieye May 29, 2026
0b1dd33
Prevent Closed handler from evicting a replacement HubConnection
myieye May 29, 2026
ac4da6f
Harden SignalR listener lifecycle; add MAUI connectivity recovery
myieye Jun 1, 2026
e77d5e1
Add periodic push-listener recovery backstop
myieye Jun 3, 2026
598f119
Resubscribe all registered projects after revive; heal per-project in…
myieye Jun 3, 2026
9a596c4
Guard cache eviction against replacing connections in teardown paths
myieye Jun 3, 2026
95e84c3
Serialize HubConnection creation and revive per server
myieye Jun 3, 2026
96dd74a
Bridge HubConnection logging into the app logger
myieye Jun 3, 2026
7756afa
Re-ensure push listeners on MAUI window resume
myieye Jun 3, 2026
b492c69
Adapt reconnect backoff to device-reported network state
myieye Jun 3, 2026
e71c7e1
Interrupt mid-backoff reconnects on strong network-recovery signals
myieye Jun 3, 2026
c2d817d
Housekeeping: stale TODO, comment accuracy, guard OnProjectUpdated ha…
myieye Jun 3, 2026
2f8463a
Detect reconnect-time logout by account presence, not token fetch
myieye Jun 12, 2026
40068a1
Move listener recovery to hosted services; fix false-logout teardown
myieye Jun 16, 2026
32d76ce
Log when push-listener recovery triggers register and fire
myieye Jun 16, 2026
89d16f3
Minor refactor
myieye Jun 16, 2026
86d1d56
Re-probe server health on reconnect instead of trusting a stale offli…
myieye Jun 17, 2026
190eb03
Log device connectivity transitions as a normal event; ignore *.props…
myieye Jun 17, 2026
3940d96
Show offline state when syncing offline instead of a raw connection e…
myieye Jun 17, 2026
e6811e9
Treat sync timeouts as offline; make the offline toast status-neutral
myieye Jun 17, 2026
d9ead06
Roll back project upload when the initial sync doesn't reach the server
myieye Jun 17, 2026
4177009
Fix inaccurate recovery comment: FwLiteWeb has a connectivity trigger…
myieye Jun 17, 2026
0255309
Extract Lexbox hub connection handling
hahn-kev Jun 19, 2026
a2dccd4
checkpoint
hahn-kev Jun 19, 2026
df27dd4
rework LexboxHubConnection to be durable and out live the signalr con…
hahn-kev Jun 19, 2026
8c92c14
ensure we don't release the connection lock if it was never taken
hahn-kev Jun 19, 2026
1327677
remove service tests
hahn-kev Jun 19, 2026
e553ade
Add Lexbox hub connection unit coverage
hahn-kev Jun 19, 2026
97c173e
Refactor BackgroundSyncService to implement IBackgroundSyncService in…
hahn-kev Jun 22, 2026
8b52157
remove SendAsync from ILexboxSignalRConnection and replace with Liste…
hahn-kev Jun 22, 2026
0906b8a
Fill in some missing connection tests
hahn-kev Jun 22, 2026
980507e
Merge branch 'develop' into signalr-retry-improvements-v3
hahn-kev Jun 22, 2026
b435742
dispose of connections as part of shutdown
hahn-kev Jun 22, 2026
1cb51ba
move the connection lock up to include disconnected checks. Don't cal…
hahn-kev Jun 22, 2026
e4d124e
switch how we release the connection lock to prevent bugs
hahn-kev Jun 23, 2026
4a57d68
remove redundant assembly info
hahn-kev Jun 23, 2026
962494c
fix issue activiating the hub connection
hahn-kev Jun 23, 2026
8c831b0
migrate most services to use LexboxProjectChangeListener directly
hahn-kev Jun 23, 2026
e89d1ef
fix compile error on tests
hahn-kev Jun 23, 2026
53cbc1f
Prioritize IsSignedIn check/status
myieye Jun 23, 2026
386a2e6
remove AuthChangeListnerTrigger and listen to changes from the global…
hahn-kev Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ localResourcesCache/
backend/FwLite/FwLiteShared/wwwroot/viewer

*.csproj.user
backend/Directory.Build.props.user
*.props.user
*.log
failedSyncs/

Expand Down
9 changes: 7 additions & 2 deletions backend/.editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_prefer_local_over_anonymous_function = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
# Remove unnecessary using directives
# Remove unnecessary using directives
dotnet_diagnostic.IDE0005.severity = warning
###############################
# C# Formatting Rules #
Expand Down Expand Up @@ -134,6 +134,11 @@ dotnet_diagnostic.VSTHRD200.severity = none
# "Expression value is never used"
dotnet_diagnostic.IDE0058.severity = suggestion

# IDE0061: Use block body for local function
dotnet_diagnostic.IDE0061.severity = silent
# IDE0022: Use block body for method
dotnet_diagnostic.IDE0022.severity = silent

[{*Kernel}.cs]
dotnet_diagnostic.IDE0058.severity = none

Expand All @@ -146,4 +151,4 @@ indent_size = unset
indent_style = unset
insert_final_newline = false
tab_width = unset
trim_trailing_whitespace = false
trim_trailing_whitespace = false
18 changes: 18 additions & 0 deletions backend/FwLite/FwLiteMaui.Tests/ConnectivitySyncTriggerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using FwLiteMaui.Services;

namespace FwLiteMaui.Tests;

public class ConnectivitySyncTriggerTests
{
[Theory]
[InlineData(NetworkAccess.None, NetworkAccess.Internet, true)]
[InlineData(NetworkAccess.Unknown, NetworkAccess.Internet, true)]
[InlineData(NetworkAccess.ConstrainedInternet, NetworkAccess.Internet, true)]
[InlineData(NetworkAccess.Internet, NetworkAccess.Internet, false)] // already online — don't re-run
[InlineData(NetworkAccess.Internet, NetworkAccess.None, false)] // going offline
[InlineData(NetworkAccess.None, NetworkAccess.Local, false)] // local network only, no internet
public void ShouldRecover_OnlyOnTransitionIntoInternet(NetworkAccess previous, NetworkAccess current, bool expected)
{
ConnectivitySyncTrigger.ShouldRecover(previous, current).Should().Be(expected);
}
}
34 changes: 32 additions & 2 deletions backend/FwLite/FwLiteMaui/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using FwLiteShared.Projects;
using FwLiteShared.Services;
using Microsoft.Extensions.Logging;

namespace FwLiteMaui;

Expand All @@ -6,11 +9,20 @@ public partial class App : Application
public IServiceProvider ServiceProvider { get; }
private readonly MainPage _mainPage;
public static string? OverrideStartupUrl { get; set; }
private readonly LexboxProjectChangeListener _lexboxProjectChangeListener;
private readonly ILogger<App> _logger;

public App(MainPage mainPage, IServiceProvider serviceProvider)
public App(MainPage mainPage, IPreferencesService preferences, LexboxProjectChangeListener lexboxProjectChangeListener, ILogger<App> logger, IServiceProvider serviceProvider)
{
ServiceProvider = serviceProvider;
_mainPage = mainPage;
_lexboxProjectChangeListener = lexboxProjectChangeListener;
_logger = logger;
var lastUrl = preferences.Get(nameof(PreferenceKey.AppLastUrl));
if (lastUrl?.StartsWith('/') == true)
{
mainPage.StartPath = lastUrl;
}
InitializeComponent();
}

Expand All @@ -21,7 +33,25 @@ internal void LoadAppUrl(string url)

protected override Window CreateWindow(IActivationState? activationState)
{
return CreateWindow(_mainPage);
var window = CreateWindow(_mainPage);
// An OS-frozen app (e.g. Android Doze/Standby) can come back with its push listener down and no connectivity
// transition to recover on; resume is when the user is watching, so recover immediately instead of
// waiting for the periodic backstop.
window.Resumed += (_, _) => _ = EnsureListenersAfterResume();
return window;
}

private async Task EnsureListenersAfterResume()
{
try
{
_logger.LogInformation("App resumed; ensuring push listeners");
await _lexboxProjectChangeListener.EnsureListenersForTrackedProjects(kickReconnecting: true);
}
catch (Exception e)
{
_logger.LogWarning(e, "Failed to ensure push listeners after app resume");
}
}

public static Window CreateWindow(MainPage mainPage, int? width = null)
Expand Down
2 changes: 2 additions & 0 deletions backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ public static void AddFwLiteMauiServices(this IServiceCollection services,
services.AddSingleton(_ => Preferences.Default);
services.AddSingleton(_ => VersionTracking.Default);
services.AddSingleton(_ => Connectivity.Current);
services.AddSingleton<IHostedService, ConnectivitySyncTrigger>();
services.AddSingleton<INetworkStatus, ConnectivityNetworkStatus>();
services.AddSingleton(_ => Launcher.Default);
services.AddSingleton(_ => Browser.Default);
services.AddSingleton(_ => Share.Default);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using FwLiteShared.Services;

namespace FwLiteMaui.Services;

public class ConnectivityNetworkStatus(IConnectivity connectivity) : INetworkStatus
{
// ConstrainedInternet (e.g. captive portal) counts as offline, matching ConnectivitySyncTrigger.
public bool IsOnline => connectivity.NetworkAccess == NetworkAccess.Internet;
}
62 changes: 62 additions & 0 deletions backend/FwLite/FwLiteMaui/Services/ConnectivitySyncTrigger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using FwLiteShared.Projects;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace FwLiteMaui.Services;

// Primary use case: app started offline should start syncing if the device comes online
public sealed class ConnectivitySyncTrigger(
IConnectivity connectivity,
LexboxProjectChangeListener lexboxProjectChangeListener,
ILogger<ConnectivitySyncTrigger> logger) : IHostedService
{
private NetworkAccess _lastAccess;

public Task StartAsync(CancellationToken cancellationToken)
{
_lastAccess = connectivity.NetworkAccess;
connectivity.ConnectivityChanged += OnConnectivityChanged;
logger.LogInformation("Watching device connectivity to re-establish push listeners (current access: {NetworkAccess})", _lastAccess);
return Task.CompletedTask;
}

public Task StopAsync(CancellationToken cancellationToken)
{
connectivity.ConnectivityChanged -= OnConnectivityChanged;
return Task.CompletedTask;
}

private void OnConnectivityChanged(object? sender, ConnectivityChangedEventArgs e)
{
var previous = _lastAccess;
var current = _lastAccess = e.NetworkAccess;

logger.LogInformation("Device connectivity changed: {Previous} -> {Current} (profiles: {Profiles})",
previous, current, string.Join(", ", e.ConnectionProfiles));

if (!ShouldRecover(previous, current)) return;

logger.LogInformation("Connectivity regained (internet access); ensuring push listeners");
_ = EnsureListeners();
}

private async Task EnsureListeners(CancellationToken cancellationToken = default)
{
try
{
await lexboxProjectChangeListener.EnsureListenersForTrackedProjects(kickReconnecting: true, cancellationToken);
}
catch (Exception e)
{
logger.LogWarning(e, "Failed to ensure push listeners after connectivity change");
}
}

// Only react to a transition INTO internet access, so recovery doesn't re-run on every connectivity
// change (e.g. wifi<->cellular) while already online. Idempotent recovery makes a missed edge harmless;
// this just keeps the common flapping case quiet.
public static bool ShouldRecover(NetworkAccess previous, NetworkAccess current)
{
return current == NetworkAccess.Internet && previous != NetworkAccess.Internet;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using FwLiteShared.Projects;
using FwLiteShared.Services;
using Microsoft.AspNetCore.SignalR.Client;

namespace FwLiteShared.Tests.Projects;

public class AdaptiveRetryPolicyTests
{
private class FakeNetworkStatus(bool isOnline) : INetworkStatus
{
public bool IsOnline => isOnline;
}

private static RetryContext Context(long previousRetryCount) => new()
{
PreviousRetryCount = previousRetryCount,
ElapsedTime = TimeSpan.Zero,
RetryReason = new Exception("test"),
};

[Theory]
[InlineData(true)]
[InlineData(false)]
public void FirstTwoAttemptsAreImmediateRegardlessOfNetwork(bool isOnline)
{
var policy = new LexboxHubConnection.AdaptiveRetryPolicy(new FakeNetworkStatus(isOnline));

policy.NextRetryDelay(Context(0)).Should().Be(TimeSpan.Zero);
policy.NextRetryDelay(Context(1)).Should().Be(TimeSpan.FromSeconds(5));
}

[Theory]
[InlineData(2)]
[InlineData(10)]
[InlineData(1000)]
public void WhileOnline_RetriesQuickly(long previousRetryCount)
{
var policy = new LexboxHubConnection.AdaptiveRetryPolicy(new FakeNetworkStatus(isOnline: true));

policy.NextRetryDelay(Context(previousRetryCount)).Should().Be(TimeSpan.FromSeconds(10));
}

[Theory]
[InlineData(2)]
[InlineData(10)]
[InlineData(1000)]
public void WhileOffline_BacksOff(long previousRetryCount)
{
var policy = new LexboxHubConnection.AdaptiveRetryPolicy(new FakeNetworkStatus(isOnline: false));

policy.NextRetryDelay(Context(previousRetryCount)).Should().Be(TimeSpan.FromSeconds(60));
}

[Fact]
public void ReevaluatesNetworkStatusPerAttempt()
{
var online = false;
var status = new ToggleNetworkStatus(() => online);
var policy = new LexboxHubConnection.AdaptiveRetryPolicy(status);

policy.NextRetryDelay(Context(5)).Should().Be(TimeSpan.FromSeconds(60));
online = true;
policy.NextRetryDelay(Context(6)).Should().Be(TimeSpan.FromSeconds(10));
}

private class ToggleNetworkStatus(Func<bool> isOnline) : INetworkStatus
{
public bool IsOnline => isOnline();
}

// Returning null would end SignalR's retry loop permanently; the deliberate stop on logout belongs to
// HandleReconnecting, never the policy.
[Theory]
[InlineData(0, true)]
[InlineData(1, false)]
[InlineData(50, true)]
[InlineData(50, false)]
public void NeverGivesUp(long previousRetryCount, bool isOnline)
{
var policy = new LexboxHubConnection.AdaptiveRetryPolicy(new FakeNetworkStatus(isOnline));

policy.NextRetryDelay(Context(previousRetryCount)).Should().NotBeNull();
}
}
Loading
Loading