From ed6595d58305b67686251b47713e40d48abb47e8 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 23 May 2023 17:24:39 +0200 Subject: [PATCH 01/23] [7.0] Bump to PHP 8.2 minimum --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 317c07e7..dda5575e 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ } ], "require": { - "php": ">=8.1" + "php": ">=8.2" }, "autoload": { "psr-4": { "Symfony\\Component\\Process\\": "" }, From 2bea96bf209885d1a6d5908967ac1a875f2e3141 Mon Sep 17 00:00:00 2001 From: Wouter de Jong Date: Sun, 2 Jul 2023 23:52:21 +0200 Subject: [PATCH 02/23] [Components] Convert to native return types --- Exception/ProcessFailedException.php | 5 +---- Exception/ProcessTimedOutException.php | 15 +++------------ ExecutableFinder.php | 8 ++------ InputStream.php | 16 ++++------------ PhpProcess.php | 5 +---- Process.php | 21 +++++---------------- 6 files changed, 16 insertions(+), 54 deletions(-) diff --git a/Exception/ProcessFailedException.php b/Exception/ProcessFailedException.php index cf006dae..ddb89559 100644 --- a/Exception/ProcessFailedException.php +++ b/Exception/ProcessFailedException.php @@ -47,10 +47,7 @@ public function __construct(Process $process) $this->process = $process; } - /** - * @return Process - */ - public function getProcess() + public function getProcess(): Process { return $this->process; } diff --git a/Exception/ProcessTimedOutException.php b/Exception/ProcessTimedOutException.php index e507ca30..bf2775a1 100644 --- a/Exception/ProcessTimedOutException.php +++ b/Exception/ProcessTimedOutException.php @@ -38,26 +38,17 @@ public function __construct(Process $process, int $timeoutType) )); } - /** - * @return Process - */ - public function getProcess() + public function getProcess(): Process { return $this->process; } - /** - * @return bool - */ - public function isGeneralTimeout() + public function isGeneralTimeout(): bool { return self::TYPE_GENERAL === $this->timeoutType; } - /** - * @return bool - */ - public function isIdleTimeout() + public function isIdleTimeout(): bool { return self::TYPE_IDLE === $this->timeoutType; } diff --git a/ExecutableFinder.php b/ExecutableFinder.php index e3387dfe..f127054b 100644 --- a/ExecutableFinder.php +++ b/ExecutableFinder.php @@ -23,20 +23,16 @@ class ExecutableFinder /** * Replaces default suffixes of executable. - * - * @return void */ - public function setSuffixes(array $suffixes) + public function setSuffixes(array $suffixes): void { $this->suffixes = $suffixes; } /** * Adds new possible suffix to check for executable. - * - * @return void */ - public function addSuffix(string $suffix) + public function addSuffix(string $suffix): void { $this->suffixes[] = $suffix; } diff --git a/InputStream.php b/InputStream.php index 086f5a9e..74618d4d 100644 --- a/InputStream.php +++ b/InputStream.php @@ -29,10 +29,8 @@ class InputStream implements \IteratorAggregate /** * Sets a callback that is called when the write buffer becomes empty. - * - * @return void */ - public function onEmpty(callable $onEmpty = null) + public function onEmpty(callable $onEmpty = null): void { $this->onEmpty = $onEmpty; } @@ -42,10 +40,8 @@ public function onEmpty(callable $onEmpty = null) * * @param resource|string|int|float|bool|\Traversable|null $input The input to append as scalar, * stream resource or \Traversable - * - * @return void */ - public function write(mixed $input) + public function write(mixed $input): void { if (null === $input) { return; @@ -58,20 +54,16 @@ public function write(mixed $input) /** * Closes the write buffer. - * - * @return void */ - public function close() + public function close(): void { $this->open = false; } /** * Tells whether the write buffer is closed or not. - * - * @return bool */ - public function isClosed() + public function isClosed(): bool { return !$this->open; } diff --git a/PhpProcess.php b/PhpProcess.php index ef54a3d2..0d31e26f 100644 --- a/PhpProcess.php +++ b/PhpProcess.php @@ -55,10 +55,7 @@ public static function fromShellCommandline(string $command, string $cwd = null, throw new LogicException(sprintf('The "%s()" method cannot be called when using "%s".', __METHOD__, self::class)); } - /** - * @return void - */ - public function start(callable $callback = null, array $env = []) + public function start(callable $callback = null, array $env = []): void { if (null === $this->getCommandLine()) { throw new RuntimeException('Unable to find the PHP executable.'); diff --git a/Process.php b/Process.php index f330d405..944931fd 100644 --- a/Process.php +++ b/Process.php @@ -200,10 +200,7 @@ public function __sleep(): array throw new \BadMethodCallException('Cannot serialize '.__CLASS__); } - /** - * @return void - */ - public function __wakeup() + public function __wakeup(): void { throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); } @@ -288,13 +285,11 @@ public function mustRun(callable $callback = null, array $env = []): static * @param callable|null $callback A PHP callback to run whenever there is some * output available on STDOUT or STDERR * - * @return void - * * @throws RuntimeException When process can't be launched * @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 = []): void { if ($this->isRunning()) { throw new RuntimeException('Process is already running.'); @@ -1145,11 +1140,9 @@ public function setInput(mixed $input): static * In case you run a background process (with the start method), you should * trigger this method regularly to ensure the process timeout * - * @return void - * * @throws ProcessTimedOutException In case the timeout was reached */ - public function checkTimeout() + public function checkTimeout(): void { if (self::STATUS_STARTED !== $this->status) { return; @@ -1187,10 +1180,8 @@ public function getStartTime(): float * * Enabling the "create_new_console" option allows a subprocess to continue * to run after the main process exited, on both Windows and *nix - * - * @return void */ - public function setOptions(array $options) + public function setOptions(array $options): void { if ($this->isRunning()) { throw new RuntimeException('Setting options while the process is running is not possible.'); @@ -1284,10 +1275,8 @@ protected function buildCallback(callable $callback = null): \Closure * Updates the status of the process, reads pipes. * * @param bool $blocking Whether to use a blocking read call - * - * @return void */ - protected function updateStatus(bool $blocking) + protected function updateStatus(bool $blocking): void { if (self::STATUS_STARTED !== $this->status) { return; From 8d97ce8ed84704534f1aceea05ae302b13ec53a2 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 21 Jul 2023 15:36:26 +0200 Subject: [PATCH 03/23] Add types to public and protected properties --- Process.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Process.php b/Process.php index 5a5cfe9f..b5705a8e 100644 --- a/Process.php +++ b/Process.php @@ -89,7 +89,7 @@ class Process implements \IteratorAggregate * * User-defined errors must use exit codes in the 64-113 range. */ - public static $exitCodes = [ + public static array $exitCodes = [ 0 => 'OK', 1 => 'General error', 2 => 'Misuse of shell builtins', From 11ab5d7c99854aff757f01cd27e709209e72f356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Auswo=CC=88ger?= Date: Wed, 1 Nov 2023 17:49:58 +0100 Subject: [PATCH 04/23] [Process] Pass the commandline as array to `proc_open()` --- Exception/ProcessStartFailedException.php | 45 ++++++++++++++ Messenger/RunProcessContext.php | 4 +- Process.php | 61 +++++++++++++------ .../RunProcessMessageHandlerTest.php | 6 +- Tests/ProcessTest.php | 23 +++++++ 5 files changed, 116 insertions(+), 23 deletions(-) create mode 100644 Exception/ProcessStartFailedException.php diff --git a/Exception/ProcessStartFailedException.php b/Exception/ProcessStartFailedException.php new file mode 100644 index 00000000..9bd5a036 --- /dev/null +++ b/Exception/ProcessStartFailedException.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Exception; + +use Symfony\Component\Process\Process; + +/** + * Exception for processes failed during startup. + */ +class ProcessStartFailedException extends ProcessFailedException +{ + private Process $process; + + public function __construct(Process $process, ?string $message) + { + if ($process->isStarted()) { + throw new InvalidArgumentException('Expected a process that failed during startup, but the given process was started successfully.'); + } + + $error = sprintf('The command "%s" failed.'."\n\nWorking directory: %s\n\nError: %s", + $process->getCommandLine(), + $process->getWorkingDirectory(), + $message ?? 'unknown' + ); + + // Skip parent constructor + RuntimeException::__construct($error); + + $this->process = $process; + } + + public function getProcess(): Process + { + return $this->process; + } +} diff --git a/Messenger/RunProcessContext.php b/Messenger/RunProcessContext.php index b5ade072..5e223040 100644 --- a/Messenger/RunProcessContext.php +++ b/Messenger/RunProcessContext.php @@ -27,7 +27,7 @@ public function __construct( Process $process, ) { $this->exitCode = $process->getExitCode(); - $this->output = $process->isOutputDisabled() ? null : $process->getOutput(); - $this->errorOutput = $process->isOutputDisabled() ? null : $process->getErrorOutput(); + $this->output = !$process->isStarted() || $process->isOutputDisabled() ? null : $process->getOutput(); + $this->errorOutput = !$process->isStarted() || $process->isOutputDisabled() ? null : $process->getErrorOutput(); } } diff --git a/Process.php b/Process.php index 6b73c31d..0b29a646 100644 --- a/Process.php +++ b/Process.php @@ -15,6 +15,7 @@ use Symfony\Component\Process\Exception\LogicException; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Exception\ProcessSignaledException; +use Symfony\Component\Process\Exception\ProcessStartFailedException; use Symfony\Component\Process\Exception\ProcessTimedOutException; use Symfony\Component\Process\Exception\RuntimeException; use Symfony\Component\Process\Pipes\UnixPipes; @@ -233,11 +234,11 @@ public function __clone() * * @return int The exit status code * - * @throws RuntimeException When process can't be launched - * @throws RuntimeException When process is already running - * @throws ProcessTimedOutException When process timed out - * @throws ProcessSignaledException When process stopped after receiving signal - * @throws LogicException In case a callback is provided and output has been disabled + * @throws ProcessStartFailedException When process can't be launched + * @throws RuntimeException When process is already running + * @throws ProcessTimedOutException When process timed out + * @throws ProcessSignaledException When process stopped after receiving signal + * @throws LogicException In case a callback is provided and output has been disabled * * @final */ @@ -284,9 +285,9 @@ public function mustRun(callable $callback = null, array $env = []): static * @param callable|null $callback A PHP callback to run whenever there is some * output available on STDOUT or STDERR * - * @throws RuntimeException When process can't be launched - * @throws RuntimeException When process is already running - * @throws LogicException In case a callback is provided and output has been disabled + * @throws ProcessStartFailedException When process can't be launched + * @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 = []): void { @@ -306,12 +307,7 @@ public function start(callable $callback = null, array $env = []): void $env += '\\' === \DIRECTORY_SEPARATOR ? array_diff_ukey($this->getDefaultEnv(), $env, 'strcasecmp') : $this->getDefaultEnv(); if (\is_array($commandline = $this->commandline)) { - $commandline = implode(' ', array_map($this->escapeArgument(...), $commandline)); - - if ('\\' !== \DIRECTORY_SEPARATOR) { - // exec is mandatory to deal with sending a signal to the process - $commandline = 'exec '.$commandline; - } + $commandline = array_values(array_map(strval(...), $commandline)); } else { $commandline = $this->replacePlaceholders($commandline, $env); } @@ -322,6 +318,11 @@ public function start(callable $callback = null, array $env = []): void // last exit code is output on the fourth pipe and caught to work around --enable-sigchild $descriptors[3] = ['pipe', 'w']; + if (\is_array($commandline)) { + // exec is mandatory to deal with sending a signal to the process + $commandline = 'exec '.$this->buildShellCommandline($commandline); + } + // 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 2>/dev/null; code=$?; echo $code >&3; exit $code'; @@ -338,10 +339,20 @@ public function start(callable $callback = null, array $env = []): void throw new RuntimeException(sprintf('The provided cwd "%s" does not exist.', $this->cwd)); } - $process = @proc_open($commandline, $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options); + $lastError = null; + set_error_handler(function ($type, $msg) use (&$lastError) { + $lastError = $msg; + + return true; + }); + try { + $process = @proc_open($commandline, $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options); + } finally { + restore_error_handler(); + } if (!\is_resource($process)) { - throw new RuntimeException('Unable to launch a new process.'); + throw new ProcessStartFailedException($this, $lastError); } $this->process = $process; $this->status = self::STATUS_STARTED; @@ -366,8 +377,8 @@ public function start(callable $callback = null, array $env = []): void * @param callable|null $callback A PHP callback to run whenever there is some * output available on STDOUT or STDERR * - * @throws RuntimeException When process can't be launched - * @throws RuntimeException When process is already running + * @throws ProcessStartFailedException When process can't be launched + * @throws RuntimeException When process is already running * * @see start() * @@ -943,7 +954,7 @@ public function getLastOutputTime(): ?float */ public function getCommandLine(): string { - return \is_array($this->commandline) ? implode(' ', array_map($this->escapeArgument(...), $this->commandline)) : $this->commandline; + return $this->buildShellCommandline($this->commandline); } /** @@ -1472,8 +1483,18 @@ private function doSignal(int $signal, bool $throwException): bool return true; } - private function prepareWindowsCommandLine(string $cmd, array &$env): string + private function buildShellCommandline(string|array $commandline): string + { + if (\is_string($commandline)) { + return $commandline; + } + + return implode(' ', array_map($this->escapeArgument(...), $commandline)); + } + + private function prepareWindowsCommandLine(string|array $cmd, array &$env): string { + $cmd = $this->buildShellCommandline($cmd); $uid = uniqid('', true); $cmd = preg_replace_callback( '/"(?:( diff --git a/Tests/Messenger/RunProcessMessageHandlerTest.php b/Tests/Messenger/RunProcessMessageHandlerTest.php index 049da77a..e095fa09 100644 --- a/Tests/Messenger/RunProcessMessageHandlerTest.php +++ b/Tests/Messenger/RunProcessMessageHandlerTest.php @@ -33,7 +33,11 @@ public function testRunFailedProcess() (new RunProcessMessageHandler())(new RunProcessMessage(['invalid'])); } catch (RunProcessFailedException $e) { $this->assertSame(['invalid'], $e->context->message->command); - $this->assertSame('\\' === \DIRECTORY_SEPARATOR ? 1 : 127, $e->context->exitCode); + $this->assertContains( + $e->context->exitCode, + [null, '\\' === \DIRECTORY_SEPARATOR ? 1 : 127], + 'Exit code should be 1 on Windows, 127 on other systems, or null', + ); return; } diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index 44fb54ee..dfb4fd29 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -16,6 +16,7 @@ use Symfony\Component\Process\Exception\LogicException; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Exception\ProcessSignaledException; +use Symfony\Component\Process\Exception\ProcessStartFailedException; use Symfony\Component\Process\Exception\ProcessTimedOutException; use Symfony\Component\Process\Exception\RuntimeException; use Symfony\Component\Process\InputStream; @@ -66,6 +67,28 @@ public function testInvalidCwd() $cmd->run(); } + /** + * @dataProvider invalidProcessProvider + */ + public function testInvalidCommand(Process $process) + { + try { + $this->assertSame('\\' === \DIRECTORY_SEPARATOR ? 1 : 127, $process->run()); + } catch (ProcessStartFailedException $e) { + // An invalid command might already fail during start since PHP 8.3 for platforms + // supporting posix_spawn(), see https://github.com/php/php-src/issues/12589 + $this->assertStringContainsString('No such file or directory', $e->getMessage()); + } + } + + public function invalidProcessProvider() + { + return [ + [new Process(['invalid'])], + [Process::fromShellCommandline('invalid')], + ]; + } + /** * @group transient-on-windows */ From da3a37850f7d13e2aca31a687eda3ff74a93a38b Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Wed, 1 Nov 2023 09:14:07 +0100 Subject: [PATCH 05/23] [Tests] Streamline --- Tests/ProcessTest.php | 54 +++++++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index dfb4fd29..63ad90d9 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -328,11 +328,13 @@ public function testSetInputWhileRunningThrowsAnException() /** * @dataProvider provideInvalidInputValues */ - public function testInvalidInput($value) + public function testInvalidInput(array|object $value) { + $process = $this->getProcess('foo'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('"Symfony\Component\Process\Process::setInput" only accepts strings, Traversable objects or stream resources.'); - $process = $this->getProcess('foo'); + $process->setInput($value); } @@ -347,7 +349,7 @@ public static function provideInvalidInputValues() /** * @dataProvider provideInputValues */ - public function testValidInput($expected, $value) + public function testValidInput(?string $expected, null|float|string $value) { $process = $this->getProcess('foo'); $process->setInput($value); @@ -593,8 +595,10 @@ public function testSuccessfulMustRunHasCorrectExitCode() public function testMustRunThrowsException() { - $this->expectException(ProcessFailedException::class); $process = $this->getProcess('exit 1'); + + $this->expectException(ProcessFailedException::class); + $process->mustRun(); } @@ -972,9 +976,11 @@ public function testExitCodeIsAvailableAfterSignal() public function testSignalProcessNotRunning() { + $process = $this->getProcess('foo'); + $this->expectException(LogicException::class); $this->expectExceptionMessage('Cannot send signal on a non running process.'); - $process = $this->getProcess('foo'); + $process->signal(1); // SIGHUP } @@ -1062,20 +1068,24 @@ public function testDisableOutputDisablesTheOutput() public function testDisableOutputWhileRunningThrowsException() { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Disabling output while the process is running is not possible.'); $p = $this->getProcessForCode('sleep(39);'); $p->start(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Disabling output while the process is running is not possible.'); + $p->disableOutput(); } public function testEnableOutputWhileRunningThrowsException() { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Enabling output while the process is running is not possible.'); $p = $this->getProcessForCode('sleep(40);'); $p->disableOutput(); $p->start(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Enabling output while the process is running is not possible.'); + $p->enableOutput(); } @@ -1091,19 +1101,23 @@ public function testEnableOrDisableOutputAfterRunDoesNotThrowException() public function testDisableOutputWhileIdleTimeoutIsSet() { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Output cannot be disabled while an idle timeout is set.'); $process = $this->getProcess('foo'); $process->setIdleTimeout(1); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Output cannot be disabled while an idle timeout is set.'); + $process->disableOutput(); } public function testSetIdleTimeoutWhileOutputIsDisabled() { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('timeout cannot be set while the output is disabled.'); $process = $this->getProcess('foo'); $process->disableOutput(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('timeout cannot be set while the output is disabled.'); + $process->setIdleTimeout(1); } @@ -1119,11 +1133,13 @@ public function testSetNullIdleTimeoutWhileOutputIsDisabled() */ public function testGetOutputWhileDisabled($fetchMethod) { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Output has been disabled.'); $p = $this->getProcessForCode('sleep(41);'); $p->disableOutput(); $p->start(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Output has been disabled.'); + $p->{$fetchMethod}(); } @@ -1523,17 +1539,21 @@ public function testPreparedCommandWithQuoteInIt() public function testPreparedCommandWithMissingValue() { + $p = Process::fromShellCommandline('echo "${:abc}"'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Command line is missing a value for parameter "abc": echo "${:abc}"'); - $p = Process::fromShellCommandline('echo "${:abc}"'); + $p->run(null, ['bcd' => 'BCD']); } public function testPreparedCommandWithNoValues() { + $p = Process::fromShellCommandline('echo "${:abc}"'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Command line is missing a value for parameter "abc": echo "${:abc}"'); - $p = Process::fromShellCommandline('echo "${:abc}"'); + $p->run(null, []); } From 80826a9791754c93e06a80a348f66837aa5d0d1d Mon Sep 17 00:00:00 2001 From: Dariusz Ruminski Date: Mon, 15 Jan 2024 20:49:54 +0100 Subject: [PATCH 06/23] CS: enable ordered_types.null_adjustment=always_last --- Tests/ProcessTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index 63ad90d9..b2a34a21 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -349,7 +349,7 @@ public static function provideInvalidInputValues() /** * @dataProvider provideInputValues */ - public function testValidInput(?string $expected, null|float|string $value) + public function testValidInput(?string $expected, float|string|null $value) { $process = $this->getProcess('foo'); $process->setInput($value); From cebb2aec790b0fba2489af42a6c60933203e6390 Mon Sep 17 00:00:00 2001 From: Dariusz Ruminski Date: Mon, 18 Mar 2024 20:27:13 +0100 Subject: [PATCH 07/23] chore: CS fixes --- Tests/SignalListener.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Tests/SignalListener.php b/Tests/SignalListener.php index 618be740..7a351858 100644 --- a/Tests/SignalListener.php +++ b/Tests/SignalListener.php @@ -9,7 +9,10 @@ * file that was distributed with this source code. */ -pcntl_signal(\SIGUSR1, function () { echo 'SIGUSR1'; exit; }); +pcntl_signal(\SIGUSR1, function () { + echo 'SIGUSR1'; + exit; +}); echo 'Caught '; From 14730a20edaff1b8d5ca265b1a8e85aa8ac88993 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Fri, 16 Feb 2024 12:37:04 +0100 Subject: [PATCH 08/23] feat(process): allow to ignore signals when executing a process --- CHANGELOG.md | 5 +++++ Process.php | 34 ++++++++++++++++++++++++++++++++++ Tests/ProcessTest.php | 30 ++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e26819b5..f7b68b5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.1 +--- + + * Add `Process::setIgnoredSignals()` to disable signal propagation to the child process + 6.4 --- diff --git a/Process.php b/Process.php index c95afabc..0b593201 100644 --- a/Process.php +++ b/Process.php @@ -77,6 +77,7 @@ class Process implements \IteratorAggregate private bool $tty = false; private bool $pty; private array $options = ['suppress_errors' => true, 'bypass_shell' => true]; + private array $ignoredSignals = []; private WindowsPipes|UnixPipes $processPipes; @@ -346,9 +347,23 @@ public function start(?callable $callback = null, array $env = []): void return true; }); + + $oldMask = []; + + if (\function_exists('pcntl_sigprocmask')) { + // we block signals we want to ignore, as proc_open will use fork / posix_spawn which will copy the signal mask this allow to block + // signals in the child process + pcntl_sigprocmask(\SIG_BLOCK, $this->ignoredSignals, $oldMask); + } + try { $process = @proc_open($commandline, $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options); } finally { + if (\function_exists('pcntl_sigprocmask')) { + // we restore the signal mask here to avoid any side effects + pcntl_sigprocmask(\SIG_SETMASK, $oldMask); + } + restore_error_handler(); } @@ -1206,6 +1221,20 @@ public function setOptions(array $options): void } } + /** + * Defines a list of posix signals that will not be propagated to the process. + * + * @param list<\SIG*> $signals + */ + public function setIgnoredSignals(array $signals): void + { + if ($this->isRunning()) { + throw new RuntimeException('Setting ignored signals while the process is running is not possible.'); + } + + $this->ignoredSignals = $signals; + } + /** * Returns whether TTY is supported on the current operating system. */ @@ -1455,6 +1484,11 @@ private function resetProcessData(): void */ private function doSignal(int $signal, bool $throwException): bool { + // Signal seems to be send when sigchild is enable, this allow blocking the signal correctly in this case + if ($this->isSigchildEnabled() && \in_array($signal, $this->ignoredSignals)) { + return false; + } + if (null === $pid = $this->getPid()) { if ($throwException) { throw new LogicException('Cannot send signal on a non running process.'); diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index d027cc50..653fa6d8 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -1663,6 +1663,36 @@ public function testNotTerminableInputPipe() $this->assertFalse($process->isRunning()); } + public function testIgnoringSignal() + { + if (!\function_exists('pcntl_signal')) { + $this->markTestSkipped('pnctl extension is required.'); + } + + $process = $this->getProcess('sleep 10'); + $process->setIgnoredSignals([\SIGTERM]); + + $process->start(); + $process->stop(timeout: 0.2); + + $this->assertNotSame(\SIGTERM, $process->getTermSignal()); + } + + // This test ensure that the previous test is reliable, in case of the sleep command ignoring the SIGTERM signal + public function testNotIgnoringSignal() + { + if (!\function_exists('pcntl_signal')) { + $this->markTestSkipped('pnctl extension is required.'); + } + + $process = $this->getProcess('sleep 10'); + + $process->start(); + $process->stop(timeout: 0.2); + + $this->assertSame(\SIGTERM, $process->getTermSignal()); + } + private function getProcess(string|array $commandline, ?string $cwd = null, ?array $env = null, mixed $input = null, ?int $timeout = 60): Process { if (\is_string($commandline)) { From 8e99b1b15d2975b242fc089382c8f8055a85d996 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Fri, 5 Apr 2024 10:54:29 +0200 Subject: [PATCH 09/23] fix(process): don't call sigprocmask if there is no ignored signals --- Process.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Process.php b/Process.php index 0b593201..656834bd 100644 --- a/Process.php +++ b/Process.php @@ -350,7 +350,7 @@ public function start(?callable $callback = null, array $env = []): void $oldMask = []; - if (\function_exists('pcntl_sigprocmask')) { + if ($this->ignoredSignals && \function_exists('pcntl_sigprocmask')) { // we block signals we want to ignore, as proc_open will use fork / posix_spawn which will copy the signal mask this allow to block // signals in the child process pcntl_sigprocmask(\SIG_BLOCK, $this->ignoredSignals, $oldMask); @@ -359,7 +359,7 @@ public function start(?callable $callback = null, array $env = []): void try { $process = @proc_open($commandline, $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options); } finally { - if (\function_exists('pcntl_sigprocmask')) { + if ($this->ignoredSignals && \function_exists('pcntl_sigprocmask')) { // we restore the signal mask here to avoid any side effects pcntl_sigprocmask(\SIG_SETMASK, $oldMask); } From f64dbc87a58cfedfc6e7f8635697a94386f2ccc4 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Fri, 24 May 2024 13:05:21 +0200 Subject: [PATCH 10/23] use constructor property promotion --- Exception/ProcessFailedException.php | 7 +++---- Exception/ProcessSignaledException.php | 9 +++------ Exception/ProcessStartFailedException.php | 10 ++++------ Exception/ProcessTimedOutException.php | 12 ++++-------- Pipes/UnixPipes.php | 16 ++++++---------- Pipes/WindowsPipes.php | 9 ++++----- 6 files changed, 24 insertions(+), 39 deletions(-) diff --git a/Exception/ProcessFailedException.php b/Exception/ProcessFailedException.php index 499809ee..6cd8a355 100644 --- a/Exception/ProcessFailedException.php +++ b/Exception/ProcessFailedException.php @@ -20,10 +20,9 @@ */ class ProcessFailedException extends RuntimeException { - private Process $process; - - public function __construct(Process $process) - { + public function __construct( + private Process $process, + ) { if ($process->isSuccessful()) { throw new InvalidArgumentException('Expected a failed process, but the given process was successful.'); } diff --git a/Exception/ProcessSignaledException.php b/Exception/ProcessSignaledException.php index 0fed8ac3..4466b0d8 100644 --- a/Exception/ProcessSignaledException.php +++ b/Exception/ProcessSignaledException.php @@ -20,12 +20,9 @@ */ final class ProcessSignaledException extends RuntimeException { - private Process $process; - - public function __construct(Process $process) - { - $this->process = $process; - + public function __construct( + private Process $process, + ) { parent::__construct(sprintf('The process has been signaled with signal "%s".', $process->getTermSignal())); } diff --git a/Exception/ProcessStartFailedException.php b/Exception/ProcessStartFailedException.php index 9bd5a036..24bd5009 100644 --- a/Exception/ProcessStartFailedException.php +++ b/Exception/ProcessStartFailedException.php @@ -18,10 +18,10 @@ */ class ProcessStartFailedException extends ProcessFailedException { - private Process $process; - - public function __construct(Process $process, ?string $message) - { + public function __construct( + private Process $process, + ?string $message, + ) { if ($process->isStarted()) { throw new InvalidArgumentException('Expected a process that failed during startup, but the given process was started successfully.'); } @@ -34,8 +34,6 @@ public function __construct(Process $process, ?string $message) // Skip parent constructor RuntimeException::__construct($error); - - $this->process = $process; } public function getProcess(): Process diff --git a/Exception/ProcessTimedOutException.php b/Exception/ProcessTimedOutException.php index 252e1112..b692e35f 100644 --- a/Exception/ProcessTimedOutException.php +++ b/Exception/ProcessTimedOutException.php @@ -23,14 +23,10 @@ class ProcessTimedOutException extends RuntimeException public const TYPE_GENERAL = 1; public const TYPE_IDLE = 2; - private Process $process; - private int $timeoutType; - - public function __construct(Process $process, int $timeoutType) - { - $this->process = $process; - $this->timeoutType = $timeoutType; - + public function __construct( + private Process $process, + private int $timeoutType, + ) { parent::__construct(sprintf( 'The process "%s" exceeded the timeout of %s seconds.', $process->getCommandLine(), diff --git a/Pipes/UnixPipes.php b/Pipes/UnixPipes.php index 7bd0db0e..8e95afaa 100644 --- a/Pipes/UnixPipes.php +++ b/Pipes/UnixPipes.php @@ -22,16 +22,12 @@ */ class UnixPipes extends AbstractPipes { - private ?bool $ttyMode; - private bool $ptyMode; - private bool $haveReadSupport; - - public function __construct(?bool $ttyMode, bool $ptyMode, mixed $input, bool $haveReadSupport) - { - $this->ttyMode = $ttyMode; - $this->ptyMode = $ptyMode; - $this->haveReadSupport = $haveReadSupport; - + public function __construct( + private ?bool $ttyMode, + private bool $ptyMode, + mixed $input, + private bool $haveReadSupport, + ) { parent::__construct($input); } diff --git a/Pipes/WindowsPipes.php b/Pipes/WindowsPipes.php index 8033442a..26fa7498 100644 --- a/Pipes/WindowsPipes.php +++ b/Pipes/WindowsPipes.php @@ -33,12 +33,11 @@ class WindowsPipes extends AbstractPipes Process::STDOUT => 0, Process::STDERR => 0, ]; - private bool $haveReadSupport; - - public function __construct(mixed $input, bool $haveReadSupport) - { - $this->haveReadSupport = $haveReadSupport; + public function __construct( + mixed $input, + private bool $haveReadSupport, + ) { if ($this->haveReadSupport) { // Fix for PHP bug #51800: reading from STDOUT pipe hangs forever on Windows if the output is too big. // Workaround for this problem is to use temporary files instead of pipes on Windows platform. From f7456b8509dacf9f6ae0f4bc6857fa6d7059e77a Mon Sep 17 00:00:00 2001 From: Travis Carden Date: Tue, 21 Nov 2023 18:24:14 -0500 Subject: [PATCH 11/23] [Process] `ExecutableFinder::addSuffix()` has no effect --- ExecutableFinder.php | 21 ++++++++++++---- Tests/ExecutableFinderTest.php | 25 +++++++++++++++++++ .../Fixtures/executable_with_added_suffix.foo | 1 + Tests/Fixtures/executable_without_suffix | 1 + 4 files changed, 43 insertions(+), 5 deletions(-) create mode 100755 Tests/Fixtures/executable_with_added_suffix.foo create mode 100755 Tests/Fixtures/executable_without_suffix diff --git a/ExecutableFinder.php b/ExecutableFinder.php index ceb7a558..9ab99606 100644 --- a/ExecutableFinder.php +++ b/ExecutableFinder.php @@ -19,7 +19,15 @@ */ class ExecutableFinder { - private array $suffixes = ['.exe', '.bat', '.cmd', '.com']; + private array $suffixes = []; + + public function __construct() + { + // Set common extensions on Windows. + if ('\\' === \DIRECTORY_SEPARATOR) { + $this->suffixes = ['.exe', '.bat', '.cmd', '.com']; + } + } /** * Replaces default suffixes of executable. @@ -30,7 +38,10 @@ public function setSuffixes(array $suffixes): void } /** - * Adds new possible suffix to check for executable. + * Adds new possible suffix to check for executable, including the dot (.). + * + * $finder = new ExecutableFinder(); + * $finder->addSuffix('.foo'); */ public function addSuffix(string $suffix): void { @@ -52,10 +63,10 @@ public function find(string $name, ?string $default = null, array $extraDirs = [ ); $suffixes = ['']; - if ('\\' === \DIRECTORY_SEPARATOR) { - $pathExt = getenv('PATHEXT'); - $suffixes = array_merge($pathExt ? explode(\PATH_SEPARATOR, $pathExt) : $this->suffixes, $suffixes); + if ('\\' === \DIRECTORY_SEPARATOR && $pathExt = getenv('PATHEXT')) { + $suffixes = array_merge(explode(\PATH_SEPARATOR, $pathExt), $suffixes); } + $suffixes = array_merge($suffixes, $this->suffixes); foreach ($suffixes as $suffix) { foreach ($dirs as $dir) { if (@is_file($file = $dir.\DIRECTORY_SEPARATOR.$name.$suffix) && ('\\' === \DIRECTORY_SEPARATOR || @is_executable($file))) { diff --git a/Tests/ExecutableFinderTest.php b/Tests/ExecutableFinderTest.php index a1b8d6d5..56cb3d51 100644 --- a/Tests/ExecutableFinderTest.php +++ b/Tests/ExecutableFinderTest.php @@ -85,6 +85,31 @@ public function testFindWithExtraDirs() $this->assertSamePath(\PHP_BINARY, $result); } + public function testFindWithoutSuffix() + { + $fixturesDir = __DIR__.\DIRECTORY_SEPARATOR.'Fixtures'; + $name = 'executable_without_suffix'; + + $finder = new ExecutableFinder(); + $result = $finder->find($name, null, [$fixturesDir]); + + $this->assertSamePath($fixturesDir.\DIRECTORY_SEPARATOR.$name, $result); + } + + public function testFindWithAddedSuffixes() + { + $fixturesDir = __DIR__.\DIRECTORY_SEPARATOR.'Fixtures'; + $name = 'executable_with_added_suffix'; + $suffix = '.foo'; + + $finder = new ExecutableFinder(); + $finder->addSuffix($suffix); + + $result = $finder->find($name, null, [$fixturesDir]); + + $this->assertSamePath($fixturesDir.\DIRECTORY_SEPARATOR.$name.$suffix, $result); + } + /** * @runInSeparateProcess */ diff --git a/Tests/Fixtures/executable_with_added_suffix.foo b/Tests/Fixtures/executable_with_added_suffix.foo new file mode 100755 index 00000000..471a493a --- /dev/null +++ b/Tests/Fixtures/executable_with_added_suffix.foo @@ -0,0 +1 @@ +See \Symfony\Component\Process\Tests\ExecutableFinderTest::testFindWithAddedSuffixes() diff --git a/Tests/Fixtures/executable_without_suffix b/Tests/Fixtures/executable_without_suffix new file mode 100755 index 00000000..9bf8b4db --- /dev/null +++ b/Tests/Fixtures/executable_without_suffix @@ -0,0 +1 @@ +See \Symfony\Component\Process\Tests\ExecutableFinderTest::testFindWithoutSuffix() From 6f5cb98173a20111362d5767158226a42bc61d0d Mon Sep 17 00:00:00 2001 From: "Alexander M. Turek" Date: Thu, 20 Jun 2024 17:52:34 +0200 Subject: [PATCH 12/23] Prefix all sprintf() calls --- Exception/ProcessFailedException.php | 4 ++-- Exception/ProcessSignaledException.php | 2 +- Exception/ProcessStartFailedException.php | 2 +- Exception/ProcessTimedOutException.php | 4 ++-- InputStream.php | 2 +- PhpProcess.php | 2 +- PhpSubprocess.php | 2 +- Pipes/AbstractPipes.php | 2 +- Pipes/WindowsPipes.php | 2 +- Process.php | 18 +++++++++--------- ProcessUtils.php | 2 +- Tests/ProcessTest.php | 8 ++++---- 12 files changed, 25 insertions(+), 25 deletions(-) diff --git a/Exception/ProcessFailedException.php b/Exception/ProcessFailedException.php index 6cd8a355..de8a9e98 100644 --- a/Exception/ProcessFailedException.php +++ b/Exception/ProcessFailedException.php @@ -27,7 +27,7 @@ public function __construct( throw new InvalidArgumentException('Expected a failed process, but the given process was successful.'); } - $error = sprintf('The command "%s" failed.'."\n\nExit Code: %s(%s)\n\nWorking directory: %s", + $error = \sprintf('The command "%s" failed.'."\n\nExit Code: %s(%s)\n\nWorking directory: %s", $process->getCommandLine(), $process->getExitCode(), $process->getExitCodeText(), @@ -35,7 +35,7 @@ public function __construct( ); if (!$process->isOutputDisabled()) { - $error .= sprintf("\n\nOutput:\n================\n%s\n\nError Output:\n================\n%s", + $error .= \sprintf("\n\nOutput:\n================\n%s\n\nError Output:\n================\n%s", $process->getOutput(), $process->getErrorOutput() ); diff --git a/Exception/ProcessSignaledException.php b/Exception/ProcessSignaledException.php index 4466b0d8..3fd13e5d 100644 --- a/Exception/ProcessSignaledException.php +++ b/Exception/ProcessSignaledException.php @@ -23,7 +23,7 @@ final class ProcessSignaledException extends RuntimeException public function __construct( private Process $process, ) { - parent::__construct(sprintf('The process has been signaled with signal "%s".', $process->getTermSignal())); + parent::__construct(\sprintf('The process has been signaled with signal "%s".', $process->getTermSignal())); } public function getProcess(): Process diff --git a/Exception/ProcessStartFailedException.php b/Exception/ProcessStartFailedException.php index 24bd5009..37254725 100644 --- a/Exception/ProcessStartFailedException.php +++ b/Exception/ProcessStartFailedException.php @@ -26,7 +26,7 @@ public function __construct( throw new InvalidArgumentException('Expected a process that failed during startup, but the given process was started successfully.'); } - $error = sprintf('The command "%s" failed.'."\n\nWorking directory: %s\n\nError: %s", + $error = \sprintf('The command "%s" failed.'."\n\nWorking directory: %s\n\nError: %s", $process->getCommandLine(), $process->getWorkingDirectory(), $message ?? 'unknown' diff --git a/Exception/ProcessTimedOutException.php b/Exception/ProcessTimedOutException.php index b692e35f..d3fe4934 100644 --- a/Exception/ProcessTimedOutException.php +++ b/Exception/ProcessTimedOutException.php @@ -27,7 +27,7 @@ public function __construct( private Process $process, private int $timeoutType, ) { - parent::__construct(sprintf( + parent::__construct(\sprintf( 'The process "%s" exceeded the timeout of %s seconds.', $process->getCommandLine(), $this->getExceededTimeout() @@ -54,7 +54,7 @@ public function getExceededTimeout(): ?float return match ($this->timeoutType) { self::TYPE_GENERAL => $this->process->getTimeout(), self::TYPE_IDLE => $this->process->getIdleTimeout(), - default => throw new \LogicException(sprintf('Unknown timeout type "%d".', $this->timeoutType)), + default => throw new \LogicException(\sprintf('Unknown timeout type "%d".', $this->timeoutType)), }; } } diff --git a/InputStream.php b/InputStream.php index cd91029e..586e7429 100644 --- a/InputStream.php +++ b/InputStream.php @@ -46,7 +46,7 @@ public function write(mixed $input): void return; } if ($this->isClosed()) { - throw new RuntimeException(sprintf('"%s" is closed.', static::class)); + throw new RuntimeException(\sprintf('"%s" is closed.', static::class)); } $this->input[] = ProcessUtils::validateInput(__METHOD__, $input); } diff --git a/PhpProcess.php b/PhpProcess.php index 01d88954..0e7ff846 100644 --- a/PhpProcess.php +++ b/PhpProcess.php @@ -52,7 +52,7 @@ public function __construct(string $script, ?string $cwd = null, ?array $env = n public static function fromShellCommandline(string $command, ?string $cwd = null, ?array $env = null, mixed $input = null, ?float $timeout = 60): static { - throw new LogicException(sprintf('The "%s()" method cannot be called when using "%s".', __METHOD__, self::class)); + throw new LogicException(\sprintf('The "%s()" method cannot be called when using "%s".', __METHOD__, self::class)); } public function start(?callable $callback = null, array $env = []): void diff --git a/PhpSubprocess.php b/PhpSubprocess.php index a97f8b26..38a1864f 100644 --- a/PhpSubprocess.php +++ b/PhpSubprocess.php @@ -75,7 +75,7 @@ public function __construct(array $command, ?string $cwd = null, ?array $env = n public static function fromShellCommandline(string $command, ?string $cwd = null, ?array $env = null, mixed $input = null, ?float $timeout = 60): static { - throw new LogicException(sprintf('The "%s()" method cannot be called when using "%s".', __METHOD__, self::class)); + throw new LogicException(\sprintf('The "%s()" method cannot be called when using "%s".', __METHOD__, self::class)); } public function start(?callable $callback = null, array $env = []): void diff --git a/Pipes/AbstractPipes.php b/Pipes/AbstractPipes.php index cbbb7277..51a566f3 100644 --- a/Pipes/AbstractPipes.php +++ b/Pipes/AbstractPipes.php @@ -101,7 +101,7 @@ protected function write(): ?array } elseif (!isset($this->inputBuffer[0])) { if (!\is_string($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))); + 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 26fa7498..116b8e30 100644 --- a/Pipes/WindowsPipes.php +++ b/Pipes/WindowsPipes.php @@ -52,7 +52,7 @@ public function __construct( set_error_handler(function ($type, $msg) use (&$lastError) { $lastError = $msg; }); for ($i = 0;; ++$i) { foreach ($pipes as $pipe => $name) { - $file = sprintf('%s\\sf_proc_%02X.%s', $tmpDir, $i, $name); + $file = \sprintf('%s\\sf_proc_%02X.%s', $tmpDir, $i, $name); if (!$h = fopen($file.'.lock', 'w')) { if (file_exists($file.'.lock')) { diff --git a/Process.php b/Process.php index fd3ad875..ce00d9b4 100644 --- a/Process.php +++ b/Process.php @@ -338,7 +338,7 @@ public function start(?callable $callback = null, array $env = []): void } if (!is_dir($this->cwd)) { - throw new RuntimeException(sprintf('The provided cwd "%s" does not exist.', $this->cwd)); + throw new RuntimeException(\sprintf('The provided cwd "%s" does not exist.', $this->cwd)); } $lastError = null; @@ -1215,7 +1215,7 @@ public function setOptions(array $options): void foreach ($options as $key => $value) { if (!\in_array($key, $existingOptions)) { $this->options = $defaultOptions; - throw new LogicException(sprintf('Invalid option "%s" passed to "%s()". Supported options are "%s".', $key, __METHOD__, implode('", "', $existingOptions))); + throw new LogicException(\sprintf('Invalid option "%s" passed to "%s()". Supported options are "%s".', $key, __METHOD__, implode('", "', $existingOptions))); } $this->options[$key] = $value; } @@ -1498,10 +1498,10 @@ private function doSignal(int $signal, bool $throwException): bool } if ('\\' === \DIRECTORY_SEPARATOR) { - exec(sprintf('taskkill /F /T /PID %d 2>&1', $pid), $output, $exitCode); + exec(\sprintf('taskkill /F /T /PID %d 2>&1', $pid), $output, $exitCode); if ($exitCode && $this->isRunning()) { if ($throwException) { - throw new RuntimeException(sprintf('Unable to kill the process (%s).', implode(' ', $output))); + throw new RuntimeException(\sprintf('Unable to kill the process (%s).', implode(' ', $output))); } return false; @@ -1511,12 +1511,12 @@ private function doSignal(int $signal, bool $throwException): bool $ok = @proc_terminate($this->process, $signal); } elseif (\function_exists('posix_kill')) { $ok = @posix_kill($pid, $signal); - } elseif ($ok = proc_open(sprintf('kill -%d %d', $signal, $pid), [2 => ['pipe', 'w']], $pipes)) { + } elseif ($ok = proc_open(\sprintf('kill -%d %d', $signal, $pid), [2 => ['pipe', 'w']], $pipes)) { $ok = false === fgets($pipes[2]); } if (!$ok) { if ($throwException) { - throw new RuntimeException(sprintf('Error while sending signal "%s".', $signal)); + throw new RuntimeException(\sprintf('Error while sending signal "%s".', $signal)); } return false; @@ -1595,7 +1595,7 @@ function ($m) use (&$env, $uid) { private function requireProcessIsStarted(string $functionName): void { if (!$this->isStarted()) { - throw new LogicException(sprintf('Process must be started before calling "%s()".', $functionName)); + throw new LogicException(\sprintf('Process must be started before calling "%s()".', $functionName)); } } @@ -1607,7 +1607,7 @@ private function requireProcessIsStarted(string $functionName): void private function requireProcessIsTerminated(string $functionName): void { if (!$this->isTerminated()) { - throw new LogicException(sprintf('Process must be terminated before calling "%s()".', $functionName)); + throw new LogicException(\sprintf('Process must be terminated before calling "%s()".', $functionName)); } } @@ -1637,7 +1637,7 @@ private function replacePlaceholders(string $commandline, array $env): string { return preg_replace_callback('/"\$\{:([_a-zA-Z]++[_a-zA-Z0-9]*+)\}"/', function ($matches) use ($commandline, $env) { if (!isset($env[$matches[1]]) || false === $env[$matches[1]]) { - throw new InvalidArgumentException(sprintf('Command line is missing a value for parameter "%s": ', $matches[1]).$commandline); + throw new InvalidArgumentException(\sprintf('Command line is missing a value for parameter "%s": ', $matches[1]).$commandline); } return $this->escapeArgument($env[$matches[1]]); diff --git a/ProcessUtils.php b/ProcessUtils.php index 092c5ccf..a2dbde9f 100644 --- a/ProcessUtils.php +++ b/ProcessUtils.php @@ -56,7 +56,7 @@ public static function validateInput(string $caller, mixed $input): mixed return new \IteratorIterator($input); } - throw new InvalidArgumentException(sprintf('"%s" only accepts strings, Traversable objects or stream resources.', $caller)); + throw new InvalidArgumentException(\sprintf('"%s" only accepts strings, Traversable objects or stream resources.', $caller)); } return $input; diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index 653fa6d8..cd084872 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -191,7 +191,7 @@ public function testAllOutputIsActuallyReadOnTermination() // another byte which will never be read. $expectedOutputSize = PipesInterface::CHUNK_SIZE * 2 + 2; - $code = sprintf('echo str_repeat(\'*\', %d);', $expectedOutputSize); + $code = \sprintf('echo str_repeat(\'*\', %d);', $expectedOutputSize); $p = $this->getProcessForCode($code); $p->start(); @@ -384,7 +384,7 @@ public static function chainedCommandsOutputProvider() */ public function testChainedCommandsOutput($expected, $operator, $input) { - $process = $this->getProcess(sprintf('echo %s %s echo %s', $input, $operator, $input)); + $process = $this->getProcess(\sprintf('echo %s %s echo %s', $input, $operator, $input)); $process->run(); $this->assertEquals($expected, $process->getOutput()); } @@ -992,7 +992,7 @@ public function testMethodsThatNeedARunningProcess($method) $process = $this->getProcess('foo'); $this->expectException(LogicException::class); - $this->expectExceptionMessage(sprintf('Process must be started before calling "%s()".', $method)); + $this->expectExceptionMessage(\sprintf('Process must be started before calling "%s()".', $method)); $process->{$method}(); } @@ -1492,7 +1492,7 @@ public function testEscapeArgument($arg) public function testRawCommandLine() { - $p = Process::fromShellCommandline(sprintf('"%s" -r %s "a" "" "b"', self::$phpBin, escapeshellarg('print_r($argv);'))); + $p = Process::fromShellCommandline(\sprintf('"%s" -r %s "a" "" "b"', self::$phpBin, escapeshellarg('print_r($argv);'))); $p->run(); $expected = "Array\n(\n [0] => -\n [1] => a\n [2] => \n [3] => b\n)\n"; From ca79b6e26d679b30022f9215319d4ea8b95ef5fe Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Thu, 27 Jun 2024 09:29:05 +0200 Subject: [PATCH 13/23] [Lock][Process] Replace `strtok` calls --- ExecutableFinder.php | 8 +++++++- PhpExecutableFinder.php | 7 ++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/ExecutableFinder.php b/ExecutableFinder.php index 9ab99606..dca238de 100644 --- a/ExecutableFinder.php +++ b/ExecutableFinder.php @@ -79,8 +79,14 @@ public function find(string $name, ?string $default = null, array $extraDirs = [ } } + if (!\function_exists('exec')) { + return $default; + } + $command = '\\' === \DIRECTORY_SEPARATOR ? 'where' : 'command -v --'; - if (\function_exists('exec') && ($executablePath = strtok(@exec($command.' '.escapeshellarg($name)), \PHP_EOL)) && @is_executable($executablePath)) { + $execResult = @exec($command.' '.escapeshellarg($name)); + + if (($executablePath = substr($execResult, 0, strpos($execResult, \PHP_EOL) ?: null)) && @is_executable($executablePath)) { return $executablePath; } diff --git a/PhpExecutableFinder.php b/PhpExecutableFinder.php index 4a882e0f..fb2f3716 100644 --- a/PhpExecutableFinder.php +++ b/PhpExecutableFinder.php @@ -33,8 +33,13 @@ public function find(bool $includeArgs = true): string|false { if ($php = getenv('PHP_BINARY')) { if (!is_executable($php)) { + if (!\function_exists('exec')) { + return false; + } + $command = '\\' === \DIRECTORY_SEPARATOR ? 'where' : 'command -v --'; - if (\function_exists('exec') && $php = strtok(exec($command.' '.escapeshellarg($php)), \PHP_EOL)) { + $execResult = exec($command.' '.escapeshellarg($php)); + if ($php = substr($execResult, 0, strpos($execResult, \PHP_EOL) ?: null)) { if (!is_executable($php)) { return false; } From 5c9cf89df869bb3522744a682272126134d3ea48 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sat, 6 Jul 2024 09:57:16 +0200 Subject: [PATCH 14/23] Update .gitattributes --- .gitattributes | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 From 7f2f542c668ad6c313dc4a5e9c3321f733197eca Mon Sep 17 00:00:00 2001 From: Thibaut THOUEMENT Date: Thu, 18 Jul 2024 11:59:33 +0200 Subject: [PATCH 15/23] Fix ProcessTest - testIgnoringSignal for local --- Tests/ProcessTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index 653fa6d8..005b9175 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -1669,7 +1669,7 @@ public function testIgnoringSignal() $this->markTestSkipped('pnctl extension is required.'); } - $process = $this->getProcess('sleep 10'); + $process = $this->getProcess(['sleep', '10']); $process->setIgnoredSignals([\SIGTERM]); $process->start(); @@ -1685,7 +1685,7 @@ public function testNotIgnoringSignal() $this->markTestSkipped('pnctl extension is required.'); } - $process = $this->getProcess('sleep 10'); + $process = $this->getProcess(['sleep', '10']); $process->start(); $process->stop(timeout: 0.2); From bb0a8b7772610211c2cd7d6e4e36acfcbadcb613 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Sun, 28 Jul 2024 13:18:19 +0200 Subject: [PATCH 16/23] replace uniqid() with random_bytes() to create identifiers --- Process.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Process.php b/Process.php index ce00d9b4..f81d1922 100644 --- a/Process.php +++ b/Process.php @@ -1543,7 +1543,7 @@ private function buildShellCommandline(string|array $commandline): string private function prepareWindowsCommandLine(string|array $cmd, array &$env): string { $cmd = $this->buildShellCommandline($cmd); - $uid = uniqid('', true); + $uid = bin2hex(random_bytes(4)); $cmd = preg_replace_callback( '/"(?:( [^"%!^]*+ From 82d962eed80966a41220bd28424ba6a71d3f357d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Auswo=CC=88ger?= Date: Thu, 5 Sep 2024 23:34:25 +0200 Subject: [PATCH 17/23] [Process] Fix backwards compatibility for invalid commands --- Process.php | 5 +++++ Tests/ProcessTest.php | 9 ++------- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Process.php b/Process.php index fd3ad875..878296b6 100644 --- a/Process.php +++ b/Process.php @@ -358,6 +358,11 @@ public function start(?callable $callback = null, array $env = []): void try { $process = @proc_open($commandline, $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options); + + // Ensure array vs string commands behave the same + if (!$process && \is_array($commandline)) { + $process = @proc_open('exec '.$this->buildShellCommandline($commandline), $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options); + } } finally { if ($this->ignoredSignals && \function_exists('pcntl_sigprocmask')) { // we restore the signal mask here to avoid any side effects diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index 005b9175..3b0533b7 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -72,13 +72,8 @@ public function testInvalidCwd() */ public function testInvalidCommand(Process $process) { - try { - $this->assertSame('\\' === \DIRECTORY_SEPARATOR ? 1 : 127, $process->run()); - } catch (ProcessStartFailedException $e) { - // An invalid command might already fail during start since PHP 8.3 for platforms - // supporting posix_spawn(), see https://github.com/php/php-src/issues/12589 - $this->assertStringContainsString('No such file or directory', $e->getMessage()); - } + // An invalid command should not fail during start + $this->assertSame('\\' === \DIRECTORY_SEPARATOR ? 1 : 127, $process->run()); } public function invalidProcessProvider() From 85dc2723935920dabcb8bc553e882174c4a6b344 Mon Sep 17 00:00:00 2001 From: Marcel Pociot Date: Fri, 13 Sep 2024 16:26:53 +0200 Subject: [PATCH 18/23] [Process] Add Laravel Herd php detection path --- PhpExecutableFinder.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/PhpExecutableFinder.php b/PhpExecutableFinder.php index fb2f3716..b740231a 100644 --- a/PhpExecutableFinder.php +++ b/PhpExecutableFinder.php @@ -86,6 +86,10 @@ public function find(bool $includeArgs = true): string|false $dirs[] = 'C:\xampp\php\\'; } + if ($herdPath = getenv('HERD_HOME')) { + $dirs[] = $herdPath.\DIRECTORY_SEPARATOR.'bin'; + } + return $this->executableFinder->find('php', false, $dirs); } From 5c03ee6369281177f07f7c68252a280beccba847 Mon Sep 17 00:00:00 2001 From: "Alexander M. Turek" Date: Thu, 19 Sep 2024 23:14:15 +0200 Subject: [PATCH 19/23] Make more data providers static --- Tests/ProcessTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index 3b0533b7..a639f058 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -76,7 +76,7 @@ public function testInvalidCommand(Process $process) $this->assertSame('\\' === \DIRECTORY_SEPARATOR ? 1 : 127, $process->run()); } - public function invalidProcessProvider() + public static function invalidProcessProvider(): array { return [ [new Process(['invalid'])], From 2ad775b9f17c8c9c1fe457750ce191e0f7c1fbff Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Thu, 26 Sep 2024 10:09:09 +0200 Subject: [PATCH 20/23] Remove unused imports --- Tests/ProcessTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index 5532016b..290100e6 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -16,7 +16,6 @@ use Symfony\Component\Process\Exception\LogicException; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Exception\ProcessSignaledException; -use Symfony\Component\Process\Exception\ProcessStartFailedException; use Symfony\Component\Process\Exception\ProcessTimedOutException; use Symfony\Component\Process\Exception\RuntimeException; use Symfony\Component\Process\InputStream; From 18f50a7e1ab5c0c574f820df5d7a77bf3cdd3f5a Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 29 Oct 2024 21:50:35 +0100 Subject: [PATCH 21/23] [Process] On Windows, don't rely on the OS to find executables --- Process.php | 7 +++++++ Tests/ProcessFailedExceptionTest.php | 10 +++++----- Tests/ProcessTest.php | 11 +++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/Process.php b/Process.php index 78b0fd35..b7d07571 100644 --- a/Process.php +++ b/Process.php @@ -85,6 +85,7 @@ class Process implements \IteratorAggregate private ?int $cachedExitCode = null; private static ?bool $sigchild = null; + private static array $executables = []; /** * Exit codes translation table. @@ -1543,6 +1544,12 @@ private function buildShellCommandline(string|array $commandline): string return $commandline; } + if ('\\' === \DIRECTORY_SEPARATOR && isset($commandline[0][0]) && \strlen($commandline[0]) === strcspn($commandline[0], ':/\\')) { + // On Windows, we don't rely on the OS to find the executable if possible to avoid lookups + // in the current directory which could be untrusted. Instead we use the ExecutableFinder. + $commandline[0] = (self::$executables[$commandline[0]] ??= (new ExecutableFinder())->find($commandline[0])) ?? $commandline[0]; + } + return implode(' ', array_map($this->escapeArgument(...), $commandline)); } diff --git a/Tests/ProcessFailedExceptionTest.php b/Tests/ProcessFailedExceptionTest.php index 259ffd63..d05beb85 100644 --- a/Tests/ProcessFailedExceptionTest.php +++ b/Tests/ProcessFailedExceptionTest.php @@ -80,8 +80,8 @@ public function testProcessFailedExceptionPopulatesInformationFromProcessOutput( $exception = new ProcessFailedException($process); - $this->assertEquals( - "The command \"$cmd\" failed.\n\nExit Code: $exitCode($exitText)\n\nWorking directory: {$workingDirectory}\n\nOutput:\n================\n{$output}\n\nError Output:\n================\n{$errorOutput}", + $this->assertStringMatchesFormat( + "The command \"%s\" failed.\n\nExit Code: $exitCode($exitText)\n\nWorking directory: {$workingDirectory}\n\nOutput:\n================\n{$output}\n\nError Output:\n================\n{$errorOutput}", str_replace("'php'", 'php', $exception->getMessage()) ); } @@ -126,9 +126,9 @@ public function testDisabledOutputInFailedExceptionDoesNotPopulateOutput() $exception = new ProcessFailedException($process); - $this->assertEquals( - "The command \"$cmd\" failed.\n\nExit Code: $exitCode($exitText)\n\nWorking directory: {$workingDirectory}", - str_replace("'php'", 'php', $exception->getMessage()) + $this->assertStringMatchesFormat( + "The command \"%s\" failed.\n\nExit Code: $exitCode($exitText)\n\nWorking directory: {$workingDirectory}", + $exception->getMessage() ); } } diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index 290100e6..939b7166 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -1687,6 +1687,17 @@ public function testNotIgnoringSignal() $this->assertSame(\SIGTERM, $process->getTermSignal()); } + public function testPathResolutionOnWindows() + { + if ('\\' !== \DIRECTORY_SEPARATOR) { + $this->markTestSkipped('This test is for Windows platform only'); + } + + $process = $this->getProcess(['where']); + + $this->assertSame('C:\\Windows\\system32\\where.EXE', $process->getCommandLine()); + } + private function getProcess(string|array $commandline, ?string $cwd = null, ?array $env = null, mixed $input = null, ?int $timeout = 60): Process { if (\is_string($commandline)) { From 9e3b9d4f5302fdd82e1b3e0f3a8542817a6f1d92 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Wed, 5 Feb 2025 09:21:03 +0100 Subject: [PATCH 22/23] skip transient test on GitHub Actions --- Tests/ProcessTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index 06b2e803..8f9f131d 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -710,6 +710,9 @@ public function testProcessIsNotSignaled() if ('\\' === \DIRECTORY_SEPARATOR) { $this->markTestSkipped('Windows does not support POSIX signals'); } + if (\PHP_VERSION_ID < 80300 && isset($_SERVER['GITHUB_ACTIONS'])) { + $this->markTestSkipped('Transient on GHA with PHP < 8.3'); + } $process = $this->getProcess('echo foo'); $process->run(); From d8f411ff3c7ddc4ae9166fb388d1190a2df5b5cf Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Wed, 5 Feb 2025 09:21:03 +0100 Subject: [PATCH 23/23] skip transient test on GitHub Actions --- Tests/ProcessTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index 8f9f131d..b17bfc7a 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -710,9 +710,6 @@ public function testProcessIsNotSignaled() if ('\\' === \DIRECTORY_SEPARATOR) { $this->markTestSkipped('Windows does not support POSIX signals'); } - if (\PHP_VERSION_ID < 80300 && isset($_SERVER['GITHUB_ACTIONS'])) { - $this->markTestSkipped('Transient on GHA with PHP < 8.3'); - } $process = $this->getProcess('echo foo'); $process->run(); @@ -1689,6 +1686,9 @@ public function testNotIgnoringSignal() if (!\function_exists('pcntl_signal')) { $this->markTestSkipped('pnctl extension is required.'); } + if (\PHP_VERSION_ID < 80300 && isset($_SERVER['GITHUB_ACTIONS'])) { + $this->markTestSkipped('Transient on GHA with PHP < 8.3'); + } $process = $this->getProcess(['sleep', '10']);