Skip to content

closeSession() rebuilds a throwaway Application every test (redundant boot + memory leak) #144

Description

@achretien

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):

  1. _after() → getClient()->resetApplication() (L448) → Yii::$app = null.
  2. 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):

  1. _after() → getClient()->resetApplication() (L448) → Yii::$app = null.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions