feat(core): put the first packet of a fresh UDP flow in targeting scope - #71
Merged
Merged
Conversation
decide() step 1 asked the live socket map only when is_syn, and is_syn is set for TCP alone. UDP has no SYN, so it was never asked: a fresh flow was judged against a port set the resolver had not rebuilt yet. For a long-lived flow that costs one packet; for DNS over UDP and QUIC, which take a fresh ephemeral port per exchange, it costs every one of them. The fix is `(is_syn or not is_tcp)` - a TCP SYN, or anything that is not TCP. No new parameter on decide(). ProcessTargeting.syn_covers becomes owner_targeted, because the old name became a lie about when it runs. The SHAPE was chosen by measurement, and the measurement reversed the first recommendation. Each variant as a real byte patch of core.py, fresh subprocess, three traffic mixes x three map sizes, median of 5 (ns per decide(), all misses): 400/tcp-bulk today 799 every-miss 997 syn-or-not-tcp 788 10000/tcp-bulk today 796 every-miss 1056 syn-or-not-tcp 790 100000/tcp-bulk today 834 every-miss 1052 syn-or-not-tcp 833 100000/udp-heavy today 819 every-miss 1028 syn-or-not-tcp 1017 Asking on every miss costs +209..+266 ns/packet on TCP-heavy traffic, ~26% of decide(); this form is free there. Map size barely matters (400 -> 100k ports is ~35 ns), so it is about how often each variant asks, not the lookup. Price, named rather than implied: covering UDP widens the recycled-PID false positive from one SYN per connection to every UDP datagram of such a socket until the next rebuild. Ordinary TCP data is still never asked. - test_core.py::test_a_fresh_udp_flow_of_a_targeted_process_is_in_scope pins both halves, so nobody simplifies it into the expensive form - test_targeting_socketwatch.py::test_owner_targeted_says_no_for_portless_traffic guards the `port is None` line, which this change put on a live path for the first time: ICMP is not TCP, so every ping now calls owner_targeted(None) - four mutants, all caught - after the run showed the portless case was guarded by nothing, because the core test drives a fake with its own implementation Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last "known edge, never reproduced" from the handoff. owner_targeted trusts the pid the live map reports without verifying identity (verifying is create_time() in the packet path, convention 20), so between a target exiting and the next rebuild a socket Windows hands that pid number is treated as the target's. The docstring claimed "up to one resolver cycle (0.30 s)" - a design statement with nothing behind it. Measured against the real socket table (no WinDivert, no admin: portmap reads iphlpapi and ProcessTargeting resolves against it). Seven rounds per transport, probe holding a real socket, killed, time until its pid leaves _pids: TCP median 309 ms (271-327) UDP median 315 ms (290-325) i.e. the 0.30 s routine tick and no more, and TIME_WAIT does NOT stretch it - worth checking rather than assuming, since a TCP socket can outlive its owner. Upper bound: with no traffic every rebuild is the routine tick, while a live session's misses shorten it toward the 0.05 s floor. Deliberately not done: chasing real Windows PID reuse. Hitting the same number inside a 0.30 s window needs a spawn storm at ~30-50 ms per process, and "we tried and it did not reproduce" is an expensive non-result. The window is the bound that matters and it is directly measurable. The new guard owns BOTH halves: the false positive is real, and it ENDS at the next rebuild. Adding identity verification later fails the first half, which is the intended way of being sent back to these docs. Three mutants, all caught: owner_targeted always False, _pids never forgetting a pid, and the rebuild re-adopting a pid on its stale cached name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Master worktree against this tree, same machine, same probe: 8 DNS queries from FRESH sockets with the probe holding one socket open, so _pids cannot be the hidden variable. --target <probe> --dst-ip 8.8.8.8 --dst-port 53 --loss 100 --filter out, outbound only so the reply comes back untouched and the result stays binary. master 8 of 8 answered scoped_seen 0 of 1944 captured drop_loss 0 this tree 0 of 8 answered scoped_seen 8 drop_loss 8 The master column is the gap in its purest form: with a process target set, not one packet of 1944 was ever considered in scope. The branch column cross-checks exactly - 8 queries, 8 scoped, 8 dropped. 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.
Closes the last two open items from the engine handoff: point 3 (UDP not covered by the
fresh-flow fix) and point C (the recycled-PID window, "known edge, never reproduced").
The gap, and how big it actually was
decide()step 1 asked the live socket map only whenis_syn, andis_synis set for TCPalone. UDP has no SYN, so it was never asked. For a long-lived flow that costs one packet -
but DNS over UDP and QUIC take a fresh ephemeral port per exchange, so it cost all of
them.
Acceptance, master worktree against this tree on the same machine. 8 DNS queries from fresh
sockets, the probe holding one socket open so
_pidscannot be the hidden variable,--target <probe> --dst-ip 8.8.8.8 --dst-port 53 --loss 100 --filter out:scoped_seendrop_lossThe master column is the finding in its purest form: with a process target set, not one
packet of 1944 was ever in scope. The branch column cross-checks exactly.
The shape was chosen by measurement, and the measurement reversed my recommendation
I first proposed "ask on every miss" on the strength of a microbenchmark of one call (185 ns)
plus reasoning that the difference between variants was negligible. That reasoning was the
thing being asked about, so it got measured properly - each variant as a real byte patch of
core.py, fresh subprocess, three traffic mixes x three map sizes, median of 5:syn or not tcp(ns per
decide(), every packet missing.) The chosen form is free on TCP-heavy traffic -within noise of today - while the other costs ~26% of
decide(). Map size barely matters(400 -> 100 000 ports is ~35 ns), so this is about how often each variant asks, not about the
lookup.
ProcessTargeting.syn_coversis renamedowner_targeted, since the old name became alie about when it runs.
Point C: the window is measured, not asserted
owner_targetedtrusts the pid without verifying identity (verifying iscreate_time()inthe packet path, convention 20). The docstring claimed "up to one resolver cycle (0.30 s)"
with nothing behind it. Measured against the real socket table - no WinDivert or admin needed,
portmapreadsiphlpapi- seven rounds per transport:The routine 0.30 s tick and no more, and TIME_WAIT does not stretch it - worth checking
rather than assuming, since a TCP socket can outlive its owner. Chasing real Windows PID reuse
was deliberately skipped: hitting the same number inside 0.30 s needs a spawn storm at
~30-50 ms per process, and "we tried and it did not reproduce" is an expensive non-result.
The price is named where it belongs: covering UDP widens that false positive from one SYN
per connection to every UDP datagram of such a socket. Ordinary TCP data is still never asked,
so an established connection cannot be dragged in.
A mutant found a real hole rather than confirming a test
Mutating
owner_targetedto ignore the portless case went green - the core test drives a_FakePortsdouble with its own implementation, so it never touched the code under test. Sametrap F16 recorded, hit from a different direction. And that line was newly load-bearing:
ICMP is not TCP, so every ping now calls
owner_targeted(None)on the capture thread. Ithas its own guard against the real class now, and the core test says which half it pins.
Verification
python -m pytest tests- 721 passed, 0 failed on an elevated shell (718 -> 721, threenew guards).
python smoke_gui.py- OK.DNS and QUIC). No em/en dashes, LF endings intact.
🤖 Generated with Claude Code