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