Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 6d2985e

Browse files
committedJun 2, 2025·
feat: add vpn start progress
1 parent 22c9bcd commit 6d2985e

File tree

17 files changed

+421
-91
lines changed

17 files changed

+421
-91
lines changed
 

‎.idea/.idea.Coder.Desktop/.idea/projectSettingsUpdater.xml

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎App/Models/RpcModel.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,30 @@ public enum VpnLifecycle
1919
Stopping,
2020
}
2121

22+
public class VpnStartupProgress
23+
{
24+
public double Progress { get; set; } = 0.0; // 0.0 to 1.0
25+
public string Message { get; set; } = string.Empty;
26+
27+
public VpnStartupProgress Clone()
28+
{
29+
return new VpnStartupProgress
30+
{
31+
Progress = Progress,
32+
Message = Message,
33+
};
34+
}
35+
}
36+
2237
public class RpcModel
2338
{
2439
public RpcLifecycle RpcLifecycle { get; set; } = RpcLifecycle.Disconnected;
2540

2641
public VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown;
2742

43+
// Nullable because it is only set when the VpnLifecycle is Starting
44+
public VpnStartupProgress? VpnStartupProgress { get; set; }
45+
2846
public IReadOnlyList<Workspace> Workspaces { get; set; } = [];
2947

3048
public IReadOnlyList<Agent> Agents { get; set; } = [];
@@ -35,6 +53,7 @@ public RpcModel Clone()
3553
{
3654
RpcLifecycle = RpcLifecycle,
3755
VpnLifecycle = VpnLifecycle,
56+
VpnStartupProgress = VpnStartupProgress?.Clone(),
3857
Workspaces = Workspaces,
3958
Agents = Agents,
4059
};

‎App/Properties/PublishProfiles/win-arm64.pubxml

Lines changed: 0 additions & 12 deletions
This file was deleted.

‎App/Properties/PublishProfiles/win-x64.pubxml

Lines changed: 0 additions & 12 deletions
This file was deleted.

‎App/Properties/PublishProfiles/win-x86.pubxml

Lines changed: 0 additions & 12 deletions
This file was deleted.

‎App/Services/RpcController.cs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,12 @@ public async Task StartVpn(CancellationToken ct = default)
161161
throw new RpcOperationException(
162162
$"Cannot start VPN without valid credentials, current state: {credentials.State}");
163163

164-
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Starting; });
164+
MutateState(state =>
165+
{
166+
state.VpnLifecycle = VpnLifecycle.Starting;
167+
// Explicitly clear the startup progress.
168+
state.VpnStartupProgress = null;
169+
});
165170

166171
ServiceMessage reply;
167172
try
@@ -251,6 +256,9 @@ private void MutateState(Action<RpcModel> mutator)
251256
using (_stateLock.Lock())
252257
{
253258
mutator(_state);
259+
// Unset the startup progress if the VpnLifecycle is not Starting
260+
if (_state.VpnLifecycle != VpnLifecycle.Starting)
261+
_state.VpnStartupProgress = null;
254262
newState = _state.Clone();
255263
}
256264

@@ -283,15 +291,32 @@ private void ApplyStatusUpdate(Status status)
283291
});
284292
}
285293

294+
private void ApplyStartProgressUpdate(StartProgress message)
295+
{
296+
MutateState(state =>
297+
{
298+
// MutateState will undo these changes if it doesn't believe we're
299+
// in the "Starting" state.
300+
state.VpnStartupProgress = new VpnStartupProgress
301+
{
302+
Progress = message.Progress,
303+
Message = message.Message,
304+
};
305+
});
306+
}
307+
286308
private void SpeakerOnReceive(ReplyableRpcMessage<ClientMessage, ServiceMessage> message)
287309
{
288310
switch (message.Message.MsgCase)
289311
{
312+
case ServiceMessage.MsgOneofCase.Start:
313+
case ServiceMessage.MsgOneofCase.Stop:
290314
case ServiceMessage.MsgOneofCase.Status:
291315
ApplyStatusUpdate(message.Message.Status);
292316
break;
293-
case ServiceMessage.MsgOneofCase.Start:
294-
case ServiceMessage.MsgOneofCase.Stop:
317+
case ServiceMessage.MsgOneofCase.StartProgress:
318+
ApplyStartProgressUpdate(message.Message.StartProgress);
319+
break;
295320
case ServiceMessage.MsgOneofCase.None:
296321
default:
297322
// TODO: log unexpected message

‎App/ViewModels/TrayWindowViewModel.cs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost
2929
{
3030
private const int MaxAgents = 5;
3131
private const string DefaultDashboardUrl = "https://coder.com";
32-
private const string DefaultHostnameSuffix = ".coder";
32+
private const string DefaultStartProgressMessage = "Starting Coder Connect...";
3333

3434
private readonly IServiceProvider _services;
3535
private readonly IRpcController _rpcController;
@@ -53,6 +53,7 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost
5353

5454
[ObservableProperty]
5555
[NotifyPropertyChangedFor(nameof(ShowEnableSection))]
56+
[NotifyPropertyChangedFor(nameof(ShowVpnStartProgressSection))]
5657
[NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))]
5758
[NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))]
5859
[NotifyPropertyChangedFor(nameof(ShowAgentsSection))]
@@ -63,14 +64,33 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost
6364

6465
[ObservableProperty]
6566
[NotifyPropertyChangedFor(nameof(ShowEnableSection))]
67+
[NotifyPropertyChangedFor(nameof(ShowVpnStartProgressSection))]
6668
[NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))]
6769
[NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))]
6870
[NotifyPropertyChangedFor(nameof(ShowAgentsSection))]
6971
[NotifyPropertyChangedFor(nameof(ShowAgentOverflowButton))]
7072
[NotifyPropertyChangedFor(nameof(ShowFailedSection))]
7173
public partial string? VpnFailedMessage { get; set; } = null;
7274

73-
public bool ShowEnableSection => VpnFailedMessage is null && VpnLifecycle is not VpnLifecycle.Started;
75+
[ObservableProperty]
76+
[NotifyPropertyChangedFor(nameof(VpnStartProgressIsIndeterminate))]
77+
[NotifyPropertyChangedFor(nameof(VpnStartProgressValueOrDefault))]
78+
public partial int? VpnStartProgressValue { get; set; } = null;
79+
80+
public int VpnStartProgressValueOrDefault => VpnStartProgressValue ?? 0;
81+
82+
[ObservableProperty]
83+
[NotifyPropertyChangedFor(nameof(VpnStartProgressMessageOrDefault))]
84+
public partial string? VpnStartProgressMessage { get; set; } = null;
85+
86+
public string VpnStartProgressMessageOrDefault =>
87+
string.IsNullOrEmpty(VpnStartProgressMessage) ? DefaultStartProgressMessage : VpnStartProgressMessage;
88+
89+
public bool VpnStartProgressIsIndeterminate => VpnStartProgressValueOrDefault == 0;
90+
91+
public bool ShowEnableSection => VpnFailedMessage is null && VpnLifecycle is not VpnLifecycle.Starting and not VpnLifecycle.Started;
92+
93+
public bool ShowVpnStartProgressSection => VpnFailedMessage is null && VpnLifecycle is VpnLifecycle.Starting;
7494

7595
public bool ShowWorkspacesHeader => VpnFailedMessage is null && VpnLifecycle is VpnLifecycle.Started;
7696

@@ -170,6 +190,20 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
170190
VpnLifecycle = rpcModel.VpnLifecycle;
171191
VpnSwitchActive = rpcModel.VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started;
172192

193+
// VpnStartupProgress is only set when the VPN is starting.
194+
if (rpcModel.VpnLifecycle is VpnLifecycle.Starting && rpcModel.VpnStartupProgress != null)
195+
{
196+
// Convert 0.00-1.00 to 0-100.
197+
var progress = (int)(rpcModel.VpnStartupProgress.Progress * 100);
198+
VpnStartProgressValue = Math.Clamp(progress, 0, 100);
199+
VpnStartProgressMessage = string.IsNullOrEmpty(rpcModel.VpnStartupProgress.Message) ? null : rpcModel.VpnStartupProgress.Message;
200+
}
201+
else
202+
{
203+
VpnStartProgressValue = null;
204+
VpnStartProgressMessage = null;
205+
}
206+
173207
// Add every known agent.
174208
HashSet<ByteString> workspacesWithAgents = [];
175209
List<AgentViewModel> agents = [];

‎App/Views/Pages/TrayWindowLoginRequiredPage.xaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
</HyperlinkButton>
3737

3838
<HyperlinkButton
39-
Command="{x:Bind ViewModel.ExitCommand, Mode=OneWay}"
39+
Command="{x:Bind ViewModel.ExitCommand}"
4040
Margin="-12,-8,-12,-5"
4141
HorizontalAlignment="Stretch"
4242
HorizontalContentAlignment="Left">

‎App/Views/Pages/TrayWindowMainPage.xaml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
<ProgressRing
4444
Grid.Column="1"
4545
IsActive="{x:Bind ViewModel.VpnLifecycle, Converter={StaticResource ConnectingBoolConverter}, Mode=OneWay}"
46+
IsIndeterminate="{x:Bind ViewModel.VpnStartProgressIsIndeterminate, Mode=OneWay}"
47+
Value="{x:Bind ViewModel.VpnStartProgressValueOrDefault, Mode=OneWay}"
4648
Width="24"
4749
Height="24"
4850
Margin="10,0"
@@ -74,6 +76,13 @@
7476
Visibility="{x:Bind ViewModel.ShowEnableSection, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"
7577
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
7678

79+
<TextBlock
80+
Text="{x:Bind ViewModel.VpnStartProgressMessageOrDefault, Mode=OneWay}"
81+
TextWrapping="Wrap"
82+
Margin="0,6,0,6"
83+
Visibility="{x:Bind ViewModel.ShowVpnStartProgressSection, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"
84+
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
85+
7786
<TextBlock
7887
Text="Workspaces"
7988
FontWeight="semibold"
@@ -344,7 +353,7 @@
344353
Command="{x:Bind ViewModel.ExitCommand, Mode=OneWay}"
345354
Margin="-12,-8,-12,-5"
346355
HorizontalAlignment="Stretch"
347-
HorizontalContentAlignment="Left">
356+
HorizontalContentAlignment="Left">
348357

349358
<TextBlock Text="Exit" Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
350359
</HyperlinkButton>

‎Tests.Vpn.Service/DownloaderTest.cs

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Security.Cryptography;
33
using System.Security.Cryptography.X509Certificates;
44
using System.Text;
5+
using System.Threading.Channels;
56
using Coder.Desktop.Vpn.Service;
67
using Microsoft.Extensions.Logging.Abstractions;
78

@@ -278,7 +279,7 @@ public async Task Download(CancellationToken ct)
278279
NullDownloadValidator.Instance, ct);
279280
await dlTask.Task;
280281
Assert.That(dlTask.TotalBytes, Is.EqualTo(4));
281-
Assert.That(dlTask.BytesRead, Is.EqualTo(4));
282+
Assert.That(dlTask.BytesWritten, Is.EqualTo(4));
282283
Assert.That(dlTask.Progress, Is.EqualTo(1));
283284
Assert.That(dlTask.IsCompleted, Is.True);
284285
Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test"));
@@ -301,17 +302,56 @@ public async Task DownloadSameDest(CancellationToken ct)
301302
var dlTask0 = await startTask0;
302303
await dlTask0.Task;
303304
Assert.That(dlTask0.TotalBytes, Is.EqualTo(5));
304-
Assert.That(dlTask0.BytesRead, Is.EqualTo(5));
305+
Assert.That(dlTask0.BytesWritten, Is.EqualTo(5));
305306
Assert.That(dlTask0.Progress, Is.EqualTo(1));
306307
Assert.That(dlTask0.IsCompleted, Is.True);
307308
var dlTask1 = await startTask1;
308309
await dlTask1.Task;
309310
Assert.That(dlTask1.TotalBytes, Is.EqualTo(5));
310-
Assert.That(dlTask1.BytesRead, Is.EqualTo(5));
311+
Assert.That(dlTask1.BytesWritten, Is.EqualTo(5));
311312
Assert.That(dlTask1.Progress, Is.EqualTo(1));
312313
Assert.That(dlTask1.IsCompleted, Is.True);
313314
}
314315

316+
[Test(Description = "Download with X-Original-Content-Length")]
317+
[CancelAfter(30_000)]
318+
public async Task DownloadWithXOriginalContentLength(CancellationToken ct)
319+
{
320+
using var httpServer = new TestHttpServer(async ctx =>
321+
{
322+
ctx.Response.StatusCode = 200;
323+
ctx.Response.Headers.Add("X-Original-Content-Length", "6"); // wrong but should be used until complete
324+
ctx.Response.ContentType = "text/plain";
325+
ctx.Response.ContentLength64 = 4; // This should be ignored.
326+
await ctx.Response.OutputStream.WriteAsync("test"u8.ToArray(), ct);
327+
});
328+
var url = new Uri(httpServer.BaseUrl + "/test");
329+
var destPath = Path.Combine(_tempDir, "test");
330+
var manager = new Downloader(NullLogger<Downloader>.Instance);
331+
var req = new HttpRequestMessage(HttpMethod.Get, url);
332+
var dlTask = await manager.StartDownloadAsync(req, destPath, NullDownloadValidator.Instance, ct);
333+
334+
var progressChannel = Channel.CreateUnbounded<DownloadProgressEvent>();
335+
dlTask.ProgressChanged += (_, args) =>
336+
Assert.That(progressChannel.Writer.TryWrite(args), Is.True);
337+
338+
await dlTask.Task;
339+
Assert.That(dlTask.TotalBytes, Is.EqualTo(4)); // should equal BytesWritten after completion
340+
Assert.That(dlTask.BytesWritten, Is.EqualTo(4));
341+
progressChannel.Writer.Complete();
342+
343+
var list = progressChannel.Reader.ReadAllAsync(ct).ToBlockingEnumerable(ct).ToList();
344+
Assert.That(list.Count, Is.GreaterThanOrEqualTo(2)); // there may be an item in the middle
345+
// The first item should be the initial progress with 0 bytes written.
346+
Assert.That(list[0].BytesWritten, Is.EqualTo(0));
347+
Assert.That(list[0].TotalBytes, Is.EqualTo(6)); // from X-Original-Content-Length
348+
Assert.That(list[0].Progress, Is.EqualTo(0.0d));
349+
// The last item should be final progress with the actual total bytes.
350+
Assert.That(list[^1].BytesWritten, Is.EqualTo(4));
351+
Assert.That(list[^1].TotalBytes, Is.EqualTo(4)); // from the actual bytes written
352+
Assert.That(list[^1].Progress, Is.EqualTo(1.0d));
353+
}
354+
315355
[Test(Description = "Download with custom headers")]
316356
[CancelAfter(30_000)]
317357
public async Task WithHeaders(CancellationToken ct)
@@ -347,7 +387,7 @@ public async Task DownloadExisting(CancellationToken ct)
347387
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
348388
NullDownloadValidator.Instance, ct);
349389
await dlTask.Task;
350-
Assert.That(dlTask.BytesRead, Is.Zero);
390+
Assert.That(dlTask.BytesWritten, Is.Zero);
351391
Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test"));
352392
Assert.That(File.GetLastWriteTime(destPath), Is.LessThan(DateTime.Now - TimeSpan.FromDays(1)));
353393
}
@@ -368,7 +408,7 @@ public async Task DownloadExistingDifferentContent(CancellationToken ct)
368408
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
369409
NullDownloadValidator.Instance, ct);
370410
await dlTask.Task;
371-
Assert.That(dlTask.BytesRead, Is.EqualTo(4));
411+
Assert.That(dlTask.BytesWritten, Is.EqualTo(4));
372412
Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test"));
373413
Assert.That(File.GetLastWriteTime(destPath), Is.GreaterThan(DateTime.Now - TimeSpan.FromDays(1)));
374414
}

‎Vpn.Proto/vpn.proto

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ message ServiceMessage {
6060
oneof msg {
6161
StartResponse start = 2;
6262
StopResponse stop = 3;
63-
Status status = 4; // either in reply to a StatusRequest or broadcasted
63+
Status status = 4; // either in reply to a StatusRequest or broadcasted
64+
StartProgress start_progress = 5; // broadcasted during startup
6465
}
6566
}
6667

@@ -218,6 +219,19 @@ message StartResponse {
218219
string error_message = 2;
219220
}
220221

222+
// StartProgress is sent from the manager to the client to indicate the
223+
// download/startup progress of the tunnel. This will be sent during the
224+
// processing of a StartRequest before the StartResponse is sent.
225+
//
226+
// Note: this is currently a broadcasted message to all clients due to the
227+
// inability to easily send messages to a specific client in the Speaker
228+
// implementation. If clients are not expecting these messages, they
229+
// should ignore them.
230+
message StartProgress {
231+
double progress = 1; // 0.0 to 1.0
232+
string message = 2; // human-readable status message, must be set
233+
}
234+
221235
// StopRequest is a request from the manager to stop the tunnel. The tunnel replies with a
222236
// StopResponse.
223237
message StopRequest {}

‎Vpn.Service/Downloader.cs

Lines changed: 190 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -338,32 +338,81 @@
338338
}
339339
}
340340

341+
public class DownloadProgressEvent
342+
{
343+
// TODO: speed calculation would be nice
344+
public ulong BytesWritten { get; init; }
345+
public ulong? TotalBytes { get; init; } // null if unknown
346+
public double? Progress { get; init; } // 0.0 - 1.0, null if unknown
347+
348+
public override string ToString()
349+
{
350+
var s = FriendlyBytes(BytesWritten);
351+
if (TotalBytes != null)
352+
s += $" of {FriendlyBytes(TotalBytes.Value)}";
353+
else
354+
s += " of unknown";
355+
if (Progress != null)
356+
s += $" ({Progress:0%})";
357+
return s;
358+
}
359+
360+
private static readonly string[] ByteSuffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];
361+
362+
// Unfortunately this is copied from FriendlyByteConverter in App. Ideally
363+
// it should go into some shared utilities project, but it's overkill to do
364+
// that for a single tiny function until we have more shared code.
365+
private static string FriendlyBytes(ulong bytes)
366+
{
367+
if (bytes == 0)
368+
return $"0 {ByteSuffixes[0]}";
369+
370+
var place = Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024)));
371+
var num = Math.Round(bytes / Math.Pow(1024, place), 1);
372+
return $"{num} {ByteSuffixes[place]}";
373+
}
374+
}
375+
341376
/// <summary>
342-
/// Downloads an Url to a file on disk. The download will be written to a temporary file first, then moved to the final
377+
/// Downloads a Url to a file on disk. The download will be written to a temporary file first, then moved to the final
343378
/// destination. The SHA1 of any existing file will be calculated and used as an ETag to avoid downloading the file if
344379
/// it hasn't changed.
345380
/// </summary>
346381
public class DownloadTask
347382
{
348383
private const int BufferSize = 4096;
384+
private const int ProgressUpdateDelayMs = 50;
385+
private const string XOriginalContentLengthHeader = "X-Original-Content-Length"; // overrides Content-Length if available
349386

350-
private static readonly HttpClient HttpClient = new();
387+
private static readonly HttpClient HttpClient = new(new HttpClientHandler
388+
{
389+
AutomaticDecompression = DecompressionMethods.All,
390+
});
351391
private readonly string _destinationDirectory;
352392

353393
private readonly ILogger _logger;
354394

355395
private readonly RaiiSemaphoreSlim _semaphore = new(1, 1);
356396
private readonly IDownloadValidator _validator;
357-
public readonly string DestinationPath;
397+
private readonly string _destinationPath;
398+
private readonly string _tempDestinationPath;
399+
400+
// ProgressChanged events are always delayed by up to 50ms to avoid
401+
// flooding.
402+
//
403+
// This will be called:
404+
// - once after the request succeeds but before the read/write routine
405+
// begins
406+
// - occasionally while the file is being downloaded (at least 50ms apart)
407+
// - once when the download is complete
408+
public EventHandler<DownloadProgressEvent>? ProgressChanged;
358409

359410
public readonly HttpRequestMessage Request;
360-
public readonly string TempDestinationPath;
361411

362-
public ulong? TotalBytes { get; private set; }
363-
public ulong BytesRead { get; private set; }
364412
public Task Task { get; private set; } = null!; // Set in EnsureStartedAsync
365-
366-
public double? Progress => TotalBytes == null ? null : (double)BytesRead / TotalBytes.Value;
413+
public ulong BytesWritten { get; private set; }
414+
public ulong? TotalBytes { get; private set; }
415+
public double? Progress => TotalBytes == null ? null : (double)BytesWritten / TotalBytes.Value;
367416
public bool IsCompleted => Task.IsCompleted;
368417

369418
internal DownloadTask(ILogger logger, HttpRequestMessage req, string destinationPath, IDownloadValidator validator)
@@ -374,17 +423,17 @@
374423

375424
if (string.IsNullOrWhiteSpace(destinationPath))
376425
throw new ArgumentException("Destination path must not be empty", nameof(destinationPath));
377-
DestinationPath = Path.GetFullPath(destinationPath);
378-
if (Path.EndsInDirectorySeparator(DestinationPath))
379-
throw new ArgumentException($"Destination path '{DestinationPath}' must not end in a directory separator",
426+
_destinationPath = Path.GetFullPath(destinationPath);
427+
if (Path.EndsInDirectorySeparator(_destinationPath))
428+
throw new ArgumentException($"Destination path '{_destinationPath}' must not end in a directory separator",
380429
nameof(destinationPath));
381430

382-
_destinationDirectory = Path.GetDirectoryName(DestinationPath)
431+
_destinationDirectory = Path.GetDirectoryName(_destinationPath)
383432
?? throw new ArgumentException(
384-
$"Destination path '{DestinationPath}' must have a parent directory",
433+
$"Destination path '{_destinationPath}' must have a parent directory",
385434
nameof(destinationPath));
386435

387-
TempDestinationPath = Path.Combine(_destinationDirectory, "." + Path.GetFileName(DestinationPath) +
436+
_tempDestinationPath = Path.Combine(_destinationDirectory, "." + Path.GetFileName(_destinationPath) +
388437
".download-" + Path.GetRandomFileName());
389438
}
390439

@@ -406,9 +455,9 @@
406455

407456
// If the destination path exists, generate a Coder SHA1 ETag and send
408457
// it in the If-None-Match header to the server.
409-
if (File.Exists(DestinationPath))
458+
if (File.Exists(_destinationPath))
410459
{
411-
await using var stream = File.OpenRead(DestinationPath);
460+
await using var stream = File.OpenRead(_destinationPath);
412461
var etag = Convert.ToHexString(await SHA1.HashDataAsync(stream, ct)).ToLower();
413462
Request.Headers.Add("If-None-Match", "\"" + etag + "\"");
414463
}
@@ -419,11 +468,11 @@
419468
_logger.LogInformation("File has not been modified, skipping download");
420469
try
421470
{
422-
await _validator.ValidateAsync(DestinationPath, ct);
471+
await _validator.ValidateAsync(_destinationPath, ct);
423472
}
424473
catch (Exception e)
425474
{
426-
_logger.LogWarning(e, "Existing file '{DestinationPath}' failed custom validation", DestinationPath);
475+
_logger.LogWarning(e, "Existing file '{DestinationPath}' failed custom validation", _destinationPath);
427476
throw new Exception("Existing file failed validation after 304 Not Modified", e);
428477
}
429478

@@ -448,6 +497,26 @@
448497
if (res.Content.Headers.ContentLength >= 0)
449498
TotalBytes = (ulong)res.Content.Headers.ContentLength;
450499

500+
// X-Original-Content-Length overrules Content-Length if set.
501+
if (res.Headers.TryGetValues(XOriginalContentLengthHeader, out var headerValues))
502+
{
503+
// If there are multiple we only look at the first one.
504+
var headerValue = headerValues.ToList().FirstOrDefault();
505+
if (!string.IsNullOrEmpty(headerValue) && ulong.TryParse(headerValue, out var originalContentLength))
506+
TotalBytes = originalContentLength;
507+
else
508+
_logger.LogWarning(
509+
"Failed to parse {XOriginalContentLengthHeader} header value '{HeaderValue}'",
510+
XOriginalContentLengthHeader, headerValue);
511+
}
512+
513+
SendProgressUpdate(new DownloadProgressEvent
514+
{
515+
BytesWritten = 0,
516+
TotalBytes = TotalBytes,
517+
Progress = 0.0,
518+
});
519+
451520
await Download(res, ct);
452521
}
453522

@@ -459,11 +528,11 @@
459528
FileStream tempFile;
460529
try
461530
{
462-
tempFile = File.Create(TempDestinationPath, BufferSize, FileOptions.SequentialScan);
531+
tempFile = File.Create(_tempDestinationPath, BufferSize, FileOptions.SequentialScan);
463532
}
464533
catch (Exception e)
465534
{
466-
_logger.LogError(e, "Failed to create temporary file '{TempDestinationPath}'", TempDestinationPath);
535+
_logger.LogError(e, "Failed to create temporary file '{TempDestinationPath}'", _tempDestinationPath);
467536
throw;
468537
}
469538

@@ -476,13 +545,31 @@
476545
{
477546
await tempFile.WriteAsync(buffer.AsMemory(0, n), ct);
478547
sha1?.TransformBlock(buffer, 0, n, null, 0);
479-
BytesRead += (ulong)n;
548+
BytesWritten += (ulong)n;
549+
await QueueProgressUpdate(new DownloadProgressEvent
550+
{
551+
BytesWritten = BytesWritten,
552+
TotalBytes = TotalBytes,
553+
Progress = Progress,
554+
}, ct);
480555
}
481556
}
482557

483-
if (TotalBytes != null && BytesRead != TotalBytes)
558+
// Clear any pending progress updates to ensure they won't be sent
559+
// after the final update.
560+
await ClearQueuedProgressUpdate(ct);
561+
// Then write the final status update.
562+
TotalBytes = BytesWritten;
563+
SendProgressUpdate(new DownloadProgressEvent
564+
{
565+
BytesWritten = BytesWritten,
566+
TotalBytes = BytesWritten,
567+
Progress = 1.0,
568+
});
569+
570+
if (TotalBytes != null && BytesWritten != TotalBytes)
484571
throw new IOException(
485-
$"Downloaded file size does not match response Content-Length: Content-Length={TotalBytes}, BytesRead={BytesRead}");
572+
$"Downloaded file size does not match response Content-Length: Content-Length={TotalBytes}, BytesRead={BytesWritten}");
486573

487574
// Verify the ETag if it was sent by the server.
488575
if (res.Headers.Contains("ETag") && sha1 != null)
@@ -497,26 +584,99 @@
497584

498585
try
499586
{
500-
await _validator.ValidateAsync(TempDestinationPath, ct);
587+
await _validator.ValidateAsync(_tempDestinationPath, ct);
501588
}
502589
catch (Exception e)
503590
{
504591
_logger.LogWarning(e, "Downloaded file '{TempDestinationPath}' failed custom validation",
505-
TempDestinationPath);
592+
_tempDestinationPath);
506593
throw new HttpRequestException("Downloaded file failed validation", e);
507594
}
508595

509-
File.Move(TempDestinationPath, DestinationPath, true);
596+
File.Move(_tempDestinationPath, _destinationPath, true);
510597
}
511-
finally
598+
catch
512599
{
513600
#if DEBUG
514601
_logger.LogWarning("Not deleting temporary file '{TempDestinationPath}' in debug mode",
515-
TempDestinationPath);
602+
_tempDestinationPath);
516603
#else
517-
if (File.Exists(TempDestinationPath))
518-
File.Delete(TempDestinationPath);
604+
try
605+
{
606+
if (File.Exists(TempDestinationPath))
607+
File.Delete(TempDestinationPath);
608+
}
609+
catch (Exception e)
610+
{
611+
_logger.LogError(e, "Failed to delete temporary file '{TempDestinationPath}'", _tempDestinationPath);
612+
}
519613
#endif
614+
throw;
520615
}
521616
}
617+
618+
// _progressEventLock protects _progressUpdateTask and _pendingProgressEvent.
619+
private readonly RaiiSemaphoreSlim _progressEventLock = new(1, 1);
620+
private readonly CancellationTokenSource _progressUpdateCts = new();
621+
private Task? _progressUpdateTask;
622+
private DownloadProgressEvent? _pendingProgressEvent;
623+
624+
// Can be called multiple times, but must not be called or in progress while
625+
// SendQueuedProgressUpdateNow is called.
626+
private async Task QueueProgressUpdate(DownloadProgressEvent e, CancellationToken ct)
627+
{
628+
using var _1 = await _progressEventLock.LockAsync(ct);
629+
_pendingProgressEvent = e;
630+
631+
if (_progressUpdateCts.IsCancellationRequested)
632+
throw new InvalidOperationException("Progress update task was cancelled, cannot queue new progress update");
633+
634+
// Start a task with a 50ms delay unless one is already running.
635+
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct, _progressUpdateCts.Token);
636+
cts.CancelAfter(TimeSpan.FromSeconds(5));
637+
_progressUpdateTask ??= Task.Delay(ProgressUpdateDelayMs, cts.Token)
638+
.ContinueWith(t =>
639+
{
640+
cts.Cancel();
641+
using var _2 = _progressEventLock.Lock();
642+
_progressUpdateTask = null;
643+
if (t.IsFaulted || t.IsCanceled) return;
644+
645+
var ev = _pendingProgressEvent;
646+
if (ev != null) SendProgressUpdate(ev);
647+
}, cts.Token);
648+
}
649+
650+
// Must only be called after all QueueProgressUpdate calls have completed.
651+
private async Task ClearQueuedProgressUpdate(CancellationToken ct)
652+
{
653+
Task? t;
654+
using (var _ = _progressEventLock.LockAsync(ct))
655+
{
656+
await _progressUpdateCts.CancelAsync();
657+
t = _progressUpdateTask;
658+
}
659+
660+
// We can't continue to hold the lock here because the continuation
661+
// grabs a lock. We don't need to worry about a new task spawning after
662+
// this because the token is cancelled.
663+
if (t == null) return;
664+
try
665+
{
666+
await t.WaitAsync(ct);
667+
}
668+
catch (TaskCanceledException)
669+
{
670+
// Ignore
671+
}
672+
}
673+
674+
private void SendProgressUpdate(DownloadProgressEvent e)
675+
{
676+
var handler = ProgressChanged;
677+
if (handler == null)
678+
return;
679+
// Start a new task in the background to invoke the event.
680+
_ = Task.Run(() => handler.Invoke(this, e));
681+
}
522682
}

‎Vpn.Service/Manager.cs

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ public interface IManager : IDisposable
2626
/// </summary>
2727
public class Manager : IManager
2828
{
29+
// We scale the download progress to 0.00-0.90, and use 0.90-1.00 for the
30+
// remainder of startup.
31+
private const double DownloadProgressScale = 0.90;
32+
2933
private readonly ManagerConfig _config;
3034
private readonly IDownloader _downloader;
3135
private readonly ILogger<Manager> _logger;
@@ -131,6 +135,8 @@ private async ValueTask<StartResponse> HandleClientMessageStart(ClientMessage me
131135
{
132136
try
133137
{
138+
await BroadcastStartProgress(0.0, "Starting Coder Connect...", ct);
139+
134140
var serverVersion =
135141
await CheckServerVersionAndCredentials(message.Start.CoderUrl, message.Start.ApiToken, ct);
136142
if (_status == TunnelStatus.Started && _lastStartRequest != null &&
@@ -151,10 +157,14 @@ private async ValueTask<StartResponse> HandleClientMessageStart(ClientMessage me
151157
_lastServerVersion = serverVersion;
152158

153159
// TODO: each section of this operation needs a timeout
160+
154161
// Stop the tunnel if it's running so we don't have to worry about
155162
// permissions issues when replacing the binary.
156163
await _tunnelSupervisor.StopAsync(ct);
164+
157165
await DownloadTunnelBinaryAsync(message.Start.CoderUrl, serverVersion.SemVersion, ct);
166+
167+
await BroadcastStartProgress(DownloadProgressScale, "Starting Coder Connect...", ct);
158168
await _tunnelSupervisor.StartAsync(_config.TunnelBinaryPath, HandleTunnelRpcMessage,
159169
HandleTunnelRpcError,
160170
ct);
@@ -237,6 +247,9 @@ private void HandleTunnelRpcMessage(ReplyableRpcMessage<ManagerMessage, TunnelMe
237247
_logger.LogWarning("Received unexpected message reply type {MessageType}", message.Message.MsgCase);
238248
break;
239249
case TunnelMessage.MsgOneofCase.Log:
250+
// Ignored. We already log stdout/stderr from the tunnel
251+
// binary.
252+
break;
240253
case TunnelMessage.MsgOneofCase.NetworkSettings:
241254
_logger.LogWarning("Received message type {MessageType} that is not expected on Windows",
242255
message.Message.MsgCase);
@@ -311,12 +324,28 @@ private async ValueTask<Status> CurrentStatus(CancellationToken ct = default)
311324
private async Task BroadcastStatus(TunnelStatus? newStatus = null, CancellationToken ct = default)
312325
{
313326
if (newStatus != null) _status = newStatus.Value;
314-
await _managerRpc.BroadcastAsync(new ServiceMessage
327+
await FallibleBroadcast(new ServiceMessage
315328
{
316329
Status = await CurrentStatus(ct),
317330
}, ct);
318331
}
319332

333+
private async Task FallibleBroadcast(ServiceMessage message, CancellationToken ct = default)
334+
{
335+
// Broadcast the messages out with a low timeout. If clients don't
336+
// receive broadcasts in time, it's not a big deal.
337+
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
338+
//cts.CancelAfter(TimeSpan.FromMilliseconds(100));
339+
try
340+
{
341+
await _managerRpc.BroadcastAsync(message, cts.Token);
342+
}
343+
catch (Exception ex)
344+
{
345+
_logger.LogWarning(ex, "Could not broadcast low priority message to all RPC clients: {Message}", message);
346+
}
347+
}
348+
320349
private void HandleTunnelRpcError(Exception e)
321350
{
322351
_logger.LogError(e, "Manager<->Tunnel RPC error");
@@ -427,10 +456,44 @@ private async Task DownloadTunnelBinaryAsync(string baseUrl, SemVersion expected
427456

428457
var downloadTask = await _downloader.StartDownloadAsync(req, _config.TunnelBinaryPath, validators, ct);
429458

430-
// TODO: monitor and report progress when we have a mechanism to do so
459+
var progressLock = new RaiiSemaphoreSlim(1, 1);
460+
var progressBroadcastCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
461+
downloadTask.ProgressChanged += (sender, ev) =>
462+
{
463+
using var _ = progressLock.Lock();
464+
if (progressBroadcastCts.IsCancellationRequested) return;
465+
_logger.LogInformation("Download progress: {ev}", ev);
466+
467+
// Scale the progress value to be between 0.00 and 0.90.
468+
var progress = ev.Progress * DownloadProgressScale ?? 0.0;
469+
var message = $"Downloading Coder Connect binary...\n{ev}";
470+
BroadcastStartProgress(progress, message, progressBroadcastCts.Token).Wait(progressBroadcastCts.Token);
471+
};
431472

432473
// Awaiting this will check the checksum (via the ETag) if the file
433474
// exists, and will also validate the signature and version.
434475
await downloadTask.Task;
476+
477+
// Prevent any lagging progress events from being sent.
478+
// ReSharper disable once PossiblyMistakenUseOfCancellationToken
479+
using (await progressLock.LockAsync(ct))
480+
await progressBroadcastCts.CancelAsync();
481+
482+
// We don't send a broadcast here as we immediately send one in the
483+
// parent routine.
484+
_logger.LogInformation("Completed downloading VPN binary");
485+
}
486+
487+
private async Task BroadcastStartProgress(double progress, string message, CancellationToken ct = default)
488+
{
489+
_logger.LogInformation("Start progress: {Progress:0%} - {Message}", progress, message);
490+
await FallibleBroadcast(new ServiceMessage
491+
{
492+
StartProgress = new StartProgress
493+
{
494+
Progress = progress,
495+
Message = message,
496+
},
497+
}, ct);
435498
}
436499
}

‎Vpn.Service/ManagerRpc.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ public async Task BroadcastAsync(ServiceMessage message, CancellationToken ct)
133133
try
134134
{
135135
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
136-
cts.CancelAfter(5 * 1000);
136+
cts.CancelAfter(TimeSpan.FromSeconds(2));
137137
await client.Speaker.SendMessage(message, cts.Token);
138138
}
139139
catch (ObjectDisposedException)

‎Vpn.Service/Program.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ public static class Program
1616
#if !DEBUG
1717
private const string ServiceName = "Coder Desktop";
1818
private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\VpnService";
19+
private const string DefaultLogLevel = "Information";
1920
#else
2021
// This value matches Create-Service.ps1.
2122
private const string ServiceName = "Coder Desktop (Debug)";
2223
private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\DebugVpnService";
24+
private const string DefaultLogLevel = "Debug";
2325
#endif
2426

2527
private const string ManagerConfigSection = "Manager";
@@ -81,6 +83,10 @@ private static async Task BuildAndRun(string[] args)
8183
builder.Services.AddSingleton<ITelemetryEnricher, TelemetryEnricher>();
8284

8385
// Services
86+
builder.Services.AddHostedService<ManagerService>();
87+
builder.Services.AddHostedService<ManagerRpcService>();
88+
89+
// Either run as a Windows service or a console application
8490
if (!Environment.UserInteractive)
8591
{
8692
MainLogger.Information("Running as a windows service");
@@ -91,9 +97,6 @@ private static async Task BuildAndRun(string[] args)
9197
MainLogger.Information("Running as a console application");
9298
}
9399

94-
builder.Services.AddHostedService<ManagerService>();
95-
builder.Services.AddHostedService<ManagerRpcService>();
96-
97100
var host = builder.Build();
98101
Log.Logger = (ILogger)host.Services.GetService(typeof(ILogger))!;
99102
MainLogger.Information("Application is starting");
@@ -108,7 +111,7 @@ private static void AddDefaultConfig(IConfigurationBuilder builder)
108111
["Serilog:Using:0"] = "Serilog.Sinks.File",
109112
["Serilog:Using:1"] = "Serilog.Sinks.Console",
110113

111-
["Serilog:MinimumLevel"] = "Information",
114+
["Serilog:MinimumLevel"] = DefaultLogLevel,
112115
["Serilog:Enrich:0"] = "FromLogContext",
113116

114117
["Serilog:WriteTo:0:Name"] = "File",

‎Vpn.Service/TunnelSupervisor.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,17 +100,15 @@ public async Task StartAsync(string binPath,
100100
};
101101
// TODO: maybe we should change the log format in the inner binary
102102
// to something without a timestamp
103-
var outLogger = Log.ForContext("SourceContext", "coder-vpn.exe[OUT]");
104-
var errLogger = Log.ForContext("SourceContext", "coder-vpn.exe[ERR]");
105103
_subprocess.OutputDataReceived += (_, args) =>
106104
{
107105
if (!string.IsNullOrWhiteSpace(args.Data))
108-
outLogger.Debug("{Data}", args.Data);
106+
_logger.LogDebug("stdout: {Data}", args.Data);
109107
};
110108
_subprocess.ErrorDataReceived += (_, args) =>
111109
{
112110
if (!string.IsNullOrWhiteSpace(args.Data))
113-
errLogger.Debug("{Data}", args.Data);
111+
_logger.LogDebug("stderr: {Data}", args.Data);
114112
};
115113

116114
// Pass the other end of the pipes to the subprocess and dispose

‎Vpn/Speaker.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ public async Task StartAsync(CancellationToken ct = default)
123123
// Handshakes should always finish quickly, so enforce a 5s timeout.
124124
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
125125
cts.CancelAfter(TimeSpan.FromSeconds(5));
126-
await PerformHandshake(ct);
126+
await PerformHandshake(cts.Token);
127127

128128
// Start ReceiveLoop in the background.
129129
_receiveTask = ReceiveLoop(_cts.Token);

0 commit comments

Comments
 (0)
Please sign in to comment.