fix(engine): bind the targeting under the lock that protects it#48
Merged
Merged
Conversation
_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>
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.
What and why
_start_lockedreadself._targetingbare and then acted on that reference three times -set_table, a synchronousrefresh,retarget- while every other access to that field, inset_targetandtarget_for, is under_target_lock. A concurrentset_target()landing in themiddle 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 itsdocstring 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 changelogand the test's own docstring:
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 exactlythis) stayed green.
landing inside the start path could hit it.
observed symptom. Recorded this way on purpose: in six months
fix(engine): bind the targeting under the lockreads like a race someone had seen, and the nextsession 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
_stop_lock -> _target_lock -> ProcessTargeting._lock -> PortTable._lock, and nothing walks it theother way: the resolver never calls back into the engine, and
retarget()is only ever reachedwith
_target_lockalready held._resolver.start()comes after the block andstop()joins the resolver, so the synchronousrefresh()inside the lock has no contender. Thecost is one cold resolve (~36 ms elevated, per the measured figures in
portmap.info) at sessionstart, which could at worst delay a concurrent "Apply" by that much, once.
start binds ONE coherent object, not that a mismatch becomes less likely.
_target_lockrecordsacquisitions 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 == []).change is one
withstatement and the block indent under it.Checklist
python -m pytest testspasses locally. (667 tests, 0 failures, on an elevated shell, sothe 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.)tests/for the style). - one guard, mutation-checked, with itslimits stated in its docstring.
lang/en.jsonandlang/pl.jsonupdated. -n/a: no UI text.
CHANGELOG.md; technical ones and new tests inCHANGELOG-INTERNAL.md, under[Unreleased]. - internal only (convention 39): nothing atester can observe changes.
type(scope): summary).VERSION.txt.🤖 Generated with Claude Code