*/ public array $logs = []; /** * Stable status messages (success, warning, error). * * @var list */ public array $stableMessages = []; /** * The maximum number of stable messages to display. */ public int $maxStableMessages = 10; /** * The identifier for the task. */ public string $identifier = ''; /** * Whether the task has finished. */ public bool $finished = false; /** * Buffer for incomplete lines from non-blocking socket reads. */ protected string $buffer = ''; /** * The log index where the current partial started, or null if not streaming. */ protected ?int $partialStartIndex = null; /** * Create a new Task instance. */ public function __construct( public string $label = '', public int $limit = 10, public bool $keepSummary = false, public ?string $subLabel = null, ) { $this->identifier = uniqid(); } /** * Render the task and execute the callback. * * @template TReturn of mixed * * @param Closure(Logger): TReturn $callback * @return TReturn */ public function run(Closure $callback): mixed { $this->limit = min($this->limit, $this->terminal()->lines() - 10); $this->recalculateMaxStableMessages(); $this->capturePreviousNewLines(); if (! function_exists('pcntl_fork')) { return $this->renderStatically($callback); } $originalAsync = pcntl_async_signals(true); pcntl_signal(SIGINT, fn () => exit()); try { $this->hideCursor(); $this->render(); $sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); if ($sockets === false) { return $this->renderStatically($callback); } $this->pid = pcntl_fork(); if ($this->pid === 0) { fclose($sockets[1]); $childSocket = $sockets[0]; stream_set_blocking($childSocket, false); while (true) { // @phpstan-ignore-line $this->receiveMessages($childSocket); if (! $this->finished) { $this->render(); $this->count++; } usleep($this->interval * 1000); } } else { fclose($sockets[0]); $this->socket = $sockets[1]; $logger = new Logger($this->identifier, $this->socket); $result = $callback($logger); if ($this->socket !== null) { // Send a reset message to the parent process to reset the terminal. fwrite($this->socket, $this->identifier.'_'.'reset:'.($originalAsync ? 1 : 0).PHP_EOL); usleep($this->interval * 2000); } return $result; } } catch (\Throwable $e) { $this->resetTerminal($originalAsync); throw $e; } } /** * Receive and process messages from the parent process. * * @param resource $socket */ protected function receiveMessages($socket): void { $prefix = preg_quote($this->identifier, '/'); while (($data = fgets($socket)) !== false) { // Buffer incomplete lines from non-blocking reads. if (! str_ends_with($data, PHP_EOL)) { $this->buffer .= $data; continue; } $line = rtrim($this->buffer.$data, PHP_EOL); $this->buffer = ''; if ($line === '') { continue; } // Check for typed messages: {id}_{type}:{content} if (preg_match('/^'.$prefix.'_(success|warning|error|label|sublabel|reset|partial|commitpartial):(.*)/', $line, $matches)) { $type = $matches[1]; $content = $matches[2]; if ($type === 'reset') { $this->resetTerminal((bool) $content); continue; } if ($type === 'partial') { $this->replacePartialLines($content); continue; } if ($type === 'commitpartial') { $this->partialStartIndex = null; continue; } if ($type === 'label') { $this->label = $content; } elseif ($type === 'sublabel') { $this->subLabel = $content; $this->recalculateMaxStableMessages(); } else { $this->stableMessages[] = ['type' => $type, 'message' => $content]; $this->logs = []; $this->partialStartIndex = null; } while (count($this->stableMessages) > $this->maxStableMessages) { array_shift($this->stableMessages); } continue; } // Regular log line — strip cursor-reset control sequences. $line = preg_replace('/\e\[(?:1)?G\e\[2K/', '', $line); // Wrap and add to ring buffer. $this->addLogLines($line); } } /** * Wrap a log line and append to the ring buffer, trimming to the limit. */ protected function addLogLines(string $line): void { $width = $this->terminal()->cols() - 10; $plainText = $this->stripEscapeSequences($line); if (mb_strwidth($plainText) > $width) { $wrapped = $this->ansiWordwrap($line, $width); } else { $wrapped = [$line]; } array_push($this->logs, ...$wrapped); while (count($this->logs) > $this->limit) { array_shift($this->logs); } } /** * Replace the in-progress partial lines with the full accumulated text. */ protected function replacePartialLines(string $text): void { if ($this->partialStartIndex === null) { $this->partialStartIndex = count($this->logs); } // Truncate back to where the partial started. $this->logs = array_slice($this->logs, 0, $this->partialStartIndex); // Wrap and append the full accumulated partial text. $width = $this->terminal()->cols() - 10; $plainText = $this->stripEscapeSequences($text); if (mb_strwidth($plainText) > $width) { $wrapped = $this->ansiWordwrap($text, $width); } else { $wrapped = [$text]; } array_push($this->logs, ...$wrapped); while (count($this->logs) > $this->limit) { array_shift($this->logs); $this->partialStartIndex = max(0, $this->partialStartIndex - 1); } } /** * Recompute the stable-message budget based on the current sub-label state. */ protected function recalculateMaxStableMessages(): void { $reserved = 2 + ($this->subLabel !== null && $this->subLabel !== '' ? 1 : 0); $this->maxStableMessages = max(0, $this->terminal()->lines() - 10 - $this->limit - $reserved); } /** * Reset the terminal. */ protected function resetTerminal(bool $originalAsync): void { $this->finished = true; pcntl_async_signals($originalAsync); pcntl_signal(SIGINT, SIG_DFL); if ($this->socket !== null) { fclose($this->socket); $this->socket = null; } if ($this->keepSummary && count($this->stableMessages) > 0) { $this->render(); return; } $this->eraseRenderedLines(); } /** * Render a static version of the task. * * @template TReturn of mixed * * @param Closure(Logger): TReturn $callback * @return TReturn */ protected function renderStatically(Closure $callback): mixed { $this->static = true; try { $this->hideCursor(); $this->render(); $logger = new Logger($this->identifier); $result = $callback($logger); } finally { $this->eraseRenderedLines(); } return $result; } /** * Disable prompting for input. * * @throws RuntimeException */ public function prompt(): never { throw new RuntimeException('Task cannot be prompted.'); } /** * Get the current value of the prompt. */ public function value(): bool { return true; } /** * Clear the lines rendered by the task. */ protected function eraseRenderedLines(): void { $lines = explode(PHP_EOL, $this->prevFrame); $this->moveCursor(-999, -count($lines) + 1); $this->eraseDown(); } /** * Clean up after the task. */ public function __destruct() { if (! empty($this->pid)) { posix_kill($this->pid, SIGHUP); } parent::__destruct(); } }