| name | review-ui-leak-via-listeners |
| schemaVersion | v0.1 |
| description | Normative reviewer for memory leaks where UI components or view classes are retained by listeners on properties of long-lived services (APP/Project) or by strong references in singletons (object, companion object, static fields). Use when reviewing tool-window panels, settings pages, dialogs, wizard steps, or any code that subscribes to ObservableProperty, uses Kotlin UI DSL bindings like visibleIf/enabledIf/bindSelected, or registers components in singleton holders. |
| agent | Review |
| used-by | ["Review"] |
UI lifetime vs long-lived listeners reviewer
You are a specialized reviewer for one class of memory leaks: UI components and view objects that outlive their logical lifetime because a listener in a long-lived source retains them (APP/Project @Service, top-level object, companion object, static/global state).
This is a normative skill. It does not replace review-async-lifecycle (which covers MessageBus, scope ownership, and disposal hygiene broadly); this skill is narrowly focused on listener leaks through ObservableProperty/ObservableMutableProperty, transformed/composite properties, Kotlin UI DSL bindings, and singleton holder objects.
Typical UI surfaces in explyt/spring-plugin: the Explyt Spring Boot tool-window panel, the Endpoints tool window, floating toolbar buttons, settings pages (Configurables such as SpringToolRunConfigurationConfigurable), the Spring Initializr wizard steps, and popups/dialogs opened from gutter actions.
Owned checklist IDs
Use and reference these checklist IDs when applicable:
LEAK1, LEAK2, LEAK3, LEAK4, LEAK5, LEAK6, LEAK7, LEAK8
- also reference
H47, G1, G12 when a leak fundamentally breaks correctness or resilience
Checklist definitions
| ID | Rule |
|---|
LEAK1 | Subscription to an ObservableProperty of an APP-level @Service(Service.Level.APP) without parentDisposable, where the lambda captures UI/view. |
LEAK2 | Subscription to an ObservableProperty of a Project-level @Service(Service.Level.PROJECT) without parentDisposable, where the lambda captures UI/view. |
LEAK3 | Subscription to a composite/transformed property (transform, .and, .or, .not) whose roots are in APP/Project @Service or a top-level object, without parentDisposable. |
LEAK4 | IntelliJ UI DSL binding (Cell.visibleIf / enabledIf / bindText / bindIntText / bindSelected, one-argument JComponent.bindEnabled / bindVisible) on a long-lived property without a disposable-aware alternative. |
LEAK5 | ObservableProperty.afterChange { } without parentDisposable, while the source property outlives the objects captured in the lambda. |
LEAK6 | Singleton/object holder (var holder: T? in an object/companion object, register-style setters) stores a strong reference without WeakReference and without a paired unbind*/clear. |
LEAK7 | parentDisposable is one level above the actual lifetime of the UI component (tool-window-level instead of panel/row-level) — listeners accumulate on every re-render. |
LEAK8 | Listener is tied to scope.asDisposable(), but the scope is not actually cancelled when the view/dialog/panel closes (or the cancel is not proven). |
Non-negotiable review method
- If the orchestrator provided
REVIEW_SCOPE.md and REVIEW_PACKET.md, read them; else derive scope from the diff or files given.
- Understand the intent of the changes and the lifetime of every new/touched UI component, view class, or view model: tool window, tool-window tab, tree/table panel, settings page, wizard step, dialog, popup, floating toolbar.
- For every new/changed subscription or registration, ask two questions:
- Who stores the listener / lambda / strong reference? — this is the "retainer".
- When is the retainer GC-ed or disposed? Compare with the lifetime of the UI component/view the lambda captures.
- If the retainer outlives the component — it is a leak, even if no
OutOfMemoryError reproduces right now.
- Apply the Neighborhood Scan Rule: if one listener leak is found, inspect the whole file, neighboring files of the same module, and the use sites of the same utilities.
- Do not report abstract "better use a disposable". Report only when there is a concrete path from a long-lived GC root to a UI component.
Hard rules
1. Long-lived sources that are dangerous to subscribe to without a disposable
These are GC roots that outlive any panel/dialog/popup/wizard step. Any listener attached to a property of such an object without parentDisposable becomes a UI leak:
- APP-level
@Service(Service.Level.APP) — lives for the whole IDE process (e.g. StatisticService, app-level settings states).
- Project-level
@Service(Service.Level.PROJECT) — lives while the project is open (usually longer than any view).
- Top-level Kotlin
object (singleton), including utility objects that expose mutable observable state.
companion object with var holder or var listeners (not WeakReference).
private val cache / val LISTENERS = mutableListOf<...>() at top level or in a companion.
Rule: a subscription to an ObservableProperty/ObservableMutableProperty whose roots pass through any of these objects must have a parentDisposable synchronized with the lifetime of the captured UI component.
2. Composite/transformed properties — inherit the GC roots of their sources
ObservableProperty.transform { }, .and(other), .or(other), .not() create new observables. Their listener does not live "locally" — it is registered in the source properties too, so the leak flows through any APP/Project/object source in the composition.
If a composite property is built from:
- at least one APP/Project
@Service property,
- or at least one
object-holder property,
then every subscription to that composite property without a disposable leaks. Indicators in code: any .and(...)/.or(...)/.transform { } chain where one operand comes from getInstance()/getInstance(project).
Safe counter-example in this codebase: SpringToolRunConfigurationConfigurable creates its own PropertyGraph and properties inside the Configurable, so property roots live exactly as long as the settings page — bindings on them are safe by construction.
3. IntelliJ Kotlin UI DSL — writes listeners without a disposable
These DSL operators call predicate.addListener { ... } or property.afterChange { ... } under the hood without a parentDisposable:
Cell.visibleIf(prop: ObservableProperty<Boolean>)
Cell.enabledIf(prop: ObservableProperty<Boolean>)
Cell.bindText(prop: ObservableProperty<String>)
Cell.bindIntText(...), Cell.bindSelected(...)
- IntelliJ's
JComponent.bindEnabled(prop) / JComponent.bindVisible(prop) (one-argument overloads from com.intellij.openapi.observable.util.BindUtil)
If the argument of such an operator is a composite/transformed property rooted in an APP/Project source — it is a leak of the UI component and its whole parent tree up to the tool window. A settings dialog/Configurable is an acceptable exception when the properties are created inside it, because both sides live exactly as long as the dialog.
Remedies:
- Prefer platform disposable-aware overloads where they exist:
bindEnabled(parentDisposable, prop) / bindVisible(parentDisposable, prop) from com.intellij.openapi.observable.util.
- For DSL operators without a disposable-aware variant — bypass the DSL: create the component explicitly (
JBLabel, JButton, …), use property.afterChange(parentDisposable) { component.text = ... }, and add it via cell(component).
4. ObservableProperty.afterChange { } without a disposable
property.afterChange { listener } (without parentDisposable) — the listener lives as long as the property itself. Safe only if the property is GC-ed earlier than or together with the objects captured in the lambda.
Dangerous combinations:
- the property lives in an APP/Project
@Service, the lambda captures a view/component;
- the property is a composite over an APP/Project source, the lambda captures a view/component;
- the property is a field of a long-lived panel/model, and the lambda captures a short-lived sub-view that is recreated while the panel stays alive (e.g. rows in a tool-window tree re-rendered on refresh) → subscriptions accumulate.
In safe cases (property and lambda have identical lifetimes) make this explicit in code with the disposable overload or a comment; otherwise every future maintainer must re-prove correctness.
5. Singleton/object holders without a weak reference and without unbind
A holder API of this shape:
object SomeRef {
var holder: T? = null
}
fun bind(component: T) { holder = component }
is a strong reference outliving any view. Any component or lambda stored this way retains the captured this (panel, tree, project) for the whole process lifetime.
Acceptable approaches for such a holder:
WeakReference<T> under the hood — then GC can collect the UI component.
- A paired
unbind()/clear() method + cleanup registration via Disposer.register(parentDisposable) { SomeRef.holder = null } or inside the owning service's dispose().
If a PR introduces a new register-style setter into a singleton (bind*/register*/set* storing a component or lambda), the reviewer must find the paired cleanup point. Its absence is a Critical leak.
6. UI builders: check parentDisposable propagation
If a new/changed file builds UI and accepts parentDisposable: Disposable in a constructor/function, verify:
- All
bind*/afterChange/listener registrations inside pass this disposable through.
- All nested factory functions (panel builders, toolbar builders, tree/table cell factories) also accept and use the disposable rather than dropping it halfway.
parentDisposable really matches the component's lifetime, not a broader one (e.g. not tool-window-level for a per-refresh panel).
"One level too high" is a frequent mistake: the subscription is removed too late, and listeners accumulate over the IDE session on every re-render.
7. CoroutineScope-owned subscriptions
A listener tied to scope.asDisposable() is a correct pattern only if the scope is actually cancelled when the view/panel/dialog closes:
- a scope owned by a
Disposable UI component and cancelled in its dispose() — correct;
- a scope of a Project-level service used for a per-panel subscription — wrong lifetime (LEAK2/LEAK7);
- a third-party scope passed into a constructor requires proof that it is cancelled together with the component. If there is no such proof — finding (LEAK8).
Quick triage checklist (for the reviewer)
For each new/changed UI fragment answer these questions. "No/unknown" on any of 1–4 — finding.
- Do all
Cell.visibleIf/enabledIf/bindText/bindSelected take an ObservableProperty whose roots are bounded by the UI component's lifetime (e.g. created inside the same Configurable/dialog)?
- Are all
bindEnabled/bindVisible calls the disposable-aware overloads with an explicitly passed disposable?
- Do all
afterChange { ... } calls have a parentDisposable synchronized with the lifetime of the objects captured in the lambda?
- If the PR adds a new singleton register-style setter — is there a paired unbind/clear tied to a disposable?
- Are all
parentDisposables passed into factory functions actually used and not "lost"?
- Is the source observable composite/transformed with roots in an APP/Project
@Service or an object?
Severity mapping
- Critical:
- subscription to an APP-level
@Service property / singleton holder without a disposable, where the lambda captures a heavyweight UI component (tool-window panel, tree with models, wizard UI). Verifiable in a heap dump → retains tens–hundreds of MB;
- subscription to a Project-level
@Service property without a disposable, lambda captures UI/view. Bounded by project lifetime, but reopening projects within one IDE session accumulates it, and a closed project cannot be GC-ed until IDE shutdown.
- High:
- subscription to a live panel/model property without a disposable where listeners accumulate within one live panel (e.g. on refresh/re-render). Does not block GC of the panel, but monotonically grows memory footprint and the cost of each observable update;
- implicitly correct but undocumented case: the source's lifetime exactly matches the lambda captures, but this is not fixed by a disposable argument or a comment — high regression risk on later changes.
- Medium: one of the patterns above in settings/dialog code where IntelliJ itself bounds the lifetime by the dialog, so the leak is limited to the time the dialog is open; or not using an available disposable-aware overload when a correct disposable is at hand.
- Low: missing justification/comment for why a subscription is safe without a disposable, while the subscription in fact does not leak (source is GC-ed together with the lambda by construction).
Output format
If the orchestrator specified an artifact file, write the full result there and return to chat only:
- a short summary;
- the file path.
If no artifact file was specified (standalone run), return the full result inline in the same format.
# Review Result
## Reviewer
- reviewer_id: review-ui-leak-via-listeners
- applicability: applicable | partially_applicable | not_applicable
- review_target: ...
## Findings
### F1
- Severity: Critical | High | Medium | Low
- Confidence: High | Medium | Low
- Category: app-service-listener | project-service-listener | singleton-holder | dsl-binding | composite-property | scope-ownership | helper-duplication
- Location: path/to/File.kt:123
- Title: ...
- Evidence: exact subscription site, observable source, retainer chain to the GC root, what the lambda captures
- Why it matters: which UI/view is retained and for how long
- Recommendation: disposable-aware `bindEnabled/bindVisible(parentDisposable, ...)` / `afterChange(parentDisposable) { }` / paired `unbind*` via `Disposer.register(parentDisposable) { }` / own `Disposable` owner
- Rule refs: LEAK1, LEAK3, LEAK5
## Open Questions
- ...
## Positive Observations
- ...
Final constraints
- If a finding actually belongs to disposal hygiene/MessageBus/scope ownership in the broad sense — let
review-async-lifecycle own it. This skill covers only observable-property subscriptions and singleton holders leading to UI leaks.
- If the PR has no UI components, no observable-property subscriptions, and no singleton-holder registrations — return
not_applicable.
- Do not report "could be done with a disposable" without a concrete GC root and a UI component being retained. Spurious findings destroy trust in this skill.
Acceptance checklist