Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
42 changes: 2 additions & 40 deletions App.Avalonia/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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
{
Expand All @@ -188,41 +185,6 @@ private async Task InitializeServicesAsync()
}
}

private async Task<bool> 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<CoderConnectSettings> settingsManager,
ICredentialManager credentialManager,
Expand Down
1 change: 1 addition & 0 deletions Tests.Vpn/Tests.Vpn.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

<ItemGroup>
<ProjectReference Include="..\Vpn\Vpn.csproj" />
<ProjectReference Include="..\Vpn.Linux\Vpn.Linux.csproj" />
</ItemGroup>

</Project>
41 changes: 41 additions & 0 deletions Tests.Vpn/UnixSocketClientTransportTest.cs
Original file line number Diff line number Diff line change
@@ -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 */ }
}
}
}
36 changes: 27 additions & 9 deletions Vpn.Linux/UnixSocketClientTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,34 @@ public UnixSocketClientTransport(string socketPath = "/run/coder-desktop/vpn.soc

public async Task<Stream> 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;
}
}
Loading