Skip to content

Memory leak in JPEG EXIF rotation: rotate90/rotate270 leak the rotated pixel buffer #2572

Description

@iurisilvio

Bug

Image::rotatePixels() allocates a temporary unrotated buffer in the rotate90 and rotate270 lambdas via new uint8_t[n_bytes] and never frees it (src/Image.cc#L1397-L1411 and L1413-L1427).

Every JPEG decoded with EXIF orientation 5, 6, 7, or 8 (the "rotated 90/270" branches) leaks the full rotated pixel buffer (width × height × 4 bytes) on each loadImage call. Phone-camera JPEGs almost always carry an orientation tag, so this triggers on the majority of real-world uploads.

Minimal repro

Uses Landscape_6.jpg from the well-known recurser/exif-orientation-examples test-fixture repo (1200×1800, EXIF Orientation=6).

'use strict';
if (typeof gc !== 'function') {
  console.error('Run with --expose-gc');
  process.exit(1);
}
const canvas = require('canvas');
const url =
  'https://raw.githubusercontent.com/recurser/exif-orientation-examples/master/Landscape_6.jpg';
const ITER = parseInt(process.argv[2] || '100', 10);
const mb = (n) => +(n / 1024 / 1024).toFixed(1);

(async () => {
  const buf = Buffer.from(await (await fetch(url)).arrayBuffer());
  console.log(`fetched ${buf.length} bytes`);
  gc(); gc();
  const baseline = mb(process.memoryUsage().rss);
  console.log(`baseline rss=${baseline} MiB`);
  for (let i = 1; i <= ITER; i++) {
    const img = await canvas.loadImage(buf);
    img.src = Buffer.alloc(0); // best-effort cleanup
    if (i % 10 === 0) {
      gc(); gc();
      console.log(`iter=${i} rss=${mb(process.memoryUsage().rss)} MiB`);
    }
  }
  gc(); gc();
  const end = mb(process.memoryUsage().rss);
  console.log(`\nbaseline=${baseline}M end=${end}M delta=${(end - baseline).toFixed(1)}M per-iter=${(((end - baseline) * 1024) / ITER).toFixed(2)} KiB`);
})();

Run:

node --expose-gc repro.js 100

Observed (Linux arm64, node-canvas 3.2.3 from npm, Node 20)

baseline rss=74 MiB
iter=10  rss=171 MiB
iter=50  rss=500 MiB
iter=100 rss=911 MiB

baseline=74M end=911M delta=837M per-iter=8572 KiB

8.4 MiB/iter matches the formula exactly: 1200 × 1800 × 4 channels = 8,640,000 bytes. RSS grows linearly with no plateau — process OOMs around iter ~250 on a 2 GiB limit.

After fix (delete[] unrotated; added to both lambdas), same script on same machine plateaus at ~118 MiB after the first ~10 iterations and stays flat (delta=22M after 100 iter, all of it allocator warmup).

Expected

RSS stable across iterations.

Cause

rotate90 and rotate270 lambdas do:

auto rotate90 = [](uint8_t* pixels, int width, int height, int channels) {
  const int n_bytes = width * height * channels;
  uint8_t *unrotated = new uint8_t[n_bytes];  // ALLOC
  ...
  // NEVER `delete[] unrotated;`
};

Both lambdas should delete[] unrotated; before returning. Trivial one-line fix in each. PR to follow.

Notes

  • mirrorHoriz/mirrorVert lambdas in the same function are unaffected — they swap pixels in place without a temporary buffer.
  • Affects EXIF orientations 5, 6, 7, 8 (any that hit rotate90 or rotate270).
  • Reproduces on linux-x64 and darwin-arm64 prebuilt binaries.

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