*/ private readonly array $command, private readonly int $timeoutSeconds = 300, private readonly int $maxOutputBytes = 2_000_000, private ?string $stdin = null, ) { } public static function binary(string $name, array $args, int $timeout = 300): self { $site = config('Site'); $bin = $site->binaries[$name] ?? null; if ($bin === null || ! is_executable($bin)) { throw new \RuntimeException("Binary '{$name}' is not available on this host."); } return new self([$bin, ...array_map('strval', array_values($args))], $timeout); } public function withStdin(?string $data): self { $this->stdin = $data; return $this; } public function run(): bool { $descriptors = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; $process = proc_open($this->command, $descriptors, $pipes, sys_get_temp_dir(), [ 'LC_ALL' => 'C', 'HOME' => rtrim(WRITEPATH, '/'), 'PATH' => '/usr/bin:/bin', ]); if (! is_resource($process)) { return false; } stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false); if ($this->stdin !== null) { fwrite($pipes[0], $this->stdin); } fclose($pipes[0]); $start = microtime(true); $timedOut = false; $outLen = 0; // NB: proc_get_status() reports a valid 'exitcode' only on the FIRST // call that observes running === false — capture it right there. while (true) { $status = proc_get_status($process); if ($status === false || ! $status['running']) { $this->exitCode = (int) ($status['exitcode'] ?? -1); break; } // drain pipes so the child never blocks on full buffers foreach ([1, 2] as $i) { if ($outLen < $this->maxOutputBytes) { $chunk = fread($pipes[$i], 65536); $this->{$i === 1 ? 'stdout' : 'stderr'} .= (string) $chunk; $outLen += strlen((string) $chunk); } } if (microtime(true) - $start > $this->timeoutSeconds) { $timedOut = true; proc_terminate($process, 9); break; } usleep(10_000); } foreach ([1, 2] as $i) { $rest = stream_get_contents($pipes[$i]) ?: ''; $this->{$i === 1 ? 'stdout' : 'stderr'} .= substr($rest, 0, $this->maxOutputBytes); fclose($pipes[$i]); } proc_close($process); if ($timedOut) { log_message('warning', 'Process timed out after {t}s: {cmd}', ['t' => $this->timeoutSeconds, 'cmd' => basename((string) ($this->command[0] ?? ''))]); return false; } return $this->exitCode === 0; } public function ok(): bool { return $this->exitCode === 0; } public function exit(): int { return $this->exitCode; } public function out(): string { return $this->stdout; } public function err(): string { return $this->stderr; } }