Skip to content

Commit fb8e9fd

Browse files
fix: startup reliability, ConnectOnLaunch wiring, and reconnect UX (#3)
- Add bounded startup retry (5 attempts with exponential backoff) for RPC reconnect to handle boot race when VPN service socket isn't ready - Wire ConnectOnLaunch setting into startup so VPN auto-starts when setting is enabled and all prerequisites are met - Fix inverted reconnect button logic: enable in Disconnected state (when user needs it), disable during Connecting - Fix SpeakerOnError fire-and-forget: properly await Reconnect via Task.Run instead of using var on a Task (which is a no-op Dispose) - Fix settings page copy: 'Windows startup' -> 'when you sign in'
1 parent 44b428b commit fb8e9fd

4 files changed

Lines changed: 105 additions & 15 deletions

File tree

App.Avalonia/App.axaml.cs

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,16 @@ private async Task InitializeServicesAsync()
147147

148148
var credentialManager = _services.GetRequiredService<ICredentialManager>();
149149
var rpcController = _services.GetRequiredService<IRpcController>();
150+
var settingsManager = _services.GetRequiredService<ISettingsManager<CoderConnectSettings>>();
150151

151152
AppBootstrapLogger.Info("Initializing credentials and RPC connection...");
152153

153-
using var credentialLoadCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
154+
var appStopping = _hostApplicationLifetime?.ApplicationStopping ?? CancellationToken.None;
155+
using var credentialLoadCts = CancellationTokenSource.CreateLinkedTokenSource(appStopping);
156+
credentialLoadCts.CancelAfter(TimeSpan.FromSeconds(15));
157+
154158
var loadCredentialsTask = credentialManager.LoadCredentials(credentialLoadCts.Token);
155-
var reconnectTask = rpcController.Reconnect();
159+
var reconnectTask = ReconnectWithStartupRetryAsync(rpcController, appStopping);
156160

157161
try
158162
{
@@ -164,11 +168,91 @@ private async Task InitializeServicesAsync()
164168
if (loadCredentialsTask.IsFaulted)
165169
AppBootstrapLogger.Error("Credential initialization failed", loadCredentialsTask.Exception?.GetBaseException());
166170

171+
// reconnectTask logs its own errors internally; just note if it threw here
167172
if (reconnectTask.IsFaulted)
168-
AppBootstrapLogger.Error("RPC reconnect failed", reconnectTask.Exception?.GetBaseException());
169-
else if (reconnectTask.IsCanceled)
170-
AppBootstrapLogger.Warn("RPC reconnect canceled");
173+
AppBootstrapLogger.Error("Startup reconnect failed unexpectedly", reconnectTask.Exception?.GetBaseException());
174+
}
175+
176+
var reconnectSucceeded = reconnectTask is { IsCompletedSuccessfully: true, Result: true };
177+
178+
if (!reconnectSucceeded)
179+
AppBootstrapLogger.Warn("Startup continuing in disconnected state after retry exhaustion");
180+
181+
try
182+
{
183+
await MaybeAutoStartVpnOnLaunchAsync(settingsManager, credentialManager, rpcController, reconnectSucceeded, appStopping);
184+
}
185+
catch (Exception ex)
186+
{
187+
AppBootstrapLogger.Error("ConnectOnLaunch failed", ex);
188+
}
189+
}
190+
191+
private async Task<bool> ReconnectWithStartupRetryAsync(IRpcController rpcController, CancellationToken ct)
192+
{
193+
TimeSpan[] delays =
194+
[
195+
TimeSpan.Zero,
196+
TimeSpan.FromSeconds(1),
197+
TimeSpan.FromSeconds(2),
198+
TimeSpan.FromSeconds(4),
199+
TimeSpan.FromSeconds(8),
200+
];
201+
202+
Exception? lastError = null;
203+
204+
for (var attempt = 0; attempt < delays.Length; attempt++)
205+
{
206+
if (attempt > 0)
207+
await Task.Delay(delays[attempt], ct);
208+
209+
try
210+
{
211+
await rpcController.Reconnect(ct);
212+
AppBootstrapLogger.Info($"RPC reconnect succeeded on attempt {attempt + 1}/{delays.Length}");
213+
return true;
214+
}
215+
catch (Exception ex) when (!ct.IsCancellationRequested)
216+
{
217+
lastError = ex;
218+
AppBootstrapLogger.Warn($"RPC reconnect attempt {attempt + 1}/{delays.Length} failed: {ex.Message}");
219+
}
171220
}
221+
222+
AppBootstrapLogger.Error("RPC reconnect exhausted startup retries", lastError);
223+
return false;
224+
}
225+
226+
private async Task MaybeAutoStartVpnOnLaunchAsync(
227+
ISettingsManager<CoderConnectSettings> settingsManager,
228+
ICredentialManager credentialManager,
229+
IRpcController rpcController,
230+
bool reconnectSucceeded,
231+
CancellationToken ct)
232+
{
233+
var settings = await settingsManager.Read(ct);
234+
if (!settings.ConnectOnLaunch)
235+
return;
236+
237+
if (!reconnectSucceeded)
238+
{
239+
AppBootstrapLogger.Info("ConnectOnLaunch skipped because startup reconnect did not succeed");
240+
return;
241+
}
242+
243+
var creds = credentialManager.GetCachedCredentials();
244+
var rpc = rpcController.GetState();
245+
246+
if (creds.State != CredentialState.Valid ||
247+
rpc.RpcLifecycle != RpcLifecycle.Connected ||
248+
rpc.VpnLifecycle != VpnLifecycle.Stopped)
249+
{
250+
AppBootstrapLogger.Info($"ConnectOnLaunch skipped (cred={creds.State}, rpc={rpc.RpcLifecycle}, vpn={rpc.VpnLifecycle})");
251+
return;
252+
}
253+
254+
await rpcController.StartVpn(ct);
255+
AppBootstrapLogger.Info("ConnectOnLaunch started VPN successfully");
172256
}
173257

174258
private void ConfigureTrayIcons(TrayIconViewModel trayIconViewModel)

App.Avalonia/Views/Pages/SettingsMainPage.axaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
<TextBlock Text="Start on login"
2727
FontWeight="SemiBold" />
2828
<TextBlock TextWrapping="Wrap"
29-
Text="This setting controls whether the Coder Desktop app starts on Windows startup." />
29+
Text="This setting controls whether the Coder Desktop app starts when you sign in." />
3030
<ToggleSwitch IsChecked="{CompiledBinding StartOnLogin, Mode=TwoWay}"
3131
IsEnabled="{CompiledBinding StartOnLoginDisabled, Converter={StaticResource InverseBoolConverter}}" />
3232
</StackPanel>

App.Shared/Services/RpcController.cs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -277,15 +277,19 @@ private async Task DisposeSpeaker()
277277

278278
private void SpeakerOnError(Exception e)
279279
{
280-
Debug.WriteLine($"Error: {e}");
281-
try
282-
{
283-
using var _ = Reconnect(CancellationToken.None);
284-
}
285-
catch
280+
_logger.LogWarning(e, "RPC speaker error; attempting immediate reconnect");
281+
282+
_ = Task.Run(async () =>
286283
{
287-
// best effort to immediately reconnect
288-
}
284+
try
285+
{
286+
await Reconnect(CancellationToken.None);
287+
}
288+
catch (Exception reconnectError)
289+
{
290+
_logger.LogWarning(reconnectError, "Immediate reconnect after speaker error failed");
291+
}
292+
});
289293
}
290294

291295
private void AssertRpcConnected()

App.Shared/ViewModels/TrayWindowDisconnectedViewModel.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public TrayWindowDisconnectedViewModel(IRpcController rpcController)
2626

2727
private void UpdateFromRpcModel(RpcModel rpcModel)
2828
{
29-
ReconnectButtonEnabled = rpcModel.RpcLifecycle != RpcLifecycle.Disconnected;
29+
ReconnectButtonEnabled = rpcModel.RpcLifecycle == RpcLifecycle.Disconnected;
3030
}
3131

3232
[RelayCommand]
@@ -36,12 +36,14 @@ public async Task Reconnect()
3636
{
3737
ReconnectFailed = false;
3838
ErrorMessage = string.Empty;
39+
ReconnectButtonEnabled = false;
3940
await _rpcController.Reconnect();
4041
}
4142
catch (Exception ex)
4243
{
4344
ErrorMessage = ex.Message;
4445
ReconnectFailed = true;
46+
ReconnectButtonEnabled = true;
4547
}
4648
}
4749
}

0 commit comments

Comments
 (0)