Skip to content
Merged
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
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ I'm toying with a React frontend instead for simplicity, hence the currently not

Use standard `dotnet build` and `dotnet test` to verify your work.

## GitHub

Use the GitHub connector for GitHub operations such as opening PRs, reading PR metadata, and commenting.
Do not assume `gh` is installed; use the CLI only as a fallback when the connector cannot perform the task.

## Migrations

For EF migrations, use the data project as both the project and startup project so the design-time factory is used:
Expand All @@ -75,4 +80,4 @@ Use collection expressions where appropriate. They are a new C# feature that loo
private readonly List<string> _foo = [];
private readonly List<string> _foo2 = ["test"];
var item1 = new HashItem { Hash = [1], PerceptualHash = 0 };
```
```
171 changes: 8 additions & 163 deletions Octans.Tests/Downloads/DownloadManagerIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Collections.Concurrent;
using System.IO.Abstractions;
using System.IO.Abstractions.TestingHelpers;
using System.Net;
Expand Down Expand Up @@ -153,8 +152,8 @@ public async Task DownloadManager_RetriesTransientFailureWithoutRetryingTerminal
await harness.StartAsync();

await WaitUntilAsync(
() => harness.Notifier.FinishedDownloads.Length == 2,
() => Task.FromResult($"completed={harness.Notifier.FinishedDownloads.Length}, " +
() => harness.Notifier.FinishedDownloads.Count == 2,
() => Task.FromResult($"completed={harness.Notifier.FinishedDownloads.Count}, " +
$"states={DescribeStates(harness.Services, transient, terminal)}"));

var stateService = harness.Services.GetRequiredService<IDownloadStateService>();
Expand Down Expand Up @@ -241,8 +240,8 @@ public async Task DownloadManager_EnforcesPerDomainConcurrencyWhileDrainingQueue
harness.HttpHandler.ReleaseResponses();

await WaitUntilAsync(
() => harness.Notifier.FinishedDownloads.Length == 3,
async () => $"completed={harness.Notifier.FinishedDownloads.Length}, " +
() => harness.Notifier.FinishedDownloads.Count == 3,
async () => $"completed={harness.Notifier.FinishedDownloads.Count}, " +
$"started={string.Join(", ", harness.HttpHandler.StartedRequests.Select(u => u.ToString()))}, " +
$"states={DescribeStates(harness.Services, firstSame, secondSame, other)}, " +
$"queued={await harness.Services.GetRequiredService<IDownloadQueue>().GetQueuedCountAsync()}");
Expand Down Expand Up @@ -285,7 +284,7 @@ public async Task DownloadManager_SkipsOpenHostCircuitAndContinuesHealthyHosts()

await WaitUntilAsync(
() => harness.Notifier.FinishedDownloads.Any(d => d.DownloadId == deferred),
async () => $"completed={harness.Notifier.FinishedDownloads.Length}, " +
async () => $"completed={harness.Notifier.FinishedDownloads.Count}, " +
$"started={string.Join(", ", harness.HttpHandler.StartedRequests.Select(u => u.ToString()))}, " +
$"states={DescribeStates(harness.Services, deferred, ready)}, " +
$"queued={await harness.Services.GetRequiredService<IDownloadQueue>().GetQueuedCountAsync()}");
Expand Down Expand Up @@ -382,7 +381,7 @@ private DownloadManagerHarness(
SqliteConnection connection,
ServiceProvider services,
MockFileSystem fileSystem,
IntegrationHttpMessageHandler httpHandler,
DownloadManagerHttpMessageHandler httpHandler,
TrackingCompletionNotifier notifier)
{
_connection = connection;
Expand All @@ -394,7 +393,7 @@ private DownloadManagerHarness(

public ServiceProvider Services { get; }
public MockFileSystem FileSystem { get; }
public IntegrationHttpMessageHandler HttpHandler { get; }
public DownloadManagerHttpMessageHandler HttpHandler { get; }
public TrackingCompletionNotifier Notifier { get; }
public IDownloadHostCircuitRegistry HostCircuitRegistry =>
Services.GetRequiredService<IDownloadHostCircuitRegistry>();
Expand All @@ -415,7 +414,7 @@ public static async Task<DownloadManagerHarness> Create(
TotalFreeSpace = 1024 * 1024 * 1024,
TotalSize = 1024L * 1024 * 1024 * 10
});
var httpHandler = new IntegrationHttpMessageHandler();
var httpHandler = new DownloadManagerHttpMessageHandler();
var notifier = new TrackingCompletionNotifier();
var timeProvider = new FakeTimeProvider(TestClock.UtcNow);

Expand Down Expand Up @@ -464,158 +463,4 @@ public async ValueTask DisposeAsync()
Services.GetServices<IHostedService>().OfType<DownloadBackgroundService>().Single();
}

private sealed class TrackingCompletionNotifier : IDownloadCompletionNotifier
{
private readonly ConcurrentQueue<DownloadJobResult> _finishedDownloads = new();

public DownloadJobResult[] FinishedDownloads => _finishedDownloads.ToArray();

public Task DownloadFinishedAsync(DownloadJobResult result, CancellationToken cancellationToken = default)
{
_finishedDownloads.Enqueue(result);
return Task.CompletedTask;
}
}

private sealed class IntegrationHttpMessageHandler : HttpMessageHandler
{
private readonly Lock _lock = new();
private readonly Dictionary<Uri, byte[]> _responses = new();
private readonly Dictionary<Uri, Queue<Func<HttpResponseMessage>>> _responseSequences = new();
private readonly Dictionary<Uri, int> _requestCounts = new();
private readonly Dictionary<string, int> _activeRequestsByHost = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, int> _maxActiveRequestsByHost = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<Uri> _startedRequests = [];
private readonly TaskCompletionSource _releaseResponses =
new(TaskCreationOptions.RunContinuationsAsynchronously);

public bool PauseBeforeResponding { get; set; }

public int ActiveRequestCount
{
get
{
lock (_lock)
{
return _activeRequestCount;
}
}
}

private int _activeRequestCount;

public IReadOnlyCollection<Uri> StartedRequests
{
get
{
lock (_lock)
{
return _startedRequests.ToArray();
}
}
}

public void AddResponse(string url, string body)
{
_responses[new(url)] = System.Text.Encoding.UTF8.GetBytes(body);
}

public void AddResponse(string url, HttpStatusCode statusCode)
{
AddResponseSequence(url, () => new(statusCode));
}

public void AddResponseSequence(string url, params Func<HttpResponseMessage>[] responses)
{
_responseSequences[new(url)] = new(responses);
}

public int MaxActiveRequestsForHost(string host)
{
lock (_lock)
{
return _maxActiveRequestsByHost.GetValueOrDefault(host);
}
}

public int RequestCountFor(Uri uri)
{
lock (_lock)
{
return _requestCounts.GetValueOrDefault(uri);
}
}

public void ReleaseResponses()
{
_releaseResponses.TrySetResult();
}

protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var requestUri = request.RequestUri ?? throw new InvalidOperationException("Request URI is required.");
TrackRequestStarted(requestUri);

try
{
if (PauseBeforeResponding)
{
await _releaseResponses.Task.WaitAsync(cancellationToken);
}

if (!_responses.TryGetValue(requestUri, out var body))
{
if (_responseSequences.TryGetValue(requestUri, out var sequence) && sequence.TryDequeue(out var next))
{
return next();
}

return new(HttpStatusCode.NotFound);
}

return new(HttpStatusCode.OK)
{
Content = new ByteArrayContent(body)
};
}
finally
{
TrackRequestFinished(requestUri.Host);
}
}

private void TrackRequestStarted(Uri requestUri)
{
lock (_lock)
{
_activeRequestCount++;
_requestCounts.TryGetValue(requestUri, out var requestCount);
_requestCounts[requestUri] = requestCount + 1;
_startedRequests.Add(requestUri);
_activeRequestsByHost.TryGetValue(requestUri.Host, out var hostCount);
hostCount++;
_activeRequestsByHost[requestUri.Host] = hostCount;
_maxActiveRequestsByHost.TryGetValue(requestUri.Host, out var maxHostCount);
_maxActiveRequestsByHost[requestUri.Host] = Math.Max(maxHostCount, hostCount);
}
}

private void TrackRequestFinished(string host)
{
lock (_lock)
{
_activeRequestCount--;
_activeRequestsByHost.TryGetValue(host, out var hostCount);
if (hostCount <= 1)
{
_activeRequestsByHost.Remove(host);
return;
}

_activeRequestsByHost[host] = hostCount - 1;
}
}
}
}
Loading