Automattic / node-canvas

Node canvas is a Cairo backed Canvas implementation for NodeJS.
10.13k stars 1.17k forks source link

Severe memory leak when rendering text repeatedly and using `deregisterAllFonts()` #1974

Open jhuckaby opened 2 years ago

jhuckaby commented 2 years ago

There appears to be a rather severe memory leak when rendering text repeatedly, and calling deregisterAllFonts() and registerFont() between each iteration.

Steps to Reproduce

Here is a simple script to reproduce and show the leak:

// This test demonstrates a memory leak when repeatedly registering, rendering, and deregistering a font.
// Run the script and look at the memory output, specifically the "rss" (resident memory) reading.

const Canvas = require('canvas');

console.log( "MEM BEFORE: ", process.memoryUsage() );

for (var idx = 0; idx < 2500; idx++) {
    Canvas.registerFont('futurastd-m.otf', { family: 'FuturaStdMedium' });

    var canvas = Canvas.createCanvas(640, 100);
    var ctx = canvas.getContext('2d');
    var text = 'Now is the time for all good men.';

    ctx.font = 'normal normal 36px FuturaStdMedium';
    ctx.fillText(text, 10, 35);

    var buf = canvas.toBuffer();

    buf = null;
    ctx = null;
    canvas = null;

    Canvas.deregisterAllFonts();
}

console.log( "MEM AFTER: ", process.memoryUsage() );

After running 2,500 iterations in a single process, the rss memory is upwards of 400MB on Linux, and 4GB on macOS.

I do realize that rendering text and producing PNG images takes some memory, but this seems to continually leak the longer it runs.

I have grabbed multiple JS heap dumps and compared them in Chrome Dev Tools. The memory is definitely not in Node.js land, so it must be on the C++ side of things.

Your Environment

Here is a ZIP file containing the script to reproduce, and the FuturaStdMedium OTF font:

https://pixlcore.com/public/1b019a4f65ab0694/node-canvas-font-mem-leak.zip

I suspect the problem is inside the deregisterAllFonts() function, which I myself wrote and introduced in PR #1811. However, I have been over the code many times and I can't see where anything could be leaking.

Another note of interest: The leak on macOS is easily 10 times worse than Linux, with the final memory clocking in at 4 GB after 2,500 iterations. It is also much slower, taking almost 10 minutes to run on my 2021 MBP. My theory here is that every time you call deregisterAllFonts() and then registerFont(), it re-registers all system fonts, and on macOS the list of built-in system fonts is vast. On my headless Linux box there are very few (if any) built-in system fonts, so the leak is slower there.

Final note: You don't actually have to call fillText() to reproduce the memory leak. Simply creating a canvas along with registerFont() and deregisterAllFonts() is enough to do it.

chearon commented 2 years ago

I haven't looked into this yet but it kind of sounds like the pango_cairo_font_map_set_default call here might not be releasing memory, even though it's documented to do so. Does the a similar leak happen if you don't call deregisterAllFonts?

jhuckaby commented 2 years ago

@chearon Yup, it sure does:

MEM BEFORE:  {
  rss: 38981632,      // 37.1 MB
  heapTotal: 5742592,
  heapUsed: 3029136,
  external: 934283,
  arrayBuffers: 25770
}
MEM AFTER:  {
  rss: 343392256,     // 327.4 MB
  heapTotal: 5353472,
  heapUsed: 2714592,
  external: 1581833,
  arrayBuffers: 612270
}

This is with the call to deregisterAllFonts() commented out. How interesting!

chearon commented 2 years ago

Indeed. That's a bit more evidence for a PangoFontMap leak but I can't find anything wrong with our usage... if removing the pango_cairo_font_map_set_default(NULL); calls fixes the memory leak, we would know it's the font map.

If you run with node --expose-gc and call global.gc() at the end of the loop, is there still a leak? There is one PangoCairoFontMap per ctx above, so that loop would have lots of memory until the old ctxes are freed.

jhuckaby commented 2 years ago

Yup, it still leaks with GC on every loop:

[jhuckaby@mendo pango-font-bug]$ node --expose-gc leak.js
MEM BEFORE:  {
  rss: 40337408,      // 38.4 MB
  heapTotal: 5742592,
  heapUsed: 2982232,
  external: 934304,
  arrayBuffers: 25770
}
MEM AFTER:  {
  rss: 290988032,     // 277.5 MB
  heapTotal: 4829184,
  heapUsed: 2529136,
  external: 987162,
  arrayBuffers: 17578
}

This is a bit less than usual (277 MB, down from 327 MB), so I doubled the iterations to 5,000 to see if the leak was linear, and continually getting worse as it went:

[jhuckaby@mendo pango-font-bug]$ node --expose-gc leak.js
MEM BEFORE:  {
  rss: 40386560,      // 38.5 MB
  heapTotal: 5480448,
  heapUsed: 2986208,
  external: 952830,
  arrayBuffers: 44296
}
MEM AFTER:  {
  rss: 511475712,     // 487.7 MB
  heapTotal: 4829184,
  heapUsed: 2531216,
  external: 987162,
  arrayBuffers: 17578
}

Yeah, it is.

EHadoux commented 1 year ago

What's the progress on this? We're (indirectly) using canvas to turn PDFs into pngs as an internal microservice on AWS but the containers eventually use all their memory up and need to be killed and restarted.

shevy11 commented 1 year ago

any updates on this one? we're using node-canvas in a serverless function that registers multiple fonts in each invocation which eventually reaches a memory limit if the function was up enough time

konser80 commented 9 months ago

Any updates on this? We need it too.