Proposal: let a handler supply its own response body stream - see #509 - #554
Proposal: let a handler supply its own response body stream - see #509#554landrix wants to merge 1 commit into
Conversation
|
| function OutContentStreamToBuffer: boolean; | ||
| /// release any pending SetOutStream() body without reading it | ||
| // - if we do own it, e.g. once the response has been sent otherwise | ||
| procedure OutContentStreamDiscard; |
|
|
||
| destructor THttpServerRequestAbstract.Destroy; | ||
| begin | ||
| OutContentStreamDiscard; // e.g. on a connection aborted before SetupResponse |
There was a problem hiding this comment.
perhaps just these lines are enough:
if ifOutContentStreamOwned in fInternalFlags then
FreeAndNilSafe(fOutContentStream);
| if fOutContentStream = nil then | ||
| exit; | ||
| // this server can not stream a response body: read it as a regular content | ||
| tmp := TRawByteStringStream.Create; |
There was a problem hiding this comment.
I guess a FastNewString + ReadAll() could make the trick since we have fOutContentStreamLength
| procedure THttpServerRequest.ProcessOutStream(var Context: THttpRequestContext); | ||
| begin | ||
| // hand a SetOutStream() body to the context, as ProcessStaticFile() does | ||
| if fRespStatus <> HTTP_SUCCESS then |
There was a problem hiding this comment.
only accept 200 is perhaps too restrictive
|
|
||
| // a readable stream which raises on Size/Seek, and does NOT descend from | ||
| // TStreamWithNoSeek: SetOutStream() should detect it the hard way | ||
| TRaiseSeekStream = class(TStream) |
There was a problem hiding this comment.
you may have inherited from TRawByteStringStream and override only Seek/Write
| TSynLog.Family.ExceptionIgnore.Remove(EHttpServer); | ||
| end; | ||
| end; | ||
|
|
55bedb9 to
283a305
Compare
|
I am very pleased with the current state. Should we merge? |
|
I'll let you know; I'm running a few more tests. |
…synopse#509 - let a handler supply its own TStream, sent by chunks from the existing send loop, e.g. for content rebuilt from database blobs: no need to materialize the whole body as a RawByteString in OutContent - new THttpRequestContext.ContentFromStream() does the actual work, next to ContentFromFile(), including 'Range:' support (206/416) and the rfContentStreamNeedFree ownership flag - both methods take a THttpOutStreamOptions set: hosOwned to let the server release the stream, hosNoRange to serve it sequentially - a TStreamWithNoSeek, or a stream raising on Position/Size, is detected as such by itself and gets an 'Accept-Ranges: none' header - a body of unknown length is out of scope, since the send loop always emits a Content-Length: header - THttpApiServer and REST-over-WebSockets can not stream an in-process TStream, so they do read it into OutContent via OutContentStreamToBuffer, up to HttpContentFromFileSizeInMemory as any regular response would - note: THttpServerRequest.Destroy now calls inherited Destroy
283a305 to
ca0f2ee
Compare
|
Both hunks I flagged as unverified are now exercised, on native x64 hardware http.sys and WebSockets, tested directly rather than through the suite, since So the 1MB stream is buffered and arrives byte identical, and the 3MB one answers Delphi, executed for the first time rather than only compiled: That run caught a real defect, which is why the branch moved. My Fixed by making the helper raise on One aside, unrelated to this PR: |
|
So, from my perspective, everything is ready for now. |
This is a proposal, not a finished feature waiting to be merged. #509 has had no
answer yet, and the API shape is your call - I wrote this so there is something
concrete to react to, and I am happy to throw any part of it away.
The send loop is already stream based (
ContentStream,ProcessBody,ValidateRange,rfContentStreamNeedFree), so this adds an entry point ratherthan new plumbing.
Note that this builds on the
Range:overflow fix you merged as b61d9db: thefeature relies on its
ProcessBody()abort, since a handler-supplied stream canend short far more easily than a file can.
API
Returns
HTTP_SUCCESS, like the otherSetOut*methods, so a handler can writeresult := Ctxt.SetOutStream(...).aContentLength = -1computesaStream.Size - aStream.Position, like a filewould use its size; that position is also the origin of any
Range:hosOwnedhands the stream to the server, which frees it once the response issent or the request aborted
hosNoRangedeclares up front that this stream serves noRange:, for a sourcewhich can only be read forward; it is also set automatically when the stream
turns out not to be seekable
send loop always emits
Content-Length:and has no chunked response encodingHow it is wired
The actual work is a new
THttpRequestContext.ContentFromStream(), sitting rightnext to
ContentFromFile()and doing the same things: setContentLength,validate the range, seek, assign
ContentStream, setrfContentStreamNeedFree,return
HTTP_SUCCESSorHTTP_RANGENOTSATISFIABLE.THttpServerRequest. ProcessOutStreamis then a ~20 line wrapper called fromSetupResponse, mirroringProcessStaticFile. Both socket server families share that path, so both stream it.Content-Range:, 416 when unsatisfiableTStreamWithNoSeeksuch asTPipeStream, orone whose
Position/Sizeraise - is served sequentially withAccept-Ranges: none, and aRange:request on it is ignored, so it answers200 with the whole body rather than a 206 without a
Content-Range:TStream-THttpApiServer, wherehttp.sys sends from the kernel, and REST-over-WebSockets answers - call
OutContentStreamToBuffer, which reads it intoOutContent. A handler staysportable and only loses the memory benefit there, mirroring how
OnBodyDownloadfalls back when it returns nil. Rejecting outright would have been the other
option; I picked the one that does not break a handler moved between server
classes. For http.sys this happens inside
SendResponse, so that anOnBeforeRequestanswering early cannot bypass it.Ownership and error paths
The stream is owned by the request until
SetupResponsehands it to the context,and by the context afterwards. It is released on every path I could find:
ProcessDoneafter sending,Recycleon a reusedTHttpAsyncServerconnection,the new
THttpServerRequestAbstract.Destroyif the connection dies first, asecond
SetOutStreamcall, and the 416 rejection.THttpAsyncServer.AsyncResponsediscards a pending stream before assigning itsdelayed content, otherwise the stale stream would win in
SetupResponse.Two cases are worth calling out, because I got both wrong at first:
SetOutStreamhad its body sent as the 500response, since
CompressContentAndFinalizeHeadusesContentStreamand neverlooks at the error page in
OutContent. An error response now always discards apending stream.
range-processed - a
Range:request could truncate a 404 body, or replace itwith the generated 416 page. Range handling is now gated on
StatusCodeIsSuccess.Note:
THttpServerRequest.Destroypreviously did not callinherited(it wasvoid); it does now. An external descendant overriding
Destroywithout callinginheritedwould leak - none exists in this repository.Tests
TNetworkProtocols.DoHttpOutStreamruns the same steps against bothTHttpServerandTHttpAsyncServer: a 3MB body from an owned stream, a rangerequest with its
Content-Range:header, an unsatisfiable range, an explicitshorter length, a replaced stream, a caller-owned stream which must survive, a
handler which raises after
SetOutStream, a non-200 response with a range, astream supplied already positioned (with and without a range), a partially
consumed
TStreamWithNoSeek, a stream whosePositionraises without it being aTStreamWithNoSeek, and one which does answerPosition/Sizebut raises on theactual
Seek().Those last two are deliberately separate, because they exercise different guards:
the first is caught in
SetOutStreamand downgrades the stream toAccept-Ranges: none, the second is caught inContentFromStreamand answers 416.Keeping them apart also keeps the test compiler independent -
GetPositionisvirtual under FPC only, so a helper stream which raises on
Seek(0, soCurrent)alone would take one path under FPC and the other under Delphi.
Ownership is counted, not assumed: the test streams increment a counter in their
destructor, so a leak or a double free fails an assertion. Every claim was
verified by reverting the corresponding code and confirming the matching
assertions fail - on both server families separately.
What was measured, and what was not
I develop on ARM64, where x64 only runs under emulation, so everything needing a
native x64 result was run on separate hardware (Windows 11, i9-14900K, FPC 3.2.2
x86_64-win64, Delphi 13
dcc64anddcc32).TNetworkProtocolsgreen and stableover repeated runs.
HTTPand_SocketIOgreenover 30 runs each. Win32 matters here because the whole path is
Int64sized.THttpApiServer(http.sys), exercised directly rather than through the suite:a 1MB stream arrives byte identical, and a 3MB one - above
HttpContentFromFileSizeInMemory- answers 500 with an empty body instead of atruncated one.
TWebSocketServerRest, sameresult. That is the path
OutputToFramegained, and it had never been executedbefore.
Recyclecleanup of a pendingstream, and a connection dying between the handler and
SetupResponse. I tracedthem by reading the code and believe they are sound, but no assertion proves it.
That x64 round also caught a real defect, fixed in the current commit: a test
helper stream raised on every
Seek(), including the harmlessSeek(0, soCurrent)that Delphi's
TStream.Positionuses, so/failseektook a different branch percompiler - 30 out of 30 Delphi runs red, green under FPC throughout. The shipped
code was correct on both; the test expectation was not.
Open questions, where I would follow your preference
Non-seekable detection.Settled by your review: automatic detectionstays, and
hosNoRangeis there for a handler which already knows.The other backends.Settled: they buffer intoOutContent.SetupResponsepicks 206 fromrfWantRangealone,before knowing whether a partial response actually happens. That never shows
with files, since a file always validates the range or fails with 416; it
surfaced here for the non-seekable and non-200 cases, which I handled by
clearing
rfWantRange. If you would rather have the status followrfRange,that is a one line change in
SetupResponseand both workarounds can go.Content-Range: bytes */size. Unchanged fromContentFromFile,so this proposal does not alter it - mentioning it only because RFC 9110
recommends the header.
My use case, for context: file contents stored as 4MB chunks across sharded
SQLite databases, so there is no file to point
SetOutFileat, and a temporaryfile per download would be pointless I/O.