(This issue makes sense only after #2562)
Bug
canvas_state_t (CanvasRenderingContext2d.h:19-72) holds four cairo_pattern_t* members (fillPattern, strokePattern, fillGradient, strokeGradient) but the destructor only frees fontDescription. Every time a state is destroyed with any of those four pointers non-null, the cairo pattern leaks.
Additionally, the copy constructor copies the raw pointers without cairo_pattern_reference(). Multiple states (from save()/restore() chains) end up sharing the same cairo_pattern_t* with no shared ownership — so freeing in any state's destructor would dangle the others.
A related correctness issue: Context2d::_fillStyle / Context2d::_strokeStyle are weak references (.Reset(obj), refcount 0). Under prompt finalization (e.g. when NAPI_EXPERIMENTAL is enabled and node_api_post_finalizer runs the finalizer at a safe point), V8 can garbage-collect the JS CanvasGradient / CanvasPattern object while the Context2d's state still references its native cairo_pattern_t. Subsequent reads of ctx.fillStyle return a stale handle.
Reproduction
'use strict';
if (typeof gc !== 'function') {
console.error('Run with --expose-gc');
process.exit(1);
}
const canvas = require('canvas');
const ITER = parseInt(process.argv[2] || '20000', 10);
const MODE = process.argv[3] || 'gradient'; // 'gradient' or 'color' (control)
const mb = (n) => +(n / 1024 / 1024).toFixed(1);
(async () => {
gc(); gc();
const baseline = mb(process.memoryUsage().rss);
let last = baseline;
console.log(`mode=${MODE} iter=${ITER} baseline rss=${baseline} MiB`);
for (let i = 1; i <= ITER; i++) {
const c = canvas.createCanvas(200, 200);
const ctx = c.getContext('2d');
if (MODE === 'gradient') {
const grad = ctx.createLinearGradient(0, 0, 200, 200);
grad.addColorStop(0, 'red');
grad.addColorStop(1, 'blue');
ctx.fillStyle = grad;
} else {
ctx.fillStyle = 'red';
}
ctx.fillRect(0, 0, 200, 200);
c.width = 0; c.height = 0;
if (i % 2000 === 0) {
gc(); gc();
const rss = mb(process.memoryUsage().rss);
console.log(`iter=${i} rss=${rss}M slope=${(((rss - last) * 1024) / 2000).toFixed(2)} KiB/iter`);
last = rss;
}
}
})();
Observed on canvas@3.2.3 from npm (Linux arm64, Node 20)
mode=gradient iter=10000 baseline rss=46 MiB
iter=2000 rss=51.7M slope=2.92 KiB/iter
iter=4000 rss=60.5M slope=4.51 KiB/iter
iter=6000 rss=70.4M slope=5.07 KiB/iter
iter=8000 rss=80.0M slope=4.92 KiB/iter
iter=10000 rss=89.6M slope=4.92 KiB/iter
per-iter=4.97 KiB
Slope is consistent ~5 KiB/iter — each iteration leaks roughly one cairo_pattern_t worth (a few hundred bytes for solid gradients plus cairo's internal metadata). Slope on mode=color is ~0 since the four pattern fields stay null.
Fix
Take refcounted ownership in canvas_state_t:
- Setters refcount the new pattern (
cairo_pattern_reference) and release the previous one (cairo_pattern_destroy).
- Copy constructor and
operator= reference each pattern instead of copying raw pointers.
- Destructor releases all four patterns.
- New helpers
setFillPattern / setStrokePattern / setFillGradient / setStrokeGradient / clearFillPattern / clearStrokePattern enforce the discipline at the call sites in Context2d::SetFillStyle / Context2d::SetStrokeStyle / _setFillColor / _setStrokeColor.
For the style retention bug, change _fillStyle.Reset(obj) and _strokeStyle.Reset(obj) to Reset(obj, 1) so the JS gradient/pattern object stays alive as long as the Context2d references its native cairo_pattern_t.
The same change should also clear the state stack in ~Context2d before destroying _layout/_context so state destructors run with a valid context, and replace resetState's single states.pop() with a while (!states.empty()) states.pop() followed by states.emplace() + state = &states.top() (the original pop() underflows if states is empty and leaves state dangling).
Affects
Any application that uses gradients or patterns in a canvas pipeline (data viz, charting, complex image processing). The bug is in core Context2d state ownership.
(This issue makes sense only after #2562)
Bug
canvas_state_t(CanvasRenderingContext2d.h:19-72) holds fourcairo_pattern_t*members (fillPattern,strokePattern,fillGradient,strokeGradient) but the destructor only freesfontDescription. Every time a state is destroyed with any of those four pointers non-null, the cairo pattern leaks.Additionally, the copy constructor copies the raw pointers without
cairo_pattern_reference(). Multiple states (fromsave()/restore()chains) end up sharing the samecairo_pattern_t*with no shared ownership — so freeing in any state's destructor would dangle the others.A related correctness issue:
Context2d::_fillStyle/Context2d::_strokeStyleare weak references (.Reset(obj), refcount 0). Under prompt finalization (e.g. whenNAPI_EXPERIMENTALis enabled andnode_api_post_finalizerruns the finalizer at a safe point), V8 can garbage-collect the JSCanvasGradient/CanvasPatternobject while the Context2d's state still references its nativecairo_pattern_t. Subsequent reads ofctx.fillStylereturn a stale handle.Reproduction
Observed on
canvas@3.2.3from npm (Linux arm64, Node 20)Slope is consistent ~5 KiB/iter — each iteration leaks roughly one
cairo_pattern_tworth (a few hundred bytes for solid gradients plus cairo's internal metadata). Slope onmode=coloris ~0 since the four pattern fields stay null.Fix
Take refcounted ownership in
canvas_state_t:cairo_pattern_reference) and release the previous one (cairo_pattern_destroy).operator=reference each pattern instead of copying raw pointers.setFillPattern/setStrokePattern/setFillGradient/setStrokeGradient/clearFillPattern/clearStrokePatternenforce the discipline at the call sites inContext2d::SetFillStyle/Context2d::SetStrokeStyle/_setFillColor/_setStrokeColor.For the style retention bug, change
_fillStyle.Reset(obj)and_strokeStyle.Reset(obj)toReset(obj, 1)so the JS gradient/pattern object stays alive as long as the Context2d references its nativecairo_pattern_t.The same change should also clear the state stack in
~Context2dbefore destroying_layout/_contextso state destructors run with a valid context, and replaceresetState's singlestates.pop()with awhile (!states.empty()) states.pop()followed bystates.emplace()+state = &states.top()(the originalpop()underflows ifstatesis empty and leavesstatedangling).Affects
Any application that uses gradients or patterns in a canvas pipeline (data viz, charting, complex image processing). The bug is in core
Context2dstate ownership.