From 971b39eec9bcd13ae053afbc98e097bf8fe7135e Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Tue, 3 Nov 2026 12:43:18 +0300 Subject: [PATCH 1/5] Install module logger before building the application in startApp() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While createObject() builds the application, the build itself already calls Yii::getLogger() (log dispatcher construction, debug bootstrap). If no logger is installed yet, a plain yii\log\Logger is created, and its init() registers a shutdown function that PHP retains for the whole process — along with the logger, its dispatcher, log targets and the application built by each test. Memory therefore grows with every test, and large suites OOM (issue #78). Install the module logger (which overrides init() to skip that registration) before the application is built, so no plain yii\log\Logger is created during bootstrap. Behavior after the build is unchanged: the same logger instance is installed in both orderings. Add a regression suite (tests/cases/leak) whose test double application records the class of the logger present while its constructor runs and asserts it is the module logger; it fails against the previous ordering. --- src/Codeception/Lib/Connector/Yii2.php | 18 +++++++---- tests/cases/leak/app/App.php | 33 ++++++++++++++++++++ tests/cases/leak/codeception.yml | 13 ++++++++ tests/cases/leak/config.php | 11 +++++++ tests/cases/leak/functional.suite.yml | 8 +++++ tests/cases/leak/functional/LeakCest.php | 38 ++++++++++++++++++++++++ 6 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 tests/cases/leak/app/App.php create mode 100644 tests/cases/leak/codeception.yml create mode 100644 tests/cases/leak/config.php create mode 100644 tests/cases/leak/functional.suite.yml create mode 100644 tests/cases/leak/functional/LeakCest.php diff --git a/src/Codeception/Lib/Connector/Yii2.php b/src/Codeception/Lib/Connector/Yii2.php index 379bfc1..f21dbfe 100644 --- a/src/Codeception/Lib/Connector/Yii2.php +++ b/src/Codeception/Lib/Connector/Yii2.php @@ -305,18 +305,24 @@ public function startApp(?\yii\log\Logger $logger = null): void self::MAIL_IGNORE => null// Do nothing }; + // Install the logger before the application is built: while `createObject()` + // runs, the application already calls `Yii::getLogger()` (its log dispatcher and + // debug bootstrap). If no logger is installed yet, a plain `yii\log\Logger` is + // created, and its `init()` registers a shutdown function that keeps it alive — + // along with its dispatcher, log targets and the application — after every test. + // The module logger overrides `init()` to avoid that, so it must be in place first. + if ($logger instanceof \yii\log\Logger) { + Yii::setLogger($logger); + } else { + Yii::setLogger(new Logger()); + } + // @phpstan-ignore argument.templateType $app = Yii::createObject($config); if (! $app instanceof \yii\base\Application) { throw new ModuleConfigException($this, "Failed to initialize Yii2 app"); } \Yii::$app = $app; - - if ($logger instanceof \yii\log\Logger) { - Yii::setLogger($logger); - } else { - Yii::setLogger(new Logger()); - } } /** diff --git a/tests/cases/leak/app/App.php b/tests/cases/leak/app/App.php new file mode 100644 index 0000000..ad47ae9 --- /dev/null +++ b/tests/cases/leak/app/App.php @@ -0,0 +1,33 @@ +loggerAtCtorStart = $this->loggerClass(); + parent::__construct($config); + } + + private function loggerClass(): ?string + { + static $property = null; + $property ??= new ReflectionProperty(\yii\BaseYii::class, '_logger'); + $logger = $property->getValue(); + return $logger === null ? null : $logger::class; + } +} diff --git a/tests/cases/leak/codeception.yml b/tests/cases/leak/codeception.yml new file mode 100644 index 0000000..03b3466 --- /dev/null +++ b/tests/cases/leak/codeception.yml @@ -0,0 +1,13 @@ +paths: + tests: . + output: ../../_output + data: _data + support: ../../_support +namespace: tests\ +extensions: + enabled: + - Codeception\Extension\RunFailed +modules: + config: + Yii2: + configFile: config.php diff --git a/tests/cases/leak/config.php b/tests/cases/leak/config.php new file mode 100644 index 0000000..c457bf4 --- /dev/null +++ b/tests/cases/leak/config.php @@ -0,0 +1,11 @@ + app\leak\App::class, + 'id' => 'leak', + 'basePath' => __DIR__, +]; diff --git a/tests/cases/leak/functional.suite.yml b/tests/cases/leak/functional.suite.yml new file mode 100644 index 0000000..318fbeb --- /dev/null +++ b/tests/cases/leak/functional.suite.yml @@ -0,0 +1,8 @@ +# Codeception Test Suite Configuration +# +# Suite for functional tests +actor: FunctionalTester +modules: + enabled: + - Yii2 + - Asserts diff --git a/tests/cases/leak/functional/LeakCest.php b/tests/cases/leak/functional/LeakCest.php new file mode 100644 index 0000000..1705dc0 --- /dev/null +++ b/tests/cases/leak/functional/LeakCest.php @@ -0,0 +1,38 @@ +assertInstanceOf(Logger::class, \Yii::getLogger()); + + $app = \Yii::$app; + $loggerAtCtorStart = $app instanceof App ? $app->loggerAtCtorStart : null; + + // The logger present while the application was constructed must already be the + // module logger — otherwise a plain `yii\log\Logger` was created mid-build. + $I->assertSame(Logger::class, $loggerAtCtorStart); + } +} From 5a3644df7365a1692d4d1567e12040c3e45a41d1 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Tue, 3 Nov 2026 13:43:03 +0300 Subject: [PATCH 2/5] Restore module logger after application bootstrap An application bootstrap may replace the global logger while the application is being built. The module still reports failure diagnostics from the logger it installed, so put that logger back after createObject() in addition to the pre-build installation. Add a regression covering the public _before/_failed/_after lifecycle with a bootstrap that installs a custom global logger. --- src/Codeception/Lib/Connector/Yii2.php | 18 ++++-- tests/cases/bootstrap-logger/app/App.php | 11 ++++ .../bootstrap-logger/app/CustomLogger.php | 20 ++++++ tests/cases/bootstrap-logger/codeception.yml | 12 ++++ tests/cases/bootstrap-logger/config.php | 23 +++++++ .../bootstrap-logger/functional.suite.yml | 7 +++ .../functional/BootstrapLoggerCest.php | 62 +++++++++++++++++++ 7 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 tests/cases/bootstrap-logger/app/App.php create mode 100644 tests/cases/bootstrap-logger/app/CustomLogger.php create mode 100644 tests/cases/bootstrap-logger/codeception.yml create mode 100644 tests/cases/bootstrap-logger/config.php create mode 100644 tests/cases/bootstrap-logger/functional.suite.yml create mode 100644 tests/cases/bootstrap-logger/functional/BootstrapLoggerCest.php diff --git a/src/Codeception/Lib/Connector/Yii2.php b/src/Codeception/Lib/Connector/Yii2.php index f21dbfe..16b1991 100644 --- a/src/Codeception/Lib/Connector/Yii2.php +++ b/src/Codeception/Lib/Connector/Yii2.php @@ -305,17 +305,16 @@ public function startApp(?\yii\log\Logger $logger = null): void self::MAIL_IGNORE => null// Do nothing }; - // Install the logger before the application is built: while `createObject()` + // Install the module logger before the application is built: while `createObject()` // runs, the application already calls `Yii::getLogger()` (its log dispatcher and // debug bootstrap). If no logger is installed yet, a plain `yii\log\Logger` is // created, and its `init()` registers a shutdown function that keeps it alive — // along with its dispatcher, log targets and the application — after every test. // The module logger overrides `init()` to avoid that, so it must be in place first. - if ($logger instanceof \yii\log\Logger) { - Yii::setLogger($logger); - } else { - Yii::setLogger(new Logger()); - } + $moduleLogger = $logger instanceof \yii\log\Logger + ? $logger + : new Logger(); + Yii::setLogger($moduleLogger); // @phpstan-ignore argument.templateType $app = Yii::createObject($config); @@ -323,6 +322,13 @@ public function startApp(?\yii\log\Logger $logger = null): void throw new ModuleConfigException($this, "Failed to initialize Yii2 app"); } \Yii::$app = $app; + + // The application's bootstrap may replace the global logger while it is being + // built (a `bootstrap` callback calling `Yii::setLogger()`, or a `logger` config + // entry). Restore the module logger afterwards so that messages emitted during the + // test reach it and `Module\Yii2::_failed()` can report them; otherwise such a + // bootstrap replacement would swallow the module's failure diagnostics. + Yii::setLogger($moduleLogger); } /** diff --git a/tests/cases/bootstrap-logger/app/App.php b/tests/cases/bootstrap-logger/app/App.php new file mode 100644 index 0000000..c5dfc37 --- /dev/null +++ b/tests/cases/bootstrap-logger/app/App.php @@ -0,0 +1,11 @@ + app\bootstrapLogger\App::class, + 'id' => 'bootstrap-logger', + 'basePath' => __DIR__, + 'bootstrap' => [ + static function (): void { + \Yii::setLogger(new \app\bootstrapLogger\CustomLogger()); + }, + ], + 'components' => [ + 'request' => [ + 'cookieValidationKey' => 'bootstrap-logger', + 'enableCsrfValidation' => false, + ], + ], +]; diff --git a/tests/cases/bootstrap-logger/functional.suite.yml b/tests/cases/bootstrap-logger/functional.suite.yml new file mode 100644 index 0000000..50b37a5 --- /dev/null +++ b/tests/cases/bootstrap-logger/functional.suite.yml @@ -0,0 +1,7 @@ +actor: FunctionalTester +modules: + enabled: + - Yii2 + - Asserts +steps: + - Codeception\Step\ConditionalAssertion diff --git a/tests/cases/bootstrap-logger/functional/BootstrapLoggerCest.php b/tests/cases/bootstrap-logger/functional/BootstrapLoggerCest.php new file mode 100644 index 0000000..f0eff1f --- /dev/null +++ b/tests/cases/bootstrap-logger/functional/BootstrapLoggerCest.php @@ -0,0 +1,62 @@ + __DIR__ . '/../config.php', + 'cleanup' => false, + 'transaction' => false, + ]); + + $test = new Unit('testBootstrapLogger505'); + + $module->_initialize(); + $module->_before($test); + + // The global logger must be the module logger both while the application + // is being built and once it is done — i.e. the bootstrap did not leave its + // custom global logger in place. + $I->assertInstanceOf(Logger::class, \Yii::getLogger()); + + \Yii::warning('bootstrap-logger-505', 'bootstrap-logger'); + $module->_failed($test, new \RuntimeException('intentional test failure')); + + $report = $test->getMetadata()->getReports()['yii-log'] ?? ''; + $I->assertStringContainsString('bootstrap-logger-505', $report); + + $module->_after($test); + } +} From bfb1f630376521a3a02484544e0e150774ce4057 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Sun, 8 Nov 2026 10:31:36 +0300 Subject: [PATCH 3/5] Defer yii\log shutdown registrations and run them on application reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plain yii\log\Logger built while the application is bootstrapped (e.g. one an application's own bootstrap callback installs) registers shutdown functions in init(). PHP keeps those closures until process exit, retaining the logger and with it its dispatcher, log targets, pending messages and the whole application — so each test leaves them behind and large suites exhaust memory (issue #78, the residual case the module-logger install does not cover). Install a namespace-level yii\log\register_shutdown_function once per process (Yii does not import the function, so unqualified calls in Logger::init() resolve there). While an application is being started — and until it is reset — store those registrations with their arguments instead of registering them. resetApplication() runs the stored registrations before tearing the application down, invoking them until the queue is empty (the stored outer closure re-registers the nested 'flush(true)' one, and the final flag must survive the round-trip to export the log targets). This removes the per-test logger retention without changing what the module logger reports: the module logger is still installed before the build and restored after it, and its init() never registers shutdown functions. Messages logged through an application-bootstrapped logger during a test are still delivered to its targets, now by the deferred drain instead of at process exit. --- src/Codeception/Lib/Connector/Yii2.php | 102 +++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/src/Codeception/Lib/Connector/Yii2.php b/src/Codeception/Lib/Connector/Yii2.php index 16b1991..3bac712 100644 --- a/src/Codeception/Lib/Connector/Yii2.php +++ b/src/Codeception/Lib/Connector/Yii2.php @@ -135,6 +135,73 @@ final class Yii2 extends Client */ private array $emails = []; + /** + * Whether an application is currently being started or already started and not yet reset. + * While this is true, `yii\log` shutdown registrations are stored (see + * `registerShutdown()`) instead of being registered for real: a real shutdown closure + * bound to a logger keeps the logger — and with it its dispatcher, log targets and the + * application they hold — alive until process exit, even though the application is + * discarded after every test. + * + * @internal + */ + private static bool $appStarting = false; + + /** + * Shutdown registrations captured in the `yii\log` namespace while an application is + * being started. `yii\log\Logger::init()` registers two of them per logger (a closure + * bound to the logger and `[$this, 'flush']` with `true`); they are run by + * `resetApplication()` instead of surviving to process exit. + * + * @internal + * + * @var list}> + */ + private static array $deferredShutdowns = []; + + /** + * @internal + * + * @return bool whether an application is currently being started or not yet reset + */ + public static function appStarting(): bool + { + return self::$appStarting; + } + + /** + * Stores a shutdown registration (with its arguments) instead of registering it, + * to be run later by `resetApplication()`. + * + * The arguments are kept because `yii\log\Logger::init()` registers + * `[$this, 'flush']` with `true` — the final-flush flag that gates target export — + * and it must survive the store/invoke round-trip. + * + * @internal + */ + public static function registerShutdown(callable $callable, mixed ...$args): void + { + self::$deferredShutdowns[] = [$callable, $args]; + } + + /** + * Runs the stored shutdown registrations. + * + * The loop continues until the queue is empty because invoking the stored outer closure + * re-registers the nested `[$this, 'flush']` one, which must in turn be invoked; the cap + * is a safety guard against pathological re-registration. + * + * @internal + */ + private static function runDeferredShutdowns(): void + { + $cap = 100; + while (self::$deferredShutdowns !== [] && $cap-- > 0) { + [$callable, $args] = array_shift(self::$deferredShutdowns); + \call_user_func($callable, ...$args); + } + } + /** * @internal */ @@ -158,6 +225,16 @@ private function getWebRequest(): YiiRequest public function resetApplication(bool $closeSession = true): void { codecept_debug('Destroying application'); + + // Run the shutdown registrations stored during the previous `startApp()` before the + // application is torn down: invoking them here (instead of letting them run at + // process exit) is what stops the loggers — and the dispatchers, targets and + // application they hold — from being retained for the whole test process. This is + // idempotent: when called before the next application is started, the queue is + // already empty. + self::runDeferredShutdowns(); + self::$appStarting = false; + if ($closeSession) { $this->closeSession(); } @@ -305,6 +382,31 @@ public function startApp(?\yii\log\Logger $logger = null): void self::MAIL_IGNORE => null// Do nothing }; + // Intercept shutdown registrations made in the `yii\log` namespace for the rest of + // this process. Yii does not import `register_shutdown_function`, so an unqualified + // call in `yii\log\Logger::init()` resolves to a namespace-level function when one + // is defined there; while `$appStarting` is on, those calls are stored instead of + // being registered. Without this, every plain logger built during the application + // bootstrap (e.g. one the application itself sets via a `bootstrap` callback) keeps + // itself alive — with its dispatcher, log targets and the application — until + // process exit, because PHP's shutdown closures outlive the discarded application. + if (! function_exists('\yii\log\register_shutdown_function')) { + eval(<<<'PHP' + namespace yii\log { + function register_shutdown_function(callable $callable, mixed ...$args) { + if (\Codeception\Lib\Connector\Yii2::appStarting()) { + \Codeception\Lib\Connector\Yii2::registerShutdown($callable, ...$args); + return null; + } + return \register_shutdown_function($callable, ...$args); + } + } + PHP); + } + // Keep it on through the whole test so loggers created after the build are + // captured too; `resetApplication()` runs the stored registrations and turns it off. + self::$appStarting = true; + // Install the module logger before the application is built: while `createObject()` // runs, the application already calls `Yii::getLogger()` (its log dispatcher and // debug bootstrap). If no logger is installed yet, a plain `yii\log\Logger` is From c3d61e70d46f1248ebb0ad501a46c753fc57cf7a Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Sun, 8 Nov 2026 11:25:53 +0300 Subject: [PATCH 4/5] Store deferred shutdown args as a list PHPStan types the splatted variadic args of registerShutdown() as array, which did not match the declared list of the deferred-shutdowns property. A variadic array is always int-keyed at runtime, so array_values() only normalizes the stored shape to the declaration. No behavior change. --- src/Codeception/Lib/Connector/Yii2.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Codeception/Lib/Connector/Yii2.php b/src/Codeception/Lib/Connector/Yii2.php index 3bac712..8fa1400 100644 --- a/src/Codeception/Lib/Connector/Yii2.php +++ b/src/Codeception/Lib/Connector/Yii2.php @@ -181,7 +181,9 @@ public static function appStarting(): bool */ public static function registerShutdown(callable $callable, mixed ...$args): void { - self::$deferredShutdowns[] = [$callable, $args]; + // `array_values()` normalizes the splatted args to a `list`: a variadic array is always + // int-keyed at runtime, so this only makes the stored shape match the property. + self::$deferredShutdowns[] = [$callable, array_values($args)]; } /** From 9facd8c89d68f11c5fea4503882a5a841c30f96f Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Sun, 8 Nov 2026 11:48:55 +0300 Subject: [PATCH 5/5] Address review findings on deferred shutdown handling - Drain cap: size it four times the queue's own size instead of a fixed 100. The queue drains FIFO and a plain logger contributes exactly two invocations per drain, so any finite logger queue drains fully; the cap exists only to bound pathological re-registration, and leftover entries stay in the queue until the next drain rather than in PHP's real shutdown registry. - resetApplication(): close the session before draining and clearing the app-starting flag. When Yii::$app was already null, closeSession() starts an application to close it; previously that happened after the flag was cleared, leaving its logger registrations pending with interception still enabled. - startApp(): install the interceptor and turn the flag on before the config file is included, so a plain logger constructed in the config (or a file it requires) is captured from the start. PHP resolves the unqualified call at runtime, so this also intercepts call sites compiled before the hook. --- src/Codeception/Lib/Connector/Yii2.php | 69 ++++++++++++++++---------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/src/Codeception/Lib/Connector/Yii2.php b/src/Codeception/Lib/Connector/Yii2.php index 8fa1400..84246eb 100644 --- a/src/Codeception/Lib/Connector/Yii2.php +++ b/src/Codeception/Lib/Connector/Yii2.php @@ -189,15 +189,19 @@ public static function registerShutdown(callable $callable, mixed ...$args): voi /** * Runs the stored shutdown registrations. * - * The loop continues until the queue is empty because invoking the stored outer closure - * re-registers the nested `[$this, 'flush']` one, which must in turn be invoked; the cap - * is a safety guard against pathological re-registration. + * The queue is drained FIFO. `yii\log\Logger::init()` — the only production producer — + * contributes exactly two invocations per logger (the stored outer closure first, then + * the nested final `flush(true)` it re-registers when invoked), so a cap of four times + * the queue's own size fully covers every finite logger queue. It is there to bound + * pathological re-registration in the `yii\log` namespace: when a callable stores more + * entries than the headroom allows, the drain stops and the leftover entries stay in + * the queue until the next drain — never in PHP's real shutdown registry. * * @internal */ private static function runDeferredShutdowns(): void { - $cap = 100; + $cap = 4 * count(self::$deferredShutdowns); while (self::$deferredShutdowns !== [] && $cap-- > 0) { [$callable, $args] = array_shift(self::$deferredShutdowns); \call_user_func($callable, ...$args); @@ -228,6 +232,15 @@ public function resetApplication(bool $closeSession = true): void { codecept_debug('Destroying application'); + // Close the session while the application is still reachable. If `Yii::$app` was + // already gone, `closeSession()` starts an application to close it; doing that + // before the drain keeps `$appStarting` on for that build, so its logger + // registrations are captured and drained below, and the flag is cleared right + // after — instead of being left pending with interception still enabled. + if ($closeSession) { + $this->closeSession(); + } + // Run the shutdown registrations stored during the previous `startApp()` before the // application is torn down: invoking them here (instead of letting them run at // process exit) is what stops the loggers — and the dispatchers, targets and @@ -237,9 +250,6 @@ public function resetApplication(bool $closeSession = true): void self::runDeferredShutdowns(); self::$appStarting = false; - if ($closeSession) { - $this->closeSession(); - } Yii::$app = null; \yii\web\UploadedFile::reset(); Event::offAll(); @@ -363,26 +373,6 @@ public function getCsrfParamName(): string public function startApp(?\yii\log\Logger $logger = null): void { codecept_debug('Starting application'); - $config = include $this->configFile; - if (! isset($config['class'])) { - $config['class'] = $this->applicationClass ?? \yii\web\Application::class; - } - - if (isset($config['container'])) { - Yii::configure(Yii::$container, $config['container']); - unset($config['container']); - } - - match ($this->mailMethod) { - self::MAIL_CATCH => $config = $this->mockMailer($config), - self::MAIL_EVENT_AFTER => $config['components']['mailer']['on ' . BaseMailer::EVENT_AFTER_SEND] = function (MailEvent $event): void { - if ($event->isSuccessful) { - $this->emails[] = $event->message; - } - }, - self::MAIL_EVENT_BEFORE => $config['components']['mailer']['on ' . BaseMailer::EVENT_BEFORE_SEND] = fn (MailEvent $event) => $this->emails[] = $event->message, - self::MAIL_IGNORE => null// Do nothing - }; // Intercept shutdown registrations made in the `yii\log` namespace for the rest of // this process. Yii does not import `register_shutdown_function`, so an unqualified @@ -392,6 +382,10 @@ public function startApp(?\yii\log\Logger $logger = null): void // bootstrap (e.g. one the application itself sets via a `bootstrap` callback) keeps // itself alive — with its dispatcher, log targets and the application — until // process exit, because PHP's shutdown closures outlive the discarded application. + // PHP resolves this call at runtime (namespace fallback per call), so defining the + // function here also intercepts call sites compiled before it; and `$appStarting` + // is on before the config file is included, so a plain logger constructed there — + // or in a file it requires — is captured from the start. if (! function_exists('\yii\log\register_shutdown_function')) { eval(<<<'PHP' namespace yii\log { @@ -409,6 +403,27 @@ function register_shutdown_function(callable $callable, mixed ...$args) { // captured too; `resetApplication()` runs the stored registrations and turns it off. self::$appStarting = true; + $config = include $this->configFile; + if (! isset($config['class'])) { + $config['class'] = $this->applicationClass ?? \yii\web\Application::class; + } + + if (isset($config['container'])) { + Yii::configure(Yii::$container, $config['container']); + unset($config['container']); + } + + match ($this->mailMethod) { + self::MAIL_CATCH => $config = $this->mockMailer($config), + self::MAIL_EVENT_AFTER => $config['components']['mailer']['on ' . BaseMailer::EVENT_AFTER_SEND] = function (MailEvent $event): void { + if ($event->isSuccessful) { + $this->emails[] = $event->message; + } + }, + self::MAIL_EVENT_BEFORE => $config['components']['mailer']['on ' . BaseMailer::EVENT_BEFORE_SEND] = fn (MailEvent $event) => $this->emails[] = $event->message, + self::MAIL_IGNORE => null// Do nothing + }; + // Install the module logger before the application is built: while `createObject()` // runs, the application already calls `Yii::getLogger()` (its log dispatcher and // debug bootstrap). If no logger is installed yet, a plain `yii\log\Logger` is