Skip to content

feat(sandbox): stream file downloads end to end - #409

Open
aniruddhaadak80 wants to merge 1 commit into
truefoundry:mainfrom
aniruddhaadak80:feat/stream-sandbox-file-downloads
Open

feat(sandbox): stream file downloads end to end#409
aniruddhaadak80 wants to merge 1 commit into
truefoundry:mainfrom
aniruddhaadak80:feat/stream-sandbox-file-downloads

Conversation

@aniruddhaadak80

@aniruddhaadak80 aniruddhaadak80 commented Aug 22, 2026

Copy link
Copy Markdown

Summary

Sandbox file downloads currently buffer the complete file inside every provider and then copy that buffer again into the HTTP response. This PR changes SandboxProvider.downloadFile to return { size, stream } and streams bytes end to end, so peak memory is bounded by chunk/backpressure rather than file size, the client starts receiving bytes before the whole file is read, and disconnects stop provider reads.

Design notes for the questions raised in the issue:

  • Contract shape: a plain object { size: number; stream: ReadableStream<Uint8Array> }. The size comes from the existing pre-download stat (which also keeps the is-dir and max-size checks ahead of any bytes), so the route still sends an exact Content-Length; errors raised before the first byte stay mappable to HTTP statuses, while mid-stream failures surface as an errored stream detectable against Content-Length.
  • Cancellation: the route wires req.raw.signal into the provider call, consumer cancellation propagates through the stream's cancel(), and both stop provider reads (process-group kill locally, stream destroy on Daytona, no further execs on TFY).
  • TFY transport: the sandbox server has no file endpoint today, so instead of base64-buffering the whole file in one exec, bytes are pulled as sequential fixed-window execs (tail -c +N | head -c M | base64 -w0). Memory stays bounded per 2 MiB chunk with no server-side changes; a temporary fully-buffered fallback was deliberately avoided.

Closes #283

Changes

  • trueforge-core: SandboxProvider.downloadFile now returns SandboxFileDownload (size + single-consumption web stream) and accepts an optional abort signal.
  • trueforge-core: new shared boundedFileStream helper wraps incremental chunks in one canonical implementation that enforces the download cap while streaming (guards against a file growing after stat) and returns generator cleanup on cancel/error.
  • Local provider: streams raw stdout Buffers via a new incremental SRT session runner (startSupervisorStdoutStream, binary-safe — the old path utf8-decoded buffered output, which forced base64). Spawn/wrap failures still throw before any byte moves so the route can answer with a proper status; /bin/cat runs under the same SRT wrap policy as exec/upload.
  • Daytona provider: uses sandbox.fs.downloadFileStream(...) (already exposed by @daytona/sdk) adapted into the contract stream, keeping stat-first checks and the 404 mapping.
  • TFY provider: windowed exec pulls as above, with per-window length verification (detects files mutated mid-download), first-window failure mapped to SandboxFileNotFoundError, and caller-abort support combined with the exec timeout via AbortSignal.any.
  • Turns download route: sends the stream as the body with unchanged headers (Content-Type, exact Content-Length from the stat size, Content-Disposition, Cache-Control) and unchanged error mapping; removed the now-dead buffering helper.
  • Added .changeset/stream-sandbox-file-downloads.md (published packages changed). No generated OpenAPI/SDK edits — wire shape is unchanged.

How was this tested?

  • New unit tests:
    • boundedFileStream.test.ts: ordered forwarding, cap enforcement (offending chunk withheld), source-error propagation, cleanup on consumer cancel.
    • daytonaDownload.test.ts: streamed round-trip + stat size, dir/oversize rejected before opening the stream, missing-file mapping, streaming-cap re-enforcement when a file grows after stat.
    • tfyDownload.test.ts: multi-window reassembly across the 2 MiB boundary (command shape asserted), empty file, pre-byte rejections, short-read failure, first-window failure mapping.
  • Provider contract suite now verifies incremental round-trip of a 1 MiB payload (multi-chunk), plus missing-file and directory error contracts for every backend.
  • Route tests (sandboxFileDownload.test.ts): progressive delivery through the Hono response (first chunk observed while the producer is parked), exact headers, signal passed down, and SandboxError → status mapping.
  • Updated the local sandbox smoke test to consume the new stream API.
  • Locally: pnpm --filter @truefoundry/trueforge-core typecheck/test, pnpm --filter @truefoundry/trueforge typecheck/test, targeted jest suites, eslint and prettier on all touched files pass. (Windows dev box: repo-wide pnpm build/format:check are blocked by Unix-only scripts/CRLF checkout and two local-sandbox suites fail on POSIX-mode assertions — all verified identical on a clean main stash, unrelated to this change.)

Checklist

  • I have read the contributing guidelines
  • pnpm build, pnpm test, pnpm typecheck, pnpm lint:ci, and pnpm format:check pass locally
  • Tests added/updated where it makes sense
  • No hand-edits to generated code (packages/trueforge-sdk, .github/fern/openapi/openapi.json, docs/openapi.json) — fork PRs omit SDK regen; maintainers regenerate after merge
  • Docs / .env.example updated if configuration or behavior changed

Note

Medium Risk
Changes the sandbox download contract and HTTP body path (streaming, abort, size-cap re-check) across all providers. Not auth, but a user-facing data transfer path where mid-stream failures become truncated bodies.

Overview
Sandbox file downloads no longer buffer the whole file. SandboxProvider.downloadFile now returns { size, stream } plus an optional abort signal, so peak memory tracks chunk size, Content-Length still comes from the pre-stat, and client disconnects stop provider reads.

Shared boundedFileStream wraps incremental chunks, re-enforces the download cap while streaming (withholds past-cap bytes), and runs generator cleanup on cancel. Daytona uses downloadFileStream; TFY pulls 2 MiB windows via sequential execs instead of one base64 dump; local SRT cats raw stdout through a new startSupervisorStdoutStream.

The turns download route pipes the stream as the body (same headers) and maps pre-byte SandboxErrors to JSON statuses as before. Mid-stream failures surface as a truncated body vs Content-Length.

Reviewed by Cursor Bugbot for commit 78d534b. Bugbot is set up for automated code reviews on this repo. Configure here.

@changeset-bot

changeset-bot Bot commented Aug 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 78d534b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@truefoundry/trueforge-core Patch
@truefoundry/trueforge Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 5 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 78d534b. Configure here.

const w = wake;
wake = undefined;
w?.();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stdout errors can crash process

High Severity

startSupervisorStdoutStream attaches a data listener to child.stdout but never an error listener, unlike runSupervisorSession which calls ignoreStreamError on both stdout and stderr. An stdout pipe error during abort, timeout, or kill can become an unhandled error event and take down the Node process.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 78d534b. Configure here.

supervised.abort();
},
{ once: true },
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-aborted signal ignored

Medium Severity

Local and Daytona only addEventListener('abort', …) after the transfer is already open. If signal is already aborted (client gone during auth, provider resolve, or stat), the listener never runs, so /bin/cat or the Daytona stream keeps reading until timeout or completion. TFY avoids this via AbortSignal.any.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 78d534b. Configure here.

const w = wake;
wake = undefined;
w?.();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No stdout backpressure

Medium Severity

startSupervisorStdoutStream puts stdout in flowing mode and pushes every chunk into an unbounded pending array with no pause/resume. A slow or stalled HTTP consumer lets /bin/cat buffer the whole file in memory, so local downloads are not bounded by chunk size as the PR claims.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 78d534b. Configure here.

// Early consumer return lands here too; a completed process is an ESRCH no-op.
killExecTree(child);
SandboxManager.cleanupAfterCommand();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup skipped if unread

Medium Severity

SandboxManager.cleanupAfterCommand() runs only in the chunks() generator finally, not on the child close handler. If the returned stream is never pulled or cancelled, the process may be killed by the timeout while SRT wrap cleanup never runs, unlike runSupervisorSession which always cleans up on close.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 78d534b. Configure here.

throw new SandboxFileNotFoundError(params.path);
}
throw error;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream can exceed Content-Length

Medium Severity

Local and Daytona stream whatever the backend returns afterstat. If the file grows but stays under fileMaxBytesForDownload, the body can exceed the declared size used as Content-Length. TFY avoids this by reading exact windows up to info.size; the route still advertises that size for all providers.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 78d534b. Configure here.

@aniruddhaadak80

Copy link
Copy Markdown
Author

Heads up for reviewers: the CI / Generate SDK workflow runs are sitting in action_required because this is my first contribution here — they need a one-click 'Approve and run workflows' to start. Everything those jobs run was verified locally (typecheck, unit suites incl. the new ones, eslint/prettier on touched files); details in the PR description.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stream sandbox file downloads end to end

2 participants