Skip to content

feat: Res.Send<TStream>(S) now writes stream bytes to the response body - #540

Open
freitasjca wants to merge 1 commit into
HashLoad:masterfrom
freitasjca:feat/send-tstream-body
Open

feat: Res.Send<TStream>(S) now writes stream bytes to the response body#540
freitasjca wants to merge 1 commit into
HashLoad:masterfrom
freitasjca:feat/send-tstream-body

Conversation

@freitasjca

@freitasjca freitasjca commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Res.Send<TStream>(SomeStream).Status(200) currently produces HTTP 200 Content-Length: 0 on every Horse provider (Indy, HTTP.sys, IOCP, Epoll). This is because THorseResponse.Send<T> only assigns FContent := AContent — a TObject slot intended for content-type middleware (horse-jhonson, etc.) — and no provider bridge ever reads FContent as a stream.

This PR adds a DoSendStream helper and a runtime is TStream branch in Send<T> so that stream bodies are copied into the existing FCSContentStream / FWebResponse.ContentStream slots that every provider already reads. A non-generic typed Send(TStream) overload is also added so Res.Send(S) works without an explicit generic type argument.

Third-party providers (CrossSocket, mORMot, nghttp2) benefit automatically because the fix operates on the same shared shadow-field slots — no provider-side changes needed anywhere in the ecosystem.

The user-visible change

Before:

Res.ContentType('text/plain').Send<TStream>(MyStream).Status(200);
// → HTTP 200 Content-Length: 0  (stream never reaches the wire)

After:

Res.ContentType('text/plain').Send<TStream>(MyStream).Status(200);
// → HTTP 200 Content-Length: N  (stream contents in body)

Res.Send(MyStream).Status(200);  // typed overload — no <TStream> needed
// → HTTP 200 Content-Type: application/octet-stream  (default if none set)

Design

Three additions to src/Horse.Response.pas:

  1. Private DoSendStream(AStream: TStream) — copies the source stream into the active body slot:

    • FCSContentStream (HTTP.sys, IOCP, Epoll, and third-party providers via the shadow-field path)
    • FWebResponse.ContentStream (Indy / WebBroker — the traditional TWebResponse path)

    Follows the exact copy-and-own pattern already used by the existing SendFile(TStream, ...) method. Defaults Content-Type to application/octet-stream if the caller hasn't set one. Resets Position := 0 before copying so the common "position at end after writing" gotcha doesn't produce empty bodies.

  2. Non-generic overload Send(const AContent: TStream): THorseResponse — declared alongside the existing Send(string) and Send(TBytes) overloads. Compiler picks it when the argument's static type is TStream (or a known subclass), so Res.Send(MyStream) works without <TStream>.

  3. Modified Send<T> body — runtime TObject(AContent) is TStream branch routes to DoSendStream; the else-path FContent := AContent is unchanged, so all existing non-stream Send<T> callers (JSON serializers via horse-jhonson, etc.) keep working identically.

Ownership contract

Horse takes ownership of the stream — DoSendStream copies the bytes into an internal TMemoryStream and then calls AStream.Free. Matches the historical Send<T> contract where Clear/Destroy freed FContent. Callers must NOT wrap in try/finally S.Free (double-free).

SendFile(AStream, name, type) is unchanged — it still does NOT take ownership. Existing SendFile code continues to work identically.

Backward compatibility

  • Non-stream Send<T> callers hit the unchanged else FContent := AContent branch — JSON middleware and any other TObject-slot users see identical behavior.
  • Double Send<TStream>(S1).Send<TStream>(S2) on the same response replaces the first body with the second (via the existing FCSOwnsContentStream free-first guard on the shadow path; via FWebResponse.ContentStream := overwrite on the Indy path).
  • Res.Send(nil) is a no-op via nil-guard in DoSendStream (matches Send('') behavior).
  • No provider unit is touched — the fix works everywhere because every provider bridge already reads ContentStream.

Behavioral change to be aware of

Any code that currently calls Res.Send<TStream>(S) and expected an empty body (either as a workaround or because they tested against the broken behavior) will now receive the stream contents. This is the intended fix, but downstream test mocks that relied on receiving empty responses may need updating.

Verification

A comprehensive reproducer + regression suite is available at patches/horse-provider-crosssocket/samples/stream-demo/ in freitasjca/horse-crosssocket-workspace. Runs on the CrossSocket provider by default; the same handler code works on any Horse provider once this PR is merged.

Test coverage in the demo:

  • 7 endpoints exercising Send<TStream>(S) with TMemoryStream, position-at-end, position-reset, 30 bytes, 1024-byte pattern, 64 KB payload, TFDMemTable in sfBinary/sfXML/sfJSON
  • 3 regression endpoints using SendFile (unchanged) that produce byte-identical output to the new Send<TStream> path — validates the fix produces the same wire bytes as the pre-existing working API

Result: 47/47 assertions PASS on CrossSocket Win32 (Delphi 12.1).

Files changed

  • src/Horse.Response.pas — one file, 90 lines added, 1 line modified (only the generic Send<T> body changes)
  • Zero provider changes
  • Zero breaking API changes

Base version

Branched off master at Horse 3.3.2 (commit 72cc45f). Applies cleanly on top of the current release.

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