From 364533a7eda7e68e7995549a1792bc259d233b93 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Fri, 7 Aug 2026 02:55:38 +0200 Subject: [PATCH] Stop a logger from reentering itself while it is dispatching (#19589) A logger that writes through a subsystem which logs its own failures can be asked to write again while it is still handling a message. Nothing noticed, so the two called each other until the process ran out of memory. The reachable route needs no exotic setup. A logger that stores rows in a table performs an insert, Cake\Database\Log\QueryLogger picks that insert up and calls Log::write('debug', ...), and the message comes straight back to the same logger, which inserts another row. Scopes do not help: BaseLog defaults to an empty scopes array, and Log::write() treats an empty array as matching everything, scoped messages included. So a logger configured with nothing but a className is exposed. A second route runs through the cache. An engine that cannot reach its backend logs the failure, and a table-backed logger asking for schema metadata lands back in the cache pool that is still being built. Track which streams are dispatching and skip a stream that is already inside a write. The nested message goes to error_log() rather than being dropped silently, and the other configured loggers still receive it, so one logger looping does not silence the rest of the stack. The marker is cleared in a finally, so a logger that throws does not disable itself for the remainder of the process. Keyed per stream rather than a single global flag on purpose. A global flag would suppress every logger for the duration of any nested write, which is a much broader change than the problem calls for. The tradeoff is that a cycle running across two different streams is not caught; that needs a depth counter and is not worth the complexity until someone hits it. --- Log.php | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/Log.php b/Log.php index ffecaf2b5..d570b7238 100644 --- a/Log.php +++ b/Log.php @@ -130,6 +130,15 @@ class Log */ protected static bool $_dirtyConfig = false; + /** + * Loggers currently dispatching a message, keyed by stream name. + * + * Used to detect a logger reentering itself, see {@link \Cake\Log\Log::write()}. + * + * @var array + */ + protected static array $writing = []; + /** * LogEngineRegistry class * @@ -332,6 +341,16 @@ public static function engine(string $name): ?LoggerInterface * then the logged message will be ignored and silently dropped. You can check if this has happened * by inspecting the return of write(). If false the message was not handled. * + * ### Reentrant log messages + * + * A logger that writes through a subsystem which logs its own failures can end up being asked + * to write again while it is still handling a message. A logger storing rows in a table is the + * usual example: the insert is picked up by the query logger, which writes a log message, which + * reaches the same logger. Left alone this repeats until the process runs out of memory. + * + * While a logger is dispatching, it will not be handed another message. The nested message goes + * to `error_log()` instead so it is not lost, and the other configured loggers still receive it. + * * @param string|int $level The severity level of the message being written. * The value must be an integer or string matching a known level. * @param \Stringable|string $message Message content to log @@ -377,7 +396,23 @@ public static function write(string|int $level, Stringable|string $message, arra is_array($scopes) && array_intersect((array)$context['scope'], $scopes); if ($correctLevel && $inScope) { - $logger->log($level, $message, $context); + if (isset(static::$writing[$streamName])) { + error_log(sprintf( + '[cake][%s] %s (dropped: logger `%s` reentered while writing)', + $level, + $message, + $streamName, + )); + + continue; + } + + static::$writing[$streamName] = true; + try { + $logger->log($level, $message, $context); + } finally { + unset(static::$writing[$streamName]); + } $logged = true; } }