Skip to content

Fix memory retention in sorting buffers - #210

Open
lucashabersaat wants to merge 2 commits into
mapbox:mainfrom
lucashabersaat:lhabersaat/leak
Open

lucashabersaat wants to merge 2 commits into
mapbox:mainfrom
lucashabersaat:lhabersaat/leak

Conversation

@lucashabersaat

Copy link
Copy Markdown

Clear the reusable sortArr and sortBuf arrays after z-order relinking. This prevents stale node references from retaining previously triangulated polygon graphs in case of decreasing input.

Verification

  • All 248 Earcut tests pass

Memory-retention reproduction

Used the script below to verify and measure heap retained after Earcut processes progressively smaller polygons.
Check out this branch, save the script as memory-retention.mjs, then run each commit in a separate Node process:

node --expose-gc memory-retention.mjs HEAD^
node --expose-gc memory-retention.mjs HEAD
Script
import { execFileSync } from "node:child_process";

const revision = process.argv[2];

if (!revision) {
    console.error("Usage: node --expose-gc memory-retention.mjs <git-revision>");
    process.exit(1);
}

if (typeof global.gc !== "function") {
    console.error("Run Node with --expose-gc.");
    process.exit(1);
}

const source = execFileSync("git", ["show", `${revision}:src/earcut.js`]);
const moduleUrl = `data:text/javascript;base64,${source.toString("base64")}`;
const {default: earcut} = await import(moduleUrl);

function polygon(count) {
    const coordinates = new Float64Array(count * 2);

    for (let i = 0; i < count; i++) {
        const angle = 2 * Math.PI * i / count;
        const radius = 1000 + (i % 2);
        coordinates[2 * i] = Math.cos(angle) * radius;
        coordinates[2 * i + 1] = Math.sin(angle) * radius;
    }

    return coordinates;
}

function collect() {
    for (let i = 0; i < 5; i++) global.gc();
    return process.memoryUsage().heapUsed;
}

for (let i = 0; i < 5; i++) earcut(polygon(100));
const before = collect();

for (let count = 8000; count >= 4000; count -= 400) {
    earcut(polygon(count));
}

for (let i = 0; i < 5; i++) earcut(polygon(10));
const after = collect();

console.log(JSON.stringify({
    revision,
    beforeMiB: before / 2 ** 20,
    afterMiB: after / 2 ** 20,
    retainedMiB: (after - before) / 2 ** 20
}, null, 2));

Results

Using Node 26.8.1:

Before this PR: 4.626 MiB retained
After this PR: 0.049 MiB retained


Tell me if there is anything else you need. :)

@lucashabersaat
lucashabersaat requested a review from a team as a code owner September 21, 2026 15:42
@lucashabersaat
lucashabersaat requested review from underoot and removed request for a team September 21, 2026 15:42
@mourner
mourner requested review from mourner and removed request for underoot September 21, 2026 15:50

@mourner mourner left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, as is this bring a +8% performance regression on our benchmark. But there seems to be an alternative that doesn't regress performance and also fixes memory retention — try this out.

Comment thread src/earcut.js Outdated
Comment on lines +547 to +548
sortArr.length = 0;
sortBuf.length = 0;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
sortArr.length = 0;
sortBuf.length = 0;
sortArr.fill(null, 0, n);
sortBuf.fill(null, 0, n);

@lucashabersaat

Copy link
Copy Markdown
Author

Makes sense, thanks for the suggestion. Had to cast the type, though.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants