Skip to content

fix: bound the kuri-bind reflective plan/scan caches - #141

Merged
OmarAlJarrah merged 3 commits into
mainfrom
b2-classloader-cache
Jul 19, 2026
Merged

fix: bound the kuri-bind reflective plan/scan caches#141
OmarAlJarrah merged 3 commits into
mainfrom
b2-classloader-cache

Conversation

@OmarAlJarrah

Copy link
Copy Markdown
Member

Summary

  • PlanCompiler.plans and KotlinReflectMemberScanner.scanCache were unbounded ConcurrentHashMap<KClass<*>, ...> instances living inside the process-lifetime KuriBind.executor singleton. A KClass strongly retains its backing Class and, through it, its ClassLoader, so every distinct type ever bound (including nested value types routed through runtimeValueIsBindable) stayed cached, pinning its ClassLoader, for the life of the process.
  • Added BoundedCache, a small thread-safe LRU (a single access-ordered LinkedHashMap guarded by a ReentrantLock), and switched both caches onto it with a 2048-entry default, evicting the least-recently-used entry once the cap is reached.
  • A weak-key map was the first design considered, but doesn't actually solve this: TypePlan stores its KClass directly, and ScannedMember's reader closures hold kotlin-reflect KProperty1 instances that reference their declaring class, so the cached value keeps the key (and its ClassLoader) strongly reachable regardless of how the map's key reference is held. A bounded LRU sidesteps that entirely.

Test plan

  • ./gradlew :kuri-bind:jvmTest — new BoundedCacheTest (size-bound eviction, LRU-order eviction, cache-hit identity, concurrent-miss convergence), PlanCompilerCacheTest, and MemberScannerCacheTest (both prove a type evicted by newer lookups is recompiled rather than retained forever), plus the full existing kuri-bind suite
  • ./gradlew :kuri-bind:ktlintCheck
  • ./gradlew :kuri-bind:detekt
  • ./gradlew :kuri-bind:apiCheck — no public API surface changed (BoundedCache is internal; the new constructor parameters on PlanCompiler/KotlinReflectMemberScanner are internal-only and default-valued), so no apiDump needed
  • kuri-bind only targets jvm, so jsNodeTest/wasmJsNodeTest don't apply to this module

Closes #89

PlanCompiler.plans and KotlinReflectMemberScanner.scanCache were plain
ConcurrentHashMap<KClass<*>, ...> with no size limit, living inside the
process-lifetime KuriBind.executor singleton. A KClass strongly retains its
backing Class and, through it, its ClassLoader, so every distinct type ever
bound - including nested value types routed through runtimeValueIsBindable -
was cached and its ClassLoader pinned for the life of the process. In a host
that mints classes dynamically (per-deployment ClassLoaders, hot redeploy,
bytecode-generated proxies), that accumulates without bound.

Introduce BoundedCache, a small thread-safe LRU backed by a single
access-ordered LinkedHashMap guarded by a ReentrantLock, and swap both caches
onto it with a 2048-entry default. A weak-key map was considered first, but
both cached values hold a strong path back to the class used as the key
(TypePlan stores its KClass directly; ScannedMember's reader closures hold
kotlin-reflect KProperty1 instances that reference their declaring class) -
weak keys would not actually let those classes become unreachable, so a
bounded LRU is the fix that works.

Closes #89
…ut KDoc

The concurrent double-checked-locking test never asserted that a race
actually happened, so it could pass even if the discard-on-second-check
path were broken by scheduling luck. Force every racer to enter compute
before any can insert, and assert compute ran once per racer. Add coverage
for concurrent eviction across distinct keys past the cap, and for the
liveness property that a slow compute for one key doesn't block a
getOrPut for a different key.

Also corrects getOrPut's @param compute KDoc, which warned of a deadlock
that can't happen: compute always runs with the lock released, so a
reentrant or cross-thread call is safe. The real risk of recursing on the
same key is unbounded recursion, not a deadlock.
@OmarAlJarrah
OmarAlJarrah merged commit cb64b12 into main Jul 19, 2026
13 checks passed
@OmarAlJarrah
OmarAlJarrah deleted the b2-classloader-cache branch July 19, 2026 17:49
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.

kuri-bind: unbounded reflective plan/scan caches pin ClassLoaders for the process lifetime

1 participant