fix(dashboard): serialize reload teardown under the switch locks (#9019) - #9337
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Reload joins the existing two-lock switch mechanism verbatim — root-cause fix, no new locking scheme, and the flipped tests were authored to be flipped. The two rewritten tests were explicit defect pins whose own docstrings said "invert them and drop the 'unguarded' wording" once reload serialized — flipping them is the pre-arranged fix signal, not the erasure of a decision. Lock order matches all five pre-existing sites, the guards awaited inside the locks are synchronous, and the new 409/ Suggestions
[DESIGN-REVIEWED] 45efb16 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All checks are complete. First-Principles-Verdict: CONCERNS A whole second feature — the fleet probe's Not justified as shipped
What this change shipsIntent: stop a dashboard slot reload from interleaving with a settings switch's commit-then-reset on the same session (#9019) — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 45efb16 |
85833b2 to
49ef790
Compare
self-added: no |
49ef790 to
e427b0a
Compare
self-added: yes |
|
Watch (the identity re-check lands only in reload; the 5 pre-existing
self-added: no |
e427b0a to
afa404c
Compare
self-added: yes |
api_chat_slot_reload tore down the slot's effective session while holding no lock, so a reload could interleave with a switch handler's commit-then-reset span on the same session. skip_if_busy=True does not cover that span: the atomic decline only fires while the session is BUSY, and the commit-then-reset span is exactly the idle window it misses. Reload now joins the same two locks the four commit-before-reset switch handlers hold, in the same order (slot._lock, then _slot_switch_session_lock(session_key) with session_key resolved inside slot._lock), across its probe-then-teardown. The two defect-pinning interleaving-seam tests are flipped to assert the serialized behavior. It also re-checks state._slots.get(name) is slot after EACH of its two lock-acquisition awaits, the same post-await re-authorization the tags/folders/regenerate handlers already carry. Both awaits are real suspension points where the slot can be recreated under the same name for a different app. Two further guards close the same class of gap at later await points. After the session-lock wait, effective_session_key(slot) != session_key answers 409/session_rebound: the identity checks alone miss a rebind on the SAME slot object, since effective_session_key reads the mutable slot.linked_session_key. And after the teardown call itself (which is its own await, retried once), the same effective_session_key check runs again: _bind_cron_slot writes linked_session_key with no lock of its own, so a rebind can land during the teardown's await just as easily as during the earlier lock waits, and reporting success there would be the same silent stale-session failure one await later. Five interleaving-seam tests pin these guards independently, each mutation-verified. The 5 pre-existing switch handlers share the identity-check gap (not the rebind gaps, which they already guard) and are not touched here; tracked as issue #9383 as a separable follow-up. Refs #8442 Co-authored-by: Kiro Crew <noreply@kiro.dev>
e14ad6d to
93f2eaa
Compare
The post-reset re-check at the end of api_chat_slot_reload's teardown re-derived only the session key, not slot identity, unlike the two earlier re-checks in the same handler (right after slot._lock and right after the session lock). Registry mutation (slot removal + same-name re-registration for a different app) takes no lock of its own, so it can land during the _reset_slot_session await exactly as it can during the two earlier lock-acquisition waits those checks guard -- and a same-named replacement whose session happens to resolve to the same key would pass a key-only re-check, letting the reload notice broadcast under the replacement's identity. The re-check now mirrors the two earlier ones: state._slots.get(name) is not slot first (404/slot_not_found, same as those two), then the existing session-key rebind check (409/session_rebound). One new mutation-verified regression test in test_slot_reset_interleaving_seam.py, hooking the same reset:pre_pop seam point the existing rebind-during-reset test uses, but swapping the slot object itself rather than mutating linked_session_key on the same object. Co-authored-by: Kiro Crew <noreply@kiro.dev>
Summary
Fixes #9019.
api_chat_slot_reloadtore down the slot's effective session while holding no lock, so a reload could interleave with a switch handler's commit-then-reset span on the same session. Nothing ordered the two: a switch could report success on a session that reload had already replaced, or vice versa.The contract decision
The issue left an open premise: is
skip_if_busy=Truealone sufficient, or must reload join the switch's session-keyed lock? The answer is (b) — reload must be serialized.skip_if_busy=Truedeclines atomically, but only while the session is BUSY, and a switch handler's commit-then-reset span is exactly the idle window in which that decline never fires. So the atomic decline cannot cover the span; reload has to take the lock.Changes
src/kiro_crew/dashboard/chat_handlers.pyapi_chat_slot_reloadnow wraps its probe-then-teardown in acontextlib.AsyncExitStackacquiring the same two locks the four commit-before-reset switch handlers hold, in the same order:slot._lock, then_slot_switch_session_lock(session_key)withsession_key = effective_session_key(slot)resolved insideslot._lock(resolving it earlier would guard the wrong session if a channel/cron rebind lands while the request queues).namecan be recreated for a different app, so each is immediately followed bystate._slots.get(name) is slotand a 404 on mismatch — the same re-authorization the tags/folders/regenerate handlers already do after their own await points (e.g.chat_tags.py). Without the first check, a stale request queued onslot._lockcould authorize a teardown against a same-named replacement slot; without the second, the same gap reopens across the session-lock wait (real contention exists there too, when a switch on the same session holds it).effective_session_key(slot) != session_keyis re-checked and answers 409/session_rebound. The slot-identity check alone is insufficient here —effective_session_keyreads the MUTABLEslot.linked_session_key, so a cron/channel rebind on the very same slot object (no swap, no recreation) can change what session the capturedsession_keynames while this request sits on the lock. Mirrors the switch handlers' own post-lockeffective_session_key(slot) != session_keyguard (e.g. the model/agent/effort switch handlers).effective_session_key(slot) != session_keycheck runs a THIRD time, after the teardown call (_reset_slot_session, including its one retry) completes, still inside both locks._bind_cron_slotwritesslot.linked_session_keywith no lock of its own, so a rebind is just as free to land during that specific await — one await later than the second guard's window — and reporting success there would be the same silent stale-session failure, just later.has_active_turn()409 fast path, the children 409 (_subagents_attached_response), the app-isolation denial, thereload:pre_resetseam point, and both_reset_slot_session(..., skip_if_busy=True)calls plus the retry-once 409 ladder now run under both locks.push_slots_update) runs after the locks release, unchanged from before.skip_if_busy=Truesemantics are unchanged apart from the new 409/session_reboundcase above. The only other behavioral change is ordering: a concurrent switch on the same session now serializes behind reload (and vice versa) instead of interleaving.test/test_slot_reset_interleaving_seam.pyTestReloadRacesSwitchCommitResetSpanwere flipped and renamed to assert the serialized (correct) behavior. Each suspends one racer at a seam point while it holds the session lock, launches the other, and asserts (via a bounded_yield_untilplus a "has not reached its own seam point" clause) that the second racer is blocked, then releases the first and asserts both finish in serialized order.slot._lockitself (reload queues on it before reaching its first re-check) and swaps in a same-named replacement slot, one holds the session lock directly viachat_handlers._slot_switch_session_lockand does the same swap after reload passes the first re-check, a third holds the session lock and instead mutatesslot.linked_session_keyon the SAME slot object (no swap) to pin the rebind guard, and a fourth hooks thereset:pre_popseam INSIDE_reset_slot_sessionitself to mutate the link mid-teardown, pinning the third guard. Each asserts reload refuses (404 for the two identity cases, 409/session_reboundfor the two rebind cases) rather than tearing down the wrong session.comment-history-baseline.jsontest/test_slot_reset_interleaving_seam.py's entry drops from the pre-existing 8 to 0 (pruned) as a direct consequence of rewriting this PR's own added comments/docstrings to present tense, per the repo's shrink-only comment-history ratchet (scripts/check_comment_history.py --write-baseline, which only lowers or prunes, never raises).Testing
All green:
test/test_slot_reset_interleaving_seam.py— 11 passed (including the two flipped tests and the four new identity/rebind tests)test/test_chat_slot_switch_atomicity.py— 88 passed (no regression to the switch handlers)test/test_dashboard_chat.py::TestSessionReload— 13 passed (reload's external contract unchanged)flake8,black --check(py310),mypy,isort— clean on both changed filesscripts/check_comment_history.py,scripts/check_sync_io_in_async.py,scripts/check_loop_bound_locks.py— cleanTest quality: reverting only the handler fix while keeping the flipped tests makes both tests fail, confirming they exercise the new lock serialization rather than static values. The reload span went from 0
async with(the bug) to 1 guarding both resets. All four identity/rebind tests were mutation-verified: removing any one of the four guards makes exactly its own test fail without affecting the other three.Refs #8442
Pattern harvest
Not generalizable: reload was the one teardown path outside
_slot_switch_session_lock(grepped_slot_switch_session_lock— 5 pre-existing sites already used the ExitStack two-lock template; grepped_reset_slot_session(— all other callers already ran under those locks). The fix joins the existing lock mechanism verbatim rather than introducing a new one. The four additions beyond the original diff — the twostate._slots.get(name) is slotre-checks and the twoeffective_session_key(slot) != session_keyrebind guards (one after the session lock, one after the teardown call itself) — are also not a new pattern: all four mirror guards the tags/folders/regenerate and switch handlers already carry elsewhere in this file; reload was simply the one handler in this family that had not been given the full set yet. The 5 pre-existing switch handlers share the identity-check gap (not the rebind gaps, which they already guard) and are not touched by this PR — tracked as #9383 rather than folded in here, since fixing them is a wider, separable change from what #9019 asked for.