feat: add WriteFileInWorkspaceStream #2

Merged
thklein merged 5 commits from feat/streaming-workspace-write into main 2026-10-02 14:00:59 +00:00
Owner

Part of basepeak-ai/bpai#1220 — workspace file writes materialise the file many times over; a single large file OOM-killed a tenant.

What

WriteFileInWorkspaceStream(ctx, filePath, r io.Reader, opts...) posts a small JSON header followed directly by r to the server's new workspaces/write-file-stream route (basepeak-ai/gptscript#13). The content is never held in memory as a whole.

Run.request now sends an io.Reader payload as is (application/octet-stream) instead of JSON-marshalling it. Response handling stays the same as runBasicCommand, so conflict errors still parse into ConflictInWorkspaceError.

A server without the route answers 404. That is returned as ErrStreamingWriteUnsupported, so callers can fall back to WriteFileInWorkspace. Because r may have been partly read by then, the caller has to reopen its source.

WriteFileInWorkspace is unchanged.

Measured

Writing a 1 GiB file, peak RSS of the calling process:

call peak
WriteFileInWorkspace 8 937 MiB
WriteFileInWorkspaceStream 10 MiB

Tests

  • TestWriteFileInWorkspaceStream: a ~3.6 MB binary round-trip through a real workspace-provider daemon, with a path containing a space and a subdirectory, plus a revision conflict.
  • TestWriteFileInWorkspaceStreamOldServer: a 404 maps to ErrStreamingWriteUnsupported.

The integration test needs a gptscript binary that has the new route. Until basepeak-ai/gptscript#13 is merged and released, run it with GPTSCRIPT_BIN pointing at a build of that branch.

Part of basepeak-ai/bpai#1220 — workspace file writes materialise the file many times over; a single large file OOM-killed a tenant. ## What `WriteFileInWorkspaceStream(ctx, filePath, r io.Reader, opts...)` posts a small JSON header followed directly by `r` to the server's new `workspaces/write-file-stream` route (basepeak-ai/gptscript#13). The content is never held in memory as a whole. `Run.request` now sends an `io.Reader` payload as is (`application/octet-stream`) instead of JSON-marshalling it. Response handling stays the same as `runBasicCommand`, so conflict errors still parse into `ConflictInWorkspaceError`. A server without the route answers 404. That is returned as `ErrStreamingWriteUnsupported`, so callers can fall back to `WriteFileInWorkspace`. Because `r` may have been partly read by then, the caller has to reopen its source. `WriteFileInWorkspace` is unchanged. ## Measured Writing a 1 GiB file, peak RSS of the calling process: | call | peak | |---|---| | `WriteFileInWorkspace` | 8 937 MiB | | `WriteFileInWorkspaceStream` | 10 MiB | ## Tests - `TestWriteFileInWorkspaceStream`: a ~3.6 MB binary round-trip through a real workspace-provider daemon, with a path containing a space and a subdirectory, plus a revision conflict. - `TestWriteFileInWorkspaceStreamOldServer`: a 404 maps to `ErrStreamingWriteUnsupported`. The integration test needs a gptscript binary that has the new route. Until basepeak-ai/gptscript#13 is merged and released, run it with `GPTSCRIPT_BIN` pointing at a build of that branch.
feat: add WriteFileInWorkspaceStream
Some checks failed
run_tests.yaml / feat: add WriteFileInWorkspaceStream (push) Failing after 0s
pull_request.yaml / feat: add WriteFileInWorkspaceStream (pull_request_target) Failing after 0s
run_tests.yaml / feat: add WriteFileInWorkspaceStream (pull_request) Failing after 0s
ce798f2778
WriteFileInWorkspace takes the whole file as a []byte and sends it as
a base64 string in a JSON body, so a write costs several times the
file size before a byte reaches storage: ~9 GiB in the caller for a
1 GiB file.

WriteFileInWorkspaceStream takes an io.Reader and posts it, after a
small JSON header, as the request body of the server's new
workspaces/write-file-stream route. Peak stays at ~10 MiB for 1 GiB.

A server without that route answers 404, which is returned as
ErrStreamingWriteUnsupported so callers can fall back to
WriteFileInWorkspace.

Refs basepeak-ai/bpai#1220
test: write an empty file on both workspace write paths
Some checks failed
run_tests.yaml / test: write an empty file on both workspace write paths (push) Failing after 0s
pull_request.yaml / test: write an empty file on both workspace write paths (pull_request_target) Failing after 0s
run_tests.yaml / test: write an empty file on both workspace write paths (pull_request) Failing after 0s
829da2322d
The probe held a mutex across its HTTP call, so while the first
caller's probe waited on a slow server, every other write on the
same client waited too, regardless of its own context. Support is now
an atomic state behind a pointer (so GPTScript stays copyable); early
callers each ask, and each probe has its own 10s timeout.

Also:
- a write of known length is sent with a Content-Length, as the
  buffered route was, instead of chunked
- the doc no longer promises r is unread on every
  ErrStreamingWriteUnsupported: only a server swapped for an older
  one between probe and write can return it after reading
- tests for a server downgraded between probe and write, for a caller
  not being held up by another's slow probe, for chunked framing of
  an unknown length, and for a conflict found while an 8 MiB body is
  still being sent

Refs basepeak-ai/bpai#1220
fix: let a streamed write's context interrupt a stalled source
Some checks failed
run_tests.yaml / fix: let a streamed write's context interrupt a stalled source (push) Failing after 0s
pull_request.yaml / fix: let a streamed write's context interrupt a stalled source (pull_request_target) Failing after 0s
run_tests.yaml / fix: let a streamed write's context interrupt a stalled source (pull_request) Failing after 0s
run_tests.yaml / feat: stream workspace file writes (#2) (pull_request) Failing after 0s
bc059f9d95
Handed the caller's reader directly, the HTTP client cannot interrupt
a Read that blocks: on cancellation the transport waits for its writer,
which is stuck in that Read. A write from a stalled network share then
ignored its deadline for as long as the share stalled. A stream of
unknown length now goes through a pipe that fails as soon as the
context is done.

Also warns once when the server cannot be asked about streaming, so
writes quietly taking the buffered route can be spotted, and drops a
404 check on an error path that comes before any response.

Refs basepeak-ai/bpai#1220
Author
Owner

Review (fresh-context reviewers) — outcome

Fixed

  • The probe held a mutex across its HTTP call, so a slow first probe stalled every other write on the client, whatever their contexts. It now uses atomic state behind a pointer, takes no lock, and has its own 10 s timeout. Test: a caller with a 200 ms deadline is not held up by another caller's 3 s probe.
  • A streamed write ignored its context while the source stalled (verified by the reviewer). The transport can't interrupt a blocked Read. A stream of unknown length now goes through a pipe that fails when the context is done. Test: a stalled source with a 300 ms deadline returns at 0.3 s. Without the fix, the same test hangs until the test runner kills it.
  • Writes of known length were sent chunked, and they now carry a Content-Length again, as the buffered route did.
  • The doc overpromised that r is unread on ErrStreamingWriteUnsupported: reworded. Also removed a dead 404 check on a pre-response error path.
  • A failed probe silently left every write buffered: it now warns once per client.
  • New tests: a server downgraded between probe and write (falls back and remembers), chunked framing for unknown length, and a conflict while an 8 MiB body is still being sent (integration).

Dismissed

  • Cache "unsupported" when a proxy returns an unclear status (401/502): bpai's sdkserver is embedded and reached directly, so this can't happen there. The buffered call still falls back correctly, and the new warning makes the case visible.

Re-validated on the final commit

  • Cross-version matrix against real binaries: new × new passes; old client × new server passes; new client × old server passes everything except the stream-call tests, which need the route.
  • 1 GiB WriteFileInWorkspace([]byte): ~34 MiB peak in the server, and nothing in the client beyond the caller's own buffer.

CI note: this repo's workflows fail after 0 s on main too, so these local runs are the evidence.

## Review (fresh-context reviewers) — outcome **Fixed** - **The probe held a mutex across its HTTP call**, so a slow first probe stalled every other write on the client, whatever their contexts. It now uses atomic state behind a pointer, takes no lock, and has its own 10 s timeout. Test: a caller with a 200 ms deadline is not held up by another caller's 3 s probe. - **A streamed write ignored its context while the source stalled** (verified by the reviewer). The transport can't interrupt a blocked `Read`. A stream of unknown length now goes through a pipe that fails when the context is done. Test: a stalled source with a 300 ms deadline returns at 0.3 s. Without the fix, the same test hangs until the test runner kills it. - **Writes of known length were sent chunked**, and they now carry a `Content-Length` again, as the buffered route did. - **The doc overpromised that `r` is unread on `ErrStreamingWriteUnsupported`:** reworded. Also removed a dead 404 check on a pre-response error path. - **A failed probe silently left every write buffered:** it now warns once per client. - New tests: a server downgraded between probe and write (falls back and remembers), chunked framing for unknown length, and a conflict while an 8 MiB body is still being sent (integration). **Dismissed** - *Cache "unsupported" when a proxy returns an unclear status (401/502):* bpai's sdkserver is embedded and reached directly, so this can't happen there. The buffered call still falls back correctly, and the new warning makes the case visible. **Re-validated on the final commit** - Cross-version matrix against real binaries: new × new passes; old client × new server passes; new client × old server passes everything except the stream-call tests, which need the route. - 1 GiB `WriteFileInWorkspace([]byte)`: ~34 MiB peak in the server, and nothing in the client beyond the caller's own buffer. CI note: this repo's workflows fail after 0 s on `main` too, so these local runs are the evidence.
thklein deleted branch feat/streaming-workspace-write 2026-10-02 14:01:00 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
basepeak-ai/go-gptscript!2
No description provided.