fix(apps): serialize the builtin config sync against both config writers - #6984
Conversation
_sync_builtin_config is a read-modify-write of the same config.json the legacy dashboard writers (agents endpoint, updates.py, security.py, messaging.py, mcp.py, core.py STT) mutate while holding ONLY the loop-side _get_config_lock. Both enable and disable offloaded it with a bare asyncio.to_thread, so the write held just the sidecar advisory flock that update_config_locked takes -- which excludes nothing that family respects. A settings PUT landing mid-write commits from a snapshot taken before it, silently reverting the enabled flag the handler just persisted, or losing the user's settings. config/loader.py states the rule directly: such a caller must ALSO hold the in-process asyncio lock. Dispatch both sites through the existing run_config_write, which is the one entry point holding both generations and still hands the blocking work (the flock wait, and on Windows the owner-only lockdown's possible SMB round-trip) to a worker -- so the off-loop property the to_thread was there for is unchanged. No new lock and no new abstraction. Lock order is app_lifecycle_lock -> config lock, matching handle_app_uninstall in the same file, which already nests them that way for the same reason. The reverse order was checked across the whole tree before making this change: 14 functions acquire app_lifecycle_lock and none of them is reachable from inside a config-lock block, so no inversion exists to deadlock against. Three behavioural tests probe _get_config_lock().locked() from inside the worker on both the enable and disable paths, and assert the lock is released afterwards -- red-before is `assert False is True` on both. A static ratchet names the offending line numbers if either dispatch regresses to a bare offload, plus a self-check proving the ratchet can still fail. The existing shape test is updated to the stronger dispatcher rather than left passing by accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of All claims in the description verified against the base tree: Design-Verdict: PASS Real silent-lost-update defect, closed by routing through the repo's existing both-locks dispatcher — minimal, precedent-matching, trivially reversible. [DESIGN-REVIEWED] afe5aa9 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsFINDING -- src/kiro_crew/apps/routes.py:1627 -- function-local |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All claims verified against the base. I have everything needed for the verdict: the fix is real and routes through the existing 27-consumer helper; one sibling one-lock writer remains ( First-Principles-Verdict: CONCERNS The lock fix earns its place, but it ships three guards for one property, and one counted sibling of the same defect stays unfixed. What this change shipsIntent: stop a concurrent dashboard settings save from silently reverting a builtin app's enable/disable toggle — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] afe5aa9 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (2 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: fix — routes the builtin-config enable/disable sync through run_config_write (holds both the loop-side config lock and the sidecar flock) instead of a bare asyncio.to_thread, so a concurrent settings PUT can no longer commit from a stale snapshot and silently revert the app's enabled flag; keeps the blocking work off the event loop. CodeQL is not applicable on this fork PR (default-setup emits no check-run); SAST coverage is Semgrep only, latest run success with 0 annotations.
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (2 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: routes the builtin enable/disable config.json sync through run_config_write instead of a bare asyncio.to_thread, so it holds the loop-side config lock as well as the sidecar flock and no longer loses updates against the legacy dashboard writers. CodeQL is not applicable on this fork PR (default-setup emits no check-run); SAST coverage is Semgrep only, latest run success with 0 annotations.
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (2 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: routes the builtin config sync through run_config_write instead of a bare asyncio.to_thread, so the read-modify-write of config.json holds BOTH the loop-side config lock and the sidecar flock -- a concurrent settings PUT can no longer commit from a pre-write snapshot and silently revert the app's enabled flag; lock order app_lifecycle_lock -> config lock matches the existing handle_app_uninstall nesting, and the blocking work still runs off the loop. CodeQL is not applicable on this fork PR (default-setup emits no check-run); SAST coverage is Semgrep only, latest run success with 0 annotations.
…writers `POST /api/update/auto` persisted the flag with a bare `asyncio.to_thread(update_config_locked, ...)`. That holds only the sidecar advisory flock, which excludes the CLI and a second gateway but not the legacy dashboard writers -- core.py's theme/settings PUT, the agents endpoint, security.py, messaging.py, mcp.py, computer_use.py -- which read-modify-write the same config.json holding only the loop-side `_get_config_lock`. So a theme save landing between this endpoint's read and its write commits from a snapshot taken before it and silently reverts the auto-update flag the user just toggled, or this write reverts their theme. Nothing errors; the response is built from `enabled`, not from a re-read of what landed. Route it through `run_config_write`, the one entry point that holds both generations. It takes the loop-side lock on the event loop and runs the blocking writer in a worker, so the flock wait still never stalls the loop. Nothing here holds a config lock already, so no nesting is introduced, and the helper propagates the writer's exceptions unchanged so the fail-closed 500 arm is untouched. Sibling of kirodotdev#6984, which made the same correction in apps/routes.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Rebased onto main by Kiro Crew (conflict in test_config_rmw_preserves_settings.py resolved by keeping both appended test classes); original work by Leon (leonlaiyc). Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
…writers `POST /api/update/auto` persisted the flag with a bare `asyncio.to_thread(update_config_locked, ...)`. That holds only the sidecar advisory flock, which excludes the CLI and a second gateway but not the legacy dashboard writers -- core.py's theme/settings PUT, the agents endpoint, security.py, messaging.py, mcp.py, computer_use.py -- which read-modify-write the same config.json holding only the loop-side `_get_config_lock`. So a theme save landing between this endpoint's read and its write commits from a snapshot taken before it and silently reverts the auto-update flag the user just toggled, or this write reverts their theme. Nothing errors; the response is built from `enabled`, not from a re-read of what landed. Route it through `run_config_write`, the one entry point that holds both generations. It takes the loop-side lock on the event loop and runs the blocking writer in a worker, so the flock wait still never stalls the loop. Nothing here holds a config lock already, so no nesting is introduced, and the helper propagates the writer's exceptions unchanged so the fail-closed 500 arm is untouched. Sibling of kirodotdev#6984, which made the same correction in apps/routes.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Rebased onto main by Kiro Crew (conflict in test_config_rmw_preserves_settings.py resolved by keeping both appended test classes); original work by Leon (leonlaiyc). Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
Problem / Motivation
Enabling or disabling a builtin gateway app writes
config.jsonthrough_sync_builtin_config, and both call sites offloaded it with a bareasyncio.to_thread:That offload is correct about the event loop and wrong about exclusion.
_sync_builtin_configperforms a read-modify-write of the mainconfig.jsonunderupdate_config_locked, which takes only the sidecar advisory flock.config.jsonhas two writer generations that do not exclude each other:update_config_lockedtakes the sidecar flock (CLI, boot refresh, other processes).updates.py,security.py,messaging.py,mcp.py,core.pySTT — take the loop-side_get_config_lockasyncio lock alone.Holding only the flock excludes nothing that second family respects. A settings PUT that lands between this handler's read and its write commits from a snapshot taken before it — silently reverting the
enabledflag the user just toggled, or losing whatever settings they changed.config/loader.pystates the rule directly: a caller running while the dashboard serves requests must also hold the in-process asyncio lock.Why it matters
The lost update is silent and bidirectional. Either the app comes back enabled after the user disabled it, or an unrelated settings write is reverted by an app toggle. Nothing errors, nothing is logged, and the UI reports success in both directions — the response is built from the handler's own result, not from a re-read of what actually landed on disk.
It is also reachable by ordinary use rather than by a race a user has to provoke: enabling an app and saving a setting are both routine dashboard actions, and the write window includes a filesystem lock wait plus, on Windows, an owner-only DACL application that can cost an unbounded SMB round-trip on a network-homed data home.
What changed (motivation → approach → change)
Symptom → a config write that can be silently reverted. Root cause → the dispatch holds one of the two locks that guard
config.json. Change → route both sites through the entry point that holds both.src/kiro_crew/apps/routes.py:dashboard.chat_utils.run_config_writeis the repository's existing helper for exactly this — no new lock and no new abstraction is introduced here. It acquires the loop-side_get_config_lock, then runs the sync in a worker thread, so the flock wait never blocks the loop. The off-loop property theto_threadexisted for is unchanged; what is added is exclusion against the legacy family.Lock ordering was proven before the change, not assumed. The new nesting is
app_lifecycle_lock→ config lock, which is already whathandle_app_uninstalldoes in this same file (routes.py:1348) for the same reason. The reverse order was checked across the whole tree by walking everywith/async withblock: 14 functions acquireapp_lifecycle_lock, and none of them is reachable from inside a_get_config_lock/run_config_writeblock. There is no inversion to deadlock against, and the only existing nesting anywhere insrc/kiro_crewis the forward one.The import is call-time, matching the
_get_config_lockimport a few hundred lines above and for the same documented reason:appssits belowdashboardin the package tree, and no module insrc/kiro_crew/apps/importskiro_crew.dashboardat load time. This is layering, not a circular-import claim.Deliberately unchanged: the error contract (
OSErrorstill degrades to a warning on the response, because the config write is not the point of the request), the restart notification, and_sync_builtin_configitself.Tests
test/test_builtin_app_lifecycle.py:test_disable_holds_the_loop_side_lock_across_the_writeandtest_enable_holds_the_loop_side_lock_across_the_write— behavioural, not shape. Each patches_sync_builtin_configwith a probe that records_get_config_lock().locked()and its own thread identity from inside the worker, then drives the real handler. Both assert the lock is held and that the work is off the event loop, so a fix that took the lock by moving the write back onto the loop would fail too.test_the_loop_side_lock_is_released_afterwards— holding it is only correct if the handler gives it back; also asserts the flag actually landed on disk.TestBuiltinConfigDispatchRatchet— a static AST guard that fires only when the callable being offloaded is_sync_builtin_configitself, so ordinaryasyncio.to_threaduse elsewhere in the module stays legal. Its failure names the exact line numbers.test_the_ratchet_can_actually_fail— feeds the ratchet's own predicate the shape it exists to reject, so a scan that silently stopped matching cannot pass vacuously.Red-before, with only the two production lines reverted and the tests in place:
assert False is Trueon both enable and disable — the loop-side lock is not held;assert not [1603, 1699]— the two offending sites named;assert 0 == 2.test_async_call_sites_offload_off_the_event_loopalready existed and pinned the weakerasyncio.to_threadspelling. It is updated rather than left passing by accident: it now pins the stronger dispatcher, and its docstring says why the property it guards is unchanged.Green on this head:
test_builtin_app_lifecycle.py,test_apps_routes_coverage.py,test_lifecycle_hooks.py,test_enable_deps_resolution.py,test_apps_instances_loop_offload.py— 263 passed, 2 skipped, 0 failed.Gates green:
mypy --platform linuxon the changed module, the baselined black gate (2 files in scope, no new offenders), isort, flake8. The full backend suite was not run locally; CI runs it authoritatively.Manual verification
N/A — unit coverage sufficient: the defect is a lock-holding property, and the tests observe that property directly from inside the worker thread that performs the write, on both the enable and the disable path. A manual reproduction would require winning a race that the probe asserts deterministically.
Related Issues
No filed issue. The residual was counted by the First Principles review on #4550, which fixed the same one-lock-only defect for the registry PUT in this module and explicitly left these two sites out of scope. This closes them as their own change rather than widening that PR.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement