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.php — validateUploadFile() (line 622)
app/code/core/Mage/ImportExport/Model/Import/Uploader.php — init() registers the validateUploadFile callback
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:PHP's shutdown function registry holds a strong reference to the callable's object — in this case, the
Varien_Image_Adapter_Gd2instance. 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 destructorgc_collect_cycles()cannot free the object (refcount > 0, no cycle to collect)GdImagestored in$this->_imageHandleris never freed mid-requestThis was benign in PHP 7, where GD image resources were C-level resources that did not count toward
memory_limit. In PHP 8.0+,GdImageis a proper PHP object whose memory does count towardmemory_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 avarien/imagemodel to obtain the MIME type:The comment — "without counting toward memory limit" — was accurate for PHP 7. On PHP 8+,
GdImageobjects do count toward the limit, and theunset()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,500Varien_Imageinstances created, ~15 GB ofGdImagememory accumulated, and a fatal OOM long before completion.Verified with a minimal reproduction:
Output — memory grows ~10 MB per image and never drops:
Compare with direct GD usage, which frees correctly:
Proposed fix
There are two independent issues worth addressing:
Fix 1 —
Varien_Image_Adapter_Gd2: remove the shutdown functionThe shutdown function was added as a safety net to ensure
imagedestroy()is called if the destructor is skipped. In PHP 8+,GdImageobjects 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:
Fix 2 —
Mage_Catalog_Helper_Image::validateUploadFile: avoid GD entirelyvalidateUploadFile()only needs the MIME type, which is already available from thegetimagesize()call at the top of the same method. There is no need to open the image with Varien_Image: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
Mage_ImportExportproduct import with imagesFiles
lib/Varien/Image/Adapter/Gd2.php— constructor (line 33),destruct()methodapp/code/core/Mage/Catalog/Helper/Image.php—validateUploadFile()(line 622)app/code/core/Mage/ImportExport/Model/Import/Uploader.php—init()registers thevalidateUploadFilecallback