Provider reconciliation: FPC-trunk compat, IOCP/Epoll/HTTP.sys fixes, streaming robustness#534
Open
freitasjca wants to merge 10 commits into
Open
Provider reconciliation: FPC-trunk compat, IOCP/Epoll/HTTP.sys fixes, streaming robustness#534freitasjca wants to merge 10 commits into
freitasjca wants to merge 10 commits into
Conversation
…uter, arena allocator, global error handler, route-level middlewares)
…CP/Epoll/HttpSys/FPC + CrossSocket adapters)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Provider reconciliation: FPC-trunk compat, IOCP/Epoll/HTTP.sys bug fixes, and streaming robustness
From:
freitasjca/horse:master→ Into:HashLoad/horse:masterSummary
While re-syncing a downstream, CrossSocket-compatible fork onto the recent
streaming + WebSocket merge (
Res.SendStream/IHorseStreamWriter), we ran thefull provider test matrix against every shipped transport and root-caused a set
of real, reproducible bugs in the providers themselves. This PR brings those fixes
back upstream so the main repo benefits directly.
Every change here is additive or a localized bug fix — no public API is renamed,
removed, or re-signed. Without any of the relevant compiler defines, Horse compiles
and behaves exactly as before. The fixes span four areas:
correct string-body length + streaming writer selection.
Content-Type, RemoteAddr, duplicate
Set-Cookie, concurrent-stream integrity.Listencontract, duplicateSet-Cookie, streamedContent-Type.
Set-Cookie,streaming completion (keep-alive after a stream).
All fixes were validated on two independent HTTP clients (see Validation
methodology below) so that server behaviour is confirmed, not a client artifact.
Validation methodology — why two test clients
The evidence below comes from
samples/tests/, exercised by two deliberatelydifferent HTTP clients hitting the same server on port 9010. Using two independent
stacks is the core of the methodology:
1.
HorseNetHttpTestClient.dpr—System.Net.HttpClient(WinHTTP)client-side bug cannot silently distort server results.
Content-Length; de-chunksresponses transparently; auto-adds
Content-Length: 0on empty PUT/POST.stack on both ends).
2.
HorseIndyTestClient.dpr—TCrossHttpClient(pooling, keep-alive)TCrossHttpClient.Create(2 {IoThreads})— a connection pool of only2 keep-alive sockets that are aggressively reused and multiplexed.
Transfer-Encoding: chunked(noContent-Length).Why both are necessary
A single client hides transport bugs. The two stacks stress different server code
paths, and several bugs in this PR are visible to only one of them:
Content-Length(masks it)Passing both clients demonstrates the fixes are genuine server-side behaviour.
(This is not hypothetical: a prior
TCrossHttpClient-only hang on a200+Content-Length: 0response — a client-library bug WinHTTP handled correctly — isexactly why a WinHTTP reference client exists.)
Test evidence (
samples/tests/, both clients)HorseNetHttpTestClient(WinHTTP)HorseIndyTestClient(TCrossHttpClient)(Counts differ per provider because some tests are transport-specific — e.g. the
FPC/Linux builds decline real streaming with a clean
501, which the suite accepts;Windows engine transports stream for real.)
The suite (per provider) covers: all HTTP methods, path/query params, cookies
(including duplicate
Set-Cookie), multipart upload, file download, 64 KB / 65 536-bytebodies, request isolation under 4- and 8-way concurrency, explicit 400/500, large
responses, the
RawWebRequest/RawWebResponseadapter surfaces, CORS preflight, andpush streaming (
/stream/pull,/stream/content-type,/stream/empty, and 2×concurrent streams) with a post-stream
/pinghealth probe to catch keep-alivewedges.
Fixes by provider
FPC (Lazarus / fphttpserver)
1.
Horse.Provider.FPC.HTTPApplication.pas— HTTP/1.1 keep-alive never enabled (threaded mode).TFPCustomHttpServer.KeepConnectionsdefaults toFalse, and in threaded modeTFPHTTPConnectionThread's keep-alive loop is gated onWaitUntil > 0, which is0whenever
KeepConnectionTimeout <= 0(the default) — so evenKeepConnections := Truealone still closes after one response. Every HTTP/1.1 response was closing the TCP
connection; pooling clients saw a reconnect per request. Fix: after
Initialize,set both
KeepConnections := TrueandKeepConnectionTimeout := 15000on theembedded server (reached via same-unit descendant casts, since neither
THTTPApplicationnorTFPHTTPServerHandlerforwards them).2.
Horse.WebModule.pas— write requests 500 on FPC trunk (CSRF/session).FPC trunk's
TCustomFPWebModule.HandleRequestnow runs a CSRF/session check(
IsWriteRequest→TSessionHTTPModule.GetSession) before callingDoOnRequest,and
FSessionRequestis never assigned on this path → every POST/PUT/DELETE/PATCHfails with "Default session not available outside handlerequest". Fix: override
HandleRequestdirectly and dispatch straight into the Horse pipeline, bypassing thesession/CSRF/action machinery Horse doesn't use (WebSocket integration in
HandlerActionis preserved).3.
Horse.Core.Param.Header.pas— FPC-trunk generics guard won't compile.The
CONST_GENERICguard is{$IF DEFINED(CPU64) AND DEFINED(WINDOWS)} const {$ELSE} constref— so on FPC-trunk Linux it selectsconstref, but trunk's rtl-genericsIEqualityComparer<T>now usesconston every platform → "No matchingimplementation for interface method Equals". Fix: add an
{$IF FPC_FULLVERSION >= 30301} constbranch first (trunk =consteverywhere),keeping the CPU64/WINDOWS branch for older FPC.
4.
Horse.Response.pas(FPC string-body path) — trailing newline on every string body.fpWeb's
TResponse.Contentis backed byTStrings; the wire path emitsFContents.Text, which appends aLineEnding—'pong'arrives as'pong'\n, a65 536-byte body as 65 537 bytes, and it cannot be trimmed post-hoc. Fix: on the
FPC fpWeb-native path, send via
ContentStream := TStringStream.Create(LContent)+explicit
ContentLengthinstead ofContent.5.
Horse.Provider.FPC.HTTPApplication.pas—SendStreamhangs (wrong default writer).Horse.Response.pasregisters the Indy WebBroker stream writer as the globaldefault (
FStreamWriterFactory, last-wins); real providers override it, but the FPCHTTPApplication provider registered nothing, inheriting the Indy writer, which does
Indy
WriteClienton a non-Indy connection and blocks. Fix: the FPC providerregisters its own factory (clean
501refusal for the non-engine transport; a realfpWeb chunked writer is a future option). Upstream may also want to guard the
WebBroker default to Indy-only.
IOCP (Windows)
6. 65 536-byte POST closed mid-upload + keep-alive cascade (one coupled defect).
(a) The DoS guard
if (not Processing) and (LNewLen > 16384) then CloseConnectionran on the accumulated body, not just headers (
Processingisn't set until thewhole body arrives), so any multi-read body > 16 KB was closed mid-upload. (b) On an
incomplete body,
ProcessClientReadcalledPostReadandExecutethen calledPostReadagain → two overlappedWSARecvon the same buffer; when the closepath freed the context, the second completion dereferenced freed memory → worker
crash / heap corruption → an all-timeout wedge that persisted across client runs.
Fix: scope the 16 KB cap to the header section (apply in the parse-failed branch)
and bound the body by an explicit
IOCP_MAX_BODY_SIZE(16 MB); makeProcessClientReadthe sole owner of re-arming (removedExecute's duplicatePostRead) — exactly one outstandingWSARecvper connection.7. No
Transfer-Encoding: chunkedrequest-body support → keep-alive desync.TryParseRequestrecognised onlyContent-Length.TCrossHttpClientstreamsmultipart uploads chunked (no CL), so the server parsed length
0, dispatched anempty body, never consumed the chunk bytes, then read them as the next request →
permanent keep-alive desync (every reused-connection request timed out). WinHTTP
buffers multipart and sends
Content-Length, so it never hit this. Fix: detectTransfer-Encoding: chunked; wait for the terminating0-chunk, then de-chunk to acontiguous known-length body before dispatch (mirrors the
Content-Lengthwait).8. Request
Cookieheader — final cookie loses its last character.user=tester; session=abc123→session=abc12. The segment splitter usedCopy(LCookies, LStart, I - LStart)for both the;-delimited case and theend-of-string case, where the length must be
I - LStart + 1to include the lastchar. Fix: branch on the terminator (
;→I - LStart, end →I - LStart + 1).9.
Res.ContentTypedropped (everything defaults totext/plain).The response builder populated headers only from
ARes.CustomHeaders; it never readARes.CSContentType. Fix: injectCSContentTypewhenContent-Typeis absent —applied on both the normal (
SendResponse) and streaming (SendRawHeaders) headerpaths.
10.
RemoteAddralways empty.IOCP resolves the
GetAcceptExSockaddrsextension pointer but never calls it, sothe connection's
ClientIPis never set. Fix: call it in theioAcceptcompletion and set
ClientIPfrom the remote sockaddr.11. Duplicate
Set-Cookiecollapsed +RawWebResponse.SetCustomHeaderdropped.IOCP funnels all headers through a
TDictionary(dedups → duplicateSet-Cookiefolds) and reads only the shadow header store (not the adapter store where
SetCustomHeaderwrites). Fix (additive, regression-safe): inHorse.Response.pasadd a lazily-allocated
RepeatHeaders: TStringListthatAddHeaderalso appends toonly for
Set-Cookie(RFC 6265 §3 — the header that must not fold); the provideremits each
RepeatHeadersentry directly (bypassing the dedup dict) and reads theunion of shadow + adapter stores. The shared
Horse.Response.paschange is purelyadditive — other providers compile and behave identically.
12. Concurrent-stream corruption (2×
/stream/pull).The merged stream writer did a single
send()and ignored the return (headers +each chunk). Under concurrent load a partial send truncated a chunk, corrupting the
response and leaving the just-streamed keep-alive connection unusable on reuse (only
the 2-connection pooling client exposed it). Fix: a
SendFullyloop (send untilall bytes go, stop on error) on the header and body writes.
Epoll (Linux)
13.
InternalListendoesn't block — console server exits at startup.Merged
InternalListenspawns worker threads, setsFRunning := True, firesDoOnListen, and returns — unlike every other provider, which honours theblocking
Listencontract withif IsConsole then while FRunning do Sleep(100). Theconsole server printed its banner and immediately exited. Fix: re-add the blocking
wait loop after
DoOnListen.14. Duplicate
Set-Cookie+ adapterSetCustomHeader+ streamed Content-Type.Same classes as IOCP #11 and #9, applied to the Epoll header-emit path (read the
adapter store as a second emit; emit
RepeatHeadersverbatim; passCSContentTypeinto the streaming
SendHeaders).HTTP.sys (Windows)
15. Response wired to the Indy path but read from the CS shadow.
The response was created via
THorseResponse.Create(LWebResponse)(IndyFWebResponsepath), butSendResponsereads the CS shadow (CSContentType/CustomHeaders) — soRes.ContentType/AddHeaderwrote toFWebResponsewhileSendResponseread the empty shadow (Content-Type →text/html, test 23; OPTIONSstatus, test 26). Fix:
Create(nil) + SetCSRawWebResponseat both dispatch sites(matching IOCP/Epoll) and remove the now double-free
LWebResponse.Free(ownershiptransfers to the response).
16. Query string parsed with the leading
?.GetQueryStringreturned HTTP.sys'sCookedUrl.pQueryStringwith the?, so thefirst key became
?name→Req.Query['name'] = ''. Fix: strip the leading?atthe source.
17. Request header separator mismatch.
GetHeadersListsetsNameValueSeparator := ':'then callsPopulateHeaders, but thecode hard-coded
Name=Value, soReq.Headers['X-…'](parsed with:) never matched→ empty. Fix: honour
ADest.NameValueSeparator.18. Duplicate
Set-Cookie(REPEATHDR treatment).Same class as IOCP #11, applied to the
UnknownHeadersarray build (running index;skip
Set-Cookiefrom shadow + adapter; emit eachRepeatHeadersentry as its ownUnknownHeader).19. Streaming double-send →
HttpSendHttpResponseerror 22.After
SendStreamresponds, the dispatch calledSendResponseunconditionally →a second
HttpSendHttpResponseon the sameRequestId→ERROR_INVALID_PARAMETER.Fix: a
FResponseSentflag (set by the stream writer, checked bySendResponse).20. Streamed response never closes → keep-alive wedge + concurrent-stream failure.
The base
Closesends the0-terminator viaWriteRawBytes, which always setsMORE_DATA, so HTTP.sys never completes the response — WinHTTP tolerates it (completesoff the chunked terminator, doesn't reuse), but keep-alive clients wedge on the next
request. Fix: override the writer's
Closeto send the terminator as the finalentity body without
MORE_DATA, completing the response so keep-alive returns clean(this also fixed the 2× concurrent-stream test).
Compatibility
Horse.Response.pasadditions (
RepeatHeaders, FPC stream-body) are additive; providers not using themcompile and behave identically.
trunk 3.3.1 — please build with that version to verify.
.dproj/.dpk/.lpichanges required by these edits.Notes for reviewers
(FPC / IOCP / Epoll / HTTP.sys) or even per-bug if that eases review.
Horse.Response.pasRepeatHeaderschange is the only shared-code touch — it isadditive and guarded, but it is worth reviewing across all providers at once (it is
consumed by IOCP/Epoll/HTTP.sys and ignored by the rest).
samples/tests/) that produced the evidence above can be includedor kept downstream, as preferred — they depend only on the RTL (
HorseNetHttpTestClient)and on Delphi-Cross-Socket (
HorseIndyTestClient).and offered separately, to keep this change set fully validated.