Skip to content

Refuse blocked TCP connections instead of dropping them in silence - #179

Merged
donislawdev merged 3 commits into
masterfrom
feat/block-reject
Sep 5, 2026
Merged

Refuse blocked TCP connections instead of dropping them in silence#179
donislawdev merged 3 commits into
masterfrom
feat/block-reject

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

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 the
reset 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=0 and no ACK - which a stack in SYN_SENT is entitled to ignore (RFC 793), and did:
that measurement has been in core.py since 2026-07-28. Measured again before writing any
of this, against a real peer: 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.

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 and
put in cooldown, and it reaches users as connections_reset in the stats CSV and the
reproduction report. Refusals are connections_refused, and the report gains
blocked_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.decide scores 27 against
max-complexity = 27, so the mode rides inside the existing return as a boolean
expression; _capture_loop is one of the twelve functions nested four deep against a
ratchet 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 in MUTATIONS and run rather than
written - 4 of 4 caught, each by its own test. Field.parameter_of met its first
non-number and the pass-through sweep crashed on bounds[1]; fixed in the test, because a
checkbox's maximum is True, rather than by giving the field fake bounds.

Performance: bench is clean. The blocked path itself refused a verdict twice - the mode
measured 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

donislawdev and others added 3 commits September 5, 2026 09:40
…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>
@donislawdev
donislawdev merged commit 3067484 into master Sep 5, 2026
14 checks passed
@donislawdev
donislawdev deleted the feat/block-reject branch September 5, 2026 08:16
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