diff --git a/framework/Exceptions/messages/messages.txt b/framework/Exceptions/messages/messages.txt index d45137039..91c1a1dd8 100644 --- a/framework/Exceptions/messages/messages.txt +++ b/framework/Exceptions/messages/messages.txt @@ -567,6 +567,8 @@ dbtablegateway_mismatch_column_name = In dynamic __call() method '{0}', no matc dbtablegateway_invalid_table_info = Table must be a string or an instance of TDbTableInfo. resource_not_a_resource = Expected an open PHP resource but got '{0}'. +processfork_failed = Unable to fork the process. +liveprocessfork_readonly_receiver = The TLiveProcessFork data is read-only on the receiving side; only the sending side mutates it. stream_open_failed = Unable to open stream '{0}' with mode '{1}': {2} streamresourcewrapper_unusable_stream = The stream is neither readable nor writable and cannot be exposed as a resource. streamfilter_already_registered = Stream filter '{0}' is already registered. diff --git a/framework/IO/Process/TLiveProcessFork.php b/framework/IO/Process/TLiveProcessFork.php new file mode 100644 index 000000000..db45d328e --- /dev/null +++ b/framework/IO/Process/TLiveProcessFork.php @@ -0,0 +1,212 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\IO\Process; + +use Prado\Exceptions\TInvalidOperationException; + +/** + * TLiveProcessFork class. + * + * A {@see TProcessFork} with a live data map the child streams to the parent. Reached as an array + * ({@see \ArrayAccess}), each `$fork[$key] = $value` (or `unset($fork[$key])`) in the child is framed + * and sent over the channel; the parent's reactor decodes the frames into its own copy as they + * arrive, so the parent watches the child's progress and partial results live rather than only at the + * end. + * + * The flow is one-way, child to parent: the child mutates, the parent observes. The parent applies + * each update as the channel drains (through {@see wait()} or a registered + * {@see \Prado\IO\Socket\TSocketReactor}), raising {@see onData} per update; {@see getData()} returns + * the current map. Only serializable values cross the channel. Updates are framed with a 4-byte + * length prefix, since a stream of messages needs a boundary the single end of stream cannot give. + * + * The wire is symmetric and the channel is full-duplex, so a future bidirectional (parent to child) + * mode is additive, not a breaking change: it overrides {@see shouldSend()} so the parent streams its + * mutations too, and adds a child-side drain. {@see applyOp()} already applies received ops directly, + * so neither side echoes what it receives. + * + * ```php + * $fork = TLiveProcessFork::fork(function (TLiveProcessFork $self) { + * foreach (range(1, 100) as $i) { + * $self['progress'] = $i; // streamed to the parent as it changes + * } + * return 0; + * }); + * $fork->attachEventHandler('onData', fn ($f) => print($f['progress'] . "\n")); + * $fork->wait(); // pumps every update, then reaps + * ``` + * + * @author Brad Anderson + * @since 4.4.0 + */ +class TLiveProcessFork extends TProcessFork implements \ArrayAccess +{ + /** The live fork always carries a channel to stream updates. */ + protected const REQUIRES_CHANNEL = true; + + /** @var array The live map: the child's working copy, mirrored on the parent. */ + private array $_data = []; + + /** @var string The parent's frame-reassembly buffer. */ + private string $_buffer = ''; + + /** + * Indicates whether a key is set. + * @param mixed $offset The key. + * @return bool Whether the key is set. + */ + public function offsetExists(mixed $offset): bool + { + return isset($this->_data[$offset]); + } + + /** + * Returns a key's value, or null when unset. + * @param mixed $offset The key. + * @return mixed The value, or null. + */ + public function offsetGet(mixed $offset): mixed + { + return $this->_data[$offset] ?? null; + } + + /** + * Sets a key's value, streaming the change to the parent. The map is read-only on the receiving + * side, so a write from a non-sending side ({@see shouldSend()} false) throws rather than mutating + * a mirror that goes nowhere. + * @param mixed $offset The key, or null to append. + * @param mixed $value The serializable value. + * @throws TInvalidOperationException When written from the receiving side. + */ + public function offsetSet(mixed $offset, mixed $value): void + { + $this->assertWritable(); + if ($offset === null) { + $this->_data[] = $value; + $offset = array_key_last($this->_data); + } else { + $this->_data[$offset] = $value; + } + $this->sendOp('set', $offset, $value); + } + + /** + * Unsets a key, streaming the removal to the parent. The map is read-only on the receiving side. + * @param mixed $offset The key. + * @throws TInvalidOperationException When written from the receiving side. + */ + public function offsetUnset(mixed $offset): void + { + $this->assertWritable(); + unset($this->_data[$offset]); + $this->sendOp('unset', $offset, null); + } + + /** + * Asserts that this side may mutate the map. The receiving side ({@see shouldSend()} false) is + * read-only; its mirror is updated only by the ops it receives through {@see applyOp()}. + * @throws TInvalidOperationException When this side does not send. + */ + protected function assertWritable(): void + { + if (!$this->shouldSend()) { + throw new TInvalidOperationException('liveprocessfork_readonly_receiver'); + } + } + + /** + * Returns the live map: the child's working copy, or the parent's mirror as drained so far. + * @return array The live data. + */ + public function getData(): array + { + return $this->_data; + } + + /** + * Reports whether a local mutation is streamed over the channel. One-way today: only the child + * sends. A bidirectional mode overrides this so the parent streams its mutations too; the wire + * and {@see applyOp()} are already symmetric, so nothing else changes. + * @return bool Whether to stream a local mutation to the peer. + */ + protected function shouldSend(): bool + { + return $this->getIsChild(); + } + + /** + * Frames a mutation and writes it to the channel. + * @param string $kind The operation, 'set' or 'unset'. + * @param mixed $key The key. + * @param mixed $value The value (null for 'unset'). + */ + private function sendOp(string $kind, mixed $key, mixed $value): void + { + $blob = serialize([$kind, $key, $value]); + $this->writeChannel(pack('N', strlen($blob)) . $blob); + } + + /** + * Decodes complete length-prefixed frames from the drained bytes and applies them to the mirror. + * @param string $bytes The bytes read from the channel. + */ + protected function consume(string $bytes): void + { + $this->_buffer .= $bytes; + while (strlen($this->_buffer) >= 4) { + $length = (int) unpack('N', substr($this->_buffer, 0, 4))[1]; + if (strlen($this->_buffer) < 4 + $length) { + break; // the frame is not fully arrived yet + } + $op = @unserialize(substr($this->_buffer, 4, $length)); + $this->_buffer = substr($this->_buffer, 4 + $length); + if (is_array($op)) { + $this->applyOp($op); + } + } + } + + /** + * Applies one decoded operation to the parent's mirror and raises {@see onData}. + * @param array $op The decoded [kind, key, value] operation. + */ + private function applyOp(array $op): void + { + // Assign the mirror directly, never through offsetSet(): a received op must not re-enter the + // send path, or a bidirectional peer would echo it back in a loop. The op tuple may grow + // (e.g. a version field); extra elements are ignored, so the frame stays forward-compatible. + [$kind, $key, $value] = $op + [null, null, null]; + if ($kind === 'set') { + $this->_data[$key] = $value; + } elseif ($kind === 'unset') { + unset($this->_data[$key]); + } + $this->onData($op); + } + + /** + * Raised on the parent for each live update applied from the child. + * @param mixed $param The decoded [kind, key, value] operation. + */ + public function onData(mixed $param): void + { + $this->raiseEvent('onData', $this, $param); + } + + /** + * Excludes the transient frame-reassembly buffer from serialization; the live map is kept. + * @param array $exprops The properties excluded from __sleep. + */ + protected function _getZappableSleepProps(&$exprops) + { + parent::_getZappableSleepProps($exprops); + $exprops[] = "\0" . __CLASS__ . "\0_buffer"; + } +} diff --git a/framework/IO/Process/TProcessFork.php b/framework/IO/Process/TProcessFork.php new file mode 100644 index 000000000..8870265a9 --- /dev/null +++ b/framework/IO/Process/TProcessFork.php @@ -0,0 +1,550 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\IO\Process; + +use Prado\Exceptions\TIOException; +use Prado\Exceptions\TNotSupportedException; +use Prado\IO\Socket\TSocketReactor; +use Prado\IO\Socket\TSocketStream; +use Prado\Prado; +use Prado\TComponent; +use Prado\Util\Helpers\TProcessHelper; +use Prado\Util\TLogger; +use Prado\Util\TSignalsDispatcher; + +/** + * TProcessFork class. + * + * An object handle for a child created with {@see TProcessHelper::fork() pcntl_fork}. Where + * {@see TProcess} wraps an external command opened with `proc_open` (a resource handle, so it + * extends {@see \Prado\IO\TResource}), a fork duplicates the running PHP process and yields a PID, + * so this handle extends {@see TComponent} and owns no stream resource of its own. + * + * A fork is run in one of two ways: + * + * - {@see fork()} — worker: the child runs {@see run()} (a subclass override or the given body) + * inside an isolation wrapper and exits with its return code; the parent receives the handle. + * - {@see start()} — container: the call returns the handle in both processes; the child checks + * {@see getIsChild()}, does its work, and calls {@see exit()}. + * + * The parent side reaps the child synchronously with {@see wait()} (blocking) or {@see poll()} + * (non-blocking), or asynchronously by enabling {@see setAsync() Async} (a subclass defaults it + * through {@see DEFAULT_ASYNC}), which registers the child with the {@see TSignalsDispatcher} so a + * `SIGCHLD` reap raises {@see onExit} without an explicit wait. {@see terminate()} and + * {@see kill()} signal the child. An optional {@see getChannel() Channel} is one end of a socket + * pair for parent-child messaging; a channel-backed fork drains it through a {@see TSocketReactor}, + * either inside {@see wait()} or folded into an existing event loop with {@see register()}, so the + * drained data and {@see onExit} arrive as the loop runs. + * + * ```php + * $worker = TProcessFork::fork(function (TProcessFork $self) { + * $self->getChannel()?->write('done'); + * return 0; + * }, channel: true); + * echo $worker->getChannel()->read(16); // 'done' + * $worker->wait(); // reaps the child, raises onExit + * ``` + * + * @author Brad Anderson + * @since 4.4.0 + * @method bool dyRun() + */ +class TProcessFork extends TComponent +{ + /** The child is running. */ + public const STATE_RUNNING = 'running'; + + /** The child has exited and been reaped. */ + public const STATE_EXITED = 'exited'; + + /** The exit code used when the child body throws an uncaught exception (sysexits EX_SOFTWARE). */ + public const EXIT_EXCEPTION = 70; + + /** The bytes read from the channel per readable event. */ + public const CHUNK_SIZE = 65536; + + /** Whether a subclass always opens a parent-child channel (the result/live forks need it). */ + protected const REQUIRES_CHANNEL = false; + + /** Whether the parent reaps asynchronously by default; a subclass overrides it to opt in. */ + protected const DEFAULT_ASYNC = false; + + /** @var int The child PID on the parent side, or the child's own PID on the child side. */ + private int $_pid = 0; + + /** @var bool Whether this handle is held by the child process. */ + private bool $_isChild = false; + + /** @var string The lifecycle state, {@see STATE_RUNNING} or {@see STATE_EXITED}. */ + private string $_state = self::STATE_RUNNING; + + /** @var ?int The exit code, recorded once the child is reaped. */ + private ?int $_exitCode = null; + + /** @var ?TSocketStream The owned end of the parent-child channel, or null when none was created. */ + private ?TSocketStream $_channel = null; + + /** @var ?callable The child body run by {@see run()} in worker mode. */ + protected $_body; + + /** @var bool Whether the child is reaped asynchronously through the signals dispatcher. */ + private bool $_async = false; + + /** @var ?TSocketReactor The reactor draining the channel while the parent waits, when channel-backed. */ + private ?TSocketReactor $_reactor = null; + + /** + * Forks the process into a worker. The child runs {@see run()} inside an isolation wrapper and + * exits with its return code; the parent receives the handle. + * @param ?callable $body The child body, called as `fn(TProcessFork $self): int`. Omit it to + * override {@see run()} in a subclass instead. + * @param bool $channel Whether to open a socket-pair channel between parent and child. + * @param bool $captureForkLog Whether the child's log is captured back to the parent. + * @throws TNotSupportedException When the platform cannot fork. + * @throws TIOException When the fork fails. + * @return static The parent-side handle. + */ + public static function fork(?callable $body = null, bool $channel = false, bool $captureForkLog = false): static + { + return static::forkProcess($body, $channel, $captureForkLog, true); + } + + /** + * Forks the process and returns the handle in both processes. No body runs automatically: the + * child tests {@see getIsChild()}, does its work, and calls {@see exit()}. + * @param bool $channel Whether to open a socket-pair channel between parent and child. + * @param bool $captureForkLog Whether the child's log is captured back to the parent. + * @throws TNotSupportedException When the platform cannot fork. + * @throws TIOException When the fork fails. + * @return static The handle, in both the parent and the child. + */ + public static function start(bool $channel = false, bool $captureForkLog = false): static + { + return static::forkProcess(null, $channel, $captureForkLog, false); + } + + /** + * Forks the process, splits the optional channel, and dispatches the child to its body in worker + * mode. The child never returns from a worker fork; it exits through {@see exit()}. + * @param ?callable $body The child body for worker mode. + * @param bool $channel Whether to open a parent-child channel. + * @param bool $captureForkLog Whether the child's log is captured back to the parent. + * @param bool $runBody Whether the child runs {@see run()} and exits (worker), or returns (container). + * @throws TNotSupportedException When the platform cannot fork. + * @throws TIOException When the fork fails. + * @return static The handle. + */ + protected static function forkProcess(?callable $body, bool $channel, bool $captureForkLog, bool $runBody): static + { + if (!TProcessHelper::isForkable()) { + throw new TNotSupportedException('processhelper_no_forking'); + } + $fork = Prado::createComponent(static::class); + $fork->_body = $body; + [$parentEnd, $childEnd] = ($channel || static::REQUIRES_CHANNEL) ? TSocketStream::pair() : [null, null]; + + $pid = TProcessHelper::fork($captureForkLog); + if ($pid === -1) { + $parentEnd?->close(); + $childEnd?->close(); + throw new TIOException('processfork_failed'); + } + if ($pid === 0) { + $fork->_pid = (int) getmypid(); + $fork->_isChild = true; + $parentEnd?->close(); + $fork->_channel = $childEnd; + $fork->onFork($fork); + if ($runBody) { + $fork->execute(); // runs the body and exits; never returns + } + return $fork; + } + $fork->_pid = $pid; + $childEnd?->close(); + $fork->_channel = $parentEnd; + $fork->onFork($fork); + if (static::DEFAULT_ASYNC) { + $fork->setAsync(true); + } + return $fork; + } + + /** + * The child body run in worker mode. The default runs the callable passed to {@see fork()}; a + * subclass overrides this to define the worker without a callable. + * @return int The exit code (0 on success). + */ + protected function run(): int + { + if ($this->_body !== null) { + $result = ($this->_body)($this); + return is_int($result) ? $result : 0; + } + return 0; + } + + /** + * Runs {@see run()} in the child inside a try/finally so an exception becomes a non-zero exit + * rather than unwinding into the parent's control flow. This never returns. + */ + protected function execute(): void + { + $code = self::EXIT_EXCEPTION; + try { + $code = $this->run(); + } catch (\Throwable $e) { + Prado::log((string) $e, TLogger::ERROR, static::class); + } finally { + $this->exit($code); + } + } + + /** + * Closes the channel and exits the child process. A no-op in the parent. + * @param int $code The exit code. + */ + public function exit(int $code = 0): void + { + if (!$this->getIsChild()) { + return; + } + $this->_channel?->close(); + exit($code); + } + + /** + * Blocks until the child exits, then records its exit code and raises {@see onExit}. A + * channel-backed fork drains the channel through a {@see TSocketReactor} as it waits, so the + * child never blocks writing a large result into a full pipe; a channel-less fork waits on a + * plain blocking {@see https://www.php.net/pcntl_waitpid waitpid}. A fork already + * {@see register() registered} is driven through that reactor, and the parameter is ignored. + * @param ?TSocketReactor $reactor The reactor to drive, or null to drive a private one. + * @return ?int The exit code, or null when there is no child to wait on. + */ + public function wait(?TSocketReactor $reactor = null): ?int + { + if ($this->getIsChild() || $this->_pid <= 0 || $this->_state === self::STATE_EXITED) { + return $this->_exitCode; + } + if ($this->_channel === null) { + return $this->reap(0); + } + if ($this->_reactor === null) { + $this->register($reactor ?? Prado::createComponent(TSocketReactor::class)); + } + while ($this->_state !== self::STATE_EXITED) { + $this->_reactor->tick(null); + } + return $this->_exitCode; + } + + /** + * Registers the channel with a reactor so the parent drains it (and detects the child's exit by + * the channel's end of stream) without blocking, folding the fork into an existing event loop. + * A child handle, a channel-less fork, or an already-registered fork is a no-op. + * @param TSocketReactor $reactor The reactor to drain the channel through. + */ + public function register(TSocketReactor $reactor): void + { + if ($this->getIsChild() || $this->_channel === null || $this->_reactor !== null) { + return; + } + $this->_reactor = $reactor; + $this->_channel->setBlocking(false); + $reactor->register($this->_channel, onReadable: fn () => $this->drainChannel()); + } + + /** + * Reads what the channel has ready. Bytes are handed to {@see consume()}; an end of stream means + * the child closed its end, so the channel is finished, unregistered, and closed, and the child is + * reaped. A child normally closes as it exits, but a container child may close early and keep + * working, so the reap polls (and follows up on a reactor timer) rather than blocking a shared + * event loop in a waitpid. + */ + protected function drainChannel(): void + { + $bytes = false; + try { + $bytes = $this->_channel?->read(static::CHUNK_SIZE); + } catch (\Throwable $e) { + $bytes = ''; // a read failure is treated as the child closing + } + if ($bytes !== '' && $bytes !== false) { + $this->consume($bytes); + return; + } + $this->finishChannel(); + if ($this->_channel !== null) { + $this->_reactor?->unregister($this->_channel); + $this->_channel->close(); + } + if ($this->poll() === null && $this->_reactor !== null) { + $timerId = 0; + $timerId = $this->_reactor->every(0.01, function () use (&$timerId) { + if ($this->poll() !== null) { + $this->_reactor?->cancelTimer($timerId); + } + }); + } + } + + /** + * Consumes bytes drained from the channel. The base fork keeps none; a subclass collects a + * result or applies live updates. + * @param string $bytes The bytes read from the channel. + */ + protected function consume(string $bytes): void + { + } + + /** + * Completes channel processing at the child's end of stream, before the child is reaped. A + * subclass decodes whatever it buffered. + */ + protected function finishChannel(): void + { + } + + /** + * Writes all of the bytes to the channel from the child, looping until the whole buffer is sent so + * a short write cannot truncate a large payload. A no-op without a channel. + * @param string $data The bytes to write. + */ + protected function writeChannel(string $data): void + { + if ($this->_channel === null) { + return; + } + $offset = 0; + $length = strlen($data); + while ($offset < $length) { + $written = $this->_channel->write($offset === 0 ? $data : substr($data, $offset)); + if ($written <= 0) { + break; + } + $offset += $written; + } + } + + /** + * Reaps the child without blocking, recording the exit code and raising {@see onExit} when it has + * exited. + * @return ?int The exit code, or null when the child is still running or absent. + */ + public function poll(): ?int + { + return $this->reap(WNOHANG); + } + + /** + * Waits on the child with the given options, capturing its exit on termination. + * @param int $options The pcntl_waitpid options (0 to block, WNOHANG to poll). + * @return ?int The recorded exit code, or null when nothing was reaped. + */ + private function reap(int $options): ?int + { + if ($this->getIsChild() || $this->_pid <= 0 || $this->_state === self::STATE_EXITED) { + return $this->_exitCode; + } + $status = 0; + if (pcntl_waitpid($this->_pid, $status, $options) === $this->_pid) { + $this->captureExit(TProcessHelper::exitStatus($status)); + } + return $this->_exitCode; + } + + /** + * Records the exit code the first time the child is reaped and raises {@see onExit}. + * @param int $code The exit code. + */ + protected function captureExit(int $code): void + { + if ($this->_state !== self::STATE_EXITED) { + $this->_exitCode = $code; + $this->_state = self::STATE_EXITED; + if ($this->_async) { + // Detach the child's signal handler however it was reaped, so a synchronous reap + // while async is on does not leave a registration the dispatcher never clears. + TSignalsDispatcher::singleton(false)?->detachPidHandler($this->_pid, [$this, 'reapFromSignal']); + $this->_async = false; + } + $this->onExit($code); + } + } + + /** + * Sends a signal to the child, asking it to stop. + * @param int $signal The signal to send. Default SIGTERM. + * @return bool Whether the signal was sent. + */ + public function terminate(int $signal = SIGTERM): bool + { + if ($this->getIsChild() || $this->_pid <= 0 || $this->_state === self::STATE_EXITED) { + return false; + } + return TProcessHelper::sendSignal($signal, $this->_pid); + } + + /** + * Forcibly kills the child. + * @return bool Whether the child was killed. + */ + public function kill(): bool + { + if ($this->getIsChild() || $this->_pid <= 0 || $this->_state === self::STATE_EXITED) { + return false; + } + return TProcessHelper::kill($this->_pid); + } + + /** + * The PID-handler callback for {@see setAsync() Async} reaping: the dispatcher reaped the child, + * so record its exit and raise {@see onExit}. + * @param object $sender The signals dispatcher. + * @param mixed $param The signal parameter carrying the child status. + */ + public function reapFromSignal($sender, $param): void + { + $info = ($param !== null && method_exists($param, 'getParameter')) ? $param->getParameter() : null; + $status = (is_array($info) && isset($info['status'])) ? (int) $info['status'] : 0; + $this->captureExit(TProcessHelper::exitStatus($status)); + } + + /** + * Returns the child PID on the parent side, or this process's PID on the child side. + * @return int The process ID. + */ + public function getProcessId(): int + { + return $this->_pid; + } + + /** + * Returns whether this handle is held by the child process. + * @return bool Whether this is the child. + */ + public function getIsChild(): bool + { + return $this->_isChild; + } + + /** + * Returns whether this handle is held by the parent of a forked child. + * @return bool Whether this is the parent. + */ + public function getIsParent(): bool + { + return !$this->getIsChild() && $this->_pid > 0; + } + + /** + * Returns the owned end of the parent-child channel. + * @return ?TSocketStream The channel, or null when none was opened. + */ + public function getChannel(): ?TSocketStream + { + return $this->_channel; + } + + /** + * Returns the lifecycle state. + * @return string {@see STATE_RUNNING} or {@see STATE_EXITED}. + */ + public function getState(): string + { + return $this->_state; + } + + /** + * Returns whether the child is still running, reaping it first when it has already exited. + * @return bool Whether the child runs. + */ + public function getIsRunning(): bool + { + if ($this->getIsChild()) { + return true; + } + if ($this->_state === self::STATE_EXITED || $this->_pid <= 0) { + return false; + } + $this->poll(); + return $this->_state !== self::STATE_EXITED; + } + + /** + * Returns the exit code, available once the child is reaped. + * @return ?int The exit code, or null when the child has not been reaped. + */ + public function getExitCode(): ?int + { + return $this->_exitCode; + } + + /** + * Returns whether the child is reaped asynchronously through the signals dispatcher. + * @return bool Whether async reaping is on. + */ + public function getAsync(): bool + { + return $this->_async; + } + + /** + * Enables or disables asynchronous reaping. When on, the child is registered with the + * {@see TSignalsDispatcher} so a `SIGCHLD` raises {@see onExit} without an explicit {@see wait()}. + * @param bool $value Whether to reap asynchronously. + * @return static The current handle. + */ + public function setAsync(bool $value): static + { + if ($value !== $this->getAsync() && !$this->getIsChild() && $this->_pid > 0) { + $dispatcher = TSignalsDispatcher::singleton($value); + if ($value) { + $dispatcher?->attachPidHandler($this->_pid, [$this, 'reapFromSignal']); + } else { + $dispatcher?->detachPidHandler($this->_pid, [$this, 'reapFromSignal']); + } + } + $this->_async = $value; + return $this; + } + + /** + * Raised after the fork returns, in both the parent and the child. + * @param mixed $param This {@see TProcessFork} handle. + */ + public function onFork(mixed $param): void + { + $this->raiseEvent('onFork', $this, $param); + } + + /** + * Raised when the child's exit code is first observed. + * @param mixed $param The exit code. + */ + public function onExit(mixed $param): void + { + $this->raiseEvent('onExit', $this, $param); + } + + /** + * Excludes the channel and body from serialization, as neither survives a sleep. + * @param array $exprops The properties excluded from __sleep. + */ + protected function _getZappableSleepProps(&$exprops) + { + parent::_getZappableSleepProps($exprops); + $exprops[] = "\0" . __CLASS__ . "\0_channel"; + $exprops[] = "\0" . __CLASS__ . "\0_body"; + $exprops[] = "\0" . __CLASS__ . "\0_reactor"; + } +} diff --git a/framework/IO/Process/TResultProcessFork.php b/framework/IO/Process/TResultProcessFork.php new file mode 100644 index 000000000..a36a817f0 --- /dev/null +++ b/framework/IO/Process/TResultProcessFork.php @@ -0,0 +1,136 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\IO\Process; + +use Prado\Prado; +use Prado\Util\TLogger; + +/** + * TResultProcessFork class. + * + * A {@see TProcessFork} whose child returns a result to the parent. The child body (or an overridden + * {@see produce()}) returns any serializable value; the fork serializes it over the parent-child + * channel and the parent reads it back through {@see getResult()} once the child is reaped. + * + * The channel is always opened. The parent collects the serialized result as the channel drains + * (through {@see wait()} or a registered {@see \Prado\IO\Socket\TSocketReactor}), so a result larger + * than the pipe buffer cannot deadlock the child. Only serializable data crosses the channel: a + * closure, resource, or open handle does not. An uncaught exception in the body leaves + * {@see getHasResult()} false and exits the child with {@see EXIT_EXCEPTION}. + * + * ```php + * $fork = TResultProcessFork::fork(fn () => ['rows' => 42, 'ok' => true]); + * $fork->wait(); // drains the result and reaps the child + * $data = $fork->getResult(); // ['rows' => 42, 'ok' => true] + * ``` + * + * @author Brad Anderson + * @since 4.4.0 + */ +class TResultProcessFork extends TProcessFork +{ + /** The result fork always carries a channel to deliver the result. */ + protected const REQUIRES_CHANNEL = true; + + /** @var string The parent's accumulation of the serialized result. */ + private string $_buffer = ''; + + /** @var mixed The result decoded from the child, available after the child is reaped. */ + private mixed $_result = null; + + /** @var bool Whether a result was received and decoded. */ + private bool $_hasResult = false; + + /** + * Runs the result-producing body in the child, serializes the value to the channel, and exits. + * An uncaught exception is logged and exits with {@see EXIT_EXCEPTION}. + */ + protected function execute(): void + { + $payload = ['ok' => false, 'result' => null]; + try { + $payload['result'] = $this->produce(); + $payload['ok'] = true; + } catch (\Throwable $e) { + Prado::log((string) $e, TLogger::ERROR, static::class); + } + try { + $this->writeChannel(serialize($payload)); + } catch (\Throwable $e) { + // The parent is gone; the result cannot be delivered. + } + $this->exit($payload['ok'] ? 0 : self::EXIT_EXCEPTION); + } + + /** + * Produces the result in the child. The default runs the callable passed to {@see fork()}; a + * subclass overrides this to produce a result without a callable. + * @return mixed The serializable result. + */ + protected function produce(): mixed + { + return $this->_body !== null ? ($this->_body)($this) : null; + } + + /** + * Accumulates the serialized result as the channel drains. + * @param string $bytes The bytes read from the channel. + */ + protected function consume(string $bytes): void + { + $this->_buffer .= $bytes; + } + + /** + * Decodes the accumulated result at the child's end of stream, releasing the wire buffer so a + * large result is not held twice. + */ + protected function finishChannel(): void + { + if ($this->_buffer === '') { + return; + } + $payload = @unserialize($this->_buffer); + $this->_buffer = ''; + if (is_array($payload) && ($payload['ok'] ?? false)) { + $this->_result = $payload['result'] ?? null; + $this->_hasResult = true; + } + } + + /** + * Returns the result the child produced, available once the child is reaped. + * @return mixed The result, or null when none was received. + */ + public function getResult(): mixed + { + return $this->_result; + } + + /** + * Returns whether a result was received and decoded. + * @return bool Whether the child delivered a result. + */ + public function getHasResult(): bool + { + return $this->_hasResult; + } + + /** + * Excludes the transient wire buffer from serialization; the decoded result is kept. + * @param array $exprops The properties excluded from __sleep. + */ + protected function _getZappableSleepProps(&$exprops) + { + parent::_getZappableSleepProps($exprops); + $exprops[] = "\0" . __CLASS__ . "\0_buffer"; + } +} diff --git a/framework/classes.php b/framework/classes.php index dd47217ab..789136889 100644 --- a/framework/classes.php +++ b/framework/classes.php @@ -280,9 +280,12 @@ 'TStreamDownloader' => 'Prado\IO\HttpClient\TStreamDownloader', 'IResource' => 'Prado\IO\IResource', 'ITextWriter' => 'Prado\IO\ITextWriter', +'TLiveProcessFork' => 'Prado\IO\Process\TLiveProcessFork', 'TPipeStream' => 'Prado\IO\Process\TPipeStream', 'TProcess' => 'Prado\IO\Process\TProcess', +'TProcessFork' => 'Prado\IO\Process\TProcessFork', 'TProcessStatus' => 'Prado\IO\Process\TProcessStatus', +'TResultProcessFork' => 'Prado\IO\Process\TResultProcessFork', 'TRestClient' => 'Prado\IO\Rest\TRestClient', 'TSocketAddress' => 'Prado\IO\Socket\TSocketAddress', 'TSocketReactor' => 'Prado\IO\Socket\TSocketReactor', diff --git a/tests/unit/IO/Process/TLiveProcessForkTest.php b/tests/unit/IO/Process/TLiveProcessForkTest.php new file mode 100644 index 000000000..c1986714e --- /dev/null +++ b/tests/unit/IO/Process/TLiveProcessForkTest.php @@ -0,0 +1,108 @@ +markTestSkipped('pcntl forking is not available.'); + } + } + + public function testLiveUpdatesStreamToTheParent() + { + $fork = TLiveProcessFork::fork(function (TLiveProcessFork $self) { + $self['a'] = 1; + $self['b'] = ['nested' => true]; + $self['a'] = 2; // overwrite + unset($self['b']); // remove + return 0; + }); + $fork->wait(); // pumps every update, then reaps + + self::assertSame(['a' => 2], $fork->getData()); + self::assertSame(2, $fork['a']); + self::assertFalse(isset($fork['b']), 'An unset is streamed too.'); + } + + public function testOnDataFiresPerUpdate() + { + $ops = []; + $fork = TLiveProcessFork::fork(function (TLiveProcessFork $self) { + $self['x'] = 10; + $self['y'] = 20; + return 0; + }); + $fork->attachEventHandler('onData', function ($sender, $op) use (&$ops) { + $ops[] = $op; + }); + $fork->wait(); + + self::assertSame([['set', 'x', 10], ['set', 'y', 20]], $ops, 'Each mutation arrives as its own update.'); + self::assertSame(['x' => 10, 'y' => 20], $fork->getData()); + } + + public function testManyUpdatesDoNotDeadlock() + { + $fork = TLiveProcessFork::fork(function (TLiveProcessFork $self) { + for ($i = 0; $i < 5000; $i++) { + $self['i'] = $i; // far more writes than a pipe buffer holds + } + return 0; + }); + $fork->wait(); + self::assertSame(4999, $fork['i'], 'The last streamed value wins after draining.'); + } + + public function testAppendSyntaxStreamsWithMatchingKeys() + { + $fork = TLiveProcessFork::fork(function (TLiveProcessFork $self) { + $self[] = 'first'; + $self[] = 'second'; + $self[5] = 'five'; + $self[] = 'six'; + return 0; + }); + $fork->wait(); + self::assertSame(['first', 'second', 5 => 'five', 6 => 'six'], $fork->getData(), 'An append streams the key the child assigned.'); + } + + public function testWritingOnTheParentReceiverThrows() + { + $fork = TLiveProcessFork::fork(function (TLiveProcessFork $self) { + $self['ok'] = 1; // the child (sender) may write + return 0; + }); + + // The returned handle is the parent (the receiver): a write is rejected, not silently mirrored. + $threw = false; + try { + $fork['x'] = 1; + } catch (TInvalidOperationException $e) { + $threw = true; + } + $fork->wait(); + + self::assertTrue($threw, 'A write on the parent receiver throws.'); + self::assertSame(['ok' => 1], $fork->getData(), 'The child write streamed; the rejected write left no trace.'); + } + + public function testUnsetOnTheParentReceiverThrows() + { + $fork = TLiveProcessFork::fork(fn () => 0); + + $threw = false; + try { + unset($fork['anything']); + } catch (TInvalidOperationException $e) { + $threw = true; + } + $fork->wait(); + + self::assertTrue($threw, 'An unset on the parent receiver throws.'); + } +} diff --git a/tests/unit/IO/Process/TProcessForkTest.php b/tests/unit/IO/Process/TProcessForkTest.php new file mode 100644 index 000000000..287b5f73f --- /dev/null +++ b/tests/unit/IO/Process/TProcessForkTest.php @@ -0,0 +1,224 @@ +markTestSkipped('pcntl forking is not available.'); + } + } + + public function testForkRunsBodyAndExitsZero(): void + { + $fork = TProcessFork::fork(fn () => 0); + self::assertTrue($fork->getIsParent(), 'The parent holds the handle.'); + self::assertFalse($fork->getIsChild()); + self::assertGreaterThan(0, $fork->getProcessId()); + self::assertSame(TProcessFork::STATE_RUNNING, $fork->getState()); + self::assertNull($fork->getChannel(), 'No channel is opened unless asked for.'); + $fork->exit(9); // a no-op on the parent; the wait below proves the parent survived it + self::assertSame(0, $fork->wait()); + self::assertSame(TProcessFork::STATE_EXITED, $fork->getState()); + self::assertFalse($fork->getIsRunning()); + } + + public function testForkBodyExitCodePropagates(): void + { + self::assertSame(3, TProcessFork::fork(fn () => 3)->wait()); + } + + public function testForkBodyExceptionExitsWithSoftwareCode(): void + { + $fork = TProcessFork::fork(function () { + throw new \RuntimeException('boom'); + }); + self::assertSame(TProcessFork::EXIT_EXCEPTION, $fork->wait(), 'An uncaught child exception becomes a non-zero exit.'); + } + + public function testWorkerSubclassRunIsInvoked(): void + { + self::assertSame(5, FixedCodeForkWorker::fork()->wait(), 'An overridden run() defines the worker body.'); + } + + public function testChannelRoundTripsFromChildToParent(): void + { + $fork = TProcessFork::fork(function (TProcessFork $self) { + $self->getChannel()->write('pong'); + return 0; + }, channel: true); + + self::assertNotNull($fork->getChannel(), 'The parent holds its end of the channel.'); + $fork->getChannel()->setBlocking(false); + $message = ''; + $deadline = microtime(true) + 3.0; + while ($message !== 'pong' && microtime(true) < $deadline) { + $chunk = (string) $fork->getChannel()->read(16); + $message .= $chunk; + if ($chunk === '') { + usleep(10000); + } + } + self::assertSame('pong', $message, 'The child wrote through the channel to the parent.'); + $fork->wait(); + } + + public function testStartContainerModeChildExitsExplicitly(): void + { + $fork = TProcessFork::start(); + if ($fork->getIsChild()) { + $fork->exit(7); // container mode: the child controls its own exit; never returns + } + self::assertTrue($fork->getIsParent()); + self::assertSame(7, $fork->wait()); + } + + public function testPollIsNullWhileRunningThenReturnsTheExitCode(): void + { + $fork = TProcessFork::fork(function () { + usleep(200000); // 200ms, so the parent observes it running first + return 0; + }); + self::assertNull($fork->poll(), 'poll() does not block and reports null while the child runs.'); + self::assertTrue($fork->getIsRunning()); + self::assertSame(0, $fork->wait()); + self::assertSame(0, $fork->poll(), 'poll() returns the recorded code after exit.'); + } + + public function testOnExitFiresOnceWithTheExitCode(): void + { + $fork = TProcessFork::fork(fn () => 2); + $observed = []; + $fork->attachEventHandler('onExit', function ($sender, $code) use (&$observed) { + $observed[] = $code; + }); + $fork->wait(); + $fork->poll(); // a second reap must not raise onExit again + self::assertSame([2], $observed, 'onExit fires once, carrying the exit code.'); + } + + public function testKillStopsALongRunningChild(): void + { + $fork = TProcessFork::fork(function () { + while (true) { + usleep(50000); + } + }); + self::assertTrue($fork->getIsRunning()); + self::assertTrue($fork->kill()); + $fork->wait(); + self::assertSame(TProcessFork::STATE_EXITED, $fork->getState()); + self::assertFalse($fork->getIsRunning()); + } + + public function testSignalsAreNoOpOnAReapedChild(): void + { + $fork = TProcessFork::fork(fn () => 0); + $fork->wait(); + self::assertFalse($fork->terminate(), 'A reaped child cannot be signaled.'); + self::assertFalse($fork->kill()); + } + + public function testAsyncIsOffByDefault(): void + { + $fork = TProcessFork::fork(fn () => 0); + self::assertFalse($fork->getAsync()); + $fork->wait(); + } + + public function testChannelAwareWaitDrainsAndDoesNotDeadlock(): void + { + // A channel-backed fork must drain the channel while waiting; a non-draining wait would + // deadlock when the child writes more than a pipe buffer holds. + $fork = TProcessFork::fork(function (TProcessFork $self) { + $self->getChannel()->write(str_repeat('x', 512 * 1024)); // 512 KiB > pipe buffer + return 0; + }, channel: true); + self::assertSame(0, $fork->wait()); + self::assertSame(TProcessFork::STATE_EXITED, $fork->getState()); + } + + public function testEarlyChannelCloseDoesNotBlockAnExternalReactor(): void + { + $reactor = new \Prado\IO\Socket\TSocketReactor(); + $fork = TProcessFork::start(channel: true); + if ($fork->getIsChild()) { + $fork->getChannel()->close(); // close the channel early, then keep working + usleep(800000); + exit(6); + } + $fork->register($reactor); + + // Drain to the end of stream: the channel unregisters, but the child still works. + $deadline = microtime(true) + 3.0; + while ($reactor->isRegistered($fork->getChannel()) && microtime(true) < $deadline) { + $reactor->tick(0.05); + } + self::assertNull($fork->getExitCode(), 'The end of stream does not block on the still-working child.'); + self::assertTrue($fork->getIsRunning(), 'The child keeps working after closing its channel.'); + + // The poll timer captures the exit without any blocking waitpid in the loop. + $deadline = microtime(true) + 5.0; + while ($fork->getState() !== TProcessFork::STATE_EXITED && microtime(true) < $deadline) { + $reactor->tick(0.05); + } + self::assertSame(6, $fork->getExitCode(), 'The reactor timer reaps the exit after the early close.'); + } + + public function testSynchronousReapWhileAsyncDetachesTheDispatcherHandler(): void + { + $created = TSignalsDispatcher::singleton(false) === null; + $dispatcher = TSignalsDispatcher::singleton(); + $priorAsync = TSignalsDispatcher::setAsyncSignals(false); // off, so wait() reaps deterministically + try { + $fork = TProcessFork::fork(fn () => 0); + $fork->setAsync(true); + $pid = $fork->getProcessId(); + self::assertTrue($dispatcher->hasPidHandler($pid), 'Async registers a PID handler.'); + + self::assertSame(0, $fork->wait(), 'A synchronous reap while async is on.'); + + self::assertFalse($dispatcher->hasPidHandler($pid), 'captureExit detaches the handler however the child is reaped.'); + self::assertFalse($fork->getAsync(), 'Async is cleared once the child is reaped.'); + } finally { + $created ? $dispatcher->detach() : TSignalsDispatcher::setAsyncSignals($priorAsync); + } + } + + public function testDefaultAsyncConstantEnablesAsyncOnFork(): void + { + $created = TSignalsDispatcher::singleton(false) === null; + $dispatcher = TSignalsDispatcher::singleton(); + $priorAsync = TSignalsDispatcher::setAsyncSignals(false); + try { + $fork = AsyncByDefaultProcessFork::fork(fn () => 0); + self::assertTrue($fork->getAsync(), 'DEFAULT_ASYNC enables async reaping on the parent after fork.'); + self::assertTrue($dispatcher->hasPidHandler($fork->getProcessId()), 'A PID handler is registered on fork.'); + + $fork->wait(); + self::assertFalse($fork->getAsync()); + self::assertFalse($dispatcher->hasPidHandler($fork->getProcessId())); + } finally { + $created ? $dispatcher->detach() : TSignalsDispatcher::setAsyncSignals($priorAsync); + } + } +} diff --git a/tests/unit/IO/Process/TResultProcessForkTest.php b/tests/unit/IO/Process/TResultProcessForkTest.php new file mode 100644 index 000000000..cd8fd8a76 --- /dev/null +++ b/tests/unit/IO/Process/TResultProcessForkTest.php @@ -0,0 +1,103 @@ + 'produce']; + } +} + +class TResultProcessForkTest extends PHPUnit\Framework\TestCase +{ + protected function setUp(): void + { + if (!TProcessHelper::isForkable()) { + $this->markTestSkipped('pcntl forking is not available.'); + } + } + + public function testChildResultIsReturnedToTheParent() + { + $fork = TResultProcessFork::fork(fn () => ['rows' => 42, 'ok' => true]); + self::assertSame(0, $fork->wait()); + self::assertTrue($fork->getHasResult()); + self::assertSame(['rows' => 42, 'ok' => true], $fork->getResult()); + } + + public function testScalarResult() + { + $fork = TResultProcessFork::fork(fn () => 'done'); + $fork->wait(); + self::assertSame('done', $fork->getResult()); + self::assertTrue($fork->getHasResult()); + } + + public function testLargeResultDoesNotDeadlock() + { + $big = str_repeat('0123456789abcdef', 131072); // 2 MiB, well past a pipe buffer + $fork = TResultProcessFork::fork(fn () => $big); + self::assertSame(0, $fork->wait(), 'A draining wait does not deadlock on a large result.'); + self::assertSame($big, $fork->getResult()); + } + + public function testProduceOverrideWithoutACallable() + { + $fork = FixedResultProcessFork::fork(); + $fork->wait(); + self::assertSame(['from' => 'produce'], $fork->getResult()); + } + + public function testNullResultWithSuccessIsDistinctFromFailure() + { + $fork = TResultProcessFork::fork(fn () => null); + self::assertSame(0, $fork->wait()); + self::assertTrue($fork->getHasResult(), 'A successful null result still counts as received.'); + self::assertNull($fork->getResult()); + } + + public function testNestedStructureRoundTripsBySerialization() + { + $obj = new \stdClass(); + $obj->name = 'child'; + $fork = TResultProcessFork::fork(fn () => ['list' => [1, 2.5, 'three'], 'obj' => $obj, 'utf8' => 'héllo']); + $fork->wait(); + $result = $fork->getResult(); + self::assertSame([1, 2.5, 'three'], $result['list']); + self::assertSame('child', $result['obj']->name, 'An object round-trips through serialization.'); + self::assertSame('héllo', $result['utf8']); + } + + public function testAnExceptionLeavesNoResultAndANonZeroExit() + { + $fork = TResultProcessFork::fork(function () { + throw new \RuntimeException('boom'); + }); + self::assertSame(TResultProcessFork::EXIT_EXCEPTION, $fork->wait()); + self::assertFalse($fork->getHasResult()); + self::assertNull($fork->getResult()); + } + + public function testResultCollectedThroughARegisteredReactor() + { + $reactor = new TSocketReactor(); + $fork = TResultProcessFork::fork(fn () => [1, 2, 3]); + $exitCode = null; + $fork->attachEventHandler('onExit', function ($sender, $code) use (&$exitCode) { + $exitCode = $code; + }); + $fork->register($reactor); + + $deadline = microtime(true) + 3.0; + while ($fork->getState() !== TResultProcessFork::STATE_EXITED && microtime(true) < $deadline) { + $reactor->tick(0.1); + } + self::assertSame([1, 2, 3], $fork->getResult()); + self::assertSame(0, $exitCode, 'onExit fires once the channel ends and the child is reaped.'); + } +}