fix(targeting): let the first packet of a fresh connection be in scope - #66
Conversation
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>
Acceptance run, and it corrected the limitation as writtenPredicted 19 of 20 blocked. The first run gave 6, and the gap turned out to be worth more
Two candidate causes existed and this separated them:
Holding the sockets open moved the result from 6 to 19, so it is A. The single remaining escape So the documented limit was too narrow, and is now preciseNot "the first connection of a freshly started target" but "any target with no open socket when Covering that case needs the matcher plus a name lookup in the packet path, which convention 20 |
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 asocket 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 100gave 20 established connections anddrop_syn0. So--syn-dropcombined with--targetwas 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_CONNECTbeats the SYN in10/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()isthat reader: a lock-free
pid_forchecked against_pids, called from step 1 for a TCP SYNonly - 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_numas the sequence, which a SYN does not have, so itgoes out with
seq=0and no ACK. RFC 793 lets a stack in SYN_SENT ignore that, and it wasmeasured doing exactly that (the client hung until its own timeout while
rst_sentreported 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
_pids, so the first connection of a freshly startedtarget still slips through. Covering it needs the matcher plus a name lookup in the packet path,
which is what convention 20 forbids.
_pidsis up to one resolver cycle (0.30 s) stale, so a recycled PID can pull one packet of anunrelated socket into scope - a different false positive from the stale
_portsthis codealready lived with, so it is named rather than implied.
pid_forjoins the documented table contract (snapshot/name_of/ancestors/refresh), readthrough
getattrso 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):
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 thanProcessTargeting, so thereal
getattrguard was covered by nothing. It has its own test now.test_an_armed_gate_wins_over_every_later_stepdrove every gate withis_syn=True; the rst gatenow 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