Refuse blocked TCP connections instead of dropping them in silence - #179
Merged
Conversation
…in silence A block answers nothing today, so the application under test waits out its own timeout. That is one of the two failures a client can meet, and a suite that has only ever seen it has only tested half of them: a refusal comes back fast and usually goes down an error path or an immediate failover. `--block-reject`, and a checkbox beside the block fields, forge the reset a closed port would send. The naive version of this change would have shipped a mode that does nothing: step 2c runs BEFORE the RST step, so the packet it holds is a SYN, and a reset built the old way goes out with seq=0 and no ACK, which a stack in SYN_SENT is entitled to ignore. Measured against the LAN peer before writing any of it: the old shape left connect() hanging in 3 of 3 while three resets went out and were discarded; RST|ACK acknowledging the SYN ends it with WinError 10061 in 3 of 3. The shipped tool was then measured the same way - 8012 ms of silence without the flag, a refusal at 2008 ms with it - and those two seconds are the stack's own: a genuinely closed port here answers in 2003 ms. Outbound TCP only, and both limits are in the tooltip. An inbound SYN would have the reset aimed at a connection the local stack does not have yet, so only the counters would move; UDP's refusal is an ICMP port-unreachable this tool does not build. Refusals count as `block_rejected` (`connections_refused` in the stats CSV), never as `rst_reset`: that number means an established connection was cut and put in cooldown, and it reaches users in the CSV and the reproduction report. Guards: four tests in test_rst_local.py, all four in MUTATIONS and run rather than written - 4 of 4 caught, each by its own test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d against Whether a forged reset actually refuses a real client is judged by an operating system, so the suite cannot answer it: the unit tests prove the decision and the shape, and stop at the driver. The new rig runs four full sessions of the shipped tool against the LAN peer - a control with no tool at all, the block as it always was, the block refusing, and a UDP datagram that has to stay silent, because the tooltip promises no refusal there and a promise nothing measures is how that stops being true. The reproduction report gains `blocked_refused`. "23 packets blocked" does not say what the application under test met, and the rig reads that field back to tie the tool's own count to what the client saw: a session that refuses without counting, or counts without refusing, is now a finding rather than a shrug. Measured twice, and the verdict repeats to the millisecond: connect in 11-17 ms, the plain block timing out at 8000 ms with refused=0, the refusing block answering WinError 10061 at 2004 ms with refused=5 - the same five SYN retransmits the raw shape probe counted - and UDP silent. A new rig may block a release only after a second run says the same thing; this is that second run. Every verdict is pinned exact. `reject_ms` is not, because those two seconds are Windows retransmitting the SYN rather than anything this tool does: a genuinely closed port on this machine answers in 2003 ms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Linux leg refused this change at 66% diff coverage against a floor of 80%, and Windows was green: the lines nothing reached were the ones only a failure produces, `_send_rst` catching an injection error and the rate limit it now calls. So the missing coverage was a missing test, not a pragma. It drives a divert whose every injection raises and asserts the two facts that must never merge: four refusals attempted, none of them delivered, and ONE log line for the four - which is the limit itself, added because a refusal has no cooldown and Windows retransmits the SYN five times for one connect. Registered in MUTATIONS and run: 5 of 5 block mutations caught. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A block answers nothing today, so the application under test waits out its own timeout.
That is one of the two failures a client can meet, and a suite that has only ever seen it
has tested half of them: a refusal comes back fast and usually goes down an error path or
an immediate failover.
--block-reject, and a checkbox beside the block fields, forge thereset a closed port would send.
The obvious version of this change ships a mode that does nothing. Step 2c runs BEFORE
the RST step, so the packet it holds is a SYN, and a reset built the old way goes out with
seq=0and no ACK - which a stack in SYN_SENT is entitled to ignore (RFC 793), and did:that measurement has been in
core.pysince 2026-07-28. Measured again before writing anyof this, against a real peer: the old shape left
connect()hanging in 3 of 3 while threeresets went out and were discarded;
RST|ACKacknowledging the SYN ends it with WinError10061 in 3 of 3. The shipped tool was then measured the same way - 8012 ms of silence
without the flag, a refusal at 2008 ms with it.
Those two seconds are the stack's, not ours, and the copy says so: a genuinely closed
port on this machine answers in 2003 ms, with the same five SYN retransmits. The tooltip and
the README may say "instead of waiting out its own timeout" and may not say "instantly".
Outbound TCP only, and both limits are in the tooltip. An inbound SYN would have the
reset aimed at a connection the local stack does not have yet, so it would be discarded and
only the counters would move; UDP's refusal is an ICMP port-unreachable this tool does not
build.
Counted apart from
rst_reset. That number means an established connection was cut andput in cooldown, and it reaches users as
connections_resetin the stats CSV and thereproduction report. Refusals are
connections_refused, and the report gainsblocked_refused: "23 packets blocked" does not say what the application actually met.An existing stats CSV is rotated to a dated backup on the first write, as any new column
does here.
Two ceilings shaped the code rather than review.
core.decidescores 27 againstmax-complexity = 27, so the mode rides inside the existingreturnas a booleanexpression;
_capture_loopis one of the twelve functions nested four deep against aratchet of twelve, so the engine picks its counter through a map instead of a branch.
Guards: four tests in
test_rst_local.py, all four inMUTATIONSand run rather thanwritten - 4 of 4 caught, each by its own test.
Field.parameter_ofmet its firstnon-number and the pass-through sweep crashed on
bounds[1]; fixed in the test, because acheckbox's maximum is
True, rather than by giving the field fake bounds.Performance:
benchis clean. The blocked path itself refused a verdict twice - the modemeasured 437 ns ahead in one paired run and 312 ns behind in the next, both inside their
own spread - so what it adds (one attribute read and two boolean operators, on a packet
already being dropped) is below what this machine can measure, and neither number is quoted
as a result.
🤖 Generated with Claude Code