Pit Friedrich d9a9647742 Initial commit
2026-10-11 17:45:06 +02:00
2026-10-11 17:45:06 +02:00
2026-10-11 17:45:06 +02:00
2026-10-11 17:45:06 +02:00
2026-10-11 17:45:06 +02:00
2026-10-11 17:45:06 +02:00
2026-10-11 17:45:06 +02:00
2026-10-11 17:45:06 +02:00
2026-10-11 17:45:06 +02:00
2026-10-11 17:45:06 +02:00

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):

  • VaadinSession constructor, VaadinService.init() / fireSessionDestroy() — session and service lifecycle.
  • Component constructors — 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 a CROSS_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 a CROSS_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() and gcWait means 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 markAsDirty and periodic heap walks cost CPU, and System.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"
S
Description
No description provided
Readme 76 KiB
Languages
Java 94.5%
Shell 4.2%
HTML 1.3%