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