xref: /php-src/sapi/fpm/tests/tester.inc (revision e0b79cdc)
1<?php
2
3namespace FPM;
4
5use FPM\FastCGI\Client;
6use FPM\FastCGI\SocketTransport;
7use FPM\FastCGI\StreamTransport;
8use FPM\FastCGI\Transport;
9
10require_once 'fcgi.inc';
11require_once 'logreader.inc';
12require_once 'logtool.inc';
13require_once 'response.inc';
14
15class Tester
16{
17    /**
18     * Config directory for included files.
19     */
20    const CONF_DIR = __DIR__ . '/conf.d';
21
22    /**
23     * File extension for access log.
24     */
25    const FILE_EXT_LOG_ACC = 'acc.log';
26
27    /**
28     * File extension for error log.
29     */
30    const FILE_EXT_LOG_ERR = 'err.log';
31
32    /**
33     * File extension for slow log.
34     */
35    const FILE_EXT_LOG_SLOW = 'slow.log';
36
37    /**
38     * File extension for PID file.
39     */
40    const FILE_EXT_PID = 'pid';
41
42    /**
43     * @var array
44     */
45    static private array $supportedFiles = [
46        self::FILE_EXT_LOG_ACC,
47        self::FILE_EXT_LOG_ERR,
48        self::FILE_EXT_LOG_SLOW,
49        self::FILE_EXT_PID,
50        'src.php',
51        'ini',
52        'skip.ini',
53        '*.sock',
54    ];
55
56    /**
57     * @var array
58     */
59    static private array $filesToClean = ['.user.ini'];
60
61    /**
62     * @var bool
63     */
64    private bool $debug;
65
66    /**
67     * @var array
68     */
69    private array $clients = [];
70
71    /**
72     * @var string
73     */
74    private string $clientTransport;
75
76    /**
77     * @var LogReader
78     */
79    private LogReader $logReader;
80
81    /**
82     * @var LogTool
83     */
84    private LogTool $logTool;
85
86    /**
87     * Configuration template
88     *
89     * @var string|array
90     */
91    private string|array $configTemplate;
92
93    /**
94     * The PHP code to execute
95     *
96     * @var string
97     */
98    private string $code;
99
100    /**
101     * @var array
102     */
103    private array $options;
104
105    /**
106     * @var string
107     */
108    private string $fileName;
109
110    /**
111     * @var resource
112     */
113    private $masterProcess;
114
115    /**
116     * @var bool
117     */
118    private bool $daemonized;
119
120    /**
121     * @var resource
122     */
123    private $outDesc;
124
125    /**
126     * @var array
127     */
128    private array $ports = [];
129
130    /**
131     * @var string|null
132     */
133    private ?string $error = null;
134
135    /**
136     * The last response for the request call
137     *
138     * @var Response|null
139     */
140    private ?Response $response;
141
142    /**
143     * @var string[]
144     */
145    private $expectedAccessLogs;
146
147    /**
148     * @var bool
149     */
150    private $expectSuppressableAccessLogEntries;
151
152    /**
153     * Clean all the created files up
154     *
155     * @param int $backTraceIndex
156     */
157    static public function clean($backTraceIndex = 1)
158    {
159        $filePrefix = self::getCallerFileName($backTraceIndex);
160        if (str_ends_with($filePrefix, 'clean.')) {
161            $filePrefix = substr($filePrefix, 0, -6);
162        }
163
164        $filesToClean = array_merge(
165            array_map(
166                function ($fileExtension) use ($filePrefix) {
167                    return $filePrefix . $fileExtension;
168                },
169                self::$supportedFiles
170            ),
171            array_map(
172                function ($fileExtension) {
173                    return __DIR__ . '/' . $fileExtension;
174                },
175                self::$filesToClean
176            )
177        );
178        // clean all the root files
179        foreach ($filesToClean as $filePattern) {
180            foreach (glob($filePattern) as $filePath) {
181                unlink($filePath);
182            }
183        }
184
185        self::cleanConfigFiles();
186    }
187
188    /**
189     * Clean config files
190     */
191    static public function cleanConfigFiles()
192    {
193        if (is_dir(self::CONF_DIR)) {
194            foreach (glob(self::CONF_DIR . '/*.conf') as $name) {
195                unlink($name);
196            }
197            rmdir(self::CONF_DIR);
198        }
199    }
200
201    /**
202     * @param int $backTraceIndex
203     *
204     * @return string
205     */
206    static private function getCallerFileName(int $backTraceIndex = 1): string
207    {
208        $backtrace = debug_backtrace();
209        if (isset($backtrace[$backTraceIndex]['file'])) {
210            $filePath = $backtrace[$backTraceIndex]['file'];
211        } else {
212            $filePath = __FILE__;
213        }
214
215        return substr($filePath, 0, -strlen(pathinfo($filePath, PATHINFO_EXTENSION)));
216    }
217
218    /**
219     * @return bool|string
220     */
221    static public function findExecutable(): bool|string
222    {
223        $phpPath = getenv("TEST_PHP_EXECUTABLE");
224        for ($i = 0; $i < 2; $i++) {
225            $slashPosition = strrpos($phpPath, "/");
226            if ($slashPosition) {
227                $phpPath = substr($phpPath, 0, $slashPosition);
228            } else {
229                break;
230            }
231        }
232
233        if ($phpPath && is_dir($phpPath)) {
234            if (file_exists($phpPath . "/fpm/php-fpm") && is_executable($phpPath . "/fpm/php-fpm")) {
235                /* gotcha */
236                return $phpPath . "/fpm/php-fpm";
237            }
238            $phpSbinFpmi = $phpPath . "/sbin/php-fpm";
239            if (file_exists($phpSbinFpmi) && is_executable($phpSbinFpmi)) {
240                return $phpSbinFpmi;
241            }
242        }
243
244        // try local php-fpm
245        $fpmPath = dirname(__DIR__) . '/php-fpm';
246        if (file_exists($fpmPath) && is_executable($fpmPath)) {
247            return $fpmPath;
248        }
249
250        return false;
251    }
252
253    /**
254     * Skip test if any of the supplied files does not exist.
255     *
256     * @param mixed $files
257     */
258    static public function skipIfAnyFileDoesNotExist($files)
259    {
260        if ( ! is_array($files)) {
261            $files = array($files);
262        }
263        foreach ($files as $file) {
264            if ( ! file_exists($file)) {
265                die("skip File $file does not exist");
266            }
267        }
268    }
269
270    /**
271     * Skip test if config file is invalid.
272     *
273     * @param string $configTemplate
274     *
275     * @throws \Exception
276     */
277    static public function skipIfConfigFails(string $configTemplate)
278    {
279        $tester     = new self($configTemplate, '', [], self::getCallerFileName());
280        $testResult = $tester->testConfig(true);
281        if ($testResult !== null) {
282            self::clean(2);
283            $message = $testResult[0] ?? 'Config failed';
284            die("skip $message");
285        }
286    }
287
288    /**
289     * Skip test if IPv6 is not supported.
290     */
291    static public function skipIfIPv6IsNotSupported()
292    {
293        @stream_socket_client('tcp://[::1]:0', $errno);
294        if ($errno != 111) {
295            die('skip IPv6 is not supported.');
296        }
297    }
298
299    /**
300     * Skip if not running as root.
301     */
302    static public function skipIfNotRoot()
303    {
304        if (exec('whoami') !== 'root') {
305            die('skip not running as root');
306        }
307    }
308
309    /**
310     * Skip if running as root.
311     */
312    static public function skipIfRoot()
313    {
314        if (exec('whoami') === 'root') {
315            die('skip running as root');
316        }
317    }
318
319    /**
320     * Skip if posix extension not loaded.
321     */
322    static public function skipIfPosixNotLoaded()
323    {
324        if ( ! extension_loaded('posix')) {
325            die('skip posix extension not loaded');
326        }
327    }
328
329    /**
330     * Skip if shared extension is not available in extension directory.
331     */
332    static public function skipIfSharedExtensionNotFound($extensionName)
333    {
334        $soPath = ini_get('extension_dir') . '/' . $extensionName . '.so';
335        if ( ! file_exists($soPath)) {
336            die("skip $extensionName extension not present in extension_dir");
337        }
338    }
339
340    /**
341     * Skip test if supplied shell command fails.
342     *
343     * @param string $command
344     * @param string|null $expectedPartOfOutput
345     */
346    static public function skipIfShellCommandFails(string $command, ?string $expectedPartOfOutput = null)
347    {
348        $result = exec("$command 2>&1", $output, $code);
349        if ($result === false || $code) {
350            die("skip command '$command' faieled with code $code");
351        }
352        if (!is_null($expectedPartOfOutput)) {
353            if (is_array($output)) {
354                foreach ($output as $line) {
355                    if (str_contains($line, $expectedPartOfOutput)) {
356                        // string found so no need to skip
357                        return;
358                    }
359                }
360            }
361            die("skip command '$command' did not contain output '$expectedPartOfOutput'");
362        }
363    }
364
365    /**
366     * Skip if posix extension not loaded.
367     */
368    static public function skipIfUserDoesNotExist($userName) {
369        self::skipIfPosixNotLoaded();
370        if ( posix_getpwnam( $userName ) === false ) {
371            die( "skip user $userName does not exist" );
372        }
373    }
374
375    /**
376     * Tester constructor.
377     *
378     * @param string|array $configTemplate
379     * @param string       $code
380     * @param array        $options
381     * @param string|null  $fileName
382     * @param bool|null    $debug
383     */
384    public function __construct(
385        string|array $configTemplate,
386        string $code = '',
387        array $options = [],
388        ?string $fileName = null,
389        ?bool $debug = null,
390        string $clientTransport = 'stream'
391    ) {
392        $this->configTemplate = $configTemplate;
393        $this->code           = $code;
394        $this->options        = $options;
395        $this->fileName       = $fileName ?: self::getCallerFileName();
396        if (($debugFilter = getenv('TEST_FPM_DEBUG_FILTER')) !== false) {
397            $this->debug = str_contains(basename($this->fileName), $debugFilter);
398        } else {
399            $this->debug = $debug !== null ? $debug : (bool)getenv('TEST_FPM_DEBUG');
400        }
401        $this->logReader       = new LogReader($this->debug);
402        $this->logTool         = new LogTool($this->logReader, $this->debug);
403        $this->clientTransport = $clientTransport;
404    }
405
406    /**
407     * Creates new client transport.
408     *
409     * @return Transport
410     */
411    private function createTransport()
412    {
413        return match ($this->clientTransport) {
414            'stream' => new StreamTransport(),
415            'socket' => new SocketTransport(),
416        };
417    }
418
419    /**
420     * @param string $ini
421     */
422    public function setUserIni(string $ini)
423    {
424        $iniFile = __DIR__ . '/.user.ini';
425        $this->trace('Setting .user.ini file', $ini, isFile: true);
426        file_put_contents($iniFile, $ini);
427    }
428
429    /**
430     * Test configuration file.
431     *
432     * @return null|array
433     * @throws \Exception
434     */
435    public function testConfig(
436        $silent = false,
437        array|string|null $expectedPattern = null,
438        $dumpConfig = true,
439        $printOutput = false
440    ): ?array {
441        $configFile    = $this->createConfig();
442        $configTestArg = $dumpConfig ? '-tt' : '-t';
443        $cmd           = self::findExecutable() . " -n $configTestArg -y $configFile 2>&1";
444        $this->trace('Testing config using command', $cmd, true);
445        exec($cmd, $output, $code);
446        if ($printOutput) {
447            foreach ($output as $outputLine) {
448                echo $outputLine . "\n";
449            }
450        }
451        $found = 0;
452        if ($expectedPattern !== null) {
453            $expectedPatterns = is_array($expectedPattern) ? $expectedPattern : [$expectedPattern];
454        }
455        if ($code) {
456            $messages = [];
457            foreach ($output as $outputLine) {
458                $message    = preg_replace("/\[.+?\]/", "", $outputLine, 1);
459                if ($expectedPattern !== null) {
460                    for ($i = 0; $i < count($expectedPatterns); $i++) {
461                        $pattern = $expectedPatterns[$i];
462                        if ($pattern !== null && preg_match($pattern, $message)) {
463                            $found++;
464                            $expectedPatterns[$i] = null;
465                        }
466                    }
467                }
468                $messages[] = $message;
469                if ( ! $silent) {
470                    $this->error($message, null, false);
471                }
472            }
473        } else {
474            $messages = null;
475        }
476
477        if ($expectedPattern !== null && $found < count($expectedPatterns)) {
478            $missingPatterns = array_filter($expectedPatterns);
479            $errorMessage = sprintf(
480                "The expected config %s %s %s not been found",
481                count($missingPatterns) > 1 ? 'patterns' : 'pattern',
482                implode(', ', $missingPatterns),
483                count($missingPatterns) > 1 ? 'have' : 'has',
484            );
485            $this->error($errorMessage);
486        }
487
488        return $messages;
489    }
490
491    /**
492     * Start PHP-FPM master process
493     *
494     * @param array      $extraArgs   Command extra arguments.
495     * @param bool       $forceStderr Whether to output to stderr so error log is used.
496     * @param bool       $daemonize   Whether to start FPM daemonized
497     * @param array      $extensions  List of extension to add if shared build used.
498     * @param array      $iniEntries  List of ini entries to use.
499     * @param array|null $envVars     List of env variable to execute FPM with or null to use the current ones.
500     *
501     * @return bool
502     * @throws \Exception
503     */
504    public function start(
505        array $extraArgs = [],
506        bool $forceStderr = true,
507        bool $daemonize = false,
508        array $extensions = [],
509        array $iniEntries = [],
510        ?array $envVars = null,
511    ) {
512        $configFile = $this->createConfig();
513        $desc       = $this->outDesc ? [] : [1 => array('pipe', 'w'), 2 => array('redirect', 1)];
514
515        $cmd = [self::findExecutable(), '-n', '-y', $configFile];
516
517        if ($forceStderr) {
518            $cmd[] = '-O';
519        }
520
521        $this->daemonized = $daemonize;
522        if ( ! $daemonize) {
523            $cmd[] = '-F';
524        }
525
526        $extensionDir = getenv('TEST_FPM_EXTENSION_DIR');
527        if ($extensionDir) {
528            $cmd[] = '-dextension_dir=' . $extensionDir;
529            foreach ($extensions as $extension) {
530                $cmd[] = '-dextension=' . $extension;
531            }
532        }
533
534        foreach ($iniEntries as $iniEntryName => $iniEntryValue) {
535            $cmd[] = '-d' . $iniEntryName . '=' . $iniEntryValue;
536        }
537
538        if (getenv('TEST_FPM_RUN_AS_ROOT')) {
539            $cmd[] = '--allow-to-run-as-root';
540        }
541        $cmd = array_merge($cmd, $extraArgs);
542        $this->trace('Starting FPM using command:', $cmd, true);
543
544        $this->masterProcess = proc_open($cmd, $desc, $pipes, null, $envVars);
545        register_shutdown_function(
546            function ($masterProcess) use ($configFile) {
547                @unlink($configFile);
548                if (is_resource($masterProcess)) {
549                    @proc_terminate($masterProcess);
550                    while (proc_get_status($masterProcess)['running']) {
551                        usleep(10000);
552                    }
553                }
554            },
555            $this->masterProcess
556        );
557        if ( ! $this->outDesc !== false) {
558            $this->outDesc = $pipes[1];
559            $this->logReader->setStreamSource('{{MASTER:OUT}}', $this->outDesc);
560            if ($daemonize) {
561                $this->switchLogSource('{{FILE:LOG}}');
562            }
563        }
564
565        return true;
566    }
567
568    /**
569     * Run until needle is found in the log.
570     *
571     * @param string $pattern Search pattern to find.
572     *
573     * @return bool
574     * @throws \Exception
575     */
576    public function runTill(string $pattern)
577    {
578        $this->start();
579        $found = $this->logTool->expectPattern($pattern);
580        $this->close(true);
581
582        return $found;
583    }
584
585    /**
586     * Check if connection works.
587     *
588     * @param string      $host
589     * @param string|null $successMessage
590     * @param string|null $errorMessage
591     * @param int         $attempts
592     * @param int         $delay
593     */
594    public function checkConnection(
595        string $host = '127.0.0.1',
596        ?string $successMessage = null,
597        ?string $errorMessage = 'Connection failed',
598        int $attempts = 20,
599        int $delay = 50000
600    ) {
601        $i = 0;
602        do {
603            if ($i > 0 && $delay > 0) {
604                usleep($delay);
605            }
606            $fp = @fsockopen($host, $this->getPort());
607        } while ((++$i < $attempts) && ! $fp);
608
609        if ($fp) {
610            $this->trace('Checking connection successful');
611            $this->message($successMessage);
612            fclose($fp);
613        } else {
614            $this->message($errorMessage);
615        }
616    }
617
618
619    /**
620     * Execute request with parameters ordered for better checking.
621     *
622     * @param string      $address
623     * @param string|null $successMessage
624     * @param string|null $errorMessage
625     * @param string      $uri
626     * @param string      $query
627     * @param array       $headers
628     *
629     * @return Response
630     */
631    public function checkRequest(
632        string $address,
633        ?string $successMessage = null,
634        ?string $errorMessage = null,
635        string $uri = '/ping',
636        string $query = '',
637        array $headers = []
638    ): Response {
639        return $this->request($query, $headers, $uri, $address, $successMessage, $errorMessage);
640    }
641
642    /**
643     * Execute and check ping request.
644     *
645     * @param string $address
646     * @param string $pingPath
647     * @param string $pingResponse
648     */
649    public function ping(
650        string $address = '{{ADDR}}',
651        string $pingResponse = 'pong',
652        string $pingPath = '/ping'
653    ) {
654        $response = $this->request('', [], $pingPath, $address);
655        $response->expectBody($pingResponse, 'text/plain');
656    }
657
658    /**
659     * Execute and check status request(s).
660     *
661     * @param array       $expectedFields
662     * @param string|null $address
663     * @param string      $statusPath
664     * @param mixed       $formats
665     *
666     * @throws \Exception
667     */
668    public function status(
669        array $expectedFields,
670        ?string $address = null,
671        string $statusPath = '/status',
672        $formats = ['plain', 'html', 'xml', 'json', 'openmetrics']
673    ) {
674        if ( ! is_array($formats)) {
675            $formats = [$formats];
676        }
677
678        require_once "status.inc";
679        $status = new Status($this);
680        foreach ($formats as $format) {
681            $query    = $format === 'plain' ? '' : $format;
682            $response = $this->request($query, [], $statusPath, $address);
683            $status->checkStatus($response, $expectedFields, $format);
684        }
685    }
686
687    /**
688     * Get request params array.
689     *
690     * @param string      $query
691     * @param array       $headers
692     * @param string|null $uri
693     * @param string|null $scriptFilename
694     * @param string|null $stdin
695     *
696     * @return array
697     */
698    private function getRequestParams(
699        string $query = '',
700        array $headers = [],
701        ?string $uri = null,
702        ?string $scriptFilename = null,
703        ?string $scriptName = null,
704        ?string $stdin = null,
705        ?string $method = null,
706    ): array {
707        if (is_null($scriptFilename)) {
708            $scriptFilename = $this->makeSourceFile();
709        }
710        if (is_null($uri)) {
711            $uri = '/' . basename($scriptFilename);
712        }
713        if (is_null($scriptName)) {
714            $scriptName = $uri;
715        }
716
717        $params = array_merge(
718            [
719                'GATEWAY_INTERFACE' => 'FastCGI/1.0',
720                'REQUEST_METHOD'    => $method ?? (is_null($stdin) ? 'GET' : 'POST'),
721                'SCRIPT_FILENAME'   => $scriptFilename === '' ? null : $scriptFilename,
722                'SCRIPT_NAME'       => $scriptName,
723                'QUERY_STRING'      => $query,
724                'REQUEST_URI'       => $uri . ($query ? '?' . $query : ""),
725                'DOCUMENT_URI'      => $uri,
726                'SERVER_SOFTWARE'   => 'php/fcgiclient',
727                'REMOTE_ADDR'       => '127.0.0.1',
728                'REMOTE_PORT'       => '7777',
729                'SERVER_ADDR'       => '127.0.0.1',
730                'SERVER_PORT'       => '80',
731                'SERVER_NAME'       => php_uname('n'),
732                'SERVER_PROTOCOL'   => 'HTTP/1.1',
733                'DOCUMENT_ROOT'     => __DIR__,
734                'CONTENT_TYPE'      => '',
735                'CONTENT_LENGTH'    => strlen($stdin ?? "") // Default to 0
736            ],
737            $headers
738        );
739
740        return array_filter($params, function ($value) {
741            return ! is_null($value);
742        });
743    }
744
745    /**
746     * Parse stdin and generate data for multipart config.
747     *
748     * @param array $stdin
749     * @param array $headers
750     *
751     * @return void
752     * @throws \Exception
753     */
754    private function parseStdin(array $stdin, array &$headers)
755    {
756        $parts = $stdin['parts'] ?? null;
757        if (empty($parts)) {
758            throw new \Exception('The stdin array needs to contain parts');
759        }
760        $boundary = $stdin['boundary'] ?? 'AaB03x';
761        if ( ! isset($headers['CONTENT_TYPE'])) {
762            $headers['CONTENT_TYPE'] = 'multipart/form-data; boundary=' . $boundary;
763        }
764        $count = $parts['count'] ?? null;
765        if ( ! is_null($count)) {
766            $dispositionType  = $parts['disposition'] ?? 'form-data';
767            $dispositionParam = $parts['param'] ?? 'name';
768            $namePrefix       = $parts['prefix'] ?? 'f';
769            $nameSuffix       = $parts['suffix'] ?? '';
770            $value            = $parts['value'] ?? 'test';
771            $parts            = [];
772            for ($i = 0; $i < $count; $i++) {
773                $parts[] = [
774                    'disposition' => $dispositionType,
775                    'param'       => $dispositionParam,
776                    'name'        => "$namePrefix$i$nameSuffix",
777                    'value'       => $value
778                ];
779            }
780        }
781        $out = '';
782        $nl  = "\r\n";
783        foreach ($parts as $part) {
784            if (!is_array($part)) {
785                $part = ['name' => $part];
786            } elseif ( ! isset($part['name'])) {
787                throw new \Exception('Each part has to have a name');
788            }
789            $name             = $part['name'];
790            $dispositionType  = $part['disposition'] ?? 'form-data';
791            $dispositionParam = $part['param'] ?? 'name';
792            $value            = $part['value'] ?? 'test';
793            $partHeaders          = $part['headers'] ?? [];
794
795            $out .= "--$boundary$nl";
796            $out .= "Content-disposition: $dispositionType; $dispositionParam=\"$name\"$nl";
797            foreach ($partHeaders as $headerName => $headerValue) {
798                $out .= "$headerName: $headerValue$nl";
799            }
800            $out .= $nl;
801            $out .= "$value$nl";
802        }
803        $out .= "--$boundary--$nl";
804
805        return $out;
806    }
807
808    /**
809     * Execute request.
810     *
811     * @param string            $query
812     * @param array             $headers
813     * @param string|null       $uri
814     * @param string|null       $address
815     * @param string|null       $successMessage
816     * @param string|null       $errorMessage
817     * @param bool              $connKeepAlive
818     * @param bool              $socketKeepAlive
819     * @param string|null       $scriptFilename = null
820     * @param string|null       $scriptName = null
821     * @param string|array|null $stdin          = null
822     * @param bool              $expectError
823     * @param int               $readLimit
824     * @param int               $writeDelay
825     *
826     * @return Response
827     * @throws \Exception
828     */
829    public function request(
830        string $query = '',
831        array $headers = [],
832        ?string $uri = null,
833        ?string $address = null,
834        ?string $successMessage = null,
835        ?string $errorMessage = null,
836        bool $connKeepAlive = false,
837        bool $socketKeepAlive = false,
838        ?string $scriptFilename = null,
839        ?string $scriptName = null,
840        string|array|null $stdin = null,
841        bool $expectError = false,
842        int $readLimit = -1,
843        int $writeDelay = 0,
844        ?string $method = null,
845        ?array $params = null,
846    ): Response {
847        if ($this->hasError()) {
848            return $this->createResponse(expectInvalid: true);
849        }
850
851        if (is_array($stdin)) {
852            $stdin = $this->parseStdin($stdin, $headers);
853        }
854
855        $params = $params ?? $this->getRequestParams($query, $headers, $uri, $scriptFilename, $scriptName, $stdin, $method);
856        $this->trace('Request params', $params);
857
858        try {
859            $this->response = $this->createResponse(
860                $this->getClient($address, $connKeepAlive, $socketKeepAlive)
861                    ->request_data($params, $stdin, $readLimit, $writeDelay)
862            );
863            if ($expectError) {
864                $this->error('Expected request error but the request was successful');
865            } else {
866                $this->message($successMessage);
867            }
868        } catch (\Exception $exception) {
869            if ($expectError) {
870                $this->message($successMessage);
871            } elseif ($errorMessage === null) {
872                $this->error("Request failed", $exception);
873            } else {
874                $this->message($errorMessage);
875            }
876            $this->response = $this->createResponse();
877        }
878        if ($this->debug) {
879            $this->response->debugOutput();
880        }
881
882        return $this->response;
883    }
884
885    /**
886     * Execute multiple requests in parallel.
887     *
888     * @param int|array   $requests
889     * @param string|null $address
890     * @param string|null $successMessage
891     * @param string|null $errorMessage
892     * @param bool        $socketKeepAlive
893     * @param bool        $connKeepAlive
894     * @param int         $readTimeout
895     * @param int         $writeDelay
896     *
897     * @return Response[]
898     * @throws \Exception
899     */
900    public function multiRequest(
901        int|array $requests,
902        ?string $address = null,
903        ?string $successMessage = null,
904        ?string $errorMessage = null,
905        bool $connKeepAlive = false,
906        bool $socketKeepAlive = false,
907        int $readTimeout = 0,
908        int $writeDelay = 0,
909    ) {
910        if (is_numeric($requests)) {
911            $requests = array_fill(0, $requests, []);
912        }
913
914        if ($this->hasError()) {
915            return array_map(fn($request) => $this->createResponse(expectInvalid: true), $requests);
916        }
917
918        try {
919            $connections = array_map(
920                function ($requestData) use ($address, $connKeepAlive, $socketKeepAlive, $writeDelay) {
921                    $client = $this->getClient($address, $connKeepAlive, $socketKeepAlive);
922                    $params = $this->getRequestParams(
923                        $requestData['query'] ?? '',
924                        $requestData['headers'] ?? [],
925                        $requestData['uri'] ?? null
926                    );
927                    $this->trace('Request params', $params);
928
929                    if (isset($requestData['delay'])) {
930                        usleep($requestData['delay']);
931                    }
932
933                    return [
934                        'client'    => $client,
935                        'requestId' => $client->async_request($params, false, $writeDelay),
936                    ];
937                },
938                $requests
939            );
940
941            $responses = array_map(function ($conn) use ($readTimeout) {
942                $response = $this->createResponse(
943                    $conn['client']->wait_for_response_data($conn['requestId'], $readTimeout)
944                );
945                if ($this->debug) {
946                    $response->debugOutput();
947                }
948
949                return $response;
950            }, $connections);
951            $this->message($successMessage);
952
953            return $responses;
954        } catch (\Exception $exception) {
955            if ($errorMessage === null) {
956                $this->error("Request failed", $exception);
957            } else {
958                $this->message($errorMessage);
959            }
960
961            return array_map(fn($request) => $this->createResponse(expectInvalid: true), $requests);
962        }
963    }
964
965    /**
966     * Execute request for getting FastCGI values.
967     *
968     * @param string|null $address
969     * @param bool        $connKeepAlive
970     * @param bool        $socketKeepAlive
971     *
972     * @return ValuesResponse
973     * @throws \Exception
974     */
975    public function requestValues(
976        ?string $address = null,
977        bool $connKeepAlive = false,
978        bool $socketKeepAlive = false
979    ): ValuesResponse {
980        if ($this->hasError()) {
981            return $this->createValueResponse();
982        }
983
984        try {
985            $valueResponse = $this->createValueResponse(
986                $this->getClient($address, $connKeepAlive)->getValues(['FCGI_MPXS_CONNS'])
987            );
988            if ($this->debug) {
989                $this->response->debugOutput();
990            }
991        } catch (\Exception $exception) {
992            $this->error("Request for getting values failed", $exception);
993            $valueResponse = $this->createValueResponse();
994        }
995
996        return $valueResponse;
997    }
998
999    /**
1000     * Get client.
1001     *
1002     * @param string|null $address
1003     * @param bool        $connKeepAlive
1004     * @param bool        $socketKeepAlive
1005     *
1006     * @return Client
1007     */
1008    private function getClient(
1009        ?string $address = null,
1010        bool $connKeepAlive = false,
1011        bool $socketKeepAlive = false
1012    ): Client {
1013        $address = $address ? $this->processTemplate($address) : $this->getAddr();
1014        if ($address[0] === '/') { // uds
1015            $host = 'unix://' . $address;
1016            $port = -1;
1017        } elseif ($address[0] === '[') { // ipv6
1018            $addressParts = explode(']:', $address);
1019            $host         = $addressParts[0];
1020            if (isset($addressParts[1])) {
1021                $host .= ']';
1022                $port = $addressParts[1];
1023            } else {
1024                $port = $this->getPort();
1025            }
1026        } else { // ipv4
1027            $addressParts = explode(':', $address);
1028            $host         = $addressParts[0];
1029            $port         = $addressParts[1] ?? $this->getPort();
1030        }
1031
1032        if ($socketKeepAlive) {
1033            $connKeepAlive = true;
1034        }
1035        if ( ! $connKeepAlive) {
1036            return new Client($host, $port, $this->createTransport());
1037        }
1038
1039        if ( ! isset($this->clients[$host][$port])) {
1040            $client = new Client($host, $port, $this->createTransport());
1041            $client->setKeepAlive($connKeepAlive, $socketKeepAlive);
1042            $this->clients[$host][$port] = $client;
1043        }
1044
1045        return $this->clients[$host][$port];
1046    }
1047
1048    /**
1049     * @return string
1050     */
1051    public function getUser()
1052    {
1053        return get_current_user();
1054    }
1055
1056    /**
1057     * @return string
1058     */
1059    public function getGroup()
1060    {
1061        return get_current_group();
1062    }
1063
1064    /**
1065     * @return int
1066     */
1067    public function getUid()
1068    {
1069        return getmyuid();
1070    }
1071
1072    /**
1073     * @return int
1074     */
1075    public function getGid()
1076    {
1077        return getmygid();
1078    }
1079
1080    /**
1081     * Reload FPM by sending USR2 signal and optionally change config before that.
1082     *
1083     * @param string|array $configTemplate
1084     *
1085     * @return string
1086     * @throws \Exception
1087     */
1088    public function reload($configTemplate = null)
1089    {
1090        if ( ! is_null($configTemplate)) {
1091            self::cleanConfigFiles();
1092            $this->configTemplate = $configTemplate;
1093            $this->createConfig();
1094        }
1095
1096        return $this->signal('USR2');
1097    }
1098
1099    /**
1100     * Reload FPM logs by sending USR1 signal.
1101     *
1102     * @return string
1103     * @throws \Exception
1104     */
1105    public function reloadLogs(): string
1106    {
1107        return $this->signal('USR1');
1108    }
1109
1110    /**
1111     * Send signal to the supplied PID or the server PID.
1112     *
1113     * @param string   $signal
1114     * @param int|null $pid
1115     *
1116     * @return string
1117     */
1118    public function signal($signal, ?int $pid = null)
1119    {
1120        if (is_null($pid)) {
1121            $pid = $this->getPid();
1122        }
1123        $cmd = "kill -$signal $pid";
1124        $this->trace('Sending signal using command', $cmd, true);
1125
1126        return exec("kill -$signal $pid");
1127    }
1128
1129    /**
1130     * Terminate master process
1131     */
1132    public function terminate()
1133    {
1134        if ($this->daemonized) {
1135            $this->signal('TERM');
1136        } else {
1137            proc_terminate($this->masterProcess);
1138        }
1139    }
1140
1141    /**
1142     * Close all open descriptors and process resources
1143     *
1144     * @param bool $terminate
1145     */
1146    public function close($terminate = false)
1147    {
1148        if ($terminate) {
1149            $this->terminate();
1150        }
1151        proc_close($this->masterProcess);
1152    }
1153
1154    /**
1155     * Create a config file.
1156     *
1157     * @param string $extension
1158     *
1159     * @return string
1160     * @throws \Exception
1161     */
1162    private function createConfig($extension = 'ini')
1163    {
1164        if (is_array($this->configTemplate)) {
1165            $configTemplates = $this->configTemplate;
1166            if ( ! isset($configTemplates['main'])) {
1167                throw new \Exception('The config template array has to have main config');
1168            }
1169            $mainTemplate = $configTemplates['main'];
1170            if ( ! is_dir(self::CONF_DIR)) {
1171                mkdir(self::CONF_DIR);
1172            }
1173            foreach ($this->createPoolConfigs($configTemplates) as $name => $poolConfig) {
1174                $this->makeFile(
1175                    'conf',
1176                    $this->processTemplate($poolConfig),
1177                    self::CONF_DIR,
1178                    $name
1179                );
1180            }
1181        } else {
1182            $mainTemplate = $this->configTemplate;
1183        }
1184
1185        return $this->makeFile($extension, $this->processTemplate($mainTemplate));
1186    }
1187
1188    /**
1189     * Create pool config templates.
1190     *
1191     * @param array $configTemplates
1192     *
1193     * @return array
1194     * @throws \Exception
1195     */
1196    private function createPoolConfigs(array $configTemplates)
1197    {
1198        if ( ! isset($configTemplates['poolTemplate'])) {
1199            unset($configTemplates['main']);
1200
1201            return $configTemplates;
1202        }
1203        $poolTemplate = $configTemplates['poolTemplate'];
1204        $configs      = [];
1205        if (isset($configTemplates['count'])) {
1206            $start = $configTemplates['start'] ?? 1;
1207            for ($i = $start; $i < $start + $configTemplates['count']; $i++) {
1208                $configs[$i] = str_replace('%index%', $i, $poolTemplate);
1209            }
1210        } elseif (isset($configTemplates['names'])) {
1211            foreach ($configTemplates['names'] as $name) {
1212                $configs[$name] = str_replace('%name%', $name, $poolTemplate);
1213            }
1214        } else {
1215            throw new \Exception('The config template requires count or names if poolTemplate set');
1216        }
1217
1218        return $configs;
1219    }
1220
1221    /**
1222     * Process template string.
1223     *
1224     * @param string $template
1225     *
1226     * @return string
1227     */
1228    private function processTemplate(string $template)
1229    {
1230        $vars    = [
1231            'FILE:LOG:ACC'   => ['getAbsoluteFile', self::FILE_EXT_LOG_ACC],
1232            'FILE:LOG:ERR'   => ['getAbsoluteFile', self::FILE_EXT_LOG_ERR],
1233            'FILE:LOG:SLOW'  => ['getAbsoluteFile', self::FILE_EXT_LOG_SLOW],
1234            'FILE:PID'       => ['getAbsoluteFile', self::FILE_EXT_PID],
1235            'RFILE:LOG:ACC'  => ['getRelativeFile', self::FILE_EXT_LOG_ACC],
1236            'RFILE:LOG:ERR'  => ['getRelativeFile', self::FILE_EXT_LOG_ERR],
1237            'RFILE:LOG:SLOW' => ['getRelativeFile', self::FILE_EXT_LOG_SLOW],
1238            'RFILE:PID'      => ['getRelativeFile', self::FILE_EXT_PID],
1239            'ADDR:IPv4'      => ['getAddr', 'ipv4'],
1240            'ADDR:IPv4:ANY'  => ['getAddr', 'ipv4-any'],
1241            'ADDR:IPv6'      => ['getAddr', 'ipv6'],
1242            'ADDR:IPv6:ANY'  => ['getAddr', 'ipv6-any'],
1243            'ADDR:UDS'       => ['getAddr', 'uds'],
1244            'PORT'           => ['getPort', 'ip'],
1245            'INCLUDE:CONF'   => self::CONF_DIR . '/*.conf',
1246            'USER'           => ['getUser'],
1247            'GROUP'          => ['getGroup'],
1248            'UID'            => ['getUid'],
1249            'GID'            => ['getGid'],
1250            'MASTER:OUT'     => 'pipe:1',
1251            'STDERR'         => '/dev/stderr',
1252            'STDOUT'         => '/dev/stdout',
1253        ];
1254        $aliases = [
1255            'ADDR'     => 'ADDR:IPv4',
1256            'FILE:LOG' => 'FILE:LOG:ERR',
1257        ];
1258        foreach ($aliases as $aliasName => $aliasValue) {
1259            $vars[$aliasName] = $vars[$aliasValue];
1260        }
1261
1262        return preg_replace_callback(
1263            '/{{([a-zA-Z0-9:]+)(\[\w+\])?}}/',
1264            function ($matches) use ($vars) {
1265                $varName = $matches[1];
1266                if ( ! isset($vars[$varName])) {
1267                    $this->error("Invalid config variable $varName");
1268
1269                    return 'INVALID';
1270                }
1271                $pool     = $matches[2] ?? 'default';
1272                $varValue = $vars[$varName];
1273                if (is_string($varValue)) {
1274                    return $varValue;
1275                }
1276                $functionName = array_shift($varValue);
1277                $varValue[]   = $pool;
1278
1279                return call_user_func_array([$this, $functionName], $varValue);
1280            },
1281            $template
1282        );
1283    }
1284
1285    /**
1286     * @param string $type
1287     * @param string $pool
1288     *
1289     * @return string
1290     */
1291    public function getAddr(string $type = 'ipv4', $pool = 'default')
1292    {
1293        $port = $this->getPort($type, $pool, true);
1294        if ($type === 'uds') {
1295            $address = $this->getFile($port . '.sock');
1296
1297            // Socket max path length is 108 on Linux and 104 on BSD,
1298            // so we use the latter
1299            if (strlen($address) <= 104) {
1300                return $address;
1301            }
1302
1303            $addressPart = hash('crc32', dirname($address)) . '-' . basename($address);
1304
1305            // is longer on Mac, than on Linux
1306            $tmpDirAddress = sys_get_temp_dir() . '/' . $addressPart;
1307                   ;
1308
1309            if (strlen($tmpDirAddress) <= 104) {
1310                return $tmpDirAddress;
1311            }
1312
1313            $srcRootAddress = dirname(__DIR__, 3) . '/' . $addressPart;
1314
1315            return $srcRootAddress;
1316        }
1317
1318        return $this->getHost($type) . ':' . $port;
1319    }
1320
1321    /**
1322     * @param string $type
1323     * @param string $pool
1324     * @param bool   $useAsId
1325     *
1326     * @return int
1327     */
1328    public function getPort(string $type = 'ip', $pool = 'default', $useAsId = false)
1329    {
1330        if ($type === 'uds' && ! $useAsId) {
1331            return -1;
1332        }
1333
1334        if (isset($this->ports['values'][$pool])) {
1335            return $this->ports['values'][$pool];
1336        }
1337        $port                         = ($this->ports['last'] ?? 9000 + PHP_INT_SIZE - 1) + 1;
1338        $this->ports['values'][$pool] = $this->ports['last'] = $port;
1339
1340        return $port;
1341    }
1342
1343    /**
1344     * @param string $type
1345     *
1346     * @return string
1347     */
1348    public function getHost(string $type = 'ipv4')
1349    {
1350        switch ($type) {
1351            case 'ipv6-any':
1352                return '[::]';
1353            case 'ipv6':
1354                return '[::1]';
1355            case 'ipv4-any':
1356                return '0.0.0.0';
1357            default:
1358                return '127.0.0.1';
1359        }
1360    }
1361
1362    /**
1363     * Get listen address.
1364     *
1365     * @param string|null $template
1366     *
1367     * @return string
1368     */
1369    public function getListen($template = null)
1370    {
1371        return $template ? $this->processTemplate($template) : $this->getAddr();
1372    }
1373
1374    /**
1375     * Get PID.
1376     *
1377     * @return int
1378     */
1379    public function getPid()
1380    {
1381        $pidFile = $this->getFile('pid');
1382        if ( ! is_file($pidFile)) {
1383            return (int)$this->error("PID file has not been created");
1384        }
1385        $pidContent = file_get_contents($pidFile);
1386        if ( ! is_numeric($pidContent)) {
1387            return (int)$this->error("PID content '$pidContent' is not integer");
1388        }
1389        $this->trace('PID found', $pidContent);
1390
1391        return (int)$pidContent;
1392    }
1393
1394
1395    /**
1396     * Get file path for resource file.
1397     *
1398     * @param string      $extension
1399     * @param string|null $dir
1400     * @param string|null $name
1401     *
1402     * @return string
1403     */
1404    private function getFile(string $extension, ?string $dir = null, ?string $name = null): string
1405    {
1406        $fileName = (is_null($name) ? $this->fileName : $name . '.') . $extension;
1407
1408        return is_null($dir) ? $fileName : $dir . '/' . $fileName;
1409    }
1410
1411    /**
1412     * Get absolute file path for the resource file used by templates.
1413     *
1414     * @param string $extension
1415     *
1416     * @return string
1417     */
1418    private function getAbsoluteFile(string $extension): string
1419    {
1420        return $this->getFile($extension);
1421    }
1422
1423    /**
1424     * Get relative file name for resource file used by templates.
1425     *
1426     * @param string $extension
1427     *
1428     * @return string
1429     */
1430    private function getRelativeFile(string $extension): string
1431    {
1432        $fileName = rtrim(basename($this->fileName), '.');
1433
1434        return $this->getFile($extension, null, $fileName);
1435    }
1436
1437    /**
1438     * Get prefixed file.
1439     *
1440     * @param string      $extension
1441     * @param string|null $prefix
1442     *
1443     * @return string
1444     */
1445    public function getPrefixedFile(string $extension, ?string $prefix = null): string
1446    {
1447        $fileName = rtrim($this->fileName, '.');
1448        if ( ! is_null($prefix)) {
1449            $fileName = $prefix . '/' . basename($fileName);
1450        }
1451
1452        return $this->getFile($extension, null, $fileName);
1453    }
1454
1455    /**
1456     * Create a resource file.
1457     *
1458     * @param string      $extension
1459     * @param string      $content
1460     * @param string|null $dir
1461     * @param string|null $name
1462     *
1463     * @return string
1464     */
1465    private function makeFile(
1466        string $extension,
1467        string $content = '',
1468        ?string $dir = null,
1469        ?string $name = null,
1470        bool $overwrite = true
1471    ): string {
1472        $filePath = $this->getFile($extension, $dir, $name);
1473        if ( ! $overwrite && is_file($filePath)) {
1474            return $filePath;
1475        }
1476        file_put_contents($filePath, $content);
1477
1478        $this->trace('Created file: ' . $filePath, $content, isFile: true);
1479
1480        return $filePath;
1481    }
1482
1483    /**
1484     * Create a source code file.
1485     *
1486     * @return string
1487     */
1488    public function makeSourceFile(): string
1489    {
1490        return $this->makeFile('src.php', $this->code, overwrite: false);
1491    }
1492
1493    /**
1494     * Create a source file and script name.
1495     *
1496     * @return string[]
1497     */
1498    public function createSourceFileAndScriptName(): array
1499    {
1500        $sourceFile = $this->makeFile('src.php', $this->code, overwrite: false);
1501
1502        return [$sourceFile, '/' . basename($sourceFile)];
1503    }
1504
1505    /**
1506     * Create a new response.
1507     *
1508     * @param mixed $data
1509     * @param bool  $expectInvalid
1510     * @return Response
1511     */
1512    private function createResponse($data = null, bool $expectInvalid = false): Response
1513    {
1514        return new Response($this, $data, $expectInvalid);
1515    }
1516
1517    /**
1518     * Create a new values response.
1519     *
1520     * @param mixed $values
1521     * @return ValuesResponse
1522     * @throws \Exception
1523     */
1524    private function createValueResponse($values = null): ValuesResponse
1525    {
1526        return new ValuesResponse($this, $values);
1527    }
1528
1529    /**
1530     * @param string|null $msg
1531     */
1532    private function message($msg)
1533    {
1534        if ($msg !== null) {
1535            echo "$msg\n";
1536        }
1537    }
1538
1539    /**
1540     * Print log reader logs.
1541     *
1542     * @return void
1543     */
1544    public function printLogs(): void
1545    {
1546        $this->logReader->printLogs();
1547    }
1548
1549    /**
1550     * Display error.
1551     *
1552     * @param string          $msg       Error message.
1553     * @param \Exception|null $exception If there is an exception, log its message
1554     * @param bool            $prefix    Whether to prefix the error message
1555     *
1556     * @return false
1557     */
1558    private function error(string $msg, ?\Exception $exception = null, bool $prefix = true): bool
1559    {
1560        $this->error = $prefix ? 'ERROR: ' . $msg : ltrim($msg);
1561        if ($exception) {
1562            $this->error .= '; EXCEPTION: ' . $exception->getMessage();
1563        }
1564        $this->error .= "\n";
1565
1566        echo $this->error;
1567        $this->printLogs();
1568
1569        return false;
1570    }
1571
1572    /**
1573     * Check whether any error was set.
1574     *
1575     * @return bool
1576     */
1577    private function hasError()
1578    {
1579        return ! is_null($this->error) || ! is_null($this->logTool->getError());
1580    }
1581
1582    /**
1583     * Expect file with a supplied extension to exist.
1584     *
1585     * @param string $extension
1586     * @param string $prefix
1587     *
1588     * @return bool
1589     */
1590    public function expectFile(string $extension, $prefix = null)
1591    {
1592        $filePath = $this->getPrefixedFile($extension, $prefix);
1593        if ( ! file_exists($filePath)) {
1594            return $this->error("The file $filePath does not exist");
1595        }
1596        $this->trace('File path exists as expected', $filePath);
1597
1598        return true;
1599    }
1600
1601    /**
1602     * Expect file with a supplied extension to not exist.
1603     *
1604     * @param string $extension
1605     * @param string $prefix
1606     *
1607     * @return bool
1608     */
1609    public function expectNoFile(string $extension, $prefix = null)
1610    {
1611        $filePath = $this->getPrefixedFile($extension, $prefix);
1612        if (file_exists($filePath)) {
1613            return $this->error("The file $filePath exists");
1614        }
1615        $this->trace('File path does not exist as expected', $filePath);
1616
1617        return true;
1618    }
1619
1620    /**
1621     * Expect message to be written to FastCGI error stream.
1622     *
1623     * @param string $message
1624     * @param int    $limit
1625     * @param int    $repeat
1626     */
1627    public function expectFastCGIErrorMessage(
1628        string $message,
1629        int $limit = 1024,
1630        int $repeat = 0
1631    ) {
1632        $this->logTool->setExpectedMessage($message, $limit, $repeat);
1633        $this->logTool->checkTruncatedMessage($this->response->getErrorData());
1634    }
1635
1636    /**
1637     * Expect log to be empty.
1638     *
1639     * @throws \Exception
1640     */
1641    public function expectLogEmpty()
1642    {
1643        try {
1644            $line = $this->logReader->getLine(1, 0, true);
1645            if ($line === '') {
1646                $line = $this->logReader->getLine(1, 0, true);
1647            }
1648            if ($line !== null) {
1649                $this->error('Log is not closed and returned line: ' . $line);
1650            }
1651        } catch (LogTimoutException $exception) {
1652            $this->error('Log is not closed and timed out', $exception);
1653        }
1654    }
1655
1656    /**
1657     * Expect reloading lines to be logged.
1658     *
1659     * @param int  $socketCount
1660     * @param bool $expectInitialProgressMessage
1661     * @param bool $expectReloadingMessage
1662     *
1663     * @throws \Exception
1664     */
1665    public function expectLogReloadingNotices(
1666        int $socketCount = 1,
1667        bool $expectInitialProgressMessage = true,
1668        bool $expectReloadingMessage = true
1669    ) {
1670        $this->logTool->expectReloadingLines(
1671            $socketCount,
1672            $expectInitialProgressMessage,
1673            $expectReloadingMessage
1674        );
1675    }
1676
1677    /**
1678     * Expect reloading lines to be logged.
1679     *
1680     * @throws \Exception
1681     */
1682    public function expectLogReloadingLogsNotices()
1683    {
1684        $this->logTool->expectReloadingLogsLines();
1685    }
1686
1687    /**
1688     * Expect starting lines to be logged.
1689     * @throws \Exception
1690     */
1691    public function expectLogStartNotices()
1692    {
1693        $this->logTool->expectStartingLines();
1694    }
1695
1696    /**
1697     * Expect terminating lines to be logged.
1698     * @throws \Exception
1699     */
1700    public function expectLogTerminatingNotices()
1701    {
1702        $this->logTool->expectTerminatorLines();
1703    }
1704
1705    /**
1706     * Expect log pattern in logs.
1707     *
1708     * @param string   $pattern             Log pattern
1709     * @param bool     $checkAllLogs        Whether to also check past logs.
1710     * @param int|null $timeoutSeconds      Timeout in seconds for reading of all messages.
1711     * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages.
1712     *
1713     * @throws \Exception
1714     */
1715    public function expectLogPattern(
1716        string $pattern,
1717        bool $checkAllLogs = false,
1718        ?int $timeoutSeconds = null,
1719        ?int $timeoutMicroseconds = null,
1720    ) {
1721        $this->logTool->expectPattern(
1722            $pattern,
1723            false,
1724            $checkAllLogs,
1725            $timeoutSeconds,
1726            $timeoutMicroseconds
1727        );
1728    }
1729
1730    /**
1731     * Expect no such log pattern in logs.
1732     *
1733     * @param string   $pattern             Log pattern
1734     * @param bool     $checkAllLogs        Whether to also check past logs.
1735     * @param int|null $timeoutSeconds      Timeout in seconds for reading of all messages.
1736     * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages.
1737     *
1738     * @throws \Exception
1739     */
1740    public function expectNoLogPattern(
1741        string $pattern,
1742        bool $checkAllLogs = true,
1743        ?int $timeoutSeconds = null,
1744        ?int $timeoutMicroseconds = null,
1745    ) {
1746        if (is_null($timeoutSeconds) && is_null($timeoutMicroseconds)) {
1747            $timeoutMicroseconds = 10;
1748        }
1749        $this->logTool->expectPattern(
1750            $pattern,
1751            true,
1752            $checkAllLogs,
1753            $timeoutSeconds,
1754            $timeoutMicroseconds
1755        );
1756    }
1757
1758    /**
1759     * Expect log message that can span multiple lines.
1760     *
1761     * @param string $message
1762     * @param int    $limit
1763     * @param int    $repeat
1764     * @param bool   $decorated
1765     * @param bool   $wrapped
1766     *
1767     * @throws \Exception
1768     */
1769    public function expectLogMessage(
1770        string $message,
1771        int $limit = 1024,
1772        int $repeat = 0,
1773        bool $decorated = true,
1774        bool $wrapped = true
1775    ) {
1776        $this->logTool->setExpectedMessage($message, $limit, $repeat);
1777        if ($wrapped) {
1778            $this->logTool->checkWrappedMessage(true, $decorated);
1779        } else {
1780            $this->logTool->checkTruncatedMessage();
1781        }
1782    }
1783
1784    /**
1785     * Expect a single log line.
1786     *
1787     * @param string $message   The expected message.
1788     * @param bool   $isStdErr  Whether it is logged to stderr.
1789     * @param bool   $decorated Whether the log lines are decorated.
1790     *
1791     * @return bool
1792     * @throws \Exception
1793     */
1794    public function expectLogLine(
1795        string $message,
1796        bool $isStdErr = true,
1797        bool $decorated = true
1798    ): bool {
1799        $messageLen = strlen($message);
1800        $limit      = $messageLen > 1024 ? $messageLen + 16 : 1024;
1801        $this->logTool->setExpectedMessage($message, $limit);
1802
1803        return $this->logTool->checkWrappedMessage(false, $decorated, $isStdErr);
1804    }
1805
1806    /**
1807     * Expect log entry.
1808     *
1809     * @param string      $type                The log type.
1810     * @param string      $message             The expected message.
1811     * @param string|null $pool                The pool for pool prefixed log entry.
1812     * @param int         $count               The number of items.
1813     * @param bool        $checkAllLogs        Whether to also check past logs.
1814     * @param bool        $invert              Whether the log entry is not expected rather than expected.
1815     * @param int|null    $timeoutSeconds      Timeout in seconds for reading of all messages.
1816     * @param int|null    $timeoutMicroseconds Additional timeout in microseconds for reading of all messages.
1817     * @param string      $ignoreErrorFor      Ignore error for supplied string in the message.
1818     *
1819     * @return bool
1820     * @throws \Exception
1821     */
1822    private function expectLogEntry(
1823        string $type,
1824        string $message,
1825        ?string $pool = null,
1826        int $count = 1,
1827        bool $checkAllLogs = false,
1828        bool $invert = false,
1829        ?int $timeoutSeconds = null,
1830        ?int $timeoutMicroseconds = null,
1831        string $ignoreErrorFor = LogTool::DEBUG
1832    ): bool {
1833        for ($i = 0; $i < $count; $i++) {
1834            $result = $this->logTool->expectEntry(
1835                $type,
1836                $message,
1837                $pool,
1838                $ignoreErrorFor,
1839                $checkAllLogs,
1840                $invert,
1841                $timeoutSeconds,
1842                $timeoutMicroseconds,
1843            );
1844
1845            if ( ! $result) {
1846                return false;
1847            }
1848        }
1849
1850        return true;
1851    }
1852
1853    /**
1854     * Expect a log debug message.
1855     *
1856     * @param string      $message             The expected message.
1857     * @param string|null $pool                The pool for pool prefixed log entry.
1858     * @param int         $count               The number of items.
1859     * @param bool        $checkAllLogs        Whether to also check past logs.
1860     * @param bool        $invert              Whether the log entry is not expected rather than expected.
1861     * @param int|null    $timeoutSeconds      Timeout in seconds for reading of all messages.
1862     * @param int|null    $timeoutMicroseconds Additional timeout in microseconds for reading of all messages.
1863     *
1864     * @return bool
1865     * @throws \Exception
1866     */
1867    public function expectLogDebug(
1868        string $message,
1869        ?string $pool = null,
1870        int $count = 1,
1871        bool $checkAllLogs = false,
1872        bool $invert = false,
1873        ?int $timeoutSeconds = null,
1874        ?int $timeoutMicroseconds = null
1875    ): bool {
1876        return $this->expectLogEntry(
1877            LogTool::DEBUG,
1878            $message,
1879            $pool,
1880            $count,
1881            $checkAllLogs,
1882            $invert,
1883            $timeoutSeconds,
1884            $timeoutMicroseconds,
1885            LogTool::ERROR
1886        );
1887    }
1888
1889    /**
1890     * Expect a log notice.
1891     *
1892     * @param string      $message             The expected message.
1893     * @param string|null $pool                The pool for pool prefixed log entry.
1894     * @param int         $count               The number of items.
1895     * @param bool        $checkAllLogs        Whether to also check past logs.
1896     * @param bool        $invert              Whether the log entry is not expected rather than expected.
1897     * @param int|null    $timeoutSeconds      Timeout in seconds for reading of all messages.
1898     * @param int|null    $timeoutMicroseconds Additional timeout in microseconds for reading of all messages.
1899     *
1900     * @return bool
1901     * @throws \Exception
1902     */
1903    public function expectLogNotice(
1904        string $message,
1905        ?string $pool = null,
1906        int $count = 1,
1907        bool $checkAllLogs = false,
1908        bool $invert = false,
1909        ?int $timeoutSeconds = null,
1910        ?int $timeoutMicroseconds = null
1911    ): bool {
1912        return $this->expectLogEntry(
1913            LogTool::NOTICE,
1914            $message,
1915            $pool,
1916            $count,
1917            $checkAllLogs,
1918            $invert,
1919            $timeoutSeconds,
1920            $timeoutMicroseconds
1921        );
1922    }
1923
1924    /**
1925     * Expect a log warning.
1926     *
1927     * @param string      $message             The expected message.
1928     * @param string|null $pool                The pool for pool prefixed log entry.
1929     * @param int         $count               The number of items.
1930     * @param bool        $checkAllLogs        Whether to also check past logs.
1931     * @param bool        $invert              Whether the log entry is not expected rather than expected.
1932     * @param int|null    $timeoutSeconds      Timeout in seconds for reading of all messages.
1933     * @param int|null    $timeoutMicroseconds Additional timeout in microseconds for reading of all messages.
1934     *
1935     * @return bool
1936     * @throws \Exception
1937     */
1938    public function expectLogWarning(
1939        string $message,
1940        ?string $pool = null,
1941        int $count = 1,
1942        bool $checkAllLogs = false,
1943        bool $invert = false,
1944        ?int $timeoutSeconds = null,
1945        ?int $timeoutMicroseconds = null
1946    ): bool {
1947        return $this->expectLogEntry(
1948            LogTool::WARNING,
1949            $message,
1950            $pool,
1951            $count,
1952            $checkAllLogs,
1953            $invert,
1954            $timeoutSeconds,
1955            $timeoutMicroseconds
1956        );
1957    }
1958
1959    /**
1960     * Expect a log error.
1961     *
1962     * @param string      $message             The expected message.
1963     * @param string|null $pool                The pool for pool prefixed log entry.
1964     * @param int         $count               The number of items.
1965     * @param bool        $checkAllLogs        Whether to also check past logs.
1966     * @param bool        $invert              Whether the log entry is not expected rather than expected.
1967     * @param int|null    $timeoutSeconds      Timeout in seconds for reading of all messages.
1968     * @param int|null    $timeoutMicroseconds Additional timeout in microseconds for reading of all messages.
1969     *
1970     * @return bool
1971     * @throws \Exception
1972     */
1973    public function expectLogError(
1974        string $message,
1975        ?string $pool = null,
1976        int $count = 1,
1977        bool $checkAllLogs = false,
1978        bool $invert = false,
1979        ?int $timeoutSeconds = null,
1980        ?int $timeoutMicroseconds = null
1981    ): bool {
1982        return $this->expectLogEntry(
1983            LogTool::ERROR,
1984            $message,
1985            $pool,
1986            $count,
1987            $checkAllLogs,
1988            $invert,
1989            $timeoutSeconds,
1990            $timeoutMicroseconds
1991        );
1992    }
1993
1994    /**
1995     * Expect a log alert.
1996     *
1997     * @param string      $message             The expected message.
1998     * @param string|null $pool                The pool for pool prefixed log entry.
1999     * @param int         $count               The number of items.
2000     * @param bool        $checkAllLogs        Whether to also check past logs.
2001     * @param bool        $invert              Whether the log entry is not expected rather than expected.
2002     * @param int|null    $timeoutSeconds      Timeout in seconds for reading of all messages.
2003     * @param int|null    $timeoutMicroseconds Additional timeout in microseconds for reading of all messages.
2004     *
2005     * @return bool
2006     * @throws \Exception
2007     */
2008    public function expectLogAlert(
2009        string $message,
2010        ?string $pool = null,
2011        int $count = 1,
2012        bool $checkAllLogs = false,
2013        bool $invert = false,
2014        ?int $timeoutSeconds = null,
2015        ?int $timeoutMicroseconds = null
2016    ): bool {
2017        return $this->expectLogEntry(
2018            LogTool::ALERT,
2019            $message,
2020            $pool,
2021            $count,
2022            $checkAllLogs,
2023            $invert,
2024            $timeoutSeconds,
2025            $timeoutMicroseconds
2026        );
2027    }
2028
2029    /**
2030     * Expect no log lines to be logged.
2031     *
2032     * @return bool
2033     * @throws \Exception
2034     */
2035    public function expectNoLogMessages(): bool
2036    {
2037        $logLine = $this->logReader->getLine(timeoutSeconds: 0, timeoutMicroseconds: 1000);
2038        if ($logLine === "") {
2039            $logLine = $this->logReader->getLine(timeoutSeconds: 0, timeoutMicroseconds: 1000);
2040        }
2041        if ($logLine !== null) {
2042            return $this->error(
2043                "Expected no log lines but following line logged: $logLine"
2044            );
2045        }
2046        $this->trace('No log message received as expected');
2047
2048        return true;
2049    }
2050
2051    /**
2052     * Expect log config options
2053     *
2054     * @param array $options
2055     *
2056     * @return bool
2057     * @throws \Exception
2058     */
2059    public function expectLogConfigOptions(array $options)
2060    {
2061        foreach ($options as $value) {
2062            $confValue = str_replace(
2063                ']',
2064                '\]',
2065                str_replace(
2066                    '[',
2067                    '\[',
2068                    str_replace('/', '\/', $value)
2069                )
2070            );
2071            $this->expectLogNotice("\s+$confValue", checkAllLogs: true);
2072        }
2073
2074        return true;
2075    }
2076
2077
2078    /**
2079     * Print content of access log.
2080     */
2081    public function printAccessLog()
2082    {
2083        $accessLog = $this->getFile('acc.log');
2084        if (is_file($accessLog)) {
2085            print file_get_contents($accessLog);
2086        }
2087    }
2088
2089    /**
2090     * Return content of access log.
2091     *
2092     * @return string|false
2093     */
2094    public function getAccessLog()
2095    {
2096        $accessLog = $this->getFile('acc.log');
2097        if (is_file($accessLog)) {
2098            return file_get_contents($accessLog);
2099        }
2100        return false;
2101    }
2102
2103    /**
2104     * Expect a single access log line.
2105     *
2106     * @param string $LogLine
2107     * @param bool $suppressable see expectSuppressableAccessLogEntries
2108     */
2109    public function expectAccessLog(
2110        string $logLine,
2111        bool $suppressable = false
2112    ) {
2113        if (!$suppressable || $this->expectSuppressableAccessLogEntries) {
2114            $this->expectedAccessLogs[] = $logLine;
2115        }
2116    }
2117
2118    /**
2119     * Checks that all access log entries previously listed as expected by
2120     * calling "expectAccessLog" are in the access log.
2121     */
2122    public function checkAccessLog()
2123    {
2124        if (isset($this->expectedAccessLogs)) {
2125            $expectedAccessLog = implode("\n", $this->expectedAccessLogs) . "\n";
2126        } else {
2127            $this->error("Called checkAccessLog but did not previous call expectAccessLog");
2128        }
2129        if ($accessLog = $this->getAccessLog()) {
2130            if ($expectedAccessLog !== $accessLog) {
2131                $this->error(sprintf(
2132                    "Access log was not as expected.\nEXPECTED:\n%s\n\nACTUAL:\n%s",
2133                    $expectedAccessLog,
2134                    $accessLog
2135                ));
2136            }
2137        } else {
2138            $this->error("Called checkAccessLog but access log does not exist");
2139        }
2140    }
2141
2142    /**
2143     * Flags whether the access log check should expect to see suppressable
2144     * log entries, i.e. the URL is not in access.suppress_path[] config
2145     *
2146     * @param bool
2147     */
2148    public function expectSuppressableAccessLogEntries(bool $expectSuppressableAccessLogEntries)
2149    {
2150        $this->expectSuppressableAccessLogEntries = $expectSuppressableAccessLogEntries;
2151    }
2152
2153    /*
2154     * Read all log entries.
2155     *
2156     * @param string      $type    The log type
2157     * @param string      $message The expected message
2158     * @param string|null $pool    The pool for pool prefixed log entry
2159     *
2160     * @return bool
2161     * @throws \Exception
2162     */
2163    public function readAllLogEntries(string $type, string $message, ?string $pool = null): bool
2164    {
2165        return $this->logTool->readAllEntries($type, $message, $pool);
2166    }
2167
2168    /**
2169     * Read all log entries.
2170     *
2171     * @param string      $message The expected message
2172     * @param string|null $pool    The pool for pool prefixed log entry
2173     *
2174     * @return bool
2175     * @throws \Exception
2176     */
2177    public function readAllLogNotices(string $message, ?string $pool = null): bool
2178    {
2179        return $this->readAllLogEntries(LogTool::NOTICE, $message, $pool);
2180    }
2181
2182    /**
2183     * Switch the logs source.
2184     *
2185     * @param string $source The source file path or name if log is a pipe.
2186     *
2187     * @throws \Exception
2188     */
2189    public function switchLogSource(string $source)
2190    {
2191        $this->trace('Switching log descriptor to:', $source);
2192        $this->logReader->setFileSource($source, $this->processTemplate($source));
2193    }
2194
2195    /**
2196     * Trace execution by printing supplied message only in debug mode.
2197     *
2198     * @param string            $title     Trace title to print if supplied.
2199     * @param string|array|null $message   Message to print.
2200     * @param bool              $isCommand Whether message is a command array.
2201     */
2202    private function trace(
2203        string $title,
2204        string|array|null $message = null,
2205        bool $isCommand = false,
2206        bool $isFile = false
2207    ): void {
2208        if ($this->debug) {
2209            echo "\n";
2210            echo ">>> $title\n";
2211            if (is_array($message)) {
2212                if ($isCommand) {
2213                    echo implode(' ', $message) . "\n";
2214                } else {
2215                    print_r($message);
2216                }
2217            } elseif ($message !== null) {
2218                if ($isFile) {
2219                    $this->logReader->printSeparator();
2220                }
2221                echo $message . "\n";
2222                if ($isFile) {
2223                    $this->logReader->printSeparator();
2224                }
2225            }
2226        }
2227    }
2228}
2229