Skip to content

Commit a270175

Browse files
committed
Close inactive connections and requests
This new middleware introduces a timeout of closing inactive connections between requests after a configured amount of seconds. This builds on top of reactphp#405 and partially on reactphp#422
1 parent cb72360 commit a270175

9 files changed

+511
-175
lines changed

.github/workflows/ci.yml

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ jobs:
99
name: PHPUnit (PHP ${{ matrix.php }})
1010
runs-on: ubuntu-22.04
1111
strategy:
12+
fail-fast: false
1213
matrix:
1314
php:
1415
- 8.3

README.md

+20
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ multiple concurrent HTTP requests without blocking.
8282
* [Uri](#uri)
8383
* [ResponseException](#responseexception)
8484
* [React\Http\Middleware](#reacthttpmiddleware)
85+
* [InactiveConnectionTimeoutMiddleware](#inactiveconnectiontimeoutmiddleware)
8586
* [StreamingRequestMiddleware](#streamingrequestmiddleware)
8687
* [LimitConcurrentRequestsMiddleware](#limitconcurrentrequestsmiddleware)
8788
* [RequestBodyBufferMiddleware](#requestbodybuffermiddleware)
@@ -2692,6 +2693,25 @@ access its underlying response object.
26922693

26932694
### React\Http\Middleware
26942695

2696+
#### InactiveConnectionTimeoutMiddleware
2697+
2698+
The `React\Http\Middleware\InactiveConnectionTimeoutMiddleware` is purely a configuration middleware to configure the
2699+
`HttpServer` to close any inactive connections between requests to close the connection and not leave them needlessly
2700+
open. The default is `60` seconds of inactivity and should only be changed if you know what you are doing.
2701+
2702+
The following example configures the `HttpServer` to close any inactive connections after one and a half second:
2703+
2704+
```php
2705+
$http = new React\Http\HttpServer(
2706+
new React\Http\Middleware\InactiveConnectionTimeoutMiddleware(1.5),
2707+
$handler
2708+
);
2709+
```
2710+
> Internally, this class is used as a "value object" to override the default timeout of one minute.
2711+
As such it doesn't have any behavior internally, that is all in the internal "StreamingServer".
2712+
This timeout is only in effect if we expect data from the client, not when we are writing data to
2713+
the client.
2714+
26952715
#### StreamingRequestMiddleware
26962716

26972717
The `React\Http\Middleware\StreamingRequestMiddleware` can be used to

src/HttpServer.php

+7-3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use React\Http\Io\IniUtil;
99
use React\Http\Io\MiddlewareRunner;
1010
use React\Http\Io\StreamingServer;
11+
use React\Http\Middleware\InactiveConnectionTimeoutMiddleware;
1112
use React\Http\Middleware\LimitConcurrentRequestsMiddleware;
1213
use React\Http\Middleware\StreamingRequestMiddleware;
1314
use React\Http\Middleware\RequestBodyBufferMiddleware;
@@ -219,10 +220,13 @@ public function __construct($requestHandlerOrLoop)
219220
}
220221

221222
$streaming = false;
223+
$idleConnectionTimeout = InactiveConnectionTimeoutMiddleware::DEFAULT_TIMEOUT;
222224
foreach ((array) $requestHandlers as $handler) {
223225
if ($handler instanceof StreamingRequestMiddleware) {
224226
$streaming = true;
225-
break;
227+
}
228+
if ($handler instanceof InactiveConnectionTimeoutMiddleware) {
229+
$idleConnectionTimeout = $handler->getTimeout();
226230
}
227231
}
228232

@@ -252,10 +256,10 @@ public function __construct($requestHandlerOrLoop)
252256
* doing anything with the request.
253257
*/
254258
$middleware = \array_filter($middleware, function ($handler) {
255-
return !($handler instanceof StreamingRequestMiddleware);
259+
return !($handler instanceof StreamingRequestMiddleware) && !($handler instanceof InactiveConnectionTimeoutMiddleware);
256260
});
257261

258-
$this->streamingServer = new StreamingServer($loop, new MiddlewareRunner($middleware));
262+
$this->streamingServer = new StreamingServer($loop, new MiddlewareRunner($middleware), $idleConnectionTimeout);
259263

260264
$that = $this;
261265
$this->streamingServer->on('error', function ($error) use ($that) {

src/Io/StreamingServer.php

+97-7
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ final class StreamingServer extends EventEmitter
8787
/** @var Clock */
8888
private $clock;
8989

90+
/** @var LoopInterface */
91+
private $loop;
92+
93+
/** @var int */
94+
private $idleConnectionTimeout;
95+
9096
/**
9197
* Creates an HTTP server that invokes the given callback for each incoming HTTP request
9298
*
@@ -95,19 +101,21 @@ final class StreamingServer extends EventEmitter
95101
* connections in order to then parse incoming data as HTTP.
96102
* See also [listen()](#listen) for more details.
97103
*
98-
* @param LoopInterface $loop
99104
* @param callable $requestHandler
105+
* @param int $idleConnectionTimeout
100106
* @see self::listen()
101107
*/
102-
public function __construct(LoopInterface $loop, $requestHandler)
108+
public function __construct(LoopInterface $loop, $requestHandler, $idleConnectionTimeout)
103109
{
104110
if (!\is_callable($requestHandler)) {
105111
throw new \InvalidArgumentException('Invalid request handler given');
106112
}
107113

114+
$this->loop = $loop;
108115
$this->callback = $requestHandler;
109116
$this->clock = new Clock($loop);
110117
$this->parser = new RequestHeaderParser($this->clock);
118+
$this->idleConnectionTimeout = $idleConnectionTimeout;
111119

112120
$that = $this;
113121
$this->parser->on('headers', function (ServerRequestInterface $request, ConnectionInterface $conn) use ($that) {
@@ -134,7 +142,35 @@ public function __construct(LoopInterface $loop, $requestHandler)
134142
*/
135143
public function listen(ServerInterface $socket)
136144
{
137-
$socket->on('connection', array($this->parser, 'handle'));
145+
$socket->on('connection', array($this, 'handleConnection'));
146+
}
147+
148+
/** @internal */
149+
public function handleConnection(ConnectionInterface $connection)
150+
{
151+
$idleConnectionTimeout = $this->idleConnectionTimeout;
152+
$loop = $this->loop;
153+
$idleConnectionTimeoutHandler = function () use ($connection, &$closeEventHandler, &$dataEventHandler) {
154+
$connection->removeListener('close', $closeEventHandler);
155+
$connection->removeListener('data', $dataEventHandler);
156+
157+
$connection->close();
158+
};
159+
$timer = $this->loop->addTimer($idleConnectionTimeout, $idleConnectionTimeoutHandler);
160+
$closeEventHandler = function () use ($connection, &$closeEventHandler, &$dataEventHandler, $loop, &$timer) {
161+
$connection->removeListener('close', $closeEventHandler);
162+
$connection->removeListener('data', $dataEventHandler);
163+
164+
$loop->cancelTimer($timer);
165+
};
166+
$dataEventHandler = function () use ($loop, $idleConnectionTimeout, $idleConnectionTimeoutHandler, &$timer) {
167+
$loop->cancelTimer($timer);
168+
$timer = $loop->addTimer($idleConnectionTimeout, $idleConnectionTimeoutHandler);
169+
};
170+
$connection->on('close', $closeEventHandler);
171+
$connection->on('data', $dataEventHandler);
172+
173+
$this->parseRequest($connection);
138174
}
139175

140176
/** @internal */
@@ -372,7 +408,7 @@ public function handleResponse(ConnectionInterface $connection, ServerRequestInt
372408

373409
// either wait for next request over persistent connection or end connection
374410
if ($persist) {
375-
$this->parser->handle($connection);
411+
$this->parseRequest($connection);
376412
} else {
377413
$connection->end();
378414
}
@@ -393,13 +429,67 @@ public function handleResponse(ConnectionInterface $connection, ServerRequestInt
393429
// write streaming body and then wait for next request over persistent connection
394430
if ($persist) {
395431
$body->pipe($connection, array('end' => false));
396-
$parser = $this->parser;
397-
$body->on('end', function () use ($connection, $parser, $body) {
432+
$that = $this;
433+
$body->on('end', function () use ($connection, $body, &$that) {
398434
$connection->removeListener('close', array($body, 'close'));
399-
$parser->handle($connection);
435+
$that->parseRequest($connection);
400436
});
401437
} else {
402438
$body->pipe($connection);
403439
}
404440
}
441+
442+
/**
443+
* @internal
444+
*/
445+
public function parseRequest(ConnectionInterface $connection)
446+
{
447+
$idleConnectionTimeout = $this->idleConnectionTimeout;
448+
$loop = $this->loop;
449+
$parser = $this->parser;
450+
$idleConnectionTimeoutHandler = function () use ($connection, $parser, &$removeTimerHandler) {
451+
$parser->removeListener('headers', $removeTimerHandler);
452+
$parser->removeListener('error', $removeTimerHandler);
453+
454+
$parser->emit('error', array(
455+
new \RuntimeException('Request timed out', Response::STATUS_REQUEST_TIMEOUT),
456+
$connection
457+
));
458+
};
459+
$timer = $this->loop->addTimer($idleConnectionTimeout, $idleConnectionTimeoutHandler);
460+
$removeTimerHandler = function ($requestOrError, $conn) use ($loop, &$timer, $parser, $connection, &$removeTimerHandler, $idleConnectionTimeout, $idleConnectionTimeoutHandler) {
461+
if ($conn !== $connection) {
462+
return;
463+
}
464+
465+
$loop->cancelTimer($timer);
466+
$parser->removeListener('headers', $removeTimerHandler);
467+
$parser->removeListener('error', $removeTimerHandler);
468+
469+
if (!($requestOrError instanceof ServerRequestInterface)) {
470+
return;
471+
}
472+
473+
$requestBody = $requestOrError->getBody();
474+
if (!($requestBody instanceof HttpBodyStream)) {
475+
return;
476+
}
477+
478+
$timer = $loop->addTimer($idleConnectionTimeout, $idleConnectionTimeoutHandler);
479+
$requestBody->on('data', function () use (&$timer, $loop, $idleConnectionTimeout, $idleConnectionTimeoutHandler) {
480+
$loop->cancelTimer($timer);
481+
$timer = $loop->addTimer($idleConnectionTimeout, $idleConnectionTimeoutHandler);
482+
});
483+
$requestBody->on('end', function () use (&$timer, $loop) {
484+
$loop->cancelTimer($timer);
485+
});
486+
$requestBody->on('close', function () use (&$timer, $loop) {
487+
$loop->cancelTimer($timer);
488+
});
489+
};
490+
$this->parser->on('headers', $removeTimerHandler);
491+
$this->parser->on('error', $removeTimerHandler);
492+
493+
$this->parser->handle($connection);
494+
}
405495
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
namespace React\Http\Middleware;
4+
5+
use Psr\Http\Message\ResponseInterface;
6+
use Psr\Http\Message\ServerRequestInterface;
7+
use React\Http\Io\HttpBodyStream;
8+
use React\Http\Io\PauseBufferStream;
9+
use React\Promise;
10+
use React\Promise\PromiseInterface;
11+
use React\Promise\Deferred;
12+
use React\Stream\ReadableStreamInterface;
13+
14+
/**
15+
* Closes any inactive connection after the specified amount of seconds since last activity.
16+
*
17+
* This allows you to set an alternative timeout to the default one minute (60 seconds). For example
18+
* thirteen and a half seconds:
19+
*
20+
* ```php
21+
* $http = new React\Http\HttpServer(
22+
* new React\Http\Middleware\InactiveConnectionTimeoutMiddleware(13.5),
23+
* $handler
24+
* );
25+
*
26+
* > Internally, this class is used as a "value object" to override the default timeout of one minute.
27+
* As such it doesn't have any behavior internally, that is all in the internal "StreamingServer".
28+
*/
29+
final class InactiveConnectionTimeoutMiddleware
30+
{
31+
/**
32+
* @internal
33+
*/
34+
const DEFAULT_TIMEOUT = 60;
35+
36+
/**
37+
* @var float
38+
*/
39+
private $timeout;
40+
41+
/**
42+
* @param float $timeout
43+
*/
44+
public function __construct($timeout = self::DEFAULT_TIMEOUT)
45+
{
46+
$this->timeout = $timeout;
47+
}
48+
49+
public function __invoke(ServerRequestInterface $request, $next)
50+
{
51+
return $next($request);
52+
}
53+
54+
/**
55+
* @return float
56+
* @internal
57+
*/
58+
public function getTimeout()
59+
{
60+
return $this->timeout;
61+
}
62+
}

tests/FunctionalBrowserTest.php

+5-4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use React\Http\HttpServer;
1010
use React\Http\Message\Response;
1111
use React\Http\Message\ResponseException;
12+
use React\Http\Middleware\InactiveConnectionTimeoutMiddleware;
1213
use React\Http\Middleware\StreamingRequestMiddleware;
1314
use React\Promise\Promise;
1415
use React\Promise\Stream;
@@ -32,7 +33,7 @@ public function setUpBrowserAndServer()
3233
{
3334
$this->browser = new Browser();
3435

35-
$http = new HttpServer(new StreamingRequestMiddleware(), function (ServerRequestInterface $request) {
36+
$http = new HttpServer(new InactiveConnectionTimeoutMiddleware(0.2), new StreamingRequestMiddleware(), function (ServerRequestInterface $request) {
3637
$path = $request->getUri()->getPath();
3738

3839
$headers = array();
@@ -687,7 +688,7 @@ public function testPostStreamKnownLength()
687688
*/
688689
public function testPostStreamWillStartSendingRequestEvenWhenBodyDoesNotEmitData()
689690
{
690-
$http = new HttpServer(new StreamingRequestMiddleware(), function (ServerRequestInterface $request) {
691+
$http = new HttpServer(new InactiveConnectionTimeoutMiddleware(0.1), new StreamingRequestMiddleware(), function (ServerRequestInterface $request) {
691692
return new Response(200);
692693
});
693694
$socket = new SocketServer('127.0.0.1:0');
@@ -714,7 +715,7 @@ public function testPostStreamClosed()
714715

715716
public function testSendsHttp11ByDefault()
716717
{
717-
$http = new HttpServer(function (ServerRequestInterface $request) {
718+
$http = new HttpServer(new InactiveConnectionTimeoutMiddleware(0.1), function (ServerRequestInterface $request) {
718719
return new Response(
719720
200,
720721
array(),
@@ -734,7 +735,7 @@ public function testSendsHttp11ByDefault()
734735

735736
public function testSendsExplicitHttp10Request()
736737
{
737-
$http = new HttpServer(function (ServerRequestInterface $request) {
738+
$http = new HttpServer(new InactiveConnectionTimeoutMiddleware(0.1), function (ServerRequestInterface $request) {
738739
return new Response(
739740
200,
740741
array(),

0 commit comments

Comments
 (0)