Skip to content

Commit 17306ce

Browse files
authored
Merge pull request #305 from clue-labs/no-timeout
Include timeout logic to avoid dependency on reactphp/promise-timer
2 parents bf11696 + 78dc111 commit 17306ce

File tree

3 files changed

+179
-75
lines changed

3 files changed

+179
-75
lines changed

composer.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,16 @@
2828
"require": {
2929
"php": ">=5.3.0",
3030
"evenement/evenement": "^3.0 || ^2.0 || ^1.0",
31-
"react/dns": "^1.8",
31+
"react/dns": "^1.11",
3232
"react/event-loop": "^1.2",
3333
"react/promise": "^3 || ^2.6 || ^1.2.1",
34-
"react/promise-timer": "^1.9",
3534
"react/stream": "^1.2"
3635
},
3736
"require-dev": {
3837
"phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35",
3938
"react/async": "^4 || ^3 || ^2",
40-
"react/promise-stream": "^1.4"
39+
"react/promise-stream": "^1.4",
40+
"react/promise-timer": "^1.9"
4141
},
4242
"autoload": {
4343
"psr-4": {

src/TimeoutConnector.php

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44

55
use React\EventLoop\Loop;
66
use React\EventLoop\LoopInterface;
7-
use React\Promise\Timer;
8-
use React\Promise\Timer\TimeoutException;
7+
use React\Promise\Promise;
98

109
final class TimeoutConnector implements ConnectorInterface
1110
{
@@ -22,30 +21,50 @@ public function __construct(ConnectorInterface $connector, $timeout, LoopInterfa
2221

2322
public function connect($uri)
2423
{
25-
return Timer\timeout($this->connector->connect($uri), $this->timeout, $this->loop)->then(null, self::handler($uri));
26-
}
24+
$promise = $this->connector->connect($uri);
2725

28-
/**
29-
* Creates a static rejection handler that reports a proper error message in case of a timeout.
30-
*
31-
* This uses a private static helper method to ensure this closure is not
32-
* bound to this instance and the exception trace does not include a
33-
* reference to this instance and its connector stack as a result.
34-
*
35-
* @param string $uri
36-
* @return callable
37-
*/
38-
private static function handler($uri)
39-
{
40-
return function (\Exception $e) use ($uri) {
41-
if ($e instanceof TimeoutException) {
42-
throw new \RuntimeException(
43-
'Connection to ' . $uri . ' timed out after ' . $e->getTimeout() . ' seconds (ETIMEDOUT)',
44-
\defined('SOCKET_ETIMEDOUT') ? \SOCKET_ETIMEDOUT : 110
45-
);
26+
$loop = $this->loop;
27+
$time = $this->timeout;
28+
return new Promise(function ($resolve, $reject) use ($loop, $time, $promise, $uri) {
29+
$timer = null;
30+
$promise = $promise->then(function ($v) use (&$timer, $loop, $resolve) {
31+
if ($timer) {
32+
$loop->cancelTimer($timer);
33+
}
34+
$timer = false;
35+
$resolve($v);
36+
}, function ($v) use (&$timer, $loop, $reject) {
37+
if ($timer) {
38+
$loop->cancelTimer($timer);
39+
}
40+
$timer = false;
41+
$reject($v);
42+
});
43+
44+
// promise already resolved => no need to start timer
45+
if ($timer === false) {
46+
return;
4647
}
4748

48-
throw $e;
49-
};
49+
// start timeout timer which will cancel the pending promise
50+
$timer = $loop->addTimer($time, function () use ($time, &$promise, $reject, $uri) {
51+
$reject(new \RuntimeException(
52+
'Connection to ' . $uri . ' timed out after ' . $time . ' seconds (ETIMEDOUT)',
53+
\defined('SOCKET_ETIMEDOUT') ? \SOCKET_ETIMEDOUT : 110
54+
));
55+
56+
// Cancel pending connection to clean up any underlying resources and references.
57+
// Avoid garbage references in call stack by passing pending promise by reference.
58+
assert(\method_exists($promise, 'cancel'));
59+
$promise->cancel();
60+
$promise = null;
61+
});
62+
}, function () use (&$promise) {
63+
// Cancelling this promise will cancel the pending connection, thus triggering the rejection logic above.
64+
// Avoid garbage references in call stack by passing pending promise by reference.
65+
assert(\method_exists($promise, 'cancel'));
66+
$promise->cancel();
67+
$promise = null;
68+
});
5069
}
5170
}

tests/TimeoutConnectorTest.php

Lines changed: 133 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
namespace React\Tests\Socket;
44

55
use React\EventLoop\Loop;
6-
use React\Promise;
76
use React\Promise\Deferred;
7+
use React\Promise\Promise;
88
use React\Socket\TimeoutConnector;
99

1010
class TimeoutConnectorTest extends TestCase
@@ -22,90 +22,175 @@ public function testConstructWithoutLoopAssignsLoopAutomatically()
2222
$this->assertInstanceOf('React\EventLoop\LoopInterface', $loop);
2323
}
2424

25-
public function testRejectsWithTimeoutReasonOnTimeout()
25+
public function testRejectsPromiseWithoutStartingTimerWhenWrappedConnectorReturnsRejectedPromise()
2626
{
27-
$promise = new Promise\Promise(function () { });
27+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
28+
$loop->expects($this->never())->method('addTimer');
29+
$loop->expects($this->never())->method('cancelTimer');
2830

2931
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
30-
$connector->expects($this->once())->method('connect')->with('google.com:80')->will($this->returnValue($promise));
32+
$connector->expects($this->once())->method('connect')->with('example.com:80')->willReturn(\React\Promise\reject(new \RuntimeException('Failed', 42)));
3133

32-
$timeout = new TimeoutConnector($connector, 0.01);
34+
$timeout = new TimeoutConnector($connector, 5.0, $loop);
3335

34-
$promise = $timeout->connect('google.com:80');
35-
Loop::run();
36+
$promise = $timeout->connect('example.com:80');
37+
38+
$exception = null;
39+
$promise->then(null, function ($reason) use (&$exception) {
40+
$exception = $reason;
41+
});
3642

37-
$this->setExpectedException(
38-
'RuntimeException',
39-
'Connection to google.com:80 timed out after 0.01 seconds (ETIMEDOUT)',
40-
\defined('SOCKET_ETIMEDOUT') ? \SOCKET_ETIMEDOUT : 110
41-
);
42-
\React\Async\await($promise);
43+
assert($exception instanceof \RuntimeException);
44+
$this->assertEquals('Failed', $exception->getMessage());
45+
$this->assertEquals(42, $exception->getCode());
4346
}
4447

45-
public function testRejectsWithOriginalReasonWhenConnectorRejects()
48+
public function testRejectsPromiseAfterCancellingTimerWhenWrappedConnectorReturnsPendingPromiseThatRejects()
4649
{
47-
$promise = Promise\reject(new \RuntimeException('Failed', 42));
50+
$timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock();
51+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
52+
$loop->expects($this->once())->method('addTimer')->with(5.0, $this->anything())->willReturn($timer);
53+
$loop->expects($this->once())->method('cancelTimer')->with($timer);
4854

55+
$deferred = new Deferred();
4956
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
50-
$connector->expects($this->once())->method('connect')->with('google.com:80')->will($this->returnValue($promise));
57+
$connector->expects($this->once())->method('connect')->with('example.com:80')->willReturn($deferred->promise());
5158

52-
$timeout = new TimeoutConnector($connector, 5.0);
59+
$timeout = new TimeoutConnector($connector, 5.0, $loop);
60+
61+
$promise = $timeout->connect('example.com:80');
62+
63+
$deferred->reject(new \RuntimeException('Failed', 42));
64+
65+
$exception = null;
66+
$promise->then(null, function ($reason) use (&$exception) {
67+
$exception = $reason;
68+
});
5369

54-
$this->setExpectedException(
55-
'RuntimeException',
56-
'Failed',
57-
42
58-
);
59-
\React\Async\await($timeout->connect('google.com:80'));
70+
assert($exception instanceof \RuntimeException);
71+
$this->assertEquals('Failed', $exception->getMessage());
72+
$this->assertEquals(42, $exception->getCode());
6073
}
6174

62-
public function testResolvesWhenConnectorResolves()
75+
public function testResolvesPromiseWithoutStartingTimerWhenWrappedConnectorReturnsResolvedPromise()
6376
{
64-
$promise = Promise\resolve(null);
77+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
78+
$loop->expects($this->never())->method('addTimer');
79+
$loop->expects($this->never())->method('cancelTimer');
6580

81+
$connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock();
6682
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
67-
$connector->expects($this->once())->method('connect')->with('google.com:80')->will($this->returnValue($promise));
83+
$connector->expects($this->once())->method('connect')->with('example.com:80')->willReturn(\React\Promise\resolve($connection));
6884

69-
$timeout = new TimeoutConnector($connector, 5.0);
85+
$timeout = new TimeoutConnector($connector, 5.0, $loop);
7086

71-
$timeout->connect('google.com:80')->then(
72-
$this->expectCallableOnce(),
73-
$this->expectCallableNever()
74-
);
87+
$promise = $timeout->connect('example.com:80');
7588

76-
Loop::run();
89+
$resolved = null;
90+
$promise->then(function ($value) use (&$resolved) {
91+
$resolved = $value;
92+
});
93+
94+
$this->assertSame($connection, $resolved);
7795
}
7896

79-
public function testRejectsAndCancelsPendingPromiseOnTimeout()
97+
public function testResolvesPromiseAfterCancellingTimerWhenWrappedConnectorReturnsPendingPromiseThatResolves()
8098
{
81-
$promise = new Promise\Promise(function () { }, $this->expectCallableOnce());
99+
$timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock();
100+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
101+
$loop->expects($this->once())->method('addTimer')->with(5.0, $this->anything())->willReturn($timer);
102+
$loop->expects($this->once())->method('cancelTimer')->with($timer);
82103

104+
$deferred = new Deferred();
83105
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
84-
$connector->expects($this->once())->method('connect')->with('google.com:80')->will($this->returnValue($promise));
106+
$connector->expects($this->once())->method('connect')->with('example.com:80')->willReturn($deferred->promise());
85107

86-
$timeout = new TimeoutConnector($connector, 0.01);
108+
$timeout = new TimeoutConnector($connector, 5.0, $loop);
87109

88-
$timeout->connect('google.com:80')->then(
89-
$this->expectCallableNever(),
90-
$this->expectCallableOnce()
91-
);
110+
$promise = $timeout->connect('example.com:80');
92111

93-
Loop::run();
112+
$connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock();
113+
$deferred->resolve($connection);
114+
115+
$resolved = null;
116+
$promise->then(function ($value) use (&$resolved) {
117+
$resolved = $value;
118+
});
119+
120+
$this->assertSame($connection, $resolved);
94121
}
95122

96-
public function testCancelsPendingPromiseOnCancel()
123+
public function testRejectsPromiseAndCancelsPendingConnectionWhenTimeoutTriggers()
97124
{
98-
$promise = new Promise\Promise(function () { }, function () { throw new \Exception(); });
125+
$timerCallback = null;
126+
$timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock();
127+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
128+
$loop->expects($this->once())->method('addTimer')->with(0.01, $this->callback(function ($callback) use (&$timerCallback) {
129+
$timerCallback = $callback;
130+
return true;
131+
}))->willReturn($timer);
132+
$loop->expects($this->once())->method('cancelTimer')->with($timer);
133+
134+
$cancelled = 0;
135+
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
136+
$connector->expects($this->once())->method('connect')->with('example.com:80')->willReturn(new Promise(function () { }, function () use (&$cancelled) {
137+
++$cancelled;
138+
throw new \RuntimeException();
139+
}));
140+
141+
$timeout = new TimeoutConnector($connector, 0.01, $loop);
142+
143+
$promise = $timeout->connect('example.com:80');
144+
145+
$this->assertEquals(0, $cancelled);
146+
147+
$this->assertNotNull($timerCallback);
148+
$timerCallback();
149+
150+
$this->assertEquals(1, $cancelled);
151+
152+
$exception = null;
153+
$promise->then(null, function ($reason) use (&$exception) {
154+
$exception = $reason;
155+
});
156+
157+
assert($exception instanceof \RuntimeException);
158+
$this->assertEquals('Connection to example.com:80 timed out after 0.01 seconds (ETIMEDOUT)' , $exception->getMessage());
159+
$this->assertEquals(\defined('SOCKET_ETIMEDOUT') ? \SOCKET_ETIMEDOUT : 110, $exception->getCode());
160+
}
161+
162+
public function testCancellingPromiseWillCancelPendingConnectionAndRejectPromise()
163+
{
164+
$timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock();
165+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
166+
$loop->expects($this->once())->method('addTimer')->with(0.01, $this->anything())->willReturn($timer);
167+
$loop->expects($this->once())->method('cancelTimer')->with($timer);
99168

169+
$cancelled = 0;
100170
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
101-
$connector->expects($this->once())->method('connect')->with('google.com:80')->will($this->returnValue($promise));
171+
$connector->expects($this->once())->method('connect')->with('example.com:80')->willReturn(new Promise(function () { }, function () use (&$cancelled) {
172+
++$cancelled;
173+
throw new \RuntimeException('Cancelled');
174+
}));
102175

103-
$timeout = new TimeoutConnector($connector, 0.01);
176+
$timeout = new TimeoutConnector($connector, 0.01, $loop);
177+
178+
$promise = $timeout->connect('example.com:80');
179+
180+
$this->assertEquals(0, $cancelled);
181+
182+
assert(method_exists($promise, 'cancel'));
183+
$promise->cancel();
104184

105-
$out = $timeout->connect('google.com:80');
106-
$out->cancel();
185+
$this->assertEquals(1, $cancelled);
186+
187+
$exception = null;
188+
$promise->then(null, function ($reason) use (&$exception) {
189+
$exception = $reason;
190+
});
107191

108-
$out->then($this->expectCallableNever(), $this->expectCallableOnce());
192+
assert($exception instanceof \RuntimeException);
193+
$this->assertEquals('Cancelled', $exception->getMessage());
109194
}
110195

111196
public function testRejectionDuringConnectionShouldNotCreateAnyGarbageReferences()

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy