Reconnect inside a pipeline drops its replies and desyncs persistent connections
On a persistent connection, a pipeline that reconnects inside exec() writes its buffered commands
to the new socket but has already freed their reply callbacks. exec() returns an empty array, the
replies arrive with nobody waiting for them, and every later reply on that connection is one command
behind. Nothing raises.
Reproduce
<?php
$redis = new Redis(['host' => '127.0.0.1', 'persistent' => true]);
$redis->set('key', 'value');
$id = $redis->client('id');
$pipe = $redis->pipeline()->get('key');
/* The server closes the connection while GET is still buffered client side. */
(new Redis(['host' => '127.0.0.1']))->rawCommand('CLIENT', 'KILL', 'ID', (string) $id);
var_dump($pipe->exec());
var_dump($redis->ping());prints
array(0) {
}
string(5) "value"With 'persistent' => false the same script prints array(1) { [0]=> string(5) "value" } and
bool(true). What happens: exec() reconnects and sends GET, but its reply callback was freed
during the reconnect, so nobody consumes the reply. ping() reads it instead of +PONG, and from
there on every reply is one command behind.
Cause
redis_check_eof() reconnects in place and keeps the socket mode so that a buffered pipeline
survives — that is what is_reset_mode = 0 from #2358 is for. redis_sock_disconnect() frees the
reply callbacks anyway, but only in the persistent branch, while pipeline_cmd survives:
if (redis_sock->persistent) {
if (force || !redis_sock_is_atomic(redis_sock)) {
php_stream_pclose(redis_sock->stream);
redis_free_reply_callbacks(redis_sock); /* also on an in-place reconnect */The non-persistent branch keeps its callbacks, which is why persistent => false is correct.
Fix
if (force || !redis_sock_is_atomic(redis_sock)) {
php_stream_pclose(redis_sock->stream);
- redis_free_reply_callbacks(redis_sock);
+ /* An in-place reconnect (is_reset_mode == 0) keeps the buffered
+ * pipeline, so it must keep its reply callbacks too. */
+ if (is_reset_mode) {
+ redis_free_reply_callbacks(redis_sock);
+ }
if (p) p->nb_active--;is_reset_mode is 0 at exactly one of the thirteen call sites, the reconnect loop in
redis_check_eof(), and replaying is safe there because the commands are still entirely client side.
I have tests for both shapes — a plain PIPELINE and a MULTI inside a PIPELINE, which takes the
same path because redis_check_eof() tests mode == MULTI. Both fail without the patch and pass
with it, and the rest of --class Redis is unchanged. Happy to open a PR.
Affected
6.1.0, 6.2.0, 6.3.0 and develop @ 48a46c71, all reproduced; PHP 8.4.8, Redis 8.0.2.
#2357 / #2358 fixed the other half of this: the reconnect no longer resets the socket mode, but the reply callbacks were not considered. The symptom has come up before without a reproducible case — #1655 (closed into #1668), #1652, #429. #2829 looks similar but has a different cause.
(PR is currently being prepared)
Source: phpredis/phpredis