Initial commit
This commit is contained in:
+13
@@ -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
@@ -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
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 "$@"
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user