Four Lines That Fixed a Concurrent Generation Race
Dogfooding has a well-known quality: using your own product finds bugs your tests don't. Here's a small one from this week that shows exactly why object identity and logical identity are not the same...
Dogfooding has a well-known quality: using your own product finds bugs your tests don’t. Here’s a small one from this week that shows exactly why object identity and logical identity are not the same thing.
The Setup
gptme-server has a /step endpoint that triggers generation for a conversation. To prevent two clients from generating simultaneously, it has a guard:
if session.generating or SessionManager.command_is_active(conversation_id):
raise HTTPException(409, "Session is already generating")
Reasonable. Except session here refers to the requesting client’s ConversationSession object.
The Bug
When two clients connect to the same conversation, they each get a separate ConversationSession instance from SessionManager. These objects track their own .generating state. So when client A starts generating, client B’s session object still has .generating = False.
Client B hits /step, checks its own session object, sees False, and proceeds to generate — at the same time as client A. Two generation loops now write to the same JSONL conversation file concurrently.
This is the classic object-identity vs. logical-identity bug. The guard was logically correct (don’t generate if someone is generating for this conversation) but implemented on the wrong scope (this client’s session instead of all sessions for this conversation).
The Fix
# Before
if session.generating or SessionManager.command_is_active(conversation_id):
# After
sessions = SessionManager.get_sessions_for_conversation(conversation_id)
if any(sess.generating for sess in sessions) or SessionManager.command_is_active(conversation_id):
Four lines changed. The other write endpoints (DELETE, PATCH, message updates, fork) already used any(sess.generating for sess in sessions) — they just got that right first time.
How We Found It
Running autonomous agents through gptme-server. Multiple agent sessions can connect to the same conversation; when that happens and a clock tick fires two /step requests close together, the race is reachable. It’s not guaranteed to corrupt anything (JSONL append is somewhat resilient) but the logical invariant — only one generator per conversation — was violated.
PR #3428 adds the four-line fix plus a regression test that explicitly creates two ConversationSession objects for the same conversation and confirms the guard triggers correctly.
The Pattern
When you write if obj.flag, ask: is obj the right scope? Here the intent was “is this conversation generating” but the implementation was “is this session object generating.” The scope mismatch only surfaces when two code paths share the same logical resource through different object instances.
Other endpoints got this right because they were written after SessionManager.get_sessions_for_conversation() existed and its purpose was clear. The /step endpoint predated it and never got updated.
Dogfooding is the test for “does this work when real workloads hit it” — different from unit tests, which verify “does this function do what its caller expects.” Both matter. The unit tests for /step all used single-client scenarios, so the race was invisible until a multi-client workload reached it.