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
85 changes: 85 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -911,6 +911,17 @@
OUTPUT=$(docker run --rm -v "$PWD:/repo" -w /repo php:8.2-cli-alpine sh -c "rm /etc/alpine-release && $DIAGNOSE")
echo "$OUTPUT"
e2e/bashunit -a contains 'Turbo platform: linux-musl-' "$OUTPUT"
- script: |
# A parallel worker killed by a signal writes nothing of its own -
# no PHP error, no shutdown function, an empty capture file. The
# main process has to name the signal; treating the dead worker
# like one that quit cleanly leaves the whole run with nothing but
# "Some parallel worker jobs have not finished", which is what made
# https://github.com/phpstan/phpstan/issues/15131 undiagnosable.
cd e2e/parallel-worker-signal
OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse --no-progress")
echo "$OUTPUT"
../bashunit -a contains 'killed by signal 9 (SIGKILL)' "$OUTPUT"

steps:
- name: Harden the runner (Audit all outbound calls)
Expand Down Expand Up @@ -938,3 +949,77 @@

- name: "Test"
run: ${{ matrix.script }}

turbo-arena-e2e-tests:
name: "Turbo arena E2E tests"
runs-on: "ubuntu-latest"
timeout-minutes: 30

steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit

- name: "Checkout"
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
# the extension bakes its version from `git log -- turbo-ext/src`, and
# TurboExtensionEnabler activates only the expected version - a shallow
# clone hands us a "dev" build, which would skip the whole test
fetch-depth: 0
Comment on lines +964 to +970

- name: "Install PHP"
uses: "shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240" # 2.37.2
with:
coverage: "none"
php-version: "8.5"

- uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # v4.0.0

- name: "Install bashunit"
uses: "TypedDevs/bashunit@94933c088b0719e0c4cf5e3147dad67f713b970d" # 0.44.0
with:
directory: "e2e"

- name: "Build the turbo extension"
run: make -C turbo-ext -j"$(nproc)"

- name: "Verify the arena is actually exercised"
# Without an active extension ArenaCache is the no-op PHP twin and no
# shared memory exists at all; without fork the workers do not publish in
# lockstep. Either would make the test below pass for the wrong reason.
run: |
OUTPUT=$(php -d extension="$PWD/turbo-ext/phpstan_turbo.so" bin/phpstan diagnose -c e2e/turbo-arena/phpstan.neon)
echo "$OUTPUT"
e2e/bashunit -a contains 'Turbo extension: enabled' "$OUTPUT"
e2e/bashunit -a contains 'Mechanism: fork (pcntl_fork)' "$OUTPUT"

- name: "Test"
# The arena is a per-run POSIX shared-memory object: shm_open() plus an
# ftruncate() to 256 MB that tmpfs honours sparsely, committing pages on
# first touch. When the backing store cannot commit one, the worker that
# touches it takes SIGBUS and dies where it stands. 64 MB is what docker
# gives /dev/shm by default, which is how
# https://github.com/phpstan/phpstan/issues/15131 reached users; 32 MB
# makes it deterministic on a 4-core runner. Running out of shared memory
# must cost sharing, never results - the analysis has to come out exactly
# as it does with the runner's normal /dev/shm.
run: |
SO="$PWD/turbo-ext/phpstan_turbo.so"
cd e2e/turbo-arena
rm -rf tmp
php -d extension="$SO" ../../bin/phpstan analyse --no-progress > "$RUNNER_TEMP/reference.txt" 2>&1 || true
tail -5 "$RUNNER_TEMP/reference.txt"
# a warm disk cache is the worst case: every worker loads and publishes
# the same entries at the same moment, so duplicates pile up fastest
php -d extension="$SO" ../../bin/phpstan clear-result-cache
sudo mount -o remount,size=32M /dev/shm
df -h /dev/shm
php -d extension="$SO" ../../bin/phpstan analyse --no-progress > "$RUNNER_TEMP/small-shm.txt" 2>&1 || true
tail -40 "$RUNNER_TEMP/small-shm.txt"
# bashunit takes the haystack as a single argv entry, and execve caps
# one argument at 128 KB - a few hundred KB of analysis output only
# fits through the assertion once grep has narrowed it down
../bashunit -a not_contains 'parallel worker' "$(grep -F 'parallel worker' "$RUNNER_TEMP/small-shm.txt" || true)"
diff "$RUNNER_TEMP/reference.txt" "$RUNNER_TEMP/small-shm.txt"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
tmp/.memory_limit
e2e/bashunit
/.phpbench
/e2e/turbo-arena/tmp
20 changes: 20 additions & 0 deletions e2e/parallel-worker-signal/kill-worker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php declare(strict_types = 1);

// Kills the parallel worker the way a native crash does: a signal, with no PHP
// error, no shutdown function, and nothing written to the output the main
// process reads back. That is how the shared-memory arena's SIGBUS killed
// workers in https://github.com/phpstan/phpstan/issues/15131, and the main
// process must say so instead of treating the dead worker like one that quit.

use PHPStan\Process\ForkedChildCrashReporter;

// A forked worker has the main process's argv; a spawned one runs the worker
// command. Killing the main process instead would prove nothing.
$isWorker = ForkedChildCrashReporter::isActive()
|| in_array('worker', $_SERVER['argv'], true);

if (!$isWorker) {
return;
}

posix_kill(posix_getpid(), SIGKILL);
6 changes: 6 additions & 0 deletions e2e/parallel-worker-signal/phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
parameters:
level: 0
paths:
- test.php
bootstrapFiles:
- kill-worker.php
13 changes: 13 additions & 0 deletions e2e/parallel-worker-signal/test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php declare(strict_types = 1);

namespace ParallelWorkerSignal;

class Foo
{

public function doFoo(): int
{
return 1;
}

}
7 changes: 7 additions & 0 deletions e2e/turbo-arena/phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
parameters:
level: 0
tmpDir: tmp
paths:
- ../../src
scanFiles:
- ../../turbo-ext/analysis-stub.php
14 changes: 11 additions & 3 deletions src/Parallel/ForkedProcess.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
use function pcntl_waitpid;
use function pcntl_wexitstatus;
use function pcntl_wifexited;
use function pcntl_wifsignaled;
use function pcntl_wtermsig;
use function rewind;
use function stream_get_contents;
use function tmpfile;
Expand Down Expand Up @@ -63,7 +65,7 @@ public function __construct(
/**
* @param callable(mixed[] $json) : void $onData
* @param callable(Throwable $exception): void $onError
* @param callable(?int $exitCode, string $output) : void $onExit
* @param callable(?int $exitCode, string $output, ?int $termSignal) : void $onExit
*/
public function start(callable $onData, callable $onError, callable $onExit): void
{
Expand All @@ -89,7 +91,7 @@ public function start(callable $onData, callable $onError, callable $onExit): vo
// Deferred so it runs after ParallelAnalyser has attached this
// process to the pool — otherwise tryQuitProcess() would no-op.
$this->loop->futureTick(static function () use ($onExit): void {
$onExit(null, 'pcntl_fork() failed.');
$onExit(null, 'pcntl_fork() failed.', null);
});
return;
}
Expand Down Expand Up @@ -140,11 +142,17 @@ public function start(callable $onData, callable $onError, callable $onExit): vo
$this->cancelTimer();

$exitCode = null;
$termSignal = null;
if ($result > 0 && pcntl_wifexited($status)) {
$exitStatus = pcntl_wexitstatus($status);
if ($exitStatus !== false) {
$exitCode = $exitStatus;
}
} elseif ($result > 0 && pcntl_wifsignaled($status)) {
$signal = pcntl_wtermsig($status);
if ($signal !== false) {
$termSignal = $signal;
}
}

$output = '';
Expand All @@ -158,7 +166,7 @@ public function start(callable $onData, callable $onError, callable $onExit): vo
}
$this->closeCaptureFiles();

$onExit($exitCode, $output);
$onExit($exitCode, $output, $termSignal);
});
}

Expand Down
26 changes: 25 additions & 1 deletion src/Parallel/ParallelAnalyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Process\ProcessHelper;
use PHPStan\Process\TerminationSignal;
use PHPStan\Reflection\BetterReflection\SourceLocator\PreForkDirectorySymbolScanner;
use React\EventLoop\LoopInterface;
use React\Promise\Deferred;
Expand Down Expand Up @@ -386,7 +387,7 @@ public function analyse(

$job = array_pop($jobs);
$process->request(['action' => 'analyse', 'files' => $job]);
}, $handleError, function ($exitCode, string $output) use (&$someChildEnded, &$internalErrors, &$internalErrorsCount, $processIdentifier): void {
}, $handleError, function ($exitCode, string $output, ?int $termSignal) use (&$someChildEnded, &$internalErrors, &$internalErrorsCount, $processIdentifier): void {
// The main process is not sampled here any more: its own peak comes
// later (collecting the workers' results, saving the result cache) and
// is read where the number is printed. Only worker peaks are summed.
Expand All @@ -396,7 +397,30 @@ public function analyse(
$this->processPool->tryQuitProcess($processIdentifier);
return;
}

// A worker killed by a signal never gets to run any PHP: no error
// is printed, no shutdown function runs, and the output the main
// process reads back is empty. The signal is the whole report -
// without it the run says nothing but "Some parallel worker jobs
// have not finished", or, when the dead worker was holding the
// last job, nothing at all.
if ($termSignal !== null) {
$internalErrors[] = new InternalError(sprintf(
"Child process was killed by signal %s.\n%s%s",
TerminationSignal::describe($termSignal),
'The OS kills a worker this way when it runs out of memory (the kernel OOM killer sends SIGKILL) or shared memory (SIGBUS), or when it crashes natively (SIGSEGV).',
$output === '' ? '' : "\n" . $output,
), 'running parallel worker', trace: [], traceAsString: null, shouldReportBug: false);
$internalErrorsCount++;
$this->processPool->tryQuitProcess($processIdentifier);
return;
}

if ($exitCode === null) {
if ($output !== '') {
$internalErrors[] = new InternalError(sprintf('Child process ended unexpectedly: %s', $output), 'running parallel worker', trace: [], traceAsString: null, shouldReportBug: true);
$internalErrorsCount++;
}
$this->processPool->tryQuitProcess($processIdentifier);
return;
}
Expand Down
6 changes: 5 additions & 1 deletion src/Parallel/Process.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,13 @@ interface Process
{

/**
* $onExit receives the worker's exit code, everything it wrote, and the
* signal it was killed by - a worker killed by one has no exit code and
* writes nothing, so the signal is all there is to report about it.
*
* @param callable(mixed[] $json) : void $onData
* @param callable(Throwable $exception): void $onError
* @param callable(?int $exitCode, string $output) : void $onExit
* @param callable(?int $exitCode, string $output, ?int $termSignal) : void $onExit
*/
public function start(callable $onData, callable $onError, callable $onExit): void;

Expand Down
6 changes: 3 additions & 3 deletions src/Parallel/SpawnedProcess.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public function __construct(
/**
* @param callable(mixed[] $json) : void $onData
* @param callable(Throwable $exception): void $onError
* @param callable(?int $exitCode, string $output) : void $onExit
* @param callable(?int $exitCode, string $output, ?int $termSignal) : void $onExit
*/
public function start(callable $onData, callable $onError, callable $onExit): void
{
Expand All @@ -60,7 +60,7 @@ public function start(callable $onData, callable $onError, callable $onExit): vo
]);
$this->process->start($this->loop);
$this->setCallbacks($onData, $onError);
$this->process->on('exit', function ($exitCode) use ($onExit): void {
$this->process->on('exit', function ($exitCode, $termSignal) use ($onExit): void {
$this->cancelTimer();

$output = '';
Expand All @@ -70,7 +70,7 @@ public function start(callable $onData, callable $onError, callable $onExit): vo
rewind($this->stdErr);
$output .= stream_get_contents($this->stdErr);

$onExit($exitCode, $output);
$onExit($exitCode, $output, $termSignal);
fclose($this->stdOut);
fclose($this->stdErr);
});
Expand Down
17 changes: 17 additions & 0 deletions src/Process/ForkedProcessPromise.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@
use function pcntl_waitpid;
use function pcntl_wexitstatus;
use function pcntl_wifexited;
use function pcntl_wifsignaled;
use function pcntl_wtermsig;
use function posix_kill;
use function rewind;
use function sprintf;
use function stream_get_contents;
use function tmpfile;
use const SIGTERM;
Expand Down Expand Up @@ -167,6 +170,20 @@ public function run(): PromiseInterface
return;
}

// A child killed by a signal writes nothing of its own, so naming
// the signal is all the crash report there is.
if ($result > 0 && pcntl_wifsignaled($status)) {
$signal = pcntl_wtermsig($status);
if ($signal !== false) {
$this->deferred->reject(new ProcessCrashedException(sprintf(
"Child process was killed by signal %s.\n%s",
TerminationSignal::describe($signal),
$stdOut . $stdErr,
)));
return;
}
}

$this->deferred->reject(new ProcessCrashedException($stdOut . $stdErr));
});

Expand Down
70 changes: 70 additions & 0 deletions src/Process/TerminationSignal.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php declare(strict_types = 1);

namespace PHPStan\Process;

use function constant;
use function defined;
use function sprintf;

/**
* Names the signal a child process was killed by, for an error message.
*
* Signal numbers are platform-specific - SIGBUS is 7 on Linux and 10 on macOS
* - so the names come from the running platform's own SIG* constants. Those
* are defined by ext-pcntl, hence the bare number when it is not loaded.
*/
final class TerminationSignal
{

/**
* Ordered so that the canonical name wins among the aliases sharing a
* number (SIGABRT over SIGIOT, SIGCHLD over SIGCLD, SIGIO over SIGPOLL).
*/
private const NAMES = [
'SIGHUP',
'SIGINT',
'SIGQUIT',
'SIGILL',
'SIGTRAP',
'SIGABRT',
'SIGBUS',
'SIGFPE',
'SIGKILL',
'SIGUSR1',
'SIGSEGV',
'SIGUSR2',
'SIGPIPE',
'SIGALRM',
'SIGTERM',
'SIGSTKFLT',
'SIGCHLD',
'SIGCONT',
'SIGSTOP',
'SIGTSTP',
'SIGTTIN',
'SIGTTOU',
'SIGURG',
'SIGXCPU',
'SIGXFSZ',
'SIGVTALRM',
'SIGPROF',
'SIGWINCH',
'SIGIO',
'SIGPWR',
'SIGSYS',
];

public static function describe(int $signal): string
{
foreach (self::NAMES as $name) {
if (!defined($name) || constant($name) !== $signal) {
continue;
}

return sprintf('%d (%s)', $signal, $name);
}

return (string) $signal;
}

}
Loading
Loading