Repository navigation
Conversation
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 Codeception#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.
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.
|
If the application bootstraps its own logger then we still get the issue with register shutdown function leaking memory |
|
Any idea on how to fix it? |
|
A plain Yii logger registers shutdown callbacks when constructed. Installing the module logger earlier prevents that implicit construction on the default path, but an explicitly configured or bootstrapped plain logger can still retain the application through its dispatcher and targets. For an exclusively owned logger, application-controlled teardown can flush pending messages and detach the dispatcher to release that graph; the logger itself remains registered until process exit. The module cannot safely apply that cleanup to every logger: a shared logger may still need its dispatcher, and detaching it loses later messages. A logger that avoids shutdown registration is another option, provided its flush/export behavior is handled explicitly. I've narrowed the PR description to make this boundary clear. |
|
One way to fix it would be to intercept the call to If we register a function in the namespace: namespace yii\log {
function register_shutdown_function($cb) {
// noop, or even better, register this on some static client stuff so it can be called after a test, or after a request
}
}
Actually, dispatcher is only initialized after app construction (since it's an app component). I think if you paste this inside the client this could work: public static function appStarting(): bool {
return self::$appStarting;
}
public static function registerShutdown(callable $callable, mixed ...$args)
{
// For now we NOOP it. Later we could store these and call them after a test / after a request
codecept_debug('Shutdown function registered, we\'re ignoring it');
}
private static bool $appStarting = false;
public function startApp(?\yii\log\Logger $logger = null): void
{
if (!function_exists('\yii\log\register_shutdown_function')) {
/**
* We cannot guarantee this code does not get executed in a prod environment.
* Therefore we register function that has a guard
*/
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 \register_shutdown_function($callable, ...$args);
}
}
PHP);
}
... |
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 Codeception#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.
|
I tried this here, measuring it in isolation: 24 application build/reset cycles on a vendored yii2, with an app whose As written, the interceptor forwards to the real Storing instead of forwarding fixes it: while an application is starting, the registrations are stored with their arguments ( Two implementation notes from the experiment: inside the eval'd Applied to the branch: |
There was a problem hiding this comment.
🟡 Changes recommended
Shutdown-callback interception can suppress shared loggers’ process-exit flushes and leave callbacks or application state retained after reset.
4 open issues
What changed in this PR
This PR addresses logger-related memory retention in the Yii2 connector and preserves failure diagnostics when application bootstrap replaces the global logger.
Changes:
- Installs the module logger before application construction and restores it afterward.
- Adds interception and reset-time replay of Yii logger shutdown callbacks.
- Adds regression suites for logger installation timing and failure-report logging.
| File | Description |
|---|---|
tests/cases/leak/functional/LeakCest.php |
Checks logger installation before application construction. |
tests/cases/leak/functional.suite.yml |
Enables modules for the leak regression suite. |
tests/cases/leak/config.php |
Configures the logger-observing application. |
tests/cases/leak/codeception.yml |
Configures the leak test project. |
tests/cases/leak/app/App.php |
Records the logger class at constructor entry. |
tests/cases/bootstrap-logger/functional/BootstrapLoggerCest.php |
Checks failure diagnostics after bootstrap replaces the logger. |
tests/cases/bootstrap-logger/functional.suite.yml |
Configures bootstrap-logger testing modules and steps. |
tests/cases/bootstrap-logger/config.php |
Installs a custom logger during bootstrap. |
tests/cases/bootstrap-logger/codeception.yml |
Configures the bootstrap-logger test project. |
tests/cases/bootstrap-logger/app/CustomLogger.php |
Provides a replacement logger without shutdown registration. |
tests/cases/bootstrap-logger/app/App.php |
Defines the bootstrap-logger application fixture. |
src/Codeception/Lib/Connector/Yii2.php |
Changes logger installation timing and shutdown-callback handling. |
Review effort: Balanced
| $cap = 100; | ||
| while (self::$deferredShutdowns !== [] && $cap-- > 0) { |
| // 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(); | ||
| } |
| if (! function_exists('\yii\log\register_shutdown_function')) { | ||
| eval(<<<'PHP' |
| if (\Codeception\Lib\Connector\Yii2::appStarting()) { | ||
| \Codeception\Lib\Connector\Yii2::registerShutdown($callable, ...$args); | ||
| return null; |
PHPStan types the splatted variadic args of registerShutdown() as array<int|string, mixed>, which did not match the declared list<mixed> 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.
|
Small follow-up on the branch: Review of |
- 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.
|
Follow-up on the automated review — all four points checked in isolation, three led to changes in 1. Drain cap (L201) — fixed. Confirmed the dynamics: the queue drains FIFO, and a plain logger contributes exactly two invocations per drain (the stored outer closure first, then the nested final 2. 3. Fallback caching (L396) — not a PHP issue; the window is now narrowed anyway. PHP resolves unqualified function calls at runtime with a per-call namespace fallback — it does not cache a decision to call the global function. Verified in isolation, including under the opcode cache: a class with an unqualified 4. Shared loggers (L401) — documented trade-off, no change. The deferral is the leak fix, and the flush is not dropped: buffered messages are exported to the targets during the reset, which is strictly earlier than process exit. The accepted cost is that a logger instance kept in use after its application's reset loses its process-exit auto-flush for any further messages — that logger would have to call All re-verified at |

Closes #78.
Changes
src/Codeception/Lib/Connector/Yii2.php:startApp(), the module logger is installed beforeYii::createObject()builds the application, and restored afterwards if the application's own bootstrap replaced the global logger during the build.register_shutdown_function()in theyii\lognamespace is installed once per process. While an application is starting — and until it is reset — the shutdown registrations made byyii\log\Logger::init()(the flush closure and the finalflush(true)) are stored on the connector with their arguments instead of being registered with PHP.resetApplication()runs the stored registrations until the queue is empty, before tearing the application down.Why
While
createObject()builds the application, the build itself already callsYii::getLogger()(log dispatcher construction and debug bootstrap). When no logger is installed yet, a plainyii\log\Loggeris created at that point. Itsinit()callsregister_shutdown_function()with a closure bound to itself, and PHP keeps every shutdown function until the process ends — so each test leaves behind its logger together with its dispatcher, log targets, logged messages and the application they hold. The retained set grows with every test, and a large suite eventually exhaustsmemory_limit.Installing the module logger before the build only covers the default logging setup: an application can still install its own plain logger (a
bootstrapcallback, an explicitYii::setLogger()), and that logger's shutdown registrations would still retain it — together with its dispatcher, targets and application state — until process exit. So while an application is starting, the module intercepts shutdown registrations made in theyii\lognamespace (Yii does not import the function, so the unqualified calls inLogger::init()resolve there) and stores the callables with their arguments instead of registering them.resetApplication()then invokes them until the queue is empty — invoking the stored outer closure re-registers the nested finalflush(true), which is why the drain loops. This also delivers messages that were logged through an application-bootstrapped logger during a test straight to its targets, instead of only at process exit.A
bootstrapcallback may also replace the global logger itself; restoring the module logger after the build keeps that replacement confined to the bootstrap phase, so the global logger is the module logger both during and after the build and theyii-logfailure report keeps the diagnostic messages of the test in which the failure happened. The module logger itself is never intercepted: itsinit()override performs no registration.Related: #144 is a separate per-test leak with a different cause; this does not address it.