Skip to content

Commit

Permalink
extract PcntlFork
Browse files Browse the repository at this point in the history
  • Loading branch information
staabm committed Mar 17, 2024
1 parent a3acb96 commit 3cc5b46
Show file tree
Hide file tree
Showing 2 changed files with 183 additions and 164 deletions.
168 changes: 4 additions & 164 deletions src/Framework/TestRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
namespace PHPUnit\Framework;

use PHPUnit\TestRunner\TestResult\PassedTests;
use PHPUnit\Util\PHP\PcntlFork;
use const PHP_EOL;
use function assert;
use function class_exists;
Expand Down Expand Up @@ -251,179 +252,18 @@ public function run(TestCase $test): void
*/
public function runInSeparateProcess(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void
{
if ($this->isPcntlForkAvailable()) {
if (PcntlFork::isPcntlForkAvailable()) {
// forking the parent process is a more lightweight way to run a test in isolation.
// it requires the pcntl extension though.
$this->runInFork($test);
$fork = new PcntlFork();
$fork->runTest($test);
return;
}

// running in a separate process is slow, but works in most situations.
$this->runInWorkerProcess($test, $runEntireClass, $preserveGlobalState);
}

private function isPcntlForkAvailable(): bool {
$disabledFunctions = ini_get('disable_functions');

return
function_exists('pcntl_fork')
&& !str_contains($disabledFunctions, 'pcntl')
&& function_exists('socket_create_pair')
&& !str_contains($disabledFunctions, 'socket')
;
}

// IPC inspired from https://github.com/barracudanetworks/forkdaemon-php
private const SOCKET_HEADER_SIZE = 4;

private function ipc_init(): array
{
// windows needs AF_INET
$domain = strtoupper(substr(PHP_OS, 0, 3)) == 'WIN' ? AF_INET : AF_UNIX;

// create a socket pair for IPC
$sockets = array();
if (socket_create_pair($domain, SOCK_STREAM, 0, $sockets) === false)
{
throw new \RuntimeException('socket_create_pair failed: ' . socket_strerror(socket_last_error()));
}

return $sockets;
}

/**
* @param resource $socket
*/
private function socket_receive($socket): mixed
{
// initially read to the length of the header size, then
// expand to read more
$bytes_total = self::SOCKET_HEADER_SIZE;
$bytes_read = 0;
$have_header = false;
$socket_message = '';
while ($bytes_read < $bytes_total)
{
$read = @socket_read($socket, $bytes_total - $bytes_read);
if ($read === false)
{
throw new \RuntimeException('socket_receive error: ' . socket_strerror(socket_last_error()));
}

// blank socket_read means done
if ($read == '')
{
break;
}

$bytes_read += strlen($read);
$socket_message .= $read;

if (!$have_header && $bytes_read >= self::SOCKET_HEADER_SIZE)
{
$have_header = true;
list($bytes_total) = array_values(unpack('N', $socket_message));
$bytes_read = 0;
$socket_message = '';
}
}

return @unserialize($socket_message);
}

/**
* @param resource $socket
* @param mixed $message
*/
private function socket_send($socket, $message): void
{
$serialized_message = @serialize($message);
if ($serialized_message == false)
{
throw new \RuntimeException('socket_send failed to serialize message');
}

$header = pack('N', strlen($serialized_message));
$data = $header . $serialized_message;
$bytes_left = strlen($data);
while ($bytes_left > 0)
{
$bytes_sent = @socket_write($socket, $data);
if ($bytes_sent === false)
{
throw new \RuntimeException('socket_send failed to write to socket');
}

$bytes_left -= $bytes_sent;
$data = substr($data, $bytes_sent);
}
}

private function runInFork(TestCase $test): void
{
list($socket_child, $socket_parent) = $this->ipc_init();

$pid = pcntl_fork();

if ($pid === -1 ) {
throw new \Exception('could not fork');
} else if ($pid) {
// we are the parent

socket_close($socket_parent);

// read child stdout, stderr
$result = $this->socket_receive($socket_child);

$stderr = '';
$stdout = '';
if (is_array($result) && array_key_exists('error', $result)) {
$stderr = $result['error'];
} else {
$stdout = $result;
}

$php = AbstractPhpProcess::factory();
$php->processChildResult($test, $stdout, $stderr);

} else {
// we are the child

socket_close($socket_child);

$offset = hrtime();
$dispatcher = Event\Facade::instance()->initForIsolation(
\PHPUnit\Event\Telemetry\HRTime::fromSecondsAndNanoseconds(
$offset[0],
$offset[1]
)
);

$test->setInIsolation(true);
try {
$test->run();
} catch (Throwable $e) {
$this->socket_send($socket_parent, ['error' => $e->getMessage()]);
exit();
}

$result = serialize(
[
'testResult' => $test->result(),
'codeCoverage' => CodeCoverage::instance()->isActive() ? CodeCoverage::instance()->codeCoverage() : null,
'numAssertions' => $test->numberOfAssertionsPerformed(),
'output' => !$test->expectsOutput() ? $test->output() : '',
'events' => $dispatcher->flush(),
'passedTests' => PassedTests::instance()
]
);

// send result into parent
$this->socket_send($socket_parent, $result);
exit();
}
}

private function runInWorkerProcess(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void
{
$class = new ReflectionClass($test);
Expand Down
179 changes: 179 additions & 0 deletions src/Util/PHP/PcntlFork.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<?php declare(strict_types=1);
/*
* This file is part of PHPUnit.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace PHPUnit\Util\PHP;

use PHPUnit\Event\Facade;
use PHPUnit\Framework\TestCase;
use PHPUnit\Runner\CodeCoverage;
use PHPUnit\TestRunner\TestResult\PassedTests;

final class PcntlFork {
// IPC inspired from https://github.com/barracudanetworks/forkdaemon-php
private const SOCKET_HEADER_SIZE = 4;

static public function isPcntlForkAvailable(): bool {
$disabledFunctions = ini_get('disable_functions');

return
function_exists('pcntl_fork')
&& !str_contains($disabledFunctions, 'pcntl')
&& function_exists('socket_create_pair')
&& !str_contains($disabledFunctions, 'socket')
;
}

public function runTest(TestCase $test): void
{
list($socket_child, $socket_parent) = $this->ipcInit();

$pid = pcntl_fork();

if ($pid === -1 ) {
throw new \Exception('could not fork');
} else if ($pid) {
// we are the parent

socket_close($socket_parent);

// read child stdout, stderr
$result = $this->socketReceive($socket_child);

$stderr = '';
$stdout = '';
if (is_array($result) && array_key_exists('error', $result)) {
$stderr = $result['error'];
} else {
$stdout = $result;
}

$php = AbstractPhpProcess::factory();
$php->processChildResult($test, $stdout, $stderr);

} else {
// we are the child

socket_close($socket_child);

$offset = hrtime();
$dispatcher = Facade::instance()->initForIsolation(
\PHPUnit\Event\Telemetry\HRTime::fromSecondsAndNanoseconds(
$offset[0],
$offset[1]
)
);

$test->setInIsolation(true);
try {
$test->run();
} catch (Throwable $e) {
$this->socketSend($socket_parent, ['error' => $e->getMessage()]);
exit();
}

$result = serialize(
[
'testResult' => $test->result(),
'codeCoverage' => CodeCoverage::instance()->isActive() ? CodeCoverage::instance()->codeCoverage() : null,
'numAssertions' => $test->numberOfAssertionsPerformed(),
'output' => !$test->expectsOutput() ? $test->output() : '',
'events' => $dispatcher->flush(),
'passedTests' => PassedTests::instance()
]
);

// send result into parent
$this->socketSend($socket_parent, $result);
exit();
}
}

private function ipcInit(): array
{
// windows needs AF_INET
$domain = strtoupper(substr(PHP_OS, 0, 3)) == 'WIN' ? AF_INET : AF_UNIX;

// create a socket pair for IPC
$sockets = array();
if (socket_create_pair($domain, SOCK_STREAM, 0, $sockets) === false)
{
throw new \RuntimeException('socket_create_pair failed: ' . socket_strerror(socket_last_error()));
}

return $sockets;
}

/**
* @param resource $socket
*/
private function socketReceive($socket): mixed
{
// initially read to the length of the header size, then
// expand to read more
$bytes_total = self::SOCKET_HEADER_SIZE;
$bytes_read = 0;
$have_header = false;
$socket_message = '';
while ($bytes_read < $bytes_total)
{
$read = @socket_read($socket, $bytes_total - $bytes_read);
if ($read === false)
{
throw new \RuntimeException('socket_receive error: ' . socket_strerror(socket_last_error()));
}

// blank socket_read means done
if ($read == '')
{
break;
}

$bytes_read += strlen($read);
$socket_message .= $read;

if (!$have_header && $bytes_read >= self::SOCKET_HEADER_SIZE)
{
$have_header = true;
list($bytes_total) = array_values(unpack('N', $socket_message));
$bytes_read = 0;
$socket_message = '';
}
}

return @unserialize($socket_message);
}

/**
* @param resource $socket
* @param mixed $message
*/
private function socketSend($socket, $message): void
{
$serialized_message = @serialize($message);
if ($serialized_message == false)
{
throw new \RuntimeException('socket_send failed to serialize message');
}

$header = pack('N', strlen($serialized_message));
$data = $header . $serialized_message;
$bytes_left = strlen($data);
while ($bytes_left > 0)
{
$bytes_sent = @socket_write($socket, $data);
if ($bytes_sent === false)
{
throw new \RuntimeException('socket_send failed to write to socket');
}

$bytes_left -= $bytes_sent;
$data = substr($data, $bytes_sent);
}
}
}

0 comments on commit 3cc5b46

Please sign in to comment.