Skip to content
Draft
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
138 changes: 138 additions & 0 deletions backend/FwLite/FwLiteMaui.Tests/UpdateDownloadProxyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Sockets;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;

namespace FwLiteMaui.Tests;

#if WINDOWS
public class UpdateDownloadProxyTests
{
// Deterministic payload the fake upstream serves, with byte-range support.
private static readonly byte[] Payload = Enumerable.Range(0, 100_000).Select(i => (byte)(i % 251)).ToArray();

[Fact]
public async Task ForwardsFullDownloadAndReportsByteTotal()
{
await using var upstream = new FakeUpstream(Payload);
long reported = 0;
await using var proxy = await UpdateDownloadProxy.StartAsync(upstream.Url, NullLogger.Instance, total => reported = total);

using var client = new HttpClient();
var body = await client.GetByteArrayAsync(proxy.LocalUri);

body.Should().Equal(Payload);
reported.Should().Be(Payload.Length);
}

[Fact]
public async Task ForwardsRangeRequestAsPartialContent()
{
await using var upstream = new FakeUpstream(Payload);
await using var proxy = await UpdateDownloadProxy.StartAsync(upstream.Url, NullLogger.Instance, _ => { });

using var client = new HttpClient();
using var request = new HttpRequestMessage(HttpMethod.Get, proxy.LocalUri);
request.Headers.Range = new RangeHeaderValue(10, 19);
using var response = await client.SendAsync(request);

response.StatusCode.Should().Be(HttpStatusCode.PartialContent);
response.Content.Headers.ContentRange!.From.Should().Be(10);
response.Content.Headers.ContentRange!.To.Should().Be(19);
var body = await response.Content.ReadAsByteArrayAsync();
body.Should().Equal(Payload[10..20]);
}

[Fact]
public async Task ReturnsNotFoundForUnknownPath()
{
await using var upstream = new FakeUpstream(Payload);
await using var proxy = await UpdateDownloadProxy.StartAsync(upstream.Url, NullLogger.Instance, _ => { });

var wrongPath = new Uri(proxy.LocalUri, "wrong");
using var client = new HttpClient();
using var response = await client.GetAsync(wrongPath);

response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}

/// <summary>Minimal HTTP origin that serves a byte array with Range/HEAD support.</summary>
private sealed class FakeUpstream : IAsyncDisposable
{
private readonly HttpListener _listener = new();
private readonly byte[] _payload;
private readonly CancellationTokenSource _cts = new();
private readonly Task _loop;

public string Url { get; }

public FakeUpstream(byte[] payload)
{
_payload = payload;
var probe = new TcpListener(IPAddress.Loopback, 0);
probe.Start();
var port = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();

Url = $"http://localhost:{port}/asset";
_listener.Prefixes.Add($"http://localhost:{port}/");
_listener.Start();
_loop = Task.Run(AcceptLoopAsync);
}

private async Task AcceptLoopAsync()
{
while (!_cts.IsCancellationRequested)
{
HttpListenerContext ctx;
try { ctx = await _listener.GetContextAsync(); }
catch { break; }
_ = Task.Run(() => Handle(ctx));
}
}

private async Task Handle(HttpListenerContext ctx)
{
try
{
ctx.Response.Headers["Accept-Ranges"] = "bytes";
var rangeHeader = ctx.Request.Headers["Range"];
if (!string.IsNullOrEmpty(rangeHeader) && RangeHeaderValue.TryParse(rangeHeader, out var range))
{
var from = (int)(range.Ranges.First().From ?? 0);
var to = (int)(range.Ranges.First().To ?? _payload.Length - 1);
var length = to - from + 1;
ctx.Response.StatusCode = (int)HttpStatusCode.PartialContent;
ctx.Response.Headers["Content-Range"] = $"bytes {from}-{to}/{_payload.Length}";
ctx.Response.ContentLength64 = length;
if (ctx.Request.HttpMethod != "HEAD")
await ctx.Response.OutputStream.WriteAsync(_payload.AsMemory(from, length));
}
else
{
ctx.Response.StatusCode = (int)HttpStatusCode.OK;
ctx.Response.ContentLength64 = _payload.Length;
if (ctx.Request.HttpMethod != "HEAD")
await ctx.Response.OutputStream.WriteAsync(_payload);
}
}
finally
{
ctx.Response.Close();
}
}

public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
try { _listener.Stop(); } catch { /* ignore */ }
#pragma warning disable VSTHRD003 // _loop is our own Task.Run started in the ctor
try { await _loop; } catch { /* ignore */ }
#pragma warning restore VSTHRD003
((IDisposable)_listener).Dispose();
_cts.Dispose();
}
}
}
#endif
51 changes: 45 additions & 6 deletions backend/FwLite/FwLiteMaui/Platforms/Windows/AppUpdateService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Text.Json;
using Windows.Management.Deployment;
using Windows.Networking.Connectivity;
Expand Down Expand Up @@ -106,8 +107,28 @@ public async Task<UpdateResult> ApplyUpdate(FwLiteRelease latestRelease)
private async Task<UpdateResult> ApplyUpdate(FwLiteRelease latestRelease, bool quitOnUpdate)
{
logger.LogInformation("Installing new version: {Version}, Current version: {CurrentVersion}", latestRelease.Version, AppVersion.Version);
ShowUpdateInstallingNotification(latestRelease);

// Preferred path: download through a loopback proxy so we can report real download
// progress. On any failure fall back to handing PackageManager the GitHub URL directly,
// which is what we did before this change — we must never regress update capability.
try
{
var progress = new DownloadProgressReporter(eventBus, latestRelease);
await using var proxy = await UpdateDownloadProxy.StartAsync(latestRelease.Url, logger, progress.Report);
return await Deploy(proxy.LocalUri, latestRelease, quitOnUpdate);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin AI pointed out that Deploy runs for max 2 minutes due to:
var completedTask = await Task.WhenAny(updateTask, Task.Delay(TimeSpan.FromMinutes(2)));
at which point it simply does return UpdateResult.Started;

That then results in await using var proxy = being disposed, which presumably "cleans up"/cancels the download.

So, I think this needs some work.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now we'll:

  • Treat UpdateResult.Started as a failure and fall back to the old path
  • Maybe change the timeout. We expect it to be fast, so Kevin will decide.
  • Eventually once we're confident in the new download path we'll get rid of the old path.

It could we weird initially or if something goes wrong, but we expect it to work, so it's acceptable for now.

}
catch (Exception ex)
{
logger.LogWarning(ex, "Proxy update path failed; falling back to direct install");
return await Deploy(new Uri(latestRelease.Url), latestRelease, quitOnUpdate);
}
}

private async Task<UpdateResult> Deploy(Uri packageUri, FwLiteRelease latestRelease, bool quitOnUpdate)
{
var packageManager = new PackageManager();
var asyncOperation = packageManager.AddPackageByUriAsync(new Uri(latestRelease.Url),
var asyncOperation = packageManager.AddPackageByUriAsync(packageUri,
new AddPackageOptions()
{
DeferRegistrationWhenPackagesAreInUse = true,
Expand All @@ -116,15 +137,13 @@ private async Task<UpdateResult> ApplyUpdate(FwLiteRelease latestRelease, bool q
});
asyncOperation.Progress = (info, progressInfo) =>
{
NotifyInstallProgress(progressInfo.percentage, latestRelease);
if (progressInfo.state == DeploymentProgressState.Queued)
{
logger.LogInformation("Queued update");
return;
}
logger.LogInformation("Downloading update: {ProgressPercentage}%", progressInfo.percentage);
logger.LogInformation("Deploying update: {ProgressPercentage}%", progressInfo.percentage);
};
ShowUpdateInstallingNotification(latestRelease);

//note this asyncOperation is not reliable, it's possible the update will install and this will never resolve
var updateTask = asyncOperation.AsTask();
Expand All @@ -145,9 +164,29 @@ private async Task<UpdateResult> ApplyUpdate(FwLiteRelease latestRelease, bool q
return UpdateResult.Started;
}

private void NotifyInstallProgress(uint percentage, FwLiteRelease release)
// Turns the proxy's running byte total into throttled progress events (bytes + speed).
// The JsEventListener channel is small (size 10) so we cap emission at ~4/sec.
private sealed class DownloadProgressReporter(GlobalEventBus eventBus, FwLiteRelease release)
{
eventBus.PublishEvent(new AppUpdateProgressEvent(percentage, release));
private static readonly TimeSpan MinInterval = TimeSpan.FromMilliseconds(250);
private readonly long _startTimestamp = Stopwatch.GetTimestamp();
private readonly Lock _lock = new();
private long _lastEmitTimestamp;

public void Report(long totalBytesDownloaded)
{
var now = Stopwatch.GetTimestamp();
lock (_lock)
{
if (_lastEmitTimestamp != 0 && Stopwatch.GetElapsedTime(_lastEmitTimestamp, now) < MinInterval)
return;
_lastEmitTimestamp = now;
}

var elapsedSeconds = Stopwatch.GetElapsedTime(_startTimestamp, now).TotalSeconds;
var bytesPerSecond = elapsedSeconds > 0 ? totalBytesDownloaded / elapsedSeconds : 0;
eventBus.PublishEvent(new AppUpdateProgressEvent(totalBytesDownloaded, bytesPerSecond, release));
}
}

public DateTime LastUpdateCheck
Expand Down
Loading
Loading