fix(display): consume the reconcile request before serving it

Addresses the second review finding: a lost update on
_pending_plugin_reconcile.

The flag was cleared after a successful reconcile. Reconcile has already read
its config by that point, so a config change arriving mid-flight set a flag
that the trailing clear then erased -- a request that was never served, and
the newest config never reconciled. That is the same "my save did nothing"
symptom this PR exists to remove, so leaving it would have undercut the fix.

Consume the request before running it instead, and re-arm only on a retryable
failure. A change that lands during reconcile now stays set and is picked up
on the next pass.

The per-frame read stays lock-free. It is a fast path that can only produce a
false negative -- the watcher setting the flag just after it is read is seen
on the next iteration -- never a false positive that loses a request. The lock
is taken only when a reconcile is actually pending or a config change arrives.

Extracted _service_pending_reconcile() so the sequence is testable rather than
buried in run()'s loop; the review asked for a regression test that invokes
the subscriber during reconciliation, which is not reachable otherwise.

Tests: 4 new, covering a request racing in mid-reconcile, the quiet success,
the retryable-failure re-arm, and not reconciling when nothing is pending.
Two of them fail against the previous clear-after-success semantics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
This commit is contained in:
ChuckBuilds
2026-08-22 09:33:44 -04:00
co-authored by Claude Opus 5
parent 7156d31491
commit 094090d138
2 changed files with 79 additions and 5 deletions
+31 -5
View File
@@ -187,6 +187,10 @@ class DisplayController:
# run during reconcile (rare), so this lock never touches the per-frame # run during reconcile (rare), so this lock never touches the per-frame
# path -- the hot-path reads are same-thread as the writes. # path -- the hot-path reads are same-thread as the writes.
self._plugin_modes_lock = threading.Lock() self._plugin_modes_lock = threading.Lock()
# Guards the consume-and-clear of _pending_plugin_reconcile. Only taken
# when a reconcile is actually pending or a config change arrives, both
# rare -- the per-frame path just reads the bool.
self._reconcile_flag_lock = threading.Lock()
# Per-plugin config-change callbacks, kept so we can unsubscribe a # Per-plugin config-change callbacks, kept so we can unsubscribe a
# plugin when it is disabled live. # plugin when it is disabled live.
self._plugin_config_callbacks: Dict[str, Callable] = {} self._plugin_config_callbacks: Dict[str, Callable] = {}
@@ -471,7 +475,8 @@ class DisplayController:
# loop to apply (loading/unloading off the watcher thread is unsafe). # loop to apply (loading/unloading off the watcher thread is unsafe).
if (self._enabled_set_changed(old_config, new_config) if (self._enabled_set_changed(old_config, new_config)
or self._enabled_plugin_not_running(new_config)): or self._enabled_plugin_not_running(new_config)):
self._pending_plugin_reconcile = True with self._reconcile_flag_lock:
self._pending_plugin_reconcile = True
self.config_service.subscribe(_controller_config_change) self.config_service.subscribe(_controller_config_change)
@@ -1756,11 +1761,12 @@ class DisplayController:
# rebuilding available_modes happens here on the render thread so # rebuilding available_modes happens here on the render thread so
# it can't race with rendering. Deferred while on-demand is active # it can't race with rendering. Deferred while on-demand is active
# (the flag stays set) so we don't fight its temporary-enable. # (the flag stays set) so we don't fight its temporary-enable.
# The lock-free read is a fast path only; it can be a false
# negative (the watcher setting the flag just after it is read
# is seen next iteration), never a false positive that loses a
# request.
if self._pending_plugin_reconcile and not self.on_demand_active: if self._pending_plugin_reconcile and not self.on_demand_active:
# Only clear the flag on success -- a retryable failure self._service_pending_reconcile()
# (e.g. discovery) leaves it set so the request isn't lost.
if self._reconcile_enabled_plugins():
self._pending_plugin_reconcile = False
if not self.available_modes: if not self.available_modes:
# Nothing to render yet. Re-check _pending_plugin_reconcile # Nothing to render yet. Re-check _pending_plugin_reconcile
@@ -2901,6 +2907,26 @@ class DisplayController:
} }
return enabled_map(old_config) != enabled_map(new_config) return enabled_map(old_config) != enabled_map(new_config)
def _service_pending_reconcile(self) -> None:
"""Consume a pending reconcile request and run it.
The request is consumed BEFORE reconciling, not cleared after. Clearing
after would drop any config change that lands while reconcile is
running: reconcile has already read its config by then, so the clear
erases a request it never served and the newest config never
reconciles -- the same "your save did nothing" failure this whole path
exists to prevent. Consuming first means such a request stays set and
is picked up on the next pass.
A retryable failure (e.g. discovery) re-arms the flag.
"""
with self._reconcile_flag_lock:
pending = self._pending_plugin_reconcile
self._pending_plugin_reconcile = False
if pending and not self._reconcile_enabled_plugins():
with self._reconcile_flag_lock:
self._pending_plugin_reconcile = True
def _enabled_plugin_not_running(self, new_config: Dict[str, Any]) -> bool: def _enabled_plugin_not_running(self, new_config: Dict[str, Any]) -> bool:
"""True when a discovered plugin is enabled in config but not running. """True when a discovered plugin is enabled in config but not running.
@@ -385,3 +385,51 @@ class TestReconcileQueuedThroughSubscriber:
self._subscriber(controller)(old, new) self._subscriber(controller)(old, new)
assert controller._pending_plugin_reconcile is False assert controller._pending_plugin_reconcile is False
class TestPendingReconcileNotLost:
"""A config change arriving *during* reconcile must not be discarded.
The flag used to be cleared after a successful reconcile. Reconcile has
already read its config by then, so that clear erased a request it never
served and the newest config never reconciled -- the same "my save did
nothing" symptom this path exists to prevent.
"""
def test_request_arriving_during_reconcile_survives(self, test_display_controller):
controller = test_display_controller
controller._pending_plugin_reconcile = True
def reconcile_and_race():
# The watcher thread queues another change while we are mid-flight.
with controller._reconcile_flag_lock:
controller._pending_plugin_reconcile = True
return True
controller._reconcile_enabled_plugins = reconcile_and_race
controller._service_pending_reconcile()
assert controller._pending_plugin_reconcile is True, \
"a config change landing during reconcile was discarded"
def test_flag_cleared_on_a_quiet_success(self, test_display_controller):
controller = test_display_controller
controller._pending_plugin_reconcile = True
controller._reconcile_enabled_plugins = lambda: True
controller._service_pending_reconcile()
assert controller._pending_plugin_reconcile is False
def test_retryable_failure_rearms(self, test_display_controller):
controller = test_display_controller
controller._pending_plugin_reconcile = True
controller._reconcile_enabled_plugins = lambda: False
controller._service_pending_reconcile()
assert controller._pending_plugin_reconcile is True
def test_no_reconcile_when_nothing_pending(self, test_display_controller):
controller = test_display_controller
controller._pending_plugin_reconcile = False
calls = []
controller._reconcile_enabled_plugins = lambda: calls.append(1) or True
controller._service_pending_reconcile()
assert calls == []