Skip to content

test the ABI from C, starting with the programs that get it wrong - #7

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

tamnd merged 1 commit into
mainfrom
misuse

Conversation

@tamnd

@tamnd tamnd commented Aug 22, 2026

Copy link
Copy Markdown
Owner

This repository exists for the C ABI and every test in it was C++.

That sounds like a tidiness complaint and it is not. The wrapper is header-only, so a C++ test reaches the ABI through the wrapper, and every promise the wrapper keeps on your behalf is a promise the suite never once asked the ABI to keep. A destructor does not run twice. A zu::Connection has no null state to pass. A Result cannot outlive its Connection by accident, because the wrapper arranged that it cannot. All three of those are things the ABI guarantees, and all three were being tested by proxy or not at all.

The misuse scorecard item is exactly the category that falls in the gap: deliberately wrong programs, and for each one no crash, no leak, and a clear message. Most of those programs cannot be written in C++.

The harness

test/harness.h, which is harness.hpp with the C++ taken out. No dependency, same shape, same naming.

Two decisions worth knowing. The case list is written out by hand at the bottom, because C has no way to run code before main that works on every compiler this repository is willing to be built by. Forgetting to list a case is not silent: it is then a static function nothing calls, which is -Wunused-function, which is -Werror in CI. And a check that fails returns from its case rather than unwinding, so a failing case does not run its own cleanup and leaks what it was holding. That is on purpose. A failing case has already found the news, and the alternative is a goto ladder in every case obscuring the thing the case is about.

The suite

Fifteen cases in test/misuse.c. A statement used after its connection closed. A result outliving its connection on purpose, because the header says it may and a pool depends on it. A null handle into every call that takes one, including the accessors, whose whole error channel is the status. Every free and close given nothing, which a C cleanup path leans on in every line it writes. A cell index off both ends of both axes. A thousand connections opened and closed. Five hundred opens that failed. A transaction abandoned by closing rather than ending it, checked by reopening rather than by asking the connection that did the writing. And a case for the programs that look like misuse and are not, which is the half that decides whether a strict library is usable.

The descriptor cases measure the lowest free descriptor rather than counting anything, so they hold on both platforms. The leak they are about is the one that does not look like a leak: a host opening a connection per request falls over on the thousandth request, hours in, with a message about too many open files and nothing pointing here.

Two things it found

The watcher may call back in, partly. The concurrency case had to be deterministic, because two threads racing is not a test: whichever way it comes out, it came out that way once. The progress watcher solves it, since it runs on a thread of the library's and only while a statement is running. From in there:

zu_query_z         -> ZU_MISUSE_CONCURRENT
zu_prepare_z       -> ZU_MISUSE_CONCURRENT
zu_begin           -> ZU_MISUSE_CONCURRENT
zu_conn_rows_read  -> ZU_OK
zu_conn_interrupt  -> ZU_OK

The guard works. But the header names zu_conn_interrupt as the one call meant to cross threads, and zu_conn_rows_read is allowed too. The behaviour is right and the sentence is wrong: a watcher that wants to report a rate needs the row count and has no other way to reach it. Filed as tamnd/zu#618. All five statuses are asserted here, so whichever way that is resolved is a failing test rather than a surprise.

What a bad path answers, which I had wrong. A file that is there and is not a database is ZU_CORRUPT, with corrupt file header: ... too short to be a zu1 database. A path with nothing at it, and a path that is a directory, are ZU_IO. None of them is ZU_ERROR. That is the distinction a user actually needs, damaged against missing, and the engine already draws it well; nothing was checking that it does. The correction went onto tamnd/zu#614, which had reported the wrong status.

Leaks, checked rather than claimed

New CI step: the C suite runs once more under the sanitizers with leak detection on. The rest of the suite cannot do that, and this file can, because it is the one file here that gives everything back by hand rather than by destructor.

LSan counts every malloc it sees, including the ones inside libzu, which is uninstrumented but not invisible. So a connection or a result this suite dropped is caught even though the allocation happened on the far side of the ABI. That is what turns the item's "no leak" into a checked thing.

Verified against a deliberate leak before believing it, because a leak checker that reports nothing and a leak checker that is switched off read exactly the same.

Checked

38 of 38 green on server3 with gcc 13 against libzu at engine HEAD. The C suite clean under address and undefined behaviour with leaks on.

Clang is covered by CI rather than by me: there is no clang on the build host, and getting one there meant changing apt configuration that belongs to somebody else's software on a shared machine.

This repository exists for the C ABI and every test in it was C++.
That is not a small gap. The wrapper is header-only, so a C++ test
reaches the ABI through it, and everything the wrapper keeps for you is
something the suite never once asked the ABI to keep. A destructor does
not run twice. A zu::Connection has no null state to pass. A Result
cannot outlive its Connection by accident, because the wrapper arranged
that it cannot. Those are ABI promises, and they were all being tested
by proxy or not at all.

So: a C harness, and the misuse suite the scorecard item asks for,
written in C because that is the only place most of it can be written.

The harness is the C++ one with the C++ taken out. A hundred and fifty
lines, no dependency, a case list written by hand at the bottom because
C has no portable way to run code before main. Leaving a case out of
that list is still caught rather than silent: it is then a static
function nothing calls, which is -Wunused-function, which is -Werror in
CI. A check that fails returns from its case, so a failing case does not
run its own cleanup and leaks whatever it held, which is deliberate: the
failure is the news and a goto ladder in every case would bury the thing
the case is about.

Fifteen cases. A statement used after its connection closed, a result
outliving its connection on purpose because the header says it may, a
null handle into every call that takes one, every free and close given
nothing, a cell index off both ends of both axes, a thousand connections
opened and closed, five hundred opens that failed, a transaction
abandoned by closing rather than ending it, and the ones that look like
misuse and are not.

Two of them found things.

The concurrency case calls back into the library from the progress
watcher, which is the one place a second thread is certainly inside a
call at a known moment, so the check is deterministic rather than a race
run once. zu_query, zu_prepare and zu_begin all answer
ZU_MISUSE_CONCURRENT, which is the promise kept. zu_conn_rows_read
answers ZU_OK, and the header says it should not: it names
zu_conn_interrupt as the only call meant to cross threads. The behaviour
is right and the sentence is wrong, since a watcher reporting a rate
needs the row count and has no other way to it. Filed as tamnd/zu#618,
and all five statuses are asserted here so that whichever way it is
resolved is a failing test rather than a surprise.

The open case pins down what a bad path answers, which I had wrong.
A file that is there and is not a database is ZU_CORRUPT, a path with
nothing at it and a path that is a directory are ZU_IO, and none of them
is ZU_ERROR. That distinction is the one a user needs, damaged against
missing, and it was undocumented in the sense that nothing checked it.
The correction went onto tamnd/zu#614 too, which had reported the wrong
status.

Then the CI step, which is what turns the item's "no leak" into
something checked. The C suite runs once more under the sanitizers with
leak detection on, which the rest of the suite cannot do. It can because
it is the one file here that gives everything back by hand. LSan counts
every malloc it sees including the ones inside libzu, which is
uninstrumented but not invisible, so a handle this suite dropped is
caught even though the allocation happened on the far side of the ABI.
Verified against a deliberate leak first, because a leak checker that
reports nothing and a leak checker that is switched off read the same.

38 of 38 green on server3 against libzu at engine HEAD, and the C suite
clean under address and undefined behaviour with leaks on. Clang is
covered by CI rather than here: there is no clang on the build host and
installing one meant changing package configuration that belongs to
somebody else.
@tamnd
tamnd merged commit 552957d into main Aug 22, 2026
5 checks passed
@tamnd
tamnd deleted the misuse branch August 22, 2026 15:35
tamnd added a commit to tamnd/zu that referenced this pull request Aug 22, 2026
tamnd/zu-c#7 put a C test harness and a fifteen case misuse suite in
the repository that had, until now, no C tests at all. The row goes 22
to 39 of the 90 it owes.

Worth saying why that was the shape of the work rather than a file of
new cases. The wrapper is header-only, so a C++ test reaches the ABI
through it, and every promise the wrapper keeps for a caller is one the
suite had never asked the ABI to keep. Most of the misuse item is in
that gap: a destructor does not run twice, a zu::Connection has no null
state to pass, a Result cannot outlive its Connection by accident.

Two findings out of it, both filed: the progress watcher may call
zu_conn_rows_read though the header says only zu_conn_interrupt crosses
threads, which is #618, and a bad path answers ZU_CORRUPT or ZU_IO
rather than the ZU_ERROR I had reported on #614.
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