Skip to content

Commit 134d424

Browse files
authored
Merge pull request #172 from clue-labs/tcp-error
Improve error handling when sending TCP/IP data to DNS server fails (macOS)
2 parents 5f8494c + 054d1d8 commit 134d424

File tree

3 files changed

+111
-11
lines changed

3 files changed

+111
-11
lines changed

src/Query/TcpTransportExecutor.php

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,9 @@ public function query(Query $query)
184184

185185
// set socket to non-blocking and wait for it to become writable (connection success/rejected)
186186
\stream_set_blocking($socket, false);
187+
if (\function_exists('stream_set_chunk_size')) {
188+
\stream_set_chunk_size($socket, (1 << 31) - 1); // @codeCoverageIgnore
189+
}
187190
$this->socket = $socket;
188191
}
189192

@@ -234,7 +237,12 @@ public function handleWritable()
234237

235238
$written = @\fwrite($this->socket, $this->writeBuffer);
236239
if ($written === false || $written === 0) {
237-
$this->closeError('Unable to write to closed socket');
240+
$error = \error_get_last();
241+
\preg_match('/errno=(\d+) (.+)/', $error['message'], $m);
242+
$this->closeError(
243+
'Unable to send query to DNS server (' . (isset($m[2]) ? $m[2] : $error['message']) . ')',
244+
isset($m[1]) ? (int) $m[1] : 0
245+
);
238246
return;
239247
}
240248

@@ -300,8 +308,9 @@ public function handleRead()
300308
/**
301309
* @internal
302310
* @param string $reason
311+
* @param int $code
303312
*/
304-
public function closeError($reason)
313+
public function closeError($reason, $code = 0)
305314
{
306315
$this->readBuffer = '';
307316
if ($this->readPending) {
@@ -325,7 +334,8 @@ public function closeError($reason)
325334

326335
foreach ($this->names as $id => $name) {
327336
$this->pending[$id]->reject(new \RuntimeException(
328-
'DNS query for ' . $name . ' failed: ' . $reason
337+
'DNS query for ' . $name . ' failed: ' . $reason,
338+
$code
329339
));
330340
}
331341
$this->pending = $this->names = array();

tests/Query/TcpTransportExecutorTest.php

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -245,14 +245,53 @@ function ($e) use (&$wait) {
245245
$this->assertFalse($wait);
246246
}
247247

248+
public function testQueryStaysPendingWhenClientCanNotSendExcessiveMessageInOneChunk()
249+
{
250+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
251+
$loop->expects($this->once())->method('addWriteStream');
252+
$loop->expects($this->once())->method('addReadStream');
253+
$loop->expects($this->never())->method('removeWriteStream');
254+
$loop->expects($this->never())->method('removeReadStream');
255+
256+
$server = stream_socket_server('tcp://127.0.0.1:0');
257+
258+
$address = stream_socket_get_name($server, false);
259+
$executor = new TcpTransportExecutor($address, $loop);
260+
261+
$query = new Query('google' . str_repeat('.com', 100), Message::TYPE_A, Message::CLASS_IN);
262+
263+
// send a bunch of queries and keep reference to last promise
264+
for ($i = 0; $i < 8000; ++$i) {
265+
$promise = $executor->query($query);
266+
}
267+
268+
$client = stream_socket_accept($server);
269+
assert(is_resource($client));
270+
271+
$executor->handleWritable();
272+
273+
$promise->then(null, 'printf');
274+
$promise->then($this->expectCallableNever(), $this->expectCallableNever());
275+
276+
$ref = new \ReflectionProperty($executor, 'writePending');
277+
$ref->setAccessible(true);
278+
$writePending = $ref->getValue($executor);
279+
280+
$this->assertTrue($writePending);
281+
}
282+
248283
public function testQueryStaysPendingWhenClientCanNotSendExcessiveMessageInOneChunkWhenServerClosesSocket()
249284
{
250-
$writableCallback = null;
285+
if (PHP_OS === 'Darwin') {
286+
// Skip on macOS because it exhibits what looks like a kernal race condition when sending excessive data to a socket that is about to shut down (EPROTOTYPE)
287+
// Due to this race condition, this is somewhat flaky. Happens around 75% of the time, use `--repeat=100` to reproduce.
288+
// fwrite(): Send of 4260000 bytes failed with errno=41 Protocol wrong type for socket
289+
// @link http://erickt.github.io/blog/2014/11/19/adventures-in-debugging-a-potential-osx-kernel-bug/
290+
$this->markTestSkipped('Skipped on macOS due to possible race condition');
291+
}
292+
251293
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
252-
$loop->expects($this->once())->method('addWriteStream')->with($this->anything(), $this->callback(function ($cb) use (&$writableCallback) {
253-
$writableCallback = $cb;
254-
return true;
255-
}));
294+
$loop->expects($this->once())->method('addWriteStream');
256295
$loop->expects($this->once())->method('addReadStream');
257296
$loop->expects($this->never())->method('removeWriteStream');
258297
$loop->expects($this->never())->method('removeReadStream');
@@ -262,10 +301,10 @@ public function testQueryStaysPendingWhenClientCanNotSendExcessiveMessageInOneCh
262301
$address = stream_socket_get_name($server, false);
263302
$executor = new TcpTransportExecutor($address, $loop);
264303

265-
$query = new Query('google' . str_repeat('.com', 10000), Message::TYPE_A, Message::CLASS_IN);
304+
$query = new Query('google' . str_repeat('.com', 100), Message::TYPE_A, Message::CLASS_IN);
266305

267306
// send a bunch of queries and keep reference to last promise
268-
for ($i = 0; $i < 100; ++$i) {
307+
for ($i = 0; $i < 2000; ++$i) {
269308
$promise = $executor->query($query);
270309
}
271310

@@ -283,6 +322,57 @@ public function testQueryStaysPendingWhenClientCanNotSendExcessiveMessageInOneCh
283322
$this->assertTrue($writePending);
284323
}
285324

325+
public function testQueryRejectsWhenClientKeepsSendingWhenServerClosesSocket()
326+
{
327+
$loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock();
328+
$loop->expects($this->once())->method('addWriteStream');
329+
$loop->expects($this->once())->method('addReadStream');
330+
$loop->expects($this->once())->method('removeWriteStream');
331+
$loop->expects($this->once())->method('removeReadStream');
332+
333+
$server = stream_socket_server('tcp://127.0.0.1:0');
334+
335+
$address = stream_socket_get_name($server, false);
336+
$executor = new TcpTransportExecutor($address, $loop);
337+
338+
$query = new Query('google' . str_repeat('.com', 100), Message::TYPE_A, Message::CLASS_IN);
339+
340+
// send a bunch of queries and keep reference to last promise
341+
for ($i = 0; $i < 2000; ++$i) {
342+
$promise = $executor->query($query);
343+
}
344+
345+
$client = stream_socket_accept($server);
346+
fclose($client);
347+
348+
$executor->handleWritable();
349+
350+
$ref = new \ReflectionProperty($executor, 'writePending');
351+
$ref->setAccessible(true);
352+
$writePending = $ref->getValue($executor);
353+
354+
// We expect an EPIPE (Broken pipe) on second write.
355+
// However, macOS may report EPROTOTYPE (Protocol wrong type for socket) on first write due to kernel race condition.
356+
// fwrite(): Send of 4260000 bytes failed with errno=41 Protocol wrong type for socket
357+
// @link http://erickt.github.io/blog/2014/11/19/adventures-in-debugging-a-potential-osx-kernel-bug/
358+
if ($writePending) {
359+
$executor->handleWritable();
360+
}
361+
362+
$exception = null;
363+
$promise->then(null, function ($reason) use (&$exception) {
364+
$exception = $reason;
365+
});
366+
367+
// expect EPIPE (Broken pipe), except for macOS kernel race condition or legacy HHVM
368+
$this->setExpectedException(
369+
'RuntimeException',
370+
'Unable to send query to DNS server',
371+
defined('SOCKET_EPIPE') && !defined('HHVM_VERSION') ? (PHP_OS !== 'Darwin' || $writePending ? SOCKET_EPIPE : SOCKET_EPROTOTYPE) : null
372+
);
373+
throw $exception;
374+
}
375+
286376
public function testQueryRejectsWhenServerClosesConnection()
287377
{
288378
$loop = Factory::create();

tests/Query/UdpTransportExecutorTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ public function testQueryRejectsIfSendToServerFailsAfterConnection()
153153
$exception = $reason;
154154
});
155155

156-
// ECONNREFUSED( Connection refused) on Linux, EMSGSIZE (Message too long) on macOS
156+
// ECONNREFUSED (Connection refused) on Linux, EMSGSIZE (Message too long) on macOS
157157
$this->setExpectedException('RuntimeException', 'Unable to send query to DNS server');
158158
throw $exception;
159159
}

0 commit comments

Comments
 (0)