Skip to content

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

Open
freitasjca wants to merge 1 commit into
HashLoad:masterfrom
freitasjca:fix/stream-writer-factory-guard
Open

fix(streaming): exclude HORSE_PROVIDER_NGHTTP2 from the default stream-writer registration#552
freitasjca wants to merge 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.
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