You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Copied ICC profile leaks when processing rejects before ownership transfer #4620
Thanks for the ICC ownership fix in #4615. The new regression test passes here. While testing the change, I found a memory leak when an operation fails after the input ICC profile has been copied. The example below triggers it by trying to composite an overlay larger than the resized image.
Is this a possible bug in a feature of sharp, unrelated to installation?
Yes. The source builds successfully and node -e "import 'sharp'" completes without error. The reproducer also checks that a successful operation preserves the ICC profile.
Are you using the latest version of sharp?
Tested against main at cf6f3a376387e51df46d3688f1dcf34e93595d64, which includes 6992852 from #4615. The checkout reports version 0.35.5, but I rebuilt the native addon from that commit.
What is the output of running npx envinfo --binaries --system --npmPackages=sharp --npmGlobalPackages=sharp?
Relevant macOS output, with executable paths omitted:
System:
OS: macOS 27.0.1
CPU: (18) arm64 Apple M5 Max
Binaries:
Node: 24.18.0
npm: 11.16.0
Also reproduced in local Docker on Ubuntu 24.04.4 ARM64, using GCC 13.3.0 and Node 24.19.0. Both builds use packaged libvips 8.18.7 and libultrahdr 2.0.2, with global libvips disabled. The @img/sharp-libvips-* packages are version 1.3.4.
Does this problem relate to file caching?
Adding sharp.cache(false) does not fix this problem.
The macOS results below use the exact script shown here, with caching disabled.
Does this problem relate to images appearing to have been rotated by 90 degrees?
Not applicable. The reproducer does not rotate the image.
What are the steps to reproduce?
Build the sharp checkout at the revision above.
Save the code below as repro.cjs in the checkout root.
Run it under a leak detector.
Running the script normally checks that the operations behave as expected. To see the leak, run it under a leak detector.
The unmodified build reports 100 leaks for 204800 total leaked bytes, with allocation stacks through sharp::GetProfile -> g_malloc. After the local fix described below, the same script reports zero leaks. Both builds reject all 100 oversized composites, and the successful operation still preserves the ICC profile.
On Linux, ASAN/LSAN reported 100 direct leaks totaling 196,000 bytes for the same operations with default caching, and zero after the fix. The fixture has a 1,960-byte profile; macOS rounds each allocation to 2,048 bytes.
The preload path is for the tested Ubuntu ARM64 environment; adjust it to the installed GCC ASAN library on other systems. The measured run used only the existing OPENSSL_init_crypto suppression for a 24-byte initialization allocation.
What is the expected behaviour?
Reject the oversized composite with Image to composite must have same dimensions or smaller, and free the copied ICC profile. Catching that error should not leave native memory allocated after each operation.
Please provide a minimal, standalone code sample, without other dependencies, that demonstrates this problem
'use strict';constassert=require('node:assert/strict');constpath=require('node:path');constsourceRoot=path.resolve(process.argv[2]||__dirname);constsharp=require(sourceRoot);constinput=path.join(sourceRoot,'test','fixtures','Landscape_8.jpg');constnativeAddons=Object.keys(require.cache).filter((filename)=>filename.endsWith('.node'));sharp.cache(false);console.log(JSON.stringify({entry: require.resolve(sourceRoot), nativeAddons,versions: sharp.versions,cache: 'disabled'}));constexpectedError=/Imagetocompositemusthavesamedimensionsorsmaller/;constoverlay=[{input: {create: {width: 17,height: 17,channels: 3,background: 'red'}}}];asyncfunctionmain(){for(letiteration=1;iteration<=100;iteration++){awaitassert.rejects(sharp(input).keepIccProfile().resize(16,16,{fit: 'fill'}).composite(overlay).toBuffer(),expectedError,`expected oversized-composite rejection ${iteration}/100`);}constoutput=awaitsharp(input).keepIccProfile().toBuffer();const{ icc }=awaitsharp(output).metadata();assert.ok(Buffer.isBuffer(icc)&&icc.length>0,'successful ICC-retention control should preserve its profile');if(typeofglobal.gc==='function')global.gc();console.log('100 expected rejections and successful ICC-retention control passed');}main().catch((error)=>{console.error(error.stack||error.message);process.exitCode=1;});
Please provide sample image(s) that help explain this problem
In 6992852, GetProfile changes from image-owned vips_malloc to g_malloc. The successful path in src/pipeline.cc transfers or frees the copied profile, but an exception before that point leaves it allocated.
I tested a local fix using std::unique_ptr<char, decltype(&g_free)> to own the copy until SetProfile succeeds. It keeps the profile length separately and releases ownership only after that call. Unused copies are freed at the existing cleanup point, and the deleter handles exceptions. This removed the measured leaks on macOS and Linux without changing the existing APIs.
Thanks @lovell, this fixes the oversized-composite leak. However, it appears there’s another error path that now leaks: requesting channel 3 from a three-channel image while retaining its ICC profile.
Save this as repro.cjs in the sharp checkout root, after rebuilding:
constassert=require('node:assert/strict');constsharp=require('./');sharp.cache(false);asyncfunctionmain(){constinput='test/fixtures/Landscape_8.jpg';for(leti=0;i<100;i++){awaitassert.rejects(sharp(input).keepIccProfile().extractChannel(3).toBuffer(),/Cannotextractchannel3fromimagewithchannels0-2/);}constoutput=awaitsharp(input).keepIccProfile().toBuffer();const{ icc }=awaitsharp(output).metadata();assert.ok(Buffer.isBuffer(icc)&&icc.length>0);if(global.gc)global.gc();console.log('100 expected rejections; ICC retention control passed');}main().catch(error=>{console.error(error);process.exitCode=1;});
The parent commit c73eac7 reports zero leaks. With 7106a07, the same test reports 100 leaks totaling 204,800 bytes, allocated through sharp::GetProfile.
The invalid-channel branch uses return Error(), which skips the new g_free(inputProfile.first) at the end of Execute(). Previously, unused profile copies were freed before reaching that branch.
Activity
Prevent memory leak on keepIccProfile error path #4620
lovell commentedon Oct 8, 2026
Thanks for reporting, fixed in (and unit test added) in commit 7106a07
gregbenz commentedon Oct 8, 2026
Thanks @lovell, this fixes the oversized-composite leak. However, it appears there’s another error path that now leaks: requesting channel 3 from a three-channel image while retaining its ICC profile.
Save this as
repro.cjsin the sharp checkout root, after rebuilding:On macOS, run:
The parent commit
c73eac7reports zero leaks. With7106a07, the same test reports 100 leaks totaling 204,800 bytes, allocated throughsharp::GetProfile.The invalid-channel branch uses
return Error(), which skips the newg_free(inputProfile.first)at the end ofExecute(). Previously, unused profile copies were freed before reaching that branch.lovell commentedon Oct 8, 2026
Ah, I thought I'd replaced all those old
return Errorstatements but had clearly missed one, updated via commit aeab257 - thank you!