From 43a71684f9231bdd89ff29a32eed0bd01d3256de Mon Sep 17 00:00:00 2001 From: Dillard Blom Date: Mon, 19 Oct 2026 05:28:26 +0200 Subject: [PATCH] Read ahead over SFTP: take answers phpseclib has read already instead of waiting up to 10 s on the socket --- lib/Backend/SftpRangeFetcher.php | 18 ++++++++++++++---- tests/Unit/Backend/SftpRangeFetcherTest.php | 19 +++++++++++++++++++ tests/Unit/Backend/SftpReadStreamTest.php | 4 ++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/lib/Backend/SftpRangeFetcher.php b/lib/Backend/SftpRangeFetcher.php index cb059b8..153b13e 100644 --- a/lib/Backend/SftpRangeFetcher.php +++ b/lib/Backend/SftpRangeFetcher.php @@ -30,8 +30,11 @@ final class SftpRangeFetcher { public const DEPTH = 16; /** Ranges read ahead of the one the caller is at, in flight or waiting in the spool. */ public const AHEAD = 8; - /** Seconds ready() waits for any connection to answer before it waits for each in turn. */ - private const SELECT_TIMEOUT = 10; + /** + * Seconds ready() waits for any connection to answer before it waits for each in turn: short, + * in case a connection holds an answer where ready() does not look. + */ + private const SELECT_TIMEOUT = 1; /** See SftpReadStream::CHUNK and MIN_CHUNK. */ private const CHUNK = 261120; private const MIN_CHUNK = 32768; @@ -134,9 +137,16 @@ final class SftpRangeFetcher { foreach ($this->jobs as $c => $job) { $sftp = $this->connections[$c]; $vars = get_object_vars($sftp); // only what is public: phpseclib 2's are + // Answers phpseclib has read already: the socket does not show them. Its request + // buffer holds whole answers read while it waited for another; its packet buffer and + // channel buffers hold what came in with the last packet it read (small answers, such + // as a CLOSE and the next OPEN, often arrive together). $buffered = $vars['requestBuffer'] ?? null; - if (is_array($buffered) && isset($job->queue[0]) && array_key_exists($job->queue[0][0], $buffered)) { - $ready[] = $c; // read already, while waiting for another answer + $channels = $vars['channel_buffers'] ?? null; + if ((is_array($buffered) && isset($job->queue[0]) && array_key_exists($job->queue[0][0], $buffered)) + || (is_string($vars['packet_buffer'] ?? null) && $vars['packet_buffer'] !== '') + || (is_array($channels) && array_filter($channels) !== [])) { + $ready[] = $c; continue; } $socket = $vars['fsock'] ?? null; diff --git a/tests/Unit/Backend/SftpRangeFetcherTest.php b/tests/Unit/Backend/SftpRangeFetcherTest.php index 57036f4..b27e8ff 100644 --- a/tests/Unit/Backend/SftpRangeFetcherTest.php +++ b/tests/Unit/Backend/SftpRangeFetcherTest.php @@ -87,6 +87,25 @@ class SftpRangeFetcherTest extends TestCase { $this->assertSame(SftpRangeFetcher::DEPTH, $connections[1]->maxInFlight); } + public function testAnAnswerPhpseclibHasReadAlreadyIsTakenWithoutWaitingOnTheSocket(): void { + // The fake's answers are all "read already": its socket never becomes readable, and + // phpseclib's packet buffer holds data. Waiting on the socket would take a second a step. + $connections = $this->connections(2); + $sockets = []; + foreach ($connections as $c) { + $pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + $sockets[] = $pair; + $c->fsock = $pair[0]; + $c->packet_buffer = 'more'; + } + $ranges = array_slice($this->ranges(), 0, 3); + $start = microtime(true); + + $this->assertSame($this->expected($ranges), self::collect(SftpRangeFetcher::fetch(self::opened($connections), '/home/', $ranges, $this->tempDir()))); + + $this->assertLessThan(1.0, microtime(true) - $start); + } + public function testAServerThatCapsAReadStillGivesEveryByte(): void { $connections = $this->connections(3, array_fill(0, 3, ['cap' => 100000])); $ranges = $this->ranges(); diff --git a/tests/Unit/Backend/SftpReadStreamTest.php b/tests/Unit/Backend/SftpReadStreamTest.php index 0718d0e..969f921 100644 --- a/tests/Unit/Backend/SftpReadStreamTest.php +++ b/tests/Unit/Backend/SftpReadStreamTest.php @@ -468,6 +468,10 @@ final class FakeSftpConnection { public array $openHandles = []; /** @var list paths of every OPEN request */ public array $opened = []; + /** @var resource|null like phpseclib's socket (SftpRangeFetcher waits on it) */ + public $fsock = null; + /** like phpseclib's: what came in with the last packet and is not read yet */ + public string $packet_buffer = ''; /** Whether the SFTP channel is open: phpseclib 2 opens it on the first SFTP call. */ public bool $initialised = false; /** Packets sent */