Skip to content

Commit 272895b

Browse files
authored
fix: fix downloading different URLs to same destination (#70)
fixes #69 Fixes `Downloader` to remove the `DownloadTask` when it is done via a completion. This will allow changing Coder deployments, which use different URLs but whose files are downloaded to the same place.
1 parent 8f60b4d commit 272895b

File tree

2 files changed

+66
-39
lines changed

2 files changed

+66
-39
lines changed

Diff for: Tests.Vpn.Service/DownloaderTest.cs

+37-27
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,34 @@ public async Task Download(CancellationToken ct)
284284
Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test"));
285285
}
286286

287+
[Test(Description = "Perform 2 downloads with the same destination")]
288+
[CancelAfter(30_000)]
289+
public async Task DownloadSameDest(CancellationToken ct)
290+
{
291+
using var httpServer = EchoServer();
292+
var url0 = new Uri(httpServer.BaseUrl + "/test0");
293+
var url1 = new Uri(httpServer.BaseUrl + "/test1");
294+
var destPath = Path.Combine(_tempDir, "test");
295+
296+
var manager = new Downloader(NullLogger<Downloader>.Instance);
297+
var startTask0 = manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url0), destPath,
298+
NullDownloadValidator.Instance, ct);
299+
var startTask1 = manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url1), destPath,
300+
NullDownloadValidator.Instance, ct);
301+
var dlTask0 = await startTask0;
302+
await dlTask0.Task;
303+
Assert.That(dlTask0.TotalBytes, Is.EqualTo(5));
304+
Assert.That(dlTask0.BytesRead, Is.EqualTo(5));
305+
Assert.That(dlTask0.Progress, Is.EqualTo(1));
306+
Assert.That(dlTask0.IsCompleted, Is.True);
307+
var dlTask1 = await startTask1;
308+
await dlTask1.Task;
309+
Assert.That(dlTask1.TotalBytes, Is.EqualTo(5));
310+
Assert.That(dlTask1.BytesRead, Is.EqualTo(5));
311+
Assert.That(dlTask1.Progress, Is.EqualTo(1));
312+
Assert.That(dlTask1.IsCompleted, Is.True);
313+
}
314+
287315
[Test(Description = "Download with custom headers")]
288316
[CancelAfter(30_000)]
289317
public async Task WithHeaders(CancellationToken ct)
@@ -347,17 +375,17 @@ public async Task DownloadExistingDifferentContent(CancellationToken ct)
347375

348376
[Test(Description = "Unexpected response code from server")]
349377
[CancelAfter(30_000)]
350-
public void UnexpectedResponseCode(CancellationToken ct)
378+
public async Task UnexpectedResponseCode(CancellationToken ct)
351379
{
352380
using var httpServer = new TestHttpServer(ctx => { ctx.Response.StatusCode = 404; });
353381
var url = new Uri(httpServer.BaseUrl + "/test");
354382
var destPath = Path.Combine(_tempDir, "test");
355383

356384
var manager = new Downloader(NullLogger<Downloader>.Instance);
357-
// The "outer" Task should fail.
358-
var ex = Assert.ThrowsAsync<HttpRequestException>(async () =>
359-
await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
360-
NullDownloadValidator.Instance, ct));
385+
// The "inner" Task should fail.
386+
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
387+
NullDownloadValidator.Instance, ct);
388+
var ex = Assert.ThrowsAsync<HttpRequestException>(async () => await dlTask.Task);
361389
Assert.That(ex.Message, Does.Contain("404"));
362390
}
363391

@@ -384,22 +412,6 @@ public async Task MismatchedETag(CancellationToken ct)
384412
Assert.That(ex.Message, Does.Contain("ETag does not match SHA1 hash of downloaded file").And.Contains("beef"));
385413
}
386414

387-
[Test(Description = "Timeout on response headers")]
388-
[CancelAfter(30_000)]
389-
public void CancelledOuter(CancellationToken ct)
390-
{
391-
using var httpServer = new TestHttpServer(async _ => { await Task.Delay(TimeSpan.FromSeconds(5), ct); });
392-
var url = new Uri(httpServer.BaseUrl + "/test");
393-
var destPath = Path.Combine(_tempDir, "test");
394-
395-
var manager = new Downloader(NullLogger<Downloader>.Instance);
396-
// The "outer" Task should fail.
397-
var smallerCt = new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token;
398-
Assert.ThrowsAsync<TaskCanceledException>(
399-
async () => await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
400-
NullDownloadValidator.Instance, smallerCt));
401-
}
402-
403415
[Test(Description = "Timeout on response body")]
404416
[CancelAfter(30_000)]
405417
public async Task CancelledInner(CancellationToken ct)
@@ -451,12 +463,10 @@ public async Task ValidationFailureExistingFile(CancellationToken ct)
451463
await File.WriteAllTextAsync(destPath, "test", ct);
452464

453465
var manager = new Downloader(NullLogger<Downloader>.Instance);
454-
// The "outer" Task should fail because the inner task never starts.
455-
var ex = Assert.ThrowsAsync<Exception>(async () =>
456-
{
457-
await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
458-
new TestDownloadValidator(new Exception("test exception")), ct);
459-
});
466+
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
467+
new TestDownloadValidator(new Exception("test exception")), ct);
468+
// The "inner" Task should fail.
469+
var ex = Assert.ThrowsAsync<Exception>(async () => { await dlTask.Task; });
460470
Assert.That(ex.Message, Does.Contain("Existing file failed validation"));
461471
Assert.That(ex.InnerException, Is.Not.Null);
462472
Assert.That(ex.InnerException!.Message, Is.EqualTo("test exception"));

Diff for: Vpn.Service/Downloader.cs

+29-12
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Formats.Asn1;
44
using System.Net;
55
using System.Runtime.CompilerServices;
6+
using System.Runtime.ExceptionServices;
67
using System.Security.Cryptography;
78
using System.Security.Cryptography.X509Certificates;
89
using Coder.Desktop.Vpn.Utilities;
@@ -288,7 +289,26 @@ public async Task<DownloadTask> StartDownloadAsync(HttpRequestMessage req, strin
288289
{
289290
var task = _downloads.GetOrAdd(destinationPath,
290291
_ => new DownloadTask(_logger, req, destinationPath, validator));
291-
await task.EnsureStartedAsync(ct);
292+
// EnsureStarted is a no-op if we didn't create a new DownloadTask.
293+
// So, we will only remove the destination once for each time we start a new task.
294+
task.EnsureStarted(tsk =>
295+
{
296+
// remove the key first, before checking the exception, to ensure
297+
// we still clean up.
298+
_downloads.TryRemove(destinationPath, out _);
299+
if (tsk.Exception == null)
300+
{
301+
return;
302+
}
303+
304+
if (tsk.Exception.InnerException != null)
305+
{
306+
ExceptionDispatchInfo.Capture(tsk.Exception.InnerException).Throw();
307+
}
308+
309+
// not sure if this is hittable, but just in case:
310+
throw tsk.Exception;
311+
}, ct);
292312

293313
// If the existing (or new) task is for the same URL, return it.
294314
if (task.Request.RequestUri == req.RequestUri)
@@ -357,21 +377,19 @@ internal DownloadTask(ILogger logger, HttpRequestMessage req, string destination
357377
".download-" + Path.GetRandomFileName());
358378
}
359379

360-
internal async Task<Task> EnsureStartedAsync(CancellationToken ct = default)
380+
internal void EnsureStarted(Action<Task> continuation, CancellationToken ct = default)
361381
{
362-
using var _ = await _semaphore.LockAsync(ct);
382+
using var _ = _semaphore.Lock();
363383
if (Task == null!)
364-
Task = await StartDownloadAsync(ct);
365-
366-
return Task;
384+
Task = Start(ct).ContinueWith(continuation, ct);
367385
}
368386

369387
/// <summary>
370388
/// Starts downloading the file. The request will be performed in this task, but once started, the task will complete
371389
/// and the download will continue in the background. The provided CancellationToken can be used to cancel the
372390
/// download.
373391
/// </summary>
374-
private async Task<Task> StartDownloadAsync(CancellationToken ct = default)
392+
private async Task Start(CancellationToken ct = default)
375393
{
376394
Directory.CreateDirectory(_destinationDirectory);
377395

@@ -398,8 +416,7 @@ private async Task<Task> StartDownloadAsync(CancellationToken ct = default)
398416
throw new Exception("Existing file failed validation after 304 Not Modified", e);
399417
}
400418

401-
Task = Task.CompletedTask;
402-
return Task;
419+
return;
403420
}
404421

405422
if (res.StatusCode != HttpStatusCode.OK)
@@ -432,11 +449,11 @@ private async Task<Task> StartDownloadAsync(CancellationToken ct = default)
432449
throw;
433450
}
434451

435-
Task = DownloadAsync(res, tempFile, ct);
436-
return Task;
452+
await Download(res, tempFile, ct);
453+
return;
437454
}
438455

439-
private async Task DownloadAsync(HttpResponseMessage res, FileStream tempFile, CancellationToken ct)
456+
private async Task Download(HttpResponseMessage res, FileStream tempFile, CancellationToken ct)
440457
{
441458
try
442459
{

0 commit comments

Comments
 (0)