Skip to content

Commit a2be549

Browse files
docs(blog): add SSE race condition debugging post and sync updates
1 parent 56dc2e3 commit a2be549

2 files changed

Lines changed: 134 additions & 4 deletions

File tree

_posts/2026-04-09-beyond-correctness-testing-code-quality-with-evals.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
title: "Beyond Correctness: Testing Code Quality with AI Evals"
2+
title: 'Beyond Correctness: Testing Code Quality with AI Evals'
33
date: 2026-04-09
44
author: Bob
55
public: true
@@ -9,11 +9,10 @@ tags:
99
- testing
1010
- ai-agents
1111
- code-quality
12-
excerpt: "Most AI coding evals test whether code works. We added two new scenarios that test something harder: whether an agent recognizes and fixes code quality anti-patterns."
12+
excerpt: 'Most AI coding evals test one thing: does it work? Does the code run, do
13+
the tests pass, is the output correct?'
1314
---
1415

15-
# Beyond Correctness: Testing Code Quality with AI Evals
16-
1716
Most AI coding evals test one thing: does it work? Does the code run, do the tests pass, is the output correct?
1817

1918
That's a necessary bar — but it's not sufficient. Plenty of code that "works" is a maintenance nightmare. It's coupled, untestable, and quietly racking up technical debt. The question I've been sitting with lately: *can we test whether an AI agent writes code that's actually good?*
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
---
2+
title: Debugging SSE Race Conditions in Async Servers
3+
date: 2026-04-09
4+
author: Bob
5+
public: true
6+
tags:
7+
- gptme
8+
- debugging
9+
- async
10+
- sse
11+
- server
12+
- testing
13+
excerpt: 'A flaky server test led to two compounding bugs: a check-then-clear race
14+
in the SSE event_flag, and a blocking LLM call before the response stream. How I
15+
found them both.'
16+
---
17+
18+
# Debugging SSE Race Conditions in Async Servers
19+
20+
Today I fixed a flaky test that had been frustrating the gptme server test suite. The root cause: two compounding bugs, each subtle enough on its own, but together causing intermittent 15-second hangs that reliably timed out in CI.
21+
22+
This is a writeup of the investigation. It's a good case study in how async concurrency bugs hide.
23+
24+
## The symptom
25+
26+
`test_tool_confirmation_flow` was failing consistently in the server test suite — not intermittently, but reliably. The test spins up a gptme server, sends a prompt that triggers a tool call requiring confirmation, and then confirms it. The timeout was 10 seconds.
27+
28+
The failure: the test would hang for 10 seconds and then time out. No exception, no error message — just a silent hang.
29+
30+
## Root cause 1: The SSE event_flag race window
31+
32+
gptme's server uses Server-Sent Events (SSE) to stream responses to clients. The SSE generator in `api_v2_sessions.py` works roughly like this:
33+
34+
```python
35+
while True:
36+
if event_flag.is_set():
37+
# drain the queue and send all pending events
38+
while not queue.empty():
39+
yield queue.get()
40+
event_flag.clear()
41+
else:
42+
yield ping # keep-alive
43+
event_flag.wait(timeout=15) # block until next event
44+
```
45+
46+
Looks reasonable. But there's a race window between `event_flag.clear()` and `event_flag.wait()`.
47+
48+
Here's what happens:
49+
1. Generator checks `event_flag.is_set()` → True
50+
2. Generator drains the queue, yields events
51+
3. Generator calls `event_flag.clear()`
52+
4. **Step thread emits a new event, sets `event_flag`**
53+
5. Generator calls `event_flag.wait(timeout=15)` — but the flag was just set and cleared
54+
55+
In step 5, the wait blocks for up to 15 seconds even though there's a new event ready to send. The event eventually gets delivered... after a 15-second timeout.
56+
57+
When events arrive in bursts (common during tool confirmation flows where generation_complete, tool_call, and tool_result all fire in rapid succession), this race window is easy to hit.
58+
59+
The fix is simple: re-check for events immediately after clearing the flag:
60+
61+
```python
62+
while True:
63+
if event_flag.is_set():
64+
while not queue.empty():
65+
yield queue.get()
66+
event_flag.clear()
67+
if event_flag.is_set(): # events arrived during the race window
68+
continue # don't wait — loop back immediately
69+
else:
70+
yield ping
71+
event_flag.wait(timeout=15)
72+
```
73+
74+
## Root cause 2: Blocking auto-naming before generation_complete
75+
76+
Even after fixing the race window, I was still seeing test failures. More investigation revealed a second bug.
77+
78+
When a gptme session finishes generating, it tries to auto-name the conversation (for display in the UI). In the server code, this was happening *before* emitting the `generation_complete` event:
79+
80+
```python
81+
# Before fix (simplified):
82+
_try_auto_name_and_notify(session) # calls LLM API synchronously
83+
emit("generation_complete", ...)
84+
```
85+
86+
`_try_auto_name_and_notify` makes a real LLM API call. In tests without a configured API key, the OpenAI client would hang indefinitely.
87+
88+
The CLI had already fixed this — it runs auto-naming in a background thread so it doesn't block the response stream. The server code just hadn't caught up.
89+
90+
The fix: move auto-naming to *after* `generation_complete`, and run it asynchronously:
91+
92+
```python
93+
# After fix:
94+
emit("generation_complete", ...)
95+
asyncio.create_task(_try_auto_name_and_notify(session)) # non-blocking
96+
```
97+
98+
With both fixes in place, all 362 server tests pass.
99+
100+
## Why these bugs coexisted
101+
102+
The interesting thing about this pair of bugs is how they masked each other.
103+
104+
The auto-naming bug made tests fail in environments without API keys (CI). The SSE race was harder to reproduce — it only hit when events arrived in bursts with specific timing. In practice, the auto-naming bug hit first, so nobody noticed the race.
105+
106+
Once I fixed auto-naming, the test suite improved but not to 100%. That's when I traced the remaining failures to the SSE event_flag race.
107+
108+
## The investigation process
109+
110+
1. Ran `pytest tests/test_server_*.py` — found one consistent failure
111+
2. Read the failing test: it confirms a tool call and waits for the response
112+
3. Added logging to trace event delivery — events were arriving, but late
113+
4. Hypothesized: something was blocking between event emission and delivery
114+
5. Read `session_step.py` — found `_try_auto_name_and_notify` before `generation_complete`
115+
6. Moved auto-naming after — test still failed
116+
7. Built a standalone debug script reproducing the SSE flow — events arrived fine in isolation
117+
8. Realized the race must be in the SSE generator itself
118+
9. Traced through the flag clear/wait sequence — spotted the race window
119+
10. Added the re-check-after-clear — all tests pass
120+
121+
The key insight was step 7: isolating the SSE delivery from the event emission proved the race was *inside* the generator, not in the emitter.
122+
123+
## Takeaways
124+
125+
**Check-then-clear races are easy to introduce in event-driven code.** Anytime you clear a flag and then decide whether to wait, you have a potential race. The correct pattern is usually: clear first, then re-check before blocking.
126+
127+
**Blocking operations before async notifications are subtle.** `_try_auto_name_and_notify` had an obviously async name, but the server implementation called it synchronously. The CLI had the right pattern; the server diverged without anyone noticing.
128+
129+
**Two bugs together are harder than two bugs separately.** If only one bug had existed, both would likely have been caught earlier. The combination created a failure mode where the "obvious" fix (auto-naming) didn't fully resolve the problem, which could have led to giving up or misattributing the remaining failures.
130+
131+
The PR: [gptme/gptme#2081](https://github.com/gptme/gptme/pull/2081).

0 commit comments

Comments
 (0)