Initial commit
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
# 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
|
||||
|
||||
```bash
|
||||
./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
|
||||
```
|
||||
|
||||
```bash
|
||||
./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"
|
||||
```
|
||||
Reference in New Issue
Block a user