xref: /php-src/sapi/fpm/tests/logtool.inc (revision 330cc5cd)
1<?php
2
3namespace FPM;
4
5class LogTool
6{
7    const P_TIME = '\[\d\d-\w\w\w-\d{4} \d\d:\d\d:\d\d(?:\.\d+)?\]';
8    const P_DEBUG = '(?:pid \d+, (?:\w+|\(null\))\(\), line \d+: )?';
9    const P_PREFIX = '\[pool unconfined\] child \d+ said into stderr: ';
10    const P_PREFIX_STDOUT = '\[pool unconfined\] child \d+ said into stdout: ';
11    const FINAL_SUFFIX = ', pipe is closed';
12
13    const DEBUG = 'DEBUG';
14    const NOTICE = 'NOTICE';
15    const WARNING = 'WARNING';
16    const ERROR = 'ERROR';
17    const ALERT = 'ALERT';
18
19    /**
20     * @var bool
21     */
22    private bool $debug;
23
24    /**
25     * @var LogReader
26     */
27    private LogReader $logReader;
28
29    /**
30     * @var string
31     */
32    private string $message;
33
34    /**
35     * @var string|null
36     */
37    private ?string $level = null;
38
39    /**
40     * @var int
41     */
42    private int $position;
43
44    /**
45     * @var int
46     */
47    private int $suffixPosition = 0;
48
49    /**
50     * @var int
51     */
52    private int $limit;
53
54    /**
55     * @var string|null
56     */
57    private ?string $pattern;
58
59    /**
60     * @var string|null
61     */
62    private ?string $error = null;
63
64    /**
65     * @var bool
66     */
67    private bool $pipeClosed = false;
68
69    /**
70     * Log tool constructor.
71     *
72     * @param LogReader $logReader
73     * @param bool      $debug
74     */
75    public function __construct(LogReader $logReader, bool $debug = false)
76    {
77        $this->logReader = $logReader;
78        $this->debug     = $debug;
79    }
80
81    /**
82     * Set expected message for output logging.
83     *
84     * @param string $message
85     * @param int    $limit
86     * @param int    $repeat
87     */
88    public function setExpectedMessage(string $message, int $limit, int $repeat = 0)
89    {
90        $this->message  = ($repeat > 0) ? str_repeat($message, $repeat) : $message;
91        $this->limit    = $limit;
92        $this->position = 0;
93    }
94
95    /**
96     * Set the expected logging level.
97     *
98     * @param string $level
99     *
100     * @return string
101     */
102    public function setExpectedLevel(string $level): string
103    {
104        return $this->level = $level;
105    }
106
107    /**
108     * Get the expected logging level.
109     *
110     * @return string
111     */
112    public function getExpectedLevel(): string
113    {
114        return $this->level ?: 'WARNING';
115    }
116
117    /**
118     * Set whether pipe closed error is shown.
119     *
120     * @param bool $pipeClosed
121     */
122    public function setPipeClosed(bool $pipeClosed): void
123    {
124        $this->pipeClosed = $pipeClosed;
125    }
126
127    /**
128     * Match the matcher checking the log lines using the callback.
129     *
130     * @param callable    $matcher             Callback checking whether the log line matches the expected message.
131     * @param string|null $notFoundMessage     Error message to show if the message is not found.
132     * @param bool        $checkAllLogs        Whether to also check past logs.
133     * @param int|null    $timeoutSeconds      Timeout in seconds for reading of all messages.
134     * @param int|null    $timeoutMicroseconds Additional timeout in microseconds for reading of all messages.
135     *
136     * @return bool
137     * @throws \Exception
138     */
139    private function match(
140        callable $matcher,
141        ?string $notFoundMessage = null,
142        bool $checkAllLogs = false,
143        ?int $timeoutSeconds = null,
144        ?int $timeoutMicroseconds = null
145    ): bool {
146        if ($this->getError()) {
147            return false;
148        }
149
150        if ($this->logReader->readUntil(
151            $matcher,
152            $notFoundMessage,
153            $checkAllLogs,
154            $timeoutSeconds,
155            $timeoutMicroseconds
156        )) {
157            $this->popError();
158
159            return true;
160        }
161        echo $this->popError();
162
163        return false;
164    }
165
166    /**
167     * @param string|null $line Log line to check against.
168     *
169     * @return bool
170     * @throws \Exception
171     */
172    public function checkTruncatedMessage(?string $line = null): bool
173    {
174        if ($this->message === null) {
175            throw new \LogicException('The message has not been set');
176        }
177
178        $matcher = function (string $line) {
179            $lineLen = strlen($line);
180            if ( ! $this->checkLineLength($line)) {
181                return false;
182            }
183            $this->pattern = '/^PHP message: (.*?)(\.\.\.)?$/';
184            if (preg_match($this->pattern, $line, $matches) === 0) {
185                return $this->error("Unexpected truncated message: {$line}");
186            }
187
188            if ($lineLen === $this->limit - strlen('NOTICE: ') - 1) {
189                if ( ! isset($matches[2])) {
190                    return $this->error("The truncated line is not ended with '...'");
191                }
192                if ( ! $this->checkMessage($matches[1])) {
193                    return false;
194                }
195            } else {
196                if (isset($matches[2])) {
197                    // this is expecting that the expected message does not end with '...'
198                    // which should not be an issue for the test purpose.
199                    return $this->error("The line is complete and should not end with '...'");
200                }
201                if ( ! $this->checkMessage($matches[1], -1)) {
202                    return false;
203                }
204            }
205
206            return true;
207        };
208
209        if ($line !== null) {
210            return $matcher($line);
211        } else {
212            return $this->match($matcher, 'Truncated message not found');
213        }
214    }
215
216    /**
217     * Check wrapped message.
218     *
219     * @param bool $terminated Whether to check termination lines.
220     * @param bool $decorated  Whether the output is decorated with prefix and suffix.
221     * @param bool $isStdErr   Whether the message is written to stderr.
222     *
223     * @return bool
224     * @throws \Exception
225     */
226    public function checkWrappedMessage(
227        bool $terminated = true,
228        bool $decorated = true,
229        bool $isStdErr = true,
230    ): bool {
231        if ($this->message === null) {
232            throw new \LogicException('The message has not been set');
233        }
234        if ($decorated) {
235            $this->pattern = sprintf(
236                '/^(%s %s: %s%s)"([^"]*)"(.*)?$/',
237                self::P_TIME,
238                $this->getExpectedLevel(),
239                self::P_DEBUG,
240                $isStdErr ? self::P_PREFIX : self::P_PREFIX_STDOUT
241            );
242        } else {
243            $this->pattern = null;
244        }
245
246        $matcher = fn(string $line) => $this->checkLine($line);
247
248        while (strlen($this->message) !== $this->position) {
249            if ( ! $this->match($matcher, 'Output message not found')) {
250                return false;
251            }
252        }
253
254        if ($this->suffixPosition > 0) {
255            $suffixMatcher = function ($line) use ($isStdErr) {
256                $suffixPattern = sprintf(
257                    '/^%s %s: %s%s(.*)$/',
258                    self::P_TIME,
259                    $this->getExpectedLevel(),
260                    self::P_DEBUG,
261                    $isStdErr ? self::P_PREFIX : self::P_PREFIX_STDOUT
262                );
263                if (preg_match($suffixPattern, $line, $matches) === 0) {
264                    return $this->error("Unexpected line: $line");
265                }
266                if ($matches[1] !== substr(self::FINAL_SUFFIX, $this->suffixPosition)) {
267                    return $this->error(
268                        "The suffix has not been finished from position $this->suffixPosition in line: $line"
269                    );
270                }
271
272                return true;
273            };
274            if ( ! $this->match($suffixMatcher, 'Suffix message not found')) {
275                return false;
276            }
277            $this->suffixPosition = 0;
278        }
279
280        if ($terminated) {
281            return $this->expectTerminatorLines();
282        }
283
284        return true;
285    }
286
287    /**
288     * Check workers output line.
289     *
290     * @param string $line Log output line.
291     *
292     * @return bool
293     */
294    private function checkLine(string $line): bool
295    {
296        $useLine = $this->pattern === null;
297        if ($useLine) {
298            // plain (not decorated) output
299            $out         = rtrim($line);
300            $finalSuffix = null;
301        } elseif (preg_match($this->pattern, $line, $matches) > 0) {
302            $out         = $matches[2];
303            $finalSuffix = $matches[3] ?? false;
304        } else {
305            return $this->error("Unexpected line: $line", $line);
306        }
307
308        $rem     = strlen($this->message) - $this->position;
309        $lineLen = strlen($line);
310        if ( ! $this->checkLineLength($line, $lineLen)) {
311            return false;
312        }
313        if ( ! $this->checkMessage($out, $this->position, $useLine)) {
314            return false;
315        }
316        $outLen         = strlen($out);
317        $this->position += $outLen;
318        if ($rem > $outLen) { // continuous line
319            if ($lineLen !== $this->limit) {
320                if ($lineLen + ($rem - $outLen) < $this->limit) {
321                    return $this->error("Printed less than the message len");
322                }
323
324                return $this->error(
325                    "The continuous line length is $lineLen but it should equal to limit $this->limit"
326                );
327            }
328
329            return true;
330        }
331        if ($rem !== $outLen) {
332            return $this->error("Printed more than the message len");
333        }
334        if ( ! $this->pipeClosed || $finalSuffix === null) {
335            return true;
336        }
337        if ($finalSuffix === false) {
338            return $this->error("No final suffix");
339        }
340        if (empty($finalSuffix) || ! str_contains(self::FINAL_SUFFIX, $finalSuffix)) {
341            return $this->error("The final suffix has to be equal to ', pipe is closed'");
342        }
343        if (self::FINAL_SUFFIX !== $finalSuffix) {
344            $this->suffixPosition = strlen($finalSuffix);
345        }
346
347        return true;
348    }
349
350    /**
351     * Check the message line length - specifically if it's behind the limit.
352     *
353     * @param string   $line    Log output line.
354     * @param int|null $lineLen Line length.
355     *
356     * @return bool
357     */
358    private function checkLineLength(string $line, ?int $lineLen = null): bool
359    {
360        $lineLen = $lineLen ?: strlen($line);
361        if ($lineLen > $this->limit) {
362            return $this->error(
363                "The line length is $lineLen which is higher than limit $this->limit",
364                $line
365            );
366        }
367
368        return true;
369    }
370
371    /**
372     * Check whether matched message part matches the expected message.
373     *
374     * @param string $matchedMessage       The output message or part of it (match).
375     * @param int    $expectedMessageStart Message position.
376     * @param bool   $isLine               Whether the whole log line is provided as a matched message.
377     *
378     * @return bool
379     */
380    private function checkMessage(
381        string $matchedMessage,
382        int $expectedMessageStart = 0,
383        bool $isLine = false
384    ): bool {
385        if ($expectedMessageStart < 0) {
386            $expectedMessage = $this->message;
387        } else {
388            $expectedMessage = substr($this->message, $expectedMessageStart, strlen($matchedMessage));
389        }
390        if ($expectedMessage !== $matchedMessage) {
391            return $this->error(
392                $this->getMatchDebugMessage(
393                    sprintf(
394                        "The actual string(%d) does not match expected string(%d):\n",
395                        strlen($matchedMessage),
396                        strlen($expectedMessage)
397                    ),
398                    expectedMessage: $expectedMessage,
399                    actualMessage: "'$matchedMessage'",
400                ),
401                $isLine ? $matchedMessage : null
402            );
403        }
404
405        $this->traceMatch(
406            "Message matched",
407            expectedMessage: $expectedMessage,
408            actualMessage: "'$matchedMessage'",
409        );
410
411        return true;
412    }
413
414    /**
415     * Expect log entries for daemon reloading.
416     *
417     * @param int  $expectedNumberOfSockets
418     * @param bool $expectInitialProgressMessage
419     * @param bool $expectReloadingMessage
420     *
421     * @return bool
422     * @throws \Exception
423     */
424    public function expectReloadingLines(
425        int $expectedNumberOfSockets,
426        bool $expectInitialProgressMessage = true,
427        bool $expectReloadingMessage = true
428    ): bool {
429        if ($expectInitialProgressMessage && ! $this->expectNotice('Reloading in progress ...')) {
430            return false;
431        }
432        if ($expectReloadingMessage && ! $this->expectNotice('reloading: .*')) {
433            return false;
434        }
435
436        for ($i = 0; $i < $expectedNumberOfSockets; $i++) {
437            if ( ! $this->expectNotice('using inherited socket fd=\d+, "[^"]+"')) {
438                return false;
439            }
440        }
441
442        return $this->expectStartingLines();
443    }
444
445    /**
446     * Expect log entries for reloading logs.
447     *
448     * @return bool
449     * @throws \Exception
450     */
451    public function expectReloadingLogsLines(): bool
452    {
453        return (
454            $this->expectNotice('error log file re-opened') &&
455            $this->expectNotice('access log file re-opened')
456        );
457    }
458
459    /**
460     * Expect starting lines when FPM starts.
461     *
462     * @return bool
463     * @throws \Exception
464     */
465    public function expectStartingLines(): bool
466    {
467        if ($this->getError()) {
468            return false;
469        }
470
471        return (
472            $this->expectNotice('fpm is running, pid \d+') &&
473            $this->expectNotice('ready to handle connections')
474        );
475    }
476
477    /**
478     * Expect termination lines when FPM terminates.
479     *
480     * @return bool
481     * @throws \Exception
482     */
483    public function expectTerminatorLines(): bool
484    {
485        if ($this->getError()) {
486            return false;
487        }
488
489        return (
490            $this->expectNotice('Terminating ...') &&
491            $this->expectNotice('exiting, bye-bye!')
492        );
493    }
494
495    /**
496     * Get log entry matcher.
497     *
498     * @param string $type            Entry type like NOTICE, WARNING, DEBUG and so on.
499     * @param string $expectedMessage Message to search for
500     * @param string $pool            Pool that is used and prefixes the message.
501     * @param string $ignoreErrorFor  Ignore error for supplied string in the message.
502     *
503     * @return callable
504     */
505    private function getEntryMatcher(
506        string $type,
507        string $expectedMessage,
508        ?string $pool,
509        string $ignoreErrorFor
510    ): callable {
511        if ($pool !== null) {
512            $expectedMessage = '\[pool ' . $pool . '\] ' . $expectedMessage;
513        }
514        $this->trace("Matching EXPECTED: $expectedMessage");
515
516        // Allow expected message to contain %s and %s for any string or number as in run-tests.php
517        $expectRe = str_replace('%s', '[^\r\n]+', $expectedMessage);
518        $expectRe = str_replace('%d', '\d+', $expectRe);
519
520        $pattern = sprintf('/^(?:%s )?%s: %s(%s)$/', self::P_TIME, $type, self::P_DEBUG, $expectRe);
521        $this->trace("PATTERN: $pattern");
522
523        return function ($line) use ($expectedMessage, $pattern, $type, $ignoreErrorFor) {
524            $line = rtrim($line);
525            if (preg_match($pattern, $line, $matches) === 0) {
526                if ($this->getError()) { // quick bail out to save some CPU
527                    return false;
528                }
529
530                // get actual message
531                $types        = implode('|', [self::NOTICE, self::WARNING, self::ERROR, self::ALERT]);
532                $errorPattern = sprintf('/^(?:%s )?(%s): %s(.*)$/', self::P_TIME, $types, self::P_DEBUG);
533                if (preg_match($errorPattern, $line, $matches) === 0) {
534                    $actualMessage = null;
535                } else {
536                    $expectedMessage = $type . ' - ' . $expectedMessage;
537                    $actualMessage   = $matches[1] . ' - ' . $matches[2];
538                }
539
540                return $this->error(
541                    $this->getMatchDebugMessage(
542                        'Most likely invalid match for entry',
543                        $pattern,
544                        $line,
545                        $expectedMessage,
546                        $actualMessage
547                    ),
548                    $line,
549                    $ignoreErrorFor
550                );
551            }
552            $this->trace("Matched ACTUAL: " . $matches[1]);
553
554            return true;
555        };
556    }
557
558    /**
559     * Read all log entries until timeout.
560     *
561     * @param string      $type            Entry type like NOTICE, WARNING, DEBUG and so on.
562     * @param string      $expectedMessage Message to search for
563     * @param string|null $pool            Pool that is used and prefixes the message.
564     * @param string      $ignoreErrorFor  Ignore error for supplied string in the message.
565     *
566     * @return bool
567     * @throws \Exception
568     */
569    public function readAllEntries(
570        string $type,
571        string $expectedMessage,
572        ?string $pool = null,
573        string $ignoreErrorFor = self::DEBUG
574    ): bool {
575        if ($this->getError()) {
576            return false;
577        }
578
579        $matcher = $this->getEntryMatcher($type, $expectedMessage, $pool, $ignoreErrorFor);
580
581        while ($this->logReader->readUntil($matcher)) {
582            $this->popError();
583        }
584        $this->popError();
585
586        return true;
587    }
588
589    /**
590     * Expect log entry.
591     *
592     * @param string      $type                Entry type like NOTICE, WARNING, DEBUG and so on.
593     * @param string      $expectedMessage     Message to search for
594     * @param string|null $pool                Pool that is used and prefixes the message.
595     * @param string      $ignoreErrorFor      Ignore error for supplied string in the message.
596     * @param bool        $checkAllLogs        Whether to also check past logs.
597     * @param bool        $invert              Whether the log entry is not expected rather than expected.
598     * @param int|null    $timeoutSeconds      Timeout in seconds for reading of all messages.
599     * @param int|null    $timeoutMicroseconds Additional timeout in microseconds for reading of all messages.
600     *
601     * @return bool
602     * @throws \Exception
603     */
604    public function expectEntry(
605        string $type,
606        string $expectedMessage,
607        ?string $pool = null,
608        string $ignoreErrorFor = self::DEBUG,
609        bool $checkAllLogs = false,
610        bool $invert = false,
611        ?int $timeoutSeconds = null,
612        ?int $timeoutMicroseconds = null
613    ): bool {
614        if ($this->getError()) {
615            return false;
616        }
617
618        $matchResult = $this->match(
619            $this->getEntryMatcher($type, $expectedMessage, $pool, $ignoreErrorFor),
620            $invert ? null : "The $type does not match expected message",
621            $checkAllLogs,
622            $timeoutSeconds,
623            $timeoutMicroseconds
624        );
625
626        if ($matchResult && $invert) {
627            return $this->error("The $type matches unexpected message");
628        }
629
630        return $matchResult;
631    }
632
633    /**
634     * Expect debug log entry.
635     *
636     * @param string      $expectedMessage
637     * @param string|null $pool
638     *
639     * @return bool
640     * @throws \Exception
641     */
642    public function expectDebug(string $expectedMessage, ?string $pool = null): bool
643    {
644        return $this->expectEntry(self::DEBUG, $expectedMessage, $pool, self::ERROR);
645    }
646
647    /**
648     * Expect notice log entry.
649     *
650     * @param string      $expectedMessage
651     * @param string|null $pool
652     *
653     * @return bool
654     * @throws \Exception
655     */
656    public function expectNotice(string $expectedMessage, ?string $pool = null): bool
657    {
658        return $this->expectEntry(self::NOTICE, $expectedMessage, $pool);
659    }
660
661    /**
662     * Expect warning log entry.
663     *
664     * @param string      $expectedMessage
665     * @param string|null $pool
666     *
667     * @return bool
668     * @throws \Exception
669     */
670    public function expectWarning(string $expectedMessage, ?string $pool = null): bool
671    {
672        return $this->expectEntry(self::WARNING, $expectedMessage, $pool);
673    }
674
675    /**
676     * Expect error log entry.
677     *
678     * @param string      $expectedMessage
679     * @param string|null $pool
680     *
681     * @return bool
682     * @throws \Exception
683     */
684    public function expectError(string $expectedMessage, ?string $pool = null): bool
685    {
686        return $this->expectEntry(self::ERROR, $expectedMessage, $pool);
687    }
688
689    /**
690     * Expect alert log entry.
691     *
692     * @param string      $expectedMessage
693     * @param string|null $pool
694     *
695     * @return bool
696     * @throws \Exception
697     */
698    public function expectAlert(string $expectedMessage, ?string $pool = null): bool
699    {
700        return $this->expectEntry(self::ALERT, $expectedMessage, $pool);
701    }
702
703    /**
704     * Expect pattern in the log line.
705     *
706     * @param string   $pattern             Pattern to use.
707     * @param bool     $invert              Whether to expect pattern not to match.
708     * @param bool     $checkAllLogs        Whether to also check past logs.
709     * @param int|null $timeoutSeconds      Timeout in seconds for reading of all messages.
710     * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages.
711     *
712     * @return bool
713     * @throws \Exception
714     */
715    public function expectPattern(
716        string $pattern,
717        bool $invert = false,
718        bool $checkAllLogs = false,
719        ?int $timeoutSeconds = null,
720        ?int $timeoutMicroseconds = null,
721    ): bool {
722        $matchResult = $this->match(
723            function ($line) use ($pattern) {
724                if (preg_match($pattern, $line) === 1) {
725                    $this->traceMatch("Pattern expectation", $pattern, $line);
726
727                    return true;
728                }
729
730                return false;
731            },
732            $invert ? null : 'The search pattern not found',
733            $checkAllLogs,
734            $timeoutSeconds,
735            $timeoutMicroseconds
736        );
737
738        if ($invert && $matchResult) {
739            return $this->logReader->printError('The search pattern found - PATTERN: ' . $pattern);
740        }
741
742        return $matchResult;
743    }
744
745    /**
746     * Get match debug message.
747     *
748     * @param string      $title
749     * @param string|null $pattern
750     * @param string|null $line
751     * @param string|null $expectedMessage
752     * @param string|null $actualMessage
753     *
754     * @return string
755     */
756    private function getMatchDebugMessage(
757        string $title,
758        ?string $pattern = null,
759        ?string $line = null,
760        ?string $expectedMessage = null,
761        ?string $actualMessage = null
762    ): string {
763        $msg = "$title:\n";
764        if ($pattern !== null) {
765            $msg .= "- PATTERN: $pattern\n";
766        }
767        if ($line !== null) {
768            $msg .= "- LINE: $line\n";
769        }
770        if ($expectedMessage !== null) {
771            $msg .= "- EXPECTED: $expectedMessage\n";
772        }
773        if ($actualMessage !== null) {
774            $msg .= "- ACTUAL: $actualMessage\n";
775        }
776
777        return $msg;
778    }
779
780    /**
781     * Print tracing of the match.
782     *
783     * @param string      $title
784     * @param string|null $pattern
785     * @param string|null $line
786     * @param string|null $expectedMessage
787     * @param string|null $actualMessage
788     */
789    private function traceMatch(
790        string $title,
791        ?string $pattern = null,
792        ?string $line = null,
793        ?string $expectedMessage = null,
794        ?string $actualMessage = null
795    ): void {
796        if ($this->debug) {
797            echo "LogTool - " . $this->getMatchDebugMessage($title, $pattern, $line, $expectedMessage, $actualMessage);
798        }
799    }
800
801    /**
802     * Print tracing message - only in debug .
803     *
804     * @param string $msg Message to print.
805     */
806    private function trace(string $msg): void
807    {
808        if ($this->debug) {
809            print "LogTool - $msg\n";
810        }
811    }
812
813
814    /**
815     * Save error message if the line does not contain ignored string.
816     *
817     * @param string      $msg
818     * @param string|null $line
819     * @param string      $ignoreFor
820     *
821     * @return false
822     */
823    private function error(string $msg, ?string $line = null, string $ignoreFor = self::DEBUG): bool
824    {
825        if ($this->error === null && ($line === null || ! str_contains($line, $ignoreFor))) {
826            $this->trace("Setting error: $msg");
827            $this->error = $msg;
828        }
829
830        return false;
831    }
832
833    /**
834     * Get saved error.
835     *
836     * @return string|null
837     */
838    public function getError(): ?string
839    {
840        return $this->error;
841    }
842
843    /**
844     * Get saved error and clear it.
845     *
846     * @return string|null
847     */
848    public function popError(): ?string
849    {
850        $error       = $this->error;
851        $this->error = null;
852        if ($error !== null) {
853            $this->trace("Clearing error: $error");
854        }
855
856        return $error;
857    }
858}
859