Initial commit

This commit is contained in:
Pit Friedrich
2026-10-11 17:45:06 +02:00
commit d9a9647742
43 changed files with 3723 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
target/
*.iml
.idea/
.vscode/
demo-app/node_modules/
demo-app/package.json
demo-app/package-lock.json
demo-app/tsconfig.json
demo-app/types.d.ts
demo-app/vite.config.ts
demo-app/vite.generated.ts
demo-app/src/main/frontend/generated/
demo-app/src/main/bundles/
+3
View File
@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip
+124
View File
@@ -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"
```
+88
View File
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.leakfinder</groupId>
<artifactId>vaadin-leakfinder-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>demo-app</artifactId>
<name>Vaadin Leak Finder Demo App</name>
<description>Vaadin 25 app with deliberate inter-session leaks</description>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-dev</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<!-- Runnable fat jar: mvn -Pproduction package; java -jar target/demo-app-*.jar -->
<profile>
<id>production</id>
<dependencies>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-dev</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-maven-plugin</artifactId>
<version>${vaadin.version}</version>
<executions>
<execution>
<goals>
<goal>prepare-frontend</goal>
<goal>build-frontend</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<!--
This file is auto-generated by Vaadin.
-->
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<style>
html, body, #outlet {
height: 100%;
width: 100%;
margin: 0;
}
/*
Fix app height for iOS home screen apps:
- 100% doesn't cover the entire screen.
- 100vh would work on iOS 18 but not on iOS 17 (too tall in Safari, correct in standalone mode).
- 100lvh is too tall in Safari. 100svh is too short in standalone apps.
- 100dvh doesn't seem to work on first render when launched from home screen (initially like 100svh, then changes to 100lvh when you try to scroll the view).
- So, we use 100% for the regular case (Safari and other browsers), and 100lvh for iOS home screen apps. Note, that the `display-mode: standalone` media query only works if the webmanifest defines `"display": "standalone"`.
*/
@supports (-webkit-touch-callout: none) {
@media (display-mode: standalone) {
html {
height: 100lvh;
}
}
}
</style>
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
</head>
<body>
<!-- This outlet div is where the views are rendered -->
<div id="outlet"></div>
</body>
</html>
@@ -0,0 +1,12 @@
package com.example.leakdemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
@@ -0,0 +1,23 @@
package com.example.leakdemo;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
/** Classic Vaadin broadcaster. Fine by itself, leaks when listeners are never removed. */
public final class Broadcaster {
private static final List<Consumer<String>> LISTENERS = new CopyOnWriteArrayList<>();
private Broadcaster() {
}
public static Runnable register(Consumer<String> listener) {
LISTENERS.add(listener);
return () -> LISTENERS.remove(listener);
}
public static void broadcast(String message) {
LISTENERS.forEach(l -> l.accept(message));
}
}
@@ -0,0 +1,23 @@
package com.example.leakdemo;
import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
/** LEAK: registers with the static broadcaster on attach but never unregisters on detach. */
@Route("broadcaster-leak")
public class BroadcasterView extends VerticalLayout {
public BroadcasterView() {
add(new H2("Broadcaster leak"));
}
@Override
protected void onAttach(AttachEvent attachEvent) {
UI ui = attachEvent.getUI();
Broadcaster.register(message -> ui.access(() -> add(new Span(message))));
}
}
@@ -0,0 +1,23 @@
package com.example.leakdemo;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
/** Control view: per-user state only, must produce no findings. */
@Route("")
public class CleanView extends VerticalLayout {
private final Paragraph counter = new Paragraph("0");
public CleanView() {
add(new H1("Vaadin leak finder demo"), counter);
add(new Anchor("static-leak", "Static list of views"));
add(new Anchor("singleton-leak", "Singleton bean holding a grid"));
add(new Anchor("broadcaster-leak", "Broadcaster without unregister"));
add(new Anchor("shared-component", "Component shared via static field"));
add(new Anchor("snoop", "View peeking into another user's view"));
}
}
@@ -0,0 +1,19 @@
package com.example.leakdemo;
import com.vaadin.flow.component.grid.Grid;
import org.springframework.stereotype.Service;
/** LEAK: application wide singleton that caches a UI component. */
@Service
public class GridCache {
private Grid<String> lastGrid;
public void remember(Grid<String> grid) {
this.lastGrid = grid;
}
public Grid<String> lastGrid() {
return lastGrid;
}
}
@@ -0,0 +1,27 @@
package com.example.leakdemo;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/** Helper endpoints for the end-to-end test. */
@RestController
public class SessionController {
@GetMapping("/api/logout")
public String logout(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
return "invalidated";
}
return "no session";
}
@GetMapping("/api/broadcast")
public String broadcast() {
Broadcaster.broadcast("hello");
return "sent";
}
}
@@ -0,0 +1,23 @@
package com.example.leakdemo;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
/**
* LEAK: one component instance for all users. Flow throws "Can't move a node from one state tree to
* another" when the second user opens the view; calling {@code removeFromTree()} silences that, and
* now every visitor steals the banner from the previous visitor's UI, mutating that user's UI from
* the wrong request thread.
*/
@Route("shared-component")
public class SharedComponentView extends VerticalLayout {
private static final Span BANNER = new Span("Banner of the day");
public SharedComponentView() {
BANNER.getElement().removeFromTree();
add(new H2("Shared component"), BANNER);
}
}
@@ -0,0 +1,20 @@
package com.example.leakdemo;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
import java.util.List;
@Route("singleton-leak")
public class SingletonLeakView extends VerticalLayout {
public SingletonLeakView(GridCache cache) {
Grid<String> grid = new Grid<>();
grid.addColumn(s -> s).setHeader("Item");
grid.setItems(List.of("a", "b", "c"));
cache.remember(grid);
add(new H2("Singleton leak"), grid);
}
}
@@ -0,0 +1,24 @@
package com.example.leakdemo;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
/** LEAK: the view of one user keeps a reference to a view of a different user. */
@Route("snoop")
public class SnoopView extends VerticalLayout {
private Component peeked;
public SnoopView() {
add(new H2("Snoop"));
synchronized (StaticLeakView.RECENT_VIEWS) {
if (!StaticLeakView.RECENT_VIEWS.isEmpty()) {
peeked = StaticLeakView.RECENT_VIEWS.get(0);
}
}
add(new Paragraph(peeked == null ? "nothing to peek at" : "peeking at " + peeked.getClass().getSimpleName()));
}
}
@@ -0,0 +1,23 @@
package com.example.leakdemo;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
import java.util.ArrayList;
import java.util.List;
/** LEAK: a static "recently opened views" list keeps every user's view (and its UI) alive forever. */
@Route("static-leak")
public class StaticLeakView extends VerticalLayout {
static final List<Component> RECENT_VIEWS = new ArrayList<>();
public StaticLeakView() {
add(new H2("Static leak"));
synchronized (RECENT_VIEWS) {
RECENT_VIEWS.add(this);
}
}
}
@@ -0,0 +1,4 @@
server.port=${PORT:8080}
vaadin.launch-browser=false
spring.main.banner-mode=off
logging.level.org.atmosphere=warn
+119
View File
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.leakfinder</groupId>
<artifactId>vaadin-leakfinder-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>leakfinder-agent</artifactId>
<name>Vaadin Leak Finder Agent</name>
<dependencies>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
</dependency>
<!-- Only needed to compile the advice classes; they are applied from bytecode and never loaded by the agent. -->
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>flow-server</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>flow-html-components</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy-agent</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.1.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- tests self-attach the agent -->
<argLine>-XX:+EnableDynamicAgentLoading -Djdk.attach.allowAttachSelf=true</argLine>
<redirectTestOutputToFile>true</redirectTestOutputToFile>
<!-- like production: Flow's lock assertions must not mask what the agent reports -->
<enableAssertions>false</enableAssertions>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifestEntries>
<Premain-Class>dev.leakfinder.agent.LeakFinderAgent</Premain-Class>
<Agent-Class>dev.leakfinder.agent.LeakFinderAgent</Agent-Class>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<relocations>
<relocation>
<pattern>net.bytebuddy</pattern>
<shadedPattern>dev.leakfinder.shaded.bytebuddy</shadedPattern>
</relocation>
</relocations>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/versions/*/module-info.class</exclude>
<exclude>module-info.class</exclude>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,100 @@
package dev.leakfinder.advice;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.internal.StateNode;
import com.vaadin.flow.internal.StateTree;
import com.vaadin.flow.server.VaadinSession;
import dev.leakfinder.bridge.LeakBridge;
import dev.leakfinder.bridge.LeakDetectedException;
import net.bytebuddy.asm.Advice;
/**
* Byte Buddy advice inlined into Vaadin classes. The code is copied into the target methods, so it
* may reference Vaadin types: they resolve in the class loader of the instrumented class. The agent
* never loads these classes itself, it only reads their bytecode via a type pool. Parameters are
* declared as {@code Object} and cast inside the advice, so that Byte Buddy never has to resolve
* Vaadin types from the agent's class loader.
* <p>
* Advice code must stay tiny and must never throw, except for fail-fast {@link LeakDetectedException}s.
*/
public final class VaadinAdvice {
private VaadinAdvice() {
}
/** {@code VaadinSession(VaadinService)} constructor. */
public static final class SessionConstructor {
@Advice.OnMethodExit(suppress = Throwable.class)
static void exit(@Advice.This Object session) {
LeakBridge.sessionCreated(session);
}
}
/** {@code VaadinService.fireSessionDestroy(VaadinSession)}. */
public static final class SessionDestroy {
@Advice.OnMethodEnter(suppress = Throwable.class)
static void enter(@Advice.Argument(0) Object session) {
LeakBridge.sessionDestroyed(session);
}
}
/** {@code VaadinService.init()}. */
public static final class ServiceInit {
@Advice.OnMethodExit(suppress = Throwable.class)
static void exit(@Advice.This Object service) {
LeakBridge.serviceInitialized(service);
}
}
/** {@code Component()} and {@code Component(Element)} constructors: remember the creating session. */
public static final class ComponentConstructor {
@Advice.OnMethodExit(suppress = Throwable.class)
static void exit(@Advice.This Object component) {
LeakBridge.componentCreated(component, VaadinSession.getCurrent());
}
}
/** {@code ComponentUtil.onComponentAttach(Component, boolean)}: detect components moving between sessions. */
public static final class ComponentAttach {
@Advice.OnMethodEnter
static void enter(@Advice.Argument(0) Object argument) {
try {
Component component = (Component) argument;
if (component.getElement().getNode().getOwner() instanceof StateTree tree) {
UI ui = tree.getUI();
LeakBridge.componentAttached(component, ui, ui.getSession(), VaadinSession.getCurrent());
}
} catch (RuntimeException e) {
if (e instanceof LeakDetectedException) {
throw e;
}
}
}
}
/**
* {@code StateNode.markAsDirty()}: every server side UI change ends here. If the thread is
* serving session B but modifies a node of a UI in session A without holding A's lock, state
* of one user is being changed by another user's request.
*/
public static final class NodeDirty {
@Advice.OnMethodEnter
static void enter(@Advice.This Object self) {
try {
VaadinSession current = VaadinSession.getCurrent();
if (current != null && ((StateNode) self).getOwner() instanceof StateTree tree) {
UI ui = tree.getUI();
VaadinSession owner = ui.getSession();
if (owner != null && owner != current && !owner.hasLock()) {
LeakBridge.crossSessionMutation(self, ui, owner, current);
}
}
} catch (RuntimeException e) {
if (e instanceof LeakDetectedException) {
throw e;
}
}
}
}
}
@@ -0,0 +1,137 @@
package dev.leakfinder.agent;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
/**
* Agent configuration, parsed from the agent argument string:
* {@code -javaagent:leakfinder-agent.jar=key=value,key=value}. List values are separated by {@code ;}.
*
* <pre>
* scanInterval period of the heap scans (e.g. 30s, 2m; 0 disables periodic scans) default 60s
* initialDelay delay of the first scan default = scanInterval
* gcWait grace period before a destroyed session counts as retained (e.g. 30s) default 30s
* report JSON report file, rewritten whenever findings change default none
* include package prefixes whose static fields are scanned (empty = all non-excluded)
* exclude additional package prefixes whose static fields / singletons are not scanned
* stop additional package prefixes the heap walk never descends into
* maxNodes object budget per heap scan default 2000000
* maxDepth max reference path length default 64
* sessionScan walk live sessions for references to foreign session state default true
* runtimeChecks instrument attach / mutation of components default true
* failFast throw LeakDetectedException into the app on runtime findings default false
* jmx register the dev.leakfinder:type=LeakFinder MBean default true
* verbose log every scan default false
* </pre>
*/
public final class Config {
static final List<String> DEFAULT_EXCLUDES = List.of(
"java.", "javax.", "jakarta.", "jdk.", "sun.", "com.sun.",
"com.vaadin.", "elemental.", "org.atmosphere.",
"org.springframework.", "org.apache.", "org.eclipse.", "io.undertow.", "org.jboss.", "org.wildfly.",
"ch.qos.", "org.slf4j.", "com.fasterxml.", "tools.jackson.", "org.yaml.", "com.google.", "org.jsoup.",
"net.bytebuddy.", "dev.leakfinder.", "kotlin.", "io.micrometer.", "org.hibernate.", "reactor.",
"io.netty.", "org.aspectj.", "org.junit.", "org.mockito.", "org.objenesis.", "org.opentest4j.",
"org.apiguardian.", "org.glassfish.", "com.zaxxer.", "org.h2.", "org.hsqldb.", "org.postgresql.",
"com.mysql.", "org.mariadb.", "org.flywaydb.", "liquibase.", "io.github.classgraph.", "nonapi.",
"org.jspecify.", "org.jetbrains.", "org.intellij.", "com.intellij.", "org.gradle.", "org.codehaus.");
/** The heap walk does not descend into these (container / framework internals hold sessions legitimately). */
static final List<String> DEFAULT_STOPS = List.of(
"java.lang.Thread", "java.lang.ClassLoader", "java.lang.reflect.", "java.lang.invoke.",
"java.lang.ref.", "java.security.", "java.util.logging.", "jdk.", "sun.", "com.sun.",
"org.apache.catalina.", "org.apache.coyote.", "org.apache.tomcat.", "org.eclipse.jetty.",
"io.undertow.", "org.springframework.", "com.vaadin.", "org.atmosphere.", "ch.qos.", "org.slf4j.",
"net.bytebuddy.", "dev.leakfinder.", "org.junit.", "org.mockito.", "jakarta.servlet.");
public Duration scanInterval = Duration.ofSeconds(60);
public Duration initialDelay;
public Duration gcWait = Duration.ofSeconds(30);
public Path report;
public List<String> include = new ArrayList<>();
public List<String> exclude = new ArrayList<>(DEFAULT_EXCLUDES);
public List<String> stop = new ArrayList<>(DEFAULT_STOPS);
public int maxNodes = 2_000_000;
public int maxDepth = 64;
public boolean sessionScan = true;
public boolean runtimeChecks = true;
public boolean failFast;
public boolean jmx = true;
public boolean verbose;
public static Config parse(String args) {
Config c = new Config();
if (args == null || args.isBlank()) {
return c;
}
for (String part : args.split(",")) {
String p = part.trim();
if (p.isEmpty()) {
continue;
}
int eq = p.indexOf('=');
String key = (eq < 0 ? p : p.substring(0, eq)).trim();
String value = eq < 0 ? "true" : p.substring(eq + 1).trim();
switch (key) {
case "scanInterval" -> c.scanInterval = duration(value);
case "initialDelay" -> c.initialDelay = duration(value);
case "gcWait" -> c.gcWait = duration(value);
case "report" -> c.report = Path.of(value);
case "include" -> c.include = list(value);
case "exclude" -> c.exclude.addAll(list(value));
case "stop" -> c.stop.addAll(list(value));
case "maxNodes" -> c.maxNodes = Integer.parseInt(value);
case "maxDepth" -> c.maxDepth = Integer.parseInt(value);
case "sessionScan" -> c.sessionScan = Boolean.parseBoolean(value);
case "runtimeChecks" -> c.runtimeChecks = Boolean.parseBoolean(value);
case "failFast" -> c.failFast = Boolean.parseBoolean(value);
case "jmx" -> c.jmx = Boolean.parseBoolean(value);
case "verbose" -> c.verbose = Boolean.parseBoolean(value);
default -> System.err.println("[vaadin-leakfinder] unknown agent option ignored: " + key);
}
}
return c;
}
public Duration effectiveInitialDelay() {
return initialDelay != null ? initialDelay : scanInterval;
}
/** Whether static fields of / singletons of this class are scan roots. */
public boolean isRootCandidate(String className) {
if (!include.isEmpty()) {
return include.stream().anyMatch(className::startsWith);
}
return exclude.stream().noneMatch(className::startsWith);
}
public boolean isStopClass(String className) {
return stop.stream().anyMatch(className::startsWith);
}
static Duration duration(String v) {
String s = v.trim().toLowerCase(Locale.ROOT);
if (s.endsWith("ms")) {
return Duration.ofMillis(Long.parseLong(s.substring(0, s.length() - 2)));
}
if (s.endsWith("s")) {
return Duration.ofSeconds(Long.parseLong(s.substring(0, s.length() - 1)));
}
if (s.endsWith("m")) {
return Duration.ofMinutes(Long.parseLong(s.substring(0, s.length() - 1)));
}
if (s.endsWith("h")) {
return Duration.ofHours(Long.parseLong(s.substring(0, s.length() - 1)));
}
return Duration.ofSeconds(Long.parseLong(s));
}
private static List<String> list(String v) {
return new ArrayList<>(Arrays.stream(v.split(";")).map(String::trim).filter(s -> !s.isEmpty()).toList());
}
}
@@ -0,0 +1,72 @@
package dev.leakfinder.agent;
import java.io.IOException;
import java.io.InputStream;
import java.lang.instrument.Instrumentation;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
/**
* Agent entry point. Kept free of references to the bridge or core classes: the bridge must be
* appended to the bootstrap class path <em>before</em> anything links against it, otherwise the
* system class loader would define a second, invisible copy.
*/
public final class LeakFinderAgent {
static final List<String> BRIDGE_CLASSES = List.of(
"dev/leakfinder/bridge/LeakBridge",
"dev/leakfinder/bridge/LeakBridge$Hooks",
"dev/leakfinder/bridge/LeakDetectedException");
private static volatile boolean installed;
private LeakFinderAgent() {
}
public static void premain(String args, Instrumentation inst) {
start(args, inst);
}
public static void agentmain(String args, Instrumentation inst) {
start(args, inst);
}
/** Programmatic installation, e.g. from tests via {@code ByteBuddyAgent.install()}. */
public static synchronized void start(String args, Instrumentation inst) {
if (installed) {
return;
}
try {
injectBridge(inst);
Class.forName("dev.leakfinder.core.Installer")
.getMethod("install", Instrumentation.class, String.class)
.invoke(null, inst, args);
installed = true;
} catch (Throwable t) {
System.err.println("[vaadin-leakfinder] failed to start agent: " + t);
t.printStackTrace();
}
}
private static void injectBridge(Instrumentation inst) throws IOException {
Path jar = Files.createTempFile("vaadin-leakfinder-bridge", ".jar");
jar.toFile().deleteOnExit();
try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(jar))) {
for (String name : BRIDGE_CLASSES) {
String resource = name + ".class";
try (InputStream in = LeakFinderAgent.class.getClassLoader().getResourceAsStream(resource)) {
if (in == null) {
throw new IOException("missing " + resource);
}
out.putNextEntry(new JarEntry(resource));
in.transferTo(out);
out.closeEntry();
}
}
}
inst.appendToBootstrapClassLoaderSearch(new java.util.jar.JarFile(jar.toFile()));
}
}
@@ -0,0 +1,147 @@
package dev.leakfinder.bridge;
/**
* Entry point called from the advice code that is inlined into Vaadin classes.
* <p>
* This class (and its nested types) is injected into the bootstrap class loader, so it is visible
* from any class loader Vaadin happens to live in (Spring Boot fat jar, servlet container webapp,
* plain class path). It therefore must only use JDK types in its signatures. All the real work is
* delegated to a {@link Hooks} implementation registered by the agent.
*/
public final class LeakBridge {
/** Callbacks implemented by the agent core. All Vaadin objects are passed as {@code Object}. */
public interface Hooks {
void sessionCreated(Object vaadinSession);
void sessionDestroyed(Object vaadinSession);
void serviceInitialized(Object vaadinService);
void componentCreated(Object component, Object currentSession);
void componentAttached(Object component, Object ui, Object uiSession, Object currentSession);
void crossSessionMutation(Object stateNode, Object ui, Object ownerSession, Object currentSession);
}
private static volatile Hooks hooks;
/** Prevents recursion when the agent itself touches instrumented Vaadin code. */
private static final ThreadLocal<Boolean> IN_HOOK = new ThreadLocal<>();
private LeakBridge() {
}
public static void install(Hooks h) {
hooks = h;
}
public static boolean isInstalled() {
return hooks != null;
}
public static void sessionCreated(Object session) {
Hooks h = hooks;
if (h == null || enter()) {
return;
}
try {
h.sessionCreated(session);
} catch (Throwable t) {
handle(t);
} finally {
exit();
}
}
public static void sessionDestroyed(Object session) {
Hooks h = hooks;
if (h == null || enter()) {
return;
}
try {
h.sessionDestroyed(session);
} catch (Throwable t) {
handle(t);
} finally {
exit();
}
}
public static void serviceInitialized(Object service) {
Hooks h = hooks;
if (h == null || enter()) {
return;
}
try {
h.serviceInitialized(service);
} catch (Throwable t) {
handle(t);
} finally {
exit();
}
}
public static void componentCreated(Object component, Object currentSession) {
Hooks h = hooks;
if (h == null || enter()) {
return;
}
try {
h.componentCreated(component, currentSession);
} catch (Throwable t) {
handle(t);
} finally {
exit();
}
}
public static void componentAttached(Object component, Object ui, Object uiSession, Object currentSession) {
Hooks h = hooks;
if (h == null || enter()) {
return;
}
try {
h.componentAttached(component, ui, uiSession, currentSession);
} catch (Throwable t) {
handle(t);
} finally {
exit();
}
}
public static void crossSessionMutation(Object node, Object ui, Object ownerSession, Object currentSession) {
Hooks h = hooks;
if (h == null || enter()) {
return;
}
try {
h.crossSessionMutation(node, ui, ownerSession, currentSession);
} catch (Throwable t) {
handle(t);
} finally {
exit();
}
}
private static boolean enter() {
if (IN_HOOK.get() != null) {
return true;
}
IN_HOOK.set(Boolean.TRUE);
return false;
}
private static void exit() {
IN_HOOK.remove();
}
private static void handle(Throwable t) {
if (t instanceof LeakDetectedException e) {
// fail-fast mode: surface the leak in the application thread
throw e;
}
System.err.println("[vaadin-leakfinder] internal error in hook: " + t);
}
}
@@ -0,0 +1,9 @@
package dev.leakfinder.bridge;
/** Thrown into the application thread when the agent runs with {@code failFast=true}. */
public class LeakDetectedException extends IllegalStateException {
public LeakDetectedException(String message) {
super(message);
}
}
@@ -0,0 +1,95 @@
package dev.leakfinder.core;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.atomic.AtomicLong;
/** One distinct leak, identified by {@link #key}. Holds only strings, never application objects. */
public final class Finding {
public enum Kind {
/** A component that belongs to one session was attached to a UI of another session. */
CROSS_SESSION_ATTACH("ERROR"),
/** A request of one session modified the UI of another session without holding its lock. */
CROSS_SESSION_MUTATION("ERROR"),
/** A static field or singleton (shared by all users) references a UI, component or session. */
SHARED_ROOT_HOLDS_SESSION_STATE("ERROR"),
/** The UI / session-scoped state of one session references state of another session. */
SESSION_REFERENCES_FOREIGN_STATE("ERROR"),
/** A destroyed session is still strongly reachable after GC. */
DESTROYED_SESSION_RETAINED("WARN");
final String severity;
Kind(String severity) {
this.severity = severity;
}
}
final Kind kind;
final String key;
final String title;
final List<String> details;
final Instant firstSeen = Instant.now();
volatile Instant lastSeen = firstSeen;
final AtomicLong occurrences = new AtomicLong();
final Set<Integer> sessions = new TreeSet<>();
Finding(Kind kind, String key, String title, List<String> details) {
this.kind = kind;
this.key = key;
this.title = title;
this.details = List.copyOf(details);
}
public Kind kind() {
return kind;
}
public String title() {
return title;
}
public List<String> details() {
return details;
}
public long occurrences() {
return occurrences.get();
}
public synchronized Set<Integer> sessions() {
return new TreeSet<>(sessions);
}
synchronized void addSessions(Iterable<Integer> ids) {
ids.forEach(sessions::add);
}
String format() {
StringBuilder sb = new StringBuilder();
sb.append(kind.severity).append(' ').append(kind).append(": ").append(title);
for (String line : details) {
sb.append(System.lineSeparator()).append(" ").append(line);
}
return sb.toString();
}
synchronized String toJson() {
StringBuilder sb = new StringBuilder();
sb.append("{\"kind\":").append(Json.str(kind.name()))
.append(",\"severity\":").append(Json.str(kind.severity))
.append(",\"title\":").append(Json.str(title))
.append(",\"occurrences\":").append(occurrences.get())
.append(",\"sessions\":").append(sessions)
.append(",\"firstSeen\":").append(Json.str(firstSeen.toString()))
.append(",\"lastSeen\":").append(Json.str(lastSeen.toString()))
.append(",\"details\":[");
for (int i = 0; i < details.size(); i++) {
sb.append(i == 0 ? "" : ",").append(Json.str(details.get(i)));
}
return sb.append("]}").toString();
}
}
@@ -0,0 +1,159 @@
package dev.leakfinder.core;
import java.lang.reflect.Field;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Set;
/**
* Bounded breadth-first walk over the object graph using reflection. Records the reference path to
* every object the {@link Classifier} marks as a hit. Breadth-first gives the shortest path, which is
* the most readable one in a report.
* <p>
* The visited set is shared across all roots of one walker, so each object is reported once per scan
* (via the first root that reaches it). The walk is read-only and takes no locks; concurrent
* modifications may cause missed or stale edges, which is acceptable for a diagnostic tool.
*/
final class HeapWalker {
enum Action {
/** Descend into the object's fields / elements. */
CONTINUE,
/** Do not descend. */
STOP,
/** Report a path to the object and do not descend. */
HIT
}
interface Classifier {
Action classify(Object o);
}
interface HitHandler {
void onHit(Object target, List<String> path);
}
private record Node(Object value, Node parent, String edge, int depth) {
}
private final Set<Object> visited = Collections.newSetFromMap(new IdentityHashMap<>());
private final int maxNodes;
private final int maxDepth;
private boolean budgetExhausted;
HeapWalker(int maxNodes, int maxDepth) {
this.maxNodes = maxNodes;
this.maxDepth = maxDepth;
}
int visitedCount() {
return visited.size();
}
boolean budgetExhausted() {
return budgetExhausted;
}
/** Marks an object as already handled, e.g. the root of another session. */
void exclude(Object o) {
visited.add(o);
}
/**
* Walks from {@code root}. The root itself is always expanded (even if the classifier would stop
* at it), except when it was already visited or is a trivial value.
*/
void walk(String rootLabel, Object root, Classifier classifier, HitHandler handler) {
if (root == null || isTrivial(root.getClass()) || !visited.add(root)) {
return;
}
Node start = new Node(root, null, rootLabel, 0);
if (classifier.classify(root) == Action.HIT) {
handler.onHit(root, path(start));
return;
}
ArrayDeque<Node> queue = new ArrayDeque<>();
queue.add(start);
while (!queue.isEmpty()) {
if (visited.size() > maxNodes) {
budgetExhausted = true;
return;
}
Node node = queue.poll();
if (node.depth >= maxDepth) {
continue;
}
Object value = node.value;
Class<?> type = value.getClass();
if (type.isArray()) {
if (type.getComponentType().isPrimitive()) {
continue;
}
Object[] array = (Object[]) value;
for (int i = 0; i < array.length; i++) {
visit(array[i], node, "[" + i + "]", classifier, handler, queue);
}
} else {
for (Field f : Reflect.instanceFields(type)) {
Object child;
try {
child = f.get(value);
} catch (Throwable t) {
continue;
}
visit(child, node, "." + f.getName(), classifier, handler, queue);
}
}
}
}
private void visit(Object child, Node parent, String edge, Classifier classifier, HitHandler handler,
ArrayDeque<Node> queue) {
if (child == null || isTrivial(child.getClass()) || !visited.add(child)) {
return;
}
Node node = new Node(child, parent, edge, parent.depth + 1);
Action action;
try {
action = classifier.classify(child);
} catch (Throwable t) {
action = Action.STOP;
}
switch (action) {
case HIT -> handler.onHit(child, path(node));
case CONTINUE -> queue.add(node);
case STOP -> {
// nothing
}
}
}
/** Human readable path: first element is the root label, then one line per edge. */
private static List<String> path(Node end) {
List<String> lines = new ArrayList<>();
for (Node n = end; n != null; n = n.parent) {
String type = n.value.getClass().getName();
lines.add(n.parent == null ? n.edge + " (" + type + ")" : "-> " + n.edge + " (" + type + ")");
}
Collections.reverse(lines);
return lines;
}
/** Path without volatile parts (array indexes, hidden class addresses), used for deduplication. */
static String signature(List<String> path) {
StringBuilder sb = new StringBuilder();
for (String line : path) {
sb.append(Reflect.stableName(line.replaceAll("\\[\\d+]", "[*]"))).append('|');
}
return sb.toString();
}
static boolean isTrivial(Class<?> c) {
return c == String.class || c == Class.class || c == Integer.class || c == Long.class
|| c == Boolean.class || c == Character.class || c == Byte.class || c == Short.class
|| c == Double.class || c == Float.class || c == Object.class;
}
}
@@ -0,0 +1,101 @@
package dev.leakfinder.core;
import dev.leakfinder.agent.Config;
import dev.leakfinder.bridge.LeakBridge;
import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.dynamic.ClassFileLocator;
import net.bytebuddy.pool.TypePool;
import java.lang.instrument.Instrumentation;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import static net.bytebuddy.matcher.ElementMatchers.isConstructor;
import static net.bytebuddy.matcher.ElementMatchers.isSynthetic;
import static net.bytebuddy.matcher.ElementMatchers.nameStartsWith;
import static net.bytebuddy.matcher.ElementMatchers.named;
import static net.bytebuddy.matcher.ElementMatchers.not;
import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
/** Wires the agent: opens JDK modules for the heap walk and instruments Vaadin. */
public final class Installer {
private static final String ADVICE = "dev.leakfinder.advice.VaadinAdvice$";
private Installer() {
}
/** Called reflectively by {@link dev.leakfinder.agent.LeakFinderAgent} after the bridge is on the boot class path. */
public static void install(Instrumentation inst, String args) {
Config config = Config.parse(args);
openModules(inst);
LeakFinder finder = new LeakFinder(config, inst);
LeakBridge.install(finder.hooks());
// Advice classes reference Vaadin types: describe them from bytecode instead of loading them.
ClassFileLocator locator = ClassFileLocator.ForClassLoader.of(Installer.class.getClassLoader());
TypePool pool = TypePool.Default.of(locator);
Advice sessionConstructor = advice(pool, locator, "SessionConstructor");
Advice sessionDestroy = advice(pool, locator, "SessionDestroy");
Advice serviceInit = advice(pool, locator, "ServiceInit");
Advice componentConstructor = advice(pool, locator, "ComponentConstructor");
Advice componentAttach = advice(pool, locator, "ComponentAttach");
Advice nodeDirty = advice(pool, locator, "NodeDirty");
AgentBuilder.Identified.Extendable builder = new AgentBuilder.Default()
.disableClassFormatChanges()
.with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
.with(AgentBuilder.Listener.StreamWriting.toSystemError().withErrorsOnly())
.ignore(nameStartsWith("net.bytebuddy.").or(nameStartsWith("dev.leakfinder.")))
.type(named("com.vaadin.flow.server.VaadinSession"))
.transform((b, type, cl, module, pd) -> b.visit(sessionConstructor.on(isConstructor())))
.type(named("com.vaadin.flow.server.VaadinService"))
.transform((b, type, cl, module, pd) -> b
.visit(sessionDestroy.on(named("fireSessionDestroy").and(takesArguments(1))))
.visit(serviceInit.on(named("init").and(takesArguments(0)))))
.type(named("com.vaadin.flow.component.Component"))
.transform((b, type, cl, module, pd) -> b.visit(componentConstructor.on(isConstructor())));
if (config.runtimeChecks) {
builder = builder
.type(named("com.vaadin.flow.component.ComponentUtil"))
.transform((b, type, cl, module, pd) -> b.visit(componentAttach
.on(named("onComponentAttach").and(takesArguments(2)).and(not(isSynthetic())))))
.type(named("com.vaadin.flow.internal.StateNode"))
.transform((b, type, cl, module, pd) -> b.visit(nodeDirty
.on(named("markAsDirty").and(takesArguments(0)))));
}
builder.installOn(inst);
System.err.println("[vaadin-leakfinder] agent installed (runtimeChecks=" + config.runtimeChecks
+ ", failFast=" + config.failFast + ", sessionScan=" + config.sessionScan + ")");
}
private static Advice advice(TypePool pool, ClassFileLocator locator, String name) {
return Advice.to(pool.describe(ADVICE + name).resolve(), locator);
}
/**
* The heap walk reads private fields of JDK classes (collections, ...). Open every package of
* the boot layer to the agent's module so {@code setAccessible} succeeds.
*/
private static void openModules(Instrumentation inst) {
Module agentModule = Installer.class.getModule();
for (Module module : ModuleLayer.boot().modules()) {
if (!inst.isModifiableModule(module)) {
continue;
}
Map<String, Set<Module>> opens = new HashMap<>();
for (String pkg : module.getPackages()) {
opens.put(pkg, Set.of(agentModule));
}
try {
inst.redefineModule(module, Set.of(), opens, opens, Set.of(), Map.of());
} catch (Throwable t) {
// not fatal: affected fields are skipped by the walker
}
}
}
}
@@ -0,0 +1,29 @@
package dev.leakfinder.core;
final class Json {
private Json() {
}
static String str(String s) {
StringBuilder sb = new StringBuilder(s.length() + 2).append('"');
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '"' -> sb.append("\\\"");
case '\\' -> sb.append("\\\\");
case '\n' -> sb.append("\\n");
case '\r' -> sb.append("\\r");
case '\t' -> sb.append("\\t");
default -> {
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
}
return sb.append('"').toString();
}
}
@@ -0,0 +1,122 @@
package dev.leakfinder.core;
import dev.leakfinder.agent.Config;
import java.lang.instrument.Instrumentation;
import java.lang.management.ManagementFactory;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.management.ObjectName;
/** Facade of a running agent. Obtain it with {@link #get()}, e.g. from tests. */
public final class LeakFinder {
private static volatile LeakFinder instance;
private final Config config;
private final SessionTracker tracker = new SessionTracker();
private final LeakReport report;
private final LeakScanner scanner;
private final RuntimeChecks hooks;
private final AtomicBoolean started = new AtomicBoolean();
private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "vaadin-leakfinder");
t.setDaemon(true);
return t;
});
LeakFinder(Config config, Instrumentation inst) {
this.config = config;
this.report = new LeakReport(config.report);
this.scanner = new LeakScanner(config, inst, tracker, report);
this.hooks = new RuntimeChecks(config, tracker, report, this::start, this::writeReportAsync);
instance = this;
}
/** The running agent, or null if it was not installed. */
public static LeakFinder get() {
return instance;
}
RuntimeChecks hooks() {
return hooks;
}
SessionTracker tracker() {
return tracker;
}
public LeakReport report() {
return report;
}
public Config config() {
return config;
}
/** Runs a full heap scan on the agent thread and waits for it. Returns the scan summary. */
public String scanNow() {
try {
return executor.submit(scanner::scan).get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "interrupted";
} catch (ExecutionException e) {
Log.get().log(System.Logger.Level.WARNING, "[vaadin-leakfinder] scan failed", e.getCause());
return "scan failed: " + e.getCause();
}
}
public int trackedSessions() {
return tracker.sessions().size();
}
public int trackedComponents() {
return tracker.trackedComponents();
}
/** Starts periodic scans and JMX; called when the first VaadinService is initialized. */
void start() {
if (!started.compareAndSet(false, true)) {
return;
}
long interval = config.scanInterval.toMillis();
if (interval > 0) {
executor.scheduleWithFixedDelay(this::safeScan, config.effectiveInitialDelay().toMillis(), interval,
TimeUnit.MILLISECONDS);
}
if (config.jmx) {
executor.execute(this::registerMBean);
}
Log.get().log(System.Logger.Level.INFO, "[vaadin-leakfinder] Vaadin service detected, "
+ (interval > 0 ? "scanning every " + config.scanInterval : "periodic scans disabled")
+ (config.report != null ? ", report: " + config.report.toAbsolutePath() : ""));
}
private void safeScan() {
try {
scanner.scan();
} catch (Throwable t) {
Log.get().log(System.Logger.Level.WARNING, "[vaadin-leakfinder] scan failed", t);
}
}
private void writeReportAsync() {
executor.execute(report::write);
}
private void registerMBean() {
try {
ObjectName name = new ObjectName("dev.leakfinder:type=LeakFinder");
var server = ManagementFactory.getPlatformMBeanServer();
if (!server.isRegistered(name)) {
server.registerMBean(new LeakFinderControl(this), name);
}
} catch (Throwable t) {
Log.get().log(System.Logger.Level.WARNING, "[vaadin-leakfinder] cannot register MBean: " + t);
}
}
}
@@ -0,0 +1,41 @@
package dev.leakfinder.core;
final class LeakFinderControl implements LeakFinderControlMBean {
private final LeakFinder finder;
LeakFinderControl(LeakFinder finder) {
this.finder = finder;
}
@Override
public String scanNow() {
return finder.scanNow();
}
@Override
public String getReportJson() {
return finder.report().toJson();
}
@Override
public int getFindingCount() {
return finder.report().findings().size();
}
@Override
public int getTrackedSessions() {
return finder.trackedSessions();
}
@Override
public int getTrackedComponents() {
return finder.trackedComponents();
}
@Override
public void clearFindings() {
finder.report().clear();
finder.report().write();
}
}
@@ -0,0 +1,18 @@
package dev.leakfinder.core;
/** JMX interface, registered as {@code dev.leakfinder:type=LeakFinder}. */
public interface LeakFinderControlMBean {
/** Runs a heap scan now and returns its summary. */
String scanNow();
String getReportJson();
int getFindingCount();
int getTrackedSessions();
int getTrackedComponents();
void clearFindings();
}
@@ -0,0 +1,92 @@
package dev.leakfinder.core;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/** Collects deduplicated findings, logs new ones and writes the JSON report. */
public final class LeakReport {
private final Map<String, Finding> findings = new ConcurrentHashMap<>();
private final Path file;
private volatile Instant lastScan;
private volatile String lastScanSummary = "no scan yet";
LeakReport(Path file) {
this.file = file;
}
/** Records an occurrence. Returns true if this is a new distinct finding. */
boolean add(Finding.Kind kind, String key, String title, List<String> details, Iterable<Integer> sessions) {
String fullKey = kind + "|" + key;
boolean[] created = new boolean[1];
Finding f = findings.computeIfAbsent(fullKey, k -> {
created[0] = true;
return new Finding(kind, fullKey, title, details);
});
f.occurrences.incrementAndGet();
f.lastSeen = Instant.now();
f.addSessions(sessions);
if (created[0]) {
Log.get().log(kind.severity.equals("ERROR") ? System.Logger.Level.ERROR : System.Logger.Level.WARNING,
"[vaadin-leakfinder] " + f.format());
}
return created[0];
}
public List<Finding> findings() {
List<Finding> list = new ArrayList<>(findings.values());
list.sort(Comparator.comparing((Finding f) -> f.kind).thenComparing(f -> f.firstSeen));
return list;
}
public boolean has(Finding.Kind kind) {
return findings.values().stream().anyMatch(f -> f.kind == kind);
}
public void clear() {
findings.clear();
}
void scanFinished(String summary) {
lastScan = Instant.now();
lastScanSummary = summary;
}
public String toJson() {
StringBuilder sb = new StringBuilder("{\n \"generated\": ").append(Json.str(Instant.now().toString()))
.append(",\n \"lastScan\": ").append(lastScan == null ? "null" : Json.str(lastScan.toString()))
.append(",\n \"lastScanSummary\": ").append(Json.str(lastScanSummary))
.append(",\n \"findings\": [");
List<Finding> list = findings();
for (int i = 0; i < list.size(); i++) {
sb.append(i == 0 ? "\n " : ",\n ").append(list.get(i).toJson());
}
return sb.append(list.isEmpty() ? "]\n}\n" : "\n ]\n}\n").toString();
}
synchronized void write() {
if (file == null) {
return;
}
try {
Path abs = file.toAbsolutePath();
if (abs.getParent() != null) {
Files.createDirectories(abs.getParent());
}
Path tmp = abs.resolveSibling(abs.getFileName() + ".tmp");
Files.writeString(tmp, toJson(), StandardCharsets.UTF_8);
Files.move(tmp, abs, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
Log.get().log(System.Logger.Level.WARNING, "[vaadin-leakfinder] cannot write report " + file + ": " + e);
}
}
}
@@ -0,0 +1,360 @@
package dev.leakfinder.core;
import dev.leakfinder.agent.Config;
import dev.leakfinder.core.HeapWalker.Action;
import dev.leakfinder.core.SessionTracker.SessionInfo;
import java.lang.instrument.Instrumentation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Enumeration;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
/**
* Heap based detection:
* <ul>
* <li>shared roots (static fields, Spring singletons) that reach UIs, components or sessions</li>
* <li>session state (UI trees, session attributes) that reaches state of another session</li>
* <li>destroyed sessions that survive garbage collection</li>
* </ul>
*/
final class LeakScanner {
private static final int MAX_COMPONENTS_PER_SESSION = 100_000;
private final Config config;
private final Instrumentation inst;
private final SessionTracker tracker;
private final LeakReport report;
private final Method shouldBeInitialized;
private final Object unsafe;
LeakScanner(Config config, Instrumentation inst, SessionTracker tracker, LeakReport report) {
this.config = config;
this.inst = inst;
this.tracker = tracker;
this.report = report;
Object u = null;
Method m = null;
try {
Class<?> unsafeClass = Class.forName("jdk.internal.misc.Unsafe");
u = unsafeClass.getMethod("getUnsafe").invoke(null);
m = unsafeClass.getMethod("shouldBeInitialized", Class.class);
m.setAccessible(true);
} catch (Throwable t) {
Log.get().log(System.Logger.Level.DEBUG, "jdk.internal.misc.Unsafe unavailable, scanning statics of all classes");
m = null;
}
this.unsafe = u;
this.shouldBeInitialized = m;
}
/** Runs all scans. Returns a one line summary. */
synchronized String scan() {
long start = System.nanoTime();
List<SessionInfo> sessions = tracker.sessions();
if (sessions.stream().anyMatch(s -> s.isDestroyed() && !s.retentionReported)) {
System.gc();
sessions = tracker.sessions();
}
HeapWalker shared = new HeapWalker(config.maxNodes, config.maxDepth);
int roots = scanSharedRoots(shared);
int scannedSessions = 0;
if (config.sessionScan) {
for (SessionInfo info : sessions) {
if (!info.isDestroyed() && scanSession(info)) {
scannedSessions++;
}
}
}
int retained = checkRetainedDestroyedSessions(sessions);
String summary = String.format(
"scan took %d ms: %d shared roots, %d objects visited%s, %d live sessions scanned, "
+ "%d destroyed sessions still reachable, %d findings",
(System.nanoTime() - start) / 1_000_000, roots, shared.visitedCount(),
shared.budgetExhausted() ? " (budget exhausted, raise maxNodes)" : "", scannedSessions, retained,
report.findings().size());
report.scanFinished(summary);
report.write();
Log.get().log(config.verbose ? System.Logger.Level.INFO : System.Logger.Level.DEBUG, "[vaadin-leakfinder] " + summary);
return summary;
}
// ---- shared roots ---------------------------------------------------------------------------
private int scanSharedRoots(HeapWalker walker) {
HeapWalker.Classifier classifier = o -> {
if (Reflect.isA(o, Reflect.VAADIN_SESSION)) {
return Action.HIT;
}
if (Reflect.isA(o, Reflect.COMPONENT)) {
return tracker.ownerOf(o) != null ? Action.HIT : Action.CONTINUE;
}
return config.isStopClass(o.getClass().getName()) ? Action.STOP : Action.CONTINUE;
};
HeapWalker.HitHandler handler = (target, path) -> {
SessionInfo owner = tracker.ownerOf(target);
if (owner == null) {
return;
}
String what = describe(target);
if (owner.isDestroyed()) {
owner.retentionReported = true;
report.add(Finding.Kind.DESTROYED_SESSION_RETAINED, HeapWalker.signature(path),
root(path) + " keeps " + what + " of a destroyed session alive", path, List.of(owner.id));
} else {
report.add(Finding.Kind.SHARED_ROOT_HOLDS_SESSION_STATE, HeapWalker.signature(path),
root(path) + " is shared by all users but references " + what + " of a single session",
path, List.of(owner.id));
}
};
int roots = 0;
for (Class<?> c : inst.getAllLoadedClasses()) {
if (!isStaticRootClass(c)) {
continue;
}
Field[] fields;
try {
fields = c.getDeclaredFields();
} catch (Throwable t) {
continue;
}
for (Field f : fields) {
if (!Modifier.isStatic(f.getModifiers()) || f.getType().isPrimitive() || !Reflect.trySetAccessible(f)) {
continue;
}
Object value;
try {
value = f.get(null);
} catch (Throwable t) {
continue;
}
if (value != null) {
roots++;
walker.walk("static " + c.getName() + "." + f.getName(), value, classifier, handler);
}
}
}
for (Object service : tracker.services()) {
for (Map.Entry<String, Object> bean : springSingletons(service)) {
Object value = bean.getValue();
if (config.isRootCandidate(value.getClass().getName())) {
roots++;
walker.walk("spring singleton '" + bean.getKey() + "'", value, classifier, handler);
}
}
}
return roots;
}
private boolean isStaticRootClass(Class<?> c) {
if (c.isArray() || c.isPrimitive() || c.isHidden()) {
return false;
}
ClassLoader cl = c.getClassLoader();
if (cl == null || cl == ClassLoader.getPlatformClassLoader() || !config.isRootCandidate(c.getName())) {
return false;
}
if (shouldBeInitialized != null) {
try {
// reading a static field would run the static initializer of a not yet initialized class
if ((Boolean) shouldBeInitialized.invoke(unsafe, c)) {
return false;
}
} catch (Throwable ignored) {
// fall through and scan
}
}
return true;
}
/** Singletons of every Spring application context registered as servlet context attribute. */
private List<Map.Entry<String, Object>> springSingletons(Object service) {
List<Map.Entry<String, Object>> result = new ArrayList<>();
Object vaadinContext = Reflect.call(service, "getContext");
Object servletContext = Reflect.hasMethod(vaadinContext, "getContext", 0)
? Reflect.call(vaadinContext, "getContext") : null;
if (!(Reflect.call(servletContext, "getAttributeNames") instanceof Enumeration<?> names)) {
return result;
}
List<Object> seenFactories = new ArrayList<>();
while (names.hasMoreElements()) {
Object attr = Reflect.call(servletContext, "getAttribute", names.nextElement());
if (!Reflect.hasMethod(attr, "getBeanFactory", 0)) {
continue;
}
Object factory = Reflect.call(attr, "getBeanFactory");
if (factory == null || seenFactories.stream().anyMatch(f -> f == factory)) {
continue;
}
seenFactories.add(factory);
if (Reflect.call(factory, "getSingletonNames") instanceof String[] beanNames) {
for (String name : beanNames) {
Object bean = Reflect.call(factory, "getSingleton", name);
if (bean != null) {
result.add(Map.entry(name, bean));
}
}
}
}
return result;
}
// ---- per session ----------------------------------------------------------------------------
/** Walks the UI trees and attributes of one session under its lock. */
private boolean scanSession(SessionInfo info) {
Object session = info.get();
if (session == null) {
return false;
}
Runnable scan = () -> scanSessionLocked(info, session);
Object command = command(session, scan);
if (command == null || Reflect.call(session, "getSession") == null) {
return false;
}
try {
Method access = session.getClass().getMethod("accessSynchronously", command.getClass().getInterfaces()[0]);
access.invoke(session, command);
return true;
} catch (Throwable t) {
Log.get().log(System.Logger.Level.DEBUG, "[vaadin-leakfinder] cannot scan " + info + ": " + t);
return false;
}
}
private void scanSessionLocked(SessionInfo info, Object session) {
HeapWalker walker = new HeapWalker(config.maxNodes, config.maxDepth);
walker.exclude(session);
HeapWalker.Classifier classifier = o -> {
if (Reflect.isA(o, Reflect.VAADIN_SESSION)) {
return o == session ? Action.STOP : Action.HIT;
}
if (Reflect.isA(o, Reflect.COMPONENT)) {
SessionInfo owner = tracker.ownerOf(o);
if (owner != null && owner != info) {
return Action.HIT;
}
return Action.CONTINUE;
}
return config.isStopClass(o.getClass().getName()) ? Action.STOP : Action.CONTINUE;
};
HeapWalker.HitHandler handler = (target, path) -> {
SessionInfo owner = tracker.ownerOf(target);
if (owner == null || owner == info) {
return;
}
report.add(Finding.Kind.SESSION_REFERENCES_FOREIGN_STATE, HeapWalker.signature(path),
root(path) + " of one session references " + describe(target) + " of another session",
path, List.of(info.id, owner.id));
};
for (Object component : components(session)) {
walker.walk("component " + component.getClass().getName(), component, classifier, handler);
}
if (Reflect.field(Reflect.field(session, "attributes"), "attributes") instanceof Map<?, ?> attributes) {
for (Map.Entry<?, ?> e : List.copyOf(attributes.entrySet())) {
walker.walk("vaadin session attribute '" + e.getKey() + "'", e.getValue(), classifier, handler);
}
}
Object wrapped = Reflect.call(session, "getSession");
if (Reflect.call(wrapped, "getAttributeNames") instanceof Collection<?> names) {
for (Object name : List.copyOf(names)) {
Object value = Reflect.call(wrapped, "getAttribute", name);
if (value != null && !Reflect.isA(value, Reflect.VAADIN_SESSION)) {
walker.walk("http session attribute '" + name + "'", value, classifier, handler);
}
}
}
}
/** All components of all UIs of the session. Must run while holding the session lock. */
private List<Object> components(Object session) {
List<Object> result = new ArrayList<>();
if (!(Reflect.field(session, "uIs") instanceof Map<?, ?> uis)) {
return result;
}
List<Object> todo = new ArrayList<>(uis.values());
while (!todo.isEmpty() && result.size() < MAX_COMPONENTS_PER_SESSION) {
Object c = todo.remove(todo.size() - 1);
result.add(c);
if (Reflect.call(c, "getChildren") instanceof Stream<?> children) {
children.forEach(todo::add);
}
}
return result;
}
/** A {@code com.vaadin.flow.server.Command} proxy running the given task. */
private static Object command(Object session, Runnable task) {
try {
ClassLoader cl = session.getClass().getClassLoader();
Class<?> commandType = Class.forName("com.vaadin.flow.server.Command", false, cl);
return Proxy.newProxyInstance(cl, new Class<?>[]{commandType}, (proxy, method, args) -> switch (method.getName()) {
case "execute" -> {
task.run();
yield null;
}
case "hashCode" -> System.identityHashCode(proxy);
case "equals" -> proxy == args[0];
case "toString" -> "vaadin-leakfinder session scan";
default -> null;
});
} catch (Throwable t) {
return null;
}
}
// ---- destroyed sessions ---------------------------------------------------------------------
private int checkRetainedDestroyedSessions(List<SessionInfo> sessions) {
int retained = 0;
long now = System.currentTimeMillis();
for (SessionInfo info : sessions) {
if (!info.isDestroyed() || info.get() == null) {
continue;
}
retained++;
if (!info.retentionReported && now - info.destroyedAt > config.gcWait.toMillis()) {
info.retentionReported = true;
report.add(Finding.Kind.DESTROYED_SESSION_RETAINED, "unexplained",
"Destroyed Vaadin sessions are still reachable after GC, but no path from static fields or "
+ "Spring singletons was found",
List.of("The reference may come from a thread, a thread local, a framework cache or an "
+ "excluded package (see the 'include'/'exclude'/'stop' options).",
"Take a heap dump (jcmd <pid> GC.heap_dump) and inspect the GC roots of "
+ "com.vaadin.flow.server.VaadinSession instances."),
List.of(info.id));
}
}
return retained;
}
// ---- formatting -----------------------------------------------------------------------------
private static String root(List<String> path) {
String first = path.get(0);
int typeStart = first.indexOf(" (");
return typeStart > 0 ? first.substring(0, typeStart) : first;
}
private static String describe(Object target) {
if (Reflect.isA(target, Reflect.VAADIN_SESSION)) {
return "the VaadinSession";
}
if (Reflect.isA(target, Reflect.UI)) {
return "the UI (" + target.getClass().getName() + ")";
}
return "component " + target.getClass().getName();
}
}
@@ -0,0 +1,22 @@
package dev.leakfinder.core;
/**
* Lazily created logger. Creating a {@link System.Logger} in {@code premain} would initialize
* java.util.logging before the application had a chance to configure it.
*/
final class Log {
private static volatile System.Logger logger;
private Log() {
}
static System.Logger get() {
System.Logger l = logger;
if (l == null) {
l = System.getLogger("dev.leakfinder");
logger = l;
}
return l;
}
}
@@ -0,0 +1,197 @@
package dev.leakfinder.core;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* Reflection helpers. The agent core lives in the system class loader and cannot link against Vaadin,
* which may sit in a child class loader, so all Vaadin access goes through here by name.
*/
final class Reflect {
static final String VAADIN_SESSION = "com.vaadin.flow.server.VaadinSession";
static final String UI = "com.vaadin.flow.component.UI";
static final String COMPONENT = "com.vaadin.flow.component.Component";
private static final ClassValue<Set<String>> SUPERTYPES = new ClassValue<>() {
@Override
protected Set<String> computeValue(Class<?> type) {
Set<String> names = new HashSet<>();
collect(type, names);
return names;
}
private void collect(Class<?> c, Set<String> names) {
if (c == null || !names.add(c.getName())) {
return;
}
collect(c.getSuperclass(), names);
for (Class<?> i : c.getInterfaces()) {
collect(i, names);
}
}
};
private static final ClassValue<Field[]> INSTANCE_FIELDS = new ClassValue<>() {
@Override
protected Field[] computeValue(Class<?> type) {
List<Field> fields = new ArrayList<>();
for (Class<?> c = type; c != null && c != Object.class; c = c.getSuperclass()) {
Field[] declared;
try {
declared = c.getDeclaredFields();
} catch (Throwable t) {
continue;
}
for (Field f : declared) {
if (Modifier.isStatic(f.getModifiers()) || f.getType().isPrimitive()) {
continue;
}
if (trySetAccessible(f)) {
fields.add(f);
}
}
}
return fields.toArray(new Field[0]);
}
};
private static final ClassValue<Map<String, Method>> METHODS = new ClassValue<>() {
@Override
protected Map<String, Method> computeValue(Class<?> type) {
return new ConcurrentHashMap<>();
}
};
private static final Method NO_METHOD;
static {
try {
NO_METHOD = Object.class.getMethod("hashCode");
} catch (NoSuchMethodException e) {
throw new ExceptionInInitializerError(e);
}
}
private Reflect() {
}
static boolean isA(Object o, String className) {
return o != null && SUPERTYPES.get(o.getClass()).contains(className);
}
static boolean isA(Class<?> c, String className) {
return SUPERTYPES.get(c).contains(className);
}
static Field[] instanceFields(Class<?> c) {
return INSTANCE_FIELDS.get(c);
}
static boolean trySetAccessible(java.lang.reflect.AccessibleObject f) {
try {
return f.trySetAccessible();
} catch (Throwable t) {
return false;
}
}
/** Invokes a public method by name and argument count; returns null on any failure. */
static Object call(Object target, String name, Object... args) {
if (target == null) {
return null;
}
Method m = method(target.getClass(), name, args.length);
if (m == null) {
return null;
}
try {
Object result = m.invoke(target, args);
return result instanceof Optional<?> opt ? opt.orElse(null) : result;
} catch (Throwable t) {
return null;
}
}
static boolean hasMethod(Object target, String name, int argCount) {
return target != null && method(target.getClass(), name, argCount) != null;
}
private static Method method(Class<?> type, String name, int argCount) {
Method m = METHODS.get(type).computeIfAbsent(name + '/' + argCount, k -> find(type, name, argCount));
return m == NO_METHOD ? null : m;
}
private static Method find(Class<?> type, String name, int argCount) {
for (Class<?> c = type; c != null; c = c.getSuperclass()) {
for (Method m : c.getMethods()) {
if (m.getName().equals(name) && m.getParameterCount() == argCount
&& !Modifier.isStatic(m.getModifiers())) {
// prefer the declaration on a public type, the runtime class may be hidden / package private
Method best = publicDeclaration(m);
trySetAccessible(best);
return best;
}
}
}
return NO_METHOD;
}
private static Method publicDeclaration(Method m) {
Set<Class<?>> seen = new HashSet<>();
List<Class<?>> todo = new ArrayList<>(List.of(m.getDeclaringClass()));
while (!todo.isEmpty()) {
Class<?> c = todo.remove(0);
if (c == null || !seen.add(c)) {
continue;
}
if (Modifier.isPublic(c.getModifiers())) {
try {
return c.getMethod(m.getName(), m.getParameterTypes());
} catch (NoSuchMethodException ignored) {
// keep looking
}
}
todo.add(c.getSuperclass());
todo.addAll(List.of(c.getInterfaces()));
}
return m;
}
/** Reads a declared field by name anywhere in the hierarchy. */
static Object field(Object target, String name) {
if (target == null) {
return null;
}
for (Class<?> c = target.getClass(); c != null; c = c.getSuperclass()) {
try {
Field f = c.getDeclaredField(name);
if (trySetAccessible(f)) {
return f.get(target);
}
return null;
} catch (NoSuchFieldException ignored) {
// continue with super class
} catch (Throwable t) {
return null;
}
}
return null;
}
/** Hidden class names contain an address suffix that differs between runs. */
static String stableName(Class<?> c) {
return stableName(c.getName());
}
static String stableName(String name) {
return name.replaceAll("/0x[0-9a-fA-F]+", "").replaceAll("\\$\\$SpringCGLIB\\$\\$\\d+", "\\$\\$SpringCGLIB");
}
}
@@ -0,0 +1,160 @@
package dev.leakfinder.core;
import dev.leakfinder.agent.Config;
import dev.leakfinder.bridge.LeakBridge;
import dev.leakfinder.bridge.LeakDetectedException;
import dev.leakfinder.core.SessionTracker.SessionInfo;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* Receives the callbacks of the instrumented Vaadin code: tracks session lifecycle and component
* ownership and reports cross-session attach / mutation as they happen.
*/
final class RuntimeChecks implements LeakBridge.Hooks {
private static final List<String> FRAMEWORK_PREFIXES = List.of(
"java.", "javax.", "jdk.", "sun.", "com.sun.", "jakarta.", "com.vaadin.",
"org.springframework.", "org.apache.", "org.eclipse.", "io.undertow.", "net.bytebuddy.",
"org.atmosphere.", "elemental.");
private final Config config;
private final SessionTracker tracker;
private final LeakReport report;
private final Runnable onServiceInit;
private final Runnable onNewFinding;
RuntimeChecks(Config config, SessionTracker tracker, LeakReport report, Runnable onServiceInit,
Runnable onNewFinding) {
this.config = config;
this.tracker = tracker;
this.report = report;
this.onServiceInit = onServiceInit;
this.onNewFinding = onNewFinding;
}
@Override
public void sessionCreated(Object vaadinSession) {
tracker.info(vaadinSession);
}
@Override
public void sessionDestroyed(Object vaadinSession) {
tracker.destroyed(vaadinSession);
}
@Override
public void serviceInitialized(Object vaadinService) {
tracker.addService(vaadinService);
onServiceInit.run();
}
@Override
public void componentCreated(Object component, Object currentSession) {
if (currentSession != null) {
tracker.setOwner(component, tracker.info(currentSession));
}
}
@Override
public void componentAttached(Object component, Object ui, Object uiSession, Object currentSession) {
if (uiSession == null) {
return;
}
SessionInfo target = tracker.info(uiSession);
SessionInfo previous = tracker.owner(component);
tracker.setOwner(component, target);
if (previous == null || previous == target) {
return;
}
List<String> stack = stack();
String type = component.getClass().getName();
String title = "Component " + type + " of " + previous + " was attached to a UI of " + target
+ ". The same component instance is shared between users.";
String parents = parentChain(component);
List<String> details = new ArrayList<>();
details.add("component: " + type);
details.add("attached inside: " + parents);
details.add("attached at:");
details.addAll(stack);
String key = Reflect.stableName(type) + "|" + parents + "|" + userFrames(stack);
record(Finding.Kind.CROSS_SESSION_ATTACH, key, title, details, previous, target);
}
@Override
public void crossSessionMutation(Object stateNode, Object ui, Object ownerSession, Object currentSession) {
SessionInfo owner = tracker.info(ownerSession);
SessionInfo current = tracker.info(currentSession);
List<String> stack = stack();
String title = "Request of " + current + " modified the UI (" + ui.getClass().getName() + ") of "
+ owner + " without holding that session's lock.";
List<String> details = new ArrayList<>();
details.add("modified at:");
details.addAll(stack);
record(Finding.Kind.CROSS_SESSION_MUTATION, userFrames(stack), title, details, owner, current);
}
private void record(Finding.Kind kind, String key, String title, List<String> details, SessionInfo a,
SessionInfo b) {
if (report.add(kind, key, title, details, List.of(a.id, b.id))) {
onNewFinding.run();
}
if (config.failFast) {
throw new LeakDetectedException("[vaadin-leakfinder] " + kind + ": " + title);
}
}
/** "MyView > MainLayout > UI": where the component sits, often more telling than the stack. */
private static String parentChain(Object component) {
List<String> names = new ArrayList<>();
Object parent = Reflect.call(component, "getParent");
while (parent != null && names.size() < 10) {
names.add(Reflect.stableName(parent.getClass()));
parent = Reflect.call(parent, "getParent");
}
return names.isEmpty() ? "(no parent)" : String.join(" > ", names);
}
/**
* Stack of the current thread without the agent's own frames: the top frames as they are, then
* only application frames (framework frames between them are elided).
*/
static List<String> stack() {
List<String> all = StackWalker.getInstance().walk(frames -> frames
.dropWhile(f -> f.getClassName().startsWith("dev.leakfinder."))
.map(f -> "at " + f)
.collect(Collectors.toList()));
List<String> result = new ArrayList<>(all.subList(0, Math.min(TOP_FRAMES, all.size())));
boolean elided = false;
for (String frame : all.subList(result.size(), all.size())) {
if (isApplicationFrame(frame) && result.size() < MAX_FRAMES) {
result.add(frame);
elided = false;
} else if (!elided) {
result.add("...");
elided = true;
}
}
return result;
}
private static final int TOP_FRAMES = 12;
private static final int MAX_FRAMES = 40;
private static boolean isApplicationFrame(String frame) {
return FRAMEWORK_PREFIXES.stream().noneMatch(p -> frame.startsWith("at " + p))
&& !frame.startsWith("at org.junit.");
}
/** The first few application frames identify the code location of a runtime finding. */
private static String userFrames(List<String> stack) {
List<String> user = stack.stream()
.filter(RuntimeChecks::isApplicationFrame)
.filter(line -> !line.equals("..."))
.limit(3)
.toList();
return Reflect.stableName(String.join("|", user.isEmpty() ? stack.stream().limit(5).toList() : user));
}
}
@@ -0,0 +1,137 @@
package dev.leakfinder.core;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Keeps track of Vaadin sessions, services and which session owns which component. Everything is
* weakly referenced; sessions are identified by a small sequence number in reports so that real
* HTTP session ids never end up in logs.
*/
final class SessionTracker {
/** Agent side view of a VaadinSession. Holds no strong reference to the session. */
static final class SessionInfo {
final int id;
final WeakReference<Object> session;
final long createdAt = System.currentTimeMillis();
volatile long destroyedAt;
/** Set once a retention of this destroyed session has been reported. */
volatile boolean retentionReported;
SessionInfo(int id, Object session) {
this.id = id;
this.session = new WeakReference<>(session);
}
boolean isDestroyed() {
return destroyedAt != 0;
}
Object get() {
return session.get();
}
@Override
public String toString() {
return "session#" + id + (isDestroyed() ? " (destroyed)" : "");
}
}
private final AtomicInteger sequence = new AtomicInteger();
private final WeakIdentityMap<Object, SessionInfo> sessions = new WeakIdentityMap<>();
private final WeakIdentityMap<Object, SessionInfo> owners = new WeakIdentityMap<>();
private final List<SessionInfo> all = new CopyOnWriteArrayList<>();
private final List<WeakReference<Object>> services = new CopyOnWriteArrayList<>();
SessionInfo info(Object session) {
return sessions.computeIfAbsent(session, s -> {
SessionInfo info = new SessionInfo(sequence.incrementAndGet(), s);
all.add(info);
return info;
});
}
SessionInfo existingInfo(Object session) {
return sessions.get(session);
}
void destroyed(Object session) {
SessionInfo info = info(session);
if (info.destroyedAt == 0) {
info.destroyedAt = System.currentTimeMillis();
}
}
void addService(Object service) {
for (WeakReference<Object> ref : services) {
if (ref.get() == service) {
return;
}
}
services.add(new WeakReference<>(service));
}
List<Object> services() {
List<Object> result = new ArrayList<>();
for (WeakReference<Object> ref : services) {
Object s = ref.get();
if (s != null) {
result.add(s);
} else {
services.remove(ref);
}
}
return result;
}
/** Snapshot of all sessions whose objects are still reachable. Drops infos of collected sessions. */
List<SessionInfo> sessions() {
List<SessionInfo> result = new ArrayList<>();
for (SessionInfo info : all) {
if (info.get() == null) {
all.remove(info);
} else {
result.add(info);
}
}
return result;
}
// ---- component ownership -------------------------------------------------------------------
SessionInfo owner(Object component) {
return owners.get(component);
}
void setOwner(Object component, SessionInfo owner) {
owners.put(component, owner);
}
int trackedComponents() {
return owners.size();
}
/**
* Owning session of a Vaadin object, or null when it has none (e.g. a component created at
* startup and never attached).
*/
SessionInfo ownerOf(Object o) {
if (Reflect.isA(o, Reflect.VAADIN_SESSION)) {
return info(o);
}
if (!Reflect.isA(o, Reflect.COMPONENT)) {
return null;
}
SessionInfo known = owner(o);
if (known != null) {
return known;
}
Object ui = Reflect.isA(o, Reflect.UI) ? o : Reflect.call(o, "getUI");
Object session = Reflect.call(ui, "getSession");
return session == null ? null : info(session);
}
}
@@ -0,0 +1,126 @@
package dev.leakfinder.core;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Concurrent map with weak, identity-compared keys. The agent must never keep application objects
* alive itself, otherwise it would create the very leaks it reports.
*/
final class WeakIdentityMap<K, V> {
private final ConcurrentHashMap<Object, V> map = new ConcurrentHashMap<>();
private final ReferenceQueue<K> queue = new ReferenceQueue<>();
V get(K key) {
return map.get(new LookupKey(key));
}
void put(K key, V value) {
expunge();
map.put(new WeakKey<>(key, queue), value);
}
V computeIfAbsent(K key, java.util.function.Function<K, V> factory) {
V v = get(key);
if (v != null) {
return v;
}
expunge();
return map.computeIfAbsent(new WeakKey<>(key, queue), k -> factory.apply(key));
}
V remove(K key) {
return map.remove(new LookupKey(key));
}
int size() {
expunge();
return map.size();
}
/** Snapshot of live entries; keys are strongly referenced only by the returned list. */
@SuppressWarnings("unchecked")
List<Map.Entry<K, V>> entries() {
expunge();
List<Map.Entry<K, V>> result = new ArrayList<>();
map.forEach((k, v) -> {
Object referent = ((WeakKey<?>) k).get();
if (referent != null) {
result.add(Map.entry((K) referent, v));
}
});
return result;
}
void clear() {
map.clear();
}
private void expunge() {
Reference<? extends K> ref;
while ((ref = queue.poll()) != null) {
map.remove(ref);
}
}
/** Stored key. Equal to a {@link LookupKey} or another WeakKey pointing at the same object. */
private static final class WeakKey<T> extends WeakReference<T> {
private final int hash;
WeakKey(T referent, ReferenceQueue<T> queue) {
super(referent, queue);
this.hash = System.identityHashCode(referent);
}
@Override
public int hashCode() {
return hash;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
Object mine = get();
if (mine == null) {
return false;
}
if (o instanceof WeakKey<?> other) {
return mine == other.get();
}
if (o instanceof LookupKey other) {
return mine == other.referent;
}
return false;
}
}
/** Short lived strong key used for lookups only. */
private static final class LookupKey {
private final Object referent;
LookupKey(Object referent) {
this.referent = referent;
}
@Override
public int hashCode() {
return System.identityHashCode(referent);
}
@Override
public boolean equals(Object o) {
if (o instanceof WeakKey<?> other) {
return referent == other.get();
}
return o instanceof LookupKey other && referent == other.referent;
}
}
}
@@ -0,0 +1,50 @@
package com.acme.fixtures;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.html.Div;
import java.util.ArrayList;
import java.util.List;
/** Application-like classes used by the tests. Outside of the agent's default exclude list. */
public final class Fixtures {
private Fixtures() {
}
/** Static, application wide cache: anything put here is shared by all sessions. */
public static final class GlobalCache {
public static final List<Object> ENTRIES = new ArrayList<>();
private GlobalCache() {
}
}
/** Static cache used by the destroyed-session test. */
public static final class Graveyard {
public static final List<Object> ENTRIES = new ArrayList<>();
private Graveyard() {
}
}
public static class LeakedView extends Div {
}
public static class RetainedView extends Div {
}
/** A view of one user keeping a reference to another user's component. */
public static class SnoopView extends Div {
public Component peeked;
}
/** Only references its own children. */
public static class CleanView extends Div {
public final Div child = new Div();
public CleanView() {
add(child);
}
}
}
@@ -0,0 +1,46 @@
package dev.leakfinder.agent;
import org.junit.jupiter.api.Test;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ConfigTest {
@Test
void parsesOptions() {
Config c = Config.parse("scanInterval=30s,gcWait=500ms,report=target/leaks.json,include=com.acme;org.foo,"
+ "failFast,maxNodes=10");
assertEquals(Duration.ofSeconds(30), c.scanInterval);
assertEquals(Duration.ofMillis(500), c.gcWait);
assertEquals(Path.of("target/leaks.json"), c.report);
assertEquals(List.of("com.acme", "org.foo"), c.include);
assertTrue(c.failFast);
assertEquals(10, c.maxNodes);
}
@Test
void rootCandidates() {
Config defaults = Config.parse(null);
assertTrue(defaults.isRootCandidate("com.example.MyView"));
assertFalse(defaults.isRootCandidate("com.vaadin.flow.component.UI"));
assertFalse(defaults.isRootCandidate("org.springframework.context.Foo"));
Config included = Config.parse("include=com.acme");
assertTrue(included.isRootCandidate("com.acme.Cache"));
assertFalse(included.isRootCandidate("com.example.MyView"));
}
@Test
void stopClasses() {
Config c = Config.parse("stop=com.example.internal.");
assertTrue(c.isStopClass("org.apache.catalina.session.StandardSession"));
assertTrue(c.isStopClass("com.example.internal.Foo"));
assertFalse(c.isStopClass("java.util.ArrayList"));
}
}
@@ -0,0 +1,226 @@
package dev.leakfinder.core;
import com.acme.fixtures.Fixtures;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.server.VaadinService;
import com.vaadin.flow.server.VaadinSession;
import com.vaadin.flow.server.WrappedSession;
import dev.leakfinder.agent.LeakFinderAgent;
import net.bytebuddy.agent.ByteBuddyAgent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Installs the agent into the test JVM and exercises it against real Flow classes with two
* hand-made sessions.
*/
class LeakFinderTest {
private static LeakFinder finder;
private static VaadinService service;
@BeforeAll
static void installAgent() {
LeakFinderAgent.start("scanInterval=0,jmx=false,gcWait=0s,include=com.acme", ByteBuddyAgent.install());
finder = LeakFinder.get();
assertNotNull(finder, "agent not installed");
service = Mockito.mock(VaadinService.class);
}
@Test
void componentMovedToOtherSessionIsReportedAtRuntime() {
TestSession a = new TestSession(service);
TestSession b = new TestSession(service);
UI uiA = ui(a);
UI uiB = ui(b);
Span shared = inSession(a, () -> {
Span s = new Span("shared banner");
uiA.add(s);
return s;
});
inSession(b, () -> {
// Flow refuses to move a node between state trees unless it is explicitly removed first
shared.getElement().removeFromTree();
uiB.add(shared);
return null;
});
Finding attach = only(Finding.Kind.CROSS_SESSION_ATTACH, Span.class.getName());
assertTrue(attach.details().stream().anyMatch(l -> l.contains("LeakFinderTest")), attach.format());
assertEquals(Set.of(a.info().id, b.info().id), attach.sessions());
Finding mutation = only(Finding.Kind.CROSS_SESSION_MUTATION, "LeakFinderTest");
assertTrue(mutation.sessions().contains(a.info().id));
}
@Test
void sameSessionMovesAreNotReported() {
TestSession a = new TestSession(service);
UI ui1 = ui(a);
UI ui2 = ui(a);
inSession(a, () -> {
Fixtures.CleanView view = new Fixtures.CleanView();
ui1.add(view);
view.getElement().removeFromTree();
ui2.add(view);
return null;
});
assertFalse(mentions(Finding.Kind.CROSS_SESSION_ATTACH, Fixtures.CleanView.class.getName()));
}
@Test
void staticFieldHoldingComponentIsReported() {
TestSession a = new TestSession(service);
UI uiA = ui(a);
inSession(a, () -> {
Fixtures.LeakedView view = new Fixtures.LeakedView();
uiA.add(view);
Fixtures.GlobalCache.ENTRIES.add(view);
return null;
});
finder.scanNow();
Finding f = only(Finding.Kind.SHARED_ROOT_HOLDS_SESSION_STATE, Fixtures.GlobalCache.class.getName());
assertTrue(f.details().get(0).startsWith("static " + Fixtures.GlobalCache.class.getName() + ".ENTRIES"),
f.format());
assertTrue(f.details().get(f.details().size() - 1).contains(Fixtures.LeakedView.class.getName()), f.format());
}
@Test
void sessionReferencingForeignComponentIsReported() {
TestSession a = new TestSession(service);
TestSession b = new TestSession(service);
UI uiA = ui(a);
UI uiB = ui(b);
Fixtures.CleanView victim = inSession(a, () -> {
Fixtures.CleanView v = new Fixtures.CleanView();
uiA.add(v);
return v;
});
inSession(b, () -> {
Fixtures.SnoopView snoop = new Fixtures.SnoopView();
snoop.peeked = victim;
uiB.add(snoop);
return null;
});
finder.scanNow();
Finding f = only(Finding.Kind.SESSION_REFERENCES_FOREIGN_STATE, Fixtures.SnoopView.class.getName());
assertTrue(f.details().stream().anyMatch(l -> l.contains(".peeked")), f.format());
assertEquals(Set.of(a.info().id, b.info().id), f.sessions());
// the victim's own UI tree is clean
assertFalse(f.title().startsWith("component " + Fixtures.CleanView.class.getName()), f.format());
}
@Test
void destroyedSessionKeptAliveByStaticIsReported() {
TestSession a = new TestSession(service);
UI uiA = ui(a);
inSession(a, () -> {
Fixtures.RetainedView view = new Fixtures.RetainedView();
uiA.add(view);
Fixtures.Graveyard.ENTRIES.add(view);
return null;
});
finder.hooks().sessionDestroyed(a);
finder.scanNow();
Finding f = only(Finding.Kind.DESTROYED_SESSION_RETAINED, Fixtures.Graveyard.class.getName());
assertTrue(f.sessions().contains(a.info().id));
}
@Test
void reportIsValidLookingJson() {
String json = finder.report().toJson();
assertTrue(json.startsWith("{") && json.trim().endsWith("}"), json);
assertTrue(json.contains("\"findings\""), json);
}
// ---- helpers -------------------------------------------------------------------------------
private static Finding only(Finding.Kind kind, String mention) {
List<Finding> matches = finder.report().findings().stream()
.filter(f -> f.kind() == kind)
.filter(f -> f.title().contains(mention) || f.details().stream().anyMatch(l -> l.contains(mention)))
.toList();
assertEquals(1, matches.size(), () -> "expected one " + kind + " mentioning " + mention + " in\n"
+ finder.report().toJson());
return matches.get(0);
}
private static boolean mentions(Finding.Kind kind, String mention) {
return finder.report().findings().stream()
.filter(f -> f.kind() == kind)
.anyMatch(f -> f.title().contains(mention) || f.details().stream().anyMatch(l -> l.contains(mention)));
}
private static <T> T inSession(TestSession session, Supplier<T> action) {
session.lock.lock();
VaadinSession.setCurrent(session);
try {
return action.get();
} finally {
VaadinSession.setCurrent(null);
session.lock.unlock();
}
}
@SuppressWarnings("unchecked")
private static UI ui(TestSession session) {
return inSession(session, () -> {
UI ui = new UI();
ui.getInternals().setSession(session);
Map<Integer, UI> uis = (Map<Integer, UI>) Reflect.field(session, "uIs");
uis.put(uis.size() + 1, ui);
return ui;
});
}
/** Session with a plain lock and a mocked HTTP session, enough for the agent's needs. */
static final class TestSession extends VaadinSession {
final ReentrantLock lock = new ReentrantLock();
private final WrappedSession wrapped = Mockito.mock(WrappedSession.class);
TestSession(VaadinService service) {
super(service);
Mockito.when(wrapped.getAttributeNames()).thenReturn(Set.of());
}
@Override
public Lock getLockInstance() {
return lock;
}
@Override
public boolean hasLock() {
return lock.isHeldByCurrentThread();
}
@Override
public WrappedSession getSession() {
return wrapped;
}
SessionTracker.SessionInfo info() {
return finder.tracker().info(this);
}
}
}
Vendored Executable
+295
View File
@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"
Vendored
+189
View File
@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+84
View File
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>dev.leakfinder</groupId>
<artifactId>vaadin-leakfinder-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<name>Vaadin Leak Finder</name>
<description>Java agent detecting inter-session leaks in Vaadin Flow 25 applications</description>
<modules>
<module>leakfinder-agent</module>
<module>demo-app</module>
</modules>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<vaadin.version>25.2.1</vaadin.version>
<spring-boot.version>4.0.5</spring-boot.version>
<bytebuddy.version>1.18.10</bytebuddy.version>
<junit.version>6.0.3</junit.version>
<mockito.version>5.20.0</mockito.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-bom</artifactId>
<version>${vaadin.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>${junit.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>${bytebuddy.version}</version>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy-agent</artifactId>
<version>${bytebuddy.version}</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.6</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.5.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.2</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
Executable
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# End-to-end check: runs the demo app (production fat jar) with the agent, drives two user sessions
# through the leaky views with plain HTTP requests and asserts the agent's JSON report.
#
# scripts/e2e.sh build everything, then test
# SKIP_BUILD=1 scripts/e2e.sh reuse existing jars
# MVN=/path/to/mvn PORT=18765 override maven / port
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd)
PORT=${PORT:-18765}
BASE="http://localhost:$PORT"
MVN=${MVN:-mvn}
WORK=$(mktemp -d)
REPORT="$WORK/leaks.json"
LOG="$WORK/app.log"
AGENT="$ROOT/leakfinder-agent/target/leakfinder-agent-0.1.0-SNAPSHOT.jar"
APP="$ROOT/demo-app/target/demo-app-0.1.0-SNAPSHOT.jar"
if [[ "${SKIP_BUILD:-0}" != 1 ]]; then
(cd "$ROOT" && "$MVN" -q -pl leakfinder-agent install -DskipTests && "$MVN" -q -pl demo-app -Pproduction package -DskipTests)
fi
APP_PID=
cleanup() {
[[ -n "$APP_PID" ]] && kill "$APP_PID" 2>/dev/null || true
echo "app log: $LOG"
}
trap cleanup EXIT
PORT=$PORT java "-javaagent:$AGENT=scanInterval=2s,gcWait=1s,report=$REPORT" -jar "$APP" >"$LOG" 2>&1 &
APP_PID=$!
for _ in $(seq 1 120); do
grep -q 'Started Application' "$LOG" && break
if ! kill -0 "$APP_PID" 2>/dev/null; then echo "app died"; tail -50 "$LOG"; exit 1; fi
sleep 1
done
grep -q 'Started Application' "$LOG" || { echo "app did not start"; exit 1; }
# Opens a route in a new UI of the session identified by cookie jar $1, like a browser tab would:
# bootstrap request (v-r=init) followed by the client side "ui-navigate" event.
open_view() {
local jar="$WORK/$1" route="$2" init key ui
init=$(curl -sf --max-time 20 -c "$jar" -b "$jar" "$BASE/?v-r=init&location=$route")
key=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["appConfig"]["uidl"]["Vaadin-Security-Key"])' <<<"$init")
ui=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["appConfig"]["v-uiId"])' <<<"$init")
curl -sf --max-time 20 -o /dev/null -c "$jar" -b "$jar" -H 'Content-Type: application/json' \
-X POST "$BASE/?v-r=uidl&v-uiId=$ui" \
-d "{\"csrfToken\":\"$key\",\"rpc\":[{\"type\":\"event\",\"node\":1,\"event\":\"ui-navigate\",\"data\":{\"route\":\"$route\",\"query\":\"\",\"appShellTitle\":\"\",\"historyState\":null,\"trigger\":\"\"}}],\"syncId\":0,\"clientId\":0}"
echo " $1 -> /$route"
}
echo "driving sessions"
open_view alice static-leak
open_view alice singleton-leak
open_view alice broadcaster-leak
open_view alice shared-component
open_view bob shared-component # steals alice's banner
open_view bob singleton-leak
open_view bob broadcaster-leak
open_view bob snoop # keeps a reference to alice's view
open_view bob "" # clean view
curl -sf --max-time 20 "$BASE/api/broadcast" >/dev/null
curl -sf --max-time 20 -c "$WORK/alice" -b "$WORK/alice" "$BASE/api/logout" >/dev/null
echo " alice logged out"
sleep 8
python3 - "$REPORT" <<'EOF'
import json, sys
report = json.load(open(sys.argv[1]))
findings = report["findings"]
print(report["lastScanSummary"])
for f in findings:
print(f" {f['severity']:5} {f['kind']:34} sessions={f['sessions']} {f['title']}")
def text(f):
return f["title"] + "\n" + "\n".join(f["details"])
def expect(kind, *needles):
if not any(f["kind"] == kind and all(n in text(f) for n in needles) for f in findings):
failures.append(f"missing {kind} mentioning {needles}")
failures = []
expect("SHARED_ROOT_HOLDS_SESSION_STATE", "static com.example.leakdemo.StaticLeakView.RECENT_VIEWS")
expect("SHARED_ROOT_HOLDS_SESSION_STATE", "static com.example.leakdemo.Broadcaster.LISTENERS")
expect("SHARED_ROOT_HOLDS_SESSION_STATE", "spring singleton 'gridCache'", "Grid")
expect("SHARED_ROOT_HOLDS_SESSION_STATE", "static com.example.leakdemo.SharedComponentView.BANNER")
expect("CROSS_SESSION_ATTACH", "com.vaadin.flow.component.html.Span", "SharedComponentView")
expect("CROSS_SESSION_MUTATION", "SharedComponentView")
expect("SESSION_REFERENCES_FOREIGN_STATE", "SnoopView", ".peeked", "StaticLeakView")
expect("DESTROYED_SESSION_RETAINED", "StaticLeakView.RECENT_VIEWS")
if any("CleanView" in text(f) for f in findings):
failures.append("false positive: CleanView mentioned")
if failures:
print("\nE2E FAILED:\n " + "\n ".join(failures))
sys.exit(1)
print("\nE2E PASSED")
EOF