xref: /PHP-8.1/sapi/fpm/tests/logtool.inc (revision 5a4520bc)
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        $pattern = sprintf('/^(?:%s )?%s: %s(%s)$/', self::P_TIME, $type, self::P_DEBUG, $expectedMessage);
517        $this->trace("PATTERN: $pattern");
518
519        return function ($line) use ($expectedMessage, $pattern, $type, $ignoreErrorFor) {
520            $line = rtrim($line);
521            if (preg_match($pattern, $line, $matches) === 0) {
522                if ($this->getError()) { // quick bail out to save some CPU
523                    return false;
524                }
525
526                // get actual message
527                $types        = implode('|', [self::NOTICE, self::WARNING, self::ERROR, self::ALERT]);
528                $errorPattern = sprintf('/^(?:%s )?(%s): %s(.*)$/', self::P_TIME, $types, self::P_DEBUG);
529                if (preg_match($errorPattern, $line, $matches) === 0) {
530                    $actualMessage = null;
531                } else {
532                    $expectedMessage = $type . ' - ' . $expectedMessage;
533                    $actualMessage   = $matches[1] . ' - ' . $matches[2];
534                }
535
536                return $this->error(
537                    $this->getMatchDebugMessage(
538                        'Most likely invalid match for entry',
539                        $pattern,
540                        $line,
541                        $expectedMessage,
542                        $actualMessage
543                    ),
544                    $line,
545                    $ignoreErrorFor
546                );
547            }
548            $this->trace("Matched ACTUAL: " . $matches[1]);
549
550            return true;
551        };
552    }
553
554    /**
555     * Read all log entries until timeout.
556     *
557     * @param string      $type            Entry type like NOTICE, WARNING, DEBUG and so on.
558     * @param string      $expectedMessage Message to search for
559     * @param string|null $pool            Pool that is used and prefixes the message.
560     * @param string      $ignoreErrorFor  Ignore error for supplied string in the message.
561     *
562     * @return bool
563     * @throws \Exception
564     */
565    public function readAllEntries(
566        string $type,
567        string $expectedMessage,
568        string $pool = null,
569        string $ignoreErrorFor = self::DEBUG
570    ): bool {
571        if ($this->getError()) {
572            return false;
573        }
574
575        $matcher = $this->getEntryMatcher($type, $expectedMessage, $pool, $ignoreErrorFor);
576
577        while ($this->logReader->readUntil($matcher)) {
578            $this->popError();
579        }
580        $this->popError();
581
582        return true;
583    }
584
585    /**
586     * Expect log entry.
587     *
588     * @param string      $type                Entry type like NOTICE, WARNING, DEBUG and so on.
589     * @param string      $expectedMessage     Message to search for
590     * @param string|null $pool                Pool that is used and prefixes the message.
591     * @param string      $ignoreErrorFor      Ignore error for supplied string in the message.
592     * @param bool        $checkAllLogs        Whether to also check past logs.
593     * @param bool        $invert              Whether the log entry is not expected rather than expected.
594     * @param int|null    $timeoutSeconds      Timeout in seconds for reading of all messages.
595     * @param int|null    $timeoutMicroseconds Additional timeout in microseconds for reading of all messages.
596     *
597     * @return bool
598     * @throws \Exception
599     */
600    public function expectEntry(
601        string $type,
602        string $expectedMessage,
603        string $pool = null,
604        string $ignoreErrorFor = self::DEBUG,
605        bool $checkAllLogs = false,
606        bool $invert = false,
607        int $timeoutSeconds = null,
608        int $timeoutMicroseconds = null
609    ): bool {
610        if ($this->getError()) {
611            return false;
612        }
613
614        $matchResult = $this->match(
615            $this->getEntryMatcher($type, $expectedMessage, $pool, $ignoreErrorFor),
616            $invert ? null : "The $type does not match expected message",
617            $checkAllLogs,
618            $timeoutSeconds,
619            $timeoutMicroseconds
620        );
621
622        if ($matchResult && $invert) {
623            return $this->error("The $type matches unexpected message");
624        }
625
626        return $matchResult;
627    }
628
629    /**
630     * Expect debug log entry.
631     *
632     * @param string      $expectedMessage
633     * @param string|null $pool
634     *
635     * @return bool
636     * @throws \Exception
637     */
638    public function expectDebug(string $expectedMessage, string $pool = null): bool
639    {
640        return $this->expectEntry(self::DEBUG, $expectedMessage, $pool, self::ERROR);
641    }
642
643    /**
644     * Expect notice log entry.
645     *
646     * @param string      $expectedMessage
647     * @param string|null $pool
648     *
649     * @return bool
650     * @throws \Exception
651     */
652    public function expectNotice(string $expectedMessage, string $pool = null): bool
653    {
654        return $this->expectEntry(self::NOTICE, $expectedMessage, $pool);
655    }
656
657    /**
658     * Expect warning log entry.
659     *
660     * @param string      $expectedMessage
661     * @param string|null $pool
662     *
663     * @return bool
664     * @throws \Exception
665     */
666    public function expectWarning(string $expectedMessage, string $pool = null): bool
667    {
668        return $this->expectEntry(self::WARNING, $expectedMessage, $pool);
669    }
670
671    /**
672     * Expect error log entry.
673     *
674     * @param string      $expectedMessage
675     * @param string|null $pool
676     *
677     * @return bool
678     * @throws \Exception
679     */
680    public function expectError(string $expectedMessage, string $pool = null): bool
681    {
682        return $this->expectEntry(self::ERROR, $expectedMessage, $pool);
683    }
684
685    /**
686     * Expect alert log entry.
687     *
688     * @param string      $expectedMessage
689     * @param string|null $pool
690     *
691     * @return bool
692     * @throws \Exception
693     */
694    public function expectAlert(string $expectedMessage, string $pool = null): bool
695    {
696        return $this->expectEntry(self::ALERT, $expectedMessage, $pool);
697    }
698
699    /**
700     * Expect pattern in the log line.
701     *
702     * @param string   $pattern             Pattern to use.
703     * @param bool     $invert              Whether to expect pattern not to match.
704     * @param bool     $checkAllLogs        Whether to also check past logs.
705     * @param int|null $timeoutSeconds      Timeout in seconds for reading of all messages.
706     * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages.
707     *
708     * @return bool
709     * @throws \Exception
710     */
711    public function expectPattern(
712        string $pattern,
713        bool $invert = false,
714        bool $checkAllLogs = false,
715        int $timeoutSeconds = null,
716        int $timeoutMicroseconds = null,
717    ): bool {
718        $matchResult = $this->match(
719            function ($line) use ($pattern) {
720                if (preg_match($pattern, $line) === 1) {
721                    $this->traceMatch("Pattern expectation", $pattern, $line);
722
723                    return true;
724                }
725
726                return false;
727            },
728            $invert ? null : 'The search pattern not found',
729            $checkAllLogs,
730            $timeoutSeconds,
731            $timeoutMicroseconds
732        );
733
734        if ($invert && $matchResult) {
735            return $this->logReader->printError('The search pattern found - PATTERN: ' . $pattern);
736        }
737
738        return $matchResult;
739    }
740
741    /**
742     * Get match debug message.
743     *
744     * @param string      $title
745     * @param string|null $pattern
746     * @param string|null $line
747     * @param string|null $expectedMessage
748     * @param string|null $actualMessage
749     *
750     * @return string
751     */
752    private function getMatchDebugMessage(
753        string $title,
754        string $pattern = null,
755        string $line = null,
756        string $expectedMessage = null,
757        string $actualMessage = null
758    ): string {
759        $msg = "$title:\n";
760        if ($pattern !== null) {
761            $msg .= "- PATTERN: $pattern\n";
762        }
763        if ($line !== null) {
764            $msg .= "- LINE: $line\n";
765        }
766        if ($expectedMessage !== null) {
767            $msg .= "- EXPECTED: $expectedMessage\n";
768        }
769        if ($actualMessage !== null) {
770            $msg .= "- ACTUAL: $actualMessage\n";
771        }
772
773        return $msg;
774    }
775
776    /**
777     * Print tracing of the match.
778     *
779     * @param string      $title
780     * @param string|null $pattern
781     * @param string|null $line
782     * @param string|null $expectedMessage
783     * @param string|null $actualMessage
784     */
785    private function traceMatch(
786        string $title,
787        string $pattern = null,
788        string $line = null,
789        string $expectedMessage = null,
790        string $actualMessage = null
791    ): void {
792        if ($this->debug) {
793            echo "LogTool - " . $this->getMatchDebugMessage($title, $pattern, $line, $expectedMessage, $actualMessage);
794        }
795    }
796
797    /**
798     * Print tracing message - only in debug .
799     *
800     * @param string $msg Message to print.
801     */
802    private function trace(string $msg): void
803    {
804        if ($this->debug) {
805            print "LogTool - $msg\n";
806        }
807    }
808
809
810    /**
811     * Save error message if the line does not contain ignored string.
812     *
813     * @param string      $msg
814     * @param string|null $line
815     * @param string      $ignoreFor
816     *
817     * @return false
818     */
819    private function error(string $msg, string $line = null, string $ignoreFor = self::DEBUG): bool
820    {
821        if ($this->error === null && ($line === null || ! str_contains($line, $ignoreFor))) {
822            $this->trace("Setting error: $msg");
823            $this->error = $msg;
824        }
825
826        return false;
827    }
828
829    /**
830     * Get saved error.
831     *
832     * @return string|null
833     */
834    public function getError(): ?string
835    {
836        return $this->error;
837    }
838
839    /**
840     * Get saved error and clear it.
841     *
842     * @return string|null
843     */
844    public function popError(): ?string
845    {
846        $error       = $this->error;
847        $this->error = null;
848        if ($error !== null) {
849            $this->trace("Clearing error: $error");
850        }
851
852        return $error;
853    }
854}
855