Skip to content

Commit b400a57

Browse files
committed
Fix cancelling happy eyeballs to stop timer and fix rejection reason
1 parent e243955 commit b400a57

File tree

4 files changed

+239
-56
lines changed

4 files changed

+239
-56
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"php": ">=5.3.0",
88
"evenement/evenement": "^3.0 || ^2.0 || ^1.0",
99
"react/dns": "^1.1",
10-
"react/event-loop": "^1.0 || ^0.5 || ^0.4 || ^0.3.5",
10+
"react/event-loop": "^1.0 || ^0.5",
1111
"react/promise": "^2.6.0 || ^1.2.1",
1212
"react/promise-timer": "^1.4.0",
1313
"react/stream": "^1.1"

src/HappyEyeBallsConnectionBuilder.php

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,9 @@ public function __construct(LoopInterface $loop, ConnectorInterface $connector,
4848

4949
public function connect()
5050
{
51+
$timer = null;
5152
$that = $this;
52-
return new Promise\Promise(function ($resolve, $reject) use ($that) {
53+
return new Promise\Promise(function ($resolve, $reject) use ($that, &$timer) {
5354
$lookupResolve = function ($type) use ($that, $resolve, $reject) {
5455
return function (array $ips) use ($that, $type, $resolve, $reject) {
5556
unset($that->resolverPromises[$type]);
@@ -66,7 +67,6 @@ public function connect()
6667
};
6768

6869
$ipv4Deferred = null;
69-
$timer = null;
7070
$that->resolverPromises[Message::TYPE_AAAA] = $that->resolve(Message::TYPE_AAAA, $reject)->then($lookupResolve(Message::TYPE_AAAA))->then(function () use (&$ipv4Deferred) {
7171
if ($ipv4Deferred instanceof Promise\Deferred) {
7272
$ipv4Deferred->resolve();
@@ -99,15 +99,13 @@ public function connect()
9999
return $deferred->promise();
100100
})->then($lookupResolve(Message::TYPE_A));
101101
}, function ($_, $reject) use ($that, &$timer) {
102-
$that->cleanUp();
102+
$reject(new \RuntimeException('Connection to ' . $that->uri . ' cancelled' . (!$that->connectionPromises ? ' during DNS lookup' : '')));
103+
$_ = $reject = null;
103104

105+
$that->cleanUp();
104106
if ($timer instanceof TimerInterface) {
105107
$that->loop->cancelTimer($timer);
106108
}
107-
108-
$reject(new \RuntimeException('Connection to ' . $that->uri . ' cancelled during DNS lookup'));
109-
110-
$_ = $reject = null;
111109
});
112110
}
113111

tests/HappyEyeBallsConnectionBuilderTest.php

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,242 @@
44

55
use React\Promise\Promise;
66
use React\Socket\HappyEyeBallsConnectionBuilder;
7+
use React\Dns\Model\Message;
8+
use React\Promise\Deferred;
79

810
class HappyEyeBallsConnectionBuilderTest extends TestCase
911
{
12+
public function testConnectWillResolveTwiceViaResolver()
13+
{
14+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
15+
$loop->expects($this->never())->method('addTimer');
16+
17+
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
18+
$connector->expects($this->never())->method('connect');
19+
20+
$resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock();
21+
$resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive(
22+
array('reactphp.org', Message::TYPE_AAAA),
23+
array('reactphp.org', Message::TYPE_A)
24+
)->willReturn(new Promise(function () { }));
25+
26+
$uri = 'tcp://reactphp.org:80';
27+
$host = 'reactphp.org';
28+
$parts = parse_url($uri);
29+
30+
$builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts);
31+
32+
$builder->connect();
33+
}
34+
35+
public function testConnectWillStartTimerWhenIpv4ResolvesAndIpv6IsPending()
36+
{
37+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
38+
$loop->expects($this->once())->method('addTimer');
39+
$loop->expects($this->never())->method('cancelTimer');
40+
41+
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
42+
$connector->expects($this->never())->method('connect');
43+
44+
$resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock();
45+
$resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive(
46+
array('reactphp.org', Message::TYPE_AAAA),
47+
array('reactphp.org', Message::TYPE_A)
48+
)->willReturnOnConsecutiveCalls(
49+
new Promise(function () { }),
50+
\React\Promise\resolve(array('127.0.0.1'))
51+
);
52+
53+
$uri = 'tcp://reactphp.org:80';
54+
$host = 'reactphp.org';
55+
$parts = parse_url($uri);
56+
57+
$builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts);
58+
59+
$builder->connect();
60+
}
61+
62+
public function testConnectWillStartConnectingWithoutTimerWhenIpv6ResolvesAndIpv4IsPending()
63+
{
64+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
65+
$loop->expects($this->never())->method('addTimer');
66+
67+
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
68+
$connector->expects($this->once())->method('connect')->with('tcp://[::1]:80?hostname=reactphp.org')->willReturn(new Promise(function () { }));
69+
70+
$resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock();
71+
$resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive(
72+
array('reactphp.org', Message::TYPE_AAAA),
73+
array('reactphp.org', Message::TYPE_A)
74+
)->willReturnOnConsecutiveCalls(
75+
\React\Promise\resolve(array('::1')),
76+
new Promise(function () { })
77+
);
78+
79+
$uri = 'tcp://reactphp.org:80';
80+
$host = 'reactphp.org';
81+
$parts = parse_url($uri);
82+
83+
$builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts);
84+
85+
$builder->connect();
86+
}
87+
88+
public function testConnectWillStartTimerAndCancelTimerWhenIpv4ResolvesAndIpv6ResolvesAfterwardsAndStartConnectingToIpv6()
89+
{
90+
$timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock();
91+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
92+
$loop->expects($this->once())->method('addTimer')->willReturn($timer);
93+
$loop->expects($this->once())->method('cancelTimer')->with($timer);
94+
$loop->expects($this->once())->method('addPeriodicTimer')->willReturn($this->getMockBuilder('React\EventLoop\TimerInterface')->getMock());
95+
96+
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
97+
$connector->expects($this->once())->method('connect')->with('tcp://[::1]:80?hostname=reactphp.org')->willReturn(new Promise(function () { }));
98+
99+
$deferred = new Deferred();
100+
$resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock();
101+
$resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive(
102+
array('reactphp.org', Message::TYPE_AAAA),
103+
array('reactphp.org', Message::TYPE_A)
104+
)->willReturnOnConsecutiveCalls(
105+
$deferred->promise(),
106+
\React\Promise\resolve(array('127.0.0.1'))
107+
);
108+
109+
$uri = 'tcp://reactphp.org:80';
110+
$host = 'reactphp.org';
111+
$parts = parse_url($uri);
112+
113+
$builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts);
114+
115+
$builder->connect();
116+
$deferred->resolve(array('::1'));
117+
}
118+
119+
public function testCancelConnectWillRejectPromiseAndCancelBothDnsLookups()
120+
{
121+
$timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock();
122+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
123+
$loop->expects($this->once())->method('addTimer')->willReturn($timer);
124+
$loop->expects($this->once())->method('cancelTimer')->with($timer);
125+
126+
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
127+
$connector->expects($this->never())->method('connect');
128+
129+
$cancelled = 0;
130+
$resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock();
131+
$resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive(
132+
array('reactphp.org', Message::TYPE_AAAA),
133+
array('reactphp.org', Message::TYPE_A)
134+
)->willReturnOnConsecutiveCalls(
135+
new Promise(function () { }, function () use (&$cancelled) {
136+
++$cancelled;
137+
throw new \RuntimeException();
138+
}),
139+
new Promise(function () { }, function () use (&$cancelled) {
140+
++$cancelled;
141+
throw new \RuntimeException();
142+
})
143+
);
144+
145+
$uri = 'tcp://reactphp.org:80';
146+
$host = 'reactphp.org';
147+
$parts = parse_url($uri);
148+
149+
$builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts);
150+
151+
$promise = $builder->connect();
152+
$promise->cancel();
153+
154+
$this->assertEquals(2, $cancelled);
155+
156+
$exception = null;
157+
$promise->then(null, function ($e) use (&$exception) {
158+
$exception = $e;
159+
});
160+
161+
$this->assertInstanceOf('RuntimeException', $exception);
162+
$this->assertEquals('Connection to tcp://reactphp.org:80 cancelled during DNS lookup', $exception->getMessage());
163+
}
164+
165+
public function testCancelConnectWillRejectPromiseAndCancelPendingIpv6LookupAndCancelTimer()
166+
{
167+
$timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock();
168+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
169+
$loop->expects($this->once())->method('addTimer')->willReturn($timer);
170+
$loop->expects($this->once())->method('cancelTimer')->with($timer);
171+
172+
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
173+
$connector->expects($this->never())->method('connect');
174+
175+
$resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock();
176+
$resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive(
177+
array('reactphp.org', Message::TYPE_AAAA),
178+
array('reactphp.org', Message::TYPE_A)
179+
)->willReturnOnConsecutiveCalls(
180+
new Promise(function () { }, $this->expectCallableOnce()),
181+
\React\Promise\resolve(array('127.0.0.1'))
182+
);
183+
184+
$uri = 'tcp://reactphp.org:80';
185+
$host = 'reactphp.org';
186+
$parts = parse_url($uri);
187+
188+
$builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts);
189+
190+
$promise = $builder->connect();
191+
$promise->cancel();
192+
193+
$exception = null;
194+
$promise->then(null, function ($e) use (&$exception) {
195+
$exception = $e;
196+
});
197+
198+
$this->assertInstanceOf('RuntimeException', $exception);
199+
$this->assertEquals('Connection to tcp://reactphp.org:80 cancelled during DNS lookup', $exception->getMessage());
200+
}
201+
202+
public function testCancelConnectWillRejectPromiseAndCancelPendingIpv6ConnectionAttemptAndPendingIpv4Lookup()
203+
{
204+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
205+
$loop->expects($this->never())->method('addTimer');
206+
207+
$cancelled = 0;
208+
$connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock();
209+
$connector->expects($this->once())->method('connect')->with('tcp://[::1]:80?hostname=reactphp.org')->willReturn(new Promise(function () { }, function () use (&$cancelled) {
210+
++$cancelled;
211+
throw new \RuntimeException('Ignored message');
212+
}));
213+
214+
$resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock();
215+
$resolver->expects($this->exactly(2))->method('resolveAll')->withConsecutive(
216+
array('reactphp.org', Message::TYPE_AAAA),
217+
array('reactphp.org', Message::TYPE_A)
218+
)->willReturnOnConsecutiveCalls(
219+
\React\Promise\resolve(array('::1')),
220+
new Promise(function () { }, $this->expectCallableOnce())
221+
);
222+
223+
$uri = 'tcp://reactphp.org:80';
224+
$host = 'reactphp.org';
225+
$parts = parse_url($uri);
226+
227+
$builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts);
228+
229+
$promise = $builder->connect();
230+
$promise->cancel();
231+
232+
$this->assertEquals(1, $cancelled);
233+
234+
$exception = null;
235+
$promise->then(null, function ($e) use (&$exception) {
236+
$exception = $e;
237+
});
238+
239+
$this->assertInstanceOf('RuntimeException', $exception);
240+
$this->assertEquals('Connection to tcp://reactphp.org:80 cancelled', $exception->getMessage());
241+
}
242+
10243
public function testAttemptConnectionWillConnectViaConnectorToGivenIpWithPortAndHostnameFromUriParts()
11244
{
12245
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();

tests/HappyEyeBallsConnectorTest.php

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -414,54 +414,6 @@ public function testCancelDuringTcpConnectionCancelsTcpConnectionIfGivenIp()
414414
$this->loop->run();
415415
}
416416

417-
/**
418-
* @dataProvider provideIpvAddresses
419-
*/
420-
public function testCancelDuringTcpConnectionCancelsTcpConnectionAfterDnsIsResolved(array $ipv6, array $ipv4)
421-
{
422-
$pending = new Promise\Promise(function () { }, $this->expectCallableOnce());
423-
$this->resolver->expects($this->at(0))->method('resolveAll')->with($this->equalTo('example.com'), $this->anything())->willReturn(Promise\resolve($ipv6));
424-
$this->resolver->expects($this->at(1))->method('resolveAll')->with($this->equalTo('example.com'), $this->anything())->willReturn(Promise\resolve($ipv4));
425-
$this->tcp->expects($this->any())->method('connect')->with($this->stringContains(':80?hostname=example.com'))->willReturn($pending);
426-
427-
$promise = $this->connector->connect('example.com:80');
428-
$this->loop->addTimer(0.06 * (count($ipv4) + count($ipv6)), function () use ($promise) {
429-
$promise->cancel();
430-
});
431-
432-
$this->loop->run();
433-
}
434-
435-
/**
436-
* @expectedException RuntimeException
437-
* @expectedExceptionMessage All attempts to connect to "example.com" have failed
438-
* @dataProvider provideIpvAddresses
439-
*/
440-
public function testCancelDuringTcpConnectionCancelsTcpConnectionWithTcpRejectionAfterDnsIsResolved(array $ipv6, array $ipv4)
441-
{
442-
$first = new Deferred();
443-
$second = new Deferred();
444-
$this->resolver->expects($this->at(0))->method('resolveAll')->with($this->equalTo('example.com'), Message::TYPE_AAAA)->willReturn($first->promise());
445-
$this->resolver->expects($this->at(1))->method('resolveAll')->with($this->equalTo('example.com'), Message::TYPE_A)->willReturn($second->promise());
446-
$pending = new Promise\Promise(function () { }, function () {
447-
throw new \RuntimeException('Connection cancelled');
448-
});
449-
$this->tcp->expects($this->exactly(count($ipv6) + count($ipv4)))->method('connect')->with($this->stringContains(':80?hostname=example.com'))->willReturn($pending);
450-
451-
$promise = $this->connector->connect('example.com:80');
452-
$first->resolve($ipv6);
453-
$second->resolve($ipv4);
454-
455-
$that = $this;
456-
$this->loop->addTimer(0.8, function () use ($promise, $that) {
457-
$promise->cancel();
458-
459-
$that->throwRejection($promise);
460-
});
461-
462-
$this->loop->run();
463-
}
464-
465417
/**
466418
* @dataProvider provideIpvAddresses
467419
*/

0 commit comments

Comments
 (0)