onboard: per-instance asyncio.Lock + typed lifecycle exceptions on ConversationEngine#151
Open
Antawari wants to merge 1 commit into
Open
onboard: per-instance asyncio.Lock + typed lifecycle exceptions on ConversationEngine#151Antawari wants to merge 1 commit into
Antawari wants to merge 1 commit into
Conversation
…nversationEngine
Two defects rooted at the same class:
1. handle_answer awaits emit() (which awaits broadcast() → asyncio.gather)
before incrementing _turn. A second WS message arriving while the first
handle_answer is suspended inside the first emit reads the same stale
_turn — the analyzer fires twice for the same question, _turn double-
advances, and the question-emission sequence can skip Q2 or Q3. With
three answers required for completion, a double-click can leave the
engine permanently broken for that session.
2. Lifecycle violations (handle_answer before start, handle_answer after
completion) raised bare RuntimeError. The WS handler's `except Exception`
swallowed them without producing a typed error frame the browser
could surface to the user.
## Fix
src/bonfire/onboard/conversation.py:
- Added `_lock: asyncio.Lock = field(default_factory=asyncio.Lock)` to
ConversationEngine. Each instance gets its own Lock — `default_factory`
runs per instance, not per class definition, so unrelated conversations
don't serialize against each other.
- handle_answer now wraps its full body in `async with self._lock:` —
the await points inside the body can't be raced by a sibling call.
- Defined two typed exceptions, both subclassing RuntimeError for
backward-compat with existing bare-`except RuntimeError` catchers
upstream in the WS handler:
* ConversationNotStarted (handle_answer called before start)
* ConversationAlreadyComplete (handle_answer called after Q3 answered)
- Updated `__all__` to export the new exception types.
## Tests
tests/unit/test_onboard_conversation_concurrency.py (new · Knight RED):
Seven tests across three classes:
- TestLockPresence (2): asyncio.Lock attribute exists; per-instance not
shared.
- TestHandleAnswerAcquiresLock (2):
* handle_answer blocks when the lock is held externally (asserts via
asyncio.wait_for timeout).
* two concurrent handle_answer calls emit questions in strict Q1→Q2→Q3
order (pre-fix this races and skips questions).
- TestTypedLifecycleExceptions (3):
* handle_answer before start raises ConversationNotStarted.
* handle_answer after completion raises ConversationAlreadyComplete.
* both typed exceptions subclass RuntimeError (back-compat guard).
## Out of scope (filed for follow-up PR)
WS handler integration — catching ConversationNotStarted /
ConversationAlreadyComplete specifically in src/bonfire/onboard/server.py
and emitting a typed error frame to the browser. Deferred to avoid file
overlap with the in-flight front-door hardening PR which also touches
server.py.
## Verification
pytest tests/unit/test_onboard_conversation_concurrency.py
7 passed (Knight RED → GREEN verified)
pytest tests/unit/test_onboard_server.py (regression)
19 passed
ruff check + format on changed files: clean
Co-Authored-By: Claude Opus 4.7 (1M context) <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.
Summary
Two defects rooted at the same class, both load-bearing on the host-only Front Door:
1. Back-to-back
handle_answercalls corrupt_turnhandle_answerawaitsemit(...)(which awaitsbroadcast(...)→asyncio.gather) before incrementing_turn. A second WS message arriving while the first call is suspended inside the firstemitreads the same stale_turn— the analyzer fires twice for the same question,_turndouble-advances, and the question-emission sequence can skip Q2 or Q3. With three answers required for completion, a single user double-click can push the engine intois_complete=Trueafter Q2; subsequent legitimate answers raise the bareRuntimeError("Conversation is already complete.")and the WS handler silently logs without recovery — the engine is permanently broken for that session.2. Lifecycle violations raise bare
RuntimeErrorhandle_answerbeforestart()andhandle_answerafter completion both raisedRuntimeError("..."). The WS handler's broadexcept Exceptionswallowed them without producing a typed error frame the browser could surface to the user.Changes
src/bonfire/onboard/conversation.py_lock: asyncio.Lock = field(default_factory=asyncio.Lock)toConversationEngine. Each instance gets its own Lock —default_factoryruns per instance, not per class definition, so unrelated conversations don't serialize against each other.handle_answernow wraps its full body inasync with self._lock:— the await points inside the body can't be raced by a sibling call.RuntimeErrorfor backward-compat with existing bare-except RuntimeErrorcatchers upstream in the WS handler:ConversationNotStarted(handle_answercalled beforestart)ConversationAlreadyComplete(handle_answercalled after Q3 answered)__all__to export the new exception types.tests/unit/test_onboard_conversation_concurrency.py(new · Knight RED)Seven tests across three classes:
_lockattribute isasyncio.Lockdefault_factorydisciplinehandle_answerblocks when lock heldConversationNotStartedConversationAlreadyCompleteRuntimeErrorTDD verification
ConversationAlreadyComplete/ConversationNotStartedwhich don't exist pre-fix — the strongest form of structural RED).tests/unit/test_onboard_server.py— 19/19 still green.ruff check+ruff format --checkon changed files: clean.Out of scope (filed for follow-up PR)
WS handler integration — catching
ConversationNotStarted/ConversationAlreadyCompletespecifically insrc/bonfire/onboard/server.pyand emitting a typed error frame to the browser. Deferred to avoid file overlap with the in-flight front-door hardening PR (which also touchesserver.py).Test plan
pytest tests/unit/test_onboard_conversation_concurrency.py— confirm 7/7 green.pytest tests/unit/test_onboard_server.py— confirm 19/19 still green (no regression).🤖 Generated with Claude Code