Additive changes to support CrossSocket high‑performance provider#443
Open
freitasjca wants to merge 10 commits intoHashLoad:masterfrom
Open
Additive changes to support CrossSocket high‑performance provider#443freitasjca wants to merge 10 commits intoHashLoad:masterfrom
freitasjca wants to merge 10 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
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.
Additive changes to support CrossSocket high‑performance provider
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 (timeouts, SSL, worker pool, body size limits):
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.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, graceful‑drain timeout, header and body size limits, connection ceiling, 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).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
Completed:
horse-jwt,horse-cors,horse-jhonson,horse-logger, etc.) compile and respond correctly without any changes when the CrossSocket provider is active.Win64andLinux64targets.DrainTimeoutMs) verified under load.In progress:
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!