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