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