Skip to content

fix(engine): bind the targeting under the lock that protects it#48

Merged
donislawdev merged 1 commit into
masterfrom
fix/start-binds-targeting-under-its-lock
Jul 25, 2026
Merged

fix(engine): bind the targeting under the lock that protects it#48
donislawdev merged 1 commit into
masterfrom
fix/start-binds-targeting-under-its-lock

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

What and why

_start_locked read self._targeting bare and then acted on that reference three times -
set_table, a synchronous refresh, retarget - while every other access to that field, in
set_target and target_for, is under _target_lock. A concurrent set_target() landing in the
middle would leave the resolver pointed at an orphan while the core tested against the object
that replaced it. That is the same class of mismatch set_target() itself was fixed for, and its
docstring still describes.

Read this as hardening, not as a bug fix

The title says fix, so this needs saying plainly, and it is written into the commit, the changelog
and the test's own docstring:

  • The race was not reproduced. 25 start/stop cycles against a concurrent applier in
    test_concurrency_chaos.py (the suite extended in test(chaos): put the SocketWatcher into the concurrency chaos suite #47, partly in the hope of catching exactly
    this) stayed green.
  • The window is narrow today. The CLI applies settings before start, so only a GUI "Apply"
    landing inside the start path could hit it.
  • So the justification is consistency of access to a field shared between threads, not an
    observed symptom. Recorded this way on purpose: in six months
    fix(engine): bind the targeting under the lock reads like a race someone had seen, and the next
    session would make decisions on that. Cleaning up exactly that kind of confident-sounding prose is
    what docs(socketwatch): de-stale the 2b/2c prose - targeting reads the live map #43 was.

What a reviewer should know

  • Lock order was checked before widening the hold, not after. It is
    _stop_lock -> _target_lock -> ProcessTargeting._lock -> PortTable._lock, and nothing walks it the
    other way: the resolver never calls back into the engine, and retarget() is only ever reached
    with _target_lock already held.
  • In this spot there is not even contention. _resolver.start() comes after the block and
    stop() joins the resolver, so the synchronous refresh() inside the lock has no contender. The
    cost is one cold resolve (~36 ms elevated, per the measured figures in portmap.info) at session
    start, which could at worst delay a concurrent "Apply" by that much, once.
  • Rejected: locking only the read. It narrows the window without closing it. The point is that
    start binds ONE coherent object, not that a mismatch becomes less likely.
  • The test is a mechanical guard, not a timing test. An instrumented _target_lock records
    acquisitions and the test asserts start took it. A timing test for a window this narrow would be
    flaky and prove nothing in either direction - and a flaky guard is worse than none, because it
    gets muted. Mutation-checked: reverting to the bare read turns it red (taken == []).
  • The engine diff is +33/-12 and most of it is the comment explaining the above; the behavioural
    change is one with statement and the block indent under it.

Checklist

  • python -m pytest tests passes locally. (667 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). - one guard, mutation-checked, with its
    limits stated in its docstring.
  • 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 (convention 39): nothing a
    tester can observe changes.
  • Commits follow Conventional Commits (type(scope): summary).
  • No version bump - the owner closes a version via VERSION.txt.

🤖 Generated with Claude Code

_start_locked read self._targeting bare and then acted on that reference three
times - set_table, a synchronous refresh, retarget - while every other access to
the field, in set_target and target_for, is under _target_lock. A concurrent
set_target() landing in the middle would leave the resolver pointed at an ORPHAN
while the core tested against the object that replaced it: the same class of
mismatch set_target() itself was fixed for.

This is CONSISTENCY OF ACCESS, not a fixed symptom, and it should not be read as
one later. The race was not reproduced - 25 start/stop cycles against a concurrent
applier in test_concurrency_chaos.py stayed green - and the window is narrow today
because the CLI applies settings before start, so only a GUI "Apply" landing
inside start could hit it.

Lock order was checked BEFORE widening the hold, not after: _stop_lock ->
_target_lock -> ProcessTargeting._lock -> PortTable._lock, and nothing walks it
the other way, because the resolver never calls back into the engine and
retarget() is only ever reached with _target_lock already held. In this spot there
is not even contention - _resolver.start() comes after the block and stop() joins
the resolver - so the synchronous refresh() has no contender and costs one cold
resolve (~36 ms elevated, per portmap.info) at session start.

- rejected: locking only the READ. It narrows the window without closing it, and
  the point is that start binds ONE coherent object
- new test: test_target_resolver.py::test_start_binds_the_targeting_under_the_lock_that_protects_it
  - an instrumented lock that records acquisitions, not a timing test, because a
  timing test for this window would be flaky and prove nothing either way.
  Mutation-checked: reverting to the bare read turns it red. Its docstring states
  what it does NOT claim - that the race was real

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@donislawdev
donislawdev merged commit a59ece9 into master Jul 25, 2026
8 checks passed
@donislawdev
donislawdev deleted the fix/start-binds-targeting-under-its-lock branch July 25, 2026 13:32
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