Skip to content

Fix infinite loop in parseResponse() when fread() returns empty string on broken socket#291

Merged
terabytesoftw merged 4 commits intoyiisoft:masterfrom
dkostik:fix/fread-empty-string-infinite-loop
Mar 23, 2026
Merged

Fix infinite loop in parseResponse() when fread() returns empty string on broken socket#291
terabytesoftw merged 4 commits intoyiisoft:masterfrom
dkostik:fix/fread-empty-string-infinite-loop

Conversation

@dkostik
Copy link
Copy Markdown
Contributor

@dkostik dkostik commented Mar 18, 2026

Q A
Is bugfix? ✔️
New feature?
Breaks BC?
Fixed issues -

Summary

When a Redis node dies mid-response (e.g. during cluster failover or node replacement), fread() returns '' (empty string), not false.

The bulk reply parser in parseResponse() only checks for === false:

while ($length > 0) {
    if (($block = fread($this->socket, $length)) === false) {  // never triggers on ''!
        throw new SocketException("...");
    }
    $data .= $block;
    $length -= mb_strlen($block, '8bit');  // 0 -= 0 = no progress
}

This causes an infinite loop because:

  • fread() returns '' on a broken/timed-out socket (not false)
  • mb_strlen('', '8bit') is 0, so $length never decreases
  • The while ($length > 0) loop never exits
  • With dataTimeout set, each fread() blocks for N seconds before returning ''

Impact

In long-running workers (queue consumers, cron jobs), this causes the process to hang permanently. The retry/reconnect logic in executeCommand() never gets a chance to run because we're stuck inside sendRawCommand()parseResponse().

Logs are also never flushed (since flush typically happens at end of iteration), making the issue very hard to diagnose.

Reproduction scenario

  1. Worker has an open Redis connection
  2. DevOps performs node replacement / cluster resharding
  3. Worker sends a GET command
  4. Redis sends bulk reply header ($200\r\n) then the node dies
  5. fgets() reads the header successfully (small, fits in TCP buffer)
  6. fread() tries to read the 202-byte body → broken socket → returns ''
  7. Infinite loop — worker hangs forever

Fix

// Before:
if (($block = fread($this->socket, $length)) === false) {

// After:
if (($block = fread($this->socket, $length)) === false || $block === '') {

This allows SocketException to be thrown, which triggers the existing retry/reconnect logic in executeCommand().

Test

Added testParseResponseBulkReplyBrokenSocket — uses stream_socket_pair() to simulate a partial bulk reply (header sent, server closed before body), verifying that SocketException is thrown instead of hanging.

When a Redis node dies mid-response (e.g. during cluster failover or node
replacement), fread() returns '' (empty string), not false. The bulk reply
parser only checked for === false, causing the while($length > 0) loop to
spin forever since $length never decreases (strlen('') === 0).

With dataTimeout set, each fread() call blocks for dataTimeout seconds
before returning '', making the loop effectively infinite. This causes
workers to hang permanently with no logs (since log flush happens at end
of iteration).

The fix adds || $block === '' to the existing fread() check, so a
SocketException is thrown and the retry/reconnect logic can take over.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@terabytesoftw
Copy link
Copy Markdown
Member

You must correct the tests.

sendRawCommand is protected, so direct call goes through __call() magic.
Use TestCase::invokeMethod() to access it via reflection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a hang in the Redis bulk-reply parser when a socket breaks mid-response and fread() returns an empty string ('') instead of false, allowing existing reconnect/retry logic to run.

Changes:

  • Update Connection::parseResponse() to treat fread() returning '' as a socket read failure.
  • Add a PHPUnit test intended to reproduce the partial bulk-reply scenario.
  • Document the fix in the changelog.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.

File Description
src/Connection.php Prevents infinite loop by failing fast when fread() returns '' during bulk reply reads.
tests/RedisConnectionTest.php Adds coverage for broken-socket mid bulk reply (currently has issues that need adjustment).
CHANGELOG.md Notes the bugfix in the “under development” section.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

fclose($server) breaks both read and write sides, causing fwrite()
on the client to fail with "Failed to write to socket" before reaching
the fread() path we want to test.

Using stream_socket_shutdown($server, STREAM_SHUT_WR) closes only the
write side so fwrite() on client still works, but fread() on client
gets EOF after reading the partial bulk reply header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 18, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.99%. Comparing base (8739841) to head (ffe0ad4).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master     #291      +/-   ##
============================================
+ Coverage     80.88%   80.99%   +0.11%     
- Complexity      368      369       +1     
============================================
  Files            10       10              
  Lines           905      905              
============================================
+ Hits            732      733       +1     
+ Misses          173      172       -1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

- Use protocol 0 instead of STREAM_IPPROTO_IP for Unix socket pair
- Remove placeholder @see issues/XXX link
- Use expectExceptionMessageMatches() for partial match
- Add PR yiisoft#291 reference to CHANGELOG entry
- Fix PHPStan baseline pattern for sort() to work across PHPStan versions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dkostik
Copy link
Copy Markdown
Contributor Author

dkostik commented Mar 19, 2026

@terabytesoftw any updates?

@terabytesoftw terabytesoftw requested review from a team and s1lver March 19, 2026 14:50
@terabytesoftw terabytesoftw merged commit 915922c into yiisoft:master Mar 23, 2026
11 checks passed
@terabytesoftw
Copy link
Copy Markdown
Member

Thks 👍

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

Labels

status:code review The pull request needs review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants