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.

Inputs
Runner
begin / end
App entry
Metrics &
coverage
Invariants
Reset
  1. 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.

  2. 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).

  3. 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.

  4. 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

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

Heap delta

Growth per iteration against a bound. With gcBeforeMeasure the delta reflects real retention, not allocation noise.

// threads

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 valvecom.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.

note

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.

design

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 POSTGET 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.

Driver A
Dashboard
127.0.0.1:7070
Driver B
design

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.