Skip to content

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

@iurisilvio

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
    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