Skip to content

test: analyse factory-registered detector states mid-record path (#1215) - #1224

Merged
PIsberg merged 2 commits into
mainfrom
fix/1215-factory-fresh-state
Nov 8, 2026
Merged

PIsberg merged 2 commits into
mainfrom
fix/1215-factory-fresh-state

Conversation

@PIsberg

@PIsberg PIsberg commented Nov 8, 2026

Copy link
Copy Markdown
Owner

Closes #1215 (first bullet; the second is out of scope, see below).

Why

analysisSurvivesAStateRegisteredButNotYetWritten builds a fresh state through newState and runs analyze() on it. That catches, every run instead of once in many, an analyze() that throws on a state published but not yet written to. That's the #1212 bug class, where ifIssue then drops the detector's whole report.

It could not reach detectors that register only through stateFor's factory overload. Their state is built by a lambda at the record site, so the test cannot make one without running the record path.

What

  • Test hook in AbstractInstanceDetector.register. A private static volatile Consumer, set through a package-private setter, runs right after computeIfAbsent publishes a state. When unset, it costs one volatile read, on the registration path only, never on stateFor's get fast path. RunnerAllocationBudgetTest still passes.
  • New test analysisSurvivesARecordPathBetweenRegistrationAndItsFirstWrite.
    • It installs the hook to run analyze() at that moment and calls every recording method of each per-instance detector once, each with a fresh first argument so that every call registers a new state.
    • Two detectors need a hand-written driver: ScopeResultEscapeDetector needs a scope opened first, and VirtualThreadPoolingDetector needs a real ThreadPoolExecutor.
    • It reaches all 18 factory-only detectors (floor 18), and 68 of the 75 per-instance detectors in all.
  • AbstractInstanceDetectorTest: the hook fires once a first sighting is published, not on a lookup, and not once it's removed.
  • docs/architecture/adding-a-detector.md: analyze() must survive a half-written state, and this test checks it.

It found no live bug.

How it was verified

  • The test can fail. With ThisEscapeDetector's empty-escapes guard replaced by escapes.iterator().next(), only the new test went red, naming ThisEscapeDetector: inside recordConstructorEscape: java.util.NoSuchElementException. The two existing tests stayed green. The change was reverted afterwards.
  • Tests: DetectorAnalysisUnderConcurrentWritesTest 3/3, AbstractInstanceDetectorTest 10/10 and RunnerAllocationBudgetTest 2/2 (-P e2e) all pass.
  • Static gates: install with PMD, checkstyle and errorprone passes.

Not done

  • The ~73 detectors that don't extend AbstractInstanceDetector (the issue's second bullet). Each keeps its state its own way, so there is no common seam to hook.
  • Generic arguments. The drivers pass generic arguments, so a record path that needs specific earlier state is reached only when it's the first one called.

Checklist

  • A behaviour change ships with a test that fails without the fix (test-only change; proven able to fail, above)
  • Docs the change makes wrong are updated in this same change
  • Nothing was hand-edited between VIBETAGS-START / VIBETAGS-END markers
  • No public API change (the hook is package-private)
  • Commits an agent authored carry the Co-Authored-By trailer
  • No new dependency
  • CI is green

🤖 Generated with Claude Code

PIsberg and others added 2 commits November 8, 2026 11:23
analysisSurvivesAStateRegisteredButNotYetWritten builds a fresh state
through newState, so it cannot reach the 18 per-instance detectors that
register only through stateFor's factory overload: their state is built
by a lambda at the record site. A detector among them whose analyze()
throws on a published-but-unwritten state (the #1212 bug class) would
pass every deterministic test and fail only on a rare race, when
ifIssue then drops its whole report.

AbstractInstanceDetector.register now calls a private static volatile
test hook, set through a package-private setter, after computeIfAbsent
publishes a state. Unset, it costs one volatile read on the registration
path only, never on stateFor's get fast path; RunnerAllocationBudgetTest
still passes.

The new test installs the hook to run analyze() at that moment, drives
every recording method of each per-instance detector once with a fresh
first argument (plus hand drivers for ScopeResultEscapeDetector and
VirtualThreadPoolingDetector, whose registration needs an opened scope
or a real ThreadPoolExecutor), and fails on any throw. It reaches all
18 factory-only detectors (floor 15) and 68 of 75 per-instance ones.
It found no live bug. Verified it detects one: with ThisEscapeDetector's
empty-escapes guard replaced by escapes.iterator().next(), only the new
test went red, naming "ThisEscapeDetector: inside recordConstructorEscape:
java.util.NoSuchElementException"; the two existing tests stayed green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
All 18 are reached today, so a floor of 15 would let three go quietly uncovered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Nov 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@PIsberg
PIsberg merged commit 678f165 into main Nov 8, 2026
53 checks passed
@PIsberg
PIsberg deleted the fix/1215-factory-fresh-state branch November 8, 2026 11: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.

[P3] Registered-but-unwritten state check covers 54 of 148 detectors; factory-registered and non-instance detectors rely on timing

1 participant