Skip to content

feat(targeting): event-driven process targeting via the WinDivert SOCKET layer (chunk 2)#38

Merged
donislawdev merged 7 commits into
masterfrom
feat/socket-layer-targeting
Jul 23, 2026
Merged

feat(targeting): event-driven process targeting via the WinDivert SOCKET layer (chunk 2)#38
donislawdev merged 7 commits into
masterfrom
feat/socket-layer-targeting

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

What & why

Targeting a process (e.g. chrome.exe) worked out which connections belonged to the target by
polling the OS socket table a few times a second. A snapshot is always a little behind reality,
so a connection that opened and closed between two snapshots was never seen and its whole life
slipped through unimpaired. On a busy app like Chrome (many short-lived connections, QUIC, prefetch)
that meant a large share of the traffic escaped - the reported "part caught, part not".

This replaces the poll with WinDivert's SOCKET layer, which delivers a real-time event
(bind / connect / accept / close) carrying the owning ProcessId. A sniff-only handle
(SNIFF | RECV_ONLY - it cannot drop or modify anything) keeps a live local_port -> pid map, so a
connection is targeted the instant it opens. Measured: SOCKET_CONNECT arrives ~0.1 ms before
the outbound SYN reaches the NETWORK layer, so the mapping is ready before the first packet. (FLOW
layer was measured ~28 ms later, i.e. after the handshake, so SOCKET, not FLOW, is the source.)

How it was built (4 sub-chunks, one commit each)

  • 2a beantester/socketwatch.py (SocketWatcher) in isolation: the live map (add on
    bind/connect/accept/listen, remove on close, pid-checked so a late close cannot evict a
    recycled port), a snapshot reconcile() safety net with a two-pass grace, names delegated to
    portmap, an injected event source so it is unit-tested without WinDivert.
  • 2b BeanEngine runs the watcher for the session lifecycle (start/stop next to the resolver),
    bootstraps it from a portmap snapshot, reconciles each watchdog tick. Only on the real-WinDivert
    path; degrades to the poller if the SOCKET handle cannot open (does not fail the session).
  • 2c ProcessTargeting.set_table() + engine._targeting_table(): targeting resolves against
    the live map when a session has one, the poller otherwise. This is where behaviour changes.
  • 2d docs: both READMEs + a user-facing changelog line.

Fallback / testability

The polling path (portmap / target_resolver) is untouched and stays the fallback for
--simulate, the tests, and non-Windows - so the "engine testable without WinDivert" contract
holds. Every unit test runs without WinDivert (import beantester still does not import pydivert).

Verification

  • Full unit suite green on Windows; new tests: tests/test_socketwatch.py,
    tests/test_socketwatch_wiring.py, tests/test_targeting_socketwatch.py. One pre-existing GUI
    test was updated to inject its fake table into engine._ports (the shared table targeting now
    resolves against), the same injection the resolver/watcher tests use.
  • Real WinDivert smokes (elevated, sniff-only or a narrow pass-through filter - never global
    impairment): SOCKET/FLOW layers deliver PID + 5-tuple; NETWORK + SOCKET handles coexist;
    SOCKET_CONNECT precedes the SYN; the engine opens both handles together; and a fresh
    connection of the targeted process read in_scope in ~0 ms
    through the live map.

Note on prose (rules 5/6)

The targeting.py docstring and both READMEs previously called the race "a limit of the method"
that "cannot be closed". That was true of the poller and false of the event path; corrected to name
which table closes it. This also motivated a new PROJECT_NOTES rule: a recurring problem is a
wrong-primitive signal - read the dependency's capability surface before building a workaround.
(The SOCKET/FLOW layers were in pydivert the whole time.)

Before merge

Please run the real-Chrome check on your machine (GUI, target chrome.exe, watch the Connections
tab): the sea of untargeted connections should be gone, and the intermittent
"BRAK pasującego procesu / no matching process" at apply time should not recur.

🤖 Generated with Claude Code

donislawdev and others added 7 commits July 22, 2026 21:59
…t SOCKET events

Chunk 2a of the targeting overhaul: the event-driven replacement for polling the
OS socket table. The poller takes a snapshot a few times a second, so a
connection that opens and closes between two snapshots is never seen and its
whole life slips through unimpaired - measured as a large share of Chrome's
short-lived connections.

WinDivert 2.2 exposes a SOCKET layer that delivers BIND/CONNECT/ACCEPT/LISTEN/
CLOSE with the owning ProcessId, via a sniff-only handle (SNIFF | RECV_ONLY,
which cannot drop or modify anything). SocketWatcher folds those into a live
local_port->pid map: add on the first four, remove on CLOSE (pid-checked, so a
late CLOSE cannot evict a port the OS has recycled to a different process).
reconcile() seeds from a portmap snapshot and prunes a port absent for TWO passes
(grace against evicting a socket opened microseconds before the snapshot was
taken). Names/ancestors delegate to portmap (no duplication); the event source is
injected, so the map is unit-tested without WinDivert.

Why SOCKET, not FLOW (measured spike, elevated, sniff-only): SOCKET_CONNECT
arrives ~0.1 ms BEFORE the outbound SYN reaches the NETWORK layer (closes the
race); FLOW_ESTABLISHED arrives ~28 ms AFTER (post-handshake, SYN already
slipped). Two sniff handles (NETWORK+SOCKET) were confirmed to coexist, and the
real source was smoke-verified end to end (a known connection's local port mapped
to os.getpid(), removed on close).

Scope: the module in ISOLATION. It is NOT wired into BeanEngine or
ProcessTargeting yet (2b wires the lifecycle + bootstrap + fallback; 2c makes
targeting read the live map). The polling path is untouched and stays the
fallback for --simulate / tests / non-Windows.

New tests: tests/test_socketwatch.py. import beantester stays pydivert-free
(the real source constructs pydivert lazily, only in start()).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The engine now owns a SocketWatcher: created and started in _start_locked, stopped
in _stop_locked, next to the TargetResolver (both hold OS handles, both live as long
as the session). A new start(..., socket_source=None) parameter injects the event
source for tests.

Bootstrap: start reconciles the watcher from a forced portmap snapshot, so
connections open before the session are known from the first packet (events only
announce NEW sockets). The watchdog folds a fresh snapshot in each tick as the safety
net - a missed CLOSE ages out, a dropped event is recovered.

Started only on the real-WinDivert path (divert is None) or with an injected source;
on the synthetic/simulate/test path self._socketwatch stays None and the poller
stands, so the testable-without-WinDivert contract holds. A SOCKET handle that cannot
open DEGRADES to the poller (recorded, not swallowed) rather than failing the session.

No behaviour change yet: targeting still reads the polling table; the watcher is kept
live but unused until 2c. Verified by a real-path smoke (elevated, narrow pass-through
filter): the NETWORK impairing handle and the SOCKET watcher ran together (retiring
the coexistence risk) and a known connection's local port mapped to os.getpid().

New tests: tests/test_socketwatch_wiring.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…p (chunk 2c)

This is the payoff of chunk 2. ProcessTargeting gained set_table(), and the engine
now points it at the SocketWatcher's live map when a session has one
(_targeting_table() -> watcher, else portmap), rebinding in _start_locked (targeting
is usually built before start, against the poller). _start_locked was reordered so
the watcher is created before the initial synchronous resolve, so the very first
resolve already reads the live map.

Effect: a connection of the targeted process is in impairment scope the instant its
SOCKET_CONNECT event arrives - and since that event precedes the SYN, before its
first packet. Verified end to end on real WinDivert (elevated, narrow pass-through
filter): targeting bound to the watcher, and a fresh outbound connection read
in_scope in ~0 ms. The polling path stays as the fallback, unchanged, so short-lived
connections only still escape when there is no real WinDivert (--simulate, tests,
non-Windows, or a SOCKET handle that could not open).

Only the LIVE path changed: engine.target_for builds against _targeting_table(). The
one-shot reporting helpers (find_process_ports -> resolve_ports, make_targeting) keep
resolving against portmap - they are display snapshots, not session targeting.

Prose corrected (rules 5/6): the targeting.py docstring claimed the race "cannot be
closed, only made small" - true for the poller, false for the watcher. It now names
which table closes it.

test_gui_state.py: its FakeTable is now injected into engine._ports (the one shared
table targeting resolves against) rather than only monkeypatching
portmap.default_table after the engine was built - the same injection the resolver and
socket-watcher tests use.

New tests: tests/test_targeting_socketwatch.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both READMEs' targeting notes said process targeting is 'a race with the system
(and stays one)' via a ~2-3x/second socket-table poll. That is now the FALLBACK,
not the primary path: on Windows targeting follows the system's SOCKET events, so
a connection is in scope from the moment it opens (before its first packet, for
outbound). Updated the two targeting bullets and the field note in EN and PL, and
softened the exclusion note (unidentified is no longer 'every new connection').

Also a user-facing CHANGELOG entry: targeting now catches connections as they
open, including the short-lived ones a busy app like Chrome makes, which the poll
used to miss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…r stop

Two fixes from a field crash log + a "first start takes seconds" report.

1) SocketWatcher._loop recorded the WinError 995 that stop() induces (closing the
   SOCKET handle unblocks the parked recv() with "I/O aborted") as a crash, so
   every STOP left a spurious socketwatch.loop entry in crashes/. Now guarded by
   `if not self._stopping.is_set()`, like the capture loop's `if self._running`;
   a real socket-stream failure while running is still recorded.

2) The first target-start blocked ~2 s. Resolving a target walks every
   socket-owning PID and its process tree; the first protected ancestor that could
   not be opened individually made portmap.info fall back to a whole-system
   psutil.process_iter() (~2.9 s), synchronously, on the start/apply path. A PID
   the OS will not let us open is never one of the user's apps, so targeting now
   passes allow_bulk=False (new flag on info/name_of/ancestors); the bulk stays for
   warm_names() (the connection log) on the watchdog thread. Cold resolve
   2148 ms -> 371 ms. This also relieves the yes/no flicker at start: the resolver
   hit the same bulk, so targeting was stale for ~2 s and early connections slipped.

Behaviour change: a protected process can no longer be targeted by name (it will
not match); the connection log still shows its name. test_processes.py's fake
psutil gained a Process class (individual resolution, matching real psutil), and
the targeting/watcher test fakes gained the allow_bulk kwarg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s target-by-name)

The previous commit's allow_bulk=False made the first target-start fast by SKIPPING
the process-name bulk scan - and that broke targeting Chrome BY NAME: the field
reported "BRAK pasującego procesu" while targeting Chrome's PID worked. Skipping the
bulk skipped the only path that can name a HARDENED process (Chrome's network service
and renderers refuse the OpenProcess that psutil.Process(pid) needs), and name
matching needs the name where PID matching does not.

The real problem was never "walk the tree", it was that the bulk fallback was
psutil.process_iter at ~2.6-2.9 s. Replace it with a native CreateToolhelp32Snapshot
(portmap._toolhelp_process_table): it reads every process's name+ppid WITHOUT opening
any of them, measured ~6 ms for 350 processes, and it names hardened processes. So
the bulk is affordable on the synchronous start/apply resolve AND resolves Chrome by
name. Cold ProcessTargeting.refresh('chrome') 2148 ms -> ~365 ms, matched=True.

- Reverts the allow_bulk flag entirely (portmap/socketwatch/targeting + the test
  fakes). Making the bulk fast is the fix; skipping it was the bug.
- portmap._process_table() prefers toolhelp; psutil.process_iter is the fallback off
  Windows / in tests (which flip portmap._ALLOW_NATIVE_PROCESSES off).
- Keeps the SocketWatcher-stop crash guard from the previous commit.
- Corrects PortTable.info's docstring (convention 5): create_time() is ~5 ms/PID
  (not 0.005 ms) - so the per-refresh recycle check is ~180 ms with a browser open,
  a measured perf follow-up, not the sub-millisecond it claimed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e signal

Field report from a browsing session: rows were coloured orange while the column
read "impaired: no" (and "yes" rows had no colour) - "something is wrong with the
connections table".

Cause: the column reads the STORED per-flow scoped record (a session-long "was ever
in impairment scope"), but ConnsPage._tag_of still asked the engine LIVE
(in_scope_now -> local_port in target_ports). For a closed or idle flow the live
check flips to False the instant the socket closes, so the colour and the column
disagreed. The session in the screenshot was also Stopped, where the live scope is
frozen and meaningless.

_tag_of now reads the same stored c["scoped"]. Column, highlight, sort (views.py)
and CSV (gui/app.py) are all one signal now and can never diverge - a row is orange
exactly when its column says "yes". ConnsPage._in_scope removed;
engine.in_scope_now / core.in_scope are now unused (left for a follow-up removal).

tests/test_conns_columns.py updated: a scoped=True flow whose port is out of the
current target is now "yes" AND highlighted (it used to be "yes" with an empty tag).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@donislawdev
donislawdev merged commit 2f7b9d0 into master Jul 23, 2026
8 checks passed
@donislawdev
donislawdev deleted the feat/socket-layer-targeting branch July 23, 2026 19:46
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