Skip to content

fix(streaming): exclude HORSE_PROVIDER_NGHTTP2 from the default stream-writer registration - #552

Merged
regyssilveira merged 1 commit into
HashLoad:masterfrom
freitasjca:fix/stream-writer-factory-guard
Aug 28, 2026
Merged

fix(streaming): exclude HORSE_PROVIDER_NGHTTP2 from the default stream-writer registration#552
regyssilveira merged 1 commit into
HashLoad:masterfrom
freitasjca:fix/stream-writer-factory-guard

Conversation

@freitasjca

Copy link
Copy Markdown
Contributor

Summary

Horse.Response.pas registers its default WebBroker stream writer from a unit
initialization section, guarded to exclude providers that supply their own.
HORSE_PROVIDER_NGHTTP2 is missing from that guard.

Because FStreamWriterFactory is a last-writer-wins class var, and both Horse
core and the provider register into it from initialization sections, the
compiler's dependency walk decides which one survives
— nothing in either
source file does.

src/Horse.Response.pas:1361 (at master, ff41415):

initialization
{$IF NOT DEFINED(HORSE_PROVIDER_IOCP) AND
     NOT DEFINED(HORSE_PROVIDER_HTTPSYS) AND
     NOT DEFINED(HORSE_PROVIDER_EPOLL)}
  THorseResponse.RegisterStreamWriterFactory(DefaultWebBrokerStreamWriterFactory);
{$ENDIF}

Three providers are already excluded for exactly this reason. Adding the fourth
restores the evident intent.

Why it matters more than a missing define

On FPC trunk 3.3.1 the provider's factory happens to initialize last, so it
wins and streaming works. On FPC 3.2.2 the order differs, the WebBroker
default wins, and it cannot write to an HTTP/2 stream — so every streaming
request returns complete silence: no headers, no body, no error. The client
simply waits until it times out.

That means streaming on trunk has been passing by accident of initialization
order
, not by design. A compiler upgrade, a new unit, or a reordered uses
clause could flip it at any time, in either direction, silently.

Nothing else is affected — which is what made it hard to find. Ordinary
requests, TLS, mTLS, graceful shutdown and WebSocket all work normally; only
Res.SendStream is silently redirected to a writer that cannot serve it.

Reproduction

Build an nghttp2-provider server on FPC 3.2.2 with a Res.SendStream route:

$ curl -N --http2-prior-knowledge -s --max-time 8 http://127.0.0.1:9010/stream/pull
$ echo "exit=$?"
exit=28

exit=28 is curl's timeout, with zero bytes received. Verbose output shows
the request sent and no response line at all — not even < HTTP/2 200, which
distinguishes this from a stalled body.

With the one-line fix:

$ curl -N --http2-prior-knowledge -s --max-time 8 http://127.0.0.1:9010/stream/pull
{"id":1}
{"id":2}
{"id":3}
{"id":4}
{"id":5}
exit=0

Full suite on FPC 3.2.2 after the fix: 24 stages pass, 0 fail, 1 explicit skip
(gRPC, which needs trunk for TCustomAttribute). On FPC trunk 3.3.1: 27/27
unchanged, confirming the fix is a no-op where the ordering already favoured the
provider.

The change

{$IF NOT DEFINED(HORSE_PROVIDER_IOCP) AND
     NOT DEFINED(HORSE_PROVIDER_HTTPSYS) AND
     NOT DEFINED(HORSE_PROVIDER_EPOLL) AND
     NOT DEFINED(HORSE_PROVIDER_NGHTTP2)}

One condition, plus a comment recording why the list exists — the failure mode
is invisible enough that the next person adding a provider will want to know.

No behavioural change on any build where the ordering already favoured the
provider, and none at all on Delphi or on builds that use the WebBroker writer.

A suggestion, not part of this PR

This guard scales by requiring every new provider to remember to add itself,
and forgetting produces a silent failure rather than an error. Two sturdier
shapes, if you would like either as a follow-up:

  • Have RegisterStreamWriterFactory refuse to overwrite an already-registered
    factory, so the first specific registration wins and core's default only
    applies when nothing else claimed it.
  • Or register core's default lazily at first use, rather than from
    initialization, removing the ordering dependency entirely.

Both are larger changes than this one, and this PR fixes the immediate bug
without prejudging either.

Context

Found while making horse-provider-nghttp2 build on the FPC that Horse's own CI
installs (apt-get install -y fpc → 3.2.2). Companion to #549, #550 and #551.

…m-writer registration

FStreamWriterFactory is a last-writer-wins class var, and both Horse core and a
provider that supplies its own writer register into it from unit initialization
sections. Which one survives is therefore decided by the compiler's dependency
walk, not by anything in either source file.

Three providers are already excluded from the default registration for exactly
that reason. HORSE_PROVIDER_NGHTTP2 was missing.

The consequence is not cosmetic. On FPC trunk 3.3.1 the provider's factory
happens to initialize last and streaming works; on FPC 3.2.2 the order differs,
the WebBroker default wins, and it cannot write to an HTTP/2 stream — so every
Res.SendStream request returns complete silence: no headers, no body, no error,
just a client timeout. Nothing else is affected, which is what made it hard to
find.

That also means streaming on trunk has been passing by accident of
initialization order rather than by design, and could have flipped at any time.

Verified: FPC 3.2.2 goes from curl exit=28 with zero bytes to five NDJSON
records and exit=0; the full suite passes 24 stages with one explicit skip.
FPC trunk 3.3.1 stays at 27/27, confirming this is a no-op where the ordering
already favoured the provider.
@freitasjca

Copy link
Copy Markdown
Contributor Author

Some additional evidence from re-validating this on a larger suite yesterday (2026-08-23), which I think strengthens the case for taking it.

The failure does not present as a streaming bug. Without the clause, FPC 3.2.2 fails 9 stages, and only 2 of them point anywhere near the stream writer:

Stage Presents as Actually
5, 9, 10 h2c / TLS / mTLS suite hangs, 120 s timeout client blocked at check 33, GET /stream/pull
12, 13, 14 the same three via the epoll event loop same
15, 16 streaming: 0 events, 0 bytes, status 000000 the actual symptom

The first six look like transport or protocol defects — a TLS suite timing out, the event-loop driver hanging — and the run reports them that way. Checks 1–32 pass in 21 ms and then nothing. I spent a substantial part of a session diagnosing it as a protocol issue before finding the missing guard, which is what the ordering dependency really costs: not just silence on the wire, but silence that misattributes itself.

Independent confirmation that it is server-side: stage 15 is driven by curl -N, not by our own client, and it saw zero events and no status line at all.

Updated numbers — the suite has grown since I opened this:

  • FPC 3.2.2: 9 failed → 0 failed (25 passed, 2 explicit skips for the gRPC layer, which needs trunk)
  • FPC trunk 3.3.1: 33/33 before and after, confirming it is a no-op where the initialization order already favoured the provider

This also reinforces the follow-up suggestion at the end of the description. A guard list that must be updated by every provider author is a latent bug per provider; making RegisterStreamWriterFactory first-wins, or registering core's default lazily at first use, removes the ordering dependency entirely and would have made this class of failure impossible. Happy to open that as a separate PR if it would be welcome — it is a slightly larger change and seemed worth keeping out of a one-clause fix.

@regyssilveira regyssilveira left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A correção é pequena, coerente com os guards já existentes para providers com stream writer próprio e elimina a dependência da ordem de inicialização no FPC 3.2.2. Aprovado para integração como primeiro passo da sequência.

@regyssilveira
regyssilveira merged commit c9cbc90 into HashLoad:master Aug 28, 2026
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.

2 participants