diff --git a/App.Avalonia/App.axaml.cs b/App.Avalonia/App.axaml.cs index 2d0d2a4..607ba98 100644 --- a/App.Avalonia/App.axaml.cs +++ b/App.Avalonia/App.axaml.cs @@ -156,7 +156,7 @@ private async Task InitializeServicesAsync() credentialLoadCts.CancelAfter(TimeSpan.FromSeconds(15)); var loadCredentialsTask = credentialManager.LoadCredentials(credentialLoadCts.Token); - var reconnectTask = ReconnectWithStartupRetryAsync(rpcController, appStopping); + var reconnectTask = rpcController.Reconnect(appStopping); try { @@ -173,10 +173,7 @@ private async Task InitializeServicesAsync() 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"); + var reconnectSucceeded = reconnectTask.IsCompletedSuccessfully; try { @@ -188,41 +185,6 @@ private async Task InitializeServicesAsync() } } - 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, diff --git a/Tests.Vpn/Tests.Vpn.csproj b/Tests.Vpn/Tests.Vpn.csproj index bdae868..693dca6 100644 --- a/Tests.Vpn/Tests.Vpn.csproj +++ b/Tests.Vpn/Tests.Vpn.csproj @@ -32,6 +32,7 @@ + diff --git a/Tests.Vpn/UnixSocketClientTransportTest.cs b/Tests.Vpn/UnixSocketClientTransportTest.cs new file mode 100644 index 0000000..9d93f01 --- /dev/null +++ b/Tests.Vpn/UnixSocketClientTransportTest.cs @@ -0,0 +1,41 @@ +using System.Net.Sockets; +using System.Runtime.Versioning; +using Coder.Desktop.Vpn; + +namespace Coder.Desktop.Tests.Vpn; + +[TestFixture] +[Platform("Linux", Reason = "UnixSocketClientTransport is Linux-only")] +[SupportedOSPlatform("linux")] +public class UnixSocketClientTransportTest +{ + [Test(Description = "ConnectAsync waits until the service socket exists")] + [CancelAfter(30_000)] + public async Task ConnectAsync_WaitsForSocketToAppear(CancellationToken ct) + { + var socketPath = Path.Combine(Path.GetTempPath(), $"coder-desktop-test-{Guid.NewGuid():N}.sock"); + Socket? listener = null; + + try + { + var transport = new UnixSocketClientTransport(socketPath); + var connectTask = transport.ConnectAsync(ct); + + await Task.Delay(100, ct); + Assert.That(connectTask.IsCompleted, Is.False); + + listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + listener.Bind(new UnixDomainSocketEndPoint(socketPath)); + listener.Listen(1); + + using var acceptedSocket = await listener.AcceptAsync(ct); + await using var clientStream = await connectTask; + Assert.That(clientStream.CanRead, Is.True); + } + finally + { + listener?.Dispose(); + try { File.Delete(socketPath); } catch { /* best effort */ } + } + } +} diff --git a/Vpn.Linux/UnixSocketClientTransport.cs b/Vpn.Linux/UnixSocketClientTransport.cs index a570537..6c4af84 100644 --- a/Vpn.Linux/UnixSocketClientTransport.cs +++ b/Vpn.Linux/UnixSocketClientTransport.cs @@ -16,16 +16,34 @@ public UnixSocketClientTransport(string socketPath = "/run/coder-desktop/vpn.soc public async Task ConnectAsync(CancellationToken ct) { - var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); - try - { - await socket.ConnectAsync(new UnixDomainSocketEndPoint(_socketPath), ct); - return new NetworkStream(socket, ownsSocket: true); - } - catch + var retryDelay = TimeSpan.FromMilliseconds(100); + + while (true) { - socket.Dispose(); - throw; + ct.ThrowIfCancellationRequested(); + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + try + { + await socket.ConnectAsync(new UnixDomainSocketEndPoint(_socketPath), ct); + return new NetworkStream(socket, ownsSocket: true); + } + catch (SocketException ex) when (IsSocketUnavailable(ex) && !ct.IsCancellationRequested) + { + socket.Dispose(); + await Task.Delay(retryDelay, ct); + retryDelay = TimeSpan.FromMilliseconds(Math.Min(retryDelay.TotalMilliseconds * 2, 1000)); + } + catch + { + socket.Dispose(); + throw; + } } } + + private static bool IsSocketUnavailable(SocketException ex) + { + // Keep Linux startup behavior aligned with Windows named-pipe connect: wait for the service socket. + return ex.SocketErrorCode is SocketError.AddressNotAvailable or SocketError.ConnectionRefused; + } }