Skip to content

Fix memory leak: install module logger before the application is built - #147

Open
samdark wants to merge 5 commits into
Codeception:masterfrom
samdark:fix-issue-78-logger-order
Open

samdark wants to merge 5 commits into
Codeception:masterfrom
samdark:fix-issue-78-logger-order

Conversation

@samdark

@samdark samdark commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Closes #78.

Changes

  • src/Codeception/Lib/Connector/Yii2.php:
    • In startApp(), the module logger is installed before Yii::createObject() builds the application, and restored afterwards if the application's own bootstrap replaced the global logger during the build.
    • A register_shutdown_function() in the yii\log namespace is installed once per process. While an application is starting — and until it is reset — the shutdown registrations made by yii\log\Logger::init() (the flush closure and the final flush(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 calls Yii::getLogger() (log dispatcher construction and debug bootstrap). When no logger is installed yet, a plain yii\log\Logger is created at that point. Its init() calls register_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 exhausts memory_limit.

Installing the module logger before the build only covers the default logging setup: an application can still install its own plain logger (a bootstrap callback, an explicit Yii::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 the yii\log namespace (Yii does not import the function, so the unqualified calls in Logger::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 final flush(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 bootstrap callback 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 the yii-log failure report keeps the diagnostic messages of the test in which the failure happened. The module logger itself is never intercepted: its init() override performs no registration.

Related: #144 is a separate per-test leak with a different cause; this does not address it.

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.
@SamMousa

SamMousa commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

If the application bootstraps its own logger then we still get the issue with register shutdown function leaking memory

@samdark

samdark commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

Any idea on how to fix it?

@samdark

samdark commented Nov 4, 2026

Copy link
Copy Markdown
Member Author

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.

@SamMousa

SamMousa commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

One way to fix it would be to intercept the call to register_shutdown_function.
It'd actually allow for cleaner testing that includes the flush.
Since Yii does not use \register_shutdown_function nor does it import the function via use \register_shutdown_function normal namespace rules apply.

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
    
    }
}

so each test leaves behind its logger together with its dispatcher, log targets, logged messages and the application they hold.

Actually, dispatcher is only initialized after app construction (since it's an app component).
The idea is (i think, it's not originally my code) that we can have logs during this phase even when no dispatcher is set up yet.

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.
Copilot AI balanced review requested due to automatic review settings November 8, 2026 07:31
@samdark

samdark commented Nov 8, 2026

Copy link
Copy Markdown
Member Author

I tried this here, measuring it in isolation: 24 application build/reset cycles on a vendored yii2, with an app whose bootstrap installs its own plain yii\log\Logger (the residual case from the comment above), and counting what is still reachable after each reset.

As written, the interceptor forwards to the real register_shutdown_function in all cases, so it does not change retention: after 24 cycles one app logger is still retained per cycle, and its pending messages are flushed only at process exit (the current head of this PR shows the same behavior; it only fixes the default logging setup).

Storing instead of forwarding fixes it: while an application is starting, the registrations are stored with their arguments (Logger::init() registers both the flush closure and the final [$this, 'flush'], true — that argument gates target export and must survive the store/invoke round-trip), and resetApplication() runs them before tearing the application down, looping until the queue is empty because the stored outer closure re-registers the nested one. Result: no loggers retained across tests, and messages logged through the app's own logger during the test reach its targets instead of only at process exit. The module logger itself is never touched (its init() performs no registration).

Two implementation notes from the experiment: inside the eval'd namespace yii\log block the connector's statics must be referenced fully qualified (unqualified names resolve into yii\log), and the install guard is function_exists('\yii\log\register_shutdown_function').

Applied to the branch: bfb1f63.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread src/Codeception/Lib/Connector/Yii2.php Outdated
Comment on lines +198 to +199
$cap = 100;
while (self::$deferredShutdowns !== [] && $cap-- > 0) {
Comment thread src/Codeception/Lib/Connector/Yii2.php Outdated
Comment on lines 229 to 240
// 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();
}
Comment on lines +393 to +394
if (! function_exists('\yii\log\register_shutdown_function')) {
eval(<<<'PHP'
Comment on lines +397 to +399
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.
@samdark

samdark commented Nov 8, 2026

Copy link
Copy Markdown
Member Author

Small follow-up on the branch: c3d61e7.

Review of bfb1f63 caught one PHPStan finding: the splatted variadic args in registerShutdown() are typed 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 the fix is a no-op normalization to array_values($args) — no behavior change; the suite results and the retention measurements above still hold on the new head.

- 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.
@samdark

samdark commented Nov 8, 2026

Copy link
Copy Markdown
Member Author

Follow-up on the automated review — all four points checked in isolation, three led to changes in 9facd8c, one is a documented trade-off:

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 flush(true) it re-registers when invoked), so 51 loggers need 102 invocations and the old cap of 100 stops two entries short (reproduced: 49 flushed, 2 left). The cap is now sized at four times the queue's own size, which fully covers any finite logger queue; it still bounds pathological re-registration, and any leftover beyond that stays in the queue until the next drain — never in PHP's real shutdown registry. Re-verified: 51 logger pairs now drain to 51 flushed / 0 left.

2. closeSession() ordering (L242) — fixed as suggested. Confirmed the path: closeSession() calls getApplication(), which starts an application when Yii::$app is null. With the drain and the flag-clear happening first, that late build re-enabled interception and left its logger registrations pending — reproduced in isolation: after such a reset the flag was true, one callback was pending and that app's final flush had not run. closeSession() now runs first, while the flag is still on, so that build's registrations are captured and drained in the same reset; the drain still happens before Yii::$app = null, so targets that use the app during export are unaffected.

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 register_shutdown_function call was compiled (and required, served from the opcode cache) before the namespaced function existed; defining the function afterwards still intercepted both calls (hook_fired=2, no real shutdown registration at exit). So existing call sites are redirected. That said, the one real gap was the config file: it is included before the hook was installed, and a plain logger constructed there would have registered a real shutdown callback. The hook install and the flag now happen before include $this->configFile, closing that window.

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 flush() itself. That scenario sits outside this module's lifecycle: each test's application (and with it its loggers) is discarded on reset and the global logger is cleared, so an application-created logger is not shared across tests. Making the interception opt-in would require the application's own bootstrap code — the exact code path this is fixing — to know about it.

All re-verified at 9facd8c: phpstan report identical to the PR base (0 errors in the changed file), ECS clean, the six runnable local suites green (17 tests), and the 24-cycle real-module retention check unchanged (retained flat at 1, all 24 marker lines flushed in-run, module logger intact).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

High memory usage growing with each consecutive test

3 participants