xref: /PHP-8.0/sapi/fpm/tests/tester.inc (revision 716de0cf)
1<?php
2
3namespace FPM;
4
5use Adoy\FastCGI\Client;
6
7require_once 'fcgi.inc';
8require_once 'logreader.inc';
9require_once 'logtool.inc';
10require_once 'response.inc';
11
12class Tester
13{
14    /**
15     * Config directory for included files.
16     */
17    const CONF_DIR = __DIR__ . '/conf.d';
18
19    /**
20     * File extension for access log.
21     */
22    const FILE_EXT_LOG_ACC = 'acc.log';
23
24    /**
25     * File extension for error log.
26     */
27    const FILE_EXT_LOG_ERR = 'err.log';
28
29    /**
30     * File extension for slow log.
31     */
32    const FILE_EXT_LOG_SLOW = 'slow.log';
33
34    /**
35     * File extension for PID file.
36     */
37    const FILE_EXT_PID = 'pid';
38
39    /**
40     * @var array
41     */
42    static private array $supportedFiles = [
43        self::FILE_EXT_LOG_ACC,
44        self::FILE_EXT_LOG_ERR,
45        self::FILE_EXT_LOG_SLOW,
46        self::FILE_EXT_PID,
47        'src.php',
48        'ini',
49        'skip.ini',
50        '*.sock',
51    ];
52
53    /**
54     * @var array
55     */
56    static private array $filesToClean = ['.user.ini'];
57
58    /**
59     * @var bool
60     */
61    private bool $debug;
62
63    /**
64     * @var array
65     */
66    private array $clients = [];
67
68    /**
69     * @var LogReader
70     */
71    private LogReader $logReader;
72
73    /**
74     * @var LogTool
75     */
76    private LogTool $logTool;
77
78    /**
79     * Configuration template
80     *
81     * @var string|array
82     */
83    private string|array $configTemplate;
84
85    /**
86     * The PHP code to execute
87     *
88     * @var string
89     */
90    private string $code;
91
92    /**
93     * @var array
94     */
95    private array $options;
96
97    /**
98     * @var string
99     */
100    private string $fileName;
101
102    /**
103     * @var resource
104     */
105    private $masterProcess;
106
107    /**
108     * @var bool
109     */
110    private bool $daemonized;
111
112    /**
113     * @var resource
114     */
115    private $outDesc;
116
117    /**
118     * @var array
119     */
120    private array $ports = [];
121
122    /**
123     * @var string|null
124     */
125    private ?string $error = null;
126
127    /**
128     * The last response for the request call
129     *
130     * @var Response|null
131     */
132    private ?Response $response;
133
134    /**
135     * Clean all the created files up
136     *
137     * @param int $backTraceIndex
138     */
139    static public function clean($backTraceIndex = 1)
140    {
141        $filePrefix = self::getCallerFileName($backTraceIndex);
142        if (str_ends_with($filePrefix, 'clean.')) {
143            $filePrefix = substr($filePrefix, 0, -6);
144        }
145
146        $filesToClean = array_merge(
147            array_map(
148                function ($fileExtension) use ($filePrefix) {
149                    return $filePrefix . $fileExtension;
150                },
151                self::$supportedFiles
152            ),
153            array_map(
154                function ($fileExtension) {
155                    return __DIR__ . '/' . $fileExtension;
156                },
157                self::$filesToClean
158            )
159        );
160        // clean all the root files
161        foreach ($filesToClean as $filePattern) {
162            foreach (glob($filePattern) as $filePath) {
163                unlink($filePath);
164            }
165        }
166
167        self::cleanConfigFiles();
168    }
169
170    /**
171     * Clean config files
172     */
173    static public function cleanConfigFiles()
174    {
175        if (is_dir(self::CONF_DIR)) {
176            foreach (glob(self::CONF_DIR . '/*.conf') as $name) {
177                unlink($name);
178            }
179            rmdir(self::CONF_DIR);
180        }
181    }
182
183    /**
184     * @param int $backTraceIndex
185     *
186     * @return string
187     */
188    static private function getCallerFileName(int $backTraceIndex = 1): string
189    {
190        $backtrace = debug_backtrace();
191        if (isset($backtrace[$backTraceIndex]['file'])) {
192            $filePath = $backtrace[$backTraceIndex]['file'];
193        } else {
194            $filePath = __FILE__;
195        }
196
197        return substr($filePath, 0, -strlen(pathinfo($filePath, PATHINFO_EXTENSION)));
198    }
199
200    /**
201     * @return bool|string
202     */
203    static public function findExecutable(): bool|string
204    {
205        $phpPath = getenv("TEST_PHP_EXECUTABLE");
206        for ($i = 0; $i < 2; $i++) {
207            $slashPosition = strrpos($phpPath, "/");
208            if ($slashPosition) {
209                $phpPath = substr($phpPath, 0, $slashPosition);
210            } else {
211                break;
212            }
213        }
214
215        if ($phpPath && is_dir($phpPath)) {
216            if (file_exists($phpPath . "/fpm/php-fpm") && is_executable($phpPath . "/fpm/php-fpm")) {
217                /* gotcha */
218                return $phpPath . "/fpm/php-fpm";
219            }
220            $phpSbinFpmi = $phpPath . "/sbin/php-fpm";
221            if (file_exists($phpSbinFpmi) && is_executable($phpSbinFpmi)) {
222                return $phpSbinFpmi;
223            }
224        }
225
226        // try local php-fpm
227        $fpmPath = dirname(__DIR__) . '/php-fpm';
228        if (file_exists($fpmPath) && is_executable($fpmPath)) {
229            return $fpmPath;
230        }
231
232        return false;
233    }
234
235    /**
236     * Skip test if any of the supplied files does not exist.
237     *
238     * @param mixed $files
239     */
240    static public function skipIfAnyFileDoesNotExist($files)
241    {
242        if ( ! is_array($files)) {
243            $files = array($files);
244        }
245        foreach ($files as $file) {
246            if ( ! file_exists($file)) {
247                die("skip File $file does not exist");
248            }
249        }
250    }
251
252    /**
253     * Skip test if config file is invalid.
254     *
255     * @param string $configTemplate
256     *
257     * @throws \Exception
258     */
259    static public function skipIfConfigFails(string $configTemplate)
260    {
261        $tester     = new self($configTemplate, '', [], self::getCallerFileName());
262        $testResult = $tester->testConfig();
263        if ($testResult !== null) {
264            self::clean(2);
265            die("skip $testResult");
266        }
267    }
268
269    /**
270     * Skip test if IPv6 is not supported.
271     */
272    static public function skipIfIPv6IsNotSupported()
273    {
274        @stream_socket_client('tcp://[::1]:0', $errno);
275        if ($errno != 111) {
276            die('skip IPv6 is not supported.');
277        }
278    }
279
280    /**
281     * Skip if running on Travis.
282     *
283     * @param $message
284     */
285    static public function skipIfTravis($message)
286    {
287        if (getenv("TRAVIS")) {
288            die('skip Travis: ' . $message);
289        }
290    }
291
292    /**
293     * Skip if not running as root.
294     */
295    static public function skipIfNotRoot()
296    {
297        if (getmyuid() != 0) {
298            die('skip not running as root');
299        }
300    }
301
302    /**
303     * Skip if running as root.
304     */
305    static public function skipIfRoot()
306    {
307        if (getmyuid() == 0) {
308            die('skip running as root');
309        }
310    }
311
312    /**
313     * Skip if posix extension not loaded.
314     */
315    static public function skipIfPosixNotLoaded()
316    {
317        if ( ! extension_loaded('posix')) {
318            die('skip posix extension not loaded');
319        }
320    }
321
322    /**
323     * Tester constructor.
324     *
325     * @param string|array $configTemplate
326     * @param string       $code
327     * @param array        $options
328     * @param string|null  $fileName
329     * @param bool|null    $debug
330     */
331    public function __construct(
332        string|array $configTemplate,
333        string $code = '',
334        array $options = [],
335        string $fileName = null,
336        bool $debug = null
337    ) {
338        $this->configTemplate = $configTemplate;
339        $this->code           = $code;
340        $this->options        = $options;
341        $this->fileName       = $fileName ?: self::getCallerFileName();
342        $this->debug          = $debug !== null ? $debug : (bool)getenv('TEST_FPM_DEBUG');
343        $this->logReader      = new LogReader($this->debug);
344        $this->logTool        = new LogTool($this->logReader, $this->debug);
345    }
346
347    /**
348     * @param string $ini
349     */
350    public function setUserIni(string $ini)
351    {
352        $iniFile = __DIR__ . '/.user.ini';
353        $this->trace('Setting .user.ini file', $ini, isFile: true);
354        file_put_contents($iniFile, $ini);
355    }
356
357    /**
358     * Test configuration file.
359     *
360     * @return null|string
361     * @throws \Exception
362     */
363    public function testConfig()
364    {
365        $configFile = $this->createConfig();
366        $cmd        = self::findExecutable() . ' -t -y ' . $configFile . ' 2>&1';
367        $this->trace('Testing config using command', $cmd, true);
368        exec($cmd, $output, $code);
369        if ($code) {
370            return preg_replace("/\[.+?\]/", "", $output[0]);
371        }
372
373        return null;
374    }
375
376    /**
377     * Start PHP-FPM master process
378     *
379     * @param array $extraArgs   Command extra arguments.
380     * @param bool  $forceStderr Whether to output to stderr so error log is used.
381     * @param bool  $daemonize   Whether to start FPM daemonized
382     * @param array $extensions  List of extension to add if shared build used.
383     *
384     * @return bool
385     * @throws \Exception
386     */
387    public function start(
388        array $extraArgs = [],
389        bool $forceStderr = true,
390        bool $daemonize = false,
391        array $extensions = []
392    ) {
393        $configFile = $this->createConfig();
394        $desc       = $this->outDesc ? [] : [1 => array('pipe', 'w'), 2 => array('redirect', 1)];
395
396        $cmd = [self::findExecutable(), '-y', $configFile];
397
398        if ($forceStderr) {
399            $cmd[] = '-O';
400        }
401
402        $this->daemonized = $daemonize;
403        if ( ! $daemonize) {
404            $cmd[] = '-F';
405        }
406
407        $extensionDir = getenv('TEST_FPM_EXTENSION_DIR');
408        if ($extensionDir) {
409            $cmd[] = '-dextension_dir=' . $extensionDir;
410            foreach ($extensions as $extension) {
411                $cmd[] = '-dextension=' . $extension;
412            }
413        }
414
415        if (getenv('TEST_FPM_RUN_AS_ROOT')) {
416            $cmd[] = '--allow-to-run-as-root';
417        }
418        $cmd = array_merge($cmd, $extraArgs);
419        $this->trace('Starting FPM using command:', $cmd, true);
420
421        $this->masterProcess = proc_open($cmd, $desc, $pipes);
422        register_shutdown_function(
423            function ($masterProcess) use ($configFile) {
424                @unlink($configFile);
425                if (is_resource($masterProcess)) {
426                    @proc_terminate($masterProcess);
427                    while (proc_get_status($masterProcess)['running']) {
428                        usleep(10000);
429                    }
430                }
431            },
432            $this->masterProcess
433        );
434        if ( ! $this->outDesc !== false) {
435            $this->outDesc = $pipes[1];
436            $this->logReader->setStreamSource('{{MASTER:OUT}}', $this->outDesc);
437            if ($daemonize) {
438                $this->switchLogSource('{{FILE:LOG}}');
439            }
440        }
441
442        return true;
443    }
444
445    /**
446     * Run until needle is found in the log.
447     *
448     * @param string $pattern Search pattern to find.
449     *
450     * @return bool
451     * @throws \Exception
452     */
453    public function runTill(string $pattern)
454    {
455        $this->start();
456        $found = $this->logTool->expectPattern($pattern);
457        $this->close(true);
458
459        return $found;
460    }
461
462    /**
463     * Check if connection works.
464     *
465     * @param string      $host
466     * @param string|null $successMessage
467     * @param string|null $errorMessage
468     * @param int         $attempts
469     * @param int         $delay
470     */
471    public function checkConnection(
472        string $host = '127.0.0.1',
473        string $successMessage = null,
474        ?string $errorMessage = 'Connection failed',
475        int $attempts = 20,
476        int $delay = 50000
477    ) {
478        $i = 0;
479        do {
480            if ($i > 0 && $delay > 0) {
481                usleep($delay);
482            }
483            $fp = @fsockopen($host, $this->getPort());
484        } while ((++$i < $attempts) && ! $fp);
485
486        if ($fp) {
487            $this->trace('Checking connection successful');
488            $this->message($successMessage);
489            fclose($fp);
490        } else {
491            $this->message($errorMessage);
492        }
493    }
494
495
496    /**
497     * Execute request with parameters ordered for better checking.
498     *
499     * @param string      $address
500     * @param string|null $successMessage
501     * @param string|null $errorMessage
502     * @param string      $uri
503     * @param string      $query
504     * @param array       $headers
505     *
506     * @return Response
507     */
508    public function checkRequest(
509        string $address,
510        string $successMessage = null,
511        string $errorMessage = null,
512        string $uri = '/ping',
513        string $query = '',
514        array $headers = []
515    ): Response {
516        return $this->request($query, $headers, $uri, $address, $successMessage, $errorMessage);
517    }
518
519    /**
520     * Execute and check ping request.
521     *
522     * @param string $address
523     * @param string $pingPath
524     * @param string $pingResponse
525     */
526    public function ping(
527        string $address = '{{ADDR}}',
528        string $pingResponse = 'pong',
529        string $pingPath = '/ping'
530    ) {
531        $response = $this->request('', [], $pingPath, $address);
532        $response->expectBody($pingResponse, 'text/plain');
533    }
534
535    /**
536     * Execute and check status request(s).
537     *
538     * @param array       $expectedFields
539     * @param string|null $address
540     * @param string      $statusPath
541     * @param mixed       $formats
542     *
543     * @throws \Exception
544     */
545    public function status(
546        array $expectedFields,
547        string $address = null,
548        string $statusPath = '/status',
549        $formats = ['plain', 'html', 'xml', 'json']
550    ) {
551        if ( ! is_array($formats)) {
552            $formats = [$formats];
553        }
554
555        require_once "status.inc";
556        $status = new Status();
557        foreach ($formats as $format) {
558            $query    = $format === 'plain' ? '' : $format;
559            $response = $this->request($query, [], $statusPath, $address);
560            $status->checkStatus($response, $expectedFields, $format);
561        }
562    }
563
564    /**
565     * Get request params array.
566     *
567     * @param string      $query
568     * @param array       $headers
569     * @param string|null $uri
570     * @param string|null $scriptFilename
571     * @param string|null $stdin
572     *
573     * @return array
574     */
575    private function getRequestParams(
576        string $query = '',
577        array $headers = [],
578        string $uri = null,
579        string $scriptFilename = null,
580        ?string $stdin = null
581    ): array {
582        if (is_null($uri)) {
583            $uri = $this->makeSourceFile();
584        }
585
586        $params = array_merge(
587            [
588                'GATEWAY_INTERFACE' => 'FastCGI/1.0',
589                'REQUEST_METHOD'    => is_null($stdin) ? 'GET' : 'POST',
590                'SCRIPT_FILENAME'   => $scriptFilename ?: $uri,
591                'SCRIPT_NAME'       => $uri,
592                'QUERY_STRING'      => $query,
593                'REQUEST_URI'       => $uri . ($query ? '?' . $query : ""),
594                'DOCUMENT_URI'      => $uri,
595                'SERVER_SOFTWARE'   => 'php/fcgiclient',
596                'REMOTE_ADDR'       => '127.0.0.1',
597                'REMOTE_PORT'       => '7777',
598                'SERVER_ADDR'       => '127.0.0.1',
599                'SERVER_PORT'       => '80',
600                'SERVER_NAME'       => php_uname('n'),
601                'SERVER_PROTOCOL'   => 'HTTP/1.1',
602                'DOCUMENT_ROOT'     => __DIR__,
603                'CONTENT_TYPE'      => '',
604                'CONTENT_LENGTH'    => strlen($stdin ?? "") // Default to 0
605            ],
606            $headers
607        );
608
609        return array_filter($params, function ($value) {
610            return ! is_null($value);
611        });
612    }
613
614    /**
615     * Parse stdin and generate data for multipart config.
616     *
617     * @param array $stdin
618     * @param array $headers
619     *
620     * @return void
621     * @throws \Exception
622     */
623    private function parseStdin(array $stdin, array &$headers)
624    {
625        $parts = $stdin['parts'] ?? null;
626        if (empty($parts)) {
627            throw new \Exception('The stdin array needs to contain parts');
628        }
629        $boundary = $stdin['boundary'] ?? 'AaB03x';
630        if ( ! isset($headers['CONTENT_TYPE'])) {
631            $headers['CONTENT_TYPE'] = 'multipart/form-data; boundary=' . $boundary;
632        }
633        $count = $parts['count'] ?? null;
634        if ( ! is_null($count)) {
635            $dispositionType  = $parts['disposition'] ?? 'form-data';
636            $dispositionParam = $parts['param'] ?? 'name';
637            $namePrefix       = $parts['prefix'] ?? 'f';
638            $nameSuffix       = $parts['suffix'] ?? '';
639            $value            = $parts['value'] ?? 'test';
640            $parts            = [];
641            for ($i = 0; $i < $count; $i++) {
642                $parts[] = [
643                    'disposition' => $dispositionType,
644                    'param'       => $dispositionParam,
645                    'name'        => "$namePrefix$i$nameSuffix",
646                    'value'       => $value
647                ];
648            }
649        }
650        $out = '';
651        $nl  = "\r\n";
652        foreach ($parts as $part) {
653            if (!is_array($part)) {
654                $part = ['name' => $part];
655            } elseif ( ! isset($part['name'])) {
656                throw new \Exception('Each part has to have a name');
657            }
658            $name             = $part['name'];
659            $dispositionType  = $part['disposition'] ?? 'form-data';
660            $dispositionParam = $part['param'] ?? 'name';
661            $value            = $part['value'] ?? 'test';
662            $partHeaders          = $part['headers'] ?? [];
663
664            $out .= "--$boundary$nl";
665            $out .= "Content-disposition: $dispositionType; $dispositionParam=\"$name\"$nl";
666            foreach ($partHeaders as $headerName => $headerValue) {
667                $out .= "$headerName: $headerValue$nl";
668            }
669            $out .= $nl;
670            $out .= "$value$nl";
671        }
672        $out .= "--$boundary--$nl";
673
674        return $out;
675    }
676
677    /**
678     * Execute request.
679     *
680     * @param string            $query
681     * @param array             $headers
682     * @param string|null       $uri
683     * @param string|null       $address
684     * @param string|null       $successMessage
685     * @param string|null       $errorMessage
686     * @param bool              $connKeepAlive
687     * @param string|null       $scriptFilename = null
688     * @param string|array|null $stdin          = null
689     * @param bool              $expectError
690     * @param int               $readLimit
691     *
692     * @return Response
693     * @throws \Exception
694     */
695    public function request(
696        string $query = '',
697        array $headers = [],
698        string $uri = null,
699        string $address = null,
700        string $successMessage = null,
701        string $errorMessage = null,
702        bool $connKeepAlive = false,
703        string $scriptFilename = null,
704        string|array $stdin = null,
705        bool $expectError = false,
706        int $readLimit = -1,
707    ): Response {
708        if ($this->hasError()) {
709            return new Response(null, true);
710        }
711
712        if (is_array($stdin)) {
713            $stdin = $this->parseStdin($stdin, $headers);
714        }
715
716        $params = $this->getRequestParams($query, $headers, $uri, $scriptFilename, $stdin);
717        $this->trace('Request params', $params);
718
719        try {
720            $this->response = new Response(
721                $this->getClient($address, $connKeepAlive)->request_data($params, $stdin, $readLimit)
722            );
723            if ($expectError) {
724                $this->error('Expected request error but the request was successful');
725            } else {
726                $this->message($successMessage);
727            }
728        } catch (\Exception $exception) {
729            if ($expectError) {
730                $this->message($successMessage);
731            } elseif ($errorMessage === null) {
732                $this->error("Request failed", $exception);
733            } else {
734                $this->message($errorMessage);
735            }
736            $this->response = new Response();
737        }
738        if ($this->debug) {
739            $this->response->debugOutput();
740        }
741
742        return $this->response;
743    }
744
745    /**
746     * Execute multiple requests in parallel.
747     *
748     * @param int|array   $requests
749     * @param string|null $address
750     * @param string|null $successMessage
751     * @param string|null $errorMessage
752     * @param bool        $connKeepAlive
753     * @param int         $readTimeout
754     *
755     * @return Response[]
756     * @throws \Exception
757     */
758    public function multiRequest(
759        int|array $requests,
760        string $address = null,
761        string $successMessage = null,
762        string $errorMessage = null,
763        bool $connKeepAlive = false,
764        int $readTimeout = 0
765    ) {
766        if (is_numeric($requests)) {
767            $requests = array_fill(0, $requests, []);
768        }
769
770        if ($this->hasError()) {
771            return array_map(fn($request) => new Response(null, true), $requests);
772        }
773
774        try {
775            $connections = array_map(function ($requestData) use ($address, $connKeepAlive) {
776                $client = $this->getClient($address, $connKeepAlive);
777                $params = $this->getRequestParams(
778                    $requestData['query'] ?? '',
779                    $requestData['headers'] ?? [],
780                    $requestData['uri'] ?? null
781                );
782
783                return [
784                    'client'    => $client,
785                    'requestId' => $client->async_request($params, false),
786                ];
787            }, $requests);
788
789            $responses = array_map(function ($conn) use ($readTimeout) {
790                $response = new Response($conn['client']->wait_for_response_data($conn['requestId'], $readTimeout));
791                if ($this->debug) {
792                    $response->debugOutput();
793                }
794
795                return $response;
796            }, $connections);
797            $this->message($successMessage);
798
799            return $responses;
800        } catch (\Exception $exception) {
801            if ($errorMessage === null) {
802                $this->error("Request failed", $exception);
803            } else {
804                $this->message($errorMessage);
805            }
806
807            return array_map(fn($request) => new Response(null, true), $requests);
808        }
809    }
810
811    /**
812     * Get client.
813     *
814     * @param string $address
815     * @param bool   $keepAlive
816     *
817     * @return Client
818     */
819    private function getClient(string $address = null, $keepAlive = false): Client
820    {
821        $address = $address ? $this->processTemplate($address) : $this->getAddr();
822        if ($address[0] === '/') { // uds
823            $host = 'unix://' . $address;
824            $port = -1;
825        } elseif ($address[0] === '[') { // ipv6
826            $addressParts = explode(']:', $address);
827            $host         = $addressParts[0];
828            if (isset($addressParts[1])) {
829                $host .= ']';
830                $port = $addressParts[1];
831            } else {
832                $port = $this->getPort();
833            }
834        } else { // ipv4
835            $addressParts = explode(':', $address);
836            $host         = $addressParts[0];
837            $port         = $addressParts[1] ?? $this->getPort();
838        }
839
840        if ( ! $keepAlive) {
841            return new Client($host, $port);
842        }
843
844        if ( ! isset($this->clients[$host][$port])) {
845            $client = new Client($host, $port);
846            $client->setKeepAlive(true);
847            $this->clients[$host][$port] = $client;
848        }
849
850        return $this->clients[$host][$port];
851    }
852
853    /**
854     * @return string
855     */
856    public function getUser()
857    {
858        return get_current_user();
859    }
860
861    /**
862     * @return string
863     */
864    public function getGroup()
865    {
866        return get_current_group();
867    }
868
869    /**
870     * @return int
871     */
872    public function getUid()
873    {
874        return getmyuid();
875    }
876
877    /**
878     * @return int
879     */
880    public function getGid()
881    {
882        return getmygid();
883    }
884
885    /**
886     * Reload FPM by sending USR2 signal and optionally change config before that.
887     *
888     * @param string|array $configTemplate
889     *
890     * @return string
891     * @throws \Exception
892     */
893    public function reload($configTemplate = null)
894    {
895        if ( ! is_null($configTemplate)) {
896            self::cleanConfigFiles();
897            $this->configTemplate = $configTemplate;
898            $this->createConfig();
899        }
900
901        return $this->signal('USR2');
902    }
903
904    /**
905     * Reload FPM logs by sending USR1 signal.
906     *
907     * @return string
908     * @throws \Exception
909     */
910    public function reloadLogs(): string
911    {
912        return $this->signal('USR1');
913    }
914
915    /**
916     * Send signal to the supplied PID or the server PID.
917     *
918     * @param string   $signal
919     * @param int|null $pid
920     *
921     * @return string
922     */
923    public function signal($signal, int $pid = null)
924    {
925        if (is_null($pid)) {
926            $pid = $this->getPid();
927        }
928        $cmd = "kill -$signal $pid";
929        $this->trace('Sending signal using command', $cmd, true);
930
931        return exec("kill -$signal $pid");
932    }
933
934    /**
935     * Terminate master process
936     */
937    public function terminate()
938    {
939        if ($this->daemonized) {
940            $this->signal('TERM');
941        } else {
942            proc_terminate($this->masterProcess);
943        }
944    }
945
946    /**
947     * Close all open descriptors and process resources
948     *
949     * @param bool $terminate
950     */
951    public function close($terminate = false)
952    {
953        if ($terminate) {
954            $this->terminate();
955        }
956        proc_close($this->masterProcess);
957    }
958
959    /**
960     * Create a config file.
961     *
962     * @param string $extension
963     *
964     * @return string
965     * @throws \Exception
966     */
967    private function createConfig($extension = 'ini')
968    {
969        if (is_array($this->configTemplate)) {
970            $configTemplates = $this->configTemplate;
971            if ( ! isset($configTemplates['main'])) {
972                throw new \Exception('The config template array has to have main config');
973            }
974            $mainTemplate = $configTemplates['main'];
975            if ( ! is_dir(self::CONF_DIR)) {
976                mkdir(self::CONF_DIR);
977            }
978            foreach ($this->createPoolConfigs($configTemplates) as $name => $poolConfig) {
979                $this->makeFile(
980                    'conf',
981                    $this->processTemplate($poolConfig),
982                    self::CONF_DIR,
983                    $name
984                );
985            }
986        } else {
987            $mainTemplate = $this->configTemplate;
988        }
989
990        return $this->makeFile($extension, $this->processTemplate($mainTemplate));
991    }
992
993    /**
994     * Create pool config templates.
995     *
996     * @param array $configTemplates
997     *
998     * @return array
999     * @throws \Exception
1000     */
1001    private function createPoolConfigs(array $configTemplates)
1002    {
1003        if ( ! isset($configTemplates['poolTemplate'])) {
1004            unset($configTemplates['main']);
1005
1006            return $configTemplates;
1007        }
1008        $poolTemplate = $configTemplates['poolTemplate'];
1009        $configs      = [];
1010        if (isset($configTemplates['count'])) {
1011            $start = $configTemplates['start'] ?? 1;
1012            for ($i = $start; $i < $start + $configTemplates['count']; $i++) {
1013                $configs[$i] = str_replace('%index%', $i, $poolTemplate);
1014            }
1015        } elseif (isset($configTemplates['names'])) {
1016            foreach ($configTemplates['names'] as $name) {
1017                $configs[$name] = str_replace('%name%', $name, $poolTemplate);
1018            }
1019        } else {
1020            throw new \Exception('The config template requires count or names if poolTemplate set');
1021        }
1022
1023        return $configs;
1024    }
1025
1026    /**
1027     * Process template string.
1028     *
1029     * @param string $template
1030     *
1031     * @return string
1032     */
1033    private function processTemplate(string $template)
1034    {
1035        $vars    = [
1036            'FILE:LOG:ACC'   => ['getAbsoluteFile', self::FILE_EXT_LOG_ACC],
1037            'FILE:LOG:ERR'   => ['getAbsoluteFile', self::FILE_EXT_LOG_ERR],
1038            'FILE:LOG:SLOW'  => ['getAbsoluteFile', self::FILE_EXT_LOG_SLOW],
1039            'FILE:PID'       => ['getAbsoluteFile', self::FILE_EXT_PID],
1040            'RFILE:LOG:ACC'  => ['getRelativeFile', self::FILE_EXT_LOG_ACC],
1041            'RFILE:LOG:ERR'  => ['getRelativeFile', self::FILE_EXT_LOG_ERR],
1042            'RFILE:LOG:SLOW' => ['getRelativeFile', self::FILE_EXT_LOG_SLOW],
1043            'RFILE:PID'      => ['getRelativeFile', self::FILE_EXT_PID],
1044            'ADDR:IPv4'      => ['getAddr', 'ipv4'],
1045            'ADDR:IPv4:ANY'  => ['getAddr', 'ipv4-any'],
1046            'ADDR:IPv6'      => ['getAddr', 'ipv6'],
1047            'ADDR:IPv6:ANY'  => ['getAddr', 'ipv6-any'],
1048            'ADDR:UDS'       => ['getAddr', 'uds'],
1049            'PORT'           => ['getPort', 'ip'],
1050            'INCLUDE:CONF'   => self::CONF_DIR . '/*.conf',
1051            'USER'           => ['getUser'],
1052            'GROUP'          => ['getGroup'],
1053            'UID'            => ['getUid'],
1054            'GID'            => ['getGid'],
1055            'MASTER:OUT'     => 'pipe:1',
1056            'STDERR'         => '/dev/stderr',
1057            'STDOUT'         => '/dev/stdout',
1058        ];
1059        $aliases = [
1060            'ADDR'     => 'ADDR:IPv4',
1061            'FILE:LOG' => 'FILE:LOG:ERR',
1062        ];
1063        foreach ($aliases as $aliasName => $aliasValue) {
1064            $vars[$aliasName] = $vars[$aliasValue];
1065        }
1066
1067        return preg_replace_callback(
1068            '/{{([a-zA-Z0-9:]+)(\[\w+\])?}}/',
1069            function ($matches) use ($vars) {
1070                $varName = $matches[1];
1071                if ( ! isset($vars[$varName])) {
1072                    $this->error("Invalid config variable $varName");
1073
1074                    return 'INVALID';
1075                }
1076                $pool     = $matches[2] ?? 'default';
1077                $varValue = $vars[$varName];
1078                if (is_string($varValue)) {
1079                    return $varValue;
1080                }
1081                $functionName = array_shift($varValue);
1082                $varValue[]   = $pool;
1083
1084                return call_user_func_array([$this, $functionName], $varValue);
1085            },
1086            $template
1087        );
1088    }
1089
1090    /**
1091     * @param string $type
1092     * @param string $pool
1093     *
1094     * @return string
1095     */
1096    public function getAddr(string $type = 'ipv4', $pool = 'default')
1097    {
1098        $port = $this->getPort($type, $pool, true);
1099        if ($type === 'uds') {
1100            $address = $this->getFile($port . '.sock');
1101
1102            // Socket max path length is 108 on Linux and 104 on BSD,
1103            // so we use the latter
1104            if (strlen($address) <= 104) {
1105                return $address;
1106            }
1107
1108            return sys_get_temp_dir() . '/' .
1109                   hash('crc32', dirname($address)) . '-' .
1110                   basename($address);
1111        }
1112
1113        return $this->getHost($type) . ':' . $port;
1114    }
1115
1116    /**
1117     * @param string $type
1118     * @param string $pool
1119     * @param bool   $useAsId
1120     *
1121     * @return int
1122     */
1123    public function getPort(string $type = 'ip', $pool = 'default', $useAsId = false)
1124    {
1125        if ($type === 'uds' && ! $useAsId) {
1126            return -1;
1127        }
1128
1129        if (isset($this->ports['values'][$pool])) {
1130            return $this->ports['values'][$pool];
1131        }
1132        $port                         = ($this->ports['last'] ?? 9000 + PHP_INT_SIZE - 1) + 1;
1133        $this->ports['values'][$pool] = $this->ports['last'] = $port;
1134
1135        return $port;
1136    }
1137
1138    /**
1139     * @param string $type
1140     *
1141     * @return string
1142     */
1143    public function getHost(string $type = 'ipv4')
1144    {
1145        switch ($type) {
1146            case 'ipv6-any':
1147                return '[::]';
1148            case 'ipv6':
1149                return '[::1]';
1150            case 'ipv4-any':
1151                return '0.0.0.0';
1152            default:
1153                return '127.0.0.1';
1154        }
1155    }
1156
1157    /**
1158     * Get listen address.
1159     *
1160     * @param string|null $template
1161     *
1162     * @return string
1163     */
1164    public function getListen($template = null)
1165    {
1166        return $template ? $this->processTemplate($template) : $this->getAddr();
1167    }
1168
1169    /**
1170     * Get PID.
1171     *
1172     * @return int
1173     */
1174    public function getPid()
1175    {
1176        $pidFile = $this->getFile('pid');
1177        if ( ! is_file($pidFile)) {
1178            return (int)$this->error("PID file has not been created");
1179        }
1180        $pidContent = file_get_contents($pidFile);
1181        if ( ! is_numeric($pidContent)) {
1182            return (int)$this->error("PID content '$pidContent' is not integer");
1183        }
1184        $this->trace('PID found', $pidContent);
1185
1186        return (int)$pidContent;
1187    }
1188
1189
1190    /**
1191     * Get file path for resource file.
1192     *
1193     * @param string      $extension
1194     * @param string|null $dir
1195     * @param string|null $name
1196     *
1197     * @return string
1198     */
1199    private function getFile(string $extension, string $dir = null, string $name = null): string
1200    {
1201        $fileName = (is_null($name) ? $this->fileName : $name . '.') . $extension;
1202
1203        return is_null($dir) ? $fileName : $dir . '/' . $fileName;
1204    }
1205
1206    /**
1207     * Get absolute file path for the resource file used by templates.
1208     *
1209     * @param string $extension
1210     *
1211     * @return string
1212     */
1213    private function getAbsoluteFile(string $extension): string
1214    {
1215        return $this->getFile($extension);
1216    }
1217
1218    /**
1219     * Get relative file name for resource file used by templates.
1220     *
1221     * @param string $extension
1222     *
1223     * @return string
1224     */
1225    private function getRelativeFile(string $extension): string
1226    {
1227        $fileName = rtrim(basename($this->fileName), '.');
1228
1229        return $this->getFile($extension, null, $fileName);
1230    }
1231
1232    /**
1233     * Get prefixed file.
1234     *
1235     * @param string      $extension
1236     * @param string|null $prefix
1237     *
1238     * @return string
1239     */
1240    public function getPrefixedFile(string $extension, string $prefix = null): string
1241    {
1242        $fileName = rtrim($this->fileName, '.');
1243        if ( ! is_null($prefix)) {
1244            $fileName = $prefix . '/' . basename($fileName);
1245        }
1246
1247        return $this->getFile($extension, null, $fileName);
1248    }
1249
1250    /**
1251     * Create a resource file.
1252     *
1253     * @param string      $extension
1254     * @param string      $content
1255     * @param string|null $dir
1256     * @param string|null $name
1257     *
1258     * @return string
1259     */
1260    private function makeFile(
1261        string $extension,
1262        string $content = '',
1263        string $dir = null,
1264        string $name = null,
1265        bool $overwrite = true
1266    ): string {
1267        $filePath = $this->getFile($extension, $dir, $name);
1268        if ( ! $overwrite && is_file($filePath)) {
1269            return $filePath;
1270        }
1271        file_put_contents($filePath, $content);
1272        $this->trace('Created file: ' . $filePath, $content, isFile: true);
1273
1274        return $filePath;
1275    }
1276
1277    /**
1278     * Create a source code file.
1279     *
1280     * @return string
1281     */
1282    public function makeSourceFile(): string
1283    {
1284        return $this->makeFile('src.php', $this->code, overwrite: false);
1285    }
1286
1287    /**
1288     * @param string|null $msg
1289     */
1290    private function message($msg)
1291    {
1292        if ($msg !== null) {
1293            echo "$msg\n";
1294        }
1295    }
1296
1297    /**
1298     * Display error.
1299     *
1300     * @param string          $msg
1301     * @param \Exception|null $exception
1302     *
1303     * @return false
1304     */
1305    private function error($msg, \Exception $exception = null): bool
1306    {
1307        $this->error = 'ERROR: ' . $msg;
1308        if ($exception) {
1309            $this->error .= '; EXCEPTION: ' . $exception->getMessage();
1310        }
1311        $this->error .= "\n";
1312
1313        echo $this->error;
1314
1315        return false;
1316    }
1317
1318    /**
1319     * Check whether any error was set.
1320     *
1321     * @return bool
1322     */
1323    private function hasError()
1324    {
1325        return ! is_null($this->error) || ! is_null($this->logTool->getError());
1326    }
1327
1328    /**
1329     * Expect file with a supplied extension to exist.
1330     *
1331     * @param string $extension
1332     * @param string $prefix
1333     *
1334     * @return bool
1335     */
1336    public function expectFile(string $extension, $prefix = null)
1337    {
1338        $filePath = $this->getPrefixedFile($extension, $prefix);
1339        if ( ! file_exists($filePath)) {
1340            return $this->error("The file $filePath does not exist");
1341        }
1342        $this->trace('File path exists as expected', $filePath);
1343
1344        return true;
1345    }
1346
1347    /**
1348     * Expect file with a supplied extension to not exist.
1349     *
1350     * @param string $extension
1351     * @param string $prefix
1352     *
1353     * @return bool
1354     */
1355    public function expectNoFile(string $extension, $prefix = null)
1356    {
1357        $filePath = $this->getPrefixedFile($extension, $prefix);
1358        if (file_exists($filePath)) {
1359            return $this->error("The file $filePath exists");
1360        }
1361        $this->trace('File path does not exist as expected', $filePath);
1362
1363        return true;
1364    }
1365
1366    /**
1367     * Expect message to be written to FastCGI error stream.
1368     *
1369     * @param string $message
1370     * @param int    $limit
1371     * @param int    $repeat
1372     */
1373    public function expectFastCGIErrorMessage(
1374        string $message,
1375        int $limit = 1024,
1376        int $repeat = 0
1377    ) {
1378        $this->logTool->setExpectedMessage($message, $limit, $repeat);
1379        $this->logTool->checkTruncatedMessage($this->response->getErrorData());
1380    }
1381
1382    /**
1383     * Expect log to be empty.
1384     *
1385     * @throws \Exception
1386     */
1387    public function expectLogEmpty()
1388    {
1389        try {
1390            $line = $this->logReader->getLine(1, 0, true);
1391            if ($line === '') {
1392                $line = $this->logReader->getLine(1, 0, true);
1393            }
1394            if ($line !== null) {
1395                $this->error('Log is not closed and returned line: ' . $line);
1396            }
1397        } catch (LogTimoutException $exception) {
1398            $this->error('Log is not closed and timed out', $exception);
1399        }
1400    }
1401
1402    /**
1403     * Expect reloading lines to be logged.
1404     *
1405     * @param int  $socketCount
1406     * @param bool $expectInitialProgressMessage
1407     * @param bool $expectReloadingMessage
1408     *
1409     * @throws \Exception
1410     */
1411    public function expectLogReloadingNotices(
1412        int $socketCount = 1,
1413        bool $expectInitialProgressMessage = true,
1414        bool $expectReloadingMessage = true
1415    ) {
1416        $this->logTool->expectReloadingLines(
1417            $socketCount,
1418            $expectInitialProgressMessage,
1419            $expectReloadingMessage
1420        );
1421    }
1422
1423    /**
1424     * Expect reloading lines to be logged.
1425     *
1426     * @throws \Exception
1427     */
1428    public function expectLogReloadingLogsNotices()
1429    {
1430        $this->logTool->expectReloadingLogsLines();
1431    }
1432
1433    /**
1434     * Expect starting lines to be logged.
1435     * @throws \Exception
1436     */
1437    public function expectLogStartNotices()
1438    {
1439        $this->logTool->expectStartingLines();
1440    }
1441
1442    /**
1443     * Expect terminating lines to be logged.
1444     * @throws \Exception
1445     */
1446    public function expectLogTerminatingNotices()
1447    {
1448        $this->logTool->expectTerminatorLines();
1449    }
1450
1451    /**
1452     * Expect log pattern in logs.
1453     *
1454     * @param string $pattern Log pattern
1455     *
1456     * @throws \Exception
1457     */
1458    public function expectLogPattern(string $pattern)
1459    {
1460        $this->logTool->expectPattern($pattern);
1461    }
1462
1463    /**
1464     * Expect log message that can span multiple lines.
1465     *
1466     * @param string $message
1467     * @param int    $limit
1468     * @param int    $repeat
1469     * @param bool   $decorated
1470     * @param bool   $wrapped
1471     *
1472     * @throws \Exception
1473     */
1474    public function expectLogMessage(
1475        string $message,
1476        int $limit = 1024,
1477        int $repeat = 0,
1478        bool $decorated = true,
1479        bool $wrapped = true
1480    ) {
1481        $this->logTool->setExpectedMessage($message, $limit, $repeat);
1482        if ($wrapped) {
1483            $this->logTool->checkWrappedMessage(true, $decorated);
1484        } else {
1485            $this->logTool->checkTruncatedMessage();
1486        }
1487    }
1488
1489    /**
1490     * Expect a single log line.
1491     *
1492     * @param string $message   The expected message.
1493     * @param bool   $isStdErr  Whether it is logged to stderr.
1494     * @param bool   $decorated Whether the log lines are decorated.
1495     *
1496     * @return bool
1497     * @throws \Exception
1498     */
1499    public function expectLogLine(
1500        string $message,
1501        bool $isStdErr = true,
1502        bool $decorated = true
1503    ): bool {
1504        $messageLen = strlen($message);
1505        $limit      = $messageLen > 1024 ? $messageLen + 16 : 1024;
1506        $this->logTool->setExpectedMessage($message, $limit);
1507
1508        return $this->logTool->checkWrappedMessage(false, $decorated, $isStdErr);
1509    }
1510
1511    /**
1512     * Expect log entry.
1513     *
1514     * @param string      $type    The log type
1515     * @param string      $message The expected message
1516     * @param string|null $pool    The pool for pool prefixed log entry
1517     * @param int         $count   The number of items
1518     *
1519     * @return bool
1520     * @throws \Exception
1521     */
1522    private function expectLogEntry(
1523        string $type,
1524        string $message,
1525        string $pool = null,
1526        int $count = 1
1527    ): bool {
1528        for ($i = 0; $i < $count; $i++) {
1529            if ( ! $this->logTool->expectEntry($type, $message, $pool)) {
1530                return false;
1531            }
1532        }
1533
1534        return true;
1535    }
1536
1537    /**
1538     * Expect a log debug message.
1539     *
1540     * @param string      $message
1541     * @param string|null $pool
1542     * @param int         $count
1543     *
1544     * @return bool
1545     * @throws \Exception
1546     */
1547    public function expectLogDebug(string $message, string $pool = null, int $count = 1): bool
1548    {
1549        return $this->expectLogEntry(LogTool::DEBUG, $message, $pool, $count);
1550    }
1551
1552    /**
1553     * Expect a log notice.
1554     *
1555     * @param string      $message
1556     * @param string|null $pool
1557     * @param int         $count
1558     *
1559     * @return bool
1560     * @throws \Exception
1561     */
1562    public function expectLogNotice(string $message, string $pool = null, int $count = 1): bool
1563    {
1564        return $this->expectLogEntry(LogTool::NOTICE, $message, $pool, $count);
1565    }
1566
1567    /**
1568     * Expect a log warning.
1569     *
1570     * @param string      $message
1571     * @param string|null $pool
1572     * @param int         $count
1573     *
1574     * @return bool
1575     * @throws \Exception
1576     */
1577    public function expectLogWarning(string $message, string $pool = null, int $count = 1): bool
1578    {
1579        return $this->expectLogEntry(LogTool::WARNING, $message, $pool, $count);
1580    }
1581
1582    /**
1583     * Expect a log error.
1584     *
1585     * @param string      $message
1586     * @param string|null $pool
1587     * @param int         $count
1588     *
1589     * @return bool
1590     * @throws \Exception
1591     */
1592    public function expectLogError(string $message, string $pool = null, int $count = 1): bool
1593    {
1594        return $this->expectLogEntry(LogTool::ERROR, $message, $pool, $count);
1595    }
1596
1597    /**
1598     * Expect a log alert.
1599     *
1600     * @param string      $message
1601     * @param string|null $pool
1602     * @param int         $count
1603     *
1604     * @return bool
1605     * @throws \Exception
1606     */
1607    public function expectLogAlert(string $message, string $pool = null, int $count = 1): bool
1608    {
1609        return $this->expectLogEntry(LogTool::ALERT, $message, $pool, $count);
1610    }
1611
1612    /**
1613     * Expect no log lines to be logged.
1614     *
1615     * @return bool
1616     * @throws \Exception
1617     */
1618    public function expectNoLogMessages(): bool
1619    {
1620        $logLine = $this->logReader->getLine(timeoutSeconds: 0, timeoutMicroseconds: 1000);
1621        if ($logLine === "") {
1622            $logLine = $this->logReader->getLine(timeoutSeconds: 0, timeoutMicroseconds: 1000);
1623        }
1624        if ($logLine !== null) {
1625            return $this->error(
1626                "Expected no log lines but following line logged: $logLine"
1627            );
1628        }
1629        $this->trace('No log message received as expected');
1630
1631        return true;
1632    }
1633
1634    /**
1635     * Print content of access log.
1636     */
1637    public function printAccessLog()
1638    {
1639        $accessLog = $this->getFile('acc.log');
1640        if (is_file($accessLog)) {
1641            print file_get_contents($accessLog);
1642        }
1643    }
1644
1645    /**
1646     * Read all log entries.
1647     *
1648     * @param string      $type    The log type
1649     * @param string      $message The expected message
1650     * @param string|null $pool    The pool for pool prefixed log entry
1651     *
1652     * @return bool
1653     * @throws \Exception
1654     */
1655    public function readAllLogEntries(string $type, string $message, string $pool = null): bool
1656    {
1657        return $this->logTool->readAllEntries($type, $message, $pool);
1658    }
1659
1660    /**
1661     * Read all log entries.
1662     *
1663     * @param string      $message The expected message
1664     * @param string|null $pool    The pool for pool prefixed log entry
1665     *
1666     * @return bool
1667     * @throws \Exception
1668     */
1669    public function readAllLogNotices(string $message, string $pool = null): bool
1670    {
1671        return $this->readAllLogEntries(LogTool::NOTICE, $message, $pool);
1672    }
1673
1674    /**
1675     * Switch the logs source.
1676     *
1677     * @param string $source The source file path or name if log is a pipe.
1678     *
1679     * @throws \Exception
1680     */
1681    public function switchLogSource(string $source)
1682    {
1683        $this->trace('Switching log descriptor to:', $source);
1684        $this->logReader->setFileSource($source, $this->processTemplate($source));
1685    }
1686
1687    /**
1688     * Trace execution by printing supplied message only in debug mode.
1689     *
1690     * @param string            $title     Trace title to print if supplied.
1691     * @param string|array|null $message   Message to print.
1692     * @param bool              $isCommand Whether message is a command array.
1693     */
1694    private function trace(
1695        string $title,
1696        string|array $message = null,
1697        bool $isCommand = false,
1698        bool $isFile = false
1699    ): void {
1700        if ($this->debug) {
1701            echo "\n";
1702            echo ">>> $title\n";
1703            if (is_array($message)) {
1704                if ($isCommand) {
1705                    echo implode(' ', $message) . "\n";
1706                } else {
1707                    print_r($message);
1708                }
1709            } elseif ($message !== null) {
1710                if ($isFile) {
1711                    $this->logReader->printSeparator();
1712                }
1713                echo $message . "\n";
1714                if ($isFile) {
1715                    $this->logReader->printSeparator();
1716                }
1717            }
1718        }
1719    }
1720}
1721