Skip to content

fix(serve): drain request body on reject so the client isn't RST mid-read - #36

Merged
mdheller merged 1 commit into
mainfrom
fix/serve-drain-body-no-rst
Aug 3, 2026
Merged

fix(serve): drain request body on reject so the client isn't RST mid-read#36
mdheller merged 1 commit into
mainfrom
fix/serve-drain-body-no-rst

Conversation

@mdheller

@mdheller mdheller commented Aug 3, 2026

Copy link
Copy Markdown
Member

Fixes the intermittent `test_serve.py` flake — and the real server defect underneath it.

Diagnosis (reproduced, not guessed)

Under full-suite `make test` load, a different serve test failed each run with `ConnectionResetError: [Errno 54] Connection reset by peer` on `resp.read()`; each passed in isolation. Caught the real traceback across 12 runs.

It is not a port-bind race — the port is already ephemeral (`0`) and bind+listen happen in the `ThreadingHTTPServer` constructor. The cause is in `serve.py`:

```
do_POST: 503 (mesh disabled) → _reply WITHOUT reading the body
413 (oversized) → _reply WITHOUT reading the body
400 (bad JSON) → reads the body first, THEN _reply
202 (accepted) → reads the body first
```

A rejection that leaves the posted body unread in the kernel receive buffer makes the OS send RST instead of FIN on close. The client — which already has a valid response status — then gets its connection reset mid body-read. That exactly matches which tests flaked: 503 and 413 did; 400-bad-JSON never did, because it reads the body to parse it.

This is a real server bug, not a test artifact. A live telemetry producer POSTing to a disabled endpoint hits the same intermittent reset instead of a clean 503.

Fix

  • serve.py: a bounded, idempotent _drain() discards any unread body before every reject reply (wired through _reply, plus the wrong-path 404). Bounded to MESH_MAX_BODY + 64 KiB so a normal or slightly-oversized body drains fully (no reset) while the 413 DoS guard still holds for a pathological body; the path that reads the body sets _body_consumed so the drain is a no-op there and never blocks.
  • tools/tests/test_serve.py: also server_close() the listening socket and join() the serve_forever thread in finally — each call previously leaked both.

Verification

20/20 consecutive full-suite pytest tools/tests/ runs clean (was ~1-in-6 failing). At the old rate, 20 clean by luck is ~4%.

…read

test_serve.py flaked intermittently under full-suite load — a DIFFERENT serve
test failed each run with ConnectionResetError [Errno 54] on resp.read(), passing
in isolation. Not a port-bind race (the port is already ephemeral and bind+listen
happen in the constructor). The real cause is in serve.py's handler:

do_POST replies 503 (mesh disabled) and 413 (oversized) WITHOUT reading the
posted body; only the 202 and bad-JSON-400 paths read it first. A rejection that
leaves bytes in the kernel receive buffer makes the OS send RST instead of FIN on
close, so the client — which has a valid response status — gets its connection
reset mid-body-read. That exactly matches which tests flaked: is_disabled (503)
and rejects_oversized (413) did; rejects_non_json (400) never did, because it
reads the body to parse it. This is a real server defect, not a test artifact: a
live telemetry producer POSTing to a disabled endpoint hits the same reset, not a
clean 503.

Fix:
- serve.py: a bounded, idempotent `_drain()` discards any unread body before every
  reject reply (wired through `_reply`, plus the wrong-path 404). Bounded to
  MESH_MAX_BODY + 64 KiB so a normal/slightly-oversized body drains fully (no RST)
  while the 413 DoS guard still holds for a pathological body. The path that reads
  the body sets `_body_consumed` so the drain is a no-op there (never blocks).
- test_serve.py: also close the listening socket (`server_close()`) and join the
  serve_forever thread in finally — each call previously leaked both.

Verified: 20/20 consecutive full-suite `pytest tools/tests/` runs clean (was
~1-in-6 failing). At the old rate, 20 clean by luck is ~4%.
@mdheller
mdheller merged commit ddffeab into main Aug 3, 2026
5 checks passed
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