Skip to content

Misuse and lifecycle, watched by a sanitizer and a leak checker - #19

Merged
tamnd merged 9 commits into
mainfrom
misuse-lifecycle
Aug 19, 2026
Merged

Misuse and lifecycle, watched by a sanitizer and a leak checker#19
tamnd merged 9 commits into
mainfrom
misuse-lifecycle

Conversation

@tamnd

@tamnd tamnd commented Aug 19, 2026

Copy link
Copy Markdown
Owner

The suite this client was missing, and the two jobs that make it mean something.

The misuse tests are the ones nobody writes: a connection used after close, a result iterated twice, an appender flushed after its connection went away, a value nested deeper than the binding will follow. Writing them found a segfault, which is in here fixed: a recursion with no depth limit on the way in, now cut off at 64, and a write attempted through a read-only connection now refused by name instead of taken.

The two jobs see different things and that is why there are two. ASan instruments the source it compiles, so it catches what the extension does to its own memory and nothing about what the interpreter does on its behalf. Valgrind instruments the instructions that run, so it watches both sides of the boundary and every prebuilt thing either side links, and it reports a read of memory nobody wrote, which ASan does not look for at all.

Three things about the recipes worth knowing before changing them. PYTHONMALLOC=malloc is what makes the valgrind output readable, because CPython otherwise pools small objects behind its own allocator and every allocation the extension makes arrives as one 256KB arena with nothing inside it attributable to anybody. The leak job installs the pandas extra rather than all of them, because polars starts a thread pool on import and holds thread-local state per worker for the life of the process, and the one test that wants polars skips itself. And the tests that assert on a wall clock now carry a timing marker that both jobs deselect, because a ratio between two things a sanitizer slowed down by different factors measures the sanitizer.

tools/valgrind.supp names the interpreter binary as the allocator instead of listing the symptoms it produces, so it cannot grow a line every time somebody wants the build green. The gap that leaves is written into the file: a Python object this extension leaks was allocated by the interpreter on its behalf and lands under the rule, and that is the leak the suite catches from the other side by counting live connections after a collection and process descriptors across a thousand cycles.

The leak gate is validated the only way a gate can be, with a deliberate leak through ctypes that has to fail it.

584 tests pass locally, 8 deselected under the marker.

tamnd added 5 commits August 19, 2026 04:33
DX3 asks for a misuse suite and a lifecycle suite in every client.
The misuse half has been here since DX2; this is the other one, and
the two defects it turned up.

A parameter that contains itself was a segfault. The conversion walked
the value recursively, so a dict holding itself ran the C stack out
and took the interpreter with it, which arrives as a signal rather
than as a RecursionError because the recursion is on this side of the
boundary. It is refused at depth 64 now, where a real value never
reaches and a cycle always does, as a ValueError, which is what the
standard library raises for a circular reference.

A row appended through a connection that was closed under the appender
was accepted and buffered. The flush was where it was noticed, so the
same call was refused or not depending on whether the batch happened
to fill, which is a rule nobody can hold in their head. The connection
is checked where the appender is, at the call that made the mistake.

The lifecycle tests are three: a thousand connections opened and
closed, a thousand opened and dropped for the callers who never write
close, and one closed with an appender and a transaction still open on
it. Each counts the process descriptors from outside rather than
trusting the client to report on itself.
A sanitizer job and a leak job over the same suite, because they see
different things. ASan instruments the source it compiles and catches
what this extension does to its own memory; valgrind instruments the
instructions and catches both sides of the boundary, every prebuilt
thing either of them links, and a read of memory nobody wrote, which
ASan does not look for at all.

Both deselect the timing marker, which the tests that assert on a wall
clock now carry. A ratio between two things a sanitizer slowed down by
different factors is not a measurement of anything.

The leak job installs pandas rather than everything, because polars
starts a thread pool on import and holds thread-local state for the
life of the process, and none of it belongs here. tools/valgrind.supp
names the interpreter as the allocator rather than listing symptoms, so
it cannot grow a line every bad week, and it says out loud what that
leaves out.
Griffe reads the package the way a reader does, out of the .py files
and the .pyi that declares the compiled half, and it names what moved,
changed shape or went away. Nothing is built in this job on purpose:
the declared API is what the stub says, so the stub is what gets
compared, and a stub that has drifted from the extension is what the
typing tests in the suite are for.

A break is allowed and has to be paid for with a version bump, which is
the rule cargo semver-checks applies to the engine and the rule
api-extractor's committed report applies to the TypeScript client. The
change is fine; the change happening quietly is not.

The gate is validated the way the leak gate beside it is: renaming a
class the DB-API layer inherits from has to fail it, and the tree goes
back before the real check reads it.
Two things the first run of these jobs found.

The sanitizer job had no pandas, so the two whole programs the README
publishes that call to_pandas failed inside it and the job was watching
a smaller suite than it claimed to. They are installed by name rather
than through the extra that names them, because the extra would build
this extension from source to read its metadata and this job builds its
own a step later.

The leak job counted possible losses as well as definite ones, and over
the whole suite that is 420 possible and zero definite, of which 417 are
pyarrow registering compute kernels into a static table it never tears
down. A possible loss is a block whose only surviving pointer is into
its middle, which is what a registry of C++ objects looks like from
outside and what almost nothing this extension allocates looks like.
The alternative was a suppression naming pyarrow, and naming a
dependency is how a suppression file starts growing. This is the same
pair of flags the TypeScript client's job carries.
The leak job was red on two counts, and neither of them was a leak.

Three tests assert wall clock. An empty transaction under 50 us, ten
thousand turns of the main thread while a result becomes Arrow, twenty
turns of the event loop while a statement runs. Under valgrind every
instruction is interpreted and the machine is between twenty and fifty
times slower, so all three fail and say so in microseconds, which reads
like a regression and is not one. They carry the timing marker now,
which is what that marker is for and what the job's own -m "not timing"
was already asking for.

The other count is the suppression file, which was letting through
twenty two records that belong to nobody here. Seventeen are readline
building its keymaps and terminfo strings at import and never freeing
them, which pytest pays for because pytest imports readline. One is
glibc keeping the old loader scope array after a dlopen grows it, which
it does on purpose so a thread walking the list does not have it freed
underneath. One is pyarrow caching a tzinfo. The rest are the
interpreter allocating for itself, and those should already have been
covered: the rule said obj:*/python3*, and the interpreter on the runner
is a shared build whose code lives in libpython3.14.so, which that
pattern does not name.

Fixing that turned up the more interesting half. A * in a valgrind
object pattern matches slashes, so */python3* also matched every
extension under lib/python3.14/site-packages, this one included. The
rule meant to name the interpreter was covering the code the job exists
to watch. It now names the two files an interpreter can be, the shared
library and the static binary, and nothing else.

The three library rules are narrow and each says what leaks, how much,
and why the library is right to do it. The gate step is unchanged and
still fires on its deliberate leak, which is what says the file did not
grow into a way of making the build green.
@tamnd
tamnd force-pushed the misuse-lifecycle branch from c0a0e48 to 7b17f45 Compare August 19, 2026 01:53
tamnd added 3 commits August 19, 2026 08:53
Both Windows rows have been red for a while, and the log said almost
nothing: five lines of dots, then an exit code, no failure line and no
summary. That shape is not a test failing. It is the interpreter not
being there to write the summary.

os.kill(os.getpid(), signal.SIGINT) is what this file used to press the
key, and on Windows os.kill is not a signal at all. CPython special
cases exactly two values there, CTRL_C_EVENT and CTRL_BREAK_EVENT, and
everything else falls through to OpenProcess and TerminateProcess. So
the four tests that press the key were terminating the process they were
running in, one of them at a time, and pytest died in the middle of the
first one.

The two console events are no better. A console event goes to every
process attached to the console, which on a build machine is the build.

What is left is the thing CPython itself calls once a real press has
arrived: PyErr_SetInterrupt, spelled _thread.interrupt_main. It trips
the same flag the console handler trips, so everything downstream of it
is the same code on both platforms, which is all of the code this
repository wrote. A tripped SIGINT, a statement deep in the executor,
and the next PyErr_CheckSignals turning it into a KeyboardInterrupt on
the main thread inside the fifty millisecond budget.

The hop that is no longer covered on Windows is the one from the
operating system into the C runtime, and nothing running inside this
process can cover it without taking the process with it, which is what
was happening. The helper says so where a reader will find it.
With the suite no longer terminating itself on Windows, the rows came
back with one failure on them, and it is a real one that has been there
the whole time behind the crash.

    %gql --read-only C:\data\social.zu1

opens nothing. The magic lexes its line with shlex.split, which is
POSIX by default, and POSIX means a backslash is an escape. So that
path arrives at connect as C:datasocial.zu1 and the person reads that
the system cannot find a file they are looking at in the directory
listing in front of them.

The line still has to be lexed rather than split on whitespace, because
a notebook on any platform can be pointed at a path with a space in it
and quoting is how somebody says so. What changes is the escape
character, which is now the platform's: none on Windows, backslash
everywhere else, so a Unix user goes on writing two\ words and a
Windows user goes on writing what every other program on their machine
accepts. The comment character goes off for the same reason shlex.split
turns it off, which is that # is a legal character in a file name.

Four tests. A quoted path with a space in it, which runs everywhere and
is the only reason any of this is lexed at all. The Windows separators,
which runs on Windows. The Unix escape, which runs everywhere else and
is there so this cannot be fixed by taking something away. And a hash
in a name.
Fixing the object patterns uncovered what they had been hiding: one
definite loss of eighty bytes inside this extension, on every run,
allocated while the module was still being executed.

It is pyo3's. A class with members gets its tp_members as a Vec
whose pointer is handed to CPython, and CPython holds that array
for as long as the type exists, so pyo3 leaks it rather than free
memory the interpreter is still reading. One class here has members
and the rest have getters, which is why it is one record and not
eight. It is in this object file only because pyo3 is compiled into
it.

Naming that in the suppression file takes a symbol, and the wheel a
release ships is stripped, which is why the report from CI read ???
on every frame. So the leaks job now builds with the symbol table
left on. The instructions are the same optimised ones, because a
build valgrind reads has to be the build that runs; what changes is
that a report from this job says where, which is the difference
between a leak checker and a number.
The job asked cargo to keep the symbols and got a stripped extension
anyway, because two things strip this wheel and only one of them is
cargo. `strip = true` in pyproject.toml is maturin's, applied to the
artifact after the build rather than through the profile, so a profile
that says to keep the symbols is a profile maturin then undoes.

The first full run said so: 555 passed in thirty minutes, then one
definite loss of eighty bytes with ??? on every frame. That record is
pyo3 handing a type its member table, which the suppression file has
named since it was written and could not match, because a rule that
names a function cannot match a frame that has no name.

MATURIN_STRIP is the setting that reaches the tool doing the stripping.
The cargo pair stays: it is what puts the debug info there in the first
place, and maturin only decides whether it survives.

And a step that says so before valgrind runs. The failure this had was
an hour of work followed by a report nobody could act on, and the check
that would have caught it in a second is a look at the file for the
section valgrind reads a frame's name out of.
@tamnd
tamnd merged commit 326234d into main Aug 19, 2026
34 checks passed
@tamnd
tamnd deleted the misuse-lifecycle branch August 19, 2026 04:44
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