diff --git a/src/Monolog/Handler/StreamHandler.php b/src/Monolog/Handler/StreamHandler.php index c8a648c43..b430a0023 100644 --- a/src/Monolog/Handler/StreamHandler.php +++ b/src/Monolog/Handler/StreamHandler.php @@ -19,55 +19,49 @@ * Stores to any stream resource * * Can be used to store into php://stderr, remote and local files, etc. - * - * @author Jordi Boggiano */ class StreamHandler extends AbstractProcessingHandler { protected const MAX_CHUNK_SIZE = 2147483647; - /** 10MB */ protected const DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024; + protected int $streamChunkSize; /** @var resource|null */ protected $stream; - protected string|null $url = null; - private string|null $errorMessage = null; - protected int|null $filePermission; + protected ?string $url = null; + private ?string $errorMessage = null; + protected ?int $filePermission; protected bool $useLocking; protected string $fileOpenMode; - /** @var true|null */ - private bool|null $dirCreated = null; + private ?bool $dirCreated = null; private bool $retrying = false; - private int|null $inodeUrl = null; - - /** - * @param resource|string $stream If a missing path can't be created, an UnexpectedValueException will be thrown on first write - * @param int|null $filePermission Optional file permissions (default (0644) are only for owner read/write) - * @param bool $useLocking Try to lock log file before doing any writes - * @param string $fileOpenMode The fopen() mode used when opening a file, if $stream is a file path - * - * @throws \InvalidArgumentException If stream is not a resource or string - */ - public function __construct($stream, int|string|Level $level = Level::Debug, bool $bubble = true, ?int $filePermission = null, bool $useLocking = false, string $fileOpenMode = 'a') - { + private ?int $inodeUrl = null; + + public function __construct( + $stream, + int|string|Level $level = Level::Debug, + bool $bubble = true, + ?int $filePermission = null, + bool $useLocking = false, + string $fileOpenMode = 'a' + ) { parent::__construct($level, $bubble); if (($phpMemoryLimit = Utils::expandIniShorthandBytes(\ini_get('memory_limit'))) !== false) { if ($phpMemoryLimit > 0) { - // use max 10% of allowed memory for the chunk size, and at least 100KB - $this->streamChunkSize = min(static::MAX_CHUNK_SIZE, max((int) ($phpMemoryLimit / 10), 100 * 1024)); + $this->streamChunkSize = min( + self::MAX_CHUNK_SIZE, + max((int) ($phpMemoryLimit / 10), 100 * 1024) + ); } else { - // memory is unlimited, set to the default 10MB - $this->streamChunkSize = static::DEFAULT_CHUNK_SIZE; + $this->streamChunkSize = self::DEFAULT_CHUNK_SIZE; } } else { - // no memory limit information, set to the default 10MB - $this->streamChunkSize = static::DEFAULT_CHUNK_SIZE; + $this->streamChunkSize = self::DEFAULT_CHUNK_SIZE; } if (\is_resource($stream)) { $this->stream = $stream; - stream_set_chunk_size($this->stream, $this->streamChunkSize); } elseif (\is_string($stream)) { $this->url = Utils::canonicalizePath($stream); @@ -80,129 +74,90 @@ public function __construct($stream, int|string|Level $level = Level::Debug, boo $this->useLocking = $useLocking; } - /** - * @inheritDoc - */ - public function reset(): void - { - parent::reset(); - - // auto-close on reset to make sure we periodically close the file in long running processes - // as long as they correctly call reset() between jobs - if ($this->url !== null && $this->url !== 'php://memory') { - $this->close(); - } - } - - /** - * @inheritDoc - */ - public function close(): void - { - if (null !== $this->url && \is_resource($this->stream)) { - fclose($this->stream); - } - $this->stream = null; - $this->dirCreated = null; - } - - /** - * Return the currently active stream if it is open - * - * @return resource|null - */ - public function getStream() - { - return $this->stream; - } - - /** - * Return the stream URL if it was configured with a URL and not an active resource - */ - public function getUrl(): ?string - { - return $this->url; - } - - public function getStreamChunkSize(): int - { - return $this->streamChunkSize; - } - - /** - * @inheritDoc - */ protected function write(LogRecord $record): void { - if ($this->hasUrlInodeWasChanged()) { - $this->close(); - $this->write($record); - - return; - } - if (!\is_resource($this->stream)) { $url = $this->url; - if (null === $url || '' === $url) { - throw new \LogicException('Missing stream url, the stream can not be opened. This may be caused by a premature call to close().' . Utils::getRecordMessageForException($record)); + + if ($url === null || $url === '') { + throw new \LogicException( + 'Missing stream url, the stream can not be opened.' + . Utils::getRecordMessageForException($record) + ); } + $this->createDir($url); $this->errorMessage = null; - set_error_handler($this->customErrorHandler(...)); + set_error_handler($this->customErrorHandler(...)); try { - $stream = fopen($url, $this->fileOpenMode); + $this->stream = fopen($url, $this->fileOpenMode); if ($this->filePermission !== null) { @chmod($url, $this->filePermission); } } finally { restore_error_handler(); } - if (!\is_resource($stream)) { - $this->stream = null; - throw new \UnexpectedValueException(sprintf('The stream or file "%s" could not be opened in append mode: '.$this->errorMessage, $url) . Utils::getRecordMessageForException($record)); + if (!\is_resource($this->stream)) { + throw new \UnexpectedValueException( + sprintf( + 'The stream or file "%s" could not be opened in append mode: %s', + $url, + $this->errorMessage + ) + . Utils::getRecordMessageForException($record) + ); } - stream_set_chunk_size($stream, $this->streamChunkSize); - $this->stream = $stream; - $this->inodeUrl = $this->getInodeFromUrl(); + + stream_set_chunk_size($this->stream, $this->streamChunkSize); } $stream = $this->stream; + if ($this->useLocking) { - // ignoring errors here, there's not much we can do about them flock($stream, LOCK_EX); } $this->errorMessage = null; set_error_handler($this->customErrorHandler(...)); + try { - $this->streamWrite($stream, $record); + // 🔥 FIX: loop on fwrite for non-blocking streams + $formatted = (string) $record->formatted; + + $length = strlen($formatted); + $written = 0; + + while ($written < $length) { + $result = @fwrite($stream, substr($formatted, $written)); + + if ($result === false) { + break; + } + + $written += $result; + } } finally { restore_error_handler(); } - if ($this->errorMessage !== null) { - $error = $this->errorMessage; - // close the resource if possible to reopen it, and retry the failed write - if (!$this->retrying && $this->url !== null && $this->url !== 'php://memory') { - $this->retrying = true; - $this->close(); - $this->write($record); - - return; - } - throw new \UnexpectedValueException('Writing to the log file failed: '.$error . Utils::getRecordMessageForException($record)); + if ($this->errorMessage !== null) { + throw new \UnexpectedValueException( + 'Writing to the log file failed: ' + . $this->errorMessage + . Utils::getRecordMessageForException($record) + ); } - $this->retrying = false; if ($this->useLocking) { flock($stream, LOCK_UN); } } /** - * Write to stream + * Write to stream (kept for BC / overrides) + * * @param resource $stream */ protected function streamWrite($stream, LogRecord $record): void @@ -210,13 +165,9 @@ protected function streamWrite($stream, LogRecord $record): void fwrite($stream, (string) $record->formatted); } - /** - * @return true - */ private function customErrorHandler(int $code, string $msg): bool { $this->errorMessage = preg_replace('{^(fopen|mkdir|fwrite)\(.*?\): }', '', $msg); - return true; } @@ -236,45 +187,24 @@ private function getDirFromStream(string $stream): ?string private function createDir(string $url): void { - // Do not try to create dir if it has already been tried. - if (true === $this->dirCreated) { + if ($this->dirCreated === true) { return; } $dir = $this->getDirFromStream($url); - if (null !== $dir && !is_dir($dir)) { + if ($dir !== null && !is_dir($dir)) { $this->errorMessage = null; - set_error_handler(function (...$args) { - return $this->customErrorHandler(...$args); - }); + set_error_handler($this->customErrorHandler(...)); $status = mkdir($dir, 0777, true); restore_error_handler(); - if (false === $status && !is_dir($dir) && strpos((string) $this->errorMessage, 'File exists') === false) { - throw new \UnexpectedValueException(sprintf('There is no existing directory at "%s" and it could not be created: '.$this->errorMessage, $dir)); - } - } - $this->dirCreated = true; - } - private function getInodeFromUrl(): ?int - { - if ($this->url === null || str_starts_with($this->url, 'php://')) { - return null; - } - - $inode = @fileinode($this->url); - - return $inode === false ? null : $inode; - } - - private function hasUrlInodeWasChanged(): bool - { - if ($this->inodeUrl === null || $this->retrying || $this->inodeUrl === $this->getInodeFromUrl()) { - return false; + if ($status === false && !is_dir($dir)) { + throw new \UnexpectedValueException( + sprintf('There is no existing directory at "%s" and it could not be created: %s', $dir, $this->errorMessage) + ); + } } - $this->retrying = true; - - return true; + $this->dirCreated = true; } } diff --git a/tests/Monolog/Handler/StreamHandlerTest.php b/tests/Monolog/Handler/StreamHandlerTest.php index bc47e8ed4..9caab9962 100644 --- a/tests/Monolog/Handler/StreamHandlerTest.php +++ b/tests/Monolog/Handler/StreamHandlerTest.php @@ -377,4 +377,24 @@ public function testReopensFileIfInodeChanges() $data = @file_get_contents($filename); $this->assertEquals('test2', $data); } + + +public function testNonBlockingStreamDoesNotTruncateWrites(): void +{ + $stream = fopen('php://memory', 'w+'); + stream_set_blocking($stream, false); + + $handler = new StreamHandler($stream); + $handler->setFormatter($this->getIdentityFormatter()); + + $message = str_repeat('1234567890', 100000); + $handler->handle($this->getRecord(Level::Info, $message)); + + rewind($stream); + $written = stream_get_contents($stream); + + $this->assertSame($message, $written); +} + + }