Skip to content

fix(targeting): let the first packet of a fresh connection be in scope - #66

Merged
donislawdev merged 2 commits into
masterfrom
fix/first-packet-of-a-fresh-connection-is-in-scope
Jul 28, 2026
Merged

fix(targeting): let the first packet of a fresh connection be in scope#66
donislawdev merged 2 commits into
masterfrom
fix/first-packet-of-a-fresh-connection-is-in-scope

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

F16. Aiming at a process missed the first packet of every new connection.

ProcessTargeting.__contains__ answers from a frozenset rebuilt on the resolver's thread, so a
socket opened microseconds ago is unknown there - and the first packet of a connection is judged
before any rebuild it triggers.

Measured end to end before writing anything: 20 fresh connections against a process target with
--syn-drop 100 gave 20 established connections and drop_syn 0.
So --syn-drop combined with
--target was a complete no-op, and every other impairment missed one packet per connection.

This is not the 0.02 ms

The SOCKET-layer margin measured the same day (#65) is real: SOCKET_CONNECT beats the SYN in
10/10 runs. But nothing consumed it - the live map fed the rebuild, not the packet-path test.
Order in our favour is worth nothing until something reads it at the right moment. syn_covers() is
that reader: a lock-free pid_for checked against _pids, called from step 1 for a TCP SYN
only
- once per connection, not once per packet.

It drags a second fix with it, and that is the part that would have been missed

Step 4 arms a reset on the first in-scope TCP packet. With SYNs in scope, that becomes the SYN - and
the RST forged from a SYN copies its ack_num as the sequence, which a SYN does not have, so it
goes out with seq=0 and no ACK. RFC 793 lets a stack in SYN_SENT ignore that, and it was
measured doing exactly that
(the client hung until its own timeout while rst_sent reported 1).

So a reset is no longer armed from a SYN, while a SYN arriving inside an existing cooldown is
still held down. Without this, F16 would have traded a working reset for a hang.

What users will notice

With loss (or blocking, or a link outage) aimed at a process, connections now take longer to
open
- the SYN can be lost and TCP has to retransmit - and a minority fail to open at all. Before,
every connection opened at full speed and only then started suffering. Anyone measuring "time to
first byte" under impairment will see it move. That is in CHANGELOG.md, not buried here.

Known limits, all deliberate and all in the docstrings

  • A process with no socket yet is not in _pids, so the first connection of a freshly started
    target still slips through
    . Covering it needs the matcher plus a name lookup in the packet path,
    which is what convention 20 forbids.
  • _pids is up to one resolver cycle (0.30 s) stale, so a recycled PID can pull one packet of an
    unrelated socket into scope - a different false positive from the stale _ports this code
    already lived with, so it is named rather than implied.
  • UDP has no SYN and is not covered.
  • pid_for joins the documented table contract (snapshot/name_of/ancestors/refresh), read
    through getattr so a table without it answers False instead of raising on the capture thread.

Verification

  • python -m pytest tests -> 715 passed, 0 failed. python smoke_gui.py -> OK.

  • Hot path measured against a worktree of master (150k 1500 B packets, median of 5):

    path master this branch
    no targeting 135.9k pkt/s 140.3k
    targeting on, every packet a miss (the common path) 164.0k / 164.7k 164.5k / 170.4k

    No regression either way: the added cost is one boolean that short-circuits on is_syn.

  • Six new guards, six mutants, every one caught - after a repair worth recording. Two mutants
    first pointed at test_core.py, which drives a local fake rather than ProcessTargeting, so the
    real getattr guard was covered by nothing. It has its own test now.

  • test_an_armed_gate_wins_over_every_later_step drove every gate with is_syn=True; the rst gate
    now needs an ordinary packet. The test says why rather than being quietly relaxed.

Acceptance still owed: the same 20-connection probe. Expected 1 escaped, 19 blocked - not
0/20: the probe has no socket when the tool starts, so its very first connection is the documented
"unknown process" case.

🤖 Generated with Claude Code

donislawdev and others added 2 commits July 28, 2026 17:42
BREAKING-ish for anyone measuring connect times under impairment: with a process
target, connections now take longer to open (the SYN can be lost and TCP has to
retransmit) and a minority fail to open at all. Before, every connection opened
at full speed and only then started suffering.

ProcessTargeting.__contains__ answers from a frozenset rebuilt on the resolver's
thread, so a socket opened microseconds ago is unknown there - and the first
packet of a connection is judged BEFORE any rebuild it triggers. Measured end to
end: 20 fresh connections against a process target with --syn-drop 100 gave 20
established connections and drop_syn 0. --syn-drop plus --target was a complete
no-op; every other impairment missed one packet per connection.

This is NOT the 0.02 ms SOCKET-vs-SYN margin measured the same day. That margin
is real, but nothing consumed it: the live map fed the REBUILD, not the
packet-path test. Order in our favour is worth nothing until something reads it
at the right moment. syn_covers() is the thing that reads it - a lock-free
pid_for check against _pids - and step 1 calls it for a TCP SYN only, once per
connection rather than once per packet.

It drags a second fix with it, and that is the part that would have been missed.
Step 4 arms a reset on the first in-scope TCP packet, which now becomes the SYN -
and the RST forged from a SYN carries seq=0 and no ACK, which SYN_SENT is
entitled to ignore (RFC 793) and was measured doing exactly that. So a reset is
no longer armed from a SYN, while a SYN inside an existing cooldown is still held
down. Without it, this change would have traded a working reset for a hang.

pid_for joins the documented table contract, read through getattr so a table
without it answers False instead of raising on the capture thread.

Hot path measured against a worktree of master: no targeting 135.9k -> 140.3k
pkt/s; targeting on with every packet missing (the common path) 164.0/164.7k ->
164.5/170.4k. No regression - the added cost is one boolean short-circuiting on
is_syn.

Six mutants, every one caught, after two of them exposed that nothing covered the
real getattr guard - test_core.py drives a local fake, not ProcessTargeting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The prediction was 19 of 20 blocked; the first run gave 6. That gap is not the
0.02 ms SOCKET margin - `_pids` is rebuilt from the pids owning CURRENTLY OPEN
sockets, and the probe connected, closed at once and idled 0.2 s, so a rebuild
landing in that gap dropped the process out again and the next SYN was uncovered.

A second probe holding its sockets open was blocked 19 of 20 (drop_syn 38: each
connection's SYN plus its retransmit), the single escape being attempt 0, before
the process had any socket at all. Two candidate causes existed - `_pids` churn
or the watcher missing the 0.02 ms window - and this separated them: it is the
first.

So the documented limit was too narrow. Not "the first connection of a freshly
started target" but "any target with no open socket when a rebuild runs". A
browser or an app under test is covered; a script opening one connection, closing
it and pausing keeps slipping through. Both measured numbers are in the docstring
and both changelogs, because the difference between them IS the limitation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@donislawdev

Copy link
Copy Markdown
Owner Author

Acceptance run, and it corrected the limitation as written

Predicted 19 of 20 blocked. The first run gave 6, and the gap turned out to be worth more
than the fix itself.

probe behaviour SYNs caught drop_syn
before the fix 0 / 20 0
connects, closes at once, idles 0.2 s 6 / 20 12
connects and holds the sockets open 19 / 20 38

Two candidate causes existed and this separated them:

  • A - _pids is rebuilt from the pids owning currently open sockets, so a target with none
    when a rebuild runs drops out of it again;
  • B - the watcher thread not finishing SOCKET_CONNECT inside the 0.02 ms before the SYN.

Holding the sockets open moved the result from 6 to 19, so it is A. The single remaining escape
is attempt 0, before the process had opened anything at all. drop_syn 38 for 19 connections is
each SYN plus its retransmit, which is the expected shape.

So the documented limit was too narrow, and is now precise

Not "the first connection of a freshly started target" but "any target with no open socket when
a rebuild runs"
. A browser or an app under test is covered. A script that opens one connection,
closes it and pauses keeps slipping through - and that is a real usage pattern, so it is stated in
the docstring, both changelogs and PROJECT_NOTES, with both measured numbers. The difference between
6/20 and 19/20 is the limitation; quoting only the good number would have been the kind of prose
this project keeps having to correct.

Covering that case needs the matcher plus a name lookup in the packet path, which convention 20
forbids - so it stays a limit rather than becoming a follow-up.

@donislawdev
donislawdev merged commit ea80288 into master Jul 28, 2026
8 checks passed
@donislawdev
donislawdev deleted the fix/first-packet-of-a-fresh-connection-is-in-scope branch July 28, 2026 16:04
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