Vaadin Leak Finder
A Java agent that detects inter-session leaks in Vaadin Flow 25 applications: UI state of one
user (UIs, components, the VaadinSession) that is reachable from, or modified by, another user
or application-wide shared state.
Typical bugs it catches:
| Bug | How it shows up |
|---|---|
Component stored in a static field / static list |
SHARED_ROOT_HOLDS_SESSION_STATE |
Spring singleton caching a Grid, view or UI |
SHARED_ROOT_HOLDS_SESSION_STATE |
| Broadcaster listener never unregistered on detach | SHARED_ROOT_HOLDS_SESSION_STATE, later DESTROYED_SESSION_RETAINED |
One component instance shown to several users (removeFromTree() + add) |
CROSS_SESSION_ATTACH, CROSS_SESSION_MUTATION |
Request of user B changes user A's UI without ui.access() |
CROSS_SESSION_MUTATION |
| View / session-scoped bean of user B holding a component of user A | SESSION_REFERENCES_FOREIGN_STATE |
| Logged-out sessions that never get garbage collected | DESTROYED_SESSION_RETAINED |
Usage
./mvnw -pl leakfinder-agent package
java -javaagent:leakfinder-agent/target/leakfinder-agent-0.1.0-SNAPSHOT.jar=scanInterval=30s,report=target/leaks.json \
-jar your-app.jar
Findings are logged once each (through System.Logger, so they end up in the Spring Boot log) and
written to the JSON report. Example:
ERROR SHARED_ROOT_HOLDS_SESSION_STATE: spring singleton 'gridCache' is shared by all users but references component com.vaadin.flow.component.grid.Grid of a single session
spring singleton 'gridCache' (com.example.leakdemo.GridCache)
-> .lastGrid (com.vaadin.flow.component.grid.Grid)
ERROR CROSS_SESSION_ATTACH: Component com.vaadin.flow.component.html.Span of session#1 was attached to a UI of session#2. The same component instance is shared between users.
component: com.vaadin.flow.component.html.Span
attached inside: com.example.leakdemo.SharedComponentView > com.vaadin.flow.component.UI
attached at: ...
Sessions are numbered (session#1); real HTTP session ids never appear in logs or reports.
Options
Comma separated key=value pairs; list values are separated by ;.
| Option | Default | Meaning |
|---|---|---|
scanInterval |
60s |
Period of heap scans (ms, s, m, h; 0 = only on demand via JMX) |
initialDelay |
= scanInterval |
Delay of the first scan |
gcWait |
30s |
Grace period before a destroyed session that is still alive is reported |
report |
— | JSON report file, rewritten after each scan and on new runtime findings |
include |
— | Only scan statics / singletons of these package prefixes (e.g. com.mycompany) |
exclude |
JDK, Vaadin, Spring, containers, common libs | Additional prefixes whose statics / singletons are not scanned |
stop |
containers, Spring, Vaadin internals, java.lang.ref, ... |
Additional prefixes the heap walk never descends into |
maxNodes / maxDepth |
2000000 / 64 |
Heap walk budget per scan |
sessionScan |
true |
Walk each live session's UI trees and attributes for foreign state |
runtimeChecks |
true |
Instrument component attach and StateNode.markAsDirty |
failFast |
false |
Throw LeakDetectedException into the request on runtime findings (tests / CI) |
jmx |
true |
Register dev.leakfinder:type=LeakFinder (scanNow(), getReportJson(), ...) |
verbose |
false |
Log a summary line after every scan |
How it works
Instrumentation (Byte Buddy advice inlined into Flow classes):
VaadinSessionconstructor,VaadinService.init()/fireSessionDestroy()— session and service lifecycle.Componentconstructors — remember the session that was current when a component was created.ComponentUtil.onComponentAttach— a component owned by session A being attached to a UI of session B is aCROSS_SESSION_ATTACH.StateNode.markAsDirty— every server side UI change ends here. If the thread serves session B (VaadinSession.getCurrent()) but modifies a node of a UI of session A without holding A's lock, that is aCROSS_SESSION_MUTATION.ui.access()sets the current session correctly and is not reported.
Heap scans (reflective breadth-first walk, shortest path reported):
- Shared roots: static fields of loaded, initialized application classes and the singletons of
the Spring application context. Reaching a component / UI owned by a session, or a
VaadinSession, is a leak: shared state must not reference per-user state. - Per session: under the session lock, the fields of every component in every UI tree, Vaadin
session attributes (UI / route scoped beans) and HTTP session attributes (session scoped beans).
Reaching a component of a different session is
SESSION_REFERENCES_FOREIGN_STATE. - Destroyed sessions: weakly tracked; still alive after
System.gc()andgcWaitmeans something retains them. If the shared-root walk found the path it is reported, otherwise a hint to take a heap dump.
The walk stops at container / framework internals (Tomcat session managers, Spring, Flow internals, weak references), so legitimate references from the servlet container to its sessions are not reported.
Class loading: the tiny dev.leakfinder.bridge package is appended to the bootstrap class
path so the inlined advice can reach it from any class loader (Spring Boot fat jar, servlet
container webapp, IDE). The rest of the agent accesses Vaadin reflectively; Byte Buddy is shaded.
Limits
- Development / test tool. Instrumenting
markAsDirtyand periodic heap walks cost CPU, andSystem.gc()is called when destroyed sessions are pending. - Reachability is analysed from the scanned roots only. Leaks through threads, thread locals, JNI or excluded libraries are not traced (the destroyed-session check still notices the symptom).
- The walk reads live objects without stopping the application; concurrent changes can cause missed edges. Findings are deduplicated by path, so they converge over several scans.
- Runtime checks only see components created / attached after the agent started, so attach the
agent at startup (
-javaagent), not later. - Flow 25 already refuses to move a node between two UIs' state trees unless it is removed first
(
removeFromTree()); the agent catches the variant that slips past that guard.
Project layout
leakfinder-agent/ the agent (shaded jar with Premain-Class); unit tests run real Flow classes
demo-app/ Spring Boot 4 + Vaadin 25 app with deliberate leaks (see the view classes)
scripts/e2e.sh builds the demo as production jar, runs it with the agent, drives two users
over HTTP and asserts the JSON report
./mvnw verify # agent unit tests + demo build
scripts/e2e.sh # end-to-end (needs Node for the Vaadin production build, python3, curl)
./mvnw -pl demo-app spring-boot:run \
-Dspring-boot.run.jvmArguments="-javaagent:$PWD/leakfinder-agent/target/leakfinder-agent-0.1.0-SNAPSHOT.jar=scanInterval=10s,verbose"