diff --git a/lib/Sapi.php b/lib/Sapi.php index 4a9520a..f078e3a 100644 --- a/lib/Sapi.php +++ b/lib/Sapi.php @@ -113,6 +113,12 @@ public static function sendResponse(ResponseInterface $response): void if ($copied <= 0) { break; } + // Abort on client disconnect. + // With ignore_user_abort(true), the script is not aborted on client disconnect. + // To avoid reading the entire stream and dismissing the data afterward, check between the chunks if the client is still there. + if (1 === ignore_user_abort() && 1 === connection_aborted()) { + break; + } $left -= $copied; } } else { diff --git a/tests/HTTP/SapiTest.php b/tests/HTTP/SapiTest.php index c0c820a..1deb941 100644 --- a/tests/HTTP/SapiTest.php +++ b/tests/HTTP/SapiTest.php @@ -300,4 +300,35 @@ public function testSendWorksWithCallbackAsBody(): void self::assertEquals('foo', $result); } + + public function testSendConnectionAborted(): void + { + $baseUrl = getenv('BASEURL'); + if (!$baseUrl) { + $this->markTestSkipped('Set an environment value BASEURL to continue'); + } + + $url = rtrim($baseUrl, '/').'/connection_aborted.php'; + $chunk_size = 4 * 1024 * 1024; + $fetch_size = 6 * 1024 * 1024; + + $stream = fopen($url, 'r'); + $size = 0; + + while ($size <= $fetch_size) { + $temp = fread($stream, 8192); + if (false === $temp) { + break; + } + $size += strlen($temp); + } + + fclose($stream); + + sleep(5); + + $bytes_read = file_get_contents(sys_get_temp_dir().'/dummy_stream_read_counter'); + $this->assertEquals($chunk_size * 2, $bytes_read); + $this->assertGreaterThanOrEqual($fetch_size, $bytes_read); + } } diff --git a/tests/www/connection_aborted.php b/tests/www/connection_aborted.php new file mode 100644 index 0000000..e9f62ab --- /dev/null +++ b/tests/www/connection_aborted.php @@ -0,0 +1,69 @@ +position = 0; + + return true; + } + + public function stream_read(int $count): string + { + $this->position += $count; + + return random_bytes($count); + } + + public function stream_tell(): int + { + return $this->position; + } + + public function stream_eof(): bool + { + return $this->position > 25 * 1024 * 1024; + } + + public function stream_close(): void + { + file_put_contents(sys_get_temp_dir().'/dummy_stream_read_counter', $this->position); + } +} + +/* + * The DummyStream wrapper has two functions: + * - Provide dummy data. + * - Count how many bytes have been read. + */ +stream_wrapper_register('dummy', DummyStream::class); + +/* + * Overwrite default connection handling. + * The default behaviour is however for your script to be aborted when the remote client disconnects. + * + * Nextcloud/ownCloud set ignore_user_abort(true) on purpose to work around + * some edge cases where the default behavior would end a script too early. + * + * https://github.com/owncloud/core/issues/22370 + * https://github.com/owncloud/core/pull/26775 + */ +ignore_user_abort(true); + +$body = fopen('dummy://hello', 'r'); + +$response = new HTTP\Response(); +$response->setStatus(200); +$response->addHeader('Content-Length', 25 * 1024 * 1024); +$response->setBody($body); + +HTTP\Sapi::sendResponse($response);