This commit is contained in:
nuno maduro
2026-05-01 19:50:54 +01:00
parent d106b70766
commit 45b1d4ce20
10 changed files with 332 additions and 383 deletions

View File

@ -1,182 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
/**
* Re-execs the PHP process with `pcov.directory` pinned to the project
* root so pcov never instruments anything outside it (vendor, system
* includes, etc.).
*
* pcov reads `pcov.directory` once, on the first file it instruments —
* setting it via `ini_set()` from inside the test runner is too late
* for files already compiled by Composer's autoloader. Restarting the
* process with `-dpcov.directory=<root>` from the very top of `bin/pest`
* means *every* file pcov sees is filtered correctly.
*
* Only fires when ALL of these hold:
* 1. The pcov extension is loaded.
* 2. `--tia` is present in argv (plain `pest` runs are unaffected).
* 3. The current `pcov.directory` differs from the project root.
* 4. We are not already the restarted process — guarded by an env
* sentinel so a single round-trip is enough.
*
* Modelled after {@see XdebugGuard}: the same "check before doing real
* work in `bin/pest`" position, the same conservative gating around
* `--tia`. They are independent — both can fire on the same invocation
* (the user has pcov *and* xdebug loaded), in which case Xdebug is
* dropped first and the pcov restart inherits the slimmer process.
*
* @internal
*/
final class PcovGuard
{
private const string ENV_RESTARTED = 'PEST_PCOV_GUARD_RESTARTED';
/**
* Call as early as possible after Composer autoload, before any
* Pest class beyond the autoloader is touched. Idempotent and
* defensive — returns silently when pcov isn't installed, when the
* INI is already correct, or when we've already restarted.
*/
public static function maybeRestart(string $projectRoot): void
{
if (! extension_loaded('pcov')) {
return;
}
if (getenv(self::ENV_RESTARTED) === '1') {
return;
}
$argv = is_array($_SERVER['argv'] ?? null) ? $_SERVER['argv'] : [];
if (! self::hasTiaFlag($argv)) {
return;
}
$desired = self::normalise($projectRoot);
$current = self::normalise((string) ini_get('pcov.directory'));
if ($current === $desired) {
return;
}
self::restart($projectRoot, $argv);
}
/**
* @param array<int, mixed> $argv
*/
private static function hasTiaFlag(array $argv): bool
{
foreach ($argv as $value) {
if (is_string($value) && $value === '--tia') {
return true;
}
}
return false;
}
/**
* Spawns a child PHP process inheriting our stdin/stdout/stderr and
* exits with its status. `pcntl_exec` would be the cleanest path
* (replaces the current process, no double-buffering) but it isn't
* available on Windows or in environments that disable it; the
* `proc_open` fallback works everywhere PHP runs.
*
* @param array<int, mixed> $argv
*/
private static function restart(string $projectRoot, array $argv): void
{
$script = self::scriptArgv($argv);
if ($script === null) {
return;
}
$env = self::inheritEnv();
$env[self::ENV_RESTARTED] = '1';
$command = array_merge(
[PHP_BINARY, '-d', 'pcov.directory='.$projectRoot],
$script,
);
if (function_exists('pcntl_exec')) {
// `pcntl_exec` returns false on failure and replaces the
// process on success — no `exit` needed in the success path.
// Pass the env explicitly because pcntl_exec doesn't inherit
// by default.
$binary = array_shift($command);
if (is_string($binary)) {
@pcntl_exec($binary, $command, $env);
}
// If we're still here, pcntl_exec failed; fall through.
}
$proc = @proc_open(
$command,
[STDIN, STDOUT, STDERR],
$pipes,
null,
$env,
);
if (! is_resource($proc)) {
return;
}
$exitCode = proc_close($proc);
exit($exitCode === -1 ? 1 : $exitCode);
}
/**
* Reconstructs the argv we want the child process to receive: the
* script path followed by every original argument. Returns null
* when argv is malformed and we can't safely restart.
*
* @param array<int, mixed> $argv
* @return list<string>|null
*/
private static function scriptArgv(array $argv): ?array
{
$out = [];
foreach ($argv as $value) {
if (! is_string($value)) {
return null;
}
$out[] = $value;
}
return $out === [] ? null : $out;
}
/**
* @return array<string, string>
*/
private static function inheritEnv(): array
{
$env = [];
foreach (getenv() as $name => $value) {
if (is_string($name) && is_string($value)) {
$env[$name] = $value;
}
}
return $env;
}
private static function normalise(string $path): string
{
return rtrim($path, '/\\');
}
}

View File

@ -1,178 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Composer\XdebugHandler\XdebugHandler;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\Storage;
/**
* Re-execs the PHP process without Xdebug on TIA replay runs, matching the
* behaviour of composer, phpstan, rector, psalm and pint.
*
* Xdebug imposes a 3050% runtime tax on every PHP process that loads it —
* even when nothing is actively tracing, profiling or breaking. Plain `pest`
* users might rely on Xdebug being loaded (IDE breakpoints, step-through
* debugging, custom tooling), so we intentionally leave non-TIA runs alone.
*
* The guard engages only when ALL of these hold:
* 1. `--tia` is present in argv.
* 2. No `--fresh` flag (forced record always drives the coverage
* driver; dropping Xdebug would break the recording).
* 3. No `--coverage*` flag (coverage runs need the driver regardless).
* 4. A valid graph already exists on disk AND its structural fingerprint
* matches the current environment — i.e. TIA will replay rather than
* record. Record runs need the driver.
* 5. Xdebug's configured mode is either empty or exactly `['coverage']`.
* Any other mode (debug, develop, trace, profile, gcstats) signals the
* user wants Xdebug for reasons unrelated to coverage, so we leave it
* alone even on replay.
*
* `PEST_ALLOW_XDEBUG=1` remains an explicit manual override; it is honoured
* natively by `composer/xdebug-handler`.
*
* @internal
*/
final class XdebugGuard
{
/**
* Call as early as possible after composer autoload, before any Pest
* class beyond the autoloader is touched. Safe when Xdebug is not
* loaded (returns immediately) and when `composer/xdebug-handler` is
* unavailable (defensive `class_exists` check).
*/
public static function maybeDrop(string $projectRoot): void
{
if (! class_exists(XdebugHandler::class)) {
return;
}
if (! extension_loaded('xdebug')) {
return;
}
if (! self::xdebugIsCoverageOnly()) {
return;
}
$argv = is_array($_SERVER['argv'] ?? null) ? $_SERVER['argv'] : [];
if (! self::runLooksDroppable($argv, $projectRoot)) {
return;
}
(new XdebugHandler('pest'))->check();
}
/**
* True when Xdebug 3+ is running in coverage-only mode (or empty). False
* for older Xdebug without `xdebug_info` — be conservative and leave it
* loaded; we can't prove the mode is safe to drop.
*/
private static function xdebugIsCoverageOnly(): bool
{
if (! function_exists('xdebug_info')) {
return false;
}
$modes = @xdebug_info('mode');
if (! is_array($modes)) {
return false;
}
$modes = array_values(array_filter($modes, is_string(...)));
if ($modes === []) {
return true;
}
return $modes === ['coverage'];
}
/**
* Encodes the argv-based rules: `--tia` must be present, no coverage
* flag, no forced rebuild, and TIA must be about to replay rather than
* record. Plain `pest` (and anything else without `--tia`) keeps Xdebug
* loaded so non-TIA users aren't surprised by behaviour changes.
*
* @param array<int, mixed> $argv
*/
private static function runLooksDroppable(array $argv, string $projectRoot): bool
{
$hasTia = false;
foreach ($argv as $value) {
if (! is_string($value)) {
continue;
}
if ($value === '--coverage'
|| str_starts_with($value, '--coverage=')
|| str_starts_with($value, '--coverage-')) {
return false;
}
if ($value === '--fresh') {
return false;
}
if ($value === '--tia') {
$hasTia = true;
}
}
if (! $hasTia) {
return false;
}
return self::tiaWillReplay($projectRoot);
}
/**
* True when a valid TIA graph already lives on disk AND its structural
* fingerprint matches the current environment. Any other outcome
* (missing graph, unreadable JSON, structural drift) means TIA will
* record and the driver must stay loaded.
*/
private static function tiaWillReplay(string $projectRoot): bool
{
$path = self::graphPath($projectRoot);
if (! is_file($path)) {
return false;
}
$json = @file_get_contents($path);
if ($json === false) {
return false;
}
$graph = Graph::decode($json, $projectRoot);
if (! $graph instanceof Graph) {
return false;
}
return Fingerprint::structuralMatches(
$graph->fingerprint(),
Fingerprint::compute($projectRoot),
);
}
/**
* On-disk location of the TIA graph — delegates to {@see Storage} so
* the writer (TIA's bootstrapper) and this reader stay in sync
* without a runtime container lookup (the container isn't booted yet
* at this point).
*/
private static function graphPath(string $projectRoot): string
{
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;
}
}