fix(websocket): type FeedBytes' parameter as the class, removing an interface-to-class cast that fails on FPC - #551
Open
freitasjca wants to merge 1 commit into
Conversation
freitasjca
added a commit
to freitasjca/horse-provider-nghttp2
that referenced
this pull request
Aug 22, 2026
Validated end-to-end 2026-08-21: build-fpc.sh 27/27 stages, stage 18 4/4 — extended CONNECT accepted with :status 200, server frame delivered, and the client's masked frame round-tripped as 'echo:hello'. Driven by Python h2, an independent HTTP/2 implementation, so a symmetric bug here could not produce it. There is no 101 and no Sec-WebSocket-Key handshake: HTTP/2 has no protocol-switch status, so RFC 8441 opens an ordinary stream with :method CONNECT plus :protocol websocket, answered with :status 200. RFC 6455 frames then flow as DATA in both directions. - WebSocket.pas (new): TNghttp2WebSocketTransport implements the six-method IHorseWebSocketTransport over ReadInbound/PushStreamData, plus the upgrader. Read loops on ReadInbound's -1 rather than passing it through: Horse treats <= 0 as a disconnect, and an idle peer would otherwise be torn down after the first quiet tick. - Request.pas: permit extended CONNECT, still refuse the plain RFC 7540 s8.3 tunnelling form — this is an origin server, not a forward proxy. - RawRequest.pas: map extended CONNECT to GET. Horse's router re-derives the method from RawWebRequest.Method, so the shadow TMethodType alone is ignored and every route answered 405. - EnableWebSocket opt-in on the provider, default False. - Test suite: stages 15-18 and ws8441_check.py. Requires a Horse core fix on FPC: Horse.Core.WebSocket.FeedBytes casts an interface reference back to a class, which FPC does not resolve, so no inbound callback fires. Submitted upstream as HashLoad/horse#551; until it merges, apply patches/horse/src/Horse.Core.WebSocket.pas. Req.IsWebSocket still relies on synthesising the upgrade headers into the parsed view, since core cannot see :protocol. HashLoad/horse#550 adds SetWebSocketUpgrade to replace that.
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.
Summary
I opened #547 earlier today and closed it myself once testing showed its central
claim was wrong. This PR is the corrected, much narrower version.
#547 said the defect affected all providers. It does not. Delphi is not
affected — the compiler resolves the interface-to-class cast to the
implementing object, and the same test passes unpatched on Delphi 12. The defect
is specific to FPC, where
{$MODE DELPHI}does not resolve that cast thesame way and the interface pointer is reinterpreted instead.
On FPC the effect is that a WebSocket server can send but never receive, and
it fails silently.
The original report also had the wrong root cause for what we were seeing on
epoll; that turned out to be a second, independent defect, submitted separately
as #549 (see Ordering note below). The matrix in Testing is what separates
the two.
The defect
src/Horse.Core.WebSocket.pas:812— the call site converts the class to aninterface:
src/Horse.Core.WebSocket.pas:468— and the parser casts it straight back:An interface reference points at the interface's VMT field within the object,
not at the object. Delphi special-cases the cast back to a class and recovers
the object; FPC does not, so every field access through it reads from the wrong
address.
There are 11 such casts in
FeedBytes, coveringFOnMessage,FOnBinary,FOnErrorandSendRawFrame. On FPC:OnMessage,OnBinaryandOnErrornever fire, and ping gets no pong.
OnErrorbeing unreachable through the samedefect is why it fails silently.
FPC does not warn. It emits
Warning: Class types "IHorseRawResponse" and "TEpollRawResponse" are not relatedelsewhere in the epoll provider, so it flags unrelated class casts — but
accepts interface-to-class silently.
The change
The call site already holds the class, so typing the parameter as the class
removes the cast entirely and makes the unit behave identically on both
compilers:
Class-to-interface conversion — still needed, since the callbacks take
IHorseWebSocketConnection— is the safe direction and is well-defined on bothcompilers.
HandleIncomingBytesneeds no change; it already passesSelf.THorseWebSocketParseris declared beforeTHorseWebSocketConnection, so aforward declaration is added:
The absence of that forward is likely why the cast existed at all —
FeedBytescould only name the interface.
FeedByteshas exactly one caller (HandleIncomingBytes), so nothing else isaffected by the signature change. The private field access this relies on is
already used by the current code, and both types live in the same unit. Net: one
forward declaration, one parameter type, 11 cast removals.
Testing
Minimal Horse WebSocket echo server echoing from inside
OnMessage, driven by adependency-free RFC 6455 client sending one masked
"ola"frame. The echo canonly appear if the callback runs.
OnMessagenever firesRow 4 is the isolating one, and row 1 is what makes this FPC-specific.
Ordering note. On epoll this defect is masked by a separate one in
Horse.Provider.Socket.WebSocket.pas(EAGAINtreated as a disconnect), whichsevers the connection ~1 ms after the upgrade so no bytes ever reach
FeedBytes. That is fixed in #549. Merging #549 first is what makes thisdefect reproducible — attempting to verify this PR on epoll without
it will look like the bug does not exist. That masking is why #547's original
scope was wrong.
Why nothing catches it
TestWebSocketDataExchangeintests/src/tests/Tests.Integration.WebSocket.pascovers exactly this path — it sends a masked frame and asserts the echo that
OnMessageproduces. Two separate reasons it never runs:1. It cannot compile on FPC. The fixture opens with:
{$IF CompilerVersion <= 30.0}FPC does not define
CompilerVersion, and rather than substituting 0 it treatsthe unknown symbol as a string, so the comparison is a type error:
Verified on FPC 3.2.2. Since
Console.dpr:92includes the fixtureunconditionally, the whole test project fails to build on FPC.
2. The test workflow is disabled.
.github/workflows/tests.ymlreportsstate: disabled_manuallywith 0 total runs, so nothing has been compilingit regardless.
Neither observation is a complaint — I mention them only because a reviewer
might reasonably ask why an existing test did not catch this, and the honest
answer is that the test has never executed. If the workflow is ever re-enabled,
the guard would need an FPC branch to compile at all:
Happy to include that here or send it separately, whichever you prefer.