Skip to content

Commit 5463a48

Browse files
committed
Include timeout logic to avoid dependency on reactphp/promise-timer
1 parent d449e24 commit 5463a48

File tree

3 files changed

+121
-20
lines changed

3 files changed

+121
-20
lines changed

composer.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@
2929
"php": ">=5.3.0",
3030
"react/cache": "^1.0 || ^0.6 || ^0.5",
3131
"react/event-loop": "^1.2",
32-
"react/promise": "^3.0 || ^2.7 || ^1.2.1",
33-
"react/promise-timer": "^1.9"
32+
"react/promise": "^3.0 || ^2.7 || ^1.2.1"
3433
},
3534
"require-dev": {
3635
"phpunit/phpunit": "^9.5 || ^4.8.35",
37-
"react/async": "^4 || ^3 || ^2"
36+
"react/async": "^4 || ^3 || ^2",
37+
"react/promise-timer": "^1.9"
3838
},
3939
"autoload": {
4040
"psr-4": {

src/Query/TimeoutExecutor.php

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

55
use React\EventLoop\Loop;
66
use React\EventLoop\LoopInterface;
7-
use React\Promise\Timer;
7+
use React\Promise\Promise;
88

99
final class TimeoutExecutor implements ExecutorInterface
1010
{
@@ -21,11 +21,49 @@ public function __construct(ExecutorInterface $executor, $timeout, LoopInterface
2121

2222
public function query(Query $query)
2323
{
24-
return Timer\timeout($this->executor->query($query), $this->timeout, $this->loop)->then(null, function ($e) use ($query) {
25-
if ($e instanceof Timer\TimeoutException) {
26-
$e = new TimeoutException(sprintf("DNS query for %s timed out", $query->describe()), 0, $e);
24+
$promise = $this->executor->query($query);
25+
26+
$loop = $this->loop;
27+
$time = $this->timeout;
28+
return new Promise(function ($resolve, $reject) use ($loop, $time, $promise, $query) {
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;
2747
}
28-
throw $e;
48+
49+
// start timeout timer which will cancel the pending promise
50+
$timer = $loop->addTimer($time, function () use ($time, &$promise, $reject, $query) {
51+
$reject(new TimeoutException(
52+
'DNS query for ' . $query->describe() . ' timed out'
53+
));
54+
55+
// Cancel pending query to clean up any underlying resources and references.
56+
// Avoid garbage references in call stack by passing pending promise by reference.
57+
assert(\method_exists($promise, 'cancel'));
58+
$promise->cancel();
59+
$promise = null;
60+
});
61+
}, function () use (&$promise) {
62+
// Cancelling this promise will cancel the pending query, thus triggering the rejection logic above.
63+
// Avoid garbage references in call stack by passing pending promise by reference.
64+
assert(\method_exists($promise, 'cancel'));
65+
$promise->cancel();
66+
$promise = null;
2967
});
3068
}
3169
}

tests/Query/TimeoutExecutorTest.php

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class TimeoutExecutorTest extends TestCase
1515
{
1616
private $wrapped;
1717
private $executor;
18+
private $loop;
1819

1920
/**
2021
* @before
@@ -23,7 +24,9 @@ public function setUpExecutor()
2324
{
2425
$this->wrapped = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock();
2526

26-
$this->executor = new TimeoutExecutor($this->wrapped, 5.0);
27+
$this->loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
28+
29+
$this->executor = new TimeoutExecutor($this->wrapped, 5.0, $this->loop);
2730
}
2831

2932
public function testCtorWithoutLoopShouldAssignDefaultLoop()
@@ -39,6 +42,10 @@ public function testCtorWithoutLoopShouldAssignDefaultLoop()
3942

4043
public function testCancellingPromiseWillCancelWrapped()
4144
{
45+
$timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock();
46+
$this->loop->expects($this->once())->method('addTimer')->with(5.0, $this->anything())->willReturn($timer);
47+
$this->loop->expects($this->once())->method('cancelTimer')->with($timer);
48+
4249
$cancelled = 0;
4350

4451
$this->wrapped
@@ -63,8 +70,11 @@ public function testCancellingPromiseWillCancelWrapped()
6370
$promise->then($this->expectCallableNever(), $this->expectCallableOnce());
6471
}
6572

66-
public function testResolvesPromiseWhenWrappedResolves()
73+
public function testResolvesPromiseWithoutStartingTimerWhenWrappedReturnsResolvedPromise()
6774
{
75+
$this->loop->expects($this->never())->method('addTimer');
76+
$this->loop->expects($this->never())->method('cancelTimer');
77+
6878
$this->wrapped
6979
->expects($this->once())
7080
->method('query')
@@ -76,8 +86,31 @@ public function testResolvesPromiseWhenWrappedResolves()
7686
$promise->then($this->expectCallableOnce(), $this->expectCallableNever());
7787
}
7888

79-
public function testRejectsPromiseWhenWrappedRejects()
89+
public function testResolvesPromiseAfterCancellingTimerWhenWrappedReturnsPendingPromiseThatResolves()
90+
{
91+
$timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock();
92+
$this->loop->expects($this->once())->method('addTimer')->with(5.0, $this->anything())->willReturn($timer);
93+
$this->loop->expects($this->once())->method('cancelTimer')->with($timer);
94+
95+
$deferred = new Deferred();
96+
$this->wrapped
97+
->expects($this->once())
98+
->method('query')
99+
->willReturn($deferred->promise());
100+
101+
$query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN);
102+
$promise = $this->executor->query($query);
103+
104+
$deferred->resolve('0.0.0.0');
105+
106+
$promise->then($this->expectCallableOnce(), $this->expectCallableNever());
107+
}
108+
109+
public function testRejectsPromiseWithoutStartingTimerWhenWrappedReturnsRejectedPromise()
80110
{
111+
$this->loop->expects($this->never())->method('addTimer');
112+
$this->loop->expects($this->never())->method('cancelTimer');
113+
81114
$this->wrapped
82115
->expects($this->once())
83116
->method('query')
@@ -89,9 +122,35 @@ public function testRejectsPromiseWhenWrappedRejects()
89122
$promise->then($this->expectCallableNever(), $this->expectCallableOnceWith(new \RuntimeException()));
90123
}
91124

92-
public function testWrappedWillBeCancelledOnTimeout()
125+
public function testRejectsPromiseAfterCancellingTimerWhenWrappedReturnsPendingPromiseThatRejects()
126+
{
127+
$timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock();
128+
$this->loop->expects($this->once())->method('addTimer')->with(5.0, $this->anything())->willReturn($timer);
129+
$this->loop->expects($this->once())->method('cancelTimer')->with($timer);
130+
131+
$deferred = new Deferred();
132+
$this->wrapped
133+
->expects($this->once())
134+
->method('query')
135+
->willReturn($deferred->promise());
136+
137+
$query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN);
138+
$promise = $this->executor->query($query);
139+
140+
$deferred->reject(new \RuntimeException());
141+
142+
$promise->then($this->expectCallableNever(), $this->expectCallableOnceWith(new \RuntimeException()));
143+
}
144+
145+
public function testRejectsPromiseAndCancelsPendingQueryWhenTimeoutTriggers()
93146
{
94-
$this->executor = new TimeoutExecutor($this->wrapped, 0);
147+
$timerCallback = null;
148+
$timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock();
149+
$this->loop->expects($this->once())->method('addTimer')->with(5.0, $this->callback(function ($callback) use (&$timerCallback) {
150+
$timerCallback = $callback;
151+
return true;
152+
}))->willReturn($timer);
153+
$this->loop->expects($this->once())->method('cancelTimer')->with($timer);
95154

96155
$cancelled = 0;
97156

@@ -112,14 +171,18 @@ public function testWrappedWillBeCancelledOnTimeout()
112171

113172
$this->assertEquals(0, $cancelled);
114173

115-
try {
116-
\React\Async\await(\React\Promise\Timer\sleep(0));
117-
\React\Async\await($promise);
118-
$this->fail();
119-
} catch (TimeoutException $exception) {
120-
$this->assertEquals('DNS query for igor.io (A) timed out' , $exception->getMessage());
121-
}
174+
$this->assertNotNull($timerCallback);
175+
$timerCallback();
122176

123177
$this->assertEquals(1, $cancelled);
178+
179+
$exception = null;
180+
$promise->then(null, function ($reason) use (&$exception) {
181+
$exception = $reason;
182+
});
183+
184+
assert($exception instanceof TimeoutException);
185+
$this->assertInstanceOf('React\Dns\Query\TimeoutException', $exception);
186+
$this->assertEquals('DNS query for igor.io (A) timed out' , $exception->getMessage());
124187
}
125188
}

0 commit comments

Comments
 (0)