Skip to content

Commit b13615e

Browse files
committed
POC: lightweight subprocess isolation via pcntl_fork()
1 parent 1ff959e commit b13615e

File tree

2 files changed

+82
-1
lines changed

2 files changed

+82
-1
lines changed

src/Framework/TestRunner.php

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010
namespace PHPUnit\Framework;
1111

12+
use PHPUnit\TestRunner\TestResult\PassedTests;
1213
use const PHP_EOL;
1314
use function assert;
1415
use function class_exists;
@@ -249,6 +250,86 @@ public function run(TestCase $test): void
249250
* @throws StaticAnalysisCacheNotConfiguredException
250251
*/
251252
public function runInSeparateProcess(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void
253+
{
254+
if ($runEntireClass && $this->isPcntlForkAvailable()) {
255+
// forking the parent process is a more lightweight way to run a test in isolation.
256+
// it requires the pcntl extension though.
257+
$this->runInFork($test);
258+
return;
259+
}
260+
261+
// running in a separate process is slow, but works in most situations.
262+
$this->runInWorkerProcess($test, $runEntireClass, $preserveGlobalState);
263+
}
264+
265+
private function isPcntlForkAvailable(): bool {
266+
$disabledFunctions = ini_get('disable_functions');
267+
268+
return
269+
function_exists('pcntl_fork')
270+
&& !str_contains($disabledFunctions, 'pcntl')
271+
&& function_exists('socket_create_pair')
272+
&& !str_contains($disabledFunctions, 'socket')
273+
;
274+
}
275+
276+
private function runInFork(TestCase $test): void
277+
{
278+
if (socket_create_pair(AF_UNIX, SOCK_STREAM, 0, $sockets) === false) {
279+
throw new \Exception('could not create socket pair');
280+
}
281+
282+
$pid = pcntl_fork();
283+
// pcntl_fork may return NULL if the function is disabled in php.ini.
284+
if ($pid === -1 || $pid === null) {
285+
throw new \Exception('could not fork');
286+
} else if ($pid) {
287+
// we are the parent
288+
289+
pcntl_waitpid($pid, $status); // protect against zombie children
290+
291+
// read child output
292+
$output = '';
293+
while(($read = socket_read($sockets[1], 2048, PHP_BINARY_READ)) !== false) {
294+
$output .= $read;
295+
}
296+
socket_close($sockets[1]);
297+
298+
$php = AbstractPhpProcess::factory();
299+
$php->processChildResult($test, $output, ''); // TODO stderr
300+
301+
} else {
302+
// we are the child
303+
304+
$offset = hrtime();
305+
$dispatcher = Event\Facade::instance()->initForIsolation(
306+
\PHPUnit\Event\Telemetry\HRTime::fromSecondsAndNanoseconds(
307+
$offset[0],
308+
$offset[1]
309+
)
310+
);
311+
312+
$test->setInIsolation(true);
313+
$test->runBare();
314+
315+
// send result into parent
316+
socket_write($sockets[0],
317+
serialize(
318+
[
319+
'testResult' => $test->result(),
320+
'codeCoverage' => CodeCoverage::instance()->isActive() ? CodeCoverage::instance()->codeCoverage() : null,
321+
'numAssertions' => $test->numberOfAssertionsPerformed(),
322+
'output' => !$test->expectsOutput() ? $output : '',
323+
'events' => $dispatcher->flush(),
324+
'passedTests' => PassedTests::instance()
325+
]
326+
)
327+
);
328+
socket_close($sockets[0]);
329+
}
330+
}
331+
332+
private function runInWorkerProcess(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void
252333
{
253334
$class = new ReflectionClass($test);
254335

src/Util/PHP/AbstractPhpProcess.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ protected function settingsToParameters(array $settings): string
226226
* @throws Exception
227227
* @throws NoPreviousThrowableException
228228
*/
229-
private function processChildResult(Test $test, string $stdout, string $stderr): void
229+
public function processChildResult(Test $test, string $stdout, string $stderr): void
230230
{
231231
if (!empty($stderr)) {
232232
$exception = new Exception(trim($stderr));

0 commit comments

Comments
 (0)