Additive changes to support CrossSocket high‑performance provider#443
Additive changes to support CrossSocket high‑performance provider#443freitasjca wants to merge 14 commits intoHashLoad:masterfrom
Conversation
- Horse.Provider.Config.pas (new) — shared config record, breaks circular dep - Horse.Provider.Abstract.pas — add ListenWithConfig virtual class method - Horse.Request.pas — add parameterless Create overload and Clear procedure - Horse.Response.pas — add CustomHeaders, ContentStream, Clear - packages/HorseCS.dpk — runtime package for the patched fork - boss.json — Boss manifest pointing at src/ and HorseCS.dpk
|
What a beautiful piece of work... thank you very much for your contribution... |
5f94516 to
f994c46
Compare
|
Hi @viniciussanchez, Thank you for your patience with this PR. I wanted to give you an update on the current state. The changes have been revised and tested across several real-world scenarios. An automated integration test suite has been added to the provider repository covering:
24 tests, all passing on Delphi 12 Athens, Win64 Release. The suite is two standalone console programs:
The PR description has also been updated with full technical rationale for each change, the integration strategy, and a detailed bug fix log. For full technical rationale (strategies, per-file analysis, bug fix root causes): Please let me know if you have any questions or if you would like any adjustments to the scope or approach. Happy to split into smaller PRs or address any style |
Additive changes to support CrossSocket high‑performance provider
For full technical rationale (strategies, per-file analysis, bug fix root causes):
Detailed description →
Context
We have developed a new provider for Horse, horse-provider-crosssocket, that replaces the Indy transport layer with Delphi‑Cross‑Socket. This brings IOCP/epoll async I/O, security hardening (request smuggling protection, enforced size limits, read timeouts, object pooling, CRLF-stripping on response headers) and full Linux 64‑bit support including Docker deployment.
The provider requires four strictly additive patches to Horse itself. No existing method is altered or removed, so all existing Horse projects, providers, and official middlewares continue to compile and run without any changes.
Performance characteristics
Why CrossSocket is architecturally faster than Indy
The Indy provider that Horse uses by default allocates one blocking OS thread per connection. Under concurrent load this creates three well-known bottlenecks:
accept()serialisationaccept()on a single thread, which becomes a bottleneck above ~a few hundred connections/secaccept()across IO threadsTHorseRequest+THorseResponse+ their dictionaries on every requestTHorseContextPool) pre-warms 32 contexts and recycles them — the allocator is not invoked on the hot pathThese are structural differences, not tuning differences. No amount of Indy configuration closes the gap under high concurrency because the thread-per-connection model is the constraint.
Indicative numbers from the community
General async I/O HTTP servers (nginx, Go
net/http, Node.js) consistently outperform thread-per-connection servers (classic Apache prefork, Indy-based servers) by 3× to 10× on throughput and 10× to 50× on peak concurrent connections at equivalent hardware, according to published benchmarks and the C10K problem literature.For Delphi specifically, the Delphi-Cross-Socket library author and community members report:
These figures are consistent with what the epoll/IOCP architecture predicts and with results from equivalent libraries in other languages (libuv, Boost.Asio, netty).
What the CrossSocket provider adds on top
Beyond the transport layer, this provider contributes additional performance work that is independent of CrossSocket itself:
THorseContextPool) — 32 pre-warmedTHorseRequest/THorseResponsepairs recycled viaClearinstead ofFree/Create. Pool capacity scales to 512 under burst load. The allocator is bypassed entirely on the hot path.THorseWorkerPool) — 4 to 64 threads for CPU-bound route handlers, preventing any single slow handler from blocking an IO thread and stalling unrelated connections.TDictionary-backed headers — header lookup is O(1) vs. the O(n) linear scan ofTStringListused in the default Horse path.When CrossSocket is the right choice
How to activate the provider
The CrossSocket provider is selected at compile time via a project‑level conditional define. No code changes are needed in the application itself beyond registering routes and calling
Listen.Step 1 — Set the define
In Project Options → Delphi Compiler → Conditional defines (or the equivalent in Lazarus / FPC project settings), add:
Step 2 — Minimal application code
For advanced configuration (TLS, body size limits, compression):
Architectural incompatibility with host-managed providers
HORSE_CROSSSOCKETcannot coexist withHORSE_ISAPI,HORSE_APACHE,HORSE_CGI, orHORSE_FCGI, and this is not merely a define-ordering problem that could be fixed by reordering the{$ELSEIF}chain. The incompatibility is architectural and fundamental to how each deployment model owns the network socket.The core conflict: who owns the listening socket?
CrossSocket is a self-hosted transport. When
THorse.ListenorTHorse.ListenWithConfigis called, CrossSocket callsbind()+listen()on a raw OS socket and drives all I/O through its own epoll (Linux) or IOCP (Windows) event loop. The process owns the socket for its entire lifetime.ISAPI, Apache modules, CGI, and FastCGI operate under a fundamentally different contract: the host process (IIS, Apache httpd, the CGI caller) owns the socket, accepts the connection, reads the raw HTTP bytes, and hands a pre-parsed
TWebRequestto the Delphi code. The Delphi process never sees a socket file descriptor at all.These two models are mutually exclusive at the OS level:
bind()+listen()main()— long-running processHttpExtensionProc) or short-lived processTWebRequestavailableTCrossHttpServer.Start()Why a compile-time error would be better than silent wrong behaviour
The current
Horse.pasconditional chain checksHORSE_ISAPI,HORSE_APACHE,HORSE_CGI, andHORSE_FCGIbeforeHORSE_CROSSSOCKETin theTHorseProvidertype alias block. If a developer accidentally sets bothHORSE_CROSSSOCKETandHORSE_ISAPI, the ISAPI provider silently wins:THorseinherits fromTHorseProvider.ISAPI, the CrossSocket unit is compiled but itsTHorseProviderCrossSocketclass is never used, andTHorse.Listenhas no effect. The server appears to compile and link successfully but never actually listens on any port.We therefore propose that a future commit adds an explicit compile-time guard to catch this misconfiguration immediately:
This guard is not included in the current PR to keep the patch minimal and focused, but we consider it a worthwhile follow-up and would be happy to add it if the maintainers agree.
What CrossSocket replaces vs. what it cannot replace
HORSE_DAEMON)Required search paths when using Boss
Both packages ship a
boss.jsonthat tells Boss exactly which paths to expose. Understanding what Boss does — and does not — do with each field is important for a correct project setup.What Boss adds automatically
Boss distinguishes between two path fields in
boss.json:mainsrc.dproj— units here are found byusesclausesbrowsingpathHas you can see on
boss.json, BOSS installs the following packages:horse-provider-crosssocket→ Boss automatically adds:delphi-cross-socket(freitasjca fork) → Boss automatically adds:horse(freitasjca fork) → Boss automatically adds:All paths above assume the standard Boss
modules\layout at the project root. Adjust if your project uses a different Boss base directory.Changes overview
All modifications are in separate commits and are fully backward‑compatible. Detailed rationale and full code is in the provider's README.
1.
Horse.Request.pasTHorseRequest.Create– allows the context pool to pre‑allocate request objects at startup before any real request arrives. The existing constructor that accepts aTWebRequestis completely unchanged.Clearprocedure – fast field‑wipe for object reuse between requests (zero‑allocation hot path). ResetsFBody,FSession,FWebRequest, clears param dictionaries, and re‑createsFSessions.FBodyis a non‑owning reference into the CrossSocket receive buffer and is never freed byClear.Populateprocedure – injects per‑request shadow fields (method, method type, path, content‑type, remote address) directly, bypassing theFWebRequestdelegation that would crash whenFWebRequestisnil.PopulateCookiesFromHeaderprocedure – parses the rawCookierequest header into theTHorseRequest.Cookiecollection without requiring a liveTWebRequest.2.
Horse.Response.pasCustomHeadersproperty – read‑only exposure of the internalFCustomHeadersdictionary, allowing the response bridge to iterate all application‑set headers in a single pass for efficient forwarding.ContentStreamproperty – supports zero‑copy stream responses (large files, generated content) without intermediate string copies.BodyTextproperty – exposes the shadow string body field set whenFWebResponseisnil.CSContentTypeproperty – exposes the shadow content‑type field for the same reason.Clearprocedure – resetsFStatus,FContent,FContentType,FContentStream, clearsFCustomHeaders, and sets shadow fields to their defaults, mirroring the request‑side pooling contract.FCustomHeadersis aTDictionary<string,string>(Delphi), which stores one value per key. MultipleAddHeader('Set-Cookie', ...)calls will keep only the last value. Applications requiring multiple cookies in one response should compose them into a single header value for now.3.
Horse.Provider.Abstract.pasListenWithConfigvirtual class method – a new virtual method that accepts aTHorseCrossSocketConfigrecord (timeouts, size limits, SSL/mTLS settings, IO thread count, etc.). The base implementation simply calls the existingListenoverload, so all existing providers are completely unaffected.Executevirtual class method – runs the Horse middleware + route pipeline for a givenTHorseRequest/THorseResponsepair, allowing providers that bypassTWebRequestto invoke the full Horse pipeline. The base implementation callsRoutes.Execute(ARequest, AResponse).Portclass property – exposes the inherited port class variable so the no‑argumentListenoverride in the CrossSocket provider can read the port set by the caller.4. New unit
Horse.Provider.Config.pasTHorseCrossSocketConfig– arecordholding all configurable server settings: IO thread count, keep‑alive and read timeouts (reserved for future use), graceful‑drain timeout, header and body size limits, connection ceiling (reserved), compression settings, SSL/TLS certificate paths, mTLS CA certificate and peer‑verify flag, cipher list, and server banner suppression.Horse.Provider.AbstractandHorse.Provider.CrossSocket.Server:header suppressed).IoThreads,Compressible,MinCompressSize, and all SSL fields are active today. FieldsKeepAliveTimeout,ReadTimeout,MaxConnections, andSSLKeyPasswordare reserved — they are present in the record and populated byDefaultso applications can set them now, but CrossSocket does not yet expose the corresponding server‑level API.Why these changes are necessary
TWebRequestorTWebResponse. The parameterless constructor andClearmethods allow request/response objects to be reused from a pre‑allocated pool without the allocator being invoked on the hot path.CustomHeadersis the only way to read back headers previously set via the existingAddHeadermethod. Exposing it as a read‑only property enables the response bridge to forward all custom headers in one dictionary iteration.ListenWithConfiggives the provider a structured way to pass rich server configuration (timeouts, SSL, connection limits) without altering the existing zero‑argumentListensignature that all current providers use.Horse.Provider.Configmust be a standalone unit because bothHorse.Provider.Abstract(which declaresListenWithConfig) andHorse.Provider.CrossSocket(which implements it) need theTHorseCrossSocketConfigtype — placing it in either file creates a circular dependency.Note on Dependencies
The Delphi‑Cross‑Socket library, which this provider relies on, currently requires some maintenance to be fully compatible with the Boss package manager. The repository maintainer will need to:
Add a
boss.jsonfile to the root of the repository.Create a version tag (e.g.,
v1.0.0) so that Boss can resolve and pin the dependency correctly.Bundle or declare dependencies on the CnPack cryptographic library. The required files are:
CnPack\Common\CnPack.incCnPack\Crypto\CnNative.pasCnPack\Crypto\CnConsts.pasCnPack\Crypto\CnMD5.pasCnPack\Crypto\CnSHA1.pasCnPack\Crypto\CnSHA2.pasCnPack\Crypto\CnSHA3.pasCnPack\Crypto\CnSM3.pasCnPack\Crypto\CnAES.pasCnPack\Crypto\CnDES.pasCnPack\Crypto\CnBase64.pasCnPack\Crypto\CnKDF.pasCnPack\Crypto\CnRandom.pasCnPack\Crypto\CnPemUtils.pasCnPack\Crypto\CnFloat.pasA community fork (github.com/freitasjca/Delphi-Cross-Socket) has already completed steps 1,2 and 3: it ships a
boss.jsonwith"version": "1.0.0"and themainsrc/browsingpathfields correctly declared, and it adds FPC 3.3.1 support with zero source changes to the original library. This fork is whathorse-provider-crosssocketcurrently depends on. The entire stack is therefore installable today with:The ideal long‑term outcome is for the original repository to adopt the
boss.jsonso there is a single canonical source. The timeline for that depends on the original repository admin. Until then, the fork is the supported path.Testing and verification
Automated integration test suite — 24 tests, all passing (Delphi 12 Athens, Win64 Release):
Set-Cookieresponse headers,Cookierequest header echoContent-Typeheader, 65 536-byte large response without truncationAlso completed:
horse-jwt,horse-cors,horse-jhonson,horse-logger,horse-basic-authenticator) compile and respond correctly without any changes when the CrossSocket provider is active.Win64andLinux64targets.DrainTimeoutMs) verified under load.Planned before final merge:
Summary of files changed in Horse
Horse.pasHORSE_CROSSSOCKETconditional branch inusesandTHorseProvideraliasHorse.Request.pasClear,Populate,PopulateCookiesFromHeaderHorse.Response.pasCustomHeaders,ContentStream,BodyText,CSContentType,ClearHorse.Provider.Abstract.pasListenWithConfig,Execute,PortHorse.Provider.Config.pasTHorseCrossSocketConfigrecord with safe defaultsWe would be very happy to discuss any aspect of these changes, adjust scope, or split into smaller PRs if preferred. Thank you for maintaining such a fantastic framework!