Skip to content

Read a refused POST's body before answering, so a late body cannot reset the refusal away (FM-49) - #177

Merged
sykosyber merged 3 commits into
mainfrom
claude/fm-49-windows-console-flake
Nov 5, 2026
Merged

sykosyber merged 3 commits into
mainfrom
claude/fm-49-windows-console-flake

Conversation

@sykosyber

@sykosyber sykosyber commented Nov 5, 2026 •

Copy link
Copy Markdown
Member

Summary

Fixes FM-49, the Windows CI flake in tests/test_console.py.

do_POST in syberwork/server.py checked the bearer token before it read the request body. An unauthenticated POST was refused and the socket was closed with the body still unread. If the body arrived after the server had read the headers, closing the socket sent a TCP reset. On Windows the reset throws away the refusal the client has not read yet, and the client sees ConnectionAbortedError: [WinError 10053].

http.client writes the headers and the body in two sends. The console tests run the client and the server as threads in one process, so under CI load the server thread can read the headers, refuse and close before the client sends the body. test_preview_appends_nothing_for_anyone sends nine unauthenticated POSTs. It's the console test that hit this.

This is a server defect, not just a test-harness problem: a real client posting with a missing or expired token can lose the refusal the same way.

Evidence

The same test failed with the same error in three Windows CI runs:

run 41871105046 attempt 2  test_console.py, line 1974, in test_preview_appends_nothing_for_anyone
                           ConnectionAbortedError: [WinError 10053] An established connection was aborted by the software in your host machine
run 41746425855            test_console.py, line 1282, same test, same error
run 40783915488            test_console.py, line 1276, same test, same error

Forced-gap repro: an unauthenticated POST to a test Cell, with a sleep between the header send and the body send (Windows 11, Python 3.12.6):

gap before (main) after (this branch)
1 ms 100/100 aborted, WinError 10053 0/100; 100/100 got 409 unauthorized
5 ms 100/100 aborted 0/100
20 ms 100/100 aborted 0/100
50 ms 50/50 aborted 0/100
50 ms, authenticated (control) 50/50 answered 409, none aborted 100/100 answered 409

Refusal paths covered (every do_POST path that answers before the body is read)

path before now
_user() refuses the token (missing or wrong) answered with the body unread reads and drops the body first: at most 1 MB and 5 s in total, one receive per read so a trickling client can't stretch the wait
_user() raises something unexpected (answered 500) body unread same drain as above
the token is refused and Content-Length is missing, malformed or over 1 MB body unread, no header nothing read; Connection: close
the token is refused and the request has Expect: 100-continue (this HTTP/1.0 server never sends 100) client and server both wait nothing read; Connection: close
_body(): Content-Length malformed (invalid_request) or over 1 MB (payload_size) body unread, no header Connection: close
_body(): authenticated POST with no Content-Length (for example chunked) body unread, no header Connection: close

Status codes and error bodies are unchanged. An unauthenticated POST is still answered 409 {"error": "unauthorized"}, as it is on main. Every route check runs after _body(), so no other path answers early.

Exposure note: an unauthenticated client can now make one server thread read up to 1 MB, for up to 5 s in total, before it gets its refusal. That's bounded, and the server already has no per-connection timeout for slow headers.

Tests

tests/test_console.py:

  • New helper late_body_post() sends a POST over a raw socket and holds the body back until the server answers, or for 1 s. If the server answers first, the helper waits 0.2 s so the server has closed before the body is sent. It reads the response to the end of the stream and records any OSError.
  • New class ConsoleRefusals, 5 tests:
    • test_an_unauthenticated_post_whose_body_arrives_late_still_gets_its_refusal: no token and a wrong token. Checks the refusal arrives whole (no OSError, body length matches Content-Length, 409 unauthorized) and that the server waited for the body. The waiting check makes the test fail on main on every platform, not only where the reset shows.
    • test_an_unexpected_failure_resolving_the_token_drains_a_late_body_and_answers_500_whole (fix round 1): patches _user() on the handler class, for this test only, to raise RuntimeError, then sends the body late. Checks the complete 500 {"error": "internal_error"} answer arrives with no reset, after the server read the body. Against main's server it fails on Windows by reset, 3 of 3 local runs (ConnectionAbortedError(10053, ...) is not None). It fails on every platform by the wait check, because main answers before reading. With the fix it passes 10 of 10.
    • test_a_refused_body_too_large_or_of_unknown_length_is_not_read_and_the_connection_is_closed: 7 subtests. The response comes at once and carries Connection: close.
    • test_a_refused_body_that_never_arrives_is_waited_for_a_bounded_time
    • test_a_refused_body_sent_a_byte_at_a_time_is_waited_for_in_total_not_per_byte
  • Existing test tightened, not loosened: test_preview_appends_nothing_for_anyone now checks the status of every answer: (409, unauthorized) without a token, otherwise 200 or 409 and never internal_error. A broken connection becomes a named self.fail instead of an error.
  • Commit 1 (f335e42) holds only the tests. Commit 2 (859cb69) has the fix, plus additions to the commit-1 tests from the self-review: the "waited for the body" check, the Expect/no-length subtests, and the two bounded-wait tests. No test that exists on main was weakened. Commit 3 (9bd6123, fix round 1) adds the 500-path test.

Failing first, locally on Windows against main (commit-1 tests): FAILED (failures=7). Both late-body subtests failed with AssertionError: ConnectionAbortedError(10053, ...) is not None : the connection broke before the refusal was read, in 10 of 10 repeated runs. The five Connection-close subtests failed with None != 'close'. Final tests against main's server: FAILED (failures=9, errors=2). The 2 errors are the bounded-wait tests: the REFUSED_BODY_SECONDS constant they patch doesn't exist on main.

After the fix, locally on Windows:

  • ConsoleRefusals: 10/10 runs OK
  • tests.test_console: 102 tests OK (1 skipped)
  • tests.test_server: 6 OK
  • tests.test_security_review: 35 OK (4 skipped)

Before the fix, 20 runs of the whole tests.test_console file, 4 at a time, all passed (20/20). The race window is too narrow to hit on this machine without forcing it; the forced-gap repro above is the reproduction.

CI

Failing first, tests only (f335e42), run 41944655147: failed as intended.

leg result on main's server
windows FAILED (failures=7): both late-body subtests failed with AssertionError: ConnectionAbortedError(10053, 'An established connection was aborted by the software in your host machine', ...) is not None : the connection broke before the refusal was read, plus the 5 Connection-close subtests
test (3.11), (3.12), (3.13), G8 strict FAILED (failures=5): only the 5 Connection-close subtests failed

So on Linux the reset does not show: the late-body test as written in commit 1 passed there. That's why commit 2 adds the "the server waited for the body" check, which fails on main on every platform.

With the fix (859cb69): all green. Push run 41946160945 and pull_request run 41946284344: windows, test (3.11), (3.12), (3.13), compose, G8 Existing suite (strict, 3.12) and G8 all pass. G2–G6, admit and the GitHits dependency policy check also pass.

Fix round 1 (9bd6123): all green. Push run 41949236152 and pull_request run 41949242637: windows, test (3.11), (3.12), (3.13), compose, G8 Existing suite (strict, 3.12) and G8 all pass. G2–G6, admit and the GitHits dependency policy check also pass.

Not explained

ConsoleWitness.test_a_signed_head_is_witnessed_through_its_count_and_later_events_are_not failed once with TimeoutError: timed out (run 41871105046, attempt 1, line 691). It was a 10 s client timeout on an authenticated POST that takes about 56 ms locally. That shard ran 1198 s against a planned 314 s. I found no harness cause:

  • each cell gets its own port from the OS (port=0);
  • in a test, 4 processes binding 2,500 sockets each got 0 duplicate ports out of 9,996, with or without SO_REUSEADDR;
  • the request reads its body, so this fix's mechanism doesn't apply.

It is left as runner load and is not claimed fixed. The test_builder_http timeout in run 41780132804 looks similar and is out of scope.

Rollback

A plain revert of 859cb69 restores the old server behavior. Reverting f335e42 as well removes the tests.

Review notes

A fresh-context read-only review ran on the diff. Accepted and fixed:

  • a late-body check that would have been timing-dependent on Linux;
  • a per-read timeout where a total deadline was needed;
  • Expect: 100-continue;
  • Connection: close for an authenticated POST with no Content-Length.

Declined: making _body()'s Content-Length parsing as strict as _discard_body()'s. It would change which inputs an authenticated POST accepts, and that is outside this fix.

Files outside the allowlist: none (syberwork/server.py, tests/test_console.py).

Admission

Class: R
Class-computed-by: git diff --name-only origin/main...HEAD lists syberwork/server.py and tests/test_console.py. Neither matches a rule in SyberWork's policy.json, so the floor is auto. AGENTS.md §1 classes a bug fix with its test, revertible by one commit, as R.
Agent-platform: claude
Reviewed-by: openai:gpt-6-sol (response resp_0b375a196acef471006aebe152842487d090e6b84fa5703cfd, head 9bd6123, tier medium, run by the PM (Claude) on behalf of sykosyber)

🤖 Generated with Claude Code

sykosyber and others added 2 commits November 4, 2026 16:53
…deliver the refusal (FM-49)

The Windows CI leg flakes in test_preview_appends_nothing_for_anyone with
WinError 10053. The server answers an unauthenticated POST without reading
its body; when the body lands after the answer, closing the socket resets
the connection and Windows discards the refusal the client had not read.

late_body_post() forces that gap on every run with a raw socket. The new
ConsoleRefusals tests assert the refusal arrives whole, and that a refused
body too large or of unknown length is not read and the answer says
Connection: close. test_preview_appends_nothing_for_anyone now asserts the
status of every answer and turns a broken connection into a failure.

These fail on main; the server fix follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…set the refusal away (FM-49)

do_POST checked the token before reading the body. A refusal was sent and
the socket closed with the body unread; a body that landed late made the
close reset the connection, and Windows discarded the refusal the client
had not read yet (WinError 10053, the Windows CI flake in
test_preview_appends_nothing_for_anyone).

Every do_POST path that answers before the body is read now handles it:
- the token refusal, and any unexpected error while resolving the token:
  the body is read and dropped first, up to 1 MB and 5 s in total (one
  receive per read, so a trickling client cannot stretch the wait);
- a Content-Length that is missing, malformed or over 1 MB, or a body held
  back for Expect: 100-continue: nothing is read and the refusal says
  Connection: close;
- _body's own refusals (malformed or over-limit length, and an
  authenticated POST with no Content-Length): Connection: close.

Status codes and error bodies are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-05T01:12:06.006852Z 859cb69 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 859cb693ff

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread syberwork/server.py
Comment on lines +259 to +260
self.unread_body = True
break

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve refusals when the body wait expires

If an unauthenticated POST declares a valid body but sends it more slowly than the five-second limit, this branch leaves part of the body unread and the handler still sends the refusal before closing. That recreates the unread-request close that can reset the response on Windows, so the client may still lose the refusal; the byte-at-a-time test checks only that the socket becomes readable, not that the full response arrives.

AGENTS.md reference: AGENTS.md:L157-L161

Useful? React with 👍 / 👎.

Comment thread syberwork/server.py
Comment on lines +248 to +249
raw = self.headers.get("Content-Length", "").strip()
length = int(raw) if raw.isascii() and raw.isdigit() else -1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle oversized decimal lengths before converting them

On the project's default Python 3.11+ configuration, a Content-Length with more than 4,300 ASCII digits passes isdigit() but makes int(raw) raise ValueError. That happens before unread_body is set, so the outer handler returns invalid_request without Connection: close while request bytes may still be pending, reopening the refused-body reset path on Windows.

AGENTS.md reference: AGENTS.md:L157-L161

Useful? React with 👍 / 👎.

@sykosyber

Copy link
Copy Markdown
Member Author

Independent review (OpenAI gpt-6-sol): CHANGES REQUESTED at 859cb69

The refusal fix is covered for unauthorized POSTs, but one changed error path still needs a regression test.

  • medium / tests tests/test_console.py: The new do_POST handler drains a late body when _user() raises any exception, including an unexpected failure that produces a 500 response. The added tests cover unauthorized refusals but not this changed 500 path. Add a test that forces _user() to fail, sends the body late, and verifies the complete 500 response is received.

Response resp_01a65230e3565850006aebdb89be7487d0accfb46dd19d6814. Tier: medium. Reviewer: Syber's lib/factory/reviewer.mjs (a433c4b), run by the PM (Claude) on behalf of sykosyber. Author platform: claude.

…y and answers 500 whole (FM-49)

do_POST drains the body when _user() raises anything, not only the
unauthorized refusal. This test patches _user() on the handler class to
raise RuntimeError, sends the body late, and checks the complete
500 internal_error answer arrives with no reset, after the server read the
body. Against main's server it fails on Windows by reset (WinError 10053)
and on every platform by the wait check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sykosyber

Copy link
Copy Markdown
Member Author

Independent review (OpenAI gpt-6-sol): APPROVED at 9bd6123

The change reads bounded, length-delimited bodies before answering refused POSTs and marks requests that cannot be drained for connection closure. The added tests cover late bodies, refusal responses, and bounded waits. No blocking finding in the provided diff.

No findings.

Response resp_0b375a196acef471006aebe152842487d090e6b84fa5703cfd. Tier: medium. Reviewer: Syber's lib/factory/reviewer.mjs (a433c4b), run by the PM (Claude) on behalf of sykosyber. Author platform: claude.

@sykosyber
sykosyber merged commit eea739c into main Nov 5, 2026
25 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