Skip to content

Commit 6adfa64

Browse files
authored
Merge branch 'master' into master
2 parents 92d60a5 + ef83cd8 commit 6adfa64

8 files changed

Lines changed: 65 additions & 22 deletions

File tree

.github/workflows/codeql-analysis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ jobs:
3636

3737
steps:
3838
- name: Checkout repository
39-
uses: actions/checkout@v6
39+
uses: actions/checkout@v7
4040

4141
# Initializes the CodeQL tools for scanning.
4242
- name: Initialize CodeQL

.github/workflows/docs.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
name: Build docs
2626
runs-on: ubuntu-latest
2727
steps:
28-
- uses: actions/checkout@v6
28+
- uses: actions/checkout@v7
2929
- uses: actions/setup-python@v6
3030
with:
3131
python-version: "3.10"

.github/workflows/hiredis-py-integration.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ jobs:
6666
ACTIONS_ALLOW_UNSECURE_COMMANDS: true
6767
name: Redis ${{ matrix.redis-version }}; Python ${{ matrix.python-version }}; RESP Parser:${{matrix.parser-backend}} (${{ matrix.hiredis-version }}); EL:${{matrix.event-loop}}; Config:${{matrix.test-config}}
6868
steps:
69-
- uses: actions/checkout@v6
69+
- uses: actions/checkout@v7
7070
with:
7171
ref: ${{ inputs.redis-py-branch }}
7272
- name: Run tests

.github/workflows/integration.yaml

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ jobs:
3838
name: Dependency audit
3939
runs-on: ubuntu-latest
4040
steps:
41-
- uses: actions/checkout@v6
41+
- uses: actions/checkout@v7
4242
- uses: pypa/gh-action-pip-audit@v1.0.8
4343
with:
4444
inputs: dev_requirements.txt
@@ -52,7 +52,7 @@ jobs:
5252
name: Code linters
5353
runs-on: ubuntu-latest
5454
steps:
55-
- uses: actions/checkout@v6
55+
- uses: actions/checkout@v7
5656
- uses: actions/setup-python@v6
5757
with:
5858
python-version: "3.10"
@@ -102,7 +102,7 @@ jobs:
102102
ACTIONS_ALLOW_UNSECURE_COMMANDS: true
103103
name: Redis ${{ matrix.redis-version }}; Python ${{ matrix.python-version }}; RESP Parser:${{matrix.parser-backend}}; EL:${{matrix.event-loop}}; Config:${{matrix.test-config}}
104104
steps:
105-
- uses: actions/checkout@v6
105+
- uses: actions/checkout@v7
106106
- name: Run tests
107107
uses: ./.github/actions/run-tests
108108
with:
@@ -146,7 +146,7 @@ jobs:
146146
ACTIONS_ALLOW_UNSECURE_COMMANDS: true
147147
name: Redis ${{ matrix.redis-version }}; Python ${{ matrix.python-version }}; RESP Parser:${{matrix.parser-backend}}; EL:${{matrix.event-loop}}; Config:${{matrix.test-config}}
148148
steps:
149-
- uses: actions/checkout@v6
149+
- uses: actions/checkout@v7
150150
- name: Run tests
151151
uses: ./.github/actions/run-tests
152152
with:
@@ -188,7 +188,7 @@ jobs:
188188
ACTIONS_ALLOW_UNSECURE_COMMANDS: true
189189
name: PyPy Redis ${{ matrix.redis-version }}; ${{ matrix.python-version }}; RESP Parser:${{matrix.parser-backend}}; EL:${{matrix.event-loop}}; Config:${{matrix.test-config}}
190190
steps:
191-
- uses: actions/checkout@v6
191+
- uses: actions/checkout@v7
192192
- name: Run tests
193193
uses: ./.github/actions/run-tests
194194
with:
@@ -228,7 +228,7 @@ jobs:
228228
ACTIONS_ALLOW_UNSECURE_COMMANDS: true
229229
name: Redis ${{ matrix.redis-version }}; Python ${{ matrix.python-version }}; RESP Parser:${{matrix.parser-backend}} (${{ matrix.hiredis-version }}); EL:${{matrix.event-loop}}; Config:${{matrix.test-config}}
230230
steps:
231-
- uses: actions/checkout@v6
231+
- uses: actions/checkout@v7
232232
- name: Run tests
233233
uses: ./.github/actions/run-tests
234234
with:
@@ -268,7 +268,7 @@ jobs:
268268
ACTIONS_ALLOW_UNSECURE_COMMANDS: true
269269
name: Redis ${{ matrix.redis-version }}; Python ${{ matrix.python-version }}; RESP Parser:${{matrix.parser-backend}}; EL:${{matrix.event-loop}}; Config:${{matrix.test-config}}
270270
steps:
271-
- uses: actions/checkout@v6
271+
- uses: actions/checkout@v7
272272
- name: Run tests
273273
uses: ./.github/actions/run-tests
274274
with:
@@ -287,7 +287,7 @@ jobs:
287287
matrix:
288288
extension: ['tar.gz', 'whl']
289289
steps:
290-
- uses: actions/checkout@v6
290+
- uses: actions/checkout@v7
291291
- uses: actions/setup-python@v6
292292
with:
293293
python-version: "3.10"
@@ -306,7 +306,7 @@ jobs:
306306
matrix:
307307
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14', 'pypy-3.10', 'pypy-3.11']
308308
steps:
309-
- uses: actions/checkout@v6
309+
- uses: actions/checkout@v7
310310
- uses: actions/setup-python@v6
311311
with:
312312
python-version: ${{ matrix.python-version }}

.github/workflows/pypi-publish.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
build_and_package:
1414
runs-on: ubuntu-latest
1515
steps:
16-
- uses: actions/checkout@v6
16+
- uses: actions/checkout@v7
1717
- name: install python
1818
uses: actions/setup-python@v6
1919
with:

.github/workflows/spellcheck.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ jobs:
99
runs-on: ubuntu-latest
1010
steps:
1111
- name: Checkout
12-
uses: actions/checkout@v6
12+
uses: actions/checkout@v7
1313
- name: Check Spelling
14-
uses: rojopolis/spellcheck-github-actions@0.60.0
14+
uses: rojopolis/spellcheck-github-actions@0.62.0
1515
with:
1616
config_path: .github/spellcheck-settings.yml
1717
task_name: Markdown

redis/_parsers/hiredis.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,21 @@ def can_read(self, timeout: float = 0) -> bool:
123123

124124
def read_from_socket(self, timeout=SENTINEL, raise_on_timeout=True):
125125
sock = self._sock
126+
reader = self._reader
127+
# Another thread may disconnect this connection while we are here (e.g.
128+
# a shared client closed via `with redis:`); on_disconnect() sets both
129+
# _sock and _reader to None. Bind them locally and fail with a
130+
# descriptive, retryable ConnectionError instead of an AttributeError.
131+
if sock is None or reader is None:
132+
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
126133
custom_timeout = timeout is not SENTINEL
127134
try:
128135
if custom_timeout:
129136
sock.settimeout(timeout)
130-
bufflen = self._sock.recv_into(self._buffer)
137+
bufflen = sock.recv_into(self._buffer)
131138
if bufflen == 0:
132139
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
133-
self._reader.feed(self._buffer, 0, bufflen)
140+
reader.feed(self._buffer, 0, bufflen)
134141
# data was read from the socket and added to the buffer.
135142
# return True to indicate that data was read.
136143
return True
@@ -160,20 +167,24 @@ def read_response(
160167
push_request=False,
161168
timeout: Union[float, object] = SENTINEL,
162169
):
163-
if not self._reader:
170+
# Bind the reader locally so a concurrent disconnect that clears
171+
# self._reader can't turn a later .gets() into an AttributeError;
172+
# re-checking the attribute each time would still race.
173+
reader = self._reader
174+
if reader is None:
164175
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
165176

166177
if disable_decoding:
167-
response = self._reader.gets(False)
178+
response = reader.gets(False)
168179
else:
169-
response = self._reader.gets()
180+
response = reader.gets()
170181

171182
while response is NOT_ENOUGH_DATA:
172183
self.read_from_socket(timeout=timeout)
173184
if disable_decoding:
174-
response = self._reader.gets(False)
185+
response = reader.gets(False)
175186
else:
176-
response = self._reader.gets()
187+
response = reader.gets()
177188
# if the response is a ConnectionError or the response is a list and
178189
# the first item is a ConnectionError, raise it as something bad
179190
# happened

tests/test_connection.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,38 @@ def test_hiredis_read_response_timeout_zero_maps_would_block_to_timeout():
294294
parser.read_response(timeout=0)
295295

296296

297+
@pytest.mark.parametrize("cleared_attr", ["_reader", "_sock"])
298+
def test_hiredis_read_from_socket_raises_connection_error_when_disconnected(
299+
cleared_attr,
300+
):
301+
# regression for #4003: another thread may disconnect the connection while
302+
# we are reading (e.g. a shared client closed via `with redis:`), which sets
303+
# _sock and _reader to None. read_from_socket() must raise a descriptive,
304+
# retryable ConnectionError rather than an AttributeError.
305+
parser = make_hiredis_parser()
306+
parser._sock.recv_into.return_value = 10
307+
setattr(parser, cleared_attr, None)
308+
309+
with pytest.raises(ConnectionError, match="Connection closed by server"):
310+
parser.read_from_socket()
311+
312+
313+
def test_hiredis_read_response_uses_local_reader_if_disconnected_mid_read():
314+
# regression for #4003: if _reader is cleared by a concurrent disconnect
315+
# after read_from_socket() returns, the in-flight read must still complete
316+
# via the locally bound reader instead of raising AttributeError on .gets().
317+
parser = make_hiredis_parser()
318+
parser._reader.responses = [NOT_ENOUGH_DATA, b"OK"]
319+
320+
def fake_read_from_socket(*args, **kwargs):
321+
parser._reader = None # simulate on_disconnect() from another thread
322+
return True
323+
324+
parser.read_from_socket = fake_read_from_socket
325+
326+
assert parser.read_response() == b"OK"
327+
328+
297329
def test_socket_buffer_timeout_zero_maps_would_block_to_timeout():
298330
sock = Mock()
299331
sock.recv.side_effect = BlockingIOError(

0 commit comments

Comments
 (0)