Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
2 changes: 2 additions & 0 deletions docs/sdks/csharp.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ await sandbox.Files.DeleteDirectoriesAsync(new[] { "/tmp/demo" });
await sandbox.Files.DeleteFilesAsync(new[] { "/tmp/demo/hello.txt" });
```

Use `ReadBytesDetailedAsync` or `ReadBytesStreamDetailedAsync` when Range response metadata is required. Dispose a detailed stream body if it is not fully consumed.

### 5. Endpoints

`GetEndpointAsync()` returns an endpoint **without a scheme** (for example `"localhost:44772"`). Use `GetEndpointUrlAsync()` if you want a ready-to-use absolute URL.
Expand Down
1 change: 1 addition & 0 deletions docs/sdks/go.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ Created with `NewExecdClient(baseURL, accessToken string, opts ...Option)`.
| `UploadFile(ctx, file, opts)` | Upload a file to the sandbox |
| `UploadFiles(ctx, entries)` | Upload multiple files to the sandbox |
| `DownloadFile(ctx, remotePath, rangeHeader)` | Download a file from the sandbox |
| `DownloadFileResponse(ctx, remotePath, rangeHeader)` | Download a file with HTTP status and range metadata |

**Directory Operations:**

Expand Down
2 changes: 2 additions & 0 deletions docs/sdks/javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ console.log(files.map((f) => f.path));
await sandbox.files.deleteDirectories(["/tmp/demo"]);
```

Use `readBytesDetailed` or `readBytesStreamDetailed` when Range response metadata is required. Close a detailed stream body if it is not fully consumed.

### 5. Endpoints

`getEndpoint()` returns an endpoint **without a scheme** (for example `"localhost:44772"`). Use `getEndpointUrl()` if you want a ready-to-use absolute URL (for example `"http://localhost:44772"`).
Expand Down
2 changes: 2 additions & 0 deletions docs/sdks/kotlin.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ files.forEach(f -> System.out.println("Found: " + f.getPath()));
sandbox.files().deleteFiles(List.of("/tmp/hello.txt"));
```

Use `readByteArrayDetailed` or `readStreamDetailed` when Range response metadata is required.

### 5. Sandbox Management (Admin)

Use `SandboxManager` for administrative tasks and finding existing sandboxes.
Expand Down
2 changes: 2 additions & 0 deletions docs/sdks/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,8 @@ for f in files:
await sandbox.files.delete_files(["/tmp/hello.txt"])
```

Use `read_bytes_detailed` or `read_bytes_stream_detailed` when Range response metadata is required. Close a detailed stream body if it is not fully consumed.

### 5. Sandbox Management (Admin)

Use `SandboxManager` for administrative tasks and finding existing sandboxes.
Expand Down
207 changes: 164 additions & 43 deletions sdks/sandbox/csharp/src/OpenSandbox/Adapters/FilesystemAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using OpenSandbox.Core;
using OpenSandbox.Internal;
using OpenSandbox.Models;
Expand All @@ -32,6 +33,9 @@ internal sealed class FilesystemAdapter : ISandboxFiles
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
private readonly IReadOnlyDictionary<string, string> _headers;
private static readonly Regex ContentRangeRegex = new(
@"^bytes\s+(\d+)-(\d+)/(\d+|\*)$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);

private static readonly JsonSerializerOptions JsonOptions = new()
{
Expand Down Expand Up @@ -190,34 +194,69 @@ public async Task<byte[]> ReadBytesAsync(
ReadBytesOptions? options = null,
CancellationToken cancellationToken = default)
{
var headers = new Dictionary<string, string>();
var range = options?.Range;
if (range != null && range.Length > 0)
{
headers["Range"] = range;
}
var response = await ReadBytesDetailedAsync(path, options, cancellationToken).ConfigureAwait(false);
return response.Body;
}

var queryParams = new Dictionary<string, string?>
{
["path"] = path
};
public async Task<ReadBytesResponse<byte[]>> ReadBytesDetailedAsync(
string path,
ReadBytesOptions? options = null,
CancellationToken cancellationToken = default)
{
using var request = BuildDownloadRequest(path, options);
using var response = await _client.SendAsync(request, cancellationToken).ConfigureAwait(false);
await _client.EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
return CreateReadBytesResponse(response, body);
}

if (options?.Offset != null)
public async IAsyncEnumerable<byte[]> ReadBytesStreamAsync(
string path,
ReadBytesOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var response = await ReadBytesStreamDetailedAsync(path, options, cancellationToken).ConfigureAwait(false);
await using var body = response.Body;
await foreach (var chunk in body.WithCancellation(cancellationToken).ConfigureAwait(false))
{
queryParams["offset"] = options.Offset.Value.ToString();
yield return chunk;
}
if (options?.Limit != null)
}

public async Task<ReadBytesResponse<IAsyncReadBytesStream>> ReadBytesStreamDetailedAsync(
string path,
ReadBytesOptions? options = null,
CancellationToken cancellationToken = default)
{
using var request = BuildDownloadRequest(path, options);
var response = await _client.SendAsync(request, cancellationToken).ConfigureAwait(false);

if (!response.IsSuccessStatusCode)
Comment thread
mengdehong marked this conversation as resolved.
Outdated
{
queryParams["limit"] = options.Limit.Value.ToString();
try
{
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var requestId = response.Headers.TryGetValues(Constants.RequestIdHeader, out var values)
? values.FirstOrDefault()
: null;

throw new SandboxApiException(
message: "Download stream failed",
statusCode: (int)response.StatusCode,
requestId: requestId,
rawBody: content);
}
finally
{
response.Dispose();
}
}

return await _client.GetBytesAsync("/files/download", queryParams, headers, cancellationToken).ConfigureAwait(false);
IAsyncReadBytesStream body = new ResponseByteStream(response, cancellationToken);
return CreateReadBytesResponse(response, body);
}

public async IAsyncEnumerable<byte[]> ReadBytesStreamAsync(
string path,
ReadBytesOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
private HttpRequestMessage BuildDownloadRequest(string path, ReadBytesOptions? options)
{
var url = $"{_baseUrl}/files/download?path={Uri.EscapeDataString(path)}";
if (options?.Offset != null)
Expand All @@ -229,43 +268,125 @@ public async IAsyncEnumerable<byte[]> ReadBytesStreamAsync(
url += $"&limit={options.Limit.Value}";
}

using var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in _headers)
var request = new HttpRequestMessage(HttpMethod.Get, url);
var range = options?.Range;
if (!string.IsNullOrEmpty(range))
{
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
request.Headers.TryAddWithoutValidation("Range", range);
}
return request;
}

var range = options?.Range;
if (range != null && range.Length > 0)
private static ReadBytesResponse<T> CreateReadBytesResponse<T>(HttpResponseMessage response, T body)
{
var isPartial = (int)response.StatusCode == 206;
var contentLength = response.Content.Headers.ContentLength ?? -1;
var contentRange = isPartial ? ParseContentRange(GetContentHeader(response, "Content-Range")) : null;
return new ReadBytesResponse<T>(
Body: body,
StatusCode: (int)response.StatusCode,
ContentType: response.Content.Headers.ContentType?.ToString(),
Comment thread
mengdehong marked this conversation as resolved.
Outdated
ContentDisposition: response.Content.Headers.ContentDisposition?.ToString(),
ContentLength: contentLength,
TotalSize: isPartial ? contentRange?.Total ?? -1 : contentLength,
ContentRange: contentRange);
}

private static ByteRange? ParseContentRange(string? raw)
{
if (string.IsNullOrEmpty(raw))
{
request.Headers.TryAddWithoutValidation("Range", range);
return null;
}

using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
var invalid = new ByteRange(-1, -1, -1, raw!);
var match = ContentRangeRegex.Match(raw!);
if (!match.Success ||
!long.TryParse(match.Groups[1].Value, out var start) ||
!long.TryParse(match.Groups[2].Value, out var end))
{
return invalid;
}

if (!response.IsSuccessStatusCode)
var total = -1L;
if (match.Groups[3].Value != "*" && !long.TryParse(match.Groups[3].Value, out total))
{
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var requestId = response.Headers.TryGetValues(Constants.RequestIdHeader, out var values)
? values.FirstOrDefault()
: null;
return invalid;
}
if (end < start || (total != -1 && total <= end))
{
return invalid;
}
return new ByteRange(start, end, total, raw!);
}

throw new SandboxApiException(
message: "Download stream failed",
statusCode: (int)response.StatusCode,
requestId: requestId,
rawBody: content);
private static string? GetContentHeader(HttpResponseMessage response, string name)
{
return response.Content.Headers.TryGetValues(name, out var values)
? values.FirstOrDefault()
: null;
}

private sealed class ResponseByteStream : IAsyncReadBytesStream
{
private readonly HttpResponseMessage _response;
private readonly CancellationToken _requestCancellationToken;
private int _enumerated;
private int _disposed;

public ResponseByteStream(
HttpResponseMessage response,
CancellationToken requestCancellationToken)
{
_response = response;
_requestCancellationToken = requestCancellationToken;
}

var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
var buffer = new byte[8192];
int bytesRead;
public IAsyncEnumerator<byte[]> GetAsyncEnumerator(CancellationToken cancellationToken = default)
{
if (Interlocked.Exchange(ref _enumerated, 1) != 0)
{
throw new InvalidOperationException("Download body can only be read once");
}
return ReadChunksAsync(cancellationToken).GetAsyncEnumerator();
}

while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) > 0)
public ValueTask DisposeAsync()
{
var chunk = new byte[bytesRead];
Array.Copy(buffer, chunk, bytesRead);
yield return chunk;
if (Interlocked.Exchange(ref _disposed, 1) == 0)
{
_response.Dispose();
}
return default;
}

private async IAsyncEnumerable<byte[]> ReadChunksAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
_requestCancellationToken,
cancellationToken);
try
{
using var stream = await _response.Content.ReadAsStreamAsync().ConfigureAwait(false);
var buffer = new byte[8192];
int bytesRead;

while ((bytesRead = await stream.ReadAsync(
buffer,
0,
buffer.Length,
linkedCancellation.Token).ConfigureAwait(false)) > 0)
{
var chunk = new byte[bytesRead];
Array.Copy(buffer, chunk, bytesRead);
yield return chunk;
}
}
finally
{
await DisposeAsync().ConfigureAwait(false);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ private async Task<T> HandleResponseAsync<T>(HttpResponseMessage response, Cance
}
}

private async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken)
internal async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (!response.IsSuccessStatusCode)
{
Expand Down
30 changes: 30 additions & 0 deletions sdks/sandbox/csharp/src/OpenSandbox/Models/Filesystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,36 @@

namespace OpenSandbox.Models;

/// <summary>
/// Parsed Content-Range response header.
/// </summary>
public sealed record ByteRange(long Start, long End, long Total, string Raw);

/// <summary>
/// Downloaded body and its HTTP response metadata.
/// </summary>
public sealed record ReadBytesResponse<T>(
T Body,
int StatusCode,
string? ContentType,
string? ContentDisposition,
long ContentLength,
long TotalSize,
ByteRange? ContentRange)
{
/// <summary>
/// Gets whether the server returned 206 Partial Content.
/// </summary>
public bool IsPartial => StatusCode == 206;
}

/// <summary>
/// A single-use download body that can be disposed without consuming it.
/// </summary>
public interface IAsyncReadBytesStream : IAsyncEnumerable<byte[]>, IAsyncDisposable
{
}

/// <summary>
/// Information about a file in the sandbox.
/// </summary>
Expand Down
28 changes: 28 additions & 0 deletions sdks/sandbox/csharp/src/OpenSandbox/Services/ISandboxFiles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,20 @@ Task<byte[]> ReadBytesAsync(
ReadBytesOptions? options = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Reads a file as bytes together with HTTP response metadata.
/// </summary>
/// <param name="path">The file path.</param>
/// <param name="options">Optional read options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The file content and HTTP response metadata.</returns>
/// <exception cref="InvalidArgumentException">Thrown when request values are invalid.</exception>
/// <exception cref="SandboxException">Thrown when the execd service request fails.</exception>
Task<ReadBytesResponse<byte[]>> ReadBytesDetailedAsync(
string path,
ReadBytesOptions? options = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Reads a file as a stream of byte chunks.
/// </summary>
Expand All @@ -135,6 +149,20 @@ IAsyncEnumerable<byte[]> ReadBytesStreamAsync(
ReadBytesOptions? options = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Reads a file as a byte stream together with HTTP response metadata.
/// </summary>
/// <param name="path">The file path.</param>
/// <param name="options">Optional read options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The byte stream and HTTP response metadata.</returns>
/// <exception cref="InvalidArgumentException">Thrown when request values are invalid.</exception>
/// <exception cref="SandboxException">Thrown when the execd service request fails.</exception>
Task<ReadBytesResponse<IAsyncReadBytesStream>> ReadBytesStreamDetailedAsync(
string path,
ReadBytesOptions? options = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Deletes files at the specified paths.
/// </summary>
Expand Down
Loading
Loading