Skip to content

feat(core): put the first packet of a fresh UDP flow in targeting scope - #71

Merged
donislawdev merged 3 commits into
masterfrom
feat/udp-first-packet-in-targeting-scope
Jul 28, 2026
Merged

feat(core): put the first packet of a fresh UDP flow in targeting scope#71
donislawdev merged 3 commits into
masterfrom
feat/udp-first-packet-in-targeting-scope

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

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 when is_syn, and is_syn is set for TCP
alone. 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 _pids cannot be the hidden variable,
--target <probe> --dst-ip 8.8.8.8 --dst-port 53 --loss 100 --filter out:

tree queries answered scoped_seen drop_loss
master 8 of 8 0 of 1944 captured 0
this branch 0 of 8 8 8

The 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:

map / mix today every miss syn or not tcp difference
400 / tcp-bulk 799 997 788 +209
10 000 / tcp-bulk 796 1056 790 +266
100 000 / tcp-bulk 834 1052 833 +219
100 000 / mixed 865 1030 934 +96
100 000 / udp-heavy 819 1028 1017 +11

(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_covers is renamed owner_targeted, since the old name became a
lie about when it runs.

Point C: the window is measured, not asserted

owner_targeted trusts the pid without verifying identity (verifying is create_time() in
the 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,
portmap reads iphlpapi - seven rounds per transport:

transport median range
TCP 309 ms 271-327
UDP 315 ms 290-325

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_targeted to ignore the portless case went green - the core test drives a
_FakePorts double with its own implementation, so it never touched the code under test. Same
trap 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. It
has 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, three
    new guards).
  • python smoke_gui.py - OK.
  • Seven mutants across the two chunks, all caught on their intended assertion.
  • Both READMEs updated (the socket-events bullet now says "connection or UDP flow", naming
    DNS and QUIC). No em/en dashes, LF endings intact.

🤖 Generated with Claude Code

donislawdev and others added 3 commits July 28, 2026 21:09
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>
@donislawdev
donislawdev merged commit adc9ed2 into master Jul 28, 2026
8 checks passed
@donislawdev
donislawdev deleted the feat/udp-first-packet-in-targeting-scope branch July 28, 2026 19:43
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