Skip to content

Memory leak in bulk product import: Varien_Image_Adapter_Gd2 registers shutdown function preventing GC of GdImage objects #5638

Description

@ashtriggs

Summary

Importing products with images via ImportExport (Mage_ImportExport) causes a persistent memory leak of ~10 MB per image on PHP 8+. On large imports (hundreds of products), this causes a fatal OOM error before the import completes.

Root cause

Varien_Image_Adapter_Gd2::__construct() (line 33) registers a PHP shutdown function holding a reference to $this:

public function __construct()
{
    // Initialize shutdown function
    register_shutdown_function([$this, 'destruct']);
}

PHP's shutdown function registry holds a strong reference to the callable's object — in this case, the Varien_Image_Adapter_Gd2 instance. This means the refcount for every Gd2 adapter object created during a request never reaches zero until PHP shuts down. Consequently:

  • unset($adapter) does not trigger the destructor
  • gc_collect_cycles() cannot free the object (refcount > 0, no cycle to collect)
  • The GdImage stored in $this->_imageHandler is never freed mid-request
  • Each image opened via Varien_Image leaks ~10 MB for the lifetime of the PHP process

This was benign in PHP 7, where GD image resources were C-level resources that did not count toward memory_limit. In PHP 8.0+, GdImage is a proper PHP object whose memory does count toward memory_limit, so the leak now causes fatal OOM errors on bulk operations.

Reproduction

The leak is triggered by Mage_Catalog_Helper_Image::validateUploadFile() (line 622), which creates a varien/image model to obtain the MIME type:

$_processor = Mage::getModel('varien/image', $filePath);
$mimeType = $_processor->getMimeType();

// Force garbage collection since image handler resource uses memory without counting toward memory limit
unset($_processor);

The comment — "without counting toward memory limit" — was accurate for PHP 7. On PHP 8+, GdImage objects do count toward the limit, and the unset() has no effect because of the shutdown function registration.

This callback is registered by Mage_ImportExport_Model_Import_Uploader::init() and fires for every image processed during import. A bulk import of 500 products with 3 images each means ~1,500 Varien_Image instances created, ~15 GB of GdImage memory accumulated, and a fatal OOM long before completion.

Verified with a minimal reproduction:

Mage::app('admin');
$images = glob('/path/to/media/import/*.jpg'); // 1500×1296 JPEGs

foreach (array_slice($images, 0, 5) as $i => $img) {
    $adapter = new Varien_Image_Adapter_Gd2();
    $adapter->open($img);
    printf('After open  %d: %d MB\n', $i + 1, memory_get_usage(false) >> 20);
    unset($adapter);
    gc_collect_cycles();
    printf('After unset %d: %d MB\n', $i + 1, memory_get_usage(false) >> 20);
}

Output — memory grows ~10 MB per image and never drops:

After open  1: 22 MB
After unset 1: 22 MB   ← NOT freed
After open  2: 32 MB
After unset 2: 32 MB
...

Compare with direct GD usage, which frees correctly:

$gd = imagecreatefromjpeg($img);
imagedestroy($gd);
unset($gd);
// Memory returns to baseline immediately ✓

Proposed fix

There are two independent issues worth addressing:

Fix 1 — Varien_Image_Adapter_Gd2: remove the shutdown function

The shutdown function was added as a safety net to ensure imagedestroy() is called if the destructor is skipped. In PHP 8+, GdImage objects are freed by the engine when their refcount reaches zero — the shutdown function is no longer needed and actively prevents normal cleanup.

Remove the shutdown registration and rely on the destructor:

public function __construct()
{
    // Removed: register_shutdown_function held a strong reference to $this, preventing
    // GC of GdImage objects mid-request on PHP 8+ (where GdImage counts toward memory_limit).
}

public function __destruct()
{
    $this->_imageHandler = null; // GdImage freed immediately when refcount reaches 0
}

Fix 2 — Mage_Catalog_Helper_Image::validateUploadFile: avoid GD entirely

validateUploadFile() only needs the MIME type, which is already available from the getimagesize() call at the top of the same method. There is no need to open the image with Varien_Image:

public function validateUploadFile($filePath)
{
    $maxDimension = Mage::getStoreConfig(self::XML_NODE_PRODUCT_MAX_DIMENSION);
    $imageInfo = getimagesize($filePath);
    if (!$imageInfo) {
        Mage::throwException($this->__('Disallowed file type.'));
    }
    if ($imageInfo[0] > $maxDimension || $imageInfo[1] > $maxDimension) {
        Mage::throwException($this->__('Disallowed file format.'));
    }

    // getimagesize() already provides MIME type — no need to open with Varien_Image/GD
    return isset($imageInfo['mime']);
}

Fix 1 addresses the root cause for all Varien_Image usage. Fix 2 is a simpler self-contained improvement for this specific code path.

Environment

  • PHP 8.4 (confirmed; likely affects all PHP 8.0+)
  • OpenMage current main branch
  • Triggered during Mage_ImportExport product import with images

Files

  • lib/Varien/Image/Adapter/Gd2.php — constructor (line 33), destruct() method
  • app/code/core/Mage/Catalog/Helper/Image.phpvalidateUploadFile() (line 622)
  • app/code/core/Mage/ImportExport/Model/Import/Uploader.phpinit() registers the validateUploadFile callback

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    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