From 9498a7ce654836e4a264b6b38285ac3bd67ee4a1 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 22 Oct 2026 15:11:32 +0200 Subject: [PATCH] Fix Folder::idle() sending NOOP while the session is in IDLE Client::getConnection() runs checkConnection(), which verifies the session with a NOOP. idle() called it on every loop iteration, i.e. while the idle session was in IDLE. rfc2177 only allows DONE there - Dovecot answers "BAD Expected DONE" and ends the IDLE, checkConnection() then silently opens a new session without SELECT/IDLE, so no EXISTS is ever reported. Fetch the protocol connection once before entering IDLE and use it directly inside the loop. The liveness check after an empty read now uses the stream metadata (eof) instead of connected(), which also sends a NOOP. Adds a regression test with a small fake IMAP server that reacts to any command other than DONE during IDLE the way Dovecot does. Co-Authored-By: Claude Opus 5.5 --- src/Folder.php | 16 ++++- tests/IdleTest.php | 113 +++++++++++++++++++++++++++++++++ tests/support/idle_server.php | 114 ++++++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 tests/IdleTest.php create mode 100644 tests/support/idle_server.php diff --git a/src/Folder.php b/src/Folder.php index 45d2931d..2fbfaadf 100755 --- a/src/Folder.php +++ b/src/Folder.php @@ -442,7 +442,14 @@ public function idle(callable $callback, int $timeout = 300): void { $idle_client = $this->client->clone(); $idle_client->connect(); $idle_client->openFolder($this->path, true); - $idle_client->getConnection()->idle(); + + // Fetch the protocol connection once, before entering IDLE. Client::getConnection() runs + // checkConnection(), which verifies the session with a NOOP. Sending a NOOP while the session + // is in IDLE violates rfc2177 - servers such as Dovecot answer "BAD Expected DONE" and end the + // IDLE, checkConnection() then silently opens a new session without IDLE and no EXISTS ever + // arrives. Inside the loop only the protocol connection itself must be used. + $connection = $idle_client->getConnection(); + $connection->idle(); $last_action = Carbon::now()->addSeconds($timeout); @@ -451,9 +458,12 @@ public function idle(callable $callback, int $timeout = 300): void { while (true) { try { // This polymorphic call is fine - Protocol::idle() will throw an exception beforehand - $line = $idle_client->getConnection()->nextLine(Response::empty()); + $line = $connection->nextLine(Response::empty()); } catch (Exceptions\RuntimeException $e) { - if(strpos($e->getMessage(), "empty response") >= 0 && $idle_client->getConnection()->connected()) { + // Do not use connected() here - it sends a NOOP, which would end the IDLE session. + // A stream that is not at EOF is still alive (e.g. a plain read timeout). + $meta = $connection->meta(); + if(strpos($e->getMessage(), "empty response") >= 0 && !($meta["eof"] ?? true)) { continue; } if(!str_contains($e->getMessage(), "connection closed")) { diff --git a/tests/IdleTest.php b/tests/IdleTest.php new file mode 100644 index 00000000..7cf0e829 --- /dev/null +++ b/tests/IdleTest.php @@ -0,0 +1,113 @@ +server)) { + proc_terminate($this->server); + proc_close($this->server); + } + if ($this->log_file !== "" && file_exists($this->log_file)) { + unlink($this->log_file); + } + } + + /** + * Start the fake IMAP server and return its port + * @param int $lifetime seconds until the server shuts down + * + * @return int + */ + protected function startServer(int $lifetime): int { + $this->log_file = tempnam(sys_get_temp_dir(), "php-imap-idle-"); + $command = [PHP_BINARY, __DIR__ . "/support/idle_server.php", $this->log_file, (string)$lifetime]; + $this->server = proc_open($command, [1 => ["pipe", "w"], 2 => ["pipe", "w"]], $pipes); + self::assertIsResource($this->server, "fake IMAP server could not be started"); + $port = (int)trim((string)fgets($pipes[1])); + self::assertGreaterThan(0, $port, "fake IMAP server did not report its port"); + return $port; + } + + /** + * Commands received by the fake server + * + * @return array + */ + protected function receivedCommands(): array { + $commands = []; + foreach (file($this->log_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $row) { + [$connection, $idling, $line] = explode("\t", $row, 3); + $commands[] = ["connection" => (int)$connection, "idling" => $idling === "1", "line" => $line]; + } + return $commands; + } + + public function testIdleDoesNotSendCommandsDuringIdle(): void { + $port = $this->startServer(5); + + $manager = new ClientManager([ + "accounts" => [ + "default" => [ + "host" => "127.0.0.1", + "port" => $port, + "encryption" => false, + "validate_cert" => false, + "username" => "user", + "password" => "secret", + "protocol" => "imap", + ], + ], + ]); + $client = $manager->account("default"); + $client->connect(); + $folder = new Folder($client, "INBOX", "/", []); + + try { + // Third argument (max_runtime) is ignored by versions without it - the loop then + // ends with an exception once the fake server shuts down. + $folder->idle(function () {}, 1, 3); + } catch (\Throwable) { + // the server went away - only the recorded commands matter + } + + $commands = $this->receivedCommands(); + self::assertNotEmpty($commands); + + $during_idle = array_values(array_filter($commands, fn($c) => $c["idling"] && strtoupper($c["line"]) !== "DONE")); + self::assertSame([], $during_idle, "commands sent while the session was in IDLE"); + + $first_idle = null; + foreach ($commands as $index => $command) { + if (!$command["idling"] && str_ends_with(strtoupper($command["line"]), " IDLE")) { + $first_idle = $index; + break; + } + } + self::assertNotNull($first_idle, "IDLE was never issued"); + + // A login after IDLE was issued means the idle session was silently replaced + $later_logins = array_filter(array_slice($commands, $first_idle), fn($c) => str_contains(strtoupper($c["line"]), " LOGIN ")); + self::assertSame([], array_values($later_logins), "the idle session was silently reconnected"); + } +} diff --git a/tests/support/idle_server.php b/tests/support/idle_server.php new file mode 100644 index 00000000..889f0d5a --- /dev/null +++ b/tests/support/idle_server.php @@ -0,0 +1,114 @@ +\t\t" to the log file given as first argument. +* +* Usage: php idle_server.php +* Prints the listening port on the first line of STDOUT. +*/ + +$log_file = $argv[1]; +$lifetime = (float)($argv[2] ?? 5); + +$server = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr); +if (!$server) { + fwrite(STDERR, "cannot listen: $errstr\n"); + exit(1); +} +$name = stream_socket_get_name($server, false); +echo substr($name, strrpos($name, ":") + 1) . "\n"; +flush(); + +$clients = []; +$state = []; +$next_id = 1; +$end = microtime(true) + $lifetime; + +$send = function ($stream, string $data): void { + @fwrite($stream, $data . "\r\n"); +}; + +while (microtime(true) < $end) { + $read = array_merge([$server], array_values($clients)); + $write = null; + $except = null; + if (@stream_select($read, $write, $except, 0, 100000) === false) { + break; + } + foreach ($read as $stream) { + if ($stream === $server) { + $client = @stream_socket_accept($server, 0); + if ($client) { + $id = $next_id++; + $clients[$id] = $client; + $state[$id] = ["idling" => false, "idle_tag" => null]; + $send($client, "* OK [CAPABILITY IMAP4rev1 IDLE] fake server ready"); + } + continue; + } + $id = array_search($stream, $clients, true); + $line = fgets($stream); + if ($line === false || $line === "") { + fclose($stream); + unset($clients[$id], $state[$id]); + continue; + } + $line = rtrim($line, "\r\n"); + file_put_contents($log_file, $id . "\t" . ($state[$id]["idling"] ? 1 : 0) . "\t" . $line . "\n", FILE_APPEND); + + if ($state[$id]["idling"]) { + $tag = $state[$id]["idle_tag"]; + $state[$id]["idling"] = false; + if (strtoupper($line) === "DONE") { + $send($stream, "$tag OK Idle completed."); + } else { + // Same reaction as Dovecot: the IDLE command ends with BAD + $send($stream, "$tag BAD Expected DONE."); + } + continue; + } + + $parts = explode(" ", $line, 3); + $tag = $parts[0]; + $command = strtoupper($parts[1] ?? ""); + switch ($command) { + case "CAPABILITY": + $send($stream, "* CAPABILITY IMAP4rev1 IDLE"); + $send($stream, "$tag OK Capability completed."); + break; + case "SELECT": + case "EXAMINE": + $send($stream, "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)"); + $send($stream, "* 0 EXISTS"); + $send($stream, "* 0 RECENT"); + $send($stream, "* OK [UIDVALIDITY 1] UIDs valid"); + $send($stream, "* OK [UIDNEXT 1] Predicted next UID"); + $send($stream, "$tag OK [READ-WRITE] Select completed."); + break; + case "IDLE": + $state[$id]["idling"] = true; + $state[$id]["idle_tag"] = $tag; + $send($stream, "+ idling"); + break; + case "LOGOUT": + $send($stream, "* BYE Logging out"); + $send($stream, "$tag OK Logout completed."); + fclose($stream); + unset($clients[$id], $state[$id]); + break; + default: + $send($stream, "$tag OK $command completed."); + } + } +} + +foreach ($clients as $client) { + fclose($client); +} +fclose($server);