Skip to content

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

@achretien

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions