Skip to content

feat: add streamed response format 0.12.0 - #250

Open
HarshMN2345 wants to merge 13 commits into
mainfrom
feat/streamed-response-format
Open

feat: add streamed response format 0.12.0#250
HarshMN2345 wants to merge 13 commits into
mainfrom
feat/streamed-response-format

Conversation

@HarshMN2345

@HarshMN2345 HarshMN2345 commented Aug 23, 2026

Copy link
Copy Markdown
Member

Summary

Adds response format 0.12.0, whose multipart parts are length prefixed so a caller can read them as they are framed rather than waiting for the whole document to be serialised.

This is the executor half of appwrite/appwrite#13314. That PR already implements the reader, but no executor emits this format, so it currently falls back to the buffered path on every request. Neither side does anything on its own.

The format

--BOUNDARY\r\n
Content-Disposition: form-data; name="body"\r\n
Content-Transfer-Encoding: chunked\r\n
\r\n
<hex length>\r\n<content>\r\n      (repeated per run of content)
0\r\n\r\n                          (terminates the part)
--BOUNDARY--                       (after the last part)

The hex prefix is the same shape HTTP uses for chunked transfer encoding. It is what makes the incremental read tractable: content is never scanned for the boundary, so content carrying the boundary string cannot split the envelope and the reader needs no lookahead between socket reads.

statusCode and headers come from the runtime's response headers, which are complete before its first body byte, so they are emitted first and the body streams after them. logs, errors, duration and startTime are only known once the body is done, so they trail it.

Compatibility

Streaming commits on the first body byte. Until then nothing has been written, so:

  • a caller below 0.12.0 gets the buffered document, unchanged, with no format echo
  • a caller asking for JSON gets the JSON shape, unchanged
  • a response with no body at all never commits and falls back to the buffered document

The retry loop is guarded: once content is on the wire a second attempt would append a second response to it. The retryable errors all occur before a connection is established, so this is a guard rather than an expected path.

Failure handling

Once the first body byte is out the response is committed: the status line is already on the wire and cannot be revised.

  • Mid-stream failure — the connection is closed rather than letting the error hook append its JSON document to a committed envelope. A caller reads a missing closing delimiter as a failed execution, which is the contract fix(sites): stream chunked responses appwrite/appwrite#13314 relies on.
  • A part that cannot be encoded — a header value that is not valid UTF-8 makes json_encode return false. That now throws before anything is written, while the response is still recoverable, rather than emitting a well-formed but empty headers part that would leave the caller serving a page with none of its headers.

Prior art

Supersedes #223 and appwrite/appwrite#11429 (both closed unmerged, "Towards SER-334"). Those detected text/event-stream and forwarded the raw body with no multipart envelope, which meant losing logs, errors and duration. Keeping the envelope preserves execution metadata.

Tests

  • tests/unit/Executor/BodyMultipartStreamTest.php — 14 tests covering framing, hex prefixing, empty runs (a zero length run would read as the part terminator), boundary-in-content, binary content, and that an abandoned envelope is never closed on the execution's behalf
  • tests/e2e/StreamedResponseTest.php — three cases: envelope framing and part ordering, a 1MB body framed as several runs rather than one, and a 0.11.0 caller still receiving the buffered document
  • Adds a node-large-response fixture returning a body sized by request header, to produce the multi-run case

Unit, format, analyze, refactor and e2e all pass in CI.

Verified separately against a real socket with real curl, wiring this writer directly to the reader in appwrite/appwrite#13314 — both production classes, no mocks. Against a server dribbling a run every 250ms, statusCode and headers parse at +1ms and body runs arrive at +255/509/764/1018ms, with the trailing parts after. The body is genuinely forwarded as produced rather than reassembled, and the two implementations agree on the wire.

Note on scope

Sites (SSR) are framework servers that produce output progressively, so they benefit. Functions are buffered inside every runtime, so this does not make them stream.

The multipart response is serialised in full before any of it reaches the
caller, so a site that renders progressively still arrives as one block once
the runtime has finished. This adds a response format whose parts are length
prefixed, letting a caller read them as they are framed.

Content is prefixed with a hex length in the same shape HTTP uses for chunked
transfer encoding, and each part is marked Content-Transfer-Encoding: chunked.
The prefix is what makes the incremental read tractable on the other side:
content is never scanned for the boundary, so a body carrying the boundary
string cannot split the envelope and the reader needs no lookahead.

statusCode and headers are known from the runtime's response headers before its
first body byte, so they are emitted first and the body streams after them.
logs, errors and duration are only known once the body is done, so they trail
it. Streaming commits on the first body byte: a response with no body never
commits and falls back to the buffered document, as does any caller below
0.12.0 or asking for JSON.
Asserts against the raw wire rather than a decoded body, because the framing is
the thing being tested: that parts arrive length prefixed and chunk marked,
that statusCode and headers land before the body, and that a body too large for
one socket read is framed as several runs instead of one document.

A single run would mean the executor buffered after all, so that assertion is
what separates this from the existing execution tests. Adds a node resource
that returns a body sized by request header to produce that case, and checks a
0.11.0 caller still receives the buffered document with no format echo.
@HarshMN2345

Copy link
Copy Markdown
Member Author

Paired with appwrite/appwrite#13314, which implements the reader. Neither side does anything alone — that PR falls back to the buffered path until an executor emits this format, so this one should land first.

Extract the regex capture into a helper that returns a string, so the offset
access is typed rather than assumed, and take rector's instanceof narrowing and
spacing.
@HarshMN2345
HarshMN2345 force-pushed the feat/streamed-response-format branch from e0fc7e2 to fe24b50 Compare August 23, 2026 17:13
The writer carried a twenty-two line class docblock and method docblocks that
restated their own names, at roughly eight times the comment density of
BodyMultipart beside it. Keep the wire format, the reason content is length
prefixed, and the contracts that are not visible from a signature.
@HarshMN2345

Copy link
Copy Markdown
Member Author

@greptile re-review

@greptile-apps

greptile-apps Bot commented Aug 23, 2026

Copy link
Copy Markdown

Greptile Summary

Adds response format 0.12.0 for incrementally framed multipart execution responses.

  • Introduces a length-prefixed multipart stream writer.
  • Streams v5 runtime response bodies while preserving status, headers, logs, errors, and timing metadata.
  • Prevents retries after response commitment and closes the connection on post-commit execution failures.
  • Retains buffered behavior for JSON, older response formats, and empty bodies.
  • Adds unit and end-to-end coverage for framing, ordering, binary content, large responses, and compatibility.

Confidence Score: 5/5

The PR appears safe to merge.

The previously reported committed-response error path now closes the Swoole connection and returns before the shared JSON error handler can append another response, so no blocking failure remains.

Important Files Changed

Filename Overview
app/controllers.php Negotiates the streamed format, emits the multipart envelope, and safely terminates committed responses when execution fails.
src/Executor/BodyMultipartStream.php Implements byte-length-prefixed multipart framing with explicit part and envelope termination.
src/Executor/Runner/Docker.php Forwards v5 runtime response chunks through a callback and suppresses retries after streaming begins.
src/Executor/Runner/Adapter.php Extends the execution contract with an optional streaming callback.
tests/e2e/StreamedResponseTest.php Verifies streamed framing, part ordering, large-body chunking, and compatibility with older formats.
tests/unit/Executor/BodyMultipartStreamTest.php Covers multipart framing, empty runs, binary lengths, boundaries, termination, and abandoned envelopes.

Reviews (8): Last reviewed commit: "test: inline the regex capture helper" | Re-trigger Greptile

Comment on lines +953 to +972
\curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($curl, $data) use ($onStream, &$responseHeaders, &$streamed): int {
if (!$streamed) {
$streamed = true;

$outputHeaders = [];
foreach ($responseHeaders as $key => $value) {
if (\str_starts_with($key, 'x-open-runtimes-')) {
continue;
}

$outputHeaders[$key] = $value;
}

$onStream('headers', [
'statusCode' => \intval(\curl_getinfo($curl, CURLINFO_HTTP_CODE)),
'headers' => $outputHeaders,
]);
}

$onStream('body', $data);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Transport errors leave streams unterminated

When a v5 runtime sends body bytes and subsequently encounters a timeout, reset, or other curl error, this callback has already committed the multipart response but createExecution throws before the controller writes the trailing metadata and closing boundary. The client therefore receives an unterminated multipart document, followed by an attempt to render the normal JSON error through the already-committed response.

Knowledge Base Used: Runtime execution orchestration

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Executor/Runner/Docker.php
Line: 953-972

Comment:
**Transport errors leave streams unterminated**

When a v5 runtime sends body bytes and subsequently encounters a timeout, reset, or other curl error, this callback has already committed the multipart response but `createExecution` throws before the controller writes the trailing metadata and closing boundary. The client therefore receives an unterminated multipart document, followed by an attempt to render the normal JSON error through the already-committed response.

**Knowledge Base Used:** [Runtime execution orchestration](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/open-runtimes/executor/-/docs/runtime-execution-orchestration.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

A curl failure after the body has begun streaming throws out of
createExecution, and the error hook then appends its JSON document to a
response whose headers and content are already on the wire. The caller reads
those bytes as envelope content, so the error leaks into the visitor-facing
body before the truncated envelope is finally rejected.

Once the first byte is out, close the connection instead: a broken transfer is
the one remaining way to report the failure, and the caller already treats an
envelope without its closing delimiter as a failed execution.
A header value that is not valid UTF-8 makes json_encode return false, which
was written as a well-formed zero-length headers part. The caller then served
the page with none of its headers. Encode before anything is written, so the
failure surfaces while the response is still recoverable.
@HarshMN2345

Copy link
Copy Markdown
Member Author

@greptile re-review

@HarshMN2345
HarshMN2345 marked this pull request as ready for review August 24, 2026 12:53
The runner returned a 'streamed' key that the controller read once and then
unset before serialising, while a local already tracked the same fact from the
one place that can know it: whether any byte reached the write callback. That
local is also the more accurate of the two, since a part that fails to encode
throws before writing anything.

isEnded() had no caller outside the assertion that read it.
Every caller already passes a closure, and mixed only existed because a
property cannot be declared callable.
Removing the empty-run guard from writeContent left the suite green: a
substring check for the orphaned content could not match, because the mutant
frames a spurious terminator ahead of the next length prefix rather than raw
content. Asserting the whole wire catches it, and matches how the neighbouring
tests read.

The two array_search checks restated indices the full order assertion above
them already fixes. The chunk counter measured how curl handed bytes to the
client, which is per socket read whether or not the executor streamed, so it
held for a buffered response too.
An execution that dies mid-part never calls end(), and the caller reads a
missing closing delimiter as a failed execution. Nothing may close the envelope
on its behalf, so hold that: adding a destructor that finalises makes this
fail.
A generic capture-with-assertion wrapper had no counterpart anywhere in the
suite, and two of its three callers were extracting the boundary, which the
code under test does with a plain explode.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant