Skip to content

Ask the allocator what the engine never got back - #16

Merged
tamnd merged 1 commit into
mainfrom
leaks
Aug 22, 2026
Merged

Ask the allocator what the engine never got back#16
tamnd merged 1 commit into
mainfrom
leaks

Conversation

@tamnd

@tamnd tamnd commented Aug 22, 2026

Copy link
Copy Markdown
Owner

The leaks item off the clients scorecard, which is the last piece of apparatus this client owes. Sanitizer and leak jobs in CI, because a binding holds native memory and the process that finds out later is the user's.

Why the suite cannot answer this

A test that closes nothing and asserts on a message passes. There is no Cleaner behind this client and nothing waits for a collector, which the API documents rather than apologises for, so a handle that is dropped stays open until the process ends and no allocator in Java will ever call it garbage: the pointer is still reachable from a live object. That makes an allocator outside the JVM the only thing that can see the mistake.

The narrow question

Pointing LeakSanitizer at a JVM that does nothing at all reports about a megabyte in several thousand allocations. All of it is the JVM's own, and it is not a bug: an exiting process has an operating system to give its pages back, and a shutdown that walks them is time spent for nobody.

So the question this asks is not "did anything leak". It is "of the blocks nobody freed, is any of them one the engine allocated", which is answerable, because a leak record carries the stack it came from.

Frame #1, and what the JNI row taught

Matching any frame in the stack is wrong, and the JNI provider is where that shows. Asking for a jmethodID allocates a JVM-side table entry the JVM never frees, and the stack for it runs through the shim, because the shim is what asked. Any-frame matching called seven of those ours on a clean run. At frame #1 they are os::malloc in libjvm, and frame #1 is whoever called malloc, since #0 is the sanitizer's own interceptor.

A block the shim really did allocate has the shim at #1 and is still caught, which is why the shim is named in the filter at all. The count of records let through is printed on every run rather than dropped quietly, so a decision this script made is a decision somebody can argue with.

The gate

A report with no libzu in it looks the same whether nothing leaked, the sanitizer was never loaded, the library was never called, or the driver exited early. Three of those four are green for the wrong reason.

So the driver runs first with ZU_LEAK_GATE=1, which drops a database, a connection, a statement, a result, an appender and a frame on the floor on purpose, and the script stops if that comes back clean. This is the same shape as zu-go's internal/leakgate, run first for the same reason.

What it did on server3

Both providers, against libzu built from the engine at HEAD:

Panama JNI
gate, records allocated by us 57 57
clean run, records allocated by us 0 0
clean run, records in total 545 390
clean run, records that only pass through us 0 7
what the JVM itself left 1,344,160 bytes 334,637 bytes

The gate records name zu_execute, zu_appender_open and zu_database_open. The full reactor is green beside all of it: 199 tck cases on Panama, 12 Arrow, BUILD SUCCESS.

What is in here

zudb-tck/src/main/java/dev/zudb/tck/Leaks.java is the driver, and it lives in the tck because both providers owe the same answer. It runs an in-memory database with a frame the engine reads where it lies, a database on disk with a prepared statement, three appenders that end three different ways, a transaction and a duplicated connection, then the same handles on the paths that never reach the end: a syntax error, an undefined variable, a column read as the type it is not, a row past the end, a value the appender refuses. The failures are the half that matters, because a close that is never reached is the shape a leak has here.

scripts/leaks.sh finds the runtime and the symbolizer, builds the driver and one provider, and runs the gate and then the clean pass. ASAN_OPTIONS turns off handle_segv and allows a user handler, because a JVM uses the faults its own handlers catch as ordinary control flow and a sanitizer that takes those first turns a working JVM into a crash on the first query.

The CI job runs both providers on Linux. Linux only: LeakSanitizer does not exist on macOS, and what covers the same ground on the darwin rows is the lifecycle half of the misuse suite, which counts open file descriptors either side of a few hundred failures and needs no allocator to agree with it.

The engine is not built with the sanitizer, deliberately. Interposing the allocator is enough to see a block nobody freed, and an instrumented engine would mean building the whole of Rust twice to answer a question about this repository.

The scorecard

That is 80 to 90 on practice for this client, which is the tier 1 threshold. What is left is api-map and perf, which are reports the release collects rather than apparatus, and zu-python, zu-node and zu-go are missing the same two.

A binding holds native memory and the process that finds out later is
the user's. The suite here cannot see that: a test that closes nothing
and asserts on a message passes, and what it left behind is somebody
else's problem an hour into a run.

So the allocator is asked instead. A driver in the tck opens and closes
every handle this client hands out, failures beside successes, and
scripts/leaks.sh runs it with LeakSanitizer ahead of the JVM and reads
the report for blocks the engine allocated and nobody gave back.

The narrow question is the whole trick. A JVM does not free at exit, on
purpose, so pointing a leak checker at one that does nothing at all
reports about a megabyte in several thousand allocations and none of it
is anything a caller can act on. What is answerable is whether any
unfreed block came out of libzu, and a leak record carries the stack it
was allocated from, so it is answerable by reading frame #1.

Frame #1 rather than any frame, because of what the JNI row turned up.
Asking for a jmethodID allocates a JVM-side table entry the JVM never
frees, and the stack for it runs through the shim because the shim is
what asked. Any-frame matching called seven of those ours and they are
not: at #1 they are os::malloc in libjvm. A block the shim really did
allocate has the shim at #1 and is still caught. The count of records
let through is printed rather than dropped quietly.

The gate runs first and has to fail. A report with no libzu in it looks
the same whether nothing leaked, the sanitizer was never loaded, the
library was never called, or the driver died early, and three of those
four are green for the wrong reason. So the driver is run once with
ZU_LEAK_GATE=1, which drops a database, a connection, a statement, a
result, an appender and a frame on the floor, and the script stops if
that comes back clean.

On server3, both providers: the gate leaks 57 records naming
zu_execute, zu_appender_open and zu_database_open, and the clean run is
0 of 545 on Panama and 0 of 390 on JNI, against a JVM whose own report
is a megabyte either way. The full reactor is green beside it, 199 tck
cases and 12 Arrow.

Linux only. LeakSanitizer does not exist on macOS, and what covers the
same ground there is the lifecycle half of the misuse suite, which
counts open file descriptors either side of a few hundred failures and
needs no allocator to agree with it.
@tamnd
tamnd merged commit 8809dd9 into main Aug 22, 2026
13 of 21 checks passed
@tamnd
tamnd deleted the leaks branch August 22, 2026 11:53
tamnd added a commit to tamnd/zu that referenced this pull request Aug 22, 2026
…ens and closes every handle the client hands out, run under LeakSanitizer with the report filtered to blocks the engine allocated, on both providers, with a gate that leaks on purpose first. (#595)

On server3 the gate leaks 57 records naming `zu_execute`, `zu_appender_open` and `zu_database_open`, and the clean run is 0 of 545 on Panama and 0 of 390 on JNI, against a JVM whose own unfreed megabyte is deliberately not read.

That takes zu-java to 90 on practice, which is the tier 1 threshold, and makes it the fourth client at its tier. What is left there is `api-map` and `perf`, which are reports the release collects rather than apparatus, and zu-python, zu-node and zu-go are missing the same two.

| Client | Practice | Missing |
|---|---|---|
| zu-python | 90 | `api-map`, `perf` |
| zu-node | 90 | `api-map`, `perf` |
| zu-go | 90 | `api-map`, `perf` |
| zu-java | 90 | `api-map`, `perf` |
| zu-c | 0 | everything |

`docs/clients/overview.md` is regenerated with `cargo run -q -p xtask -- clients` rather than edited.
BODY
zu-java holds leaks

tamnd/zu-java#16 landed the leaks item, so the row moves from 80 to 90
on practice, which is the tier 1 threshold.

A driver in the tck opens and closes every handle the client hands out,
failures beside successes, under LeakSanitizer, and the report is read
for blocks whose stack names libzu at frame #1. The gate runs first and
drops one of everything on the floor, because a report with no libzu in
it looks the same whether nothing leaked or the sanitizer was never
loaded.

Both providers on server3: 57 records when the gate leaks, 0 of 545 on
Panama and 0 of 390 on JNI when it does not.

docs/clients/overview.md is regenerated rather than edited.
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.

1 participant