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
15 changes: 11 additions & 4 deletions Storage/Extensions/HttpClientProgress.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ namespace Supabase.Storage.Extensions
/// </summary>
internal static class HttpClientProgress
{
/// <summary>
/// Buffer size for stream copies, matching the BCL's default <see cref="Stream.CopyTo(Stream)"/>
/// size (80 KB). Kept just under the 85,000-byte Large Object Heap threshold so the buffer
/// stays on the gen-0 heap. This is set to the default expected value.
/// </summary>
private const int CopyBufferSize = 81920;

public static async Task<MemoryStream> DownloadDataAsync(
this HttpClient client,
Uri uri,
Expand Down Expand Up @@ -49,7 +56,8 @@ public static async Task<MemoryStream> DownloadDataAsync(
using (
var response = await client.SendAsync(
message,
HttpCompletionOption.ResponseHeadersRead
HttpCompletionOption.ResponseHeadersRead,
cancellationToken
)
)
{
Expand All @@ -76,10 +84,9 @@ public static async Task<MemoryStream> DownloadDataAsync(
var contentLength = response.Content.Headers.ContentLength;
using (var download = await response.Content.ReadAsStreamAsync())
{
// no progress... no contentLength... very sad
if (progress is null || !contentLength.HasValue)
{
await download.CopyToAsync(destination);
await download.CopyToAsync(destination, CopyBufferSize, cancellationToken);
return destination;
}

Expand All @@ -89,7 +96,7 @@ public static async Task<MemoryStream> DownloadDataAsync(
);
await download.CopyToAsync(
destination,
81920,
CopyBufferSize,
progressWrapper,
cancellationToken
);
Expand Down
17 changes: 11 additions & 6 deletions Storage/Interfaces/IStorageFileApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,33 +20,38 @@ Task<string> CreateSignedUrl(
int expiresIn,
DownloadOptions? options = null
);
Task<byte[]> Download(string supabasePath, EventHandler<float>? onProgress = null);
Task<byte[]> Download(string supabasePath, EventHandler<float>? onProgress = null, CancellationToken cancellationToken = default);
Task<byte[]> Download(
string supabasePath,
TransformOptions? transformOptions = null,
EventHandler<float>? onProgress = null
EventHandler<float>? onProgress = null,
CancellationToken cancellationToken = default
);
Task<string> Download(
string supabasePath,
string localPath,
EventHandler<float>? onProgress = null
EventHandler<float>? onProgress = null,
CancellationToken cancellationToken = default
);
Task<string> Download(
string supabasePath,
string localPath,
TransformOptions? transformOptions = null,
EventHandler<float>? onProgress = null
EventHandler<float>? onProgress = null,
CancellationToken cancellationToken = default
);
Task<byte[]> DownloadPublicFile(
string supabasePath,
TransformOptions? transformOptions = null,
EventHandler<float>? onProgress = null
EventHandler<float>? onProgress = null,
CancellationToken cancellationToken = default
);
Task<string> DownloadPublicFile(
string supabasePath,
string localPath,
TransformOptions? transformOptions = null,
EventHandler<float>? onProgress = null
EventHandler<float>? onProgress = null,
CancellationToken cancellationToken = default
);
string GetPublicUrl(
string path,
Expand Down
47 changes: 31 additions & 16 deletions Storage/StorageFileApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -513,19 +513,21 @@ await Helpers.MakeRequest<GenericResponse>(
/// <param name="localPath"></param>
/// <param name="transformOptions"></param>
/// <param name="onProgress"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public Task<string> Download(
string supabasePath,
string localPath,
TransformOptions? transformOptions = null,
EventHandler<float>? onProgress = null
EventHandler<float>? onProgress = null,
CancellationToken cancellationToken = default
)
{
var url =
transformOptions != null
? $"{Url}/render/image/authenticated/{GetFinalPath(supabasePath)}"
: $"{Url}/object/{GetFinalPath(supabasePath)}";
return DownloadFile(url, localPath, transformOptions, onProgress);
return DownloadFile(url, localPath, transformOptions, onProgress, cancellationToken);
}

/// <summary>
Expand All @@ -534,38 +536,43 @@ public Task<string> Download(
/// <param name="supabasePath"></param>
/// <param name="localPath"></param>
/// <param name="onProgress"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public Task<string> Download(
string supabasePath,
string localPath,
EventHandler<float>? onProgress = null
) => Download(supabasePath, localPath, null, onProgress: onProgress);
EventHandler<float>? onProgress = null,
CancellationToken cancellationToken = default
) => Download(supabasePath, localPath, null, onProgress: onProgress, cancellationToken);

/// <summary>
/// Downloads a byte array from a private bucket to be used programmatically. For public buckets <see cref="DownloadPublicFile(string, TransformOptions?, EventHandler{float}?)"/>
/// </summary>
/// <param name="supabasePath"></param>
/// <param name="transformOptions"></param>
/// <param name="onProgress"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public Task<byte[]> Download(
string supabasePath,
TransformOptions? transformOptions = null,
EventHandler<float>? onProgress = null
EventHandler<float>? onProgress = null,
CancellationToken cancellationToken = default
)
{
var url = $"{Url}/object/{GetFinalPath(supabasePath)}";
return DownloadBytes(url, transformOptions, onProgress);
return DownloadBytes(url, transformOptions, onProgress, cancellationToken);
}

/// <summary>
/// Downloads a byte array from a private bucket to be used programmatically. For public buckets <see cref="DownloadPublicFile(string, TransformOptions?, EventHandler{float}?)"/>
/// </summary>
/// <param name="supabasePath"></param>
/// <param name="onProgress"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public Task<byte[]> Download(string supabasePath, EventHandler<float>? onProgress = null) =>
Download(supabasePath, transformOptions: null, onProgress: onProgress);
public Task<byte[]> Download(string supabasePath, EventHandler<float>? onProgress = null, CancellationToken cancellationToken = default) =>
Download(supabasePath, transformOptions: null, onProgress: onProgress, cancellationToken);

/// <summary>
/// Downloads a public file to the filesystem. This method DOES NOT VERIFY that the file is actually public.
Expand All @@ -574,16 +581,18 @@ public Task<byte[]> Download(string supabasePath, EventHandler<float>? onProgres
/// <param name="localPath"></param>
/// <param name="transformOptions"></param>
/// <param name="onProgress"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public Task<string> DownloadPublicFile(
string supabasePath,
string localPath,
TransformOptions? transformOptions = null,
EventHandler<float>? onProgress = null
EventHandler<float>? onProgress = null,
CancellationToken cancellationToken = default
)
{
var url = GetPublicUrl(supabasePath, transformOptions);
return DownloadFile(url, localPath, transformOptions, onProgress);
return DownloadFile(url, localPath, transformOptions, onProgress, cancellationToken);
}

/// <summary>
Expand All @@ -592,15 +601,17 @@ public Task<string> DownloadPublicFile(
/// <param name="supabasePath"></param>
/// <param name="transformOptions"></param>
/// <param name="onProgress"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public Task<byte[]> DownloadPublicFile(
string supabasePath,
TransformOptions? transformOptions = null,
EventHandler<float>? onProgress = null
EventHandler<float>? onProgress = null,
CancellationToken cancellationToken = default
)
{
var url = GetPublicUrl(supabasePath, transformOptions);
return DownloadBytes(url, transformOptions, onProgress);
return DownloadBytes(url, transformOptions, onProgress, cancellationToken);
}

/// <summary>
Expand Down Expand Up @@ -835,7 +846,8 @@ private async Task<string> DownloadFile(
string url,
string localPath,
TransformOptions? transformOptions = null,
EventHandler<float>? onProgress = null
EventHandler<float>? onProgress = null,
CancellationToken cancellationToken = default
)
{
var builder = new UriBuilder(url);
Expand All @@ -850,7 +862,8 @@ private async Task<string> DownloadFile(
var stream = await Helpers.HttpDownloadClient!.DownloadDataAsync(
builder.Uri,
Headers,
progress
progress,
cancellationToken
);

using var fileStream = new FileStream(
Expand All @@ -867,7 +880,8 @@ private async Task<string> DownloadFile(
private async Task<byte[]> DownloadBytes(
string url,
TransformOptions? transformOptions = null,
EventHandler<float>? onProgress = null
EventHandler<float>? onProgress = null,
CancellationToken cancellationToken = default
)
{
var builder = new UriBuilder(url);
Expand All @@ -882,7 +896,8 @@ private async Task<byte[]> DownloadBytes(
var stream = await Helpers.HttpDownloadClient!.DownloadDataAsync(
builder.Uri,
Headers,
progress
progress,
cancellationToken
);

return stream.ToArray();
Expand Down
122 changes: 122 additions & 0 deletions StorageTests/Files/StorageFileDownloadCancellationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Supabase.Storage;

namespace StorageTests.Files;

/// <summary>
/// Contract tests pinning that the file API threads the caller's <see cref="CancellationToken"/> all
/// the way through the download pipeline: onto the HTTP request itself, and onto the response-body copy
/// on the chunked (no <c>Content-Length</c>) path that the progress-reporting branch never exercises.
/// A stub <see cref="HttpMessageHandler"/> stands in for the download client, and each test observes the
/// token *at the point under test* rather than relying on the thrown exception — the body copy honoured
/// the token before these fixes too, so a bare "throws when cancelled" assertion cannot tell the fix
/// apart from the pre-existing behaviour.
/// </summary>
[TestClass]
[TestCategory("Contract")]
public class StorageFileDownloadCancellationTests
{
private const string Bucket = "bucket";

private Client client = null!;
private HttpClient? downloadClient;

[TestInitialize]
public void TestInitialize() =>
this.client = new Client("http://localhost/storage/v1", new Dictionary<string, string>
{
{ "Authorization", "Bearer test-key" }
});

[TestCleanup]
public void TestCleanup() => this.downloadClient?.Dispose();

[TestMethod]
public async Task Download_ShouldForwardTheTokenToTheRequest()
{
using var cts = new CancellationTokenSource();
var requestSawLiveToken = false;
this.UseHandler(new StubHandler((_, token) =>
{
cts.Cancel();
requestSawLiveToken = token.IsCancellationRequested;
token.ThrowIfCancellationRequested();
return EmptyOk();
}));
var act = () => this.client.From(Bucket).Download("a.bin", onProgress: null, cts.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
requestSawLiveToken.Should().BeTrue(
"the request must carry the caller's token so a stalled connection can be cancelled before any body arrives");
}

[TestMethod]
public async Task Download_ShouldHonorCancellation_GivenResponseWithoutContentLength()
{
using var cts = new CancellationTokenSource();
var bodyCopySawLiveToken = false;
var body = new TokenObservingStream(token =>
{
cts.Cancel();
bodyCopySawLiveToken = token.IsCancellationRequested;
token.ThrowIfCancellationRequested();
});
this.UseHandler(new StubHandler((_, _) =>
new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(body) }));
var act = () => this.client.From(Bucket).Download("a.bin", onProgress: null, cts.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
bodyCopySawLiveToken.Should().BeTrue(
"the body copy must carry the token even when the server omits Content-Length and no progress handler is supplied");
}

private void UseHandler(HttpMessageHandler handler) =>
Supabase.Storage.Helpers.HttpDownloadClient = this.downloadClient = new HttpClient(handler);

private static HttpResponseMessage EmptyOk() =>
new(HttpStatusCode.OK) { Content = new ByteArrayContent(Array.Empty<byte>()) };

private sealed class StubHandler(Func<HttpRequestMessage, CancellationToken, HttpResponseMessage> respond)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(respond(request, cancellationToken));
}

/// <summary>
/// A non-seekable response body (so the response carries no <c>Content-Length</c>) that reports the
/// token handed to each read back to the test before signalling end-of-stream.
/// </summary>
private sealed class TokenObservingStream(Action<CancellationToken> onRead) : Stream
{
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
onRead(cancellationToken);
return Task.FromResult(0);
}

public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
onRead(cancellationToken);
return new ValueTask<int>(0);
}

public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public override void Flush() { }
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
}
15 changes: 15 additions & 0 deletions StorageTests/Files/StorageFileTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,21 @@ public async Task Download_ShouldWriteFileToDisk()
await this.bucket.Remove(new List<string> { name });
}

[TestMethod]
public async Task Download_ShouldCancelAndLeaveNoFile_GivenCancelledToken()
{
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
var name = $"{Guid.NewGuid()}.png";
var imagePath = Path.Combine(BasePath(), "Assets", "supabase-csharp.png");
await this.bucket.Upload(imagePath, name);
var downloadPath = Path.Combine(BasePath(), name);
var act = () => this.bucket.Download(name, downloadPath, null, cts.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
File.Exists(downloadPath).Should().BeFalse();
await this.bucket.Remove(new List<string> { name });
}

[TestMethod]
public async Task Download_ShouldReturnTheStoredBytes()
{
Expand Down
Loading