Skip to content

Normalize asyncio writer close races - #4217

Open
lh0156 wants to merge 5 commits into
redis:masterfrom
lh0156:agent/async-writer-race
Open

Normalize asyncio writer close races#4217
lh0156 wants to merge 5 commits into
redis:masterfrom
lh0156:agent/async-writer-race

Conversation

@lh0156

@lh0156 lh0156 commented Jul 26, 2026

Copy link
Copy Markdown

Fixes #3546

Problem

When an asyncio transport is closed concurrently with a write, Python can raise a low-level TypeError from StreamWriter.writelines() after its internal _write_ready callback has been cleared. redis-py currently lets that raw TypeError escape, so the existing retry policy cannot recognize the broken connection as retryable.

Solution

  • Keep a local writer reference for the complete write/drain operation.
  • Convert writer-close AttributeError/TypeError failures into redis-py's ConnectionError.
  • Reuse the existing disconnect path, so the connection is discarded and configured retries can handle the failure.
  • Route both timeout and no-timeout writes through the same helper to keep behavior consistent.

Tests

  • Add a deterministic async regression test for the closed-writer race.
  • Cover both socket_timeout=None and finite socket_timeout paths.
  • Full async connection suite: 65 passed, 2 skipped.
  • Async client suite: 7 passed.
  • invoke linters, compileall, and git diff --check passed.

The local integration skips are unchanged and are unrelated to this focused writer error path.


Note

Low Risk
Focused error normalization in asyncio connection I/O with targeted regression tests; behavior change is limited to exception type mapping on close races.

Overview
Fixes concurrent-close races where StreamWriter.writelines() could raise raw AttributeError/TypeError instead of a retryable connection failure.

Write path: Adds _send_packed_command() with a local writer reference, maps known close-race exceptions to ConnectionError("Connection closed while writing"), and routes both timed and untimed send_packed_command() through that helper.

Read path: _socket_is_empty() now guards a missing reader and maps buffer access failures to ConnectionError("Connection closed while reading"), including during invalidation processing.

Tests: New async tests cover closed-writer races (with/without socket_timeout), closed reader during invalidation, and that unrelated TypeError/AttributeError from bad commands still propagate.

Reviewed by Cursor Bugbot for commit 7c3a840. Bugbot is set up for automated code reviews on this repo. Configure here.

@lh0156
lh0156 marked this pull request as ready for review July 30, 2026 12:29

@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: 2132282bc4

ℹ️ 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/connection.py Outdated
Comment on lines +1152 to +1153
except (AttributeError, TypeError) as error:
raise ConnectionError("Connection closed while writing") from error

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 non-I/O TypeErrors from writes

When a caller passes a malformed packed iterable, such as a custom generator or list containing non-bytes, StreamWriter.writelines() raises TypeError before any writer-close race is involved; this broad catch now turns that caller/data error into ConnectionError("Connection closed while writing"), disconnects, and can let retry logic re-run a non-transient bad command instead of surfacing the original error. Please only translate the specific closed-writer failure and let unrelated TypeErrors propagate as before.

AGENTS.md reference: AGENTS.md:L120-L123

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in d7dd61c. The inner write wrapper now translates only AttributeError from a closed writer; caller/data TypeError values are allowed to propagate unchanged. Added a malformed packed-command regression test. The asyncio connection suite passes (67 passed, 1 skipped).

@vladvildanov

Copy link
Copy Markdown
Collaborator

Please make sure resolve Codex comments first

Comment thread redis/asyncio/connection.py Outdated
try:
writer.writelines(command)
await writer.drain()
except (AttributeError, TypeError) as error:

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.

the AttributeError/TypeError catch is broader than the None-writer case: pack_command aside, send_packed_command takes a caller-supplied iterable, so a bad element (str in the list) raises TypeError from writelines and now surfaces as ConnectionError, which retry.call_with_retry treats as retryable. that turns a programming error into a silent reconnect loop. since you already null-check writer above, is the except clause still buying anything beyond the writer-set-to-None-mid-await race?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in d7dd61c. The broad TypeError catch was removed, so a caller-supplied invalid packed iterable is no longer reported as ConnectionError. The existing closed-writer coverage now models the AttributeError path explicitly.

@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 default effort and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit d7dd61c. Configure here.

Comment thread redis/asyncio/connection.py

@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: d7dd61c5b1

ℹ️ 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 on lines +1152 to +1153
except AttributeError as error:
raise ConnectionError("Connection closed while writing") from error

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 Handle the actual closed-transport TypeError

For the reported Python 3.12 close race, the traceback in #3546 fails inside StreamWriter.writelines() as TypeError: 'NoneType' object is not callable, but this new handler only translates AttributeError; the actual failure still escapes as a raw TypeError and bypasses redis-py's ConnectionError retry path. Please classify that specific closed-transport TypeError without converting arbitrary caller/data TypeErrors.

Useful? React with 👍 / 👎.

Comment thread redis/asyncio/connection.py

@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: fa2279f74a

ℹ️ 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/connection.py

@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: 4588fa1238

ℹ️ 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/connection.py

@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: 7c3a84066f

ℹ️ 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 on lines +1157 to +1159
if str(error) != "'NoneType' object is not callable":
raise
raise ConnectionError("Connection closed while writing") from error

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 retrying caller TypeErrors as close races

In the final code, this branch still identifies the closed-transport race solely by str(error). If a public send_packed_command() caller passes a malformed iterable that raises the same CPython text while writelines() consumes it (for example by calling a None callback), that caller/data bug is rewritten as ConnectionError, the connection is disconnected, and configured retry logic can replay a non-transient bad command instead of surfacing the original TypeError; please classify the transport race by its source/state rather than the exception message alone.

AGENTS.md reference: AGENTS.md:L120-L123

Useful? React with 👍 / 👎.

@petyaslavova petyaslavova left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for digging into this — the underlying problem is real. CPython clears
_SelectorSocketTransport._write_ready in _call_connection_lost on 3.12+, and writelines()
calls it without the _conn_lost guard that write() has, so a write on a peer-reset transport
raises a raw TypeError that our retry policy cannot classify. Mapping it to ConnectionError
in the async write path is the right layer.

Three things before we can merge it:

  1. Classify by transport state rather than by exception message. str(error) comparisons
    against CPython wording are not a stable contract, and they still rewrite a malformed
    caller-supplied iterable into a retried ConnectionError (the open Codex comment).
    Checking writer.transport is None or writer.transport.is_closing() before writelines()
    is race-free — _force_close()/close() set _closing before scheduling _call_connection_lost,
    and there is no suspension point between the check and the write. Re-checking is_closing()
    inside a narrow except (AttributeError, TypeError) covers the drain() window.

  2. Please drop the _socket_is_empty()/process_invalidation_messages() change. Those methods
    have no callers in the library or the test suite, and the added blanket except AttributeError conflicts with the deliberate "fail loudly if the private buffer API
    changes" invariant in redis/_parsers/base.py.

  3. Add one regression test against real asyncio objects instead of mocks that supply the
    CPython message text — e.g. connect to a local asyncio.start_server, call
    conn._writer.transport.abort(), await asyncio.sleep(0), then send a command. The
    current tests would keep passing even if the real path stopped being detected.

With those in place this should be ready for another review.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

self._write_ready() 'NoneType' object is not callable

4 participants