SWT off-heap memory leak: one PNG save leaks a full image, one g_free fixes it
An allocation stack that looks like a libpng leak turned out to be a
g_freeSWT forgot to call. We reproduced it with a program, proved the root cause with disassembly, fixed it with one line, and shipped patched jars for x86_64 and aarch64.
The symptom: a leak stack that points at libpng
A production seal-generation service (SWT + GTK rendering, ImageLoader.save writing PNGs)
was flagged by a native memory tracker with this leak stack:
g_try_realloc
libgdk_pixbuf-2.0 (png_set_mem_fn redirect)
png_write_chunk_data / png_write_row / png_write_rows (libpng16)
gdk_pixbuf_save_to_callbackv
gdk_pixbuf_save_to_bufferv
Java_org_eclipse_swt_internal_gtk_GDK_gdk_1pixbuf_1save_1to_1bufferv
org.eclipse.swt.graphics.ImageLoader.save
CreateSeal.getImgFile
CreateSealThread.run → Display.readAndDispatch
The process RSS only ever went up, until OOM restarts. SWT 3.115.100. The first instinct was "libpng leaks" — but the frame in the stack is just the encoder growing its output buffer on one realloc. The real question: who took that buffer, and who never gave it back.
Reproduce: run the leak out with a program
A minimal program (mirroring the production asyncExec shape) that loops
ImageLoader.save(stream, SWT.IMAGE_PNG); random noise makes the PNG (and the leak) as
large as possible:
ImageData data = ...; // 256x256 random-noise RGB
ImageLoader loader = new ImageLoader();
loader.data = new ImageData[]{ data };
for (int i = 0; i < 600; i++) {
loader.save(new ByteArrayOutputStream(), SWT.IMAGE_PNG);
}
Ran headless (Xvfb + GTK3), 600 iterations, sampling VmRSS / Java heap every 50:
| Experiment | RSS growth | Steady leak/save |
|---|---|---|
| SWT 3.115.100 | +174,940 KB | ~232 KB (PNG = 230,859 B) |
| SWT 3.134.0 (2026-06 release) | +169,904 KB | ~231 KB |
Both curves are perfectly linear, while the Java heap oscillates in 4–40 MB under normal GC — the growth is entirely off-heap. The harsh fact: the latest release, 3.134.0, leaks at the identical rate (upstream only fixed it on master on 2026-06-10, so it hasn't shipped yet).
Reproducing the production capture with async-profiler
The production stack was captured with async-profiler's nativemem event + jfrconv.
We ran the exact same pipeline locally:
# 1) start the "continuously leaking" repro (ReproPngLeak supports total<=0 infinite loop)
xvfb-run -a java -cp ... ReproPngLeak 0 256 noise sync # prints pid=10499
# 2) attach and trace native allocations (malloc/realloc)
asprof start -e nativemem -f app.jfr 10499
sleep 25 # let the leak accumulate (~230 KB/save, ~300 MB in 25s)
asprof stop 10499
# 3) generate the leak report (only allocations that were never freed) as flame-graph HTML
jfrconv --total --nativemem --leak app.jfr app-leak.html
asprof stop's nativemem summary — Top1 matches the production stack frame for frame:
--- 310731581 bytes (47.24%), 0 samples
[ 0] realloc_hook
[ 3] png_write_chunk_data
[ 7] png_write_row
[ 8] png_write_rows
[11] gdk_pixbuf_save_to_callbackv
[12] gdk_pixbuf_save_to_bufferv
[13] Java_org_eclipse_swt_internal_gtk_GDK_gdk_1pixbuf_1save_1to_1bufferv
[15] org.eclipse.swt.graphics.ImageLoader.save
47.24% / 310 MB of native allocations sit in exactly the buffer
gdk_pixbuf_save_to_bufferv returned and SWT never freed. The second stack
(png_malloc_warn → deflateInit2_, the zlib compression buffer) shares the same call
chain but is a transient encoding buffer that png_write_end releases — --leak keeps
only allocations that stay alive, so Top1 is the culprit. Raw artifacts (app.jfr,
app-leak.html) are in profiling/.
Version boundaries: why x86 passed load tests but aarch64 OOM'd
The classic production confusion: the same program, load-tested on x86 with an old build — fine; on aarch64 with a newer build — off-heap memory grows until OOM. Not because aarch64 SWT has some special bug: the two architectures simply had different versions available to choose from.
Old builds (SWT 3.7.x / native lib 3740) don't leak
In the SWT 3.7.x source, ImageLoader.save(OutputStream, int) is two lines total:
public void save(OutputStream stream, int format) {
if (stream == null) SWT.error(SWT.ERROR_NULL_ARGUMENT);
FileFormat.save(stream, format, this); // pure-Java encoder
}
There is no native ImageLoader in the 3740 era — saving always goes through the
pure-Java FileFormat path (PNGFileFormat, JPEGFileFormat, ...), which never calls
gdk_pixbuf_save_to_bufferv, so this leak cannot exist.
When did the leak appear
4.13 (2019-09, Bug 545032) introduced the native ImageLoader; GTK ImageLoader.save
became: pixel data → gdk_pixbuf_new_from_data → gdk_pixbuf_save_to_bufferv →
memmove into Java. The very first commit skipped freeing buffer[0] — the leak was
born and every release since has carried it.
Why aarch64 can't dodge it
Checking Maven Central's org.eclipse.swt.gtk.linux.aarch64: the earliest version is
3.115.0 (2020-11) — there are no older aarch64 native libraries at all. x86_64, by
contrast, has artifacts back to 3.105.x (2018).
| Arch | A leak-free old build available? | Version it must use | Result |
|---|---|---|---|
| x86_64 | ✅ 3.7.x (3740, pure-Java encoder) | optional | load tests pass (old build) |
| aarch64 | ❌ none (earliest is 3.115.0) | 3.115.0+ (all leak) | OOM under load |
So: x86's SWT wasn't leak-free — x86 just happened to run an unaffected old version. aarch64 had no fallback; from the moment it could run at all, it was on the leaking native path.
Lessons from this section
- When "the same program behaves differently per architecture", check version boundaries first instead of suspecting a platform-specific bug.
- Upgrades can silently swap implementation paths (pure Java → native); behavioral differences can go far beyond what the changelog says.
- When a new platform only has newer versions available, treat that newer version's historical defects — resource leaks especially — as a first-class suspect.
Root cause: three layers of evidence pin it on SWT
1. API contract
The gdk-pixbuf docs for gdk_pixbuf_save_to_bufferv() are unambiguous:
buffer — location to receive a pointer to the newly allocated buffer… The caller of the method takes ownership of the returned data, and is responsible for freeing it.
The returned gchar** buffer must be freed by the caller.
2. Java bytecode
Decompiled ImageLoader.save(OutputStream, int) (javap -c), PNG branch:
GDK.gdk_pixbuf_save_to_bufferv(pixbuf, buffer, len, type, null, null, null);
byte[] byteArray = new byte[(int) len[0]];
C.memmove(byteArray, buffer[0], byteArray.length); // copy only
stream.write(byteArray);
...
OS.g_free(buffer_ptr); // only the "pixel buffer" is freed; buffer[0] is never
buffer[0] (the PNG output buffer) has no free path on any exit.
3. Native disassembly
The JNI glue Java_...GDK_gdk_1pixbuf_1save_1to_1bufferv (libswt-pi3-gtk-4940r23.so):
objdump -d shows it calls gdk_pixbuf_save_to_bufferv@plt twice and g_free@plt zero
times — it only writes the pointer back into a Java long[] via
Get/ReleaseLongArrayElements. The native side doesn't free it either.
Conclusion: buffer[0] has no release path on either side, so every save leaks ≈ one PNG.
And it's a bug that existed from day one — the native ImageLoader was introduced in
4.13 (Bug 545032) and the very first commit already skipped this free.
The fix: one line, verified at runtime
// after: C.memmove(byteArray, buffer[0], byteArray.length);
OS.g_free(buffer[0]);
A controlled experiment that replicates the native call chain line by line (the only
difference is that one g_free):
| Mode | RSS growth after 600 saves |
|---|---|
| buggy (same as ImageLoader.save) | linear +153,592 KB |
| fixed (+1 g_free) | completely flat after warm-up (0 leak) |
And the fixed build's PNG output is byte-identical to the official one — memory freed, output unchanged.
Why we didn't need to rebuild native libraries: aarch64 too
The fix is in the Java layer; buffer[0] is a jlong pointer with identical semantics
across architectures, so:
- the native
.sostays official and untouched; - the
ImageLoader.classin the x86_64 and aarch64 fragments is byte-identical, so one patch covers both platforms.
We used ASM to insert 4 instructions at the bytecode level
(ALOAD 28; ICONST_0; LALOAD; INVOKESTATIC OS.g_free:(J)V, reusing the existing
constant pool entry) and produced *-fixed.jar plus a one-click comparison test
(compare_fix.sh: three assertions — official leaks / fixed flattens / output identical).
Both x86_64 (native) and aarch64 (qemu-user + official arm64 GTK libs + Temurin aarch64
JDK) pass.
Filing the issue upstream: the 2026 way
Eclipse Platform has migrated from Bugzilla to GitHub Issues
(github.com/eclipse-platform/eclipse.platform.swt/issues). Search for duplicates first
(we found 0 for ImageLoader leak / pixbuf save), then fill in the official Bug report
template — mind the two fields people get wrong:
- Environment: check
Linux, notAll OS(this bug is GTK-only; Windows/macOS use different encode paths); - Version since: 4.13 (2019-09) — verified from the source of the first native ImageLoader commit, not a guess.
Methodology: pinning down which commit introduced the fix
Asked "which commit fixed it?", answering "master has the line" isn't enough — an earlier commit might have added it. Use parent-commit comparison, three steps:
- Find candidates:
git log --oneline -- <file>(or the GitHub APIcommits?path=<file>). We narrowed it to50158e18("[Gtk] Fix NativeImageLoader save to jpg", 2026-06-10). - Inspect the diff:
git show 50158e18 -- <file>/commit/50158e18.patchshows+ OS.g_free(buffer[0]);with the comment "Free the buffer ... to avoid an off-heap memory leak". - Parent-commit comparison (the key):
git show 50158e18^:<file> | grep -c "g_free(buffer[0])"→ 0, while the commit itself has 1. The parent callsmemmovethenstream.writedirectly (leaking), and this commit adds the free — introduction point locked.
Full sha: 50158e18eaae64b673651d9056ef0320b538f33d, author Alexander Kurtakov, touching
only NativeImageLoader.java (+22/−11). Bonus finding: the commit message has no Bug
number — the leak was fixed on the way to fixing jpg saving, which is why a
leak-keyword search never surfaces it.
One-liner: "having the line" ≠ "this commit added it"; find candidates with
git log -- <file>, read the diff withgit show, and confirm 0→1 with the parent-commit comparison.
Takeaways
- Don't trust the "allocation site": a leak tracker shows who allocated the memory, not who leaked it. Chase the ownership and the free path instead. Here the allocation happened in libpng and the leak lived in SWT's Java layer — 8000 km away.
- Three layers of evidence: API docs (contract) → bytecode/source (behavior) → disassembly (fact). Any single layer is suggestive; three crossing layers are a verdict.
- Quantify before "fixing": is upgrading SWT going to help? Don't guess — run the same repro and compare steady-state slopes (3.134.0 and 3.115.100 slopes match = not fixed).
- Minimal repro + one-command verification: a 20-line snippet is enough for
maintainers to reproduce; a
compare_fix.shmakes the fix regression-testable. - An architecture-agnostic fix is a gift: a pure-Java patch means no native build chain, so arm64 servers benefit directly too.