Skip to content

perf(portmap): collect the socket table outside the refresh lock#50

Merged
donislawdev merged 1 commit into
masterfrom
perf/portmap-collect-outside-the-lock
Jul 25, 2026
Merged

perf(portmap): collect the socket table outside the refresh lock#50
donislawdev merged 1 commit into
masterfrom
perf/portmap-collect-outside-the-lock

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

What and why

The capture thread takes PortTable._lock too - name_of(cheap=True) -> info() in
engine._process_for - so whatever refresh() holds that lock for, the packet path can be made to
wait for
. It held it across four iphlpapi calls, the port -> pid dict build and the
departed-pid diff, all O(number of sockets).

Measured before touching anything

"The hot path waits N ms" is exactly the claim convention 5 says to measure rather than estimate, so
this started as a measurement that could have ended in "rejected" (Win11, CPython 3.14, elevated,
medians with a control):

before after
lock hold @ 119 sockets 0.495 ms (p99 0.843) -
lock hold @ 10 000 0.303 ms 0.018 ms
lock hold @ 50 000 1.816 ms 0.018 ms
lock hold @ 100 000 3.555 ms 0.019 ms (188x)

The hold is now flat - no longer a function of table size. The whole call costs the same; the
work left the lock rather than disappearing.

The honest part: at desktop scale this was not an observable problem. The capture thread's own
lookup is 0.4 us, and its median, p95 and p99 did not move with a thread hammering
refresh(force=True) alongside - only 2 calls in 20 000 waited at all, worst 1.5 ms, with the lock
held ~0.15% of the time. "Measured, rejected" was a legitimate outcome here. What decided it was the
scaling: a network tester is the thing that gets pointed at 100 000 connections, and this
project's history is a series of "it was fine until someone ran a load test" (the flow table once
settled at 3.2 million entries). That reasoning is written into the docstring so it is not
re-litigated as a perf win it never was.

What a reviewer should know

  • Overlapping refreshes cannot move the map backwards. Collecting outside the lock lets two
    refreshes overlap, so each call takes a generation under the lock before it starts and installs
    only if nothing newer landed. This hazard was real, not theoretical - the mutation test without
    the guard installs the stale map. It is a counter and not a timestamp because time.monotonic()
    has ~15 ms granularity on Windows, so two refreshes inside one tick would compare equal and both
    install.
  • The freshness contract is unchanged: when refresh() returns, the map is at least as new as
    the moment the call started - either this call installed it, or a newer one already had.
  • The native handle is captured per call and the "it stopped answering" flip is re-checked under
    the lock (self._native is native), so a concurrent refresh cannot have its conclusion undone.
  • A methodology note worth having in the record: the first measurement script timed the whole
    refresh() call, which equalled the hold time only while everything was inside the lock. Used
    unchanged after the change it reported "3.6 ms, no improvement" - the right number for the wrong
    quantity. The lock had to be instrumented and the hold measured directly.
  • Incidental finding: since chunk 2c the resolver resolves against the SocketWatcher, whose
    refresh() is a no-op, so in a real session only the watchdog refreshes this table (~3-5/s). The
    up-to-20/s case is the poller fallback (--simulate, tests, non-Windows), which has no real
    capture thread to stall.
  • Both new tests are mutation-checked. The lock is probed from another thread on purpose -
    _lock is an RLock, so a same-thread acquire() would succeed while held and prove nothing - and
    the generation rule is driven by a gate rather than raced with sleeps.

Checklist

  • python -m pytest tests passes locally. (669 tests, 0 failures, on an elevated shell, so
    the two known non-admin failures are absent rather than excused. Verified through pytest's own
    exit code and the collected count. smoke_gui.py: OK.)
  • New behaviour has tests (see tests/ for the style). - two, both mutation-checked.
  • UI text goes through i18n keys, with both lang/en.json and lang/pl.json updated. -
    n/a: no UI text.
  • User-facing changes noted in CHANGELOG.md; technical ones and new tests in
    CHANGELOG-INTERNAL.md, under [Unreleased]. - internal only, deliberately: nothing a
    tester can observe changes, and claiming a user-visible win would be unmeasured prose.
  • Commits follow Conventional Commits (type(scope): summary).
  • No version bump - the owner closes a version via VERSION.txt.

🤖 Generated with Claude Code

The capture thread takes PortTable._lock too - name_of(cheap=True) -> info() in
engine._process_for - so whatever refresh() holds it for, the packet path can be
made to wait for. It held it across four iphlpapi calls, the port->pid dict build
AND the departed-pid diff, all O(number of sockets).

Measured before touching anything, because "the hot path waits N ms" is the kind
of claim convention 5 says to measure (Win11, CPython 3.14, elevated, medians with
a control):

- hold with everything inside: 0.495 ms median at 119 sockets, and the Python-side
  work scales linearly - 0.303 ms at 10 000 sockets, 3.555 ms at 100 000 (syscalls
  excluded, so a floor)
- the capture thread's own lookup is 0.4 us, and its median/p95/p99 did NOT move
  with a refresher hammering alongside; 2 calls in 20 000 waited, worst 1.5 ms
- so at desktop scale this was not observable, and "measured, rejected" was a
  legitimate outcome. What decided it was the scaling: a network tester is the
  thing that gets pointed at 100 000 connections, and this project's history is a
  series of "it was fine until someone ran a load test"

After: the hold is FLAT at ~0.018 ms whatever the table size - 188x shorter at
100 000 sockets and no longer a function of n. The whole call costs the same; the
work left the lock rather than disappearing.

- collection and the departed diff now run unlocked; only the reference swap,
  _last, the _info pops and _expire_info stay under it
- overlapping refreshes cannot move the map BACKWARDS: each call takes a
  generation before it starts and installs only if nothing newer landed. A counter
  and not a timestamp, because time.monotonic() has ~15 ms granularity on Windows
  and two refreshes in one tick would compare equal and both install. The hazard
  was real, not theoretical - without the guard the mutation installs the stale map
- the native handle is captured per call and the "it stopped answering" flip is
  re-checked under the lock, so a concurrent refresh cannot have its conclusion
  undone
- incidental finding: since chunk 2c the resolver resolves against the
  SocketWatcher, whose refresh() is a no-op, so in a real session only the watchdog
  refreshes this table (~3-5/s). The up-to-20/s case is the poller fallback, which
  has no real capture thread to stall
- two new tests, both mutation-checked: the lock is probed from ANOTHER thread
  (_lock is an RLock, so a same-thread acquire would prove nothing), and the
  generation rule is driven by a gate rather than raced with sleeps
- internal changelog only: nothing a tester can observe changes, and claiming a
  user-visible win would be unmeasured prose

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@donislawdev
donislawdev merged commit 7b371d9 into master Jul 25, 2026
8 checks passed
@donislawdev
donislawdev deleted the perf/portmap-collect-outside-the-lock branch July 25, 2026 18:41
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