Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions build/stubs/sapi.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Functions only provided by some SAPIs, always guarded with function_exists().
*/

/** @return bool */
function fastcgi_finish_request() {
}

/** @return void */
function litespeed_finish_request() {
}
38 changes: 38 additions & 0 deletions lib/private/AppFramework/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,14 @@
use OCP\AppFramework\Http\IOutput;
use OCP\Diagnostics\IEventLogger;
use OCP\HintException;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IRequest;
use OCP\ISession;
use OCP\Profiler\IProfiler;
use OCP\Server;
use Psr\Container\ContainerExceptionInterface;
use Psr\Log\LoggerInterface;

/**
* Entry point for every request in your app. You can consider this as your
Expand Down Expand Up @@ -198,5 +202,39 @@ public static function main(
$io->setOutput($output);
}
}

if ($response->getFlushEarly() && $container->get(IConfig::class)->getSystemValueBool('flush_response_early', true)) {
self::finishRequest($container, $io);
}
}

/**
* Hand the finished response to the client, so that the remaining work of
* this process does not keep it waiting.
*/
private static function finishRequest(DIContainer $container, IOutput $io): void {
// The remaining work must not be cut off when the client goes away
ignore_user_abort(true);

// Otherwise the next request of the same client blocks on the session
// lock for as long as this process keeps working
try {
$container->get(ISession::class)->close();
} catch (ContainerExceptionInterface) {
}

// The client may send a follow-up request that then reads data from
// before the transaction
try {
if ($container->get(IDBConnection::class)->inTransaction()) {
$container->get(LoggerInterface::class)->warning(
'A response was flushed to the client while a database transaction was still open. The client may not be able to observe the pending writes yet.',
['app' => 'core'],
);
}
} catch (ContainerExceptionInterface) {
}

$io->finishRequest();
}
}
25 changes: 25 additions & 0 deletions lib/private/AppFramework/Http/Output.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,29 @@ public function setCookie($name, $value, $expire, $path, $domain, $secure, $http
'samesite' => $sameSite
]);
}

#[\Override]
public function finishRequest(): bool {
// php-fpm, and the SAPIs aliasing it (recent LiteSpeed, FrankenPHP)
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
return true;
}

if (function_exists('litespeed_finish_request')) {
litespeed_finish_request();
return true;
}

// mod_php, cgi, cli, … cannot give the connection back to the web server,
// so only push out what we have. ob_flush() instead of ob_end_flush() keeps
// the buffer around for error handling, and is silenced because output
// handlers are allowed to be non-flushable.
if (ob_get_level() > 0) {
@ob_flush();
}
flush();

return false;
}
}
10 changes: 10 additions & 0 deletions lib/public/AppFramework/Http/IOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,14 @@ public function setHttpResponseCode($code);
* @since 8.1.0
*/
public function setCookie($name, $value, $expire, $path, $domain, $secure, $httpOnly, $sameSite = 'Lax');

/**
* Send the response produced so far to the client, so the remaining work of
* this process does not delay it. Nothing may be written to the output after.
*
* @return bool true if the connection was closed, false if the response was
* only flushed because the SAPI does not support closing it
* @since 35.0.0
*/
public function finishRequest(): bool;
}
34 changes: 34 additions & 0 deletions lib/public/AppFramework/Http/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class Response {

/** @var bool */
private $throttled = false;
private bool $flushEarly = false;
/** @var array */
private $throttleMetadata = [];

Expand Down Expand Up @@ -397,4 +398,37 @@ public function getThrottleMetadata() {
public function isThrottled() {
return $this->throttled;
}

/**
* Request that the response is sent to the client as soon as it is complete,
* instead of when the PHP process is done. Enable this only when the
* controller leaves work behind that the client does not have to wait for.
*
* Things to be aware of before enabling this:
*
* - The session is closed before the response goes out, so anything running
* afterwards can no longer write to it.
* - The status code and the body are final at that point. Errors happening
* afterwards can only be logged, never reported to the client.
* - Nothing may be written to the output afterwards, the Content-Length has
* already been announced.
* - The worker of the web server stays occupied until the process really
* ends, so this improves latency but not throughput.
* - Administrators can turn this off globally with the `flush_response_early`
* system config option
*
* @since 35.0.0
*/
public function setFlushEarly(bool $flushEarly): void {
$this->flushEarly = $flushEarly;
}

/**
* Whether the response should be sent to the client as soon as it is complete
*
* @since 35.0.0
*/
public function getFlushEarly(): bool {
return $this->flushEarly;
}
}
1 change: 1 addition & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@
<file name="build/stubs/pcntl.php"/>
<file name="build/stubs/zip.php"/>
<file name="build/stubs/psr_container.php"/>
<file name="build/stubs/sapi.php"/>
<file name="3rdparty/sabre/uri/lib/functions.php" />
<file name="build/stubs/app_api.php" />
<file name="build/stubs/php-polyfill.php" />
Expand Down
95 changes: 94 additions & 1 deletion tests/lib/AppFramework/AppTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\IOutput;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\StreamResponse;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\ISession;
use Psr\Log\LoggerInterface;

function rrmdir($directory) {
$files = array_diff(scandir($directory), ['.','..']);
Expand Down Expand Up @@ -86,6 +91,92 @@ public function testControllerNameAndMethodAreBeingPassed(): void {
$this->container);
}

public function testRequestIsNotFinishedEarlyByDefault(): void {
$this->dispatcher->expects($this->once())
->method('dispatch')
->willReturn(['HTTP/2.0 200 OK', [], [], $this->output, new Response()]);

$this->io->expects($this->never())
->method('finishRequest');

App::main($this->controllerName, $this->controllerMethod, $this->container, []);
}

public function testRequestIsFinishedEarlyWhenTheResponseAsksForIt(): void {
$response = new Response();
$response->setFlushEarly(true);

$this->dispatcher->expects($this->once())
->method('dispatch')
->willReturn(['HTTP/2.0 200 OK', [], [], $this->output, $response]);

$session = $this->createMock(ISession::class);
$session->expects($this->once())
->method('close');
$this->container[ISession::class] = $session;

$connection = $this->createMock(IDBConnection::class);
$connection->method('inTransaction')
->willReturn(false);
$this->container[IDBConnection::class] = $connection;

$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->never())
->method('warning');
$this->container[LoggerInterface::class] = $logger;

$this->io->expects($this->once())
->method('finishRequest');

App::main($this->controllerName, $this->controllerMethod, $this->container, []);
}

public function testRequestIsNotFinishedEarlyWhenDisabledByTheAdmin(): void {
$response = new Response();
$response->setFlushEarly(true);

$this->dispatcher->expects($this->once())
->method('dispatch')
->willReturn(['HTTP/2.0 200 OK', [], [], $this->output, $response]);

$config = $this->createMock(IConfig::class);
$config->method('getSystemValueBool')
->with('flush_response_early', true)
->willReturn(false);
$this->container[IConfig::class] = $config;

$this->io->expects($this->never())
->method('finishRequest');

App::main($this->controllerName, $this->controllerMethod, $this->container, []);
}

public function testOpenTransactionIsLoggedWhenFinishingEarly(): void {
$response = new Response();
$response->setFlushEarly(true);

$this->dispatcher->expects($this->once())
->method('dispatch')
->willReturn(['HTTP/2.0 200 OK', [], [], $this->output, $response]);

$this->container[ISession::class] = $this->createMock(ISession::class);

$connection = $this->createMock(IDBConnection::class);
$connection->method('inTransaction')
->willReturn(true);
$this->container[IDBConnection::class] = $connection;

$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())
->method('warning');
$this->container[LoggerInterface::class] = $logger;

$this->io->expects($this->once())
->method('finishRequest');

App::main($this->controllerName, $this->controllerMethod, $this->container, []);
}

public function testBuildAppNamespace(): void {
$ns = App::buildAppNamespace('someapp');
$this->assertEquals('OCA\Someapp', $ns);
Expand Down Expand Up @@ -144,7 +235,9 @@ public function testNoOutput(string $statusCode): void {
}

public function testCallbackIsCalled(): void {
$mock = $this->getMockBuilder('OCP\AppFramework\Http\ICallbackResponse')
// The dispatcher always hands back a Response, not a bare ICallbackResponse
$mock = $this->getMockBuilder(StreamResponse::class)
->disableOriginalConstructor()
->getMock();

$return = ['HTTP/2.0 200 OK', [], [], $this->output, $mock];
Expand Down
49 changes: 49 additions & 0 deletions tests/lib/AppFramework/Http/OutputTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,53 @@ public function testSetReadfileStream(): void {
$output = new Output('');
$output->setReadfile(fopen(__FILE__, 'r'));
}

/** The buffer has to survive the flush so error handling can still use it */
public function testFinishRequestKeepsTheOutputBuffer(): void {
$this->skipIfConnectionCanBeClosed();
$output = new Output('');

ob_start();
$levelBefore = ob_get_level();
$closed = $output->finishRequest();
$levelAfter = ob_get_level();
ob_end_clean();

$this->assertFalse($closed);
$this->assertSame($levelBefore, $levelAfter);
}

/** Draining a handler that refuses to be flushed in a loop would never end */
public function testFinishRequestWithNonFlushableBuffer(): void {
$this->skipIfConnectionCanBeClosed();
$output = new Output('');

ob_start(
static fn (string $buffer): string => $buffer,
0,
PHP_OUTPUT_HANDLER_CLEANABLE | PHP_OUTPUT_HANDLER_REMOVABLE,
);
$levelBefore = ob_get_level();
$closed = $output->finishRequest();
$levelAfter = ob_get_level();
ob_end_clean();

$this->assertFalse($closed);
$this->assertSame($levelBefore, $levelAfter);
}

public function testFinishRequestWithoutAnyOutputBuffer(): void {
$this->skipIfConnectionCanBeClosed();
$output = new Output('');

$level = ob_get_level();
$this->assertFalse($output->finishRequest());
$this->assertSame($level, ob_get_level());
}

private function skipIfConnectionCanBeClosed(): void {
if (function_exists('fastcgi_finish_request') || function_exists('litespeed_finish_request')) {
$this->markTestSkipped('This SAPI closes the connection, which would tear down the output buffer of the test runner');
}
}
}
12 changes: 12 additions & 0 deletions tests/lib/AppFramework/Http/ResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -259,4 +259,16 @@ public function testGetThrottleMetadata(): void {
$this->childResponse->throttle(['foo' => 'bar']);
$this->assertSame(['foo' => 'bar'], $this->childResponse->getThrottleMetadata());
}

public function testFlushEarlyIsOptIn(): void {
$this->assertFalse($this->childResponse->getFlushEarly());
}

public function testSetFlushEarly(): void {
$this->childResponse->setFlushEarly(true);
$this->assertTrue($this->childResponse->getFlushEarly());

$this->childResponse->setFlushEarly(false);
$this->assertFalse($this->childResponse->getFlushEarly());
}
}
Loading