Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions src/Analyser/Parallel/AnalysisNodeWorker.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,10 @@
use Boundwize\StructArmed\LayerResolver\ChainLayerResolver;
use Throwable;

use function file_get_contents;
use function file_put_contents;
use function is_array;
use function serialize;
use function sprintf;
use function unserialize;

use const STDOUT;

Expand All @@ -24,11 +22,9 @@
final readonly class AnalysisNodeWorker
{
/** @param resource|null $outputStream */
public static function run(string $inputFile, string $outputFile, mixed $outputStream = null): int
public static function run(mixed $payload, string $outputFile, mixed $outputStream = null): int
{
try {
$payload = unserialize((string) file_get_contents($inputFile));

if (! is_array($payload)) {
throw new WorkerFailedException('Invalid worker payload.');
}
Expand Down
178 changes: 165 additions & 13 deletions src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use RuntimeException;

use function array_fill;
use function array_filter;
use function array_key_exists;
use function array_map;
use function array_pop;
Expand All @@ -38,6 +39,10 @@
use function is_string;
use function min;
use function mkdir;
use function pcntl_wexitstatus;
use function pcntl_wifsignaled;
use function pcntl_wtermsig;
use function posix_getpid;
use function proc_close;
use function rmdir;
use function serialize;
Expand All @@ -47,15 +52,35 @@
use function unserialize;
use function usleep;

use const PCNTL_EINTR;
use const PHP_BINARY;
use const PHP_OS_FAMILY;
use const PHP_VERSION_ID;
use const SIGKILL;
use const STREAM_IPPROTO_IP;
use const STREAM_PF_UNIX;
use const STREAM_SOCK_STREAM;

/**
* @internal
*/
final readonly class ParallelAnalysisNodeExtractor
{
/**
* Every function the fork path calls: disable_functions can remove any of them on its own.
*/
private const FORK_REQUIRED_FUNCTIONS = [
'pcntl_fork',
'pcntl_get_last_error',
'pcntl_waitpid',
'pcntl_wifsignaled',
'pcntl_wtermsig',
'pcntl_wexitstatus',
'posix_getpid',
'posix_kill',
'stream_socket_pair',
];

/**
* @param array<string, string|list<string>> $layers
* @param array<string, array<string, mixed>> $layerPatterns
Expand Down Expand Up @@ -103,14 +128,18 @@ public function extract(
mkdir($cacheDirectory, 0777, true);
}

foreach ($this->buildWorkerBuckets($files, $workerCount) as $chunk) {
[
'inputFile' => $inputFile,
'outputFile' => $outputFile,
'stderrFile' => $stderrFile,
] = $this->createWorkerFiles($cacheDirectory);
// A forked child inherits the loaded code, config and cache, so it skips the PHP
// boot, autoload and payload (un)serialisation an exec'd worker pays for.
$canFork = array_filter(
self::FORK_REQUIRED_FUNCTIONS,
// phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName
// to avoid error in test that mock it
static fn (string $function): bool => ! function_exists($function),
// phpcs:enable
) === [];

file_put_contents($inputFile, serialize([
foreach ($this->buildWorkerBuckets($files, $workerCount) as $chunk) {
$payload = [
'basePath' => $this->basePath,
'layers' => $this->layers,
'layerPatterns' => $this->layerPatterns,
Expand All @@ -119,9 +148,25 @@ public function extract(
'emitProgress' => $emitProgress,
'withFileAnalysis' => $withFileAnalysis,
'withSignatures' => $withSignatures,
'cache' => $this->analysisResultCache?->forFiles($chunk),
'cache' => $canFork
? $this->analysisResultCache
: $this->analysisResultCache?->forFiles($chunk),
'cacheNamespace' => $this->analysisNodeCacheNamespace,
]));
];

if ($canFork) {
$pending[] = $this->forkWorker($cacheDirectory, $chunk, $payload);

continue;
}

[
'inputFile' => $inputFile,
'outputFile' => $outputFile,
'stderrFile' => $stderrFile,
] = $this->createWorkerFiles($cacheDirectory);

file_put_contents($inputFile, serialize($payload));

// phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName
// to avoid error in test that mock it
Expand All @@ -147,6 +192,7 @@ public function extract(

$pending[] = [
'process' => $process,
'pid' => null,
'files' => $chunk,
'buffer' => '',
'inputFile' => $inputFile,
Expand Down Expand Up @@ -238,9 +284,13 @@ public function extract(
// returns the real exit code (no double-waitpid race with proc_get_status).
fclose($stdoutPipe);

/** @phpstan-var resource $procResource */
$procResource = $worker['process'];
$exitCode = proc_close($procResource);
if ($worker['pid'] !== null) {
$exitCode = $this->reapForkedWorker($worker['pid']);
} else {
/** @phpstan-var resource $procResource */
$procResource = $worker['process'];
$exitCode = proc_close($procResource);
}

try {
// phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName
Expand Down Expand Up @@ -382,11 +432,113 @@ private function buildWorkerBuckets(array $files, int $workerCount): array
return $buckets;
}

/**
* @param list<string> $chunk
* @param array<string, mixed> $payload
* @return array{
* process: null,
* pid: int,
* files: list<string>,
* buffer: string,
* inputFile: string,
* outputFile: string,
* stderrFile: string,
* stdoutPipe: resource
* }
*/
private function forkWorker(string $cacheDirectory, array $chunk, array $payload): array
{
$outputFile = $this->temporaryFile($cacheDirectory);
// phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName
// to avoid error in test that mock it
$sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
// phpcs:enable

if ($sockets === false) {
$this->cleanup([$outputFile]);

throw new RuntimeException('Unable to start parallel analysis worker.');
}

[$parentSocket, $childSocket] = $sockets;

// phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName
// to avoid error in test that mock it
$pid = pcntl_fork();
// phpcs:enable

if ($pid === -1) {
fclose($parentSocket);
fclose($childSocket);
$this->cleanup([$outputFile]);

throw new RuntimeException('Unable to start parallel analysis worker.');
}

if ($pid === 0) {
fclose($parentSocket);
AnalysisNodeWorker::run($payload, $outputFile, $childSocket);
fclose($childSocket);

// The child shares the coordinator's state: exiting normally would run its
// shutdown functions, destructors and output buffers a second time.
// phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName
// to avoid error in test that mock it
posix_kill(posix_getpid(), SIGKILL);
// phpcs:enable
}

fclose($childSocket);

return [
'process' => null,
'pid' => $pid,
'files' => $chunk,
'buffer' => '',
'inputFile' => '',
'outputFile' => $outputFile,
'stderrFile' => '',
'stdoutPipe' => $parentSocket,
];
}

/**
* A forked worker always ends by SIGKILL-ing itself after writing its payload, so that
* signal counts as a normal exit; the payload then tells success from failure.
*/
private function reapForkedWorker(int $pid): int
{
// phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName
// to avoid error in test that mock it
do {
$reapedPid = pcntl_waitpid($pid, $waitStatus);
} while ($reapedPid === -1 && pcntl_get_last_error() === PCNTL_EINTR);

// phpcs:enable

// Any other failure (ECHILD: the status was collected elsewhere, e.g. a host SIGCHLD handler):
// the worker has written its payload before the socket EOF, so that payload decides.
if ($reapedPid === -1) {
return 0;
}

/** @var int $status */
$status = $waitStatus;

if (pcntl_wifsignaled($status)) {
$signal = pcntl_wtermsig($status);

return $signal === SIGKILL ? 0 : 128 + $signal;
}

return (int) pcntl_wexitstatus($status);
}

private function workerFailure(string $error, string $stderrFile): RuntimeException
{
// phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName
// to avoid error in test that mock it
$stderr = (string) file_get_contents($stderrFile);
$stderr = $stderrFile !== '' ? (string) file_get_contents($stderrFile) : '';
// phpcs:enable

return new RuntimeException(sprintf(
Expand Down
4 changes: 3 additions & 1 deletion src/Cli/StructArmedApplication.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@

use function array_slice;
use function array_values;
use function file_get_contents;
use function getcwd;
use function in_array;
use function sprintf;
use function unserialize;

final readonly class StructArmedApplication
{
Expand All @@ -27,7 +29,7 @@ public function run(array $argv, ?string $basePath = null): int
$command = $arguments[0] ?? null;

if ($command === '--internal-worker') {
return AnalysisNodeWorker::run($argv[2] ?? '', $argv[3] ?? '');
return AnalysisNodeWorker::run(unserialize((string) file_get_contents($argv[2] ?? '')), $argv[3] ?? '');
}

// Options may precede the command: `structarmed --basepath=../../ --clear-cache`.
Expand Down
32 changes: 8 additions & 24 deletions tests/Analyser/Parallel/AnalysisNodeWorkerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
use function file_get_contents;
use function file_put_contents;
use function fopen;
use function serialize;
use function unserialize;

#[CoversClass(AnalysisNodeWorker::class)]
Expand All @@ -43,17 +42,14 @@ final class Foo
}
PHP);

$inputFile = $this->makeTemporaryFile('structarmed-worker-input');
$outputFile = $this->makeTemporaryFile('structarmed-worker-output');

file_put_contents($inputFile, serialize([
$exitCode = AnalysisNodeWorker::run([
'basePath' => $dir,
'layers' => ['Domain' => 'App\\Domain'],
'layerPatterns' => [],
'files' => [$srcFile],
]));

$exitCode = AnalysisNodeWorker::run($inputFile, $outputFile, $this->silentStream());
], $outputFile, $this->silentStream());

$this->assertSame(0, $exitCode);

Expand Down Expand Up @@ -82,19 +78,16 @@ final class Foo
}
PHP);

$inputFile = $this->makeTemporaryFile('structarmed-worker-input');
$outputFile = $this->makeTemporaryFile('structarmed-worker-output');

file_put_contents($inputFile, serialize([
$this->assertSame(0, AnalysisNodeWorker::run([
'basePath' => $dir,
'layers' => ['Domain' => 'App\\Domain'],
'layerPatterns' => [],
'files' => [$srcFile],
'cache' => new AnalysisResultCache($dir, new FileHashProvider(), $cacheDir),
'cacheNamespace' => 'namespace',
]));

$this->assertSame(0, AnalysisNodeWorker::run($inputFile, $outputFile, $this->silentStream()));
], $outputFile, $this->silentStream()));

$cached = (new AnalysisResultCache($dir, new FileHashProvider(), $cacheDir))
->loadAnalysisNodes($srcFile, 'namespace', true);
Expand All @@ -120,14 +113,11 @@ public function run(Bar $bar): void
}
PHP);

$inputFile = $this->makeTemporaryFile('structarmed-worker-input');
$outputFile = $this->makeTemporaryFile('structarmed-worker-output');
$payload = ['basePath' => $dir, 'layers' => [], 'layerPatterns' => [], 'files' => [$srcFile]];

foreach ([[[], 0], [['withSignatures' => true], 1]] as [$option, $signatureCount]) {
file_put_contents($inputFile, serialize($payload + $option));

$this->assertSame(0, AnalysisNodeWorker::run($inputFile, $outputFile, $this->silentStream()));
$this->assertSame(0, AnalysisNodeWorker::run($payload + $option, $outputFile, $this->silentStream()));

$result = unserialize((string) file_get_contents($outputFile));

Expand All @@ -140,12 +130,9 @@ public function run(Bar $bar): void

public function testRunWithInvalidPayloadReturnsOneAndWritesError(): void
{
$inputFile = $this->makeTemporaryFile('structarmed-worker-input');
$outputFile = $this->makeTemporaryFile('structarmed-worker-output');

file_put_contents($inputFile, serialize('not-an-array'));

$exitCode = AnalysisNodeWorker::run($inputFile, $outputFile, $this->silentStream());
$exitCode = AnalysisNodeWorker::run('not-an-array', $outputFile, $this->silentStream());

$this->assertSame(1, $exitCode);

Expand Down Expand Up @@ -179,17 +166,14 @@ final class FooService
}
PHP);

$inputFile = $this->makeTemporaryFile('structarmed-worker-input');
$outputFile = $this->makeTemporaryFile('structarmed-worker-output');

file_put_contents($inputFile, serialize([
$exitCode = AnalysisNodeWorker::run([
'basePath' => $dir,
'layers' => ['Domain' => 'App\\Domain'],
'layerPatterns' => ['Domain' => ['pattern' => '/Service$/', 'excludePattern' => null]],
'files' => [$srcFile],
]));

$exitCode = AnalysisNodeWorker::run($inputFile, $outputFile, $this->silentStream());
], $outputFile, $this->silentStream());

$this->assertSame(0, $exitCode);

Expand Down
Loading
Loading