From f892e8f0e5946a21ee6e35acdd5544eea9e14137 Mon Sep 17 00:00:00 2001 From: wenmou Date: Sat, 22 Aug 2026 11:23:02 +0800 Subject: [PATCH 1/5] fix(sdk/go): expose download response metadata --- docs/sdks/go.md | 1 + sdks/sandbox/go/README.md | 1 + sdks/sandbox/go/execd.go | 120 +++++++++++++++++++++- sdks/sandbox/go/opensandbox_test.go | 148 ++++++++++++++++++++++++++++ sdks/sandbox/go/sandbox_files.go | 9 ++ 5 files changed, 278 insertions(+), 1 deletion(-) diff --git a/docs/sdks/go.md b/docs/sdks/go.md index 884b0d8c3..a8d3365f7 100644 --- a/docs/sdks/go.md +++ b/docs/sdks/go.md @@ -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:** diff --git a/sdks/sandbox/go/README.md b/sdks/sandbox/go/README.md index c7f3f74e4..ec398192b 100644 --- a/sdks/sandbox/go/README.md +++ b/sdks/sandbox/go/README.md @@ -228,6 +228,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:** | Method | Description | diff --git a/sdks/sandbox/go/execd.go b/sdks/sandbox/go/execd.go index 7bb40c02e..c76d29665 100644 --- a/sdks/sandbox/go/execd.go +++ b/sdks/sandbox/go/execd.go @@ -25,6 +25,7 @@ import ( "net/url" "os" "strconv" + "strings" ) // ExecdClient provides access to the OpenSandbox Execd API for code execution, @@ -417,10 +418,110 @@ type DownloadFileOptions struct { Limit int } +// ByteRange is a parsed Content-Range response header. Start and End are +// inclusive byte offsets. Total is -1 when the complete file size is unknown. +type ByteRange struct { + // Start is the inclusive first byte offset, or -1 if parsing failed. + Start int64 + // End is the inclusive last byte offset, or -1 if parsing failed. + End int64 + // Total is the complete file size, or -1 if unknown or parsing failed. + Total int64 + // Raw is the original Content-Range header value. + Raw string +} + +// DownloadFileResponse contains a download body and its HTTP response +// metadata. The caller must close Body. +type DownloadFileResponse struct { + // Body contains the downloaded bytes and must be closed by the caller. + Body io.ReadCloser + // StatusCode is the complete HTTP response status code. + StatusCode int + // ContentType is the Content-Type response header. + ContentType string + // ContentDisposition is the Content-Disposition response header. + ContentDisposition string + + // ContentLength is the size of the response body, or -1 when unknown. + // For partial responses it is the size of the returned byte range, not the + // complete file. + ContentLength int64 + + // TotalSize is the complete file size, or -1 when unknown. + TotalSize int64 + + // ContentRange is populated for a 206 response with a non-empty + // Content-Range header. If the header is malformed, Raw is preserved and + // the numeric fields are -1. + ContentRange *ByteRange +} + +// IsPartial reports whether the server honored the Range request and returned +// 206 Partial Content. +func (r *DownloadFileResponse) IsPartial() bool { + return r != nil && r.StatusCode == http.StatusPartialContent +} + +func parseContentRange(raw string) *ByteRange { + if raw == "" { + return nil + } + + result := &ByteRange{Start: -1, End: -1, Total: -1, Raw: raw} + parts := strings.Fields(raw) + if len(parts) != 2 || !strings.EqualFold(parts[0], "bytes") { + return result + } + + interval, totalText, ok := strings.Cut(parts[1], "/") + if !ok { + return result + } + startText, endText, ok := strings.Cut(interval, "-") + if !ok { + return result + } + + start, err := strconv.ParseInt(startText, 10, 64) + if err != nil || start < 0 { + return result + } + end, err := strconv.ParseInt(endText, 10, 64) + if err != nil || end < start { + return result + } + + total := int64(-1) + if totalText != "*" { + total, err = strconv.ParseInt(totalText, 10, 64) + if err != nil || total <= end { + return result + } + } + + result.Start = start + result.End = end + result.Total = total + return result +} + // DownloadFile downloads a file from the sandbox. The caller must close the // returned io.ReadCloser. Pass rangeHeader (e.g. "bytes=0-1023") for partial // content, or empty string for the full file. Use opts for line-based reading. +// Use DownloadFileResponse when response status or range metadata is needed. func (e *ExecdClient) DownloadFile(ctx context.Context, remotePath string, rangeHeader string, opts ...DownloadFileOptions) (io.ReadCloser, error) { + resp, err := e.DownloadFileResponse(ctx, remotePath, rangeHeader, opts...) + if err != nil { + return nil, err + } + return resp.Body, nil +} + +// DownloadFileResponse downloads a file and returns both its body and response +// metadata. Callers resuming a download should check IsPartial before appending +// the body to an existing file. +func (e *ExecdClient) DownloadFileResponse(ctx context.Context, remotePath string, rangeHeader string, opts ...DownloadFileOptions) (*DownloadFileResponse, error) { params := url.Values{} params.Set("path", remotePath) if len(opts) > 0 { @@ -465,7 +566,24 @@ func (e *ExecdClient) DownloadFile(ctx context.Context, remotePath string, range if err != nil { return nil, err } - return resp.Body, nil + + result := &DownloadFileResponse{ + Body: resp.Body, + StatusCode: resp.StatusCode, + ContentType: resp.Header.Get("Content-Type"), + ContentDisposition: resp.Header.Get("Content-Disposition"), + ContentLength: resp.ContentLength, + TotalSize: -1, + } + if resp.StatusCode == http.StatusPartialContent { + result.ContentRange = parseContentRange(resp.Header.Get("Content-Range")) + if result.ContentRange != nil { + result.TotalSize = result.ContentRange.Total + } + } else { + result.TotalSize = result.ContentLength + } + return result, nil } // CreateDirectory creates a directory at the given path with the specified mode. diff --git a/sdks/sandbox/go/opensandbox_test.go b/sdks/sandbox/go/opensandbox_test.go index 1e4564b93..b2209b36f 100644 --- a/sdks/sandbox/go/opensandbox_test.go +++ b/sdks/sandbox/go/opensandbox_test.go @@ -22,6 +22,7 @@ import ( "net/http" "net/http/httptest" "os" + "strconv" "strings" "sync" "testing" @@ -61,6 +62,22 @@ func jsonResponse(w http.ResponseWriter, status int, v any) { json.NewEncoder(w).Encode(v) } +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +type closeTrackingBody struct { + io.Reader + closed bool +} + +func (b *closeTrackingBody) Close() error { + b.closed = true + return nil +} + func TestCreateSandbox(t *testing.T) { now := time.Now().UTC().Truncate(time.Second) want := SandboxInfo{ @@ -1745,6 +1762,137 @@ func TestDownloadFile_Range(t *testing.T) { } } +func TestDownloadFileResponse_RangeIgnored(t *testing.T) { + fileContent := "hello from sandbox file" + + _, client := newExecdServer(t, func(w http.ResponseWriter, r *http.Request) { + if rangeHeader := r.Header.Get("Range"); rangeHeader != "bytes=1024-" { + assert.Fail(t, fmt.Sprintf("Range = %q, want %q", rangeHeader, "bytes=1024-")) + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", `attachment; filename="output.txt"`) + w.Header().Set("Content-Length", strconv.Itoa(len(fileContent))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(fileContent)) + }) + + resp, err := client.DownloadFileResponse(context.Background(), "/sandbox/output.txt", "bytes=1024-") + require.NoErrorf(t, err, "DownloadFileResponse") + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, false, resp.IsPartial()) + if resp.ContentRange != nil { + assert.Fail(t, fmt.Sprintf("ContentRange = %+v, want nil", resp.ContentRange)) + } + require.Equal(t, int64(len(fileContent)), resp.ContentLength) + require.Equal(t, int64(len(fileContent)), resp.TotalSize) + require.Equal(t, "application/octet-stream", resp.ContentType) + require.Equal(t, `attachment; filename="output.txt"`, resp.ContentDisposition) + data, err := io.ReadAll(resp.Body) + require.NoErrorf(t, err, "ReadAll") + require.Equal(t, fileContent, string(data)) +} + +func TestDownloadFileResponse_Range(t *testing.T) { + _, client := newExecdServer(t, func(w http.ResponseWriter, r *http.Request) { + if rangeHeader := r.Header.Get("Range"); rangeHeader != "bytes=0-4" { + assert.Fail(t, fmt.Sprintf("Range = %q, want %q", rangeHeader, "bytes=0-4")) + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Range", "bytes 0-4/10") + w.Header().Set("Content-Length", "5") + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write([]byte("hello")) + }) + + resp, err := client.DownloadFileResponse(context.Background(), "/sandbox/big.bin", "bytes=0-4") + require.NoErrorf(t, err, "DownloadFileResponse range") + defer resp.Body.Close() + + require.Equal(t, http.StatusPartialContent, resp.StatusCode) + require.Equal(t, true, resp.IsPartial()) + require.Equal(t, int64(5), resp.ContentLength) + require.Equal(t, int64(10), resp.TotalSize) + require.NotNil(t, resp.ContentRange) + require.Equal(t, ByteRange{Start: 0, End: 4, Total: 10, Raw: "bytes 0-4/10"}, *resp.ContentRange) +} + +func TestDownloadFileResponse_InvalidContentRange(t *testing.T) { + _, client := newExecdServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Range", "garbage") + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write([]byte("hello")) + }) + + resp, err := client.DownloadFileResponse(context.Background(), "/sandbox/big.bin", "bytes=0-4") + require.NoErrorf(t, err, "DownloadFileResponse invalid Content-Range") + defer resp.Body.Close() + + require.NotNil(t, resp.ContentRange) + require.Equal(t, ByteRange{Start: -1, End: -1, Total: -1, Raw: "garbage"}, *resp.ContentRange) + require.Equal(t, int64(-1), resp.TotalSize) +} + +func TestParseContentRange_UnknownTotal(t *testing.T) { + got := parseContentRange("bytes 1024-2047/*") + require.NotNil(t, got) + require.Equal(t, ByteRange{Start: 1024, End: 2047, Total: -1, Raw: "bytes 1024-2047/*"}, *got) +} + +func TestDownloadFileResponse_ClosesErrorBodies(t *testing.T) { + t.Run("error", func(t *testing.T) { + body := &closeTrackingBody{Reader: strings.NewReader(`{"code":"NOT_FOUND","message":"missing"}`)} + client := NewExecdClient("http://execd.test", "token", WithHTTPClient(&http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: body, + }, nil + }), + })) + + _, err := client.DownloadFileResponse(context.Background(), "/missing", "") + require.Error(t, err) + require.Equal(t, true, body.closed, "error response body should be closed") + }) + + t.Run("retry", func(t *testing.T) { + errorBody := &closeTrackingBody{Reader: strings.NewReader(`{"code":"UNAVAILABLE","message":"retry"}`)} + successBody := &closeTrackingBody{Reader: strings.NewReader("hello")} + attempts := 0 + client := NewExecdClient("http://execd.test", "token", + WithHTTPClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + attempts++ + if attempts == 1 { + return &http.Response{ + StatusCode: http.StatusServiceUnavailable, + Header: make(http.Header), + Body: errorBody, + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: successBody, + ContentLength: 5, + }, nil + })}), + WithRetry(RetryConfig{MaxRetries: 1, Multiplier: 1}), + ) + + resp, err := client.DownloadFileResponse(context.Background(), "/data", "") + require.NoErrorf(t, err, "DownloadFileResponse retry") + require.Equal(t, 2, attempts) + require.Equal(t, true, errorBody.closed, "failed attempt body should be closed") + require.Equal(t, false, successBody.closed, "successful response body should remain open") + require.NoErrorf(t, resp.Body.Close(), "close successful response body") + require.Equal(t, true, successBody.closed, "caller should close successful response body") + }) +} + func TestDownloadFile_WithCustomHeaders(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-Test-Header") != "download-ok" { diff --git a/sdks/sandbox/go/sandbox_files.go b/sdks/sandbox/go/sandbox_files.go index 6fc9a75f0..a29727163 100644 --- a/sdks/sandbox/go/sandbox_files.go +++ b/sdks/sandbox/go/sandbox_files.go @@ -103,6 +103,15 @@ func (s *Sandbox) DownloadFile(ctx context.Context, remotePath, rangeHeader stri return s.execd.DownloadFile(ctx, remotePath, rangeHeader, opts...) } +// DownloadFileResponse downloads a file and returns its body and response +// metadata. The caller must close the response body. +func (s *Sandbox) DownloadFileResponse(ctx context.Context, remotePath, rangeHeader string, opts ...DownloadFileOptions) (*DownloadFileResponse, error) { + if s.execd == nil { + return nil, fmt.Errorf("opensandbox: execd client not initialized") + } + return s.execd.DownloadFileResponse(ctx, remotePath, rangeHeader, opts...) +} + // CreateDirectory creates a directory in the sandbox. // Mode is octal digits as int (e.g. 755 for rwxr-xr-x). func (s *Sandbox) CreateDirectory(ctx context.Context, path string, mode int) error { From 96b16709a552420b8404464972d2ed673cc54ff1 Mon Sep 17 00:00:00 2001 From: wenmou Date: Sat, 22 Aug 2026 11:23:02 +0800 Subject: [PATCH 2/5] fix(sdk/js): expose download response metadata --- docs/sdks/javascript.md | 2 + .../src/adapters/downloadResponse.ts | 113 ++++++++++++ .../src/adapters/filesystemAdapter.ts | 74 ++++---- .../src/adapters/isolatedFilesystemAdapter.ts | 72 ++++---- sdks/sandbox/javascript/src/index.ts | 3 + .../javascript/src/models/filesystem.ts | 32 +++- .../javascript/src/services/filesystem.ts | 6 +- .../filesystem.download-response.test.mjs | 168 ++++++++++++++++++ .../javascript/tests/public-exports.test.mjs | 5 +- 9 files changed, 394 insertions(+), 81 deletions(-) create mode 100644 sdks/sandbox/javascript/src/adapters/downloadResponse.ts create mode 100644 sdks/sandbox/javascript/tests/filesystem.download-response.test.mjs diff --git a/docs/sdks/javascript.md b/docs/sdks/javascript.md index 567278326..bde9bdcf1 100644 --- a/docs/sdks/javascript.md +++ b/docs/sdks/javascript.md @@ -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"`). diff --git a/sdks/sandbox/javascript/src/adapters/downloadResponse.ts b/sdks/sandbox/javascript/src/adapters/downloadResponse.ts new file mode 100644 index 000000000..5b537ffb6 --- /dev/null +++ b/sdks/sandbox/javascript/src/adapters/downloadResponse.ts @@ -0,0 +1,113 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { + ByteRange, + ReadBytesResponse, + ReadBytesStream, +} from "../models/filesystem.js"; + +function parseHeaderInteger(raw: string | null): number { + if (raw === null || !/^\d+$/.test(raw)) return -1; + const value = Number(raw); + return Number.isSafeInteger(value) ? value : -1; +} + +function parseContentRange(raw: string | null): ByteRange | undefined { + if (raw === null || raw === "") return undefined; + + const invalid: ByteRange = { start: -1, end: -1, total: -1, raw }; + const match = /^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i.exec(raw); + if (!match) return invalid; + + const start = parseHeaderInteger(match[1]); + const end = parseHeaderInteger(match[2]); + const total = match[3] === "*" ? -1 : parseHeaderInteger(match[3]); + if (start < 0 || end < start || (total !== -1 && total <= end)) { + return invalid; + } + return { start, end, total, raw }; +} + +export function createReadBytesResponse( + response: Response, + body: TBody, +): ReadBytesResponse { + const isPartial = response.status === 206; + const contentRange = isPartial + ? parseContentRange(response.headers.get("content-range")) + : undefined; + const contentLength = parseHeaderInteger(response.headers.get("content-length")); + + return { + body, + statusCode: response.status, + contentType: response.headers.get("content-type") ?? undefined, + contentDisposition: response.headers.get("content-disposition") ?? undefined, + contentLength, + totalSize: isPartial ? (contentRange?.total ?? -1) : contentLength, + contentRange, + isPartial, + }; +} + +class ResponseByteStream implements ReadBytesStream { + private reader?: ReadableStreamDefaultReader; + private started = false; + private closed = false; + + constructor(private readonly response: Response) {} + + async close(): Promise { + if (this.closed) return; + this.closed = true; + + if (this.reader) { + await this.reader.cancel().catch(() => undefined); + this.reader.releaseLock(); + this.reader = undefined; + return; + } + await this.response.body?.cancel().catch(() => undefined); + } + + async *[Symbol.asyncIterator](): AsyncIterator { + if (this.started) { + throw new Error("Download body can only be read once"); + } + this.started = true; + if (this.closed) return; + + const body = this.response.body as ReadableStream | null; + if (!body) { + this.closed = true; + return; + } + + this.reader = body.getReader(); + try { + while (true) { + const { done, value } = await this.reader.read(); + if (done) return; + if (value) yield value; + } + } finally { + await this.close(); + } + } +} + +export function readResponseBody(response: Response): ReadBytesStream { + return new ResponseByteStream(response); +} diff --git a/sdks/sandbox/javascript/src/adapters/filesystemAdapter.ts b/sdks/sandbox/javascript/src/adapters/filesystemAdapter.ts index 678fd2087..5803bd59d 100644 --- a/sdks/sandbox/javascript/src/adapters/filesystemAdapter.ts +++ b/sdks/sandbox/javascript/src/adapters/filesystemAdapter.ts @@ -27,12 +27,15 @@ import type { Permission, RenameFileItem, ReplaceFileContentItem, + ReadBytesResponse, + ReadBytesStream, SearchEntry, SearchFilesResponse, SetPermissionEntry, WriteEntry, } from "../models/filesystem.js"; import { SandboxApiException, SandboxError } from "../core/exceptions.js"; +import { createReadBytesResponse, readResponseBody } from "./downloadResponse.js"; function joinUrl(baseUrl: string, pathname: string): string { const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; @@ -522,34 +525,16 @@ export class FilesystemAdapter implements SandboxFiles { path: string, opts?: { range?: string; offset?: number; limit?: number } ): Promise { - let url = - joinUrl(this.opts.baseUrl, "/files/download") + - `?path=${encodeURIComponent(path)}`; - if (opts?.offset != null) url += `&offset=${opts.offset}`; - if (opts?.limit != null) url += `&limit=${opts.limit}`; - const res = await this.fetch(url, { - method: "GET", - headers: { - ...(this.opts.headers ?? {}), - ...(opts?.range ? { Range: opts.range } : {}), - }, - }); - if (!res.ok) { - const requestId = res.headers.get("x-request-id") ?? undefined; - const rawBody = await res.text().catch(() => undefined); - throw new SandboxApiException({ - message: "Download failed", - statusCode: res.status, - requestId, - error: new SandboxError( - SandboxError.UNEXPECTED_RESPONSE, - "Download failed" - ), - rawBody, - }); - } + return (await this.readBytesDetailed(path, opts)).body; + } + + async readBytesDetailed( + path: string, + opts?: { range?: string; offset?: number; limit?: number } + ): Promise> { + const res = await this.fetchDownload(path, opts, "Download failed"); const ab = await res.arrayBuffer(); - return new Uint8Array(ab); + return createReadBytesResponse(res, new Uint8Array(ab)); } readBytesStream( @@ -559,10 +544,19 @@ export class FilesystemAdapter implements SandboxFiles { return this.downloadStream(path, opts); } - private async *downloadStream( + async readBytesStreamDetailed( path: string, opts?: { range?: string; offset?: number; limit?: number } - ): AsyncIterable { + ): Promise> { + const res = await this.fetchDownload(path, opts, "Download stream failed"); + return createReadBytesResponse(res, readResponseBody(res)); + } + + private async fetchDownload( + path: string, + opts: { range?: string; offset?: number; limit?: number } | undefined, + errorMessage: string, + ): Promise { let url = joinUrl(this.opts.baseUrl, "/files/download") + `?path=${encodeURIComponent(path)}`; @@ -579,25 +573,25 @@ export class FilesystemAdapter implements SandboxFiles { const requestId = res.headers.get("x-request-id") ?? undefined; const rawBody = await res.text().catch(() => undefined); throw new SandboxApiException({ - message: "Download stream failed", + message: errorMessage, statusCode: res.status, requestId, error: new SandboxError( SandboxError.UNEXPECTED_RESPONSE, - "Download stream failed" + errorMessage ), rawBody, }); } + return res; + } - const body = res.body as ReadableStream | null; - if (!body) return; - const reader = body.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) return; - if (value) yield value; - } + private async *downloadStream( + path: string, + opts?: { range?: string; offset?: number; limit?: number } + ): AsyncIterable { + const response = await this.readBytesStreamDetailed(path, opts); + yield* response.body; } async readFile( @@ -620,4 +614,4 @@ export class FilesystemAdapter implements SandboxFiles { await this.uploadFile(meta, e.data ?? ""); } } -} \ No newline at end of file +} diff --git a/sdks/sandbox/javascript/src/adapters/isolatedFilesystemAdapter.ts b/sdks/sandbox/javascript/src/adapters/isolatedFilesystemAdapter.ts index 418ca5792..848ee0666 100644 --- a/sdks/sandbox/javascript/src/adapters/isolatedFilesystemAdapter.ts +++ b/sdks/sandbox/javascript/src/adapters/isolatedFilesystemAdapter.ts @@ -34,12 +34,15 @@ import type { Permission, RenameFileItem, ReplaceFileContentItem, + ReadBytesResponse, + ReadBytesStream, SearchEntry, SearchFilesResponse, SetPermissionEntry, WriteEntry, } from "../models/filesystem.js"; import { SandboxApiException, SandboxError } from "../core/exceptions.js"; +import { createReadBytesResponse, readResponseBody } from "./downloadResponse.js"; function joinUrl(baseUrl: string, pathname: string): string { const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; @@ -360,33 +363,16 @@ export class IsolatedFilesystemAdapter implements SandboxFiles { path: string, opts?: { range?: string; offset?: number; limit?: number }, ): Promise { - let url = - joinUrl( - this.opts.baseUrl, - `/v1/isolated/session/${encodeURIComponent(this.sessionId)}/files/download`, - ) + `?path=${encodeURIComponent(path)}`; - if (opts?.offset != null) url += `&offset=${opts.offset}`; - if (opts?.limit != null) url += `&limit=${opts.limit}`; - const res = await this.fetch(url, { - method: "GET", - headers: { - ...(this.opts.headers ?? {}), - ...(opts?.range ? { Range: opts.range } : {}), - }, - }); - if (!res.ok) { - const requestId = res.headers.get("x-request-id") ?? undefined; - const rawBody = await res.text().catch(() => undefined); - throw new SandboxApiException({ - message: "Download failed", - statusCode: res.status, - requestId, - error: new SandboxError(SandboxError.UNEXPECTED_RESPONSE, "Download failed"), - rawBody, - }); - } + return (await this.readBytesDetailed(path, opts)).body; + } + + async readBytesDetailed( + path: string, + opts?: { range?: string; offset?: number; limit?: number }, + ): Promise> { + const res = await this.fetchDownload(path, opts, "Download failed"); const ab = await res.arrayBuffer(); - return new Uint8Array(ab); + return createReadBytesResponse(res, new Uint8Array(ab)); } readBytesStream( @@ -396,10 +382,19 @@ export class IsolatedFilesystemAdapter implements SandboxFiles { return this.downloadStream(path, opts); } - private async *downloadStream( + async readBytesStreamDetailed( path: string, opts?: { range?: string; offset?: number; limit?: number }, - ): AsyncIterable { + ): Promise> { + const res = await this.fetchDownload(path, opts, "Download stream failed"); + return createReadBytesResponse(res, readResponseBody(res)); + } + + private async fetchDownload( + path: string, + opts: { range?: string; offset?: number; limit?: number } | undefined, + errorMessage: string, + ): Promise { let url = joinUrl( this.opts.baseUrl, @@ -418,21 +413,22 @@ export class IsolatedFilesystemAdapter implements SandboxFiles { const requestId = res.headers.get("x-request-id") ?? undefined; const rawBody = await res.text().catch(() => undefined); throw new SandboxApiException({ - message: "Download stream failed", + message: errorMessage, statusCode: res.status, requestId, - error: new SandboxError(SandboxError.UNEXPECTED_RESPONSE, "Download stream failed"), + error: new SandboxError(SandboxError.UNEXPECTED_RESPONSE, errorMessage), rawBody, }); } - const body = res.body as ReadableStream | null; - if (!body) return; - const reader = body.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) return; - if (value) yield value; - } + return res; + } + + private async *downloadStream( + path: string, + opts?: { range?: string; offset?: number; limit?: number }, + ): AsyncIterable { + const response = await this.readBytesStreamDetailed(path, opts); + yield* response.body; } async readFile( diff --git a/sdks/sandbox/javascript/src/index.ts b/sdks/sandbox/javascript/src/index.ts index 2d25023ae..94b529b74 100644 --- a/sdks/sandbox/javascript/src/index.ts +++ b/sdks/sandbox/javascript/src/index.ts @@ -86,12 +86,15 @@ export type { SandboxFilter, SandboxManagerOptions } from "./manager.js"; export type { ExecdHealth } from "./services/execdHealth.js"; export type { ExecdMetrics } from "./services/execdMetrics.js"; export type { + ByteRange, FileEntryType, FileInfo, FileMetadata, Permission, RenameFileItem, ReplaceFileContentItem, + ReadBytesResponse, + ReadBytesStream, SearchFilesResponse, FilesInfoResponse, } from "./models/filesystem.js"; diff --git a/sdks/sandbox/javascript/src/models/filesystem.ts b/sdks/sandbox/javascript/src/models/filesystem.ts index 28dab8545..d46b13aea 100644 --- a/sdks/sandbox/javascript/src/models/filesystem.ts +++ b/sdks/sandbox/javascript/src/models/filesystem.ts @@ -66,6 +66,36 @@ export type FilesInfoResponse = Record; export type SearchFilesResponse = FileInfo[]; +export interface ByteRange { + /** Inclusive first byte offset, or -1 if parsing failed. */ + start: number; + /** Inclusive last byte offset, or -1 if parsing failed. */ + end: number; + /** Complete file size, or -1 if unknown or parsing failed. */ + total: number; + /** Original Content-Range header value. */ + raw: string; +} + +export interface ReadBytesResponse { + body: TBody; + statusCode: number; + contentType?: string; + contentDisposition?: string; + /** Response body size, or -1 if the Content-Length header is unavailable. */ + contentLength: number; + /** Complete file size, or -1 if unknown. */ + totalSize: number; + contentRange?: ByteRange; + /** Whether the server returned 206 Partial Content. */ + isPartial: boolean; +} + +/** A single-use download body that can be closed without consuming it. */ +export interface ReadBytesStream extends AsyncIterable { + close(): Promise; +} + // High-level filesystem facade models used by `sandbox.files`. export interface WriteEntry { path: string; @@ -113,4 +143,4 @@ export interface SetPermissionEntry { mode: number; owner?: string; group?: string; -} \ No newline at end of file +} diff --git a/sdks/sandbox/javascript/src/services/filesystem.ts b/sdks/sandbox/javascript/src/services/filesystem.ts index 105f0e197..e6a289ca4 100644 --- a/sdks/sandbox/javascript/src/services/filesystem.ts +++ b/sdks/sandbox/javascript/src/services/filesystem.ts @@ -19,6 +19,8 @@ import type { DirectoryListEntry, FileInfo, MoveEntry, + ReadBytesResponse, + ReadBytesStream, SearchEntry, SetPermissionEntry, WriteEntry, @@ -41,11 +43,13 @@ export interface SandboxFiles { writeFiles(entries: WriteEntry[]): Promise; readFile(path: string, opts?: { encoding?: string; range?: string; offset?: number; limit?: number }): Promise; readBytes(path: string, opts?: { range?: string; offset?: number; limit?: number }): Promise; + readBytesDetailed(path: string, opts?: { range?: string; offset?: number; limit?: number }): Promise>; readBytesStream(path: string, opts?: { range?: string; offset?: number; limit?: number }): AsyncIterable; + readBytesStreamDetailed(path: string, opts?: { range?: string; offset?: number; limit?: number }): Promise>; deleteFiles(paths: string[]): Promise; moveFiles(entries: MoveEntry[]): Promise; replaceContents(entries: ContentReplaceEntry[]): Promise; replaceContentsDetailed(entries: ContentReplaceEntry[]): Promise; setPermissions(entries: SetPermissionEntry[]): Promise; -} \ No newline at end of file +} diff --git a/sdks/sandbox/javascript/tests/filesystem.download-response.test.mjs b/sdks/sandbox/javascript/tests/filesystem.download-response.test.mjs new file mode 100644 index 000000000..ea90148d8 --- /dev/null +++ b/sdks/sandbox/javascript/tests/filesystem.download-response.test.mjs @@ -0,0 +1,168 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { FilesystemAdapter, createExecdClient } from "../dist/internal.js"; + +const baseUrl = "http://execd.test"; + +function createAdapter(fetch) { + const client = createExecdClient({ baseUrl, fetch }); + return new FilesystemAdapter(client, { baseUrl, fetch }); +} + +async function collect(stream) { + const chunks = []; + for await (const chunk of stream) chunks.push(...chunk); + return new Uint8Array(chunks); +} + +test("readBytesDetailed exposes partial response metadata", async () => { + const adapter = createAdapter(async (input, init) => { + assert.equal(new URL(input).pathname, "/files/download"); + assert.equal(new Headers(init.headers).get("range"), "bytes=0-4"); + return new Response("hello", { + status: 206, + headers: { + "Content-Type": "application/octet-stream", + "Content-Disposition": 'attachment; filename="data.bin"', + "Content-Length": "5", + "Content-Range": "bytes 0-4/10", + }, + }); + }); + + const response = await adapter.readBytesDetailed("/data.bin", { + range: "bytes=0-4", + }); + + assert.deepEqual(response.body, new TextEncoder().encode("hello")); + assert.equal(response.statusCode, 206); + assert.equal(response.isPartial, true); + assert.equal(response.contentType, "application/octet-stream"); + assert.equal(response.contentDisposition, 'attachment; filename="data.bin"'); + assert.equal(response.contentLength, 5); + assert.equal(response.totalSize, 10); + assert.deepEqual(response.contentRange, { + start: 0, + end: 4, + total: 10, + raw: "bytes 0-4/10", + }); +}); + +test("readBytesDetailed identifies an ignored Range request", async () => { + const adapter = createAdapter(async () => + new Response("whole file", { + status: 200, + headers: { "Content-Length": "10" }, + }), + ); + + const response = await adapter.readBytesDetailed("/data.bin", { + range: "bytes=5-", + }); + + assert.equal(response.statusCode, 200); + assert.equal(response.isPartial, false); + assert.equal(response.contentRange, undefined); + assert.equal(response.contentLength, 10); + assert.equal(response.totalSize, 10); +}); + +test("readBytesDetailed preserves malformed and unknown Content-Range values", async () => { + const ranges = ["garbage", "bytes 5-9/*"]; + const adapter = createAdapter(async () => + new Response("hello", { + status: 206, + headers: { "Content-Range": ranges.shift() }, + }), + ); + + const malformed = await adapter.readBytesDetailed("/data.bin"); + assert.deepEqual(malformed.contentRange, { + start: -1, + end: -1, + total: -1, + raw: "garbage", + }); + assert.equal(malformed.totalSize, -1); + + const unknown = await adapter.readBytesDetailed("/data.bin"); + assert.deepEqual(unknown.contentRange, { + start: 5, + end: 9, + total: -1, + raw: "bytes 5-9/*", + }); + assert.equal(unknown.totalSize, -1); +}); + +test("detailed and legacy streaming reads return the response body", async () => { + const adapter = createAdapter(async () => + new Response("hello", { + status: 206, + headers: { + "Content-Length": "5", + "Content-Range": "bytes 0-4/10", + }, + }), + ); + + const detailed = await adapter.readBytesStreamDetailed("/data.bin", { + range: "bytes=0-4", + }); + assert.equal(detailed.isPartial, true); + assert.deepEqual(await collect(detailed.body), new TextEncoder().encode("hello")); + + assert.deepEqual( + await adapter.readBytes("/data.bin"), + new TextEncoder().encode("hello"), + ); + assert.deepEqual( + await collect(adapter.readBytesStream("/data.bin")), + new TextEncoder().encode("hello"), + ); +}); + +test("detailed streams cancel unconsumed and partially consumed bodies", async () => { + const cancelled = []; + const adapter = createAdapter(async () => { + const index = cancelled.length; + cancelled.push(false); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("hello")); + }, + cancel() { + cancelled[index] = true; + }, + }), + { status: 206, headers: { "Content-Range": "bytes 0-4/10" } }, + ); + }); + + const unconsumed = await adapter.readBytesStreamDetailed("/data.bin"); + await unconsumed.body.close(); + assert.equal(cancelled[0], true); + + const partial = await adapter.readBytesStreamDetailed("/data.bin"); + for await (const _chunk of partial.body) { + break; + } + assert.equal(cancelled[1], true); +}); diff --git a/sdks/sandbox/javascript/tests/public-exports.test.mjs b/sdks/sandbox/javascript/tests/public-exports.test.mjs index 776f5b2ea..823c61d03 100644 --- a/sdks/sandbox/javascript/tests/public-exports.test.mjs +++ b/sdks/sandbox/javascript/tests/public-exports.test.mjs @@ -2,9 +2,12 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -test("public type declarations export credential substitution models", async () => { +test("public type declarations export stable models", async () => { const declarations = await readFile(new URL("../dist/index.d.ts", import.meta.url), "utf8"); assert.match(declarations, /\bCredentialSubstitution\b/); assert.match(declarations, /\bCredentialSubstitutionSurface\b/); + assert.match(declarations, /\bByteRange\b/); + assert.match(declarations, /\bReadBytesResponse\b/); + assert.match(declarations, /\bReadBytesStream\b/); }); From 162945768e039a7cf3dc7bfaa433188f614fc254 Mon Sep 17 00:00:00 2001 From: wenmou Date: Sat, 22 Aug 2026 11:23:02 +0800 Subject: [PATCH 3/5] fix(sdks/python): expose download response metadata --- docs/sdks/python.md | 2 + .../opensandbox/adapters/download_response.py | 176 +++++++++++++++ .../adapters/filesystem_adapter.py | 71 +++++- .../adapters/isolated_filesystem_adapter.py | 45 +++- .../python/src/opensandbox/models/__init__.py | 8 + .../src/opensandbox/models/filesystem.py | 71 ++++++ .../src/opensandbox/services/filesystem.py | 21 ++ .../sync/adapters/filesystem_adapter.py | 47 +++- .../adapters/isolated_filesystem_adapter.py | 47 +++- .../opensandbox/sync/services/filesystem.py | 21 ++ .../test_filesystem_download_response.py | 211 ++++++++++++++++++ 11 files changed, 691 insertions(+), 29 deletions(-) create mode 100644 sdks/sandbox/python/src/opensandbox/adapters/download_response.py create mode 100644 sdks/sandbox/python/tests/test_filesystem_download_response.py diff --git a/docs/sdks/python.md b/docs/sdks/python.md index 73b45e352..fd09650f2 100644 --- a/docs/sdks/python.md +++ b/docs/sdks/python.md @@ -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. diff --git a/sdks/sandbox/python/src/opensandbox/adapters/download_response.py b/sdks/sandbox/python/src/opensandbox/adapters/download_response.py new file mode 100644 index 000000000..2daba37b9 --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/adapters/download_response.py @@ -0,0 +1,176 @@ +# +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import re +from typing import TypeVar + +import httpx + +from opensandbox.models.filesystem import ( + AsyncReadBytesStream, + ByteRange, + ReadBytesResponse, + ReadBytesStream, +) + +_BodyT = TypeVar("_BodyT") +_CONTENT_RANGE_RE = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+|\*)$", re.IGNORECASE) + + +def _parse_header_integer(raw: str | None) -> int: + if raw is None or not raw.isdigit(): + return -1 + return int(raw) + + +def _parse_content_range(raw: str | None) -> ByteRange | None: + if not raw: + return None + + invalid = ByteRange(start=-1, end=-1, total=-1, raw=raw) + match = _CONTENT_RANGE_RE.fullmatch(raw) + if match is None: + return invalid + + start = int(match.group(1)) + end = int(match.group(2)) + total = -1 if match.group(3) == "*" else int(match.group(3)) + if end < start or (total != -1 and total <= end): + return invalid + return ByteRange(start=start, end=end, total=total, raw=raw) + + +def create_read_bytes_response( + response: httpx.Response, body: _BodyT +) -> ReadBytesResponse[_BodyT]: + """Map an HTTP download response to the stable SDK response model.""" + content_length = _parse_header_integer(response.headers.get("content-length")) + content_range = ( + _parse_content_range(response.headers.get("content-range")) + if response.status_code == 206 + else None + ) + total_size = ( + (content_range.total if content_range is not None else -1) + if response.status_code == 206 + else content_length + ) + + return ReadBytesResponse( + body=body, + status_code=response.status_code, + content_type=response.headers.get("content-type"), + content_disposition=response.headers.get("content-disposition"), + content_length=content_length, + total_size=total_size, + content_range=content_range, + ) + + +class _AsyncResponseByteStream: + def __init__(self, response: httpx.Response, chunk_size: int) -> None: + self._response = response + self._iterator = response.aiter_bytes(chunk_size=chunk_size).__aiter__() + self._closed = False + + def __aiter__(self) -> "_AsyncResponseByteStream": + return self + + async def __anext__(self) -> bytes: + if self._closed: + raise StopAsyncIteration + try: + return await self._iterator.__anext__() + except StopAsyncIteration: + await self.aclose() + raise + except BaseException: + await self.aclose() + raise + + async def aclose(self) -> None: + if self._closed: + return + self._closed = True + close_iterator = getattr(self._iterator, "aclose", None) + if close_iterator is not None: + await close_iterator() + await self._response.aclose() + + async def __aenter__(self) -> "_AsyncResponseByteStream": + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: object | None, + ) -> None: + await self.aclose() + + +class _ResponseByteStream: + def __init__(self, response: httpx.Response, chunk_size: int) -> None: + self._response = response + self._iterator = response.iter_bytes(chunk_size=chunk_size) + self._closed = False + + def __iter__(self) -> "_ResponseByteStream": + return self + + def __next__(self) -> bytes: + if self._closed: + raise StopIteration + try: + return next(self._iterator) + except StopIteration: + self.close() + raise + except BaseException: + self.close() + raise + + def close(self) -> None: + if self._closed: + return + self._closed = True + close_iterator = getattr(self._iterator, "close", None) + if close_iterator is not None: + close_iterator() + self._response.close() + + def __enter__(self) -> "_ResponseByteStream": + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: object | None, + ) -> None: + self.close() + + +def iter_async_response_bytes( + response: httpx.Response, chunk_size: int +) -> AsyncReadBytesStream: + return _AsyncResponseByteStream(response, chunk_size) + + +def iter_response_bytes( + response: httpx.Response, chunk_size: int +) -> ReadBytesStream: + return _ResponseByteStream(response, chunk_size) diff --git a/sdks/sandbox/python/src/opensandbox/adapters/filesystem_adapter.py b/sdks/sandbox/python/src/opensandbox/adapters/filesystem_adapter.py index e0a72f9ce..8155a0352 100644 --- a/sdks/sandbox/python/src/opensandbox/adapters/filesystem_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/adapters/filesystem_adapter.py @@ -40,14 +40,20 @@ extract_request_id, handle_api_error, ) +from opensandbox.adapters.download_response import ( + create_read_bytes_response, + iter_async_response_bytes, +) from opensandbox.config import ConnectionConfig from opensandbox.exceptions import InvalidArgumentException, SandboxApiException from opensandbox.models.filesystem import ( + AsyncReadBytesStream, ContentReplaceEntry, ContentReplaceResult, DirectoryListEntry, EntryInfo, MoveEntry, + ReadBytesResponse, SearchEntry, SetPermissionEntry, WriteEntry, @@ -71,6 +77,8 @@ def _rewind_seekable_stream(stream: IOBase) -> None: if not stream.seekable(): return stream.seek(0) + + class _DownloadRequest(TypedDict): url: str params: dict[str, str] @@ -166,6 +174,21 @@ async def read_bytes( offset: int | None = None, limit: int | None = None, ) -> bytes: + """Read file content as bytes.""" + return ( + await self.read_bytes_detailed( + path, range_header=range_header, offset=offset, limit=limit + ) + ).body + + async def read_bytes_detailed( + self, + path: str, + *, + range_header: str | None = None, + offset: int | None = None, + limit: int | None = None, + ) -> ReadBytesResponse[bytes]: """Read file content as bytes with support for range and line-based requests. Args: @@ -185,7 +208,9 @@ async def read_bytes( """ logger.debug(f"Reading file as bytes: {path}") try: - request_data = self._build_download_request(path, range_header, offset=offset, limit=limit) + request_data = self._build_download_request( + path, range_header, offset=offset, limit=limit + ) client = await self._get_httpx_client() response = await client.get( @@ -194,24 +219,46 @@ async def read_bytes( params=request_data["params"], ) response.raise_for_status() - return response.content + return create_read_bytes_response(response, response.content) except Exception as e: logger.error(f"Failed to read file {path}", exc_info=e) raise ExceptionConverter.to_sandbox_exception(e) from e async def read_bytes_stream( - self, - path: str, - *, - chunk_size: int = 64 * 1024, - range_header: str | None = None, - offset: int | None = None, - limit: int | None = None, + self, + path: str, + *, + chunk_size: int = 64 * 1024, + range_header: str | None = None, + offset: int | None = None, + limit: int | None = None, ) -> AsyncIterator[bytes]: + """Stream file content as byte chunks.""" + return ( + await self.read_bytes_stream_detailed( + path, + chunk_size=chunk_size, + range_header=range_header, + offset=offset, + limit=limit, + ) + ).body + + async def read_bytes_stream_detailed( + self, + path: str, + *, + chunk_size: int = 64 * 1024, + range_header: str | None = None, + offset: int | None = None, + limit: int | None = None, + ) -> ReadBytesResponse[AsyncReadBytesStream]: """Stream file content as bytes chunks via HTTP (true streaming).""" logger.debug(f"Streaming file as bytes: {path} (chunk_size={chunk_size})") try: - request_data = self._build_download_request(path, range_header, offset=offset, limit=limit) + request_data = self._build_download_request( + path, range_header, offset=offset, limit=limit + ) client = await self._get_httpx_client() url = request_data["url"] @@ -238,7 +285,9 @@ async def read_bytes_stream( status_code=response.status_code, request_id=extract_request_id(response.headers), ) - return response.aiter_bytes(chunk_size=chunk_size) + return create_read_bytes_response( + response, iter_async_response_bytes(response, chunk_size) + ) except Exception as e: logger.error(f"Failed to stream file {path}", exc_info=e) raise ExceptionConverter.to_sandbox_exception(e) from e diff --git a/sdks/sandbox/python/src/opensandbox/adapters/isolated_filesystem_adapter.py b/sdks/sandbox/python/src/opensandbox/adapters/isolated_filesystem_adapter.py index e0372a136..595e45fad 100644 --- a/sdks/sandbox/python/src/opensandbox/adapters/isolated_filesystem_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/adapters/isolated_filesystem_adapter.py @@ -35,14 +35,20 @@ extract_request_id, handle_api_error, ) +from opensandbox.adapters.download_response import ( + create_read_bytes_response, + iter_async_response_bytes, +) from opensandbox.config import ConnectionConfig from opensandbox.exceptions import InvalidArgumentException, SandboxApiException from opensandbox.models.filesystem import ( + AsyncReadBytesStream, ContentReplaceEntry, ContentReplaceResult, DirectoryListEntry, EntryInfo, MoveEntry, + ReadBytesResponse, SearchEntry, SetPermissionEntry, WriteEntry, @@ -124,6 +130,20 @@ async def read_bytes( offset: int | None = None, limit: int | None = None, ) -> bytes: + return ( + await self.read_bytes_detailed( + path, range_header=range_header, offset=offset, limit=limit + ) + ).body + + async def read_bytes_detailed( + self, + path: str, + *, + range_header: str | None = None, + offset: int | None = None, + limit: int | None = None, + ) -> ReadBytesResponse[bytes]: try: url = self._get_url(self.DOWNLOAD_PATH) params: dict[str, str] = {"path": path} @@ -137,7 +157,7 @@ async def read_bytes( response = await self._httpx_client.get(url, headers=headers, params=params) response.raise_for_status() - return response.content + return create_read_bytes_response(response, response.content) except Exception as e: raise ExceptionConverter.to_sandbox_exception(e) from e @@ -150,6 +170,25 @@ async def read_bytes_stream( offset: int | None = None, limit: int | None = None, ) -> AsyncIterator[bytes]: + return ( + await self.read_bytes_stream_detailed( + path, + chunk_size=chunk_size, + range_header=range_header, + offset=offset, + limit=limit, + ) + ).body + + async def read_bytes_stream_detailed( + self, + path: str, + *, + chunk_size: int = 64 * 1024, + range_header: str | None = None, + offset: int | None = None, + limit: int | None = None, + ) -> ReadBytesResponse[AsyncReadBytesStream]: try: url = self._get_url(self.DOWNLOAD_PATH) params: dict[str, str] = {"path": path} @@ -176,7 +215,9 @@ async def read_bytes_stream( status_code=response.status_code, request_id=extract_request_id(response.headers), ) - return response.aiter_bytes(chunk_size=chunk_size) + return create_read_bytes_response( + response, iter_async_response_bytes(response, chunk_size) + ) except Exception as e: raise ExceptionConverter.to_sandbox_exception(e) from e diff --git a/sdks/sandbox/python/src/opensandbox/models/__init__.py b/sdks/sandbox/python/src/opensandbox/models/__init__.py index d2ec14174..41f93b50c 100644 --- a/sdks/sandbox/python/src/opensandbox/models/__init__.py +++ b/sdks/sandbox/python/src/opensandbox/models/__init__.py @@ -32,11 +32,15 @@ OutputMessage, ) from opensandbox.models.filesystem import ( + AsyncReadBytesStream, + ByteRange, ContentReplaceEntry, ContentReplaceResult, DirectoryListEntry, EntryInfo, MoveEntry, + ReadBytesResponse, + ReadBytesStream, SearchEntry, SetPermissionEntry, WriteEntry, @@ -112,6 +116,10 @@ "IsolatedSessionSummary", "IsolatedWorkspaceSpec", # Filesystem models + "ByteRange", + "AsyncReadBytesStream", + "ReadBytesResponse", + "ReadBytesStream", "EntryInfo", "WriteEntry", "MoveEntry", diff --git a/sdks/sandbox/python/src/opensandbox/models/filesystem.py b/sdks/sandbox/python/src/opensandbox/models/filesystem.py index 434e39d26..79396be2f 100644 --- a/sdks/sandbox/python/src/opensandbox/models/filesystem.py +++ b/sdks/sandbox/python/src/opensandbox/models/filesystem.py @@ -19,11 +19,82 @@ Models for file operations, directory listings, and filesystem metadata. """ +from collections.abc import AsyncIterator, Iterator +from dataclasses import dataclass from datetime import datetime from io import IOBase +from typing import Generic, Protocol, TypeVar from pydantic import BaseModel, ConfigDict, Field, field_validator +_BodyT = TypeVar("_BodyT") + + +@dataclass(frozen=True) +class ByteRange: + """Parsed Content-Range response header.""" + + start: int + end: int + total: int + raw: str + + +@dataclass(frozen=True) +class ReadBytesResponse(Generic[_BodyT]): + """Downloaded body and its HTTP response metadata.""" + + body: _BodyT + status_code: int + content_type: str | None + content_disposition: str | None + content_length: int + total_size: int + content_range: ByteRange | None + + @property + def is_partial(self) -> bool: + """Whether the server returned 206 Partial Content.""" + return self.status_code == 206 + + +class ReadBytesStream(Protocol): + """Closeable synchronous download body.""" + + def __iter__(self) -> Iterator[bytes]: ... + + def __next__(self) -> bytes: ... + + def close(self) -> None: ... + + def __enter__(self) -> "ReadBytesStream": ... + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: object | None, + ) -> None: ... + + +class AsyncReadBytesStream(Protocol): + """Closeable asynchronous download body.""" + + def __aiter__(self) -> AsyncIterator[bytes]: ... + + async def __anext__(self) -> bytes: ... + + async def aclose(self) -> None: ... + + async def __aenter__(self) -> "AsyncReadBytesStream": ... + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: object | None, + ) -> None: ... + class EntryInfo(BaseModel): """ diff --git a/sdks/sandbox/python/src/opensandbox/services/filesystem.py b/sdks/sandbox/python/src/opensandbox/services/filesystem.py index d99a045d0..2fee91cab 100644 --- a/sdks/sandbox/python/src/opensandbox/services/filesystem.py +++ b/sdks/sandbox/python/src/opensandbox/services/filesystem.py @@ -23,11 +23,13 @@ from typing import Protocol from opensandbox.models.filesystem import ( + AsyncReadBytesStream, ContentReplaceEntry, ContentReplaceResult, DirectoryListEntry, EntryInfo, MoveEntry, + ReadBytesResponse, SearchEntry, SetPermissionEntry, WriteEntry, @@ -101,6 +103,15 @@ async def read_bytes( """ ... + async def read_bytes_detailed( + self, + path: str, + *, + range_header: str | None = None, + offset: int | None = None, + limit: int | None = None, + ) -> ReadBytesResponse[bytes]: ... + async def read_bytes_stream( self, path: str, @@ -115,6 +126,16 @@ async def read_bytes_stream( """ ... + async def read_bytes_stream_detailed( + self, + path: str, + *, + chunk_size: int = 64 * 1024, + range_header: str | None = None, + offset: int | None = None, + limit: int | None = None, + ) -> ReadBytesResponse[AsyncReadBytesStream]: ... + async def write_files(self, entries: list[WriteEntry]) -> None: """ Write content to files based on the provided write entries. diff --git a/sdks/sandbox/python/src/opensandbox/sync/adapters/filesystem_adapter.py b/sdks/sandbox/python/src/opensandbox/sync/adapters/filesystem_adapter.py index 565691664..f560f120c 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/adapters/filesystem_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/sync/adapters/filesystem_adapter.py @@ -37,6 +37,10 @@ extract_request_id, handle_api_error, ) +from opensandbox.adapters.download_response import ( + create_read_bytes_response, + iter_response_bytes, +) from opensandbox.config.connection_sync import ConnectionConfigSync from opensandbox.exceptions import InvalidArgumentException, SandboxApiException from opensandbox.models.filesystem import ( @@ -45,6 +49,8 @@ DirectoryListEntry, EntryInfo, MoveEntry, + ReadBytesResponse, + ReadBytesStream, SearchEntry, SetPermissionEntry, WriteEntry, @@ -154,6 +160,18 @@ def read_bytes( offset: int | None = None, limit: int | None = None, ) -> bytes: + return self.read_bytes_detailed( + path, range_header=range_header, offset=offset, limit=limit + ).body + + def read_bytes_detailed( + self, + path: str, + *, + range_header: str | None = None, + offset: int | None = None, + limit: int | None = None, + ) -> ReadBytesResponse[bytes]: logger.debug(f"Reading file as bytes: {path}") try: request_data = self._build_download_request( @@ -165,7 +183,7 @@ def read_bytes( params=request_data["params"], ) response.raise_for_status() - return response.content + return create_read_bytes_response(response, response.content) except Exception as e: logger.error(f"Failed to read file {path}", exc_info=e) raise ExceptionConverter.to_sandbox_exception(e) from e @@ -179,6 +197,23 @@ def read_bytes_stream( offset: int | None = None, limit: int | None = None, ) -> Iterator[bytes]: + return self.read_bytes_stream_detailed( + path, + chunk_size=chunk_size, + range_header=range_header, + offset=offset, + limit=limit, + ).body + + def read_bytes_stream_detailed( + self, + path: str, + *, + chunk_size: int = 64 * 1024, + range_header: str | None = None, + offset: int | None = None, + limit: int | None = None, + ) -> ReadBytesResponse[ReadBytesStream]: logger.debug(f"Streaming file as bytes: {path} (chunk_size={chunk_size})") request_data = self._build_download_request( path, range_header, offset=offset, limit=limit @@ -206,13 +241,9 @@ def read_bytes_stream( request_id=extract_request_id(response.headers), ) - def _iter() -> Iterator[bytes]: - try: - yield from response.iter_bytes(chunk_size=chunk_size) - finally: - response.close() - - return _iter() + return create_read_bytes_response( + response, iter_response_bytes(response, chunk_size) + ) def write_files(self, entries: list[WriteEntry]) -> None: """Write multiple files in a single operation using multipart upload. diff --git a/sdks/sandbox/python/src/opensandbox/sync/adapters/isolated_filesystem_adapter.py b/sdks/sandbox/python/src/opensandbox/sync/adapters/isolated_filesystem_adapter.py index 504e9b92c..a838dbf16 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/adapters/isolated_filesystem_adapter.py +++ b/sdks/sandbox/python/src/opensandbox/sync/adapters/isolated_filesystem_adapter.py @@ -35,6 +35,10 @@ extract_request_id, handle_api_error, ) +from opensandbox.adapters.download_response import ( + create_read_bytes_response, + iter_response_bytes, +) from opensandbox.config.connection_sync import ConnectionConfigSync from opensandbox.exceptions import InvalidArgumentException, SandboxApiException from opensandbox.models.filesystem import ( @@ -43,6 +47,8 @@ DirectoryListEntry, EntryInfo, MoveEntry, + ReadBytesResponse, + ReadBytesStream, SearchEntry, SetPermissionEntry, WriteEntry, @@ -124,6 +130,18 @@ def read_bytes( offset: int | None = None, limit: int | None = None, ) -> bytes: + return self.read_bytes_detailed( + path, range_header=range_header, offset=offset, limit=limit + ).body + + def read_bytes_detailed( + self, + path: str, + *, + range_header: str | None = None, + offset: int | None = None, + limit: int | None = None, + ) -> ReadBytesResponse[bytes]: try: url = self._get_url(self.DOWNLOAD_PATH) params: dict[str, str] = {"path": path} @@ -137,7 +155,7 @@ def read_bytes( response = self._httpx_client.get(url, headers=headers, params=params) response.raise_for_status() - return response.content + return create_read_bytes_response(response, response.content) except Exception as e: raise ExceptionConverter.to_sandbox_exception(e) from e @@ -150,6 +168,23 @@ def read_bytes_stream( offset: int | None = None, limit: int | None = None, ) -> Iterator[bytes]: + return self.read_bytes_stream_detailed( + path, + chunk_size=chunk_size, + range_header=range_header, + offset=offset, + limit=limit, + ).body + + def read_bytes_stream_detailed( + self, + path: str, + *, + chunk_size: int = 64 * 1024, + range_header: str | None = None, + offset: int | None = None, + limit: int | None = None, + ) -> ReadBytesResponse[ReadBytesStream]: url = self._get_url(self.DOWNLOAD_PATH) params: dict[str, str] = {"path": path} headers: dict[str, str] = {} @@ -176,13 +211,9 @@ def read_bytes_stream( request_id=extract_request_id(response.headers), ) - def _iter() -> Iterator[bytes]: - try: - yield from response.iter_bytes(chunk_size=chunk_size) - finally: - response.close() - - return _iter() + return create_read_bytes_response( + response, iter_response_bytes(response, chunk_size) + ) def write_files(self, entries: list[WriteEntry]) -> None: if not entries: diff --git a/sdks/sandbox/python/src/opensandbox/sync/services/filesystem.py b/sdks/sandbox/python/src/opensandbox/sync/services/filesystem.py index c2f98ed39..dc8db054a 100644 --- a/sdks/sandbox/python/src/opensandbox/sync/services/filesystem.py +++ b/sdks/sandbox/python/src/opensandbox/sync/services/filesystem.py @@ -30,6 +30,8 @@ DirectoryListEntry, EntryInfo, MoveEntry, + ReadBytesResponse, + ReadBytesStream, SearchEntry, SetPermissionEntry, WriteEntry, @@ -106,6 +108,15 @@ def read_bytes( """ ... + def read_bytes_detailed( + self, + path: str, + *, + range_header: str | None = None, + offset: int | None = None, + limit: int | None = None, + ) -> ReadBytesResponse[bytes]: ... + def read_bytes_stream( self, path: str, @@ -131,6 +142,16 @@ def read_bytes_stream( """ ... + def read_bytes_stream_detailed( + self, + path: str, + *, + chunk_size: int = 64 * 1024, + range_header: str | None = None, + offset: int | None = None, + limit: int | None = None, + ) -> ReadBytesResponse[ReadBytesStream]: ... + def write_files(self, entries: list[WriteEntry]) -> None: """ Write content to files based on the provided write entries. diff --git a/sdks/sandbox/python/tests/test_filesystem_download_response.py b/sdks/sandbox/python/tests/test_filesystem_download_response.py new file mode 100644 index 000000000..77bcdc2c4 --- /dev/null +++ b/sdks/sandbox/python/tests/test_filesystem_download_response.py @@ -0,0 +1,211 @@ +# +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import httpx +import pytest + +from opensandbox.adapters.filesystem_adapter import FilesystemAdapter +from opensandbox.config import ConnectionConfig +from opensandbox.config.connection_sync import ConnectionConfigSync +from opensandbox.models import ByteRange, ReadBytesResponse +from opensandbox.models.sandboxes import SandboxEndpoint +from opensandbox.sync.adapters.filesystem_adapter import FilesystemAdapterSync + + +def _endpoint() -> SandboxEndpoint: + return SandboxEndpoint(endpoint="localhost:44772") + + +@pytest.mark.asyncio +async def test_async_read_bytes_detailed_exposes_partial_metadata() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["Range"] == "bytes=0-4" + return httpx.Response( + 206, + request=request, + content=b"hello", + headers={ + "Content-Type": "application/octet-stream", + "Content-Disposition": 'attachment; filename="data.bin"', + "Content-Length": "5", + "Content-Range": "bytes 0-4/10", + }, + ) + + adapter = FilesystemAdapter( + ConnectionConfig(protocol="http", transport=httpx.MockTransport(handler)), + _endpoint(), + ) + response = await adapter.read_bytes_detailed("/data.bin", range_header="bytes=0-4") + + assert isinstance(response, ReadBytesResponse) + assert response.body == b"hello" + assert response.status_code == 206 + assert response.is_partial is True + assert response.content_type == "application/octet-stream" + assert response.content_disposition == 'attachment; filename="data.bin"' + assert response.content_length == 5 + assert response.total_size == 10 + assert response.content_range == ByteRange( + start=0, end=4, total=10, raw="bytes 0-4/10" + ) + await adapter._httpx_client.aclose() + + +@pytest.mark.asyncio +async def test_async_read_bytes_detailed_identifies_ignored_and_invalid_ranges() -> ( + None +): + ranges = iter([None, "garbage", "bytes 5-9/*"]) + + def handler(request: httpx.Request) -> httpx.Response: + content_range = next(ranges) + status = 200 if content_range is None else 206 + headers = {"Content-Length": "10"} + if content_range is not None: + headers["Content-Range"] = content_range + return httpx.Response( + status, request=request, content=b"0123456789", headers=headers + ) + + adapter = FilesystemAdapter( + ConnectionConfig(protocol="http", transport=httpx.MockTransport(handler)), + _endpoint(), + ) + + ignored = await adapter.read_bytes_detailed("/data.bin", range_header="bytes=5-") + assert ignored.is_partial is False + assert ignored.content_range is None + assert ignored.total_size == 10 + + malformed = await adapter.read_bytes_detailed("/data.bin") + assert malformed.content_range == ByteRange(-1, -1, -1, "garbage") + assert malformed.total_size == -1 + + unknown = await adapter.read_bytes_detailed("/data.bin") + assert unknown.content_range == ByteRange(5, 9, -1, "bytes 5-9/*") + assert unknown.total_size == -1 + await adapter._httpx_client.aclose() + + +class _TrackingAsyncStream(httpx.AsyncByteStream): + def __init__(self) -> None: + self.closed = False + + async def __aiter__(self): + yield b"hello" + + async def aclose(self) -> None: + self.closed = True + + +@pytest.mark.asyncio +async def test_async_detailed_and_legacy_streams_close_responses() -> None: + streams: list[_TrackingAsyncStream] = [] + + def handler(request: httpx.Request) -> httpx.Response: + stream = _TrackingAsyncStream() + streams.append(stream) + return httpx.Response( + 206, + request=request, + stream=stream, + headers={"Content-Length": "5", "Content-Range": "bytes 0-4/10"}, + ) + + adapter = FilesystemAdapter( + ConnectionConfig(protocol="http", transport=httpx.MockTransport(handler)), + _endpoint(), + ) + + detailed = await adapter.read_bytes_stream_detailed("/data.bin") + assert detailed.is_partial is True + assert streams[0].closed is False + assert b"".join([chunk async for chunk in detailed.body]) == b"hello" + assert streams[0].closed is True + + legacy = await adapter.read_bytes_stream("/data.bin") + assert b"".join([chunk async for chunk in legacy]) == b"hello" + assert streams[1].closed is True + + unconsumed = await adapter.read_bytes_stream_detailed("/data.bin") + await unconsumed.body.aclose() + assert streams[2].closed is True + + partial = await adapter.read_bytes_stream_detailed("/data.bin") + async with partial.body: + async for _chunk in partial.body: + break + assert streams[3].closed is True + await adapter._httpx_client.aclose() + + +def test_sync_detailed_and_legacy_reads_preserve_behavior() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 206, + request=request, + content=b"hello", + headers={"Content-Length": "5", "Content-Range": "bytes 0-4/10"}, + ) + + adapter = FilesystemAdapterSync( + ConnectionConfigSync(protocol="http", transport=httpx.MockTransport(handler)), + _endpoint(), + ) + + detailed = adapter.read_bytes_detailed("/data.bin") + assert detailed.body == b"hello" + assert detailed.content_range == ByteRange(0, 4, 10, "bytes 0-4/10") + assert adapter.read_bytes("/data.bin") == b"hello" + assert b"".join(adapter.read_bytes_stream("/data.bin")) == b"hello" + adapter._httpx_client.close() + + +class _TrackingSyncStream(httpx.SyncByteStream): + def __init__(self) -> None: + self.closed = False + + def __iter__(self): + yield b"hello" + + def close(self) -> None: + self.closed = True + + +def test_sync_detailed_streams_close_unconsumed_and_partial_responses() -> None: + streams: list[_TrackingSyncStream] = [] + + def handler(request: httpx.Request) -> httpx.Response: + stream = _TrackingSyncStream() + streams.append(stream) + return httpx.Response(206, request=request, stream=stream) + + adapter = FilesystemAdapterSync( + ConnectionConfigSync(protocol="http", transport=httpx.MockTransport(handler)), + _endpoint(), + ) + + unconsumed = adapter.read_bytes_stream_detailed("/data.bin") + unconsumed.body.close() + assert streams[0].closed is True + + partial = adapter.read_bytes_stream_detailed("/data.bin") + with partial.body: + for _chunk in partial.body: + break + assert streams[1].closed is True + adapter._httpx_client.close() From ee13856cbc02860b0b1196effd97eaabdb4f8338 Mon Sep 17 00:00:00 2001 From: wenmou Date: Sat, 22 Aug 2026 11:23:03 +0800 Subject: [PATCH 4/5] fix(kotlin): expose download response metadata --- docs/sdks/kotlin.md | 2 + .../execd/filesystem/FilesystemModels.kt | 23 ++++++ .../sandbox/domain/services/Filesystem.kt | 21 +++++ .../service/DownloadResponseMapper.kt | 55 +++++++++++++ .../adapters/service/FilesystemAdapter.kt | 30 +++++-- .../service/IsolatedFilesystemAdapter.kt | 30 +++++-- .../adapters/service/FilesystemAdapterTest.kt | 82 +++++++++++++++++++ 7 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/DownloadResponseMapper.kt diff --git a/docs/sdks/kotlin.md b/docs/sdks/kotlin.md index 23834ea3d..e54e7718f 100644 --- a/docs/sdks/kotlin.md +++ b/docs/sdks/kotlin.md @@ -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. diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/execd/filesystem/FilesystemModels.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/execd/filesystem/FilesystemModels.kt index 19aad90a2..361ba2d68 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/execd/filesystem/FilesystemModels.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/execd/filesystem/FilesystemModels.kt @@ -18,6 +18,29 @@ package com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem import java.time.OffsetDateTime +/** Parsed Content-Range response header. */ +data class ByteRange( + val start: Long, + val end: Long, + val total: Long, + val raw: String, +) + +/** Downloaded body and its HTTP response metadata. */ +data class ReadBytesResponse( + val body: T, + val statusCode: Int, + val contentType: String?, + val contentDisposition: String?, + val contentLength: Long, + val totalSize: Long, + val contentRange: ByteRange?, +) { + /** Whether the server returned 206 Partial Content. */ + val isPartial: Boolean + get() = statusCode == 206 +} + /** * Metadata information for a file or directory entry. * diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Filesystem.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Filesystem.kt index fcd58e918..37903a2a3 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Filesystem.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Filesystem.kt @@ -20,6 +20,7 @@ import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.ContentRep import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.ContentReplaceResult import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.EntryInfo import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.MoveEntry +import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.ReadBytesResponse import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.SearchEntry import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.SetPermissionEntry import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.WriteEntry @@ -115,6 +116,16 @@ interface Filesystem { return readByteArray(path, null) } + /** + * Reads a file as a byte array together with HTTP response metadata. + */ + fun readByteArrayDetailed( + path: String, + range: String? = null, + offset: Int? = null, + limit: Int? = null, + ): ReadBytesResponse + /** * Opens a file for reading as an InputStream. * @@ -154,6 +165,16 @@ interface Filesystem { return readStream(path, null) } + /** + * Opens a file stream together with HTTP response metadata. + */ + fun readStreamDetailed( + path: String, + range: String? = null, + offset: Int? = null, + limit: Int? = null, + ): ReadBytesResponse + /** * Writes content to files based on the provided write entries. * diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/DownloadResponseMapper.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/DownloadResponseMapper.kt new file mode 100644 index 000000000..341e13f5e --- /dev/null +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/DownloadResponseMapper.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Alibaba Group Holding Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.alibaba.opensandbox.sandbox.infrastructure.adapters.service + +import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.ByteRange +import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.ReadBytesResponse +import okhttp3.Response + +private val contentRangeRegex = Regex("""^bytes\s+(\d+)-(\d+)/(\d+|\*)$""", RegexOption.IGNORE_CASE) + +private fun parseContentRange(raw: String?): ByteRange? { + if (raw.isNullOrEmpty()) return null + + val invalid = ByteRange(start = -1, end = -1, total = -1, raw = raw) + val match = contentRangeRegex.matchEntire(raw) ?: return invalid + val start = match.groupValues[1].toLongOrNull() ?: return invalid + val end = match.groupValues[2].toLongOrNull() ?: return invalid + val total = + if (match.groupValues[3] == "*") { + -1 + } else { + match.groupValues[3].toLongOrNull() ?: return invalid + } + if (end < start || (total != -1L && total <= end)) return invalid + return ByteRange(start = start, end = end, total = total, raw = raw) +} + +internal fun Response.toReadBytesResponse(body: T): ReadBytesResponse { + val isPartial = code == 206 + val contentLength = header("Content-Length")?.toLongOrNull()?.takeIf { it >= 0 } ?: -1 + val contentRange = if (isPartial) parseContentRange(header("Content-Range")) else null + return ReadBytesResponse( + body = body, + statusCode = code, + contentType = header("Content-Type"), + contentDisposition = header("Content-Disposition"), + contentLength = contentLength, + totalSize = if (isPartial) contentRange?.total ?: -1 else contentLength, + contentRange = contentRange, + ) +} diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapter.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapter.kt index 029594a66..167822a70 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapter.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapter.kt @@ -23,6 +23,7 @@ import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.ContentRep import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.ContentReplaceResult import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.EntryInfo import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.MoveEntry +import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.ReadBytesResponse import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.SearchEntry import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.SetPermissionEntry import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.WriteEntry @@ -113,7 +114,14 @@ internal class FilesystemAdapter( range: String?, offset: Int?, limit: Int?, - ): ByteArray { + ): ByteArray = readByteArrayDetailed(path, range, offset, limit).body + + override fun readByteArrayDetailed( + path: String, + range: String?, + offset: Int?, + limit: Int?, + ): ReadBytesResponse { try { val request = buildDownloadRequest(path, range, offset, limit) httpClientProvider.httpClient.newCall(request).execute().use { response -> @@ -122,7 +130,8 @@ internal class FilesystemAdapter( "Failed to read file. Status code: $statusCode, Body: $body" } } - return response.body?.bytes() ?: ByteArray(0) + val body = response.body?.bytes() ?: ByteArray(0) + return response.toReadBytesResponse(body) } } catch (e: Exception) { logReadFailure("Failed to read file as byte array: $path", e) @@ -135,7 +144,14 @@ internal class FilesystemAdapter( range: String?, offset: Int?, limit: Int?, - ): InputStream { + ): InputStream = readStreamDetailed(path, range, offset, limit).body + + override fun readStreamDetailed( + path: String, + range: String?, + offset: Int?, + limit: Int?, + ): ReadBytesResponse { try { val request = buildDownloadRequest(path, range, offset, limit) val response = httpClientProvider.httpClient.newCall(request).execute() @@ -151,8 +167,12 @@ internal class FilesystemAdapter( } } - return response.body?.byteStream() - ?: throw IllegalStateException("Response body is null") + val responseBody = + response.body ?: run { + response.close() + throw IllegalStateException("Response body is null") + } + return response.toReadBytesResponse(responseBody.byteStream()) } catch (e: Exception) { logReadFailure("Failed to read file as stream: $path", e) throw e.toSandboxException() diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/IsolatedFilesystemAdapter.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/IsolatedFilesystemAdapter.kt index dd193c0e8..842b3f86e 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/IsolatedFilesystemAdapter.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/IsolatedFilesystemAdapter.kt @@ -23,6 +23,7 @@ import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.ContentRep import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.ContentReplaceResult import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.EntryInfo import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.MoveEntry +import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.ReadBytesResponse import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.SearchEntry import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.SetPermissionEntry import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.WriteEntry @@ -118,7 +119,14 @@ internal class IsolatedFilesystemAdapter( range: String?, offset: Int?, limit: Int?, - ): ByteArray { + ): ByteArray = readByteArrayDetailed(path, range, offset, limit).body + + override fun readByteArrayDetailed( + path: String, + range: String?, + offset: Int?, + limit: Int?, + ): ReadBytesResponse { try { val request = buildDownloadRequest(path, range, offset, limit) httpClientProvider.httpClient.newCall(request).execute().use { response -> @@ -127,7 +135,8 @@ internal class IsolatedFilesystemAdapter( "Failed to read file. Status code: $statusCode, Body: $body" } } - return response.body?.bytes() ?: ByteArray(0) + val body = response.body?.bytes() ?: ByteArray(0) + return response.toReadBytesResponse(body) } } catch (e: Exception) { logReadFailure("Failed to read file as byte array: $path", e) @@ -140,7 +149,14 @@ internal class IsolatedFilesystemAdapter( range: String?, offset: Int?, limit: Int?, - ): InputStream { + ): InputStream = readStreamDetailed(path, range, offset, limit).body + + override fun readStreamDetailed( + path: String, + range: String?, + offset: Int?, + limit: Int?, + ): ReadBytesResponse { try { val request = buildDownloadRequest(path, range, offset, limit) val response = httpClientProvider.httpClient.newCall(request).execute() @@ -154,8 +170,12 @@ internal class IsolatedFilesystemAdapter( throw e } } - return response.body?.byteStream() - ?: throw IllegalStateException("Response body is null") + val responseBody = + response.body ?: run { + response.close() + throw IllegalStateException("Response body is null") + } + return response.toReadBytesResponse(responseBody.byteStream()) } catch (e: Exception) { logReadFailure("Failed to read file as stream: $path", e) throw e.toSandboxException() diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapterTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapterTest.kt index 7bbb95754..12bff0b04 100644 --- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapterTest.kt +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/FilesystemAdapterTest.kt @@ -20,13 +20,16 @@ import com.alibaba.opensandbox.sandbox.HttpClientProvider import com.alibaba.opensandbox.sandbox.config.ConnectionConfig import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxApiException import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxError +import com.alibaba.opensandbox.sandbox.domain.models.execd.filesystem.ByteRange import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.isFileNotFound import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -97,6 +100,85 @@ class FilesystemAdapterTest { assertEquals("hello world", content) } + @Test + fun `readByteArrayDetailed exposes partial response metadata`() { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(206) + .setHeader("Content-Type", "application/octet-stream") + .setHeader("Content-Disposition", "attachment; filename=\"data.bin\"") + .setHeader("Content-Range", "bytes 0-4/10") + .setBody("hello"), + ) + + val response = filesystemAdapter.readByteArrayDetailed("/data.bin", "bytes=0-4") + + assertArrayEquals("hello".toByteArray(), response.body) + assertEquals(206, response.statusCode) + assertTrue(response.isPartial) + assertEquals("application/octet-stream", response.contentType) + assertEquals("attachment; filename=\"data.bin\"", response.contentDisposition) + assertEquals(5, response.contentLength) + assertEquals(10, response.totalSize) + assertEquals(ByteRange(0, 4, 10, "bytes 0-4/10"), response.contentRange) + assertEquals("bytes=0-4", mockWebServer.takeRequest().getHeader("Range")) + } + + @Test + fun `readByteArrayDetailed identifies an ignored Range request`() { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("whole file"), + ) + + val response = filesystemAdapter.readByteArrayDetailed("/data.bin", "bytes=5-") + + assertFalse(response.isPartial) + assertNull(response.contentRange) + assertEquals(10, response.contentLength) + assertEquals(10, response.totalSize) + } + + @Test + fun `readByteArrayDetailed preserves invalid and unknown Content-Range values`() { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(206) + .setHeader("Content-Range", "garbage") + .setBody("hello"), + ) + mockWebServer.enqueue( + MockResponse() + .setResponseCode(206) + .setHeader("Content-Range", "bytes 5-9/*") + .setBody("hello"), + ) + + val invalid = filesystemAdapter.readByteArrayDetailed("/data.bin") + val unknown = filesystemAdapter.readByteArrayDetailed("/data.bin") + + assertEquals(ByteRange(-1, -1, -1, "garbage"), invalid.contentRange) + assertEquals(-1, invalid.totalSize) + assertEquals(ByteRange(5, 9, -1, "bytes 5-9/*"), unknown.contentRange) + assertEquals(-1, unknown.totalSize) + } + + @Test + fun `detailed and legacy reads return response bodies`() { + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("hello")) + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("hello")) + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("hello")) + + filesystemAdapter.readStreamDetailed("/data.bin").body.use { stream -> + assertArrayEquals("hello".toByteArray(), stream.readBytes()) + } + assertArrayEquals("hello".toByteArray(), filesystemAdapter.readByteArray("/data.bin")) + filesystemAdapter.readStream("/data.bin").use { stream -> + assertArrayEquals("hello".toByteArray(), stream.readBytes()) + } + } + @Test fun `isFileNotFound is true for FILE_NOT_FOUND error code`() { val exception = From 33fe85d199625f464053d38bf843ceaa82650d8a Mon Sep 17 00:00:00 2001 From: wenmou Date: Sat, 22 Aug 2026 11:23:03 +0800 Subject: [PATCH 5/5] fix(csharp): expose download response metadata --- docs/sdks/csharp.md | 2 + .../OpenSandbox/Adapters/FilesystemAdapter.cs | 207 ++++++++++++++---- .../OpenSandbox/Internal/HttpClientWrapper.cs | 2 +- .../src/OpenSandbox/Models/Filesystem.cs | 30 +++ .../src/OpenSandbox/Services/ISandboxFiles.cs | 28 +++ .../FilesystemAdapterTests.cs | 174 +++++++++++++++ .../SandboxEgressLifecycleTests.cs | 6 + 7 files changed, 405 insertions(+), 44 deletions(-) diff --git a/docs/sdks/csharp.md b/docs/sdks/csharp.md index e02b49407..1cc887669 100644 --- a/docs/sdks/csharp.md +++ b/docs/sdks/csharp.md @@ -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. diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Adapters/FilesystemAdapter.cs b/sdks/sandbox/csharp/src/OpenSandbox/Adapters/FilesystemAdapter.cs index 0aadc33fc..3784377e2 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Adapters/FilesystemAdapter.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Adapters/FilesystemAdapter.cs @@ -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; @@ -32,6 +33,9 @@ internal sealed class FilesystemAdapter : ISandboxFiles private readonly HttpClient _httpClient; private readonly string _baseUrl; private readonly IReadOnlyDictionary _headers; + private static readonly Regex ContentRangeRegex = new( + @"^bytes\s+(\d+)-(\d+)/(\d+|\*)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly JsonSerializerOptions JsonOptions = new() { @@ -190,34 +194,69 @@ public async Task ReadBytesAsync( ReadBytesOptions? options = null, CancellationToken cancellationToken = default) { - var headers = new Dictionary(); - 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 - { - ["path"] = path - }; + public async Task> 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 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> 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 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) @@ -229,43 +268,125 @@ public async IAsyncEnumerable 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 CreateReadBytesResponse(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( + 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 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 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); + } } } diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Internal/HttpClientWrapper.cs b/sdks/sandbox/csharp/src/OpenSandbox/Internal/HttpClientWrapper.cs index 19aa542c8..f2ed35721 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Internal/HttpClientWrapper.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Internal/HttpClientWrapper.cs @@ -320,7 +320,7 @@ private async Task HandleResponseAsync(HttpResponseMessage response, Cance } } - private async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken) + internal async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken) { if (!response.IsSuccessStatusCode) { diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Models/Filesystem.cs b/sdks/sandbox/csharp/src/OpenSandbox/Models/Filesystem.cs index cc923b5a9..a47c4f2a7 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Models/Filesystem.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Models/Filesystem.cs @@ -16,6 +16,36 @@ namespace OpenSandbox.Models; +/// +/// Parsed Content-Range response header. +/// +public sealed record ByteRange(long Start, long End, long Total, string Raw); + +/// +/// Downloaded body and its HTTP response metadata. +/// +public sealed record ReadBytesResponse( + T Body, + int StatusCode, + string? ContentType, + string? ContentDisposition, + long ContentLength, + long TotalSize, + ByteRange? ContentRange) +{ + /// + /// Gets whether the server returned 206 Partial Content. + /// + public bool IsPartial => StatusCode == 206; +} + +/// +/// A single-use download body that can be disposed without consuming it. +/// +public interface IAsyncReadBytesStream : IAsyncEnumerable, IAsyncDisposable +{ +} + /// /// Information about a file in the sandbox. /// diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Services/ISandboxFiles.cs b/sdks/sandbox/csharp/src/OpenSandbox/Services/ISandboxFiles.cs index 505e8081b..66caf104b 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Services/ISandboxFiles.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Services/ISandboxFiles.cs @@ -121,6 +121,20 @@ Task ReadBytesAsync( ReadBytesOptions? options = null, CancellationToken cancellationToken = default); + /// + /// Reads a file as bytes together with HTTP response metadata. + /// + /// The file path. + /// Optional read options. + /// Cancellation token. + /// The file content and HTTP response metadata. + /// Thrown when request values are invalid. + /// Thrown when the execd service request fails. + Task> ReadBytesDetailedAsync( + string path, + ReadBytesOptions? options = null, + CancellationToken cancellationToken = default); + /// /// Reads a file as a stream of byte chunks. /// @@ -135,6 +149,20 @@ IAsyncEnumerable ReadBytesStreamAsync( ReadBytesOptions? options = null, CancellationToken cancellationToken = default); + /// + /// Reads a file as a byte stream together with HTTP response metadata. + /// + /// The file path. + /// Optional read options. + /// Cancellation token. + /// The byte stream and HTTP response metadata. + /// Thrown when request values are invalid. + /// Thrown when the execd service request fails. + Task> ReadBytesStreamDetailedAsync( + string path, + ReadBytesOptions? options = null, + CancellationToken cancellationToken = default); + /// /// Deletes files at the specified paths. /// diff --git a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/FilesystemAdapterTests.cs b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/FilesystemAdapterTests.cs index 5dbab53f3..1ae564dc3 100644 --- a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/FilesystemAdapterTests.cs +++ b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/FilesystemAdapterTests.cs @@ -17,6 +17,7 @@ using FluentAssertions; using OpenSandbox.Adapters; using OpenSandbox.Internal; +using OpenSandbox.Models; using Xunit; namespace OpenSandbox.Tests; @@ -71,6 +72,149 @@ public async Task ListDirectoryAsync_ShouldOmitDepthWhenNull() entries.Should().BeEmpty(); } + [Fact] + public async Task ReadBytesDetailedAsync_ShouldExposePartialMetadata() + { + var handler = new DownloadHandler(); + handler.Enqueue(DownloadResponse( + HttpStatusCode.PartialContent, + "hello", + contentRange: "bytes 0-4/10", + contentType: "application/octet-stream", + contentDisposition: "attachment; filename=\"data.bin\"")); + using var client = new HttpClient(handler); + var adapter = CreateAdapter(client); + + var response = await adapter.ReadBytesDetailedAsync( + "/data.bin", + new ReadBytesOptions { Range = "bytes=0-4" }); + + response.Body.Should().Equal(Encoding.UTF8.GetBytes("hello")); + response.StatusCode.Should().Be(206); + response.IsPartial.Should().BeTrue(); + response.ContentType.Should().Be("application/octet-stream"); + response.ContentDisposition.Should().Be("attachment; filename=\"data.bin\""); + response.ContentLength.Should().Be(5); + response.TotalSize.Should().Be(10); + response.ContentRange.Should().Be(new ByteRange(0, 4, 10, "bytes 0-4/10")); + handler.LastRange.Should().Be("bytes=0-4"); + } + + [Fact] + public async Task ReadBytesDetailedAsync_ShouldIdentifyIgnoredRange() + { + var handler = new DownloadHandler(); + handler.Enqueue(DownloadResponse(HttpStatusCode.OK, "whole file")); + using var client = new HttpClient(handler); + var adapter = CreateAdapter(client); + + var response = await adapter.ReadBytesDetailedAsync( + "/data.bin", + new ReadBytesOptions { Range = "bytes=5-" }); + + response.IsPartial.Should().BeFalse(); + response.ContentRange.Should().BeNull(); + response.ContentLength.Should().Be(10); + response.TotalSize.Should().Be(10); + } + + [Fact] + public async Task ReadBytesDetailedAsync_ShouldPreserveInvalidAndUnknownRanges() + { + var handler = new DownloadHandler(); + handler.Enqueue(DownloadResponse(HttpStatusCode.PartialContent, "hello", "garbage")); + handler.Enqueue(DownloadResponse(HttpStatusCode.PartialContent, "hello", "bytes 5-9/*")); + using var client = new HttpClient(handler); + var adapter = CreateAdapter(client); + + var invalid = await adapter.ReadBytesDetailedAsync("/data.bin"); + var unknown = await adapter.ReadBytesDetailedAsync("/data.bin"); + + invalid.ContentRange.Should().Be(new ByteRange(-1, -1, -1, "garbage")); + invalid.TotalSize.Should().Be(-1); + unknown.ContentRange.Should().Be(new ByteRange(5, 9, -1, "bytes 5-9/*")); + unknown.TotalSize.Should().Be(-1); + } + + [Fact] + public async Task DetailedAndLegacyReads_ShouldReturnResponseBodies() + { + var handler = new DownloadHandler(); + handler.Enqueue(DownloadResponse(HttpStatusCode.PartialContent, "hello", "bytes 0-4/10")); + handler.Enqueue(DownloadResponse(HttpStatusCode.OK, "hello")); + handler.Enqueue(DownloadResponse(HttpStatusCode.OK, "hello")); + using var client = new HttpClient(handler); + var adapter = CreateAdapter(client); + + var detailed = await adapter.ReadBytesStreamDetailedAsync("/data.bin"); + detailed.IsPartial.Should().BeTrue(); + (await CollectAsync(detailed.Body)).Should().Equal(Encoding.UTF8.GetBytes("hello")); + (await adapter.ReadBytesAsync("/data.bin")).Should().Equal(Encoding.UTF8.GetBytes("hello")); + (await CollectAsync(adapter.ReadBytesStreamAsync("/data.bin"))).Should().Equal(Encoding.UTF8.GetBytes("hello")); + } + + [Fact] + public async Task DetailedStreams_ShouldDisposeUnconsumedAndPartialBodies() + { + var unconsumedContent = new TrackingContent("hello"); + var partialContent = new TrackingContent("hello"); + var handler = new DownloadHandler(); + handler.Enqueue(new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = unconsumedContent }); + handler.Enqueue(new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = partialContent }); + using var client = new HttpClient(handler); + var adapter = CreateAdapter(client); + + var unconsumed = await adapter.ReadBytesStreamDetailedAsync("/data.bin"); + await unconsumed.Body.DisposeAsync(); + unconsumedContent.Disposed.Should().BeTrue(); + + var partial = await adapter.ReadBytesStreamDetailedAsync("/data.bin"); + await foreach (var _ in partial.Body) + { + break; + } + partialContent.Disposed.Should().BeTrue(); + } + + private static FilesystemAdapter CreateAdapter(HttpClient client) + { + var wrapper = new HttpClientWrapper(client, "http://localhost:8080"); + return new FilesystemAdapter(wrapper, client, "http://localhost:8080", new Dictionary()); + } + + private static HttpResponseMessage DownloadResponse( + HttpStatusCode statusCode, + string body, + string? contentRange = null, + string? contentType = null, + string? contentDisposition = null) + { + var content = new ByteArrayContent(Encoding.UTF8.GetBytes(body)); + if (contentRange != null) + { + content.Headers.TryAddWithoutValidation("Content-Range", contentRange); + } + if (contentType != null) + { + content.Headers.TryAddWithoutValidation("Content-Type", contentType); + } + if (contentDisposition != null) + { + content.Headers.TryAddWithoutValidation("Content-Disposition", contentDisposition); + } + return new HttpResponseMessage(statusCode) { Content = content }; + } + + private static async Task CollectAsync(IAsyncEnumerable chunks) + { + using var stream = new MemoryStream(); + await foreach (var chunk in chunks) + { + await stream.WriteAsync(chunk); + } + return stream.ToArray(); + } + private sealed class CaptureJsonHandler(string payload) : HttpMessageHandler { public Uri? LastRequestUri { get; private set; } @@ -85,4 +229,34 @@ protected override Task SendAsync(HttpRequestMessage reques return Task.FromResult(response); } } + + private sealed class DownloadHandler : HttpMessageHandler + { + private readonly Queue _responses = new(); + + public string? LastRange { get; private set; } + + public void Enqueue(HttpResponseMessage response) => _responses.Enqueue(response); + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + LastRange = request.Headers.TryGetValues("Range", out var values) + ? values.SingleOrDefault() + : null; + return Task.FromResult(_responses.Dequeue()); + } + } + + private sealed class TrackingContent(string body) : ByteArrayContent(Encoding.UTF8.GetBytes(body)) + { + public bool Disposed { get; private set; } + + protected override void Dispose(bool disposing) + { + Disposed = true; + base.Dispose(disposing); + } + } } diff --git a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs index 79a7d8b69..587bdede7 100644 --- a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs +++ b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs @@ -491,9 +491,15 @@ public Task ReadFileAsync(string path, ReadFileOptions? options = null, public Task ReadBytesAsync(string path, ReadBytesOptions? options = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task> ReadBytesDetailedAsync(string path, ReadBytesOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + public IAsyncEnumerable ReadBytesStreamAsync(string path, ReadBytesOptions? options = null, CancellationToken cancellationToken = default) => AsyncEnumerable.Empty(); + public Task> ReadBytesStreamDetailedAsync(string path, ReadBytesOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + public Task DeleteFilesAsync(IEnumerable paths, CancellationToken cancellationToken = default) => throw new NotImplementedException();