Skip to content
Open
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
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)
{
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(),
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