diff --git a/App.Avalonia/App.axaml.cs b/App.Avalonia/App.axaml.cs index 52322f2..2d0d2a4 100644 --- a/App.Avalonia/App.axaml.cs +++ b/App.Avalonia/App.axaml.cs @@ -147,12 +147,16 @@ private async Task InitializeServicesAsync() var credentialManager = _services.GetRequiredService(); var rpcController = _services.GetRequiredService(); + var settingsManager = _services.GetRequiredService>(); AppBootstrapLogger.Info("Initializing credentials and RPC connection..."); - using var credentialLoadCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var appStopping = _hostApplicationLifetime?.ApplicationStopping ?? CancellationToken.None; + using var credentialLoadCts = CancellationTokenSource.CreateLinkedTokenSource(appStopping); + credentialLoadCts.CancelAfter(TimeSpan.FromSeconds(15)); + var loadCredentialsTask = credentialManager.LoadCredentials(credentialLoadCts.Token); - var reconnectTask = rpcController.Reconnect(); + var reconnectTask = ReconnectWithStartupRetryAsync(rpcController, appStopping); try { @@ -164,11 +168,91 @@ private async Task InitializeServicesAsync() if (loadCredentialsTask.IsFaulted) AppBootstrapLogger.Error("Credential initialization failed", loadCredentialsTask.Exception?.GetBaseException()); + // reconnectTask logs its own errors internally; just note if it threw here if (reconnectTask.IsFaulted) - AppBootstrapLogger.Error("RPC reconnect failed", reconnectTask.Exception?.GetBaseException()); - else if (reconnectTask.IsCanceled) - AppBootstrapLogger.Warn("RPC reconnect canceled"); + AppBootstrapLogger.Error("Startup reconnect failed unexpectedly", reconnectTask.Exception?.GetBaseException()); + } + + var reconnectSucceeded = reconnectTask is { IsCompletedSuccessfully: true, Result: true }; + + if (!reconnectSucceeded) + AppBootstrapLogger.Warn("Startup continuing in disconnected state after retry exhaustion"); + + try + { + await MaybeAutoStartVpnOnLaunchAsync(settingsManager, credentialManager, rpcController, reconnectSucceeded, appStopping); + } + catch (Exception ex) + { + AppBootstrapLogger.Error("ConnectOnLaunch failed", ex); + } + } + + private async Task ReconnectWithStartupRetryAsync(IRpcController rpcController, CancellationToken ct) + { + TimeSpan[] delays = + [ + TimeSpan.Zero, + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(4), + TimeSpan.FromSeconds(8), + ]; + + Exception? lastError = null; + + for (var attempt = 0; attempt < delays.Length; attempt++) + { + if (attempt > 0) + await Task.Delay(delays[attempt], ct); + + try + { + await rpcController.Reconnect(ct); + AppBootstrapLogger.Info($"RPC reconnect succeeded on attempt {attempt + 1}/{delays.Length}"); + return true; + } + catch (Exception ex) when (!ct.IsCancellationRequested) + { + lastError = ex; + AppBootstrapLogger.Warn($"RPC reconnect attempt {attempt + 1}/{delays.Length} failed: {ex.Message}"); + } } + + AppBootstrapLogger.Error("RPC reconnect exhausted startup retries", lastError); + return false; + } + + private async Task MaybeAutoStartVpnOnLaunchAsync( + ISettingsManager settingsManager, + ICredentialManager credentialManager, + IRpcController rpcController, + bool reconnectSucceeded, + CancellationToken ct) + { + var settings = await settingsManager.Read(ct); + if (!settings.ConnectOnLaunch) + return; + + if (!reconnectSucceeded) + { + AppBootstrapLogger.Info("ConnectOnLaunch skipped because startup reconnect did not succeed"); + return; + } + + var creds = credentialManager.GetCachedCredentials(); + var rpc = rpcController.GetState(); + + if (creds.State != CredentialState.Valid || + rpc.RpcLifecycle != RpcLifecycle.Connected || + rpc.VpnLifecycle != VpnLifecycle.Stopped) + { + AppBootstrapLogger.Info($"ConnectOnLaunch skipped (cred={creds.State}, rpc={rpc.RpcLifecycle}, vpn={rpc.VpnLifecycle})"); + return; + } + + await rpcController.StartVpn(ct); + AppBootstrapLogger.Info("ConnectOnLaunch started VPN successfully"); } private void ConfigureTrayIcons(TrayIconViewModel trayIconViewModel) diff --git a/App.Avalonia/Views/Pages/SettingsMainPage.axaml b/App.Avalonia/Views/Pages/SettingsMainPage.axaml index aad2064..f348b21 100644 --- a/App.Avalonia/Views/Pages/SettingsMainPage.axaml +++ b/App.Avalonia/Views/Pages/SettingsMainPage.axaml @@ -26,7 +26,7 @@ + Text="This setting controls whether the Coder Desktop app starts when you sign in." /> diff --git a/App.Shared/Services/RpcController.cs b/App.Shared/Services/RpcController.cs index 4122913..d8be511 100644 --- a/App.Shared/Services/RpcController.cs +++ b/App.Shared/Services/RpcController.cs @@ -277,15 +277,19 @@ private async Task DisposeSpeaker() private void SpeakerOnError(Exception e) { - Debug.WriteLine($"Error: {e}"); - try - { - using var _ = Reconnect(CancellationToken.None); - } - catch + _logger.LogWarning(e, "RPC speaker error; attempting immediate reconnect"); + + _ = Task.Run(async () => { - // best effort to immediately reconnect - } + try + { + await Reconnect(CancellationToken.None); + } + catch (Exception reconnectError) + { + _logger.LogWarning(reconnectError, "Immediate reconnect after speaker error failed"); + } + }); } private void AssertRpcConnected() diff --git a/App.Shared/ViewModels/TrayWindowDisconnectedViewModel.cs b/App.Shared/ViewModels/TrayWindowDisconnectedViewModel.cs index 0e65772..a82b465 100644 --- a/App.Shared/ViewModels/TrayWindowDisconnectedViewModel.cs +++ b/App.Shared/ViewModels/TrayWindowDisconnectedViewModel.cs @@ -26,7 +26,7 @@ public TrayWindowDisconnectedViewModel(IRpcController rpcController) private void UpdateFromRpcModel(RpcModel rpcModel) { - ReconnectButtonEnabled = rpcModel.RpcLifecycle != RpcLifecycle.Disconnected; + ReconnectButtonEnabled = rpcModel.RpcLifecycle == RpcLifecycle.Disconnected; } [RelayCommand] @@ -36,12 +36,14 @@ public async Task Reconnect() { ReconnectFailed = false; ErrorMessage = string.Empty; + ReconnectButtonEnabled = false; await _rpcController.Reconnect(); } catch (Exception ex) { ErrorMessage = ex.Message; ReconnectFailed = true; + ReconnectButtonEnabled = true; } } }