Summary
In the per-test lifecycle, Connector\Yii2::resetApplication() calls closeSession(), which calls getApplication(). When Yii::$app is already null (the normal state at that point), getApplication() builds a complete throwaway application just to close a session that doesn't exist, then immediately discards it.
The result is two full application boots per test instead of one, and the throwaway app leaves behind process-global state (most visibly the ErrorHandler's register_shutdown_function + its 256 KB memory reserve) that is never released, so memory grows linearly with the number of tests and large suites OOM.
Environment
codeception/module-yii2: 2.0.5
codeception/codeception: 5.3.5
phpunit/phpunit: 13.x
- PHP: 8.x
Root cause
Lib/Connector/Yii2.php (line numbers from 2.0.5):
// getApplication() — rebuilds when no app exists
protected function getApplication(): \yii\base\Application // ~L141
{
if (! isset(Yii::$app)) {
$this->startApp(); // <-- full application boot
}
return Yii::$app ?? throw new RuntimeException('Failed to create Yii2 application');
}
public function closeSession(): void // ~L542
{
$app = $this->getApplication(); // <-- triggers rebuild when null
if ($app instanceof \yii\web\Application && $app->has('session', true)) {
$app->session->close();
}
}
// resetApplication() — calls closeSession() before nulling the app
public function resetApplication(bool $closeSession = true): void // ~L158
{
if ($closeSession) {
$this->closeSession(); // <-- rebuild happens here
}
Yii::$app = null; // ~L166
...
}
The triggering call path each test (Module/Yii2.php):
- _after() → getClient()->resetApplication() (L448) → Yii::$app = null.
- next test _before() → recreateClient() → configureClient() → $client->resetApplication() (L378).
At this point Yii::$app is null, so closeSession() → getApplication() → startApp()
builds a throwaway app. startApp() proper then builds the real app a moment later.
Note configureClient() calls resetApplication() with no argument, so the
closeSessionOnRecreateApplication config (only consulted in beforeRequest(), behind
if ($this->recreateApplication)) does not affect this path.
Reproduction / evidence
Counting Application constructions in a real unit suite (subclass that increments a counter
in __construct): 20 constructions for 10 tests = 2× per test. Backtraces:
build #1: Yii::createObject ← startApp ← Module\Yii2::_before (real app)
build #2: Yii::createObject ← startApp ← getApplication ← closeSession ← resetApplication (throwaway)
A standalone loop replicating the module's per-test cycle leaks ~0.33 MB/iteration;
calling resetApplication(false) (skipping the closeSession rebuild) drops it to ~0.04 MB/iteration.
Impact
- A full, redundant application boot on every test (CPU/time).
- Linear memory growth across a suite (orphaned shutdown-function + 256 KB reserve per throwaway
app), causing OOM on large suites.
Proposed fix
closeSession() should not create an application — there's nothing to close if none exists:
Yii::$app = null; // ~L166
...
}
The triggering call path each test (Module/Yii2.php):
- _after() → getClient()->resetApplication() (L448) → Yii::$app = null.
- next test _before() → recreateClient() → configureClient() → $client->resetApplication() (L378).
At this point Yii::$app is null, so closeSession() → getApplication() → startApp()
builds a throwaway app. startApp() proper then builds the real app a moment later.
Note configureClient() calls resetApplication() with no argument, so the
closeSessionOnRecreateApplication config (only consulted in beforeRequest(), behind
if ($this->recreateApplication)) does not affect this path.
Reproduction / evidence
Counting Application constructions in a real unit suite (subclass that increments a counter
in __construct): 20 constructions for 10 tests = 2× per test. Backtraces:
build #1: Yii::createObject ← startApp ← Module\Yii2::_before (real app)
build #2: Yii::createObject ← startApp ← getApplication ← closeSession ← resetApplication (throwaway)
A standalone loop replicating the module's per-test cycle leaks ~0.33 MB/iteration;
calling resetApplication(false) (skipping the closeSession rebuild) drops it to ~0.04 MB/iteration.
Impact
- A full, redundant application boot on every test (CPU/time).
- Linear memory growth across a suite (orphaned shutdown-function + 256 KB reserve per throwaway
app), causing OOM on large suites.
Proposed fix
closeSession() should not create an application — there's nothing to close if none exists:
public function closeSession(): void
{
$app = Yii::$app; // was: $this->getApplication();
if ($app instanceof \yii\web\Application && $app->has('session', true)) {
$app->session->close();
}
}
(Equivalently, configureClient() could call resetApplication(false), since that pre-startApp reset has no live session to close.)
Happy to open a PR.
Summary
In the per-test lifecycle,
Connector\Yii2::resetApplication()callscloseSession(), which callsgetApplication(). WhenYii::$appis alreadynull(the normal state at that point),getApplication()builds a complete throwaway application just to close a session that doesn't exist, then immediately discards it.The result is two full application boots per test instead of one, and the throwaway app leaves behind process-global state (most visibly the
ErrorHandler'sregister_shutdown_function+ its 256 KB memory reserve) that is never released, so memory grows linearly with the number of tests and large suites OOM.Environment
codeception/module-yii2: 2.0.5codeception/codeception: 5.3.5phpunit/phpunit: 13.xRoot cause
Lib/Connector/Yii2.php(line numbers from 2.0.5):The triggering call path each test (Module/Yii2.php):
At this point Yii::$app is null, so closeSession() → getApplication() → startApp()
builds a throwaway app. startApp() proper then builds the real app a moment later.
Note configureClient() calls resetApplication() with no argument, so the
closeSessionOnRecreateApplication config (only consulted in beforeRequest(), behind
if ($this->recreateApplication)) does not affect this path.
Reproduction / evidence
Counting Application constructions in a real unit suite (subclass that increments a counter
in __construct): 20 constructions for 10 tests = 2× per test. Backtraces:
build #1: Yii::createObject ← startApp ← Module\Yii2::_before (real app)
build #2: Yii::createObject ← startApp ← getApplication ← closeSession ← resetApplication (throwaway)
A standalone loop replicating the module's per-test cycle leaks ~0.33 MB/iteration;
calling resetApplication(false) (skipping the closeSession rebuild) drops it to ~0.04 MB/iteration.
Impact
app), causing OOM on large suites.
Proposed fix
closeSession() should not create an application — there's nothing to close if none exists:
}
The triggering call path each test (Module/Yii2.php):
At this point Yii::$app is null, so closeSession() → getApplication() → startApp()
builds a throwaway app. startApp() proper then builds the real app a moment later.
Note configureClient() calls resetApplication() with no argument, so the
closeSessionOnRecreateApplication config (only consulted in beforeRequest(), behind
if ($this->recreateApplication)) does not affect this path.
Reproduction / evidence
Counting Application constructions in a real unit suite (subclass that increments a counter
in __construct): 20 constructions for 10 tests = 2× per test. Backtraces:
build #1: Yii::createObject ← startApp ← Module\Yii2::_before (real app)
build #2: Yii::createObject ← startApp ← getApplication ← closeSession ← resetApplication (throwaway)
A standalone loop replicating the module's per-test cycle leaks ~0.33 MB/iteration;
calling resetApplication(false) (skipping the closeSession rebuild) drops it to ~0.04 MB/iteration.
Impact
app), causing OOM on large suites.
Proposed fix
closeSession() should not create an application — there's nothing to close if none exists:
(Equivalently, configureClient() could call resetApplication(false), since that pre-startApp reset has no live session to close.)
Happy to open a PR.