diff --git a/.gitattributes b/.gitattributes index 84c7add0..14c3c359 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,3 @@ /Tests export-ignore /phpunit.xml.dist export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..4689c4da --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/symfony + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/.github/workflows/close-pull-request.yml b/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000..e55b4781 --- /dev/null +++ b/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/symfony + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/ExecutableFinder.php b/ExecutableFinder.php index 5914b4cd..89edd22f 100644 --- a/ExecutableFinder.php +++ b/ExecutableFinder.php @@ -19,7 +19,15 @@ */ class ExecutableFinder { - private $suffixes = ['.exe', '.bat', '.cmd', '.com']; + private const CMD_BUILTINS = [ + 'assoc', 'break', 'call', 'cd', 'chdir', 'cls', 'color', 'copy', 'date', + 'del', 'dir', 'echo', 'endlocal', 'erase', 'exit', 'for', 'ftype', 'goto', + 'help', 'if', 'label', 'md', 'mkdir', 'mklink', 'move', 'path', 'pause', + 'popd', 'prompt', 'pushd', 'rd', 'rem', 'ren', 'rename', 'rmdir', 'set', + 'setlocal', 'shift', 'start', 'time', 'title', 'type', 'ver', 'vol', + ]; + + private $suffixes = []; /** * Replaces default suffixes of executable. @@ -46,41 +54,50 @@ public function addSuffix(string $suffix) * * @return string|null */ - public function find(string $name, string $default = null, array $extraDirs = []) + public function find(string $name, ?string $default = null, array $extraDirs = []) { - if (ini_get('open_basedir')) { - $searchPath = array_merge(explode(\PATH_SEPARATOR, ini_get('open_basedir')), $extraDirs); - $dirs = []; - foreach ($searchPath as $path) { - // Silencing against https://bugs.php.net/69240 - if (@is_dir($path)) { - $dirs[] = $path; - } else { - if (basename($path) == $name && @is_executable($path)) { - return $path; - } - } - } - } else { - $dirs = array_merge( - explode(\PATH_SEPARATOR, getenv('PATH') ?: getenv('Path')), - $extraDirs - ); + // windows built-in commands that are present in cmd.exe should not be resolved using PATH as they do not exist as exes + if ('\\' === \DIRECTORY_SEPARATOR && \in_array(strtolower($name), self::CMD_BUILTINS, true)) { + return $name; } - $suffixes = ['']; + $dirs = array_merge( + explode(\PATH_SEPARATOR, getenv('PATH') ?: getenv('Path')), + $extraDirs + ); + + $suffixes = []; if ('\\' === \DIRECTORY_SEPARATOR) { $pathExt = getenv('PATHEXT'); - $suffixes = array_merge($pathExt ? explode(\PATH_SEPARATOR, $pathExt) : $this->suffixes, $suffixes); + $suffixes = $this->suffixes; + $suffixes = array_merge($suffixes, $pathExt ? explode(\PATH_SEPARATOR, $pathExt) : ['.exe', '.bat', '.cmd', '.com']); } + $suffixes = '' !== pathinfo($name, PATHINFO_EXTENSION) ? array_merge([''], $suffixes) : array_merge($suffixes, ['']); foreach ($suffixes as $suffix) { foreach ($dirs as $dir) { + if ('' === $dir) { + $dir = '.'; + } if (@is_file($file = $dir.\DIRECTORY_SEPARATOR.$name.$suffix) && ('\\' === \DIRECTORY_SEPARATOR || @is_executable($file))) { return $file; } + + if (!@is_dir($dir) && basename($dir) === $name.$suffix && @is_executable($dir)) { + return $dir; + } } } + if ('\\' === \DIRECTORY_SEPARATOR || !\function_exists('exec') || \strlen($name) !== strcspn($name, '/'.\DIRECTORY_SEPARATOR)) { + return $default; + } + + $execResult = exec('command -v -- '.escapeshellarg($name)); + + if (($executablePath = substr($execResult, 0, strpos($execResult, \PHP_EOL) ?: null)) && @is_executable($executablePath)) { + return $executablePath; + } + return $default; } } diff --git a/InputStream.php b/InputStream.php index 240665f3..0c45b524 100644 --- a/InputStream.php +++ b/InputStream.php @@ -30,7 +30,7 @@ class InputStream implements \IteratorAggregate /** * Sets a callback that is called when the write buffer becomes empty. */ - public function onEmpty(callable $onEmpty = null) + public function onEmpty(?callable $onEmpty = null) { $this->onEmpty = $onEmpty; } diff --git a/LICENSE b/LICENSE index 88bf75bb..0138f8f0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2004-2022 Fabien Potencier +Copyright (c) 2004-present Fabien Potencier Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/PhpExecutableFinder.php b/PhpExecutableFinder.php index 998808b6..c3a9680d 100644 --- a/PhpExecutableFinder.php +++ b/PhpExecutableFinder.php @@ -34,15 +34,8 @@ public function __construct() public function find(bool $includeArgs = true) { if ($php = getenv('PHP_BINARY')) { - if (!is_executable($php)) { - $command = '\\' === \DIRECTORY_SEPARATOR ? 'where' : 'command -v'; - if ($php = strtok(exec($command.' '.escapeshellarg($php)), \PHP_EOL)) { - if (!is_executable($php)) { - return false; - } - } else { - return false; - } + if (!is_executable($php) && !$php = $this->executableFinder->find($php)) { + return false; } if (@is_dir($php)) { @@ -56,7 +49,7 @@ public function find(bool $includeArgs = true) $args = $includeArgs && $args ? ' '.implode(' ', $args) : ''; // PHP_BINARY return the current sapi executable - if (\PHP_BINARY && \in_array(\PHP_SAPI, ['cgi-fcgi', 'cli', 'cli-server', 'phpdbg'], true)) { + if (\PHP_BINARY && \in_array(\PHP_SAPI, ['cli', 'cli-server', 'phpdbg'], true)) { return \PHP_BINARY.$args; } diff --git a/PhpProcess.php b/PhpProcess.php index 2bc338e5..3a1d147c 100644 --- a/PhpProcess.php +++ b/PhpProcess.php @@ -32,7 +32,7 @@ class PhpProcess extends Process * @param int $timeout The timeout in seconds * @param array|null $php Path to the PHP binary to use with any additional arguments */ - public function __construct(string $script, string $cwd = null, array $env = null, int $timeout = 60, array $php = null) + public function __construct(string $script, ?string $cwd = null, ?array $env = null, int $timeout = 60, ?array $php = null) { if (null === $php) { $executableFinder = new PhpExecutableFinder(); @@ -53,7 +53,7 @@ public function __construct(string $script, string $cwd = null, array $env = nul /** * {@inheritdoc} */ - public static function fromShellCommandline(string $command, string $cwd = null, array $env = null, $input = null, ?float $timeout = 60) + public static function fromShellCommandline(string $command, ?string $cwd = null, ?array $env = null, $input = null, ?float $timeout = 60) { throw new LogicException(sprintf('The "%s()" method cannot be called when using "%s".', __METHOD__, self::class)); } @@ -61,7 +61,7 @@ public static function fromShellCommandline(string $command, string $cwd = null, /** * {@inheritdoc} */ - public function start(callable $callback = null, array $env = []) + public function start(?callable $callback = null, array $env = []) { if (null === $this->getCommandLine()) { throw new RuntimeException('Unable to find the PHP executable.'); diff --git a/Pipes/AbstractPipes.php b/Pipes/AbstractPipes.php index 01051005..656dc032 100644 --- a/Pipes/AbstractPipes.php +++ b/Pipes/AbstractPipes.php @@ -104,7 +104,7 @@ protected function write(): ?array stream_set_blocking($input, 0); } elseif (!isset($this->inputBuffer[0])) { if (!\is_string($input)) { - if (!is_scalar($input)) { + if (!\is_scalar($input)) { throw new InvalidArgumentException(sprintf('"%s" yielded a value of type "%s", but only scalars and stream resources are supported.', get_debug_type($this->input), get_debug_type($input))); } $input = (string) $input; diff --git a/Pipes/WindowsPipes.php b/Pipes/WindowsPipes.php index bca84f57..968dd026 100644 --- a/Pipes/WindowsPipes.php +++ b/Pipes/WindowsPipes.php @@ -149,7 +149,7 @@ public function readAndWrite(bool $blocking, bool $close = false): array if ($w) { @stream_select($r, $w, $e, 0, Process::TIMEOUT_PRECISION * 1E6); } elseif ($this->fileHandles) { - usleep(Process::TIMEOUT_PRECISION * 1E6); + usleep((int) (Process::TIMEOUT_PRECISION * 1E6)); } } foreach ($this->fileHandles as $type => $fileHandle) { diff --git a/Process.php b/Process.php index 92771594..91f9e8fe 100644 --- a/Process.php +++ b/Process.php @@ -55,7 +55,7 @@ class Process implements \IteratorAggregate private $hasCallback = false; private $commandline; private $cwd; - private $env; + private $env = []; private $input; private $starttime; private $lastOutputTime; @@ -80,6 +80,7 @@ class Process implements \IteratorAggregate private $processPipes; private $latestSignal; + private $cachedExitCode; private static $sigchild; @@ -140,7 +141,7 @@ class Process implements \IteratorAggregate * * @throws LogicException When proc_open is not installed */ - public function __construct(array $command, string $cwd = null, array $env = null, $input = null, ?float $timeout = 60) + public function __construct(array $command, ?string $cwd = null, ?array $env = null, $input = null, ?float $timeout = 60) { if (!\function_exists('proc_open')) { throw new LogicException('The Process class relies on proc_open, which is not available on your PHP installation.'); @@ -189,7 +190,7 @@ public function __construct(array $command, string $cwd = null, array $env = nul * * @throws LogicException When proc_open is not installed */ - public static function fromShellCommandline(string $command, string $cwd = null, array $env = null, $input = null, ?float $timeout = 60) + public static function fromShellCommandline(string $command, ?string $cwd = null, ?array $env = null, $input = null, ?float $timeout = 60) { $process = new static([], $cwd, $env, $input, $timeout); $process->commandline = $command; @@ -247,7 +248,7 @@ public function __clone() * * @final */ - public function run(callable $callback = null, array $env = []): int + public function run(?callable $callback = null, array $env = []): int { $this->start($callback, $env); @@ -266,7 +267,7 @@ public function run(callable $callback = null, array $env = []): int * * @final */ - public function mustRun(callable $callback = null, array $env = []): self + public function mustRun(?callable $callback = null, array $env = []): self { if (0 !== $this->run($callback, $env)) { throw new ProcessFailedException($this); @@ -294,7 +295,7 @@ public function mustRun(callable $callback = null, array $env = []): self * @throws RuntimeException When process is already running * @throws LogicException In case a callback is provided and output has been disabled */ - public function start(callable $callback = null, array $env = []) + public function start(?callable $callback = null, array $env = []) { if ($this->isRunning()) { throw new RuntimeException('Process is already running.'); @@ -331,7 +332,7 @@ public function start(callable $callback = null, array $env = []) // See https://unix.stackexchange.com/questions/71205/background-process-pipe-input $commandline = '{ ('.$commandline.') <&3 3<&- 3>/dev/null & } 3<&0;'; - $commandline .= 'pid=$!; echo $pid >&3; wait $pid; code=$?; echo $code >&3; exit $code'; + $commandline .= 'pid=$!; echo $pid >&3; wait $pid 2>/dev/null; code=$?; echo $code >&3; exit $code'; // Workaround for the bug, when PTS functionality is enabled. // @see : https://bugs.php.net/69442 @@ -351,7 +352,7 @@ public function start(callable $callback = null, array $env = []) $this->process = @proc_open($commandline, $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options); - if (!\is_resource($this->process)) { + if (!$this->process) { throw new RuntimeException('Unable to launch a new process.'); } $this->status = self::STATUS_STARTED; @@ -385,7 +386,7 @@ public function start(callable $callback = null, array $env = []) * * @final */ - public function restart(callable $callback = null, array $env = []): self + public function restart(?callable $callback = null, array $env = []): self { if ($this->isRunning()) { throw new RuntimeException('Process is already running.'); @@ -412,7 +413,7 @@ public function restart(callable $callback = null, array $env = []): self * @throws ProcessSignaledException When process stopped after receiving signal * @throws LogicException When process is not yet started */ - public function wait(callable $callback = null) + public function wait(?callable $callback = null) { $this->requireProcessIsStarted(__FUNCTION__); @@ -428,7 +429,7 @@ public function wait(callable $callback = null) do { $this->checkTimeout(); - $running = '\\' === \DIRECTORY_SEPARATOR ? $this->isRunning() : $this->processPipes->areOpen(); + $running = $this->isRunning() && ('\\' === \DIRECTORY_SEPARATOR || $this->processPipes->areOpen()); $this->readPipes($running, '\\' !== \DIRECTORY_SEPARATOR || !$running); } while ($running); @@ -617,10 +618,10 @@ public function getIncrementalOutput() * * @param int $flags A bit field of Process::ITER_* flags * + * @return \Generator + * * @throws LogicException in case the output has been disabled * @throws LogicException In case the process is not started - * - * @return \Generator */ #[\ReturnTypeWillChange] public function getIterator(int $flags = 0) @@ -910,11 +911,11 @@ public function getStatus() * Stops the process. * * @param int|float $timeout The timeout in seconds - * @param int $signal A POSIX signal to send in case the process has not stop at timeout, default is SIGKILL (9) + * @param int|null $signal A POSIX signal to send in case the process has not stop at timeout, default is SIGKILL (9) * * @return int|null The exit-code of the process or null if it's not running */ - public function stop(float $timeout = 10, int $signal = null) + public function stop(float $timeout = 10, ?int $signal = null) { $timeoutMicro = microtime(true) + $timeout; if ($this->isRunning()) { @@ -1310,7 +1311,7 @@ private function getDescriptors(): array * * @return \Closure */ - protected function buildCallback(callable $callback = null) + protected function buildCallback(?callable $callback = null) { if ($this->outputDisabled) { return function ($type, $data) use ($callback): bool { @@ -1345,6 +1346,19 @@ protected function updateStatus(bool $blocking) $this->processInformation = proc_get_status($this->process); $running = $this->processInformation['running']; + // In PHP < 8.3, "proc_get_status" only returns the correct exit status on the first call. + // Subsequent calls return -1 as the process is discarded. This workaround caches the first + // retrieved exit status for consistent results in later calls, mimicking PHP 8.3 behavior. + if (\PHP_VERSION_ID < 80300) { + if (!isset($this->cachedExitCode) && !$running && -1 !== $this->processInformation['exitcode']) { + $this->cachedExitCode = $this->processInformation['exitcode']; + } + + if (isset($this->cachedExitCode) && !$running && -1 === $this->processInformation['exitcode']) { + $this->processInformation['exitcode'] = $this->cachedExitCode; + } + } + $this->readPipes($running && $blocking, '\\' !== \DIRECTORY_SEPARATOR || !$running); if ($this->fallbackStatus && $this->isSigchildEnabled()) { @@ -1442,8 +1456,9 @@ private function readPipes(bool $blocking, bool $close) private function close(): int { $this->processPipes->close(); - if (\is_resource($this->process)) { + if ($this->process) { proc_close($this->process); + $this->process = null; } $this->exitcode = $this->processInformation['exitcode']; $this->status = self::STATUS_TERMINATED; @@ -1577,7 +1592,14 @@ function ($m) use (&$env, &$varCache, &$varCount, $uid) { $cmd ); - $cmd = 'cmd /V:ON /E:ON /D /C ('.str_replace("\n", ' ', $cmd).')'; + static $comSpec; + + if (!$comSpec && $comSpec = (new ExecutableFinder())->find('cmd.exe')) { + // Escape according to CommandLineToArgvW rules + $comSpec = '"'.preg_replace('{(\\\\*+)"}', '$1$1\"', $comSpec) .'"'; + } + + $cmd = ($comSpec ?? 'cmd').' /V:ON /E:ON /D /C ('.str_replace("\n", ' ', $cmd).')'; foreach ($this->processPipes->getFiles() as $offset => $filename) { $cmd .= ' '.$offset.'>"'.$filename.'"'; } @@ -1623,7 +1645,7 @@ private function escapeArgument(?string $argument): string if (str_contains($argument, "\0")) { $argument = str_replace("\0", '?', $argument); } - if (!preg_match('/[\/()%!^"<>&|\s]/', $argument)) { + if (!preg_match('/[()%!^"<>&|\s]/', $argument)) { return $argument; } $argument = preg_replace('/(\\\\+)$/', '$1$1', $argument); diff --git a/ProcessUtils.php b/ProcessUtils.php index 6cc7a610..2a7aff71 100644 --- a/ProcessUtils.php +++ b/ProcessUtils.php @@ -48,7 +48,7 @@ public static function validateInput(string $caller, $input) if (\is_string($input)) { return $input; } - if (is_scalar($input)) { + if (\is_scalar($input)) { return (string) $input; } if ($input instanceof Process) { diff --git a/Tests/ErrorProcessInitiator.php b/Tests/ErrorProcessInitiator.php old mode 100755 new mode 100644 index 4c8556ac..0b75add6 --- a/Tests/ErrorProcessInitiator.php +++ b/Tests/ErrorProcessInitiator.php @@ -14,18 +14,18 @@ use Symfony\Component\Process\Exception\ProcessTimedOutException; use Symfony\Component\Process\Process; -require \dirname(__DIR__).'/vendor/autoload.php'; +require is_file(\dirname(__DIR__).'/vendor/autoload.php') ? \dirname(__DIR__).'/vendor/autoload.php' : \dirname(__DIR__, 5).'/vendor/autoload.php'; ['e' => $php] = getopt('e:') + ['e' => 'php']; try { - $process = new Process("exec $php -r \"echo 'ready'; trigger_error('error', E_USER_ERROR);\""); + $process = new Process([$php, '-r', "echo 'ready'; trigger_error('error', E_USER_ERROR);"]); $process->start(); $process->setTimeout(0.5); while (!str_contains($process->getOutput(), 'ready')) { usleep(1000); } - $process->signal(\SIGSTOP); + $process->isRunning() && $process->signal(\SIGSTOP); $process->wait(); return $process->getExitCode(); diff --git a/Tests/ExecutableFinderTest.php b/Tests/ExecutableFinderTest.php index d056841f..84e5b3c3 100644 --- a/Tests/ExecutableFinderTest.php +++ b/Tests/ExecutableFinderTest.php @@ -19,29 +19,18 @@ */ class ExecutableFinderTest extends TestCase { - private $path; - protected function tearDown(): void { - if ($this->path) { - // Restore path if it was changed. - putenv('PATH='.$this->path); - } - } - - private function setPath($path) - { - $this->path = getenv('PATH'); - putenv('PATH='.$path); + putenv('PATH='.($_SERVER['PATH'] ?? $_SERVER['Path'])); } public function testFind() { - if (ini_get('open_basedir')) { + if (\ini_get('open_basedir')) { $this->markTestSkipped('Cannot test when open_basedir is set'); } - $this->setPath(\dirname(\PHP_BINARY)); + putenv('PATH='.\dirname(\PHP_BINARY)); $finder = new ExecutableFinder(); $result = $finder->find($this->getPhpBinaryName()); @@ -51,13 +40,13 @@ public function testFind() public function testFindWithDefault() { - if (ini_get('open_basedir')) { + if (\ini_get('open_basedir')) { $this->markTestSkipped('Cannot test when open_basedir is set'); } $expected = 'defaultValue'; - $this->setPath(''); + putenv('PATH='); $finder = new ExecutableFinder(); $result = $finder->find('foo', $expected); @@ -67,11 +56,11 @@ public function testFindWithDefault() public function testFindWithNullAsDefault() { - if (ini_get('open_basedir')) { + if (\ini_get('open_basedir')) { $this->markTestSkipped('Cannot test when open_basedir is set'); } - $this->setPath(''); + putenv('PATH='); $finder = new ExecutableFinder(); @@ -82,11 +71,11 @@ public function testFindWithNullAsDefault() public function testFindWithExtraDirs() { - if (ini_get('open_basedir')) { + if (\ini_get('open_basedir')) { $this->markTestSkipped('Cannot test when open_basedir is set'); } - $this->setPath(''); + putenv('PATH='); $extraDirs = [\dirname(\PHP_BINARY)]; @@ -105,64 +94,85 @@ public function testFindWithOpenBaseDir() $this->markTestSkipped('Cannot run test on windows'); } - if (ini_get('open_basedir')) { + if (\ini_get('open_basedir')) { $this->markTestSkipped('Cannot test when open_basedir is set'); } - $this->iniSet('open_basedir', \dirname(\PHP_BINARY).\PATH_SEPARATOR.'/'); + putenv('PATH='.\dirname(\PHP_BINARY)); + $initialOpenBaseDir = ini_set('open_basedir', \dirname(\PHP_BINARY).\PATH_SEPARATOR.'/'); - $finder = new ExecutableFinder(); - $result = $finder->find($this->getPhpBinaryName()); + try { + $finder = new ExecutableFinder(); + $result = $finder->find($this->getPhpBinaryName()); - $this->assertSamePath(\PHP_BINARY, $result); + $this->assertSamePath(\PHP_BINARY, $result); + } finally { + ini_set('open_basedir', $initialOpenBaseDir); + } } /** * @runInSeparateProcess */ - public function testFindProcessInOpenBasedir() + public function testFindBatchExecutableOnWindows() { - if (ini_get('open_basedir')) { + if (\ini_get('open_basedir')) { $this->markTestSkipped('Cannot test when open_basedir is set'); } - if ('\\' === \DIRECTORY_SEPARATOR) { - $this->markTestSkipped('Cannot run test on windows'); + if ('\\' !== \DIRECTORY_SEPARATOR) { + $this->markTestSkipped('Can be only tested on windows'); } - $this->setPath(''); - $this->iniSet('open_basedir', \PHP_BINARY.\PATH_SEPARATOR.'/'); + $target = str_replace('.tmp', '_tmp', tempnam(sys_get_temp_dir(), 'example-windows-executable')); - $finder = new ExecutableFinder(); - $result = $finder->find($this->getPhpBinaryName(), false); + try { + touch($target); + touch($target.'.BAT'); - $this->assertSamePath(\PHP_BINARY, $result); - } + $this->assertFalse(is_executable($target)); - public function testFindBatchExecutableOnWindows() - { - if (ini_get('open_basedir')) { - $this->markTestSkipped('Cannot test when open_basedir is set'); - } - if ('\\' !== \DIRECTORY_SEPARATOR) { - $this->markTestSkipped('Can be only tested on windows'); + putenv('PATH='.sys_get_temp_dir()); + + $finder = new ExecutableFinder(); + $result = $finder->find(basename($target), false); + } finally { + unlink($target); + unlink($target.'.BAT'); } - $target = tempnam(sys_get_temp_dir(), 'example-windows-executable'); + $this->assertSamePath($target.'.BAT', $result); + } - touch($target); - touch($target.'.BAT'); + /** + * @runInSeparateProcess + */ + public function testEmptyDirInPath() + { + putenv(sprintf('PATH=%s%s', \dirname(\PHP_BINARY), \PATH_SEPARATOR)); - $this->assertFalse(is_executable($target)); + try { + touch('executable'); + chmod('executable', 0700); - $this->setPath(sys_get_temp_dir()); + $finder = new ExecutableFinder(); + $result = $finder->find('executable'); - $finder = new ExecutableFinder(); - $result = $finder->find(basename($target), false); + $this->assertSame(sprintf('.%sexecutable', \DIRECTORY_SEPARATOR), $result); + } finally { + unlink('executable'); + } + } - unlink($target); - unlink($target.'.BAT'); + public function testFindBuiltInCommandOnWindows() + { + if ('\\' !== \DIRECTORY_SEPARATOR) { + $this->markTestSkipped('Can be only tested on windows'); + } - $this->assertSamePath($target.'.BAT', $result); + $finder = new ExecutableFinder(); + $this->assertSame('rmdir', strtolower($finder->find('RMDIR'))); + $this->assertSame('cd', strtolower($finder->find('cd'))); + $this->assertSame('move', strtolower($finder->find('MoVe'))); } private function assertSamePath($expected, $tested) diff --git a/Tests/ProcessFailedExceptionTest.php b/Tests/ProcessFailedExceptionTest.php index d6d7bfb0..259ffd63 100644 --- a/Tests/ProcessFailedExceptionTest.php +++ b/Tests/ProcessFailedExceptionTest.php @@ -25,7 +25,7 @@ class ProcessFailedExceptionTest extends TestCase */ public function testProcessFailedExceptionThrowsException() { - $process = $this->getMockBuilder(Process::class)->setMethods(['isSuccessful'])->setConstructorArgs([['php']])->getMock(); + $process = $this->getMockBuilder(Process::class)->onlyMethods(['isSuccessful'])->setConstructorArgs([['php']])->getMock(); $process->expects($this->once()) ->method('isSuccessful') ->willReturn(true); @@ -49,7 +49,7 @@ public function testProcessFailedExceptionPopulatesInformationFromProcessOutput( $errorOutput = 'FATAL: Unexpected error'; $workingDirectory = getcwd(); - $process = $this->getMockBuilder(Process::class)->setMethods(['isSuccessful', 'getOutput', 'getErrorOutput', 'getExitCode', 'getExitCodeText', 'isOutputDisabled', 'getWorkingDirectory'])->setConstructorArgs([[$cmd]])->getMock(); + $process = $this->getMockBuilder(Process::class)->onlyMethods(['isSuccessful', 'getOutput', 'getErrorOutput', 'getExitCode', 'getExitCodeText', 'isOutputDisabled', 'getWorkingDirectory'])->setConstructorArgs([[$cmd]])->getMock(); $process->expects($this->once()) ->method('isSuccessful') ->willReturn(false); @@ -97,7 +97,7 @@ public function testDisabledOutputInFailedExceptionDoesNotPopulateOutput() $exitText = 'General error'; $workingDirectory = getcwd(); - $process = $this->getMockBuilder(Process::class)->setMethods(['isSuccessful', 'isOutputDisabled', 'getExitCode', 'getExitCodeText', 'getOutput', 'getErrorOutput', 'getWorkingDirectory'])->setConstructorArgs([[$cmd]])->getMock(); + $process = $this->getMockBuilder(Process::class)->onlyMethods(['isSuccessful', 'isOutputDisabled', 'getExitCode', 'getExitCodeText', 'getOutput', 'getErrorOutput', 'getWorkingDirectory'])->setConstructorArgs([[$cmd]])->getMock(); $process->expects($this->once()) ->method('isSuccessful') ->willReturn(false); diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index b1a8cc72..e4d92874 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -66,11 +66,11 @@ public function testInvalidCwd() $cmd->run(); } + /** + * @group transient-on-windows + */ public function testThatProcessDoesNotThrowWarningDuringRun() { - if ('\\' === \DIRECTORY_SEPARATOR) { - $this->markTestSkipped('This test is transient on Windows'); - } @trigger_error('Test Error', \E_USER_NOTICE); $process = $this->getProcessForCode('sleep(3)'); $process->run(); @@ -130,12 +130,11 @@ public function testStopWithTimeoutIsActuallyWorking() $this->assertLessThan(15, microtime(true) - $start); } + /** + * @group transient-on-windows + */ public function testWaitUntilSpecificOutput() { - if ('\\' === \DIRECTORY_SEPARATOR) { - $this->markTestIncomplete('This test is too transient on Windows, help wanted to improve it'); - } - $p = $this->getProcess([self::$phpBin, __DIR__.'/KillableProcessWithOutput.php']); $p->start(); @@ -301,7 +300,7 @@ public function testInvalidInput($value) $process->setInput($value); } - public function provideInvalidInputValues() + public static function provideInvalidInputValues() { return [ [[]], @@ -319,7 +318,7 @@ public function testValidInput($expected, $value) $this->assertSame($expected, $process->getInput()); } - public function provideInputValues() + public static function provideInputValues() { return [ [null, null], @@ -328,7 +327,7 @@ public function provideInputValues() ]; } - public function chainedCommandsOutputProvider() + public static function chainedCommandsOutputProvider() { if ('\\' === \DIRECTORY_SEPARATOR) { return [ @@ -422,7 +421,7 @@ public function testIncrementalOutput($getOutput, $getIncrementalOutput, $uri) fclose($h); } - public function provideIncrementalOutput() + public static function provideIncrementalOutput() { return [ ['getOutput', 'getIncrementalOutput', 'php://stdout'], @@ -957,7 +956,7 @@ public function testMethodsThatNeedARunningProcess($method) $process->{$method}(); } - public function provideMethodsThatNeedARunningProcess() + public static function provideMethodsThatNeedARunningProcess() { return [ ['getOutput'], @@ -988,7 +987,7 @@ public function testMethodsThatNeedATerminatedProcess($method) throw $e; } - public function provideMethodsThatNeedATerminatedProcess() + public static function provideMethodsThatNeedATerminatedProcess() { return [ ['hasBeenSignaled'], @@ -1093,7 +1092,7 @@ public function testGetOutputWhileDisabled($fetchMethod) $p->{$fetchMethod}(); } - public function provideOutputFetchingMethods() + public static function provideOutputFetchingMethods() { return [ ['getOutput'], @@ -1130,17 +1129,17 @@ public function testTermSignalTerminatesProcessCleanly() $this->assertTrue(true, 'A call to signal() is not expected to cause wait() to throw a RuntimeException'); } - public function responsesCodeProvider() + public static function responsesCodeProvider() { return [ - //expected output / getter / code to execute + // expected output / getter / code to execute // [1,'getExitCode','exit(1);'], // [true,'isSuccessful','exit();'], ['output', 'getOutput', 'echo \'output\';'], ]; } - public function pipesCodeProvider() + public static function pipesCodeProvider() { $variations = [ 'fwrite(STDOUT, $in = file_get_contents(\'php://stdin\')); fwrite(STDERR, $in);', @@ -1183,7 +1182,7 @@ public function testIncrementalOutputDoesNotRequireAnotherCall($stream, $method) $process->stop(); } - public function provideVariousIncrementals() + public static function provideVariousIncrementals() { return [ ['php://stdout', 'getIncrementalOutput'], @@ -1425,7 +1424,12 @@ public function testGetCommandLine() { $p = new Process(['/usr/bin/php']); - $expected = '\\' === \DIRECTORY_SEPARATOR ? '"/usr/bin/php"' : "'/usr/bin/php'"; + $expected = '\\' === \DIRECTORY_SEPARATOR ? '/usr/bin/php' : "'/usr/bin/php'"; + $this->assertSame($expected, $p->getCommandLine()); + + $p = new Process(['cd', '/d']); + + $expected = '\\' === \DIRECTORY_SEPARATOR ? 'cd /d' : "'cd' '/d'"; $this->assertSame($expected, $p->getCommandLine()); } @@ -1449,7 +1453,7 @@ public function testRawCommandLine() $this->assertSame($expected, str_replace('Standard input code', '-', $p->getOutput())); } - public function provideEscapeArgument() + public static function provideEscapeArgument() { yield ['a"b%c%']; yield ['a"b^c^']; @@ -1505,8 +1509,11 @@ public function testPreparedCommandWithNoValues() public function testEnvArgument() { - $env = ['FOO' => 'Foo', 'BAR' => 'Bar']; $cmd = '\\' === \DIRECTORY_SEPARATOR ? 'echo !FOO! !BAR! !BAZ!' : 'echo $FOO $BAR $BAZ'; + $p = Process::fromShellCommandline($cmd); + $this->assertSame([], $p->getEnv()); + + $env = ['FOO' => 'Foo', 'BAR' => 'Bar']; $p = Process::fromShellCommandline($cmd, null, $env); $p->run(null, ['BAR' => 'baR', 'BAZ' => 'baZ']); @@ -1521,6 +1528,10 @@ public function testWaitStoppedDeadProcess() $process->setTimeout(2); $process->wait(); $this->assertFalse($process->isRunning()); + + if ('\\' !== \DIRECTORY_SEPARATOR && !\Closure::bind(function () { return $this->isSigchildEnabled(); }, $process, $process)()) { + $this->assertSame(0, $process->getExitCode()); + } } public function testEnvCaseInsensitiveOnWindows() @@ -1535,11 +1546,77 @@ public function testEnvCaseInsensitiveOnWindows() } } + public function testMultipleCallsToProcGetStatus() + { + $process = $this->getProcess('echo foo'); + $process->start(static function () use ($process) { + return $process->isRunning(); + }); + while ($process->isRunning()) { + usleep(1000); + } + $this->assertSame(0, $process->getExitCode()); + } + + public function testFailingProcessWithMultipleCallsToProcGetStatus() + { + $process = $this->getProcess('exit 123'); + $process->start(static function () use ($process) { + return $process->isRunning(); + }); + while ($process->isRunning()) { + usleep(1000); + } + $this->assertSame(123, $process->getExitCode()); + } + + /** + * @group slow + */ + public function testLongRunningProcessWithMultipleCallsToProcGetStatus() + { + $process = $this->getProcess('sleep 1 && echo "done" && php -r "exit(0);"'); + $process->start(static function () use ($process) { + return $process->isRunning(); + }); + while ($process->isRunning()) { + usleep(1000); + } + $this->assertSame(0, $process->getExitCode()); + } + + /** + * @group slow + */ + public function testLongRunningProcessWithMultipleCallsToProcGetStatusError() + { + $process = $this->getProcess('sleep 1 && echo "failure" && php -r "exit(123);"'); + $process->start(static function () use ($process) { + return $process->isRunning(); + }); + while ($process->isRunning()) { + usleep(1000); + } + $this->assertSame(123, $process->getExitCode()); + } + + /** + * @group transient-on-windows + */ + public function testNotTerminableInputPipe() + { + $process = $this->getProcess('echo foo'); + $process->setInput(\STDIN); + $process->start(); + $process->setTimeout(2); + $process->wait(); + $this->assertFalse($process->isRunning()); + } + /** * @param string|array $commandline - * @param mixed $input */ - private function getProcess($commandline, string $cwd = null, array $env = null, $input = null, ?int $timeout = 60): Process + private function getProcess($commandline, ?string $cwd = null, ?array $env = null, $input = null, ?int $timeout = 60): Process { if (\is_string($commandline)) { $process = Process::fromShellCommandline($commandline, $cwd, $env, $input, $timeout); @@ -1554,7 +1631,7 @@ private function getProcess($commandline, string $cwd = null, array $env = null, return self::$process = $process; } - private function getProcessForCode(string $code, string $cwd = null, array $env = null, $input = null, ?int $timeout = 60): Process + private function getProcessForCode(string $code, ?string $cwd = null, ?array $env = null, $input = null, ?int $timeout = 60): Process { return $this->getProcess([self::$phpBin, '-r', $code], $cwd, $env, $input, $timeout); }