Skip to content

misuse and lifecycle suites, and the deadlock writing them found - #9

Merged
tamnd merged 4 commits into
mainfrom
misuse-work
Aug 19, 2026
Merged

misuse and lifecycle suites, and the deadlock writing them found#9
tamnd merged 4 commits into
mainfrom
misuse-work

Conversation

@tamnd

@tamnd tamnd commented Aug 18, 2026

Copy link
Copy Markdown
Owner

DX3 asks every client for a suite of deliberately wrong programs: no crash, no hang, no leak, and a clear error for each. test/misuse.test.mjs is that suite, 29 tests over a frozen table of 22 wrong programs plus seven lifecycle tests, and writing it found four ways this client failed the line on its own terms. The fixes are in this branch with it, because a suite that documents defects rather than catching them is a suite nobody trusts.

Clear is the hard word, so the header spells it out as four things a message has to do. It names the thing the caller named. It says what was expected. It is the engine's sentence rather than a syscall's, since "failed to fill whole buffer" is a true statement about a read that tells nobody which file was not a database. And it never describes this crate's insides, because "Failed to convert JavaScript value Number 42 into rust type String" is a message about napi to somebody who mistyped a variable.

That last one was literally what happened. A statement or a path that was not a string threw a raw napi TypeError synchronously out of query, exec, cursor, stream and connect, which broke the error model and broke the README's own promise that every failure is a rejection, so a caller who wrote await had nowhere to catch it. Both are read on this side now and refused inside the task as a ZuUsageError with a plain sentence.

Parameters passed as an array or as a string were silently accepted and bound as 0, 1, 2, so the statement ran with none of the caller's values and said nothing about it. They are refused now, with a message that says zu names its parameters rather than numbering them.

A parameter that contains itself walked until the stack ran out and took the process with it. It is refused at depth 64, which is set where a real value never reaches and a cycle always does.

The fourth was the one worth the day. A query on a connection whose stream was half-read waited forever, and eight concurrent streams on one connection hung the process so hard that process.exit(9) could not escape it. Started::stream held the connection's mutex for the whole life of the statement while the producer waited on the reader, and the reader was awaiting a statement that was waiting for that mutex. A stream takes the connection out of the slot now rather than locking it, so the next statement finds the slot empty and is told at once to read the stream to the end, cancel it, or open a second connection. The take and the two readings of an empty slot happen under the one lock that decides them, so a stream and a close that arrive together cannot both find the connection theirs, and a close that lands while a statement is running drops the connection rather than putting back what the caller closed.

The sanitizer and leak jobs are the other half of the checklist line. Both were built and run on Linux before being written down rather than after. The suite over an ASan-instrumented addon: 110 tests, clean, with -Zexternal-clangrt and the runtime preloaded because the addon is opened by require long after node has started. The suite under Valgrind with --leak-check=full --errors-for-leak-kinds=definite --error-exitcode=1: 110 tests, clean, no suppression file, which is worth saying out loud because the day it needs one is the day somebody should read what it wants suppressed before writing it down. There is no unsafe in this crate, which is the reason to run these rather than the reason not to: what a binding gets wrong is a handle used after its scope closed, and that is a use-after-free even where no block says so.

The published surface is unchanged. binding.d.cts is byte-identical, check:types and check:package pass, and the full suite is 119 tests, 110 passing, 9 skipped for the runtime without Temporal.

tamnd added 4 commits August 19, 2026 04:31
DX3 asks every client for a suite of deliberately wrong programs, no
crash, no hang, no leak, and a clear error for each. Writing the table
found three ways this client failed that on its own terms, so the
fixes are here with it.

A statement or a path that is not a string threw a napi TypeError
synchronously out of the call, which broke the promise the README
makes that every failure is a rejection, and the message it threw was
about converting a JavaScript value into a rust type, to somebody who
mistyped a variable. Both are read on this side now and refused inside
the task as a ZuUsageError.

Parameters passed as an array or a string were bound as 0, 1, 2 and
the statement ran with none of the caller's values and said nothing
about it. Refused now, with a message that says parameters are named.

A parameter that contains itself walked until the stack ran out.
Refused at depth 64, which no value anybody writes reaches.

The fourth was the one worth the day. A query on a connection whose
stream was half-read waited forever, and eight streams on one
connection hung the process past what process.exit could escape,
because a stream held the connection's mutex for the whole life of the
statement while the producer waited on a reader that was itself
waiting for that mutex. A stream takes the connection out of the slot
now instead of locking it, so the next statement finds the slot empty
and is told at once to read the stream out, cancel it, or open a
second connection. The take and the two readings of an empty slot are
decided under one lock, so a stream and a close that arrive together
cannot both find the connection theirs.

The sanitizer and leak jobs are the other half of the line. Both were
built and run on Linux before being written down: the suite over an
ASan-instrumented addon, and the suite under Valgrind with definite
leaks fatal. Both are clean, and Valgrind needs no suppression file,
which is worth saying out loud because the day it does is the day
somebody should read what it wants suppressed.
Both failures were real and neither was in the addon.

The leak job wanted a suppression file after all. Which node this runs
on decides what leaks: the one in my container links the system OpenSSL
and reports nothing, and the one the hosted images install has OpenSSL
statically inside it and holds twenty four bytes of built-in compression
table from the first use of the default library context to exit. There
is no version of this addon that frees that. So tools/valgrind.supp
names the binary that allocated rather than the leak that was reported,
which is the difference between a rule and a list that grows a line
every bad week, and it says in the file what it leaves out. The C
allocators were not enough on their own, because node is C++ and holds
most of what it holds through operator new, and the names have to be
the mangled ones because that is what valgrind matches whatever it
prints. The gate is validated the way a gate has to be: a deliberate
leak out of a preloaded shared object, which is the shape of the addon
itself, has to fail it.

The bun job has been red on main since before this branch, on the two
stream tests that build sixty thousand people. Bun's node:test shim
brings Bun's own five second per-test timeout, which node does not have
and a fixture that size does not fit inside on a shared runner. The
limit is now sixty seconds, where it catches a hang rather than a
fixture.
On x86 the suite passes and the job fails, on a conditional jump inside
Maglev while it compiles on a thread of its own. That is V8 reading its
own uninitialised memory a dozen frames from anything this package
wrote, and the only suppression that would cover it is one for
uninitialised values inside node, which is the class of error worth
keeping: an addon that hands back a half-filled buffer is what this tool
catches and the sanitizer does not. --no-opt turns the tier off instead,
which removes the noise where it comes from and costs a job already
fifty times slower than the real thing nothing it was measuring.
--no-opt left Maglev running and the leaks job stayed red on the same
uninitialised-value report inside MaglevGraphBuilder. The tiers each
have their own switch now, and a list of them goes stale the next time
V8 grows one, so use the flag that cannot be partially true. The suite
runs jitless in ten seconds.
@tamnd
tamnd merged commit 884e2f4 into main Aug 19, 2026
23 checks passed
@tamnd
tamnd deleted the misuse-work branch August 19, 2026 00:56
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