Skip to content

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
HashLoad:masterfrom
freitasjca:fix/websocket-feedbytes-fpc
Open

fix(websocket): type FeedBytes' parameter as the class, removing an interface-to-class cast that fails on FPC#551
freitasjca wants to merge 1 commit into
HashLoad:masterfrom
freitasjca:fix/websocket-feedbytes-fpc

Conversation

@freitasjca

@freitasjca freitasjca commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

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 the
same 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 an
interface:

procedure THorseWebSocketConnection.HandleIncomingBytes(const ABytes: TBytes; const ALength: Integer);
begin
  if FIsConnected then
    FParser.FeedBytes(ABytes, ALength, Self);      // Self (class) -> interface
end;

src/Horse.Core.WebSocket.pas:468 — and the parser casts it straight back:

procedure THorseWebSocketParser.FeedBytes(const ABytes: TBytes; ALength: Integer;
  const AConnection: IHorseWebSocketConnection);   // received as interface
...
  if Assigned(THorseWebSocketConnection(AConnection).FOnMessage) then   // cast 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, covering FOnMessage, FOnBinary,
FOnError and SendRawFrame. On FPC: OnMessage, OnBinary and OnError
never fire, and ping gets no pong. OnError being unreachable through the same
defect is why it fails silently.

FPC does not warn. It emits
Warning: Class types "IHorseRawResponse" and "TEpollRawResponse" are not related
elsewhere 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:

procedure FeedBytes(const ABytes: TBytes; ALength: Integer;
  const AConnection: THorseWebSocketConnection);

// body — direct field access, no cast
if Assigned(AConnection.FOnMessage) then
  AConnection.FOnMessage(AConnection, LMsgText);   // class -> interface is valid

Class-to-interface conversion — still needed, since the callbacks take
IHorseWebSocketConnection — is the safe direction and is well-defined on both
compilers. HandleIncomingBytes needs no change; it already passes Self.

THorseWebSocketParser is declared before THorseWebSocketConnection, so a
forward declaration is added:

IHorseWebSocketConnection = interface;
THorseWebSocketConnection = class;      // added

The absence of that forward is likely why the cast existed at all — FeedBytes
could only name the interface.

FeedBytes has exactly one caller (HandleIncomingBytes), so nothing else is
affected 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 a
dependency-free RFC 6455 client sending one masked "ola" frame. The echo can
only appear if the callback runs.

Compiler / provider This fix EAGAIN fix (#549) Result
Delphi 12 / Indy PASS — unaffected on Delphi
FPC 3.2.2 / epoll FAIL — different cause (see below)
FPC 3.2.2 / epoll FAIL — bytes never arrive
FPC 3.2.2 / epoll FAIL — frame delivered, OnMessage never fires
FPC 3.2.2 / epoll PASS

Row 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 (EAGAIN treated as a disconnect), which
severs the connection ~1 ms after the upgrade so no bytes ever reach
FeedBytes. That is fixed in #549. Merging #549 first is what makes this
defect 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

TestWebSocketDataExchange in tests/src/tests/Tests.Integration.WebSocket.pas
covers exactly this path — it sends a masked frame and asserts the echo that
OnMessage produces. 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 treats
the unknown symbol as a string, so the comparison is a type error:

Error: Incompatible types: got "AnsiString" expected "Extended"
Error: Compile time expression: Wanted Boolean but got <erroneous type> at IF or ELSEIF

Verified on FPC 3.2.2. Since Console.dpr:92 includes the fixture
unconditionally, the whole test project fails to build on FPC.

2. The test workflow is disabled. .github/workflows/tests.yml reports
state: disabled_manually with 0 total runs, so nothing has been compiling
it 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:

{$IF DEFINED(FPC)}
  // run normally — FPC has no CompilerVersion
{$ELSEIF CompilerVersion <= 30.0}
  ...existing skip...
{$IFEND}

Happy to include that here or send it separately, whichever you prefer.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant