Skip to content

Release the decoded image in decodeDynamic - #16

Open
frathe wants to merge 1 commit into
gen2brain:mainfrom
frathe:fix-decode-image-leak
Open

Release the decoded image in decodeDynamic#16
frathe wants to merge 1 commit into
gen2brain:mainfrom
frathe:fix-decode-image-leak

Conversation

@frathe

@frathe frathe commented Aug 20, 2026

Copy link
Copy Markdown

Fixes #15.

decodeDynamic calls heif_decode_image but never calls heif_image_release on the result, so libheif's decoded image buffer is leaked on every still-image decode. The context, image handle and decoding options are all released correctly — only the decoded image is missed. The sequence decoder already does this correctly (heifImageRelease(himg) further down the same file).

Because the leak is in libheif's own allocations rather than Go memory, neither the Go GC nor pprof can see it: HeapAlloc stays completely flat while process RSS grows without bound. On macOS it shows up in vmmap as MALLOC_LARGE.

Measurements

Decoding a single 12 MP HEIC (4032×3024) 60 times, with two forced runtime.GC() calls before each reading so nothing reachable from Go is counted:

decodes before after
20 433 MB 84 MB
40 785 MB 84 MB
60 1137 MB 90 MB

The Go heap reads a flat 1 MB throughout both runs. ~18.7 MB was leaked per decode, which is exactly one full YCbCr 4:2:0 buffer for that resolution (4032 × 3024 × 1.5 = 18.3 MB) — the entire decoded image. With the fix, RSS settles after libheif's one-time codec initialisation and stops growing.

Reproducer and full details are in #15.

Why defer is safe here

Every colorspace branch copies the pixel planes into Go-owned memory (copy(i.Y, ...), copy(i.Pix, ...)) before returning, so nothing references the native buffer past the end of the function. heifImageRelease is already declared and registered — it was simply never called on this path.

go build, go vet, gofmt and go test ./... all pass.

🤖 Generated with Claude Code

heif_decode_image allocates a heif_image that was never released, leaking
libheif's decoded buffer on every still-image decode - about 18.7 MB for a
12 MP photo, one full YCbCr 4:2:0 plane set. The context, image handle and
decoding options were all released correctly; only the image was missed.
The sequence decoder already releases its images.

The leak is in libheif's allocations rather than Go memory, so the Go GC
and pprof never see it: HeapAlloc stays flat while RSS grows without bound.

Deferring the release is safe because every colorspace branch copies the
pixel planes into Go-owned memory before returning, so nothing references
the native buffer once decodeDynamic returns.

Decoding one 12 MP HEIC 60 times, with two forced GCs before each reading:

    decodes   before     after
         20   433 MB     84 MB
         40   785 MB     84 MB
         60  1137 MB     90 MB

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

Memory leak: decodeDynamic never calls heif_image_release

1 participant