Skip to content

Commit 7f3de64

Browse files
fix: wait for linux rpc socket on startup (#5)
1 parent fb8e9fd commit 7f3de64

4 files changed

Lines changed: 71 additions & 49 deletions

File tree

App.Avalonia/App.axaml.cs

Lines changed: 2 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ private async Task InitializeServicesAsync()
156156
credentialLoadCts.CancelAfter(TimeSpan.FromSeconds(15));
157157

158158
var loadCredentialsTask = credentialManager.LoadCredentials(credentialLoadCts.Token);
159-
var reconnectTask = ReconnectWithStartupRetryAsync(rpcController, appStopping);
159+
var reconnectTask = rpcController.Reconnect(appStopping);
160160

161161
try
162162
{
@@ -173,10 +173,7 @@ private async Task InitializeServicesAsync()
173173
AppBootstrapLogger.Error("Startup reconnect failed unexpectedly", reconnectTask.Exception?.GetBaseException());
174174
}
175175

176-
var reconnectSucceeded = reconnectTask is { IsCompletedSuccessfully: true, Result: true };
177-
178-
if (!reconnectSucceeded)
179-
AppBootstrapLogger.Warn("Startup continuing in disconnected state after retry exhaustion");
176+
var reconnectSucceeded = reconnectTask.IsCompletedSuccessfully;
180177

181178
try
182179
{
@@ -188,41 +185,6 @@ private async Task InitializeServicesAsync()
188185
}
189186
}
190187

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-
}
220-
}
221-
222-
AppBootstrapLogger.Error("RPC reconnect exhausted startup retries", lastError);
223-
return false;
224-
}
225-
226188
private async Task MaybeAutoStartVpnOnLaunchAsync(
227189
ISettingsManager<CoderConnectSettings> settingsManager,
228190
ICredentialManager credentialManager,

Tests.Vpn/Tests.Vpn.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232

3333
<ItemGroup>
3434
<ProjectReference Include="..\Vpn\Vpn.csproj" />
35+
<ProjectReference Include="..\Vpn.Linux\Vpn.Linux.csproj" />
3536
</ItemGroup>
3637

3738
</Project>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using System.Net.Sockets;
2+
using System.Runtime.Versioning;
3+
using Coder.Desktop.Vpn;
4+
5+
namespace Coder.Desktop.Tests.Vpn;
6+
7+
[TestFixture]
8+
[Platform("Linux", Reason = "UnixSocketClientTransport is Linux-only")]
9+
[SupportedOSPlatform("linux")]
10+
public class UnixSocketClientTransportTest
11+
{
12+
[Test(Description = "ConnectAsync waits until the service socket exists")]
13+
[CancelAfter(30_000)]
14+
public async Task ConnectAsync_WaitsForSocketToAppear(CancellationToken ct)
15+
{
16+
var socketPath = Path.Combine(Path.GetTempPath(), $"coder-desktop-test-{Guid.NewGuid():N}.sock");
17+
Socket? listener = null;
18+
19+
try
20+
{
21+
var transport = new UnixSocketClientTransport(socketPath);
22+
var connectTask = transport.ConnectAsync(ct);
23+
24+
await Task.Delay(100, ct);
25+
Assert.That(connectTask.IsCompleted, Is.False);
26+
27+
listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
28+
listener.Bind(new UnixDomainSocketEndPoint(socketPath));
29+
listener.Listen(1);
30+
31+
using var acceptedSocket = await listener.AcceptAsync(ct);
32+
await using var clientStream = await connectTask;
33+
Assert.That(clientStream.CanRead, Is.True);
34+
}
35+
finally
36+
{
37+
listener?.Dispose();
38+
try { File.Delete(socketPath); } catch { /* best effort */ }
39+
}
40+
}
41+
}

Vpn.Linux/UnixSocketClientTransport.cs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,34 @@ public UnixSocketClientTransport(string socketPath = "/run/coder-desktop/vpn.soc
1616

1717
public async Task<Stream> ConnectAsync(CancellationToken ct)
1818
{
19-
var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
20-
try
21-
{
22-
await socket.ConnectAsync(new UnixDomainSocketEndPoint(_socketPath), ct);
23-
return new NetworkStream(socket, ownsSocket: true);
24-
}
25-
catch
19+
var retryDelay = TimeSpan.FromMilliseconds(100);
20+
21+
while (true)
2622
{
27-
socket.Dispose();
28-
throw;
23+
ct.ThrowIfCancellationRequested();
24+
var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
25+
try
26+
{
27+
await socket.ConnectAsync(new UnixDomainSocketEndPoint(_socketPath), ct);
28+
return new NetworkStream(socket, ownsSocket: true);
29+
}
30+
catch (SocketException ex) when (IsSocketUnavailable(ex) && !ct.IsCancellationRequested)
31+
{
32+
socket.Dispose();
33+
await Task.Delay(retryDelay, ct);
34+
retryDelay = TimeSpan.FromMilliseconds(Math.Min(retryDelay.TotalMilliseconds * 2, 1000));
35+
}
36+
catch
37+
{
38+
socket.Dispose();
39+
throw;
40+
}
2941
}
3042
}
43+
44+
private static bool IsSocketUnavailable(SocketException ex)
45+
{
46+
// Keep Linux startup behavior aligned with Windows named-pipe connect: wait for the service socket.
47+
return ex.SocketErrorCode is SocketError.AddressNotAvailable or SocketError.ConnectionRefused;
48+
}
3149
}

0 commit comments

Comments
 (0)