Skip to content

Commit 3cc5b46

Browse files
committed
extract PcntlFork
1 parent a3acb96 commit 3cc5b46

File tree

2 files changed

+183
-164
lines changed

2 files changed

+183
-164
lines changed

src/Framework/TestRunner.php

+4-164
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
namespace PHPUnit\Framework;
1111

1212
use PHPUnit\TestRunner\TestResult\PassedTests;
13+
use PHPUnit\Util\PHP\PcntlFork;
1314
use const PHP_EOL;
1415
use function assert;
1516
use function class_exists;
@@ -251,179 +252,18 @@ public function run(TestCase $test): void
251252
*/
252253
public function runInSeparateProcess(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void
253254
{
254-
if ($this->isPcntlForkAvailable()) {
255+
if (PcntlFork::isPcntlForkAvailable()) {
255256
// forking the parent process is a more lightweight way to run a test in isolation.
256257
// it requires the pcntl extension though.
257-
$this->runInFork($test);
258+
$fork = new PcntlFork();
259+
$fork->runTest($test);
258260
return;
259261
}
260262

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

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-
// IPC inspired from https://github.com/barracudanetworks/forkdaemon-php
277-
private const SOCKET_HEADER_SIZE = 4;
278-
279-
private function ipc_init(): array
280-
{
281-
// windows needs AF_INET
282-
$domain = strtoupper(substr(PHP_OS, 0, 3)) == 'WIN' ? AF_INET : AF_UNIX;
283-
284-
// create a socket pair for IPC
285-
$sockets = array();
286-
if (socket_create_pair($domain, SOCK_STREAM, 0, $sockets) === false)
287-
{
288-
throw new \RuntimeException('socket_create_pair failed: ' . socket_strerror(socket_last_error()));
289-
}
290-
291-
return $sockets;
292-
}
293-
294-
/**
295-
* @param resource $socket
296-
*/
297-
private function socket_receive($socket): mixed
298-
{
299-
// initially read to the length of the header size, then
300-
// expand to read more
301-
$bytes_total = self::SOCKET_HEADER_SIZE;
302-
$bytes_read = 0;
303-
$have_header = false;
304-
$socket_message = '';
305-
while ($bytes_read < $bytes_total)
306-
{
307-
$read = @socket_read($socket, $bytes_total - $bytes_read);
308-
if ($read === false)
309-
{
310-
throw new \RuntimeException('socket_receive error: ' . socket_strerror(socket_last_error()));
311-
}
312-
313-
// blank socket_read means done
314-
if ($read == '')
315-
{
316-
break;
317-
}
318-
319-
$bytes_read += strlen($read);
320-
$socket_message .= $read;
321-
322-
if (!$have_header && $bytes_read >= self::SOCKET_HEADER_SIZE)
323-
{
324-
$have_header = true;
325-
list($bytes_total) = array_values(unpack('N', $socket_message));
326-
$bytes_read = 0;
327-
$socket_message = '';
328-
}
329-
}
330-
331-
return @unserialize($socket_message);
332-
}
333-
334-
/**
335-
* @param resource $socket
336-
* @param mixed $message
337-
*/
338-
private function socket_send($socket, $message): void
339-
{
340-
$serialized_message = @serialize($message);
341-
if ($serialized_message == false)
342-
{
343-
throw new \RuntimeException('socket_send failed to serialize message');
344-
}
345-
346-
$header = pack('N', strlen($serialized_message));
347-
$data = $header . $serialized_message;
348-
$bytes_left = strlen($data);
349-
while ($bytes_left > 0)
350-
{
351-
$bytes_sent = @socket_write($socket, $data);
352-
if ($bytes_sent === false)
353-
{
354-
throw new \RuntimeException('socket_send failed to write to socket');
355-
}
356-
357-
$bytes_left -= $bytes_sent;
358-
$data = substr($data, $bytes_sent);
359-
}
360-
}
361-
362-
private function runInFork(TestCase $test): void
363-
{
364-
list($socket_child, $socket_parent) = $this->ipc_init();
365-
366-
$pid = pcntl_fork();
367-
368-
if ($pid === -1 ) {
369-
throw new \Exception('could not fork');
370-
} else if ($pid) {
371-
// we are the parent
372-
373-
socket_close($socket_parent);
374-
375-
// read child stdout, stderr
376-
$result = $this->socket_receive($socket_child);
377-
378-
$stderr = '';
379-
$stdout = '';
380-
if (is_array($result) && array_key_exists('error', $result)) {
381-
$stderr = $result['error'];
382-
} else {
383-
$stdout = $result;
384-
}
385-
386-
$php = AbstractPhpProcess::factory();
387-
$php->processChildResult($test, $stdout, $stderr);
388-
389-
} else {
390-
// we are the child
391-
392-
socket_close($socket_child);
393-
394-
$offset = hrtime();
395-
$dispatcher = Event\Facade::instance()->initForIsolation(
396-
\PHPUnit\Event\Telemetry\HRTime::fromSecondsAndNanoseconds(
397-
$offset[0],
398-
$offset[1]
399-
)
400-
);
401-
402-
$test->setInIsolation(true);
403-
try {
404-
$test->run();
405-
} catch (Throwable $e) {
406-
$this->socket_send($socket_parent, ['error' => $e->getMessage()]);
407-
exit();
408-
}
409-
410-
$result = serialize(
411-
[
412-
'testResult' => $test->result(),
413-
'codeCoverage' => CodeCoverage::instance()->isActive() ? CodeCoverage::instance()->codeCoverage() : null,
414-
'numAssertions' => $test->numberOfAssertionsPerformed(),
415-
'output' => !$test->expectsOutput() ? $test->output() : '',
416-
'events' => $dispatcher->flush(),
417-
'passedTests' => PassedTests::instance()
418-
]
419-
);
420-
421-
// send result into parent
422-
$this->socket_send($socket_parent, $result);
423-
exit();
424-
}
425-
}
426-
427267
private function runInWorkerProcess(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void
428268
{
429269
$class = new ReflectionClass($test);

src/Util/PHP/PcntlFork.php

+179
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
<?php declare(strict_types=1);
2+
/*
3+
* This file is part of PHPUnit.
4+
*
5+
* (c) Sebastian Bergmann <sebastian@phpunit.de>
6+
*
7+
* For the full copyright and license information, please view the LICENSE
8+
* file that was distributed with this source code.
9+
*/
10+
namespace PHPUnit\Util\PHP;
11+
12+
use PHPUnit\Event\Facade;
13+
use PHPUnit\Framework\TestCase;
14+
use PHPUnit\Runner\CodeCoverage;
15+
use PHPUnit\TestRunner\TestResult\PassedTests;
16+
17+
final class PcntlFork {
18+
// IPC inspired from https://github.com/barracudanetworks/forkdaemon-php
19+
private const SOCKET_HEADER_SIZE = 4;
20+
21+
static public function isPcntlForkAvailable(): bool {
22+
$disabledFunctions = ini_get('disable_functions');
23+
24+
return
25+
function_exists('pcntl_fork')
26+
&& !str_contains($disabledFunctions, 'pcntl')
27+
&& function_exists('socket_create_pair')
28+
&& !str_contains($disabledFunctions, 'socket')
29+
;
30+
}
31+
32+
public function runTest(TestCase $test): void
33+
{
34+
list($socket_child, $socket_parent) = $this->ipcInit();
35+
36+
$pid = pcntl_fork();
37+
38+
if ($pid === -1 ) {
39+
throw new \Exception('could not fork');
40+
} else if ($pid) {
41+
// we are the parent
42+
43+
socket_close($socket_parent);
44+
45+
// read child stdout, stderr
46+
$result = $this->socketReceive($socket_child);
47+
48+
$stderr = '';
49+
$stdout = '';
50+
if (is_array($result) && array_key_exists('error', $result)) {
51+
$stderr = $result['error'];
52+
} else {
53+
$stdout = $result;
54+
}
55+
56+
$php = AbstractPhpProcess::factory();
57+
$php->processChildResult($test, $stdout, $stderr);
58+
59+
} else {
60+
// we are the child
61+
62+
socket_close($socket_child);
63+
64+
$offset = hrtime();
65+
$dispatcher = Facade::instance()->initForIsolation(
66+
\PHPUnit\Event\Telemetry\HRTime::fromSecondsAndNanoseconds(
67+
$offset[0],
68+
$offset[1]
69+
)
70+
);
71+
72+
$test->setInIsolation(true);
73+
try {
74+
$test->run();
75+
} catch (Throwable $e) {
76+
$this->socketSend($socket_parent, ['error' => $e->getMessage()]);
77+
exit();
78+
}
79+
80+
$result = serialize(
81+
[
82+
'testResult' => $test->result(),
83+
'codeCoverage' => CodeCoverage::instance()->isActive() ? CodeCoverage::instance()->codeCoverage() : null,
84+
'numAssertions' => $test->numberOfAssertionsPerformed(),
85+
'output' => !$test->expectsOutput() ? $test->output() : '',
86+
'events' => $dispatcher->flush(),
87+
'passedTests' => PassedTests::instance()
88+
]
89+
);
90+
91+
// send result into parent
92+
$this->socketSend($socket_parent, $result);
93+
exit();
94+
}
95+
}
96+
97+
private function ipcInit(): array
98+
{
99+
// windows needs AF_INET
100+
$domain = strtoupper(substr(PHP_OS, 0, 3)) == 'WIN' ? AF_INET : AF_UNIX;
101+
102+
// create a socket pair for IPC
103+
$sockets = array();
104+
if (socket_create_pair($domain, SOCK_STREAM, 0, $sockets) === false)
105+
{
106+
throw new \RuntimeException('socket_create_pair failed: ' . socket_strerror(socket_last_error()));
107+
}
108+
109+
return $sockets;
110+
}
111+
112+
/**
113+
* @param resource $socket
114+
*/
115+
private function socketReceive($socket): mixed
116+
{
117+
// initially read to the length of the header size, then
118+
// expand to read more
119+
$bytes_total = self::SOCKET_HEADER_SIZE;
120+
$bytes_read = 0;
121+
$have_header = false;
122+
$socket_message = '';
123+
while ($bytes_read < $bytes_total)
124+
{
125+
$read = @socket_read($socket, $bytes_total - $bytes_read);
126+
if ($read === false)
127+
{
128+
throw new \RuntimeException('socket_receive error: ' . socket_strerror(socket_last_error()));
129+
}
130+
131+
// blank socket_read means done
132+
if ($read == '')
133+
{
134+
break;
135+
}
136+
137+
$bytes_read += strlen($read);
138+
$socket_message .= $read;
139+
140+
if (!$have_header && $bytes_read >= self::SOCKET_HEADER_SIZE)
141+
{
142+
$have_header = true;
143+
list($bytes_total) = array_values(unpack('N', $socket_message));
144+
$bytes_read = 0;
145+
$socket_message = '';
146+
}
147+
}
148+
149+
return @unserialize($socket_message);
150+
}
151+
152+
/**
153+
* @param resource $socket
154+
* @param mixed $message
155+
*/
156+
private function socketSend($socket, $message): void
157+
{
158+
$serialized_message = @serialize($message);
159+
if ($serialized_message == false)
160+
{
161+
throw new \RuntimeException('socket_send failed to serialize message');
162+
}
163+
164+
$header = pack('N', strlen($serialized_message));
165+
$data = $header . $serialized_message;
166+
$bytes_left = strlen($data);
167+
while ($bytes_left > 0)
168+
{
169+
$bytes_sent = @socket_write($socket, $data);
170+
if ($bytes_sent === false)
171+
{
172+
throw new \RuntimeException('socket_send failed to write to socket');
173+
}
174+
175+
$bytes_left -= $bytes_sent;
176+
$data = substr($data, $bytes_sent);
177+
}
178+
}
179+
}

0 commit comments

Comments
 (0)