feat: add streamed response format 0.12.0 - #250
Conversation
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.
|
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.
e0fc7e2 to
fe24b50
Compare
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.
|
@greptile re-review |
Greptile SummaryAdds response format 0.12.0 for incrementally framed multipart execution responses.
Confidence Score: 5/5The 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
Reviews (8): Last reviewed commit: "test: inline the regex capture helper" | Re-trigger Greptile |
| \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); |
There was a problem hiding this 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
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.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.
|
@greptile re-review |
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.
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
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.
statusCodeandheaderscome 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,durationandstartTimeare 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:
0.12.0gets the buffered document, unchanged, with no format echoThe 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.
json_encodereturnfalse. That now throws before anything is written, while the response is still recoverable, rather than emitting a well-formed but emptyheaderspart 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-streamand forwarded the raw body with no multipart envelope, which meant losinglogs,errorsandduration. 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 behalftests/e2e/StreamedResponseTest.php— three cases: envelope framing and part ordering, a 1MB body framed as several runs rather than one, and a0.11.0caller still receiving the buffered documentnode-large-responsefixture returning a body sized by request header, to produce the multi-run caseUnit, 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,
statusCodeandheadersparse 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.