Sync: keep the remembered sign-in when the provider is merely down, and say why SYNC NOW found the machine offline
ci / desktop nightly (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / android head (push) Canceled after 4m10s
ci / build and test (push) Canceled after 4m12s

This commit is contained in:
jaap-jan committed 2026-10-07 11:11:09 +02:00
1 parent 5b32c76c95
commit 509937c4b8
5 files changed
+89 -6

No files matched your search

@@ -87,6 +87,11 @@
<TextBlock Classes="settingsrowtitle" Text="Synchronise" /> <TextBlock Classes="settingsrowtitle" Text="Synchronise" />
<TextBlock Classes="settingsrowcaption" <TextBlock Classes="settingsrowcaption"
Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open." /> Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open." />
<!-- What SYNC NOW said. The status bar that carries it elsewhere is hidden in settings
mode, so without this the button ran, failed or found the machine offline in silence. -->
<TextBlock Classes="settingsrowcaption"
IsVisible="{Binding Vault.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}"
Text="{Binding Vault.Status}" />
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"> <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Classes="ghost" Height="38" Content="SIGN IN" Command="{Binding SignInCommand}" <Button Classes="ghost" Height="38" Content="SIGN IN" Command="{Binding SignInCommand}"
@@ -70,6 +70,13 @@
</StackPanel> </StackPanel>
</Grid> </Grid>
<!-- What SYNC NOW said. The status bar that carries it elsewhere is hidden in settings mode, so
without this the button ran, failed or found the machine offline in perfect silence. -->
<TextBlock Margin="0,10,0,0" FontSize="11.5" TextWrapping="Wrap" HorizontalAlignment="Right"
Foreground="{StaticResource TextFaint}"
IsVisible="{Binding Vault.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}"
Text="{Binding Vault.Status}" />
<!-- The name-a-vault and rename-a-vault forms, in place — this window has no modal idiom for a bare <!-- The name-a-vault and rename-a-vault forms, in place — this window has no modal idiom for a bare
text field, and the members panel below is a different kind of overlay: over one vault's people, text field, and the members panel below is a different kind of overlay: over one vault's people,
not over a form. --> not over a form. -->
@@ -3227,6 +3227,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
if (token is null if (token is null
|| !Uri.TryCreate(vault.Session.Profile.ServerUrl, UriKind.Absolute, out var server)) || !Uri.TryCreate(vault.Session.Profile.ServerUrl, UriKind.Absolute, out var server))
{ {
vault.OfflineReason = "This machine has no remembered sign-in. Press SIGN IN in "
+ "Preferences to start syncing.";
return null; return null;
} }
@@ -3234,6 +3237,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
connection = resumed; connection = resumed;
rememberedToken = token; rememberedToken = token;
vault.OfflineReason = null;
OnPropertyChanged(nameof(IsOnline)); OnPropertyChanged(nameof(IsOnline));
RaiseSyncState(); RaiseSyncState();
@@ -3244,15 +3248,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return resumed; return resumed;
} }
catch (OidcException exception) catch (OidcException exception) when (IsRefusedGrant(exception))
{ {
// The provider answered and said no: the session was revoked, or the token was rotated and // The provider answered and said no: the session was revoked, or the token was rotated and
// this machine kept the old one. Retrying costs a round trip a minute and can only ever get // this machine kept the old one. Retrying costs a round trip a minute and can only ever get
// the same answer, so the token goes and the user is told the one thing that fixes it. // the same answer, so the token goes and the user is told the one thing that fixes it.
await ForgetSignInAsync(cancellationToken).ConfigureAwait(true); await ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
Announce($"Your sign-in has expired, so this machine is offline: {exception.Message} " var message = $"Your sign-in has expired, so this machine is offline: {exception.Message} "
+ "Sign in again from Preferences to start syncing."); + "Sign in again from Preferences to start syncing.";
vault.OfflineReason = message;
Announce(message);
return null; return null;
} }
@@ -3260,11 +3267,29 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{ {
// Everything else is a machine with no network, a server that is down, or a vault that was // Everything else is a machine with no network, a server that is down, or a vault that was
// locked mid-attempt — all of which are ordinary and all of which resolve themselves. The // locked mid-attempt — all of which are ordinary and all of which resolve themselves. The
// titlebar already says OFFLINE; a socket error once a minute would say nothing more. // titlebar already says OFFLINE; a socket error once a minute would say nothing more. That
// includes an OidcException with no refusal in it: discovery answering 502, or a token endpoint
// whose body is a proxy's error page, is the identity provider restarting. Forgetting the token
// for that used to strand a machine offline for good once the provider came back.
vault.OfflineReason = $"The server could not be reached: {exception.Message} Changes are "
+ "queued and will be sent once it can.";
return null; return null;
} }
} }
/// <summary>
/// Whether a failed resume was the provider refusing the token, rather than failing to answer.
/// </summary>
/// <remarks>
/// <c>invalid_grant</c> is what RFC 6749 §5.2 has a token endpoint say about a refresh token that is
/// expired, revoked or already rotated — the one answer that a retry cannot change. Every other
/// <see cref="OidcException"/> on this path is discovery or the token endpoint failing, which is a
/// provider that is down and will be back.
/// </remarks>
private static bool IsRefusedGrant(OidcException exception) =>
string.Equals(exception.ErrorCode, "invalid_grant", StringComparison.Ordinal);
/// <summary> /// <summary>
/// Writes the connection's current refresh token into the vault, if it has changed. /// Writes the connection's current refresh token into the vault, if it has changed.
/// </summary> /// </summary>
@@ -6199,8 +6199,10 @@ internal sealed partial class VaultViewModel(
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server) if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
{ {
LastSyncFailed = true; LastSyncFailed = true;
Status = "Offline. Changes are queued and will be sent as soon as this machine " Status = OfflineReason is { } reason
+ "can reach the server again."; ? $"Offline. {reason}"
: "Offline. Changes are queued and will be sent as soon as this machine "
+ "can reach the server again.";
return; return;
} }
@@ -6214,6 +6216,14 @@ internal sealed partial class VaultViewModel(
}).ConfigureAwait(true); }).ConfigureAwait(true);
} }
/// <summary>Why the last attempt to get online failed, or null when nothing has said.</summary>
/// <remarks>
/// Set by the shell, which is the thing that knows: <see cref="ServerReconnectHandler" /> answers null
/// for every reason on purpose, so that a background pass stays quiet. A press of SYNC NOW is somebody
/// asking, though, and "offline" with no why is what left a machine stuck with no way to find out.
/// </remarks>
internal string? OfflineReason { get; set; }
/// <summary> /// <summary>
/// Finds a server to sync against, getting this machine online if it is not. /// Finds a server to sync against, getting this machine online if it is not.
/// </summary> /// </summary>
@@ -9702,6 +9702,42 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
resumeAttempts.ShouldBe(attempted); resumeAttempts.ShouldBe(attempted);
} }
/// <remarks>
/// The machine this was found on: the identity provider restarted, discovery answered 502 for a
/// moment, and that was read as a refusal — so the remembered sign-in was deleted and the machine
/// stayed OFFLINE long after the server was back, with SYNC NOW saying nothing about why.
/// </remarks>
[Fact]
public async Task AProviderThatIsDown_KeepsTheSignInAndComesBackOnline()
{
await UnlockedAsync();
await shell.Vault!.SyncOnOpenAsync(Token);
await shell.LockCommand.ExecuteAsync(null);
resumeFailure = new OidcException(
"Discovery at 'https://id.example/.well-known/openid-configuration' returned 502.");
var relaunch = Relaunch(resume: ResumeAsync);
await using var _ = relaunch.ConfigureAwait(false);
await relaunch.StartAsync(Token);
relaunch.Passphrase = Passphrase;
await relaunch.UnlockCommand.ExecuteAsync(null);
await relaunch.Vault!.SyncCommand.ExecuteAsync(null);
relaunch.IsOnline.ShouldBeFalse();
relaunch.Vault.Status.ShouldContain("returned 502", Case.Insensitive,
"SYNC NOW must say why it is offline, not just that it is");
// The provider comes back, and the next pass resumes with the token it still has.
resumeFailure = null;
await relaunch.Vault.SyncOnOpenAsync(Token);
relaunch.IsOnline.ShouldBeTrue();
resumedWith.ShouldBe("refresh-token-1");
}
/// <remarks> /// <remarks>
/// The pass that runs when the vault opens used to be skipped in the application and nowhere else: the /// The pass that runs when the vault opens used to be skipped in the application and nowhere else: the
/// loop is started from inside the unlock command, so the busy flag it yields to was raised by the /// loop is started from inside the unlock command, so the busy flag it yields to was raised by the