Skip to content

fix(server): a 204 leaves with no body, whoever put one there - #1

Open
MichaelC8E wants to merge 1 commit into
Hartslief:fix/content-injection-issuefrom
MichaelC8E:fix/a-204-carries-no-body-on-any-path
Open

MichaelC8E wants to merge 1 commit into
Hartslief:fix/content-injection-issuefrom
MichaelC8E:fix/a-204-carries-no-body-on-any-path

Conversation

@MichaelC8E

Copy link
Copy Markdown

Builds on your 204 guard rather than replacing it — merge this into fix/content-injection-issue and tina4stack#132 picks it up automatically.

Your diagnosis is confirmed by measurement. With TINA4_DEBUG=true, a scaffolded response(None, 204) went out with 1037 bytes of toolbar and Content-Length: 1037; an OPTIONS on a known path went out with 1032. Under uvicorn's h11 that is refused outright — Too much data for declared Content-Length — and the connection is torn down, so the next request over it dies with RemoteDisconnected while the DELETE that caused it still returned a clean 204. Under httptools, and under the built-in dev bridge, the bytes simply go out and are discarded; with Accept-Encoding: gzip the 204 even goes out gzip-encoded, because the toolbar pushes the body past the compression threshold in build_headers.

What this adds

Your guard stops the toolbar writing to a 204. Two cases stay open, and both fail on untouched v3 as well, so your change introduces neither:

  • a handler that passes content alongside a 204 keeps it — response("<b>x</b>", 204) still ships 39 bytes
  • a HEAD on a 204 reports the Content-Length of a body that may not exist

inject_feedback_widget in app() reads the same text/html content type from the far side of handle(), so it is reachable the same way.

_stage_no_content_strip empties the body for any 204, the way _stage_head_strip already does for HEAD — "even for an explicit Router.head() handler that accidentally returned one". A guard inside one injector protects one injector.

The position is behaviour, twice

  • Before _stage_dev_inspector_capture, or the dev dashboard records body_size: 1037 for a response that ships none. The stage list's own comment says body_size is meant to be what went on the wire. (This was a real defect in the first draft of this change, found by moving it and looking.)
  • Before _stage_head_strip, or a HEAD on a 204 carries that forbidden Content-Length.

Both orderings have a test.

Tests

tests/test_204_no_body_conformance.py — 9 cases driven through the real ASGI app, modelled on its sibling test_head_no_body_conformance.py:

tree result
untouched v3 7 fail / 2 pass
your guard alone 2 fail / 7 pass
with this change 9 pass

Full suite on this branch: 13 failed, 5251 passed, 680 skipped — the same 13 as the untouched baseline on this machine (database-timeout, port-takeover, queue-validation and session-backend tests that need services this box does not run), and the 9 extra passes are this file. 9 of 9 mutations of the new stage are caught by the behaviour tests alone; test_dispatch_pipeline was excluded from that run on purpose, since it pins the stage list and would catch every reorder by construction.

The stage-order contract in test_dispatch_pipeline.py is updated to match, with the reason in its assertion message.

Also worth knowing

tina4 serve never reaches the uvicorn failure: run() only consults _find_production_server() when debug is off, and the toolbar only injects when debug is on. Hitting it needs TINA4_DEBUG=true plus an externally-run uvicorn asgi:app. Worth checking which one you were running when you first saw the DELETE fail — the symptom differs quite a bit between them.

The same missing status check exists in php (Tina4/Router.php injectDevToolbar), ruby (lib/tina4/dispatch_pipeline.rb dev_toolbar_inject) and nodejs (packages/core/src/server.ts isInjectableHtml). Read only — none reproduced yet.

RFC 9110 s15.3.5 forbids content on a 204. The toolbar guard one commit back
stops one injector from writing it; two cases stay open, and both fail on
untouched v3 as well, so that guard introduces neither:

  - a handler that passes content alongside a 204 keeps it
  - a HEAD on a 204 reports the Content-Length of a body that may not exist

The feedback widget in app() reads the same text/html content type from the
far side of handle(), so it is reachable the same way.

_stage_no_content_strip empties the body for any 204, the way
_stage_head_strip already does for HEAD - "even for an explicit
Router.head() handler that accidentally returned one". A guard inside one
injector protects one injector.

Position is behaviour twice over. Before _stage_dev_inspector_capture, or
the dev dashboard records 1037 bytes for a response that ships none - the
stage list's own comment says body_size is meant to be what went on the
wire. Before _stage_head_strip, or a HEAD on a 204 carries that forbidden
Content-Length. Both orderings have a test.

tests/test_204_no_body_conformance.py, 9 cases driven through the real ASGI
app and modelled on its HEAD sibling: 7 fail on untouched v3, 2 fail with
the toolbar guard alone, all 9 pass here. The stage-order contract in
test_dispatch_pipeline.py is updated to match.
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