Skip to content

fix(python): stop the async stream dropping its terminal sentinel - #623

Open
ayaangazali wants to merge 1 commit into
RunanywhereAI:mainfrom
ayaangazali:bugfix/python-async-stream-sentinel-drop
Open

fix(python): stop the async stream dropping its terminal sentinel#623
ayaangazali wants to merge 1 commit into
RunanywhereAI:mainfrom
ayaangazali:bugfix/python-async-stream-sentinel-drop

Conversation

@ayaangazali

@ayaangazali ayaangazali commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What

_AsyncBridge signals both completion and failure with a bare put_nowait posted to the loop thread:

def _on_done(self) -> None:
    self._loop.call_soon_threadsafe(self._q.put_nowait, _DONE)

def _on_error(self) -> None:
    self._loop.call_soon_threadsafe(self._q.put_nowait, _ERROR)

The queue is bounded (maxsize defaults to 64). When it is saturated at that moment, put_nowait raises asyncio.QueueFull inside the loop callback, where the event loop's exception handler logs it and moves on. The sentinel is simply gone, and nothing else will post one.

aiter_tokens is then parked on await bridge.get() forever, so the caller's async for never ends and the worker thread is never joined.

Why this is an oversight rather than a choice

Every sibling path in the same file already handles it:

  • Tokens, same class: _enqueue catches QueueFull and reschedules itself until the consumer drains. That is the file's own backpressure mechanism, so the author knew the queue fills.
  • Sync bridge: _on_done / _on_error use a blocking self._q.put(...), which waits for space and cannot lose the sentinel.

The async terminal path was the only one that could drop its message. This routes both sentinels through the same reschedule, bailing out when _stop is set, since by then the consumer has gone and there is nobody to hand it to.

The trigger is ordinary: a consumer slower than the producer, which is the normal case for a local model feeding UI or I/O work per token.

Testing

From sdk/runanywhere-python (Python 3.9, pytest 8.4.2):

  • Full suite: 374 passed, 21 skipped, plus one pre-existing failure, test_package_import.py::test_version_string, which raises PackageNotFoundError: runanywhere because the package is not pip-installed in my environment. It fails identically with my change stashed, so it is unrelated.
  • Added one regression test, checked both ways. With the fix, 8 passed. With the source change reverted it fails, and the captured log shows the exact mechanism:
ERROR asyncio: Exception in callback Queue.put_nowait(<object ...>)
asyncio.queues.QueueFull

I first wrote this test at the aiter_tokens level and it passed without the fix, because a promptly draining consumer frees a slot before the sentinel lands. It only reproduces deterministically by saturating the queue and driving _on_done directly, so that is what the committed test does.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved asynchronous streaming reliability when the queue is full.
    • Ensured completion and error signals are delivered without leaving consumers waiting indefinitely.
    • Prevented terminal signals from being sent after streaming has stopped.
  • Tests

    • Added regression coverage for completion delivery under queue pressure.

_AsyncBridge signals completion and failure with a bare put_nowait posted
to the loop thread:

    self._loop.call_soon_threadsafe(self._q.put_nowait, _DONE)

The queue is bounded (maxsize defaults to 64), so when it is saturated at
that moment put_nowait raises QueueFull inside the callback, where the
loop's exception handler logs and swallows it. The sentinel is gone, and
aiter_tokens is left awaiting get() with nothing else to post, so the
async for never ends.

The token path already guards against exactly this: _enqueue catches
QueueFull and reschedules itself until the consumer drains. The sync
bridge is safe too, because it uses a blocking put. The async terminal
path was the only one that could lose its message.

Route both sentinels through the same reschedule, bailing out when stop
is set since the consumer is gone by then.

Signed-off-by: ayaangazali <ayaangazali.work@gmail.com>
Copilot AI lite review requested due to automatic review settings August 4, 2026 17:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The async streaming bridge now retries completion and error sentinel insertion when its bounded queue is full. Sentinel delivery stops after the bridge stops. A regression test verifies completion delivery after queue saturation.

Changes

Async streaming bridge

Layer / File(s) Summary
Retry terminal sentinel delivery
sdk/runanywhere-python/runanywhere/_streaming.py, sdk/runanywhere-python/tests/test_streaming.py
_on_done and _on_error schedule a retrying sentinel helper. The helper skips delivery after stopping. The regression test verifies that _DONE is eventually received after the queue is drained.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested reviewers: copilot, amanswar

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the bug and testing, but it omits the template sections for change type, labels, checklist, and screenshots. Add the required template sections and mark applicable change type, testing, labels, checklist, and screenshot items.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the Python async stream bug and the fix for dropped terminal sentinels.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
sdk/runanywhere-python/tests/test_streaming.py (1)

11-11: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover _ERROR under queue saturation.

Line 11 imports _DONE, and Lines 145-168 invoke only _on_done. Add a matching full-queue case that calls _on_error and asserts _ERROR. Otherwise, a regression in failure signaling can still leave aiter_tokens waiting indefinitely.

Also applies to: 143-168

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/runanywhere-python/tests/test_streaming.py` at line 11, Extend the
streaming tests around _AsyncBridge and the existing _on_done full-queue case to
cover _on_error when the queue is saturated: invoke the error callback, then
assert that the queued sentinel is _ERROR and that aiter_tokens does not remain
waiting. Reuse the imported _ERROR symbol and preserve the existing
completion-case assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@sdk/runanywhere-python/runanywhere/_streaming.py`:
- Around line 211-226: Update _enqueue_sentinel and the related _enqueue retry
path to await queue capacity or use a cancellable delayed retry instead of
immediately rescheduling on asyncio.QueueFull. Ensure pending sentinel/token
delivery is cancelled when _stop is set or the bridge stops, while preserving
delivery when the consumer remains active.

---

Nitpick comments:
In `@sdk/runanywhere-python/tests/test_streaming.py`:
- Line 11: Extend the streaming tests around _AsyncBridge and the existing
_on_done full-queue case to cover _on_error when the queue is saturated: invoke
the error callback, then assert that the queued sentinel is _ERROR and that
aiter_tokens does not remain waiting. Reuse the imported _ERROR symbol and
preserve the existing completion-case assertions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2e194f46-2d80-49e2-b3ec-402b4dff1036

📥 Commits

Reviewing files that changed from the base of the PR and between 4bae01f and 5c32ffe.

📒 Files selected for processing (2)
  • sdk/runanywhere-python/runanywhere/_streaming.py
  • sdk/runanywhere-python/tests/test_streaming.py

Comment on lines +211 to +226
def _enqueue_sentinel(self, sentinel: object) -> None:
"""Run on the loop thread: push a terminal sentinel, rescheduling while the queue
is full so it is never dropped.

A bare ``put_nowait`` raises :class:`asyncio.QueueFull` inside the loop callback,
where it is swallowed by the exception handler and the consumer waits on ``get()``
forever. ``_enqueue`` already reschedules tokens for the same reason, and the sync
bridge gets this for free from its blocking ``put``. Stop means the consumer is
gone, so there is nobody left to hand the sentinel to.
"""
if self._stop.is_set():
return
try:
self._q.put_nowait(sentinel)
except asyncio.QueueFull:
self._loop.call_soon(self._enqueue_sentinel, sentinel)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files:\n'
git ls-files | rg 'sdk/runanywhere-python/runanywhere/_streaming\.py|sdk/runanywhere-python/runanywhere/__init__\.py$|sdk/runanywhere-python/runanywhere/VERSION' || true

printf '\nFile outline:\n'
ast-grep outline sdk/runanywhere-python/runanywhere/_streaming.py --view expanded || true

printf '\nRelevant section:\n'
cat -n sdk/runanywhere-python/runanywhere/_streaming.py | sed -n '1,280p'

printf '\nSearch close/stop/usages:\n'
rg -n "_enqueue_sentinel|_stop|close\\(|_q\\.|call_soon|QueueFull|backpressure|stop" sdk/runanywhere-python/runanywhere/_streaming.py

Repository: RunanywhereAI/runanywhere-sdks

Length of output: 18529


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Python queue behavior probe:\n'
python3 - <<'PY'
import asyncio, queue

async def immediate_reschedule_queuefull():
    ev = asyncio.Event()
    calls = 0

    def callback():
        nonlocal calls
        calls += 1
        q = queue.Queue(maxsize=1)
        q.put_nowait("x")
        try:
            q.put_nowait("sentinel")
        except queue.Full:
            # Same callback scheduled immediately when queue remains full.
            asyncio.get_running_loop().call_soon(callback)
            return
        ev.set()

    # Limit iterations to avoid an unbounded run in sandbox.
    for _ in range(100):
        callback()

    print({"calls": calls, "ev_set": ev.is_set(), "full": queue.Queue(maxsize=1).put_nowait("x") or (queue.Queue(maxsize=1).full() if True else None)})

def run():
    ev = asyncio.Event()
    calls = 0

    def callback():
        nonlocal calls
        calls += 1
        q = queue.Queue(maxsize=1)
        q.put_nowait("x")
        try:
            q.put_nowait("sentinel")
        except queue.Full:
            loop.call_soon(callback)
            return
        ev.set()

    loop = asyncio.new_event_loop()
    loop.call_soon(callback)
    while loop._ready and calls < 200:
        loop._run_once()
    loop.close()
    print({"calls": calls, "ev_set": ev.is_set(), "full": queue.Queue(maxsize=1).full()})

run()
PY

printf '\nasyncio.Queue.put_nowait behavior probe:\n'
python3 - <<'PY'
import asyncio

async def main():
    q = asyncio.Queue(maxsize=1)
    await q.put("x")
    try:
        q.put_nowait("sentinel")
    except Exception as ex:
        print(type(ex).__name__, str(ex), isinstance(ex, asyncio.QueueFull))
    print("full:", q.full())
    q.get_nowait()
    print("after put_nowait:", await q.get())

asyncio.run(main())
PY

printf '\nCheck asyncio.Queue.put and wait_for semantics from installed stdlib source if unavailable:\n'
python3 - <<'PY'
import asyncio, inspect, sys
print(sys.version)
print(inspect.getsource(asyncio.Queue.put).replace("\n", "\n"))
print("--- get ---")
print(inspect.getsource(asyncio.Queue.get))
PY

Repository: RunanywhereAI/runanywhere-sdks

Length of output: 160


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Bounded immediate reschedule probe with step limit:\n'
python3 - <<'PY'
import asyncio
import queue

def immediate_reschedule_queuefull():
    loop = asyncio.new_event_loop()
    calls = 0

    def callback():
        nonlocal calls
        calls += 1
        q = queue.Queue(maxsize=1)
        q.put_nowait("x")
        try:
            q.put_nowait("sentinel")
        except queue.Full:
            loop.call_soon(callback)
            return
        q.put_nowait("sentinel")

    loop.call_soon(callback)
    while loop._ready and calls <= 5:
        loop._run_once()
    loop.close()

    print({"calls": calls, "sentinel_received": calls > 1})

immediate_reschedule_queuefull()

python_version = __import__("sys").version
print({"python_version": python_version})
import asyncio, inspect
print({"has_getsource": bool(inspect.getsource(asyncio.Queue.put))})
print(inspect.getsource(asyncio.Queue.put))
print("--- get ---")
print(inspect.getsource(asyncio.Queue.get))
PY

Repository: RunanywhereAI/runanywhere-sdks

Length of output: 2506


Wait instead of immediately rescheduling full-queue retries.

_enqueue() and _enqueue_sentinel() both call put_nowait() inside the event-loop callback and reschedule with call_soon on asyncio.QueueFull. The queue becomes full, _stop stays clear, and the callback is scheduled again before any consumer action can remove items. If the queue stays full, this repeatedly schedules the same callback. Use await _q.put(sentinel) or a cancellable delayed retry, and cancel pending delivery when the bridge stops.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/runanywhere-python/runanywhere/_streaming.py` around lines 211 - 226,
Update _enqueue_sentinel and the related _enqueue retry path to await queue
capacity or use a cancellable delayed retry instead of immediately rescheduling
on asyncio.QueueFull. Ensure pending sentinel/token delivery is cancelled when
_stop is set or the bridge stops, while preserving delivery when the consumer
remains active.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants