Skip to content

Commit 2896295

Browse files
eberki-scaleclaude
andcommitted
fix(streaming): re-check _closed under lock to avoid stranding a racing delta
add() checked _closed before taking the lock, so a delta racing a Full-driven buffer close() could pass the check, then append after the buffer was drained and its ticker shut down — stranding the delta, never published. Re-check _closed under the lock before appending. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a75aa68 commit 2896295

2 files changed

Lines changed: 24 additions & 0 deletions

File tree

src/agentex/lib/core/services/adk/streaming.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,12 @@ async def add(self, update: StreamTaskMessageDelta) -> None:
177177
if self._closed:
178178
return
179179
async with self._lock:
180+
# Re-check under the lock: a concurrent close() (e.g. from a racing
181+
# Full) may have drained and shut down the ticker after the check
182+
# above but before we acquired the lock. Appending now would strand
183+
# the delta in a dead buffer, never published.
184+
if self._closed:
185+
return
180186
self._buf.append(update)
181187
self._buf_chars += _delta_char_len(update.delta)
182188
if not self._first_flushed or self._buf_chars >= self.MAX_BUFFERED_CHARS:

tests/lib/core/services/adk/test_streaming.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,24 @@ async def on_flush(u: StreamTaskMessageDelta) -> None:
355355
await buf.add(_text(task_message, "after"))
356356
assert flushed == []
357357

358+
@pytest.mark.asyncio
359+
async def test_add_racing_close_is_not_stranded(self, task_message: TaskMessage) -> None:
360+
"""TOCTOU: a delta that passes add()'s pre-lock _closed check but only
361+
acquires the lock after close() set _closed must be dropped, not appended
362+
to a drained, ticker-less buffer where it would never be published."""
363+
buf = CoalescingBuffer(on_flush=AsyncMock())
364+
buf.start()
365+
# Hold the lock so add() parks *after* its pre-lock _closed check.
366+
await buf._lock.acquire()
367+
add_task = asyncio.create_task(buf.add(_text(task_message, "racing")))
368+
await asyncio.sleep(0) # add() passes the _closed check, blocks on the lock
369+
buf._closed = True # close() wins the race
370+
buf._lock.release()
371+
await add_task
372+
373+
assert buf._buf == [], "racing delta was stranded in the closed buffer"
374+
await buf.close() # cleanup
375+
358376

359377
class TestCoalescingBufferCloseDuringFlush:
360378
@pytest.mark.asyncio

0 commit comments

Comments
 (0)