Files
wp-agentic-writer/vendor/nette/utils/src/Utils/Process.php
Dwindi Ramadhana 690991c526 refactor: Cleanup git state - commit all staged changes
Major refactoring cleanup:
- Add new controller architecture (class-controller-*.php)
- Add new settings-v2 UI (views/settings-v2/)
- Add new CSS architecture (agentic-sidebar.css, tokens)
- Add esbuild build pipeline (scripts/build.js, package.json)
- Add composer dependencies (vendor/)
- Add frontend src directory (assets/js/src/index.jsx)
- Add documentation files
- Remove old/obsolete files (class-settings.php, old CSS)

This commits all pending changes from previous refactoring efforts.
2026-06-17 05:27:58 +07:00

508 lines
16 KiB
PHP

<?php declare(strict_types=1);
/**
* This file is part of the Nette Framework (https://nette.org)
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
*/
namespace Nette\Utils;
use Nette;
/**
* Represents a process, which can be started and controlled (reading output, writing input, waiting for completion).
*/
final class Process
{
private const PollInterval = 10_000;
private const DefaultTimeout = 60;
private const StdIn = 0;
private const StdOut = 1;
private const StdErr = 2;
/** @var resource */
private mixed $process;
/** @var array<string, mixed> result of proc_get_status() */
private array $status = ['running' => true];
/** @var resource */
private mixed $inputPipe;
/** @var array<int, resource|string[]> pipe resources, or ['file', path, mode] descriptors for discarded output */
private array $outputPipes = [];
/** @var string[] */
private array $outputBuffers = [];
/** @var int[] Number of bytes already read from buffers. */
private array $outputBufferOffsets = [];
/** @var array<int, true> Output IDs whose target resource was supplied by the caller and must not be closed here. */
private array $callerOutputs = [];
private float $startTime;
/**
* Starts an executable with given arguments. Because the arguments are passed as an array, the shell is
* never involved, so they need no escaping and there is no risk of shell injection.
* @param string $executable Path to the executable binary.
* @param list<string> $arguments Arguments passed to the executable.
* @param string[]|null $env Environment variables or null to use the same environment as the current process.
* @param array<string, mixed> $options Additional options for proc_open(). On Windows the executable is launched directly, without cmd.exe.
* @param mixed $stdin Input: string, a readable resource (its content is copied to STDIN), a Process (its STDOUT is piped in), or null (STDIN stays open for writeStdInput()).
* @param mixed $stdout Output target: string filename, a writable resource backed by a real OS file descriptor (not php://memory etc.), false to discard, or null to capture into memory.
* @param mixed $stderr Error output target (same options as $stdout).
* @param string|null $directory Working directory.
* @param float|null $timeout Time limit in seconds, checked while waiting for or reading the process; null disables it.
*/
public static function runExecutable(
string $executable,
array $arguments = [],
?array $env = null,
array $options = [],
mixed $stdin = '',
mixed $stdout = null,
mixed $stderr = null,
?string $directory = null,
?float $timeout = self::DefaultTimeout,
): self
{
return new self([$executable, ...$arguments], $env, $options, $directory, $stdin, $stdout, $stderr, $timeout);
}
/**
* Starts a process from a command string interpreted by the shell (/bin/sh on POSIX, cmd.exe on Windows).
* Because the shell parses the string, NEVER pass unescaped user input here - use runExecutable() for that.
* @param string $command Shell command to run.
* @param string[]|null $env Environment variables or null to use the same environment as the current process.
* @param array<string, mixed> $options Options for proc_open(), e.g. ['bypass_shell' => true] on Windows to skip cmd.exe.
* @param mixed $stdin Input: string, a readable resource (its content is copied to STDIN), a Process (its STDOUT is piped in), or null (STDIN stays open for writeStdInput()).
* @param mixed $stdout Output target: string filename, a writable resource backed by a real OS file descriptor (not php://memory etc.), false to discard, or null to capture into memory.
* @param mixed $stderr Error output target (same options as $stdout).
* @param string|null $directory Working directory.
* @param float|null $timeout Time limit in seconds, checked while waiting for or reading the process; null disables it.
*/
public static function runCommand(
string $command,
?array $env = null,
array $options = [],
mixed $stdin = '',
mixed $stdout = null,
mixed $stderr = null,
?string $directory = null,
?float $timeout = self::DefaultTimeout,
): self
{
return new self($command, $env, $options, $directory, $stdin, $stdout, $stderr, $timeout);
}
/**
* @param list<string>|string $command
* @param array<string, string>|null $env
* @param array<string, mixed> $options
*/
private function __construct(
string|array $command,
?array $env,
array $options,
?string $directory,
mixed $stdin,
mixed $stdout,
mixed $stderr,
private ?float $timeout,
) {
$descriptors = [
self::StdIn => $this->createInputDescriptor($stdin),
self::StdOut => $this->createOutputDescriptor(self::StdOut, $stdout),
self::StdErr => $this->createOutputDescriptor(self::StdErr, $stderr),
];
$process = @proc_open($command, $descriptors, $pipes, $directory, $env, $options);
if (!is_resource($process)) {
throw new ProcessFailedException('Failed to start process: ' . Helpers::getLastError());
}
$this->process = $process;
[$this->inputPipe, $this->outputPipes[self::StdOut], $this->outputPipes[self::StdErr]] = $pipes + $descriptors;
if ($stdin instanceof self) {
// the source process hands over its STDOUT pipe; from now on this process owns it
unset(
$stdin->outputBuffers[self::StdOut],
$stdin->outputBufferOffsets[self::StdOut],
$stdin->outputPipes[self::StdOut],
);
}
$this->writeInitialInput($stdin);
$this->startTime = microtime(true);
}
public function __destruct()
{
$this->outputBuffers = [];
$this->terminate();
}
/**
* Checks if the process is currently running.
*/
public function isRunning(): bool
{
if (!$this->status['running']) {
return false;
}
$this->status = proc_get_status($this->process);
if (!$this->status['running']) {
$this->close();
}
return $this->status['running'];
}
/**
* Finishes the process by waiting for its completion. While waiting, the captured output is read
* continuously and kept in memory; an optional callback is invoked with each new output/error chunk.
*
* @param (\Closure(string, string): void)|null $callback
*/
public function wait(?\Closure $callback = null): void
{
while ($this->isRunning()) {
$this->enforceTimeout();
$this->drainPipes();
$this->dispatchCallback($callback);
usleep(self::PollInterval);
}
$this->dispatchCallback($callback);
}
/**
* Reads any new data from the captured pipes into the buffers, so a process producing more output
* than the OS pipe buffer holds does not block. (On Windows the captured output is a file and never blocks.)
*/
private function drainPipes(): void
{
foreach ([self::StdOut, self::StdErr] as $id) {
$this->readFromPipe($id);
}
}
/**
* Terminates the running process if it is still running.
*/
public function terminate(): void
{
if (!$this->isRunning()) {
return;
} elseif (Helpers::IsWindows) {
exec("taskkill /F /T /PID {$this->getPid()} 2>&1");
} else {
proc_terminate($this->process, 9); // 9 = SIGKILL: cannot be trapped, so the following proc_close() won't hang
}
$this->status['running'] = false;
$this->close();
}
/**
* Returns the process exit code. If the process is still running, waits until it finishes.
*/
public function getExitCode(): int
{
$this->wait();
return $this->status['exitcode'] ?? -1;
}
/**
* Returns true if the process terminated with exit code 0.
*/
public function isSuccess(): bool
{
return $this->getExitCode() === 0;
}
/**
* Waits for the process to finish and throws ProcessFailedException if exit code is not zero.
*/
public function ensureSuccess(): void
{
$code = $this->getExitCode();
if ($code !== 0) {
throw new ProcessFailedException("Process failed with non-zero exit code: $code");
}
}
/**
* Returns the PID of the running process, or null if it is not running.
*/
public function getPid(): ?int
{
return $this->isRunning() ? $this->status['pid'] : null;
}
/**
* Waits for the process to finish and returns everything it wrote to STDOUT.
*/
public function getStdOutput(): string
{
$this->wait();
return $this->outputBuffers[self::StdOut] ?? throw new Nette\InvalidStateException('Cannot read output: it is not captured (it was redirected, discarded or piped).');
}
/**
* Waits for the process to finish and returns everything it wrote to STDERR.
*/
public function getStdError(): string
{
$this->wait();
return $this->outputBuffers[self::StdErr] ?? throw new Nette\InvalidStateException('Cannot read output: it is not captured (it was redirected, discarded or piped).');
}
/**
* Returns the STDOUT data produced since the previous consumeStdOutput() call.
* To read everything incrementally, poll `while ($p->isRunning())` calling this, then call it once more
* after the loop; that last call returns whatever the process wrote just before the loop noticed it had exited.
*/
public function consumeStdOutput(): string
{
return $this->consumeBuffer(self::StdOut);
}
/**
* Returns the STDERR data produced since the previous consumeStdError() call. See consumeStdOutput().
*/
public function consumeStdError(): string
{
return $this->consumeBuffer(self::StdErr);
}
/**
* Returns newly available data from the specified buffer and advances the read pointer.
*/
private function consumeBuffer(int $id): string
{
if (!isset($this->outputBuffers[$id])) {
throw new Nette\InvalidStateException('Cannot read output: it is not captured (it was redirected, discarded or piped).');
} elseif ($this->isRunning()) {
$this->enforceTimeout();
$this->readFromPipe($id);
}
return $this->extractNewData($id);
}
/**
* Returns the buffered data not returned yet and advances the read pointer.
*/
private function extractNewData(int $id): string
{
if (!isset($this->outputBuffers[$id])) {
return '';
}
$res = substr($this->outputBuffers[$id], $this->outputBufferOffsets[$id]);
$this->outputBufferOffsets[$id] = strlen($this->outputBuffers[$id]);
return $res;
}
/**
* Writes data into the process' STDIN. If STDIN is closed, throws exception.
*/
public function writeStdInput(string $string): void
{
if (!is_resource($this->inputPipe)) {
throw new Nette\InvalidStateException('Cannot write to process: STDIN pipe is closed');
}
$this->writeToPipe($string);
}
/**
* Writes the whole string to STDIN, handling partial writes. Stops (and lets fwrite() warn) on a broken pipe,
* i.e. when the process stopped reading its STDIN.
*/
private function writeToPipe(string $string): void
{
$length = strlen($string);
for ($written = 0; $written < $length; $written += $bytes) {
$bytes = fwrite($this->inputPipe, substr($string, $written));
if (!$bytes) {
break;
}
}
}
/**
* Closes the STDIN pipe, indicating no more data will be sent.
*/
public function closeStdInput(): void
{
if (is_resource($this->inputPipe)) {
fclose($this->inputPipe);
}
}
/**
* If a callback is given, invokes it with the output/error produced since the previous call.
* @param (\Closure(string, string): void)|null $callback
*/
private function dispatchCallback(?\Closure $callback): void
{
if (!$callback) {
return;
}
$output = $this->extractNewData(self::StdOut);
$error = $this->extractNewData(self::StdErr);
if ($output !== '' || $error !== '') {
$callback($output, $error);
}
}
/**
* Checks if the timeout has expired. If yes, terminates the process.
*/
private function enforceTimeout(): void
{
if ($this->timeout !== null && (microtime(true) - $this->startTime) >= $this->timeout) {
$this->terminate();
throw new ProcessTimeoutException('Process exceeded the time limit of ' . $this->timeout . ' seconds');
}
}
/**
* Reads any new data from the specified pipe and appends it to the buffer. Does nothing if the output
* is not captured or the pipe is already closed (or handed over to another process).
*/
private function readFromPipe(int $id): void
{
if (!isset($this->outputBuffers[$id]) || !is_resource($this->outputPipes[$id] ?? null)) {
return;
} elseif (Helpers::IsWindows) {
fseek($this->outputPipes[$id], strlen($this->outputBuffers[$id]));
} else {
stream_set_blocking($this->outputPipes[$id], false);
}
$this->outputBuffers[$id] .= stream_get_contents($this->outputPipes[$id]);
}
/**
* Sends the initial input to the process: writes and closes a string or stream input,
* or leaves STDIN open when input is null (until closeStdInput()) or another Process (fed by that process).
* The input type was already validated by createInputDescriptor().
*
* Note: a string or stream input is written upfront, so if it is large and the process does not read it
* while filling its own output, this can block; in that case pass null and feed STDIN via writeStdInput().
*/
private function writeInitialInput(mixed $input): void
{
if ($input === null || $input instanceof self) {
// STDIN stays open
} elseif (is_string($input)) {
$this->writeToPipe($input);
$this->closeStdInput();
} elseif (is_resource($input)) {
stream_copy_to_stream($input, $this->inputPipe);
$this->closeStdInput();
}
}
/**
* Validates the input and determines the STDIN descriptor based on its type.
*/
private function createInputDescriptor(mixed $input): mixed
{
if ($input === null || is_string($input) || is_resource($input)) {
return ['pipe', 'r'];
} elseif (!$input instanceof self) {
throw new Nette\InvalidArgumentException('Input must be string, resource, Process or null, ' . get_debug_type($input) . ' given.');
} elseif (Helpers::IsWindows) {
throw new Nette\NotSupportedException('Process piping is not supported on Windows.');
} elseif (!isset($input->outputBuffers[self::StdOut]) || !is_resource($input->outputPipes[self::StdOut] ?? null)) {
throw new Nette\InvalidStateException('Cannot pipe from the given process: its STDOUT must be captured (it must not be redirected elsewhere).');
}
return $input->outputPipes[self::StdOut];
}
/**
* Determines the descriptor for STDOUT or STDERR based on the specified output target.
*/
private function createOutputDescriptor(int $id, mixed $output): mixed
{
if (is_resource($output)) {
$this->callerOutputs[$id] = true;
return $output;
} elseif (is_string($output)) {
return FileSystem::open($output, 'w');
} elseif ($output === false) {
return ['file', Helpers::IsWindows ? 'NUL' : '/dev/null', 'w'];
} elseif ($output === null) {
$this->outputBuffers[$id] = '';
$this->outputBufferOffsets[$id] = 0;
// On Windows anonymous pipes are blocking and cannot be polled without freezing the process,
// so captured output is backed by a temporary file that can be read non-blockingly (needed for timeouts).
return Helpers::IsWindows ? tmpfile() : ['pipe', 'w'];
} else {
throw new Nette\InvalidArgumentException('Output must be string, resource, bool or null, ' . get_debug_type($output) . ' given.');
}
}
/**
* Closes all pipes and the process resource.
*/
private function close(): void
{
$this->drainPipes();
$this->closeStdInput();
$this->closeOutputPipes();
proc_close($this->process);
}
/**
* Closes the output pipes that this class opened; resources supplied by the caller are left untouched.
* (The temporary file backing captured output on Windows is removed by fclose() itself.)
*/
private function closeOutputPipes(): void
{
foreach ($this->outputPipes as $id => $pipe) {
if (is_resource($pipe) && !isset($this->callerOutputs[$id])) {
fclose($pipe);
}
}
}
}