Skip to content

fix(async): avoid asyncio.timeout on PubSub poll path (#3748) - #4299

Open
lllakshit wants to merge 4 commits into
redis:masterfrom
lllakshit:fix/3748-async-pubsub-avoid-async-timeout
Open

fix(async): avoid asyncio.timeout on PubSub poll path (#3748)#4299
lllakshit wants to merge 4 commits into
redis:masterfrom
lllakshit:fix/3748-async-pubsub-avoid-async-timeout

Conversation

@lllakshit

@lllakshit lllakshit commented Aug 31, 2026

Copy link
Copy Markdown

Summary

Fixes #3748

Async PubSub polling was entering asyncio.timeout() on every get_message(timeout=0) / small-timeout call, which can trigger RuntimeError: list changed size during iteration in the event loop's callback heap under tight polling loops.

This mirrors the existing sync PubSub path:

  • can_read(timeout) waits for readability (or returns immediately when timeout=0) without scheduling asyncio.timeout(0)
  • Only reads when data is available; timeout=0 parses buffered bytes only so partial RESP frames cannot block
  • Non-blocking polls yield via asyncio.sleep(0) when no data is buffered

Test plan

  • test_pubsub_timeout_zero_avoids_async_timeout_zero
  • test_get_message_timeout_zero_yields_event_loop
  • Parser buffered_only unit tests
  • test_async_connection_can_read_eof_after_wait_raises
  • CI

Closes #3748

Mirror the sync PubSub read path: check can_read(timeout) before read_response so non-blocking get_message() polling does not schedule asyncio.timeout(), which can corrupt the event loop callback heap under tight PubSub loops.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6d2ef91e91

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread redis/asyncio/client.py Outdated
if not block:
if not await conn.can_read(timeout=timeout):
return None
read_timeout = math.inf

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the finite timeout while parsing the response

When a finite get_message(timeout=...) receives only the first bytes of a RESP frame, can_read() succeeds and this changes the subsequent parse to math.inf; the parser can then wait indefinitely for the rest of a fragmented or slow response instead of returning None when the requested timeout expires. Before this change, the timeout covered the whole parse, and the sync PubSub path still passes the finite timeout through to read_response.

AGENTS.md reference: AGENTS.md:L146-L149

Useful? React with 👍 / 👎.

Comment thread redis/asyncio/client.py Outdated
# without scheduling asyncio.timeout(), which can corrupt the
# event loop callback heap under tight PubSub polling (#3748).
if not block:
if not await conn.can_read(timeout=timeout):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep readiness checks inside the PubSub retry operation

If a peer closes after subscription, can_read() can raise ConnectionError when its parser observes EOF. This await now occurs before _execute, so the configured conn.retry.call_with_retry path never reconnects and resubscribes; previously the read was entirely inside _execute, and the sync path likewise performs readiness checking inside its retried callback.

AGENTS.md reference: AGENTS.md:L146-L149

Useful? React with 👍 / 👎.

Comment thread redis/asyncio/client.py Outdated
# without scheduling asyncio.timeout(), which can corrupt the
# event loop callback heap under tight PubSub polling (#3748).
if not block:
if not await conn.can_read(timeout=timeout):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve compatibility with custom can_read overrides

Custom connection_class implementations can override the previously no-argument can_read() method. PubSub.get_message(timeout=0 or positive) now passes timeout=... to that override and raises TypeError instead of polling; ConnectionPool explicitly supports custom connection classes, so this breaks existing implementations that followed the old method contract.

AGENTS.md reference: AGENTS.md:L112-L114

Useful? React with 👍 / 👎.

Comment thread redis/asyncio/client.py Outdated
Comment thread tests/test_asyncio/test_pubsub.py Outdated
Move can_read inside the PubSub retry path, preserve finite read timeouts after readiness checks, and tolerate legacy no-arg can_read overrides on custom connection classes. Tighten the regression test to assert asyncio.timeout(0) is never scheduled during timeout=0 polling.
@lllakshit

Copy link
Copy Markdown
Author

Thanks for the automated review feedback — pushed 4912242 addressing the actionable points:

  1. Finite timeout preserved — non-blocking polls now pass the original timeout through to read_response() after can_read(), instead of switching to math.inf.
  2. Retry/reconnect pathcan_read() is back inside the PubSub _execute callback (same shape as sync), so EOF/disconnect errors go through reconnect + resubscribe.
  3. Custom connection_class compat_await_connection_can_read() falls back to the legacy no-arg can_read() if a subclass does not accept timeout.
  4. Regression test — replaced the mocker fixture with unittest.mock.patch, subscribe happens before patching, and the test now specifically asserts asyncio.timeout(0) is never scheduled during get_message(timeout=0) polling.

Happy to adjust further if anything still looks off.

Comment thread redis/asyncio/connection.py
Comment thread redis/asyncio/connection.py
Yield to the event loop when polling with timeout=0 and no buffered data,
and parse timeout=0 reads from buffered bytes only so partial RESP frames
cannot block indefinitely.
@lllakshit

Copy link
Copy Markdown
Author

Thanks for the follow-up review — both issues from review #5075558105 are addressed in ed058f6:

1. Zero-timeout poll starves event loop

  • When get_message(timeout=0) finds no buffered data, try_read() now calls await asyncio.sleep(0) before returning None, matching the existing PubSub.run() behavior so tight poll loops yield to the event loop.

2. Zero-timeout parse can hang forever

  • Connection.read_response(timeout=0) now passes buffered_only=True into the parser layer.
  • Async RESP2/RESP3/hiredis parsers only consume already-buffered bytes for that path; if a RESP frame is incomplete they return None instead of awaiting more socket data.
  • Added parser-level unit tests plus a pubsub test for the event-loop yield.

Let me know if anything else should change.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ed058f6be2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

disable_decoding=disable_decoding, push_request=push_request
disable_decoding=disable_decoding,
push_request=push_request,
buffered_only=buffered_only,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve compatibility with existing parser classes

Whenever an application provides a parser_class implemented against the prior async parser contract, this forwards buffered_only=False for every normal RESP3 read and raises TypeError before it can parse a command response. parser_class is accepted by Connection.__init__, so the new private option needs to be omitted on the default path (or legacy parser signatures supported) to avoid breaking existing custom parsers.

AGENTS.md reference: AGENTS.md:L112-L114

Useful? React with 👍 / 👎.

try:
if read_timeout is not None:
if read_timeout == 0:
if not await self.can_read(timeout=0):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve no-argument can_read overrides

Although PubSub.parse_response now uses a fallback helper, the connection-level zero-timeout path still invokes self.can_read(timeout=0) directly. A custom Connection subclass that implements the previously no-argument can_read() will therefore get TypeError from public read_response(timeout=0) instead of a nonblocking result; preserve compatibility in this path as well.

AGENTS.md reference: AGENTS.md:L112-L114

Useful? React with 👍 / 👎.

Comment thread redis/_parsers/resp3.py
Comment on lines +198 to +200
except BufferedResponseIncomplete:
if buffered_only:
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid replaying RESP3 push handlers after partial reads

With the pure-Python RESP3 parser, a zero-timeout normal read that contains a complete push followed by an incomplete command reply invokes the push handler before raising this exception. Returning here leaves the buffer intact, so the next poll reparses the push and invokes the invalidation or maintenance handler a second time; retain consumed state or defer handler effects until the full response sequence is parsed.

AGENTS.md reference: AGENTS.md:L121-L124

Useful? React with 👍 / 👎.

Comment on lines +1311 to +1314
response = await self._read_response_from_parser(
disable_decoding=disable_decoding,
push_request=push_request,
buffered_only=True,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Let zero-timeout reads consume StreamReader data

can_read() reports bytes already buffered in the StreamReader as readable, but this forces both parser implementations into buffered_only mode: the pure-Python parser only consults its separate _buffer, and hiredis only consults its reader, so neither consumes those stream bytes. Consequently PubSub.get_message(timeout=0) returns None forever for a message that has already arrived (and can spin because readiness stays true), regardless of whether hiredis is installed.

AGENTS.md reference: AGENTS.md:L117-L119

Useful? React with 👍 / 👎.

stream = self._parser._stream
if stream is None:
return False
await asyncio.wait_for(stream._wait_for_data("read"), timeout=timeout)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recheck buffered data after installing the waiter

On CPython 3.10–3.11, asyncio.wait_for() schedules _wait_for_data() as a separate task. If the socket feed callback runs after the initial _parser.can_read() check but before that task installs the StreamReader waiter, the bytes remain buffered without waking it; this times out as False, so a finite-timeout PubSub call misses an already-arrived response until more data is received.

AGENTS.md reference: AGENTS.md:L103-L105

Useful? React with 👍 / 👎.

Comment on lines +1311 to +1315
response = await self._read_response_from_parser(
disable_decoding=disable_decoding,
push_request=push_request,
buffered_only=True,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid refreshing health checks on incomplete polls

When a zero-timeout read sees only a partial frame, the parser now returns None normally rather than raising a timeout, so execution reaches the health-check update below even though no response was consumed. Repeated PubSub polls on a stalled partial response keep moving next_health_check forward and prevent check_health() from sending a PING, delaying detection of the unhealthy connection.

AGENTS.md reference: AGENTS.md:L121-L124

Useful? React with 👍 / 👎.

Comment thread redis/asyncio/client.py
Comment on lines +1144 to +1147
try:
return await can_read(timeout=timeout)
except TypeError:
return await can_read()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve finite polling for legacy can_read methods

For a legacy custom connection whose no-argument can_read() returns immediately when no bytes are buffered, the compatibility fallback makes every positive-timeout PubSub poll return None immediately. Before this change, parse_response(block=False, timeout=N) called read_response(timeout=N) directly and allowed that call to wait; the helper should not silently discard the requested timeout when using the legacy signature.

AGENTS.md reference: AGENTS.md:L112-L114

Useful? React with 👍 / 👎.

Comment thread redis/asyncio/connection.py Outdated
After waiting on the stream, re-run parser.can_read() so a peer close
that wakes _wait_for_data without payload is not reported as readable.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 29c6aeea05

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread redis/asyncio/client.py
# when polling with timeout=0 and no buffered data.
await asyncio.sleep(0)
return None
read_timeout = timeout

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Carry the remaining polling timeout into the response read

When the first bytes of a partial RESP frame arrive just before can_read(timeout=N) expires, this starts read_response with a new full N-second timer, so get_message(timeout=N) can take nearly twice its documented timeout before returning. This is distinct from the previously reported unbounded parse: the fresh evidence is that the finalized read_timeout = timeout makes the second window finite but does not preserve the remaining deadline; compute and pass the remaining budget instead.

AGENTS.md reference: AGENTS.md:L121-L124

Useful? React with 👍 / 👎.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 29c6aee. Configure here.

Comment thread redis/_parsers/base.py
result = self._buffer[self._pos : end - 2]
else:
if buffered_only:
raise BufferedResponseIncomplete()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Zero-timeout parse skips StreamReader data

High Severity

buffered_only parsing looks only at the parser's own buffer and never consumes bytes already received into StreamReader._buffer. can_read() treats those bytes as readable, so get_message(timeout=0) (the default) returns None without delivering a message that has already arrived. Incoming pub/sub frames can sit unread indefinitely.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 29c6aee. Configure here.

Comment thread redis/asyncio/client.py
timeout=read_timeout,
disconnect_on_error=False,
push_request=True,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Zero-timeout poll skips event loop yield

High Severity

asyncio.sleep(0) runs only when can_read() is false. If the socket buffer has data but read_response(timeout=0) still returns None, try_read returns without yielding. A tight get_message(timeout=0) loop then monopolizes the event loop, which is the starvation case this change aimed to prevent.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 29c6aee. Configure here.

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.

RuntimeError: list changed size during iteration on asyncio PubSub get_message

1 participant