How it works
Run thousands of iterations inside a single JVM with strict boundaries. Coverage guides where to explore; availability invariants define what a bug is.
The loop
Every input flows through the same pipeline. Exploration (fuzzing / HTTP driving) doesn't get its own special path — it feeds the identical boundaries and invariant checks.
begin / end
coverage
-
Iteration boundary
The runner executes each iteration between strict
Agent.beginIteration()/Agent.endIteration()boundaries. This is the whole foundation: cleanliness is enforced, not assumed. Latency is measured before the end-of-iteration grace sleep so the grace period is never counted. -
Measure & check invariants
At iteration end the agent snapshots latency, heap delta, and thread count, then evaluates the configured invariants. An iteration is failing or interesting if it exceeds a latency budget, grows the heap past a bound, or leaves new non-daemon threads / un-shut executors behind. Each invariant is a hard oracle (fail fast) or a soft signal (record with triage metadata and continue).
-
Reset
Reset prefers enforced cleanliness, falling back to a child-first classloader swap of the target's package (
-Dbasquin.reset=classloader, preview). The philosophy is enforcement over inference — prove the JVM is clean rather than guess. -
Triage, off the hot path
Interesting inputs are saved with a classification, the invariant violated, stacks, and basic runtime stats — handed off through an in-process queue so triage work never slows the iteration loop.
Availability invariants
The invariants are the bug oracles. They surface the input- and state-dependent pathologies that crash-only testing can't see.
Latency
Per-iteration wall time against a budget. Optionally samples the execution stack at the threshold, so you get the slow path, not just the number.
Heap delta
Growth per iteration against a bound. With gcBeforeMeasure the delta reflects
real retention, not allocation noise.
Thread / executor leaks
New non-daemon threads that survive the boundary, executors that never shut down, timers created within the iteration — reported with stack evidence.
The JVMTI native agent
Thread counting on the hot path used to mean Thread.getAllStackTraces() — a
safepoint stack walk. The optional JVMTI native agent replaces that with
event-driven tracking: it subscribes to ThreadStart /
ThreadEnd (seeded at VM init from GetAllThreads) and keeps weak global
refs to live non-daemon threads. No polling, no safepoint walks. When the agent isn't loaded the
harness transparently falls back to ThreadMXBean. One jar covers JDK 17 and 21.
./gradlew buildNativeAgent # -> build/native/libbasquinjvmti.so (needs cc + JDK headers)
LIB=$PWD/build/native/libbasquinjvmti.so
java -agentpath:$LIB -Dbasquin.native.lib=$LIB -cp ... runner.GenericRunner ...
The Tomcat valve
To test an app you can't modify, Basquin attaches its iteration boundaries with a
Tomcat valve — com.basquin.valve.BasquinValve, dropped into
Tomcat's lib/ and registered globally — instead of editing the WAR or its
web.xml. The valve jar is namespace-free: a single artifact runs on
both Tomcat 9 (javax.servlet) and Tomcat 10+ (jakarta.servlet). Invariant
results are exposed per response as X-Basquin-Invariant-* headers, which the HTTP
driver harvests — with a second, reliable channel behind them (below), because a response that has
already committed can no longer carry a header.
The valve and the in-WAR IterationFilter are mutually exclusive — use the filter
for the demo WAR, the valve for third-party WARs. Running both wraps every request twice and
produces meaningless nested boundaries.
Getting the measurement back to the driver
The measurement happens inside the app's JVM; the finding has to end up in the driver. Response
headers are the fast path, but they only work while the response is still open — and on a real app
most responses aren't. A body over the 8 KB output buffer, any explicit flush at any size,
or an error status with a custom web.xml error page all commit the response before the
boundary exits, and the header is silently dropped. Measured on the benchmark apps: 97.3%
of Apache Roller's responses couldn't carry one, 75% of JSPWiki's, and 0% of JPetStore's —
whose 1–6 KB pages are why the header-only design looked like it worked. The bias runs the wrong
way, too: loss tracks response size and flushing, i.e. exactly the expensive requests worth
finding.
So the boundary also writes each explore iteration's measurements into a small, bounded,
per-request-id result store inside the target JVM, keyed by the
X-Basquin-Req id the driver stamped on the request. When no cost header comes back,
the driver polls /__basquin/result?id=… over the same control surface it already uses
— no extra port, no extra wiring — and violations reach the driver even when the response
committed. Ids are salted per run and entries are removed on read, so a stale or foreign
id misses instead of returning someone else's measurement, and the driver can safely fan the poll
out across the target's pods to reach the one that actually served the request. The poll waits on
the iteration lock, because latency is measured before the end-of-iteration grace sleep and the
client can reach end-of-body before the entry has been written.
A lost measurement is reported as unmeasured, not as zero (DD-040).
If the poll misses, the driver does not fabricate an empty sample: the request is excluded from
cost ranking, counted in reportMisses, and the run's finding count is flagged as a
lower bound — and a run where misses are the majority fails instead of finishing "clean". The
same rule runs through every consumer: an invariant nobody evaluated is reported as not
evaluated in the campaign status, the Kubernetes Ready condition and the dashboard, rather
than as a reassuring 0.
Coverage-guided exploration
Coverage comes from the app under test: a JaCoCo agent runs in the target's JVM and the driver reads it over the wire, so the coverage % is a real "% of code explored," not something measured in the harness. Coverage-guided mutation then keeps the inputs that reach new code.
The reachable surface is data, not code. A request grammar supplies route
templates and a parameter value space; the corpus supplies real values; and structural generation
(e.g. ~EST-[0-9]{1,4}) invents ids that parse but don't exist — which is how
the deepest crashes were found. @sequence blocks run ordered, session-carrying
transactions (sign on → add to cart → check out) to reach code a single request never can.
# a rule: alternatives may be literals, a corpus file, a structure, or a generator
$itemId = @../corpus/jpetstore/values/itemId.txt | ~EST-[0-9]{1,4} | <empty> | <string>
$categoryId = FISH | DOGS | ~[A-Z]{4,8}
# a route template; ${name} expands to one alternative of that rule
/actions/Catalog.action?viewItem=&itemId=${itemId}
Why all three kinds of value: real values reach happy paths and deep code; structurally valid but nonexistent values get past parsing into the lookup/dereference code where the interesting failures live; purely random junk gets rejected at the first validation. Using only one finds noticeably less.
Reaching write paths that defend themselves
Read paths are easy to drive. The interesting code — the part that allocates, writes, locks, and falls over — usually sits behind a form that will not accept a replayed request. A static corpus can never save a page to a CSRF-protected wiki: the token is minted per session, so every replay is rejected before it reaches the code you wanted to test.
A sequence can therefore capture a value out of one response and substitute it into a later request. What the corpus stores is the recipe — never the token, which would be a secret written to disk and stale by the time it was replayed:
# capture by static field name; ${{name}} substitutes it into a later step
/Edit.jsp?page=${page} <<csrf=input:X-XSRF-TOKEN
POST /Edit.jsp page=${page}&X-XSRF-TOKEN=${{csrf}}&ok=Save
Some defences randomize the field name as well as its value, which a name-keyed capture
cannot express. For those, a capture can match a whole name=value pair by regex and
substitute both halves together — JSPWiki's anti-spam field is six random lowercase letters with a
numeric value, and anchoring the value pattern to digits keeps it from binding to the decoy
fields beside it:
<<spam=inputpair:[a-z]{6}=-?[0-9]+ # binds the field's NAME and value
One more thing breaks a replayed write, and it is easy to miss: an app that detects an
unchanged write does nothing and still reports success. JSPWiki returns before writing
when the submitted text equals the page's current text — so after the first replay the save costs
nothing, while the load numbers still count it. A <nonce> generator solves it
by producing a value that is unique per fire rather than per corpus entry, so every
replay is a real change:
$rev = <nonce> # unique per fire, not per corpus line
POST /Edit.jsp page=${page}&_editedtext=${wikitext} ${rev}&ok=Save
Finally, load mode does not follow redirects. A rejected write is usually a
302 — not a 4xx, not a 5xx — so a client that follows it automatically discards the
only evidence that anything went wrong, and the run looks clean. Basquin reads the
Location instead and classifies it: a redirect back to the page just written is the
app confirming the write; a redirect to the login form is the app throwing it away. The counts
land in the run summary as redirects and redirectTargets. See the
benchmark page for what that turned up on a real app.
Explore mode does the opposite — it follows redirects, because the interesting code is
usually behind the login the redirect leads to — but it stopped trusting the JDK to do
the following. A form login answers POST /login with a 302 and rotates
the session cookie on that 302 (a session-fixation defence), and when the JDK follows a
redirect itself it drops the intermediate response's Set-Cookie and
the Cookie request header on the POST→GET rewrite — so
the page you land on renders logged out, and every write path behind that login is unreachable
while the run still looks fine. So explore now walks the chain a hop at a time, capturing a
rotated session off each hop and re-attaching the cookie (and the request's measurement id) to the
next one, so an authenticated write path is actually reached. A chain that never terminates — or
bounces /protected → /login → /protected — is itself a
Redirect-Loop finding rather than a 5th hop scored as a normal page, because an app
that redirects forever is unavailable, and availability is the oracle.
The decoupled dashboard
The web dashboard is a standalone process — never embedded in a driver, never
anywhere near the app under test. Run it once; any number of drivers (one per campaign, one per
pod) push their status and findings to it, keyed by campaign id (defaults to HOSTNAME,
a pod's name in Kubernetes). The page shows a fleet view of every reporting campaign, with
drill-down into metric cards, a coverage bar, and a findings table — route, detail, and
classification, not just counts.
It uses the JDK's built-in httpserver (no extra dependency on either side), binds
127.0.0.1 by default, and guards its ingest/analyze endpoints — the analysis
endpoint spends API credit, so network exposure is opt-in.
127.0.0.1:7070
Every significant choice — the decoupled dashboard (DD-013), the namespace-free valve (DD-011), union coverage across replicas (DD-023) — is recorded with its rejected alternatives in DESIGN-DECISIONS.md. The fuller architecture writeup is ARCHITECTURE.md.