Skip to content

Commit 5f8494c

Browse files
authored
Merge pull request #171 from clue-labs/fwrite-error
Improve error handling when sending data to DNS server fails (macOS)
2 parents 900bb63 + 93a2954 commit 5f8494c

File tree

3 files changed

+110
-26
lines changed

3 files changed

+110
-26
lines changed

.github/workflows/ci.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ jobs:
3333
- run: vendor/bin/phpunit --coverage-text -c phpunit.xml.legacy
3434
if: ${{ matrix.php < 7.3 }}
3535

36+
PHPUnit-macOS:
37+
name: PHPUnit (macOS)
38+
runs-on: macos-10.15
39+
continue-on-error: true
40+
steps:
41+
- uses: actions/checkout@v2
42+
- uses: shivammathur/setup-php@v2
43+
with:
44+
php-version: 8.0
45+
coverage: xdebug
46+
- run: composer install
47+
- run: vendor/bin/phpunit --coverage-text
48+
3649
PHPUnit-hhvm:
3750
name: PHPUnit (HHVM)
3851
runs-on: ubuntu-18.04

src/Query/UdpTransportExecutor.php

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ final class UdpTransportExecutor implements ExecutorInterface
9292
private $parser;
9393
private $dumper;
9494

95+
/**
96+
* maximum UDP packet size to send and receive
97+
*
98+
* @var int
99+
*/
100+
private $maxPacketSize = 512;
101+
95102
/**
96103
* @param string $nameserver
97104
* @param LoopInterface $loop
@@ -119,7 +126,7 @@ public function query(Query $query)
119126
$request = Message::createRequestForQuery($query);
120127

121128
$queryData = $this->dumper->toBinary($request);
122-
if (isset($queryData[512])) {
129+
if (isset($queryData[$this->maxPacketSize])) {
123130
return \React\Promise\reject(new \RuntimeException(
124131
'DNS query for ' . $query->name . ' failed: Query too large for UDP transport',
125132
\defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 90
@@ -137,7 +144,20 @@ public function query(Query $query)
137144

138145
// set socket to non-blocking and immediately try to send (fill write buffer)
139146
\stream_set_blocking($socket, false);
140-
\fwrite($socket, $queryData);
147+
$written = @\fwrite($socket, $queryData);
148+
149+
if ($written !== \strlen($queryData)) {
150+
// Write may potentially fail, but most common errors are already caught by connection check above.
151+
// Among others, macOS is known to report here when trying to send to broadcast address.
152+
// This can also be reproduced by writing data exceeding `stream_set_chunk_size()` to a server refusing UDP data.
153+
// fwrite(): send of 8192 bytes failed with errno=111 Connection refused
154+
$error = \error_get_last();
155+
\preg_match('/errno=(\d+) (.+)/', $error['message'], $m);
156+
return \React\Promise\reject(new \RuntimeException(
157+
'DNS query for ' . $query->name . ' failed: Unable to send query to DNS server (' . (isset($m[2]) ? $m[2] : $error['message']) . ')',
158+
isset($m[1]) ? (int) $m[1] : 0
159+
));
160+
}
141161

142162
$loop = $this->loop;
143163
$deferred = new Deferred(function () use ($loop, $socket, $query) {
@@ -148,11 +168,15 @@ public function query(Query $query)
148168
throw new CancellationException('DNS query for ' . $query->name . ' has been cancelled');
149169
});
150170

171+
$max = $this->maxPacketSize;
151172
$parser = $this->parser;
152-
$loop->addReadStream($socket, function ($socket) use ($loop, $deferred, $query, $parser, $request) {
173+
$loop->addReadStream($socket, function ($socket) use ($loop, $deferred, $query, $parser, $request, $max) {
153174
// try to read a single data packet from the DNS server
154175
// ignoring any errors, this is uses UDP packets and not a stream of data
155-
$data = @\fread($socket, 512);
176+
$data = @\fread($socket, $max);
177+
if ($data === false) {
178+
return;
179+
}
156180

157181
try {
158182
$response = $parser->parseMessage($data);

tests/Query/UdpTransportExecutorTest.php

Lines changed: 69 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,75 @@ public function testQueryRejectsIfServerConnectionFails()
120120
$promise = $executor->query($query);
121121

122122
$this->assertInstanceOf('React\Promise\PromiseInterface', $promise);
123-
$promise->then(null, $this->expectCallableOnce());
123+
124+
$exception = null;
125+
$promise->then(null, function ($reason) use (&$exception) {
126+
$exception = $reason;
127+
});
128+
129+
// PHP (Failed to parse address "///") differs from HHVM (Name or service not known)
130+
$this->setExpectedException('RuntimeException', 'Unable to connect to DNS server');
131+
throw $exception;
132+
}
133+
134+
public function testQueryRejectsIfSendToServerFailsAfterConnection()
135+
{
136+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
137+
$loop->expects($this->never())->method('addReadStream');
138+
139+
$executor = new UdpTransportExecutor('0.0.0.0', $loop);
140+
141+
// increase hard-coded maximum packet size to allow sending excessive data
142+
$ref = new \ReflectionProperty($executor, 'maxPacketSize');
143+
$ref->setAccessible(true);
144+
$ref->setValue($executor, PHP_INT_MAX);
145+
146+
$query = new Query(str_repeat('a.', 100000) . '.example', Message::TYPE_A, Message::CLASS_IN);
147+
$promise = $executor->query($query);
148+
149+
$this->assertInstanceOf('React\Promise\PromiseInterface', $promise);
150+
151+
$exception = null;
152+
$promise->then(null, function ($reason) use (&$exception) {
153+
$exception = $reason;
154+
});
155+
156+
// ECONNREFUSED( Connection refused) on Linux, EMSGSIZE (Message too long) on macOS
157+
$this->setExpectedException('RuntimeException', 'Unable to send query to DNS server');
158+
throw $exception;
159+
}
160+
161+
public function testQueryKeepsPendingIfReadFailsBecauseServerRefusesConnection()
162+
{
163+
$socket = null;
164+
$callback = null;
165+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
166+
$loop->expects($this->once())->method('addReadStream')->with($this->callback(function ($ref) use (&$socket) {
167+
$socket = $ref;
168+
return true;
169+
}), $this->callback(function ($ref) use (&$callback) {
170+
$callback = $ref;
171+
return true;
172+
}));
173+
174+
$executor = new UdpTransportExecutor('0.0.0.0', $loop);
175+
176+
$query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN);
177+
$promise = $executor->query($query);
178+
179+
$this->assertNotNull($socket);
180+
$callback($socket);
181+
182+
$this->assertInstanceOf('React\Promise\PromiseInterface', $promise);
183+
184+
$pending = true;
185+
$promise->then(function () use (&$pending) {
186+
$pending = false;
187+
}, function () use (&$pending) {
188+
$pending = false;
189+
});
190+
191+
$this->assertTrue($pending);
124192
}
125193

126194
/**
@@ -142,27 +210,6 @@ public function testQueryRejectsOnCancellation()
142210
$promise->then(null, $this->expectCallableOnce());
143211
}
144212

145-
public function testQueryKeepsPendingIfServerRejectsNetworkPacket()
146-
{
147-
$loop = Factory::create();
148-
149-
$executor = new UdpTransportExecutor('127.0.0.1:1', $loop);
150-
151-
$query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN);
152-
153-
$wait = true;
154-
$promise = $executor->query($query)->then(
155-
null,
156-
function ($e) use (&$wait) {
157-
$wait = false;
158-
throw $e;
159-
}
160-
);
161-
162-
\Clue\React\Block\sleep(0.2, $loop);
163-
$this->assertTrue($wait);
164-
}
165-
166213
public function testQueryKeepsPendingIfServerSendsInvalidMessage()
167214
{
168215
$loop = Factory::create();

0 commit comments

Comments
 (0)