Skip to content

perf(engine): detach call-stack ASTs at leaveFile + self-contained heap harness#481

Merged
n1ckl0sk0rtge merged 35 commits into
mainfrom
feat/callstack-ast-detach
Jul 6, 2026
Merged

perf(engine): detach call-stack ASTs at leaveFile + self-contained heap harness#481
n1ckl0sk0rtge merged 35 commits into
mainfrom
feat/callstack-ast-detach

Conversation

@n1ckl0sk0rtge

Copy link
Copy Markdown
Contributor

Summary

Stops com.ibm.engine.callstack from pinning whole-file ASTs for the entire scan, and adds a self-contained harness that proves it.

On large scans the call-stack retained a CallContext per matched invocation for the whole project; each held a live sonar-java Tree, and any Tree pins its file's full AST (parent chain → CompilationUnitTree). Measured on a near-full keycloak scan: ~179k CallContexts driving the heap to ~7 GB, growing linearly and unbounded.

What changed

AST-detach mechanism (detach at leaveFile, not at record time)

  • CallContext is now a sealed interface: RetainedCall(tree, scanContext, detachedForm?) / DetachedCall (tree-free: match keys + pre-resolved ArgSnapshots + AST-free DetachedScanContext).
  • Detachable calls (Java method invocation, no NEW_ARRAY arg) get a DetachedCall pre-built while the file is live; at leaveFile the retained entry is swapped for it, so the file's AST becomes GC-eligible while cross-file matching continues from the snapshot.
  • Same-file detections still fire during their file's analysis on the live RetainedCall, so their SonarQube issues are unaffected. Cross-file detached detections report via the internal SonarComponents.reportIssue (shared per-scan, pins no AST).

Performance/heap harness (this session)

  • CallContextStats + callContextStats() accessor threaded CallStackAgent → Handler → ILanguageSupport → JavaLanguageSupport — read-only introspection of retained-with-tree vs. detached counts.
  • CryptoCorpusGenerator — generates N synthetic cross-file crypto wrapper/caller unit pairs (JCA APIs) into a temp dir.
  • CallStackHeapPerfTest (@Tag("performance"), excluded from the default build via surefire excludedGroups) — generates → compiles to a classpath dir → scans via CheckVerifier so types resolve and detections fire → asserts detachedRatio high and retainedWithTree bounded (deterministic; no heap-MB flake) → reports heap delta + wall-time for manual soak runs.

Run it: mvn test -pl java -DexcludedGroups= -Dtest=CallStackHeapPerfTest (add -Dperf.corpus.files=3000 for a heavy soak).

Verification

  • Full default build green: 163 tests, 0 failures (perf test excluded as designed).
  • Harness on feat/callstack-ast-detach (200 files): retainedWithTree=0 detached=367 ratio=1.000 — every recorded call detached.
  • Discriminator validated: with detachCallsForFile neutralized, the harness fails (retainedWithTree=300 ratio=0.000), confirming the assertion catches AST-pinning regressions.

Design / plan docs

  • docs/superpowers/specs/2026-07-06-callstack-heap-perf-harness-design.md
  • docs/superpowers/plans/2026-07-06-callstack-heap-perf-harness.md
  • docs/superpowers/plans/2026-07-05-callstack-hooks-heap-reduction.md (the ~7 GB measurement)

Note

This does not reproduce the full-project ~7 GB number in CI — that remains the documented manual mvn sonar:sonar keycloak route. The harness targets the engine mechanism at a scaled, deterministic level.

@n1ckl0sk0rtge n1ckl0sk0rtge marked this pull request as ready for review July 6, 2026 10:56
@n1ckl0sk0rtge n1ckl0sk0rtge requested a review from a team as a code owner July 6, 2026 10:56
@n1ckl0sk0rtge n1ckl0sk0rtge force-pushed the feat/callstack-ast-detach branch from dd2375c to 9f31637 Compare July 6, 2026 12:48
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
…Verifier)

Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
…y (retained) cases

Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
…EW_ARRAY arg)

Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
…ariant

Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
…ay + reporting

Record calls retained during their file's analysis (same-file hook detections
resolve and report through the live context); at leaveFile, swap each detachable
call for a pre-built tree-free DetachedCall so the file's AST is collected while
cross-file matching continues from the snapshot. Cross-file detached detections
produce CBOM nodes and report SonarQube issues via SonarComponents (no AST pin).
Removes the redundant visitedTreeObjects set (per-bucket dedup instead).

Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Scan only the call-stack bucket matching a hook's method name instead of every
bucket; fall back to a full scan for ANY/multi-name matchers.

Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
…loak scan)

Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
…ting

Measured: plugin footprint ~28MB (~1% of ~2.9GB floor); CBOM nodes
negligible (~25KB), call-stack dominates plugin term (15.6MB, linear).
Floor growth is SonarQube/ECJ baseline, not detectedNodes. Heap track
effectively closed; eligibility filter reclassified to CPU/throughput.

Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
…nsistency

Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
buildDetachedCall snapshotted its invoked-object/parameter IType keys with
MatchContext.build(false, rule) (record context), but a DetachedCall is only
ever matched in hook context via MethodMatcher.matchKeys. Under
isHookContext=false the translation selects subtype-permissive matching
(is || isSubtypeOf) instead of the exact is() the live retained-call path uses,
so cross-file matches accepted subtypes the same-file path rejects (false
positives) and the outcome depended on file visitation order. Snapshot with
MatchContext.createForHookContext() so detached matching reproduces the live
retained-call path exactly.

Also:
- gate the heap-attribution debug log behind isDebugEnabled() so the full
  CallContextStats.from call-stack traversal does not run on every production
  scan when DEBUG is off.
- clarify ILanguageSupport.callContextStats is diagnostics-only and not part of
  the detection contract.

Signed-off-by: Nicklas Körtge <Nicklas.Koertge1@ibm.com>
@n1ckl0sk0rtge n1ckl0sk0rtge force-pushed the feat/callstack-ast-detach branch from 9f31637 to 7c20aea Compare July 6, 2026 12:50
The AST-detach branch narrowed CallStackAgent.onNewHookSubscription to fetch
only the method-name-keyed bucket (bucketsToScan) instead of scanning all
buckets. That narrowing is shared by every language, so document and guard its
safety for Python and Go:

- Python: bucketing is exercised within-file only (within-file wrapper-hook
  tests already pass, proving key alignment). No scan-level cross-file symbol
  resolution exists, so the narrowing can't drop cross-file detections. Added a
  two-file CrossFileHookResolveTest, @disabled like ResolveImportedStructTest.
- Go: GoDetectionEngine only populates the call stack and never registers hooks,
  so onNewHookSubscription/bucketsToScan is unreachable for Go; the GoVerifier
  harness is single-file only. Added a @disabled CrossFileHookDetachTest that
  documents both blockers.

Both tests are forward guards: enable them if either language gains cross-file
or hook-based resolution.
@n1ckl0sk0rtge n1ckl0sk0rtge self-assigned this Jul 6, 2026
@n1ckl0sk0rtge n1ckl0sk0rtge added the enhancement New feature or request label Jul 6, 2026
@n1ckl0sk0rtge n1ckl0sk0rtge merged commit e1fdab3 into main Jul 6, 2026
1 of 2 checks passed
@n1ckl0sk0rtge n1ckl0sk0rtge deleted the feat/callstack-ast-detach branch July 6, 2026 13:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant