Initial commit

This commit is contained in:
Pit Friedrich
2026-10-11 17:45:06 +02:00
commit d9a9647742
43 changed files with 3723 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.leakfinder</groupId>
<artifactId>vaadin-leakfinder-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>leakfinder-agent</artifactId>
<name>Vaadin Leak Finder Agent</name>
<dependencies>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
</dependency>
<!-- Only needed to compile the advice classes; they are applied from bytecode and never loaded by the agent. -->
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>flow-server</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>flow-html-components</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy-agent</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.1.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- tests self-attach the agent -->
<argLine>-XX:+EnableDynamicAgentLoading -Djdk.attach.allowAttachSelf=true</argLine>
<redirectTestOutputToFile>true</redirectTestOutputToFile>
<!-- like production: Flow's lock assertions must not mask what the agent reports -->
<enableAssertions>false</enableAssertions>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifestEntries>
<Premain-Class>dev.leakfinder.agent.LeakFinderAgent</Premain-Class>
<Agent-Class>dev.leakfinder.agent.LeakFinderAgent</Agent-Class>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<relocations>
<relocation>
<pattern>net.bytebuddy</pattern>
<shadedPattern>dev.leakfinder.shaded.bytebuddy</shadedPattern>
</relocation>
</relocations>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/versions/*/module-info.class</exclude>
<exclude>module-info.class</exclude>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,100 @@
package dev.leakfinder.advice;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.internal.StateNode;
import com.vaadin.flow.internal.StateTree;
import com.vaadin.flow.server.VaadinSession;
import dev.leakfinder.bridge.LeakBridge;
import dev.leakfinder.bridge.LeakDetectedException;
import net.bytebuddy.asm.Advice;
/**
* Byte Buddy advice inlined into Vaadin classes. The code is copied into the target methods, so it
* may reference Vaadin types: they resolve in the class loader of the instrumented class. The agent
* never loads these classes itself, it only reads their bytecode via a type pool. Parameters are
* declared as {@code Object} and cast inside the advice, so that Byte Buddy never has to resolve
* Vaadin types from the agent's class loader.
* <p>
* Advice code must stay tiny and must never throw, except for fail-fast {@link LeakDetectedException}s.
*/
public final class VaadinAdvice {
private VaadinAdvice() {
}
/** {@code VaadinSession(VaadinService)} constructor. */
public static final class SessionConstructor {
@Advice.OnMethodExit(suppress = Throwable.class)
static void exit(@Advice.This Object session) {
LeakBridge.sessionCreated(session);
}
}
/** {@code VaadinService.fireSessionDestroy(VaadinSession)}. */
public static final class SessionDestroy {
@Advice.OnMethodEnter(suppress = Throwable.class)
static void enter(@Advice.Argument(0) Object session) {
LeakBridge.sessionDestroyed(session);
}
}
/** {@code VaadinService.init()}. */
public static final class ServiceInit {
@Advice.OnMethodExit(suppress = Throwable.class)
static void exit(@Advice.This Object service) {
LeakBridge.serviceInitialized(service);
}
}
/** {@code Component()} and {@code Component(Element)} constructors: remember the creating session. */
public static final class ComponentConstructor {
@Advice.OnMethodExit(suppress = Throwable.class)
static void exit(@Advice.This Object component) {
LeakBridge.componentCreated(component, VaadinSession.getCurrent());
}
}
/** {@code ComponentUtil.onComponentAttach(Component, boolean)}: detect components moving between sessions. */
public static final class ComponentAttach {
@Advice.OnMethodEnter
static void enter(@Advice.Argument(0) Object argument) {
try {
Component component = (Component) argument;
if (component.getElement().getNode().getOwner() instanceof StateTree tree) {
UI ui = tree.getUI();
LeakBridge.componentAttached(component, ui, ui.getSession(), VaadinSession.getCurrent());
}
} catch (RuntimeException e) {
if (e instanceof LeakDetectedException) {
throw e;
}
}
}
}
/**
* {@code StateNode.markAsDirty()}: every server side UI change ends here. If the thread is
* serving session B but modifies a node of a UI in session A without holding A's lock, state
* of one user is being changed by another user's request.
*/
public static final class NodeDirty {
@Advice.OnMethodEnter
static void enter(@Advice.This Object self) {
try {
VaadinSession current = VaadinSession.getCurrent();
if (current != null && ((StateNode) self).getOwner() instanceof StateTree tree) {
UI ui = tree.getUI();
VaadinSession owner = ui.getSession();
if (owner != null && owner != current && !owner.hasLock()) {
LeakBridge.crossSessionMutation(self, ui, owner, current);
}
}
} catch (RuntimeException e) {
if (e instanceof LeakDetectedException) {
throw e;
}
}
}
}
}
@@ -0,0 +1,137 @@
package dev.leakfinder.agent;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
/**
* Agent configuration, parsed from the agent argument string:
* {@code -javaagent:leakfinder-agent.jar=key=value,key=value}. List values are separated by {@code ;}.
*
* <pre>
* scanInterval period of the heap scans (e.g. 30s, 2m; 0 disables periodic scans) default 60s
* initialDelay delay of the first scan default = scanInterval
* gcWait grace period before a destroyed session counts as retained (e.g. 30s) default 30s
* report JSON report file, rewritten whenever findings change default none
* include package prefixes whose static fields are scanned (empty = all non-excluded)
* exclude additional package prefixes whose static fields / singletons are not scanned
* stop additional package prefixes the heap walk never descends into
* maxNodes object budget per heap scan default 2000000
* maxDepth max reference path length default 64
* sessionScan walk live sessions for references to foreign session state default true
* runtimeChecks instrument attach / mutation of components default true
* failFast throw LeakDetectedException into the app on runtime findings default false
* jmx register the dev.leakfinder:type=LeakFinder MBean default true
* verbose log every scan default false
* </pre>
*/
public final class Config {
static final List<String> DEFAULT_EXCLUDES = List.of(
"java.", "javax.", "jakarta.", "jdk.", "sun.", "com.sun.",
"com.vaadin.", "elemental.", "org.atmosphere.",
"org.springframework.", "org.apache.", "org.eclipse.", "io.undertow.", "org.jboss.", "org.wildfly.",
"ch.qos.", "org.slf4j.", "com.fasterxml.", "tools.jackson.", "org.yaml.", "com.google.", "org.jsoup.",
"net.bytebuddy.", "dev.leakfinder.", "kotlin.", "io.micrometer.", "org.hibernate.", "reactor.",
"io.netty.", "org.aspectj.", "org.junit.", "org.mockito.", "org.objenesis.", "org.opentest4j.",
"org.apiguardian.", "org.glassfish.", "com.zaxxer.", "org.h2.", "org.hsqldb.", "org.postgresql.",
"com.mysql.", "org.mariadb.", "org.flywaydb.", "liquibase.", "io.github.classgraph.", "nonapi.",
"org.jspecify.", "org.jetbrains.", "org.intellij.", "com.intellij.", "org.gradle.", "org.codehaus.");
/** The heap walk does not descend into these (container / framework internals hold sessions legitimately). */
static final List<String> DEFAULT_STOPS = List.of(
"java.lang.Thread", "java.lang.ClassLoader", "java.lang.reflect.", "java.lang.invoke.",
"java.lang.ref.", "java.security.", "java.util.logging.", "jdk.", "sun.", "com.sun.",
"org.apache.catalina.", "org.apache.coyote.", "org.apache.tomcat.", "org.eclipse.jetty.",
"io.undertow.", "org.springframework.", "com.vaadin.", "org.atmosphere.", "ch.qos.", "org.slf4j.",
"net.bytebuddy.", "dev.leakfinder.", "org.junit.", "org.mockito.", "jakarta.servlet.");
public Duration scanInterval = Duration.ofSeconds(60);
public Duration initialDelay;
public Duration gcWait = Duration.ofSeconds(30);
public Path report;
public List<String> include = new ArrayList<>();
public List<String> exclude = new ArrayList<>(DEFAULT_EXCLUDES);
public List<String> stop = new ArrayList<>(DEFAULT_STOPS);
public int maxNodes = 2_000_000;
public int maxDepth = 64;
public boolean sessionScan = true;
public boolean runtimeChecks = true;
public boolean failFast;
public boolean jmx = true;
public boolean verbose;
public static Config parse(String args) {
Config c = new Config();
if (args == null || args.isBlank()) {
return c;
}
for (String part : args.split(",")) {
String p = part.trim();
if (p.isEmpty()) {
continue;
}
int eq = p.indexOf('=');
String key = (eq < 0 ? p : p.substring(0, eq)).trim();
String value = eq < 0 ? "true" : p.substring(eq + 1).trim();
switch (key) {
case "scanInterval" -> c.scanInterval = duration(value);
case "initialDelay" -> c.initialDelay = duration(value);
case "gcWait" -> c.gcWait = duration(value);
case "report" -> c.report = Path.of(value);
case "include" -> c.include = list(value);
case "exclude" -> c.exclude.addAll(list(value));
case "stop" -> c.stop.addAll(list(value));
case "maxNodes" -> c.maxNodes = Integer.parseInt(value);
case "maxDepth" -> c.maxDepth = Integer.parseInt(value);
case "sessionScan" -> c.sessionScan = Boolean.parseBoolean(value);
case "runtimeChecks" -> c.runtimeChecks = Boolean.parseBoolean(value);
case "failFast" -> c.failFast = Boolean.parseBoolean(value);
case "jmx" -> c.jmx = Boolean.parseBoolean(value);
case "verbose" -> c.verbose = Boolean.parseBoolean(value);
default -> System.err.println("[vaadin-leakfinder] unknown agent option ignored: " + key);
}
}
return c;
}
public Duration effectiveInitialDelay() {
return initialDelay != null ? initialDelay : scanInterval;
}
/** Whether static fields of / singletons of this class are scan roots. */
public boolean isRootCandidate(String className) {
if (!include.isEmpty()) {
return include.stream().anyMatch(className::startsWith);
}
return exclude.stream().noneMatch(className::startsWith);
}
public boolean isStopClass(String className) {
return stop.stream().anyMatch(className::startsWith);
}
static Duration duration(String v) {
String s = v.trim().toLowerCase(Locale.ROOT);
if (s.endsWith("ms")) {
return Duration.ofMillis(Long.parseLong(s.substring(0, s.length() - 2)));
}
if (s.endsWith("s")) {
return Duration.ofSeconds(Long.parseLong(s.substring(0, s.length() - 1)));
}
if (s.endsWith("m")) {
return Duration.ofMinutes(Long.parseLong(s.substring(0, s.length() - 1)));
}
if (s.endsWith("h")) {
return Duration.ofHours(Long.parseLong(s.substring(0, s.length() - 1)));
}
return Duration.ofSeconds(Long.parseLong(s));
}
private static List<String> list(String v) {
return new ArrayList<>(Arrays.stream(v.split(";")).map(String::trim).filter(s -> !s.isEmpty()).toList());
}
}
@@ -0,0 +1,72 @@
package dev.leakfinder.agent;
import java.io.IOException;
import java.io.InputStream;
import java.lang.instrument.Instrumentation;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
/**
* Agent entry point. Kept free of references to the bridge or core classes: the bridge must be
* appended to the bootstrap class path <em>before</em> anything links against it, otherwise the
* system class loader would define a second, invisible copy.
*/
public final class LeakFinderAgent {
static final List<String> BRIDGE_CLASSES = List.of(
"dev/leakfinder/bridge/LeakBridge",
"dev/leakfinder/bridge/LeakBridge$Hooks",
"dev/leakfinder/bridge/LeakDetectedException");
private static volatile boolean installed;
private LeakFinderAgent() {
}
public static void premain(String args, Instrumentation inst) {
start(args, inst);
}
public static void agentmain(String args, Instrumentation inst) {
start(args, inst);
}
/** Programmatic installation, e.g. from tests via {@code ByteBuddyAgent.install()}. */
public static synchronized void start(String args, Instrumentation inst) {
if (installed) {
return;
}
try {
injectBridge(inst);
Class.forName("dev.leakfinder.core.Installer")
.getMethod("install", Instrumentation.class, String.class)
.invoke(null, inst, args);
installed = true;
} catch (Throwable t) {
System.err.println("[vaadin-leakfinder] failed to start agent: " + t);
t.printStackTrace();
}
}
private static void injectBridge(Instrumentation inst) throws IOException {
Path jar = Files.createTempFile("vaadin-leakfinder-bridge", ".jar");
jar.toFile().deleteOnExit();
try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(jar))) {
for (String name : BRIDGE_CLASSES) {
String resource = name + ".class";
try (InputStream in = LeakFinderAgent.class.getClassLoader().getResourceAsStream(resource)) {
if (in == null) {
throw new IOException("missing " + resource);
}
out.putNextEntry(new JarEntry(resource));
in.transferTo(out);
out.closeEntry();
}
}
}
inst.appendToBootstrapClassLoaderSearch(new java.util.jar.JarFile(jar.toFile()));
}
}
@@ -0,0 +1,147 @@
package dev.leakfinder.bridge;
/**
* Entry point called from the advice code that is inlined into Vaadin classes.
* <p>
* This class (and its nested types) is injected into the bootstrap class loader, so it is visible
* from any class loader Vaadin happens to live in (Spring Boot fat jar, servlet container webapp,
* plain class path). It therefore must only use JDK types in its signatures. All the real work is
* delegated to a {@link Hooks} implementation registered by the agent.
*/
public final class LeakBridge {
/** Callbacks implemented by the agent core. All Vaadin objects are passed as {@code Object}. */
public interface Hooks {
void sessionCreated(Object vaadinSession);
void sessionDestroyed(Object vaadinSession);
void serviceInitialized(Object vaadinService);
void componentCreated(Object component, Object currentSession);
void componentAttached(Object component, Object ui, Object uiSession, Object currentSession);
void crossSessionMutation(Object stateNode, Object ui, Object ownerSession, Object currentSession);
}
private static volatile Hooks hooks;
/** Prevents recursion when the agent itself touches instrumented Vaadin code. */
private static final ThreadLocal<Boolean> IN_HOOK = new ThreadLocal<>();
private LeakBridge() {
}
public static void install(Hooks h) {
hooks = h;
}
public static boolean isInstalled() {
return hooks != null;
}
public static void sessionCreated(Object session) {
Hooks h = hooks;
if (h == null || enter()) {
return;
}
try {
h.sessionCreated(session);
} catch (Throwable t) {
handle(t);
} finally {
exit();
}
}
public static void sessionDestroyed(Object session) {
Hooks h = hooks;
if (h == null || enter()) {
return;
}
try {
h.sessionDestroyed(session);
} catch (Throwable t) {
handle(t);
} finally {
exit();
}
}
public static void serviceInitialized(Object service) {
Hooks h = hooks;
if (h == null || enter()) {
return;
}
try {
h.serviceInitialized(service);
} catch (Throwable t) {
handle(t);
} finally {
exit();
}
}
public static void componentCreated(Object component, Object currentSession) {
Hooks h = hooks;
if (h == null || enter()) {
return;
}
try {
h.componentCreated(component, currentSession);
} catch (Throwable t) {
handle(t);
} finally {
exit();
}
}
public static void componentAttached(Object component, Object ui, Object uiSession, Object currentSession) {
Hooks h = hooks;
if (h == null || enter()) {
return;
}
try {
h.componentAttached(component, ui, uiSession, currentSession);
} catch (Throwable t) {
handle(t);
} finally {
exit();
}
}
public static void crossSessionMutation(Object node, Object ui, Object ownerSession, Object currentSession) {
Hooks h = hooks;
if (h == null || enter()) {
return;
}
try {
h.crossSessionMutation(node, ui, ownerSession, currentSession);
} catch (Throwable t) {
handle(t);
} finally {
exit();
}
}
private static boolean enter() {
if (IN_HOOK.get() != null) {
return true;
}
IN_HOOK.set(Boolean.TRUE);
return false;
}
private static void exit() {
IN_HOOK.remove();
}
private static void handle(Throwable t) {
if (t instanceof LeakDetectedException e) {
// fail-fast mode: surface the leak in the application thread
throw e;
}
System.err.println("[vaadin-leakfinder] internal error in hook: " + t);
}
}
@@ -0,0 +1,9 @@
package dev.leakfinder.bridge;
/** Thrown into the application thread when the agent runs with {@code failFast=true}. */
public class LeakDetectedException extends IllegalStateException {
public LeakDetectedException(String message) {
super(message);
}
}
@@ -0,0 +1,95 @@
package dev.leakfinder.core;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.atomic.AtomicLong;
/** One distinct leak, identified by {@link #key}. Holds only strings, never application objects. */
public final class Finding {
public enum Kind {
/** A component that belongs to one session was attached to a UI of another session. */
CROSS_SESSION_ATTACH("ERROR"),
/** A request of one session modified the UI of another session without holding its lock. */
CROSS_SESSION_MUTATION("ERROR"),
/** A static field or singleton (shared by all users) references a UI, component or session. */
SHARED_ROOT_HOLDS_SESSION_STATE("ERROR"),
/** The UI / session-scoped state of one session references state of another session. */
SESSION_REFERENCES_FOREIGN_STATE("ERROR"),
/** A destroyed session is still strongly reachable after GC. */
DESTROYED_SESSION_RETAINED("WARN");
final String severity;
Kind(String severity) {
this.severity = severity;
}
}
final Kind kind;
final String key;
final String title;
final List<String> details;
final Instant firstSeen = Instant.now();
volatile Instant lastSeen = firstSeen;
final AtomicLong occurrences = new AtomicLong();
final Set<Integer> sessions = new TreeSet<>();
Finding(Kind kind, String key, String title, List<String> details) {
this.kind = kind;
this.key = key;
this.title = title;
this.details = List.copyOf(details);
}
public Kind kind() {
return kind;
}
public String title() {
return title;
}
public List<String> details() {
return details;
}
public long occurrences() {
return occurrences.get();
}
public synchronized Set<Integer> sessions() {
return new TreeSet<>(sessions);
}
synchronized void addSessions(Iterable<Integer> ids) {
ids.forEach(sessions::add);
}
String format() {
StringBuilder sb = new StringBuilder();
sb.append(kind.severity).append(' ').append(kind).append(": ").append(title);
for (String line : details) {
sb.append(System.lineSeparator()).append(" ").append(line);
}
return sb.toString();
}
synchronized String toJson() {
StringBuilder sb = new StringBuilder();
sb.append("{\"kind\":").append(Json.str(kind.name()))
.append(",\"severity\":").append(Json.str(kind.severity))
.append(",\"title\":").append(Json.str(title))
.append(",\"occurrences\":").append(occurrences.get())
.append(",\"sessions\":").append(sessions)
.append(",\"firstSeen\":").append(Json.str(firstSeen.toString()))
.append(",\"lastSeen\":").append(Json.str(lastSeen.toString()))
.append(",\"details\":[");
for (int i = 0; i < details.size(); i++) {
sb.append(i == 0 ? "" : ",").append(Json.str(details.get(i)));
}
return sb.append("]}").toString();
}
}
@@ -0,0 +1,159 @@
package dev.leakfinder.core;
import java.lang.reflect.Field;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Set;
/**
* Bounded breadth-first walk over the object graph using reflection. Records the reference path to
* every object the {@link Classifier} marks as a hit. Breadth-first gives the shortest path, which is
* the most readable one in a report.
* <p>
* The visited set is shared across all roots of one walker, so each object is reported once per scan
* (via the first root that reaches it). The walk is read-only and takes no locks; concurrent
* modifications may cause missed or stale edges, which is acceptable for a diagnostic tool.
*/
final class HeapWalker {
enum Action {
/** Descend into the object's fields / elements. */
CONTINUE,
/** Do not descend. */
STOP,
/** Report a path to the object and do not descend. */
HIT
}
interface Classifier {
Action classify(Object o);
}
interface HitHandler {
void onHit(Object target, List<String> path);
}
private record Node(Object value, Node parent, String edge, int depth) {
}
private final Set<Object> visited = Collections.newSetFromMap(new IdentityHashMap<>());
private final int maxNodes;
private final int maxDepth;
private boolean budgetExhausted;
HeapWalker(int maxNodes, int maxDepth) {
this.maxNodes = maxNodes;
this.maxDepth = maxDepth;
}
int visitedCount() {
return visited.size();
}
boolean budgetExhausted() {
return budgetExhausted;
}
/** Marks an object as already handled, e.g. the root of another session. */
void exclude(Object o) {
visited.add(o);
}
/**
* Walks from {@code root}. The root itself is always expanded (even if the classifier would stop
* at it), except when it was already visited or is a trivial value.
*/
void walk(String rootLabel, Object root, Classifier classifier, HitHandler handler) {
if (root == null || isTrivial(root.getClass()) || !visited.add(root)) {
return;
}
Node start = new Node(root, null, rootLabel, 0);
if (classifier.classify(root) == Action.HIT) {
handler.onHit(root, path(start));
return;
}
ArrayDeque<Node> queue = new ArrayDeque<>();
queue.add(start);
while (!queue.isEmpty()) {
if (visited.size() > maxNodes) {
budgetExhausted = true;
return;
}
Node node = queue.poll();
if (node.depth >= maxDepth) {
continue;
}
Object value = node.value;
Class<?> type = value.getClass();
if (type.isArray()) {
if (type.getComponentType().isPrimitive()) {
continue;
}
Object[] array = (Object[]) value;
for (int i = 0; i < array.length; i++) {
visit(array[i], node, "[" + i + "]", classifier, handler, queue);
}
} else {
for (Field f : Reflect.instanceFields(type)) {
Object child;
try {
child = f.get(value);
} catch (Throwable t) {
continue;
}
visit(child, node, "." + f.getName(), classifier, handler, queue);
}
}
}
}
private void visit(Object child, Node parent, String edge, Classifier classifier, HitHandler handler,
ArrayDeque<Node> queue) {
if (child == null || isTrivial(child.getClass()) || !visited.add(child)) {
return;
}
Node node = new Node(child, parent, edge, parent.depth + 1);
Action action;
try {
action = classifier.classify(child);
} catch (Throwable t) {
action = Action.STOP;
}
switch (action) {
case HIT -> handler.onHit(child, path(node));
case CONTINUE -> queue.add(node);
case STOP -> {
// nothing
}
}
}
/** Human readable path: first element is the root label, then one line per edge. */
private static List<String> path(Node end) {
List<String> lines = new ArrayList<>();
for (Node n = end; n != null; n = n.parent) {
String type = n.value.getClass().getName();
lines.add(n.parent == null ? n.edge + " (" + type + ")" : "-> " + n.edge + " (" + type + ")");
}
Collections.reverse(lines);
return lines;
}
/** Path without volatile parts (array indexes, hidden class addresses), used for deduplication. */
static String signature(List<String> path) {
StringBuilder sb = new StringBuilder();
for (String line : path) {
sb.append(Reflect.stableName(line.replaceAll("\\[\\d+]", "[*]"))).append('|');
}
return sb.toString();
}
static boolean isTrivial(Class<?> c) {
return c == String.class || c == Class.class || c == Integer.class || c == Long.class
|| c == Boolean.class || c == Character.class || c == Byte.class || c == Short.class
|| c == Double.class || c == Float.class || c == Object.class;
}
}
@@ -0,0 +1,101 @@
package dev.leakfinder.core;
import dev.leakfinder.agent.Config;
import dev.leakfinder.bridge.LeakBridge;
import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.dynamic.ClassFileLocator;
import net.bytebuddy.pool.TypePool;
import java.lang.instrument.Instrumentation;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import static net.bytebuddy.matcher.ElementMatchers.isConstructor;
import static net.bytebuddy.matcher.ElementMatchers.isSynthetic;
import static net.bytebuddy.matcher.ElementMatchers.nameStartsWith;
import static net.bytebuddy.matcher.ElementMatchers.named;
import static net.bytebuddy.matcher.ElementMatchers.not;
import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
/** Wires the agent: opens JDK modules for the heap walk and instruments Vaadin. */
public final class Installer {
private static final String ADVICE = "dev.leakfinder.advice.VaadinAdvice$";
private Installer() {
}
/** Called reflectively by {@link dev.leakfinder.agent.LeakFinderAgent} after the bridge is on the boot class path. */
public static void install(Instrumentation inst, String args) {
Config config = Config.parse(args);
openModules(inst);
LeakFinder finder = new LeakFinder(config, inst);
LeakBridge.install(finder.hooks());
// Advice classes reference Vaadin types: describe them from bytecode instead of loading them.
ClassFileLocator locator = ClassFileLocator.ForClassLoader.of(Installer.class.getClassLoader());
TypePool pool = TypePool.Default.of(locator);
Advice sessionConstructor = advice(pool, locator, "SessionConstructor");
Advice sessionDestroy = advice(pool, locator, "SessionDestroy");
Advice serviceInit = advice(pool, locator, "ServiceInit");
Advice componentConstructor = advice(pool, locator, "ComponentConstructor");
Advice componentAttach = advice(pool, locator, "ComponentAttach");
Advice nodeDirty = advice(pool, locator, "NodeDirty");
AgentBuilder.Identified.Extendable builder = new AgentBuilder.Default()
.disableClassFormatChanges()
.with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
.with(AgentBuilder.Listener.StreamWriting.toSystemError().withErrorsOnly())
.ignore(nameStartsWith("net.bytebuddy.").or(nameStartsWith("dev.leakfinder.")))
.type(named("com.vaadin.flow.server.VaadinSession"))
.transform((b, type, cl, module, pd) -> b.visit(sessionConstructor.on(isConstructor())))
.type(named("com.vaadin.flow.server.VaadinService"))
.transform((b, type, cl, module, pd) -> b
.visit(sessionDestroy.on(named("fireSessionDestroy").and(takesArguments(1))))
.visit(serviceInit.on(named("init").and(takesArguments(0)))))
.type(named("com.vaadin.flow.component.Component"))
.transform((b, type, cl, module, pd) -> b.visit(componentConstructor.on(isConstructor())));
if (config.runtimeChecks) {
builder = builder
.type(named("com.vaadin.flow.component.ComponentUtil"))
.transform((b, type, cl, module, pd) -> b.visit(componentAttach
.on(named("onComponentAttach").and(takesArguments(2)).and(not(isSynthetic())))))
.type(named("com.vaadin.flow.internal.StateNode"))
.transform((b, type, cl, module, pd) -> b.visit(nodeDirty
.on(named("markAsDirty").and(takesArguments(0)))));
}
builder.installOn(inst);
System.err.println("[vaadin-leakfinder] agent installed (runtimeChecks=" + config.runtimeChecks
+ ", failFast=" + config.failFast + ", sessionScan=" + config.sessionScan + ")");
}
private static Advice advice(TypePool pool, ClassFileLocator locator, String name) {
return Advice.to(pool.describe(ADVICE + name).resolve(), locator);
}
/**
* The heap walk reads private fields of JDK classes (collections, ...). Open every package of
* the boot layer to the agent's module so {@code setAccessible} succeeds.
*/
private static void openModules(Instrumentation inst) {
Module agentModule = Installer.class.getModule();
for (Module module : ModuleLayer.boot().modules()) {
if (!inst.isModifiableModule(module)) {
continue;
}
Map<String, Set<Module>> opens = new HashMap<>();
for (String pkg : module.getPackages()) {
opens.put(pkg, Set.of(agentModule));
}
try {
inst.redefineModule(module, Set.of(), opens, opens, Set.of(), Map.of());
} catch (Throwable t) {
// not fatal: affected fields are skipped by the walker
}
}
}
}
@@ -0,0 +1,29 @@
package dev.leakfinder.core;
final class Json {
private Json() {
}
static String str(String s) {
StringBuilder sb = new StringBuilder(s.length() + 2).append('"');
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '"' -> sb.append("\\\"");
case '\\' -> sb.append("\\\\");
case '\n' -> sb.append("\\n");
case '\r' -> sb.append("\\r");
case '\t' -> sb.append("\\t");
default -> {
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
}
return sb.append('"').toString();
}
}
@@ -0,0 +1,122 @@
package dev.leakfinder.core;
import dev.leakfinder.agent.Config;
import java.lang.instrument.Instrumentation;
import java.lang.management.ManagementFactory;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.management.ObjectName;
/** Facade of a running agent. Obtain it with {@link #get()}, e.g. from tests. */
public final class LeakFinder {
private static volatile LeakFinder instance;
private final Config config;
private final SessionTracker tracker = new SessionTracker();
private final LeakReport report;
private final LeakScanner scanner;
private final RuntimeChecks hooks;
private final AtomicBoolean started = new AtomicBoolean();
private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "vaadin-leakfinder");
t.setDaemon(true);
return t;
});
LeakFinder(Config config, Instrumentation inst) {
this.config = config;
this.report = new LeakReport(config.report);
this.scanner = new LeakScanner(config, inst, tracker, report);
this.hooks = new RuntimeChecks(config, tracker, report, this::start, this::writeReportAsync);
instance = this;
}
/** The running agent, or null if it was not installed. */
public static LeakFinder get() {
return instance;
}
RuntimeChecks hooks() {
return hooks;
}
SessionTracker tracker() {
return tracker;
}
public LeakReport report() {
return report;
}
public Config config() {
return config;
}
/** Runs a full heap scan on the agent thread and waits for it. Returns the scan summary. */
public String scanNow() {
try {
return executor.submit(scanner::scan).get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "interrupted";
} catch (ExecutionException e) {
Log.get().log(System.Logger.Level.WARNING, "[vaadin-leakfinder] scan failed", e.getCause());
return "scan failed: " + e.getCause();
}
}
public int trackedSessions() {
return tracker.sessions().size();
}
public int trackedComponents() {
return tracker.trackedComponents();
}
/** Starts periodic scans and JMX; called when the first VaadinService is initialized. */
void start() {
if (!started.compareAndSet(false, true)) {
return;
}
long interval = config.scanInterval.toMillis();
if (interval > 0) {
executor.scheduleWithFixedDelay(this::safeScan, config.effectiveInitialDelay().toMillis(), interval,
TimeUnit.MILLISECONDS);
}
if (config.jmx) {
executor.execute(this::registerMBean);
}
Log.get().log(System.Logger.Level.INFO, "[vaadin-leakfinder] Vaadin service detected, "
+ (interval > 0 ? "scanning every " + config.scanInterval : "periodic scans disabled")
+ (config.report != null ? ", report: " + config.report.toAbsolutePath() : ""));
}
private void safeScan() {
try {
scanner.scan();
} catch (Throwable t) {
Log.get().log(System.Logger.Level.WARNING, "[vaadin-leakfinder] scan failed", t);
}
}
private void writeReportAsync() {
executor.execute(report::write);
}
private void registerMBean() {
try {
ObjectName name = new ObjectName("dev.leakfinder:type=LeakFinder");
var server = ManagementFactory.getPlatformMBeanServer();
if (!server.isRegistered(name)) {
server.registerMBean(new LeakFinderControl(this), name);
}
} catch (Throwable t) {
Log.get().log(System.Logger.Level.WARNING, "[vaadin-leakfinder] cannot register MBean: " + t);
}
}
}
@@ -0,0 +1,41 @@
package dev.leakfinder.core;
final class LeakFinderControl implements LeakFinderControlMBean {
private final LeakFinder finder;
LeakFinderControl(LeakFinder finder) {
this.finder = finder;
}
@Override
public String scanNow() {
return finder.scanNow();
}
@Override
public String getReportJson() {
return finder.report().toJson();
}
@Override
public int getFindingCount() {
return finder.report().findings().size();
}
@Override
public int getTrackedSessions() {
return finder.trackedSessions();
}
@Override
public int getTrackedComponents() {
return finder.trackedComponents();
}
@Override
public void clearFindings() {
finder.report().clear();
finder.report().write();
}
}
@@ -0,0 +1,18 @@
package dev.leakfinder.core;
/** JMX interface, registered as {@code dev.leakfinder:type=LeakFinder}. */
public interface LeakFinderControlMBean {
/** Runs a heap scan now and returns its summary. */
String scanNow();
String getReportJson();
int getFindingCount();
int getTrackedSessions();
int getTrackedComponents();
void clearFindings();
}
@@ -0,0 +1,92 @@
package dev.leakfinder.core;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/** Collects deduplicated findings, logs new ones and writes the JSON report. */
public final class LeakReport {
private final Map<String, Finding> findings = new ConcurrentHashMap<>();
private final Path file;
private volatile Instant lastScan;
private volatile String lastScanSummary = "no scan yet";
LeakReport(Path file) {
this.file = file;
}
/** Records an occurrence. Returns true if this is a new distinct finding. */
boolean add(Finding.Kind kind, String key, String title, List<String> details, Iterable<Integer> sessions) {
String fullKey = kind + "|" + key;
boolean[] created = new boolean[1];
Finding f = findings.computeIfAbsent(fullKey, k -> {
created[0] = true;
return new Finding(kind, fullKey, title, details);
});
f.occurrences.incrementAndGet();
f.lastSeen = Instant.now();
f.addSessions(sessions);
if (created[0]) {
Log.get().log(kind.severity.equals("ERROR") ? System.Logger.Level.ERROR : System.Logger.Level.WARNING,
"[vaadin-leakfinder] " + f.format());
}
return created[0];
}
public List<Finding> findings() {
List<Finding> list = new ArrayList<>(findings.values());
list.sort(Comparator.comparing((Finding f) -> f.kind).thenComparing(f -> f.firstSeen));
return list;
}
public boolean has(Finding.Kind kind) {
return findings.values().stream().anyMatch(f -> f.kind == kind);
}
public void clear() {
findings.clear();
}
void scanFinished(String summary) {
lastScan = Instant.now();
lastScanSummary = summary;
}
public String toJson() {
StringBuilder sb = new StringBuilder("{\n \"generated\": ").append(Json.str(Instant.now().toString()))
.append(",\n \"lastScan\": ").append(lastScan == null ? "null" : Json.str(lastScan.toString()))
.append(",\n \"lastScanSummary\": ").append(Json.str(lastScanSummary))
.append(",\n \"findings\": [");
List<Finding> list = findings();
for (int i = 0; i < list.size(); i++) {
sb.append(i == 0 ? "\n " : ",\n ").append(list.get(i).toJson());
}
return sb.append(list.isEmpty() ? "]\n}\n" : "\n ]\n}\n").toString();
}
synchronized void write() {
if (file == null) {
return;
}
try {
Path abs = file.toAbsolutePath();
if (abs.getParent() != null) {
Files.createDirectories(abs.getParent());
}
Path tmp = abs.resolveSibling(abs.getFileName() + ".tmp");
Files.writeString(tmp, toJson(), StandardCharsets.UTF_8);
Files.move(tmp, abs, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
Log.get().log(System.Logger.Level.WARNING, "[vaadin-leakfinder] cannot write report " + file + ": " + e);
}
}
}
@@ -0,0 +1,360 @@
package dev.leakfinder.core;
import dev.leakfinder.agent.Config;
import dev.leakfinder.core.HeapWalker.Action;
import dev.leakfinder.core.SessionTracker.SessionInfo;
import java.lang.instrument.Instrumentation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Enumeration;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
/**
* Heap based detection:
* <ul>
* <li>shared roots (static fields, Spring singletons) that reach UIs, components or sessions</li>
* <li>session state (UI trees, session attributes) that reaches state of another session</li>
* <li>destroyed sessions that survive garbage collection</li>
* </ul>
*/
final class LeakScanner {
private static final int MAX_COMPONENTS_PER_SESSION = 100_000;
private final Config config;
private final Instrumentation inst;
private final SessionTracker tracker;
private final LeakReport report;
private final Method shouldBeInitialized;
private final Object unsafe;
LeakScanner(Config config, Instrumentation inst, SessionTracker tracker, LeakReport report) {
this.config = config;
this.inst = inst;
this.tracker = tracker;
this.report = report;
Object u = null;
Method m = null;
try {
Class<?> unsafeClass = Class.forName("jdk.internal.misc.Unsafe");
u = unsafeClass.getMethod("getUnsafe").invoke(null);
m = unsafeClass.getMethod("shouldBeInitialized", Class.class);
m.setAccessible(true);
} catch (Throwable t) {
Log.get().log(System.Logger.Level.DEBUG, "jdk.internal.misc.Unsafe unavailable, scanning statics of all classes");
m = null;
}
this.unsafe = u;
this.shouldBeInitialized = m;
}
/** Runs all scans. Returns a one line summary. */
synchronized String scan() {
long start = System.nanoTime();
List<SessionInfo> sessions = tracker.sessions();
if (sessions.stream().anyMatch(s -> s.isDestroyed() && !s.retentionReported)) {
System.gc();
sessions = tracker.sessions();
}
HeapWalker shared = new HeapWalker(config.maxNodes, config.maxDepth);
int roots = scanSharedRoots(shared);
int scannedSessions = 0;
if (config.sessionScan) {
for (SessionInfo info : sessions) {
if (!info.isDestroyed() && scanSession(info)) {
scannedSessions++;
}
}
}
int retained = checkRetainedDestroyedSessions(sessions);
String summary = String.format(
"scan took %d ms: %d shared roots, %d objects visited%s, %d live sessions scanned, "
+ "%d destroyed sessions still reachable, %d findings",
(System.nanoTime() - start) / 1_000_000, roots, shared.visitedCount(),
shared.budgetExhausted() ? " (budget exhausted, raise maxNodes)" : "", scannedSessions, retained,
report.findings().size());
report.scanFinished(summary);
report.write();
Log.get().log(config.verbose ? System.Logger.Level.INFO : System.Logger.Level.DEBUG, "[vaadin-leakfinder] " + summary);
return summary;
}
// ---- shared roots ---------------------------------------------------------------------------
private int scanSharedRoots(HeapWalker walker) {
HeapWalker.Classifier classifier = o -> {
if (Reflect.isA(o, Reflect.VAADIN_SESSION)) {
return Action.HIT;
}
if (Reflect.isA(o, Reflect.COMPONENT)) {
return tracker.ownerOf(o) != null ? Action.HIT : Action.CONTINUE;
}
return config.isStopClass(o.getClass().getName()) ? Action.STOP : Action.CONTINUE;
};
HeapWalker.HitHandler handler = (target, path) -> {
SessionInfo owner = tracker.ownerOf(target);
if (owner == null) {
return;
}
String what = describe(target);
if (owner.isDestroyed()) {
owner.retentionReported = true;
report.add(Finding.Kind.DESTROYED_SESSION_RETAINED, HeapWalker.signature(path),
root(path) + " keeps " + what + " of a destroyed session alive", path, List.of(owner.id));
} else {
report.add(Finding.Kind.SHARED_ROOT_HOLDS_SESSION_STATE, HeapWalker.signature(path),
root(path) + " is shared by all users but references " + what + " of a single session",
path, List.of(owner.id));
}
};
int roots = 0;
for (Class<?> c : inst.getAllLoadedClasses()) {
if (!isStaticRootClass(c)) {
continue;
}
Field[] fields;
try {
fields = c.getDeclaredFields();
} catch (Throwable t) {
continue;
}
for (Field f : fields) {
if (!Modifier.isStatic(f.getModifiers()) || f.getType().isPrimitive() || !Reflect.trySetAccessible(f)) {
continue;
}
Object value;
try {
value = f.get(null);
} catch (Throwable t) {
continue;
}
if (value != null) {
roots++;
walker.walk("static " + c.getName() + "." + f.getName(), value, classifier, handler);
}
}
}
for (Object service : tracker.services()) {
for (Map.Entry<String, Object> bean : springSingletons(service)) {
Object value = bean.getValue();
if (config.isRootCandidate(value.getClass().getName())) {
roots++;
walker.walk("spring singleton '" + bean.getKey() + "'", value, classifier, handler);
}
}
}
return roots;
}
private boolean isStaticRootClass(Class<?> c) {
if (c.isArray() || c.isPrimitive() || c.isHidden()) {
return false;
}
ClassLoader cl = c.getClassLoader();
if (cl == null || cl == ClassLoader.getPlatformClassLoader() || !config.isRootCandidate(c.getName())) {
return false;
}
if (shouldBeInitialized != null) {
try {
// reading a static field would run the static initializer of a not yet initialized class
if ((Boolean) shouldBeInitialized.invoke(unsafe, c)) {
return false;
}
} catch (Throwable ignored) {
// fall through and scan
}
}
return true;
}
/** Singletons of every Spring application context registered as servlet context attribute. */
private List<Map.Entry<String, Object>> springSingletons(Object service) {
List<Map.Entry<String, Object>> result = new ArrayList<>();
Object vaadinContext = Reflect.call(service, "getContext");
Object servletContext = Reflect.hasMethod(vaadinContext, "getContext", 0)
? Reflect.call(vaadinContext, "getContext") : null;
if (!(Reflect.call(servletContext, "getAttributeNames") instanceof Enumeration<?> names)) {
return result;
}
List<Object> seenFactories = new ArrayList<>();
while (names.hasMoreElements()) {
Object attr = Reflect.call(servletContext, "getAttribute", names.nextElement());
if (!Reflect.hasMethod(attr, "getBeanFactory", 0)) {
continue;
}
Object factory = Reflect.call(attr, "getBeanFactory");
if (factory == null || seenFactories.stream().anyMatch(f -> f == factory)) {
continue;
}
seenFactories.add(factory);
if (Reflect.call(factory, "getSingletonNames") instanceof String[] beanNames) {
for (String name : beanNames) {
Object bean = Reflect.call(factory, "getSingleton", name);
if (bean != null) {
result.add(Map.entry(name, bean));
}
}
}
}
return result;
}
// ---- per session ----------------------------------------------------------------------------
/** Walks the UI trees and attributes of one session under its lock. */
private boolean scanSession(SessionInfo info) {
Object session = info.get();
if (session == null) {
return false;
}
Runnable scan = () -> scanSessionLocked(info, session);
Object command = command(session, scan);
if (command == null || Reflect.call(session, "getSession") == null) {
return false;
}
try {
Method access = session.getClass().getMethod("accessSynchronously", command.getClass().getInterfaces()[0]);
access.invoke(session, command);
return true;
} catch (Throwable t) {
Log.get().log(System.Logger.Level.DEBUG, "[vaadin-leakfinder] cannot scan " + info + ": " + t);
return false;
}
}
private void scanSessionLocked(SessionInfo info, Object session) {
HeapWalker walker = new HeapWalker(config.maxNodes, config.maxDepth);
walker.exclude(session);
HeapWalker.Classifier classifier = o -> {
if (Reflect.isA(o, Reflect.VAADIN_SESSION)) {
return o == session ? Action.STOP : Action.HIT;
}
if (Reflect.isA(o, Reflect.COMPONENT)) {
SessionInfo owner = tracker.ownerOf(o);
if (owner != null && owner != info) {
return Action.HIT;
}
return Action.CONTINUE;
}
return config.isStopClass(o.getClass().getName()) ? Action.STOP : Action.CONTINUE;
};
HeapWalker.HitHandler handler = (target, path) -> {
SessionInfo owner = tracker.ownerOf(target);
if (owner == null || owner == info) {
return;
}
report.add(Finding.Kind.SESSION_REFERENCES_FOREIGN_STATE, HeapWalker.signature(path),
root(path) + " of one session references " + describe(target) + " of another session",
path, List.of(info.id, owner.id));
};
for (Object component : components(session)) {
walker.walk("component " + component.getClass().getName(), component, classifier, handler);
}
if (Reflect.field(Reflect.field(session, "attributes"), "attributes") instanceof Map<?, ?> attributes) {
for (Map.Entry<?, ?> e : List.copyOf(attributes.entrySet())) {
walker.walk("vaadin session attribute '" + e.getKey() + "'", e.getValue(), classifier, handler);
}
}
Object wrapped = Reflect.call(session, "getSession");
if (Reflect.call(wrapped, "getAttributeNames") instanceof Collection<?> names) {
for (Object name : List.copyOf(names)) {
Object value = Reflect.call(wrapped, "getAttribute", name);
if (value != null && !Reflect.isA(value, Reflect.VAADIN_SESSION)) {
walker.walk("http session attribute '" + name + "'", value, classifier, handler);
}
}
}
}
/** All components of all UIs of the session. Must run while holding the session lock. */
private List<Object> components(Object session) {
List<Object> result = new ArrayList<>();
if (!(Reflect.field(session, "uIs") instanceof Map<?, ?> uis)) {
return result;
}
List<Object> todo = new ArrayList<>(uis.values());
while (!todo.isEmpty() && result.size() < MAX_COMPONENTS_PER_SESSION) {
Object c = todo.remove(todo.size() - 1);
result.add(c);
if (Reflect.call(c, "getChildren") instanceof Stream<?> children) {
children.forEach(todo::add);
}
}
return result;
}
/** A {@code com.vaadin.flow.server.Command} proxy running the given task. */
private static Object command(Object session, Runnable task) {
try {
ClassLoader cl = session.getClass().getClassLoader();
Class<?> commandType = Class.forName("com.vaadin.flow.server.Command", false, cl);
return Proxy.newProxyInstance(cl, new Class<?>[]{commandType}, (proxy, method, args) -> switch (method.getName()) {
case "execute" -> {
task.run();
yield null;
}
case "hashCode" -> System.identityHashCode(proxy);
case "equals" -> proxy == args[0];
case "toString" -> "vaadin-leakfinder session scan";
default -> null;
});
} catch (Throwable t) {
return null;
}
}
// ---- destroyed sessions ---------------------------------------------------------------------
private int checkRetainedDestroyedSessions(List<SessionInfo> sessions) {
int retained = 0;
long now = System.currentTimeMillis();
for (SessionInfo info : sessions) {
if (!info.isDestroyed() || info.get() == null) {
continue;
}
retained++;
if (!info.retentionReported && now - info.destroyedAt > config.gcWait.toMillis()) {
info.retentionReported = true;
report.add(Finding.Kind.DESTROYED_SESSION_RETAINED, "unexplained",
"Destroyed Vaadin sessions are still reachable after GC, but no path from static fields or "
+ "Spring singletons was found",
List.of("The reference may come from a thread, a thread local, a framework cache or an "
+ "excluded package (see the 'include'/'exclude'/'stop' options).",
"Take a heap dump (jcmd <pid> GC.heap_dump) and inspect the GC roots of "
+ "com.vaadin.flow.server.VaadinSession instances."),
List.of(info.id));
}
}
return retained;
}
// ---- formatting -----------------------------------------------------------------------------
private static String root(List<String> path) {
String first = path.get(0);
int typeStart = first.indexOf(" (");
return typeStart > 0 ? first.substring(0, typeStart) : first;
}
private static String describe(Object target) {
if (Reflect.isA(target, Reflect.VAADIN_SESSION)) {
return "the VaadinSession";
}
if (Reflect.isA(target, Reflect.UI)) {
return "the UI (" + target.getClass().getName() + ")";
}
return "component " + target.getClass().getName();
}
}
@@ -0,0 +1,22 @@
package dev.leakfinder.core;
/**
* Lazily created logger. Creating a {@link System.Logger} in {@code premain} would initialize
* java.util.logging before the application had a chance to configure it.
*/
final class Log {
private static volatile System.Logger logger;
private Log() {
}
static System.Logger get() {
System.Logger l = logger;
if (l == null) {
l = System.getLogger("dev.leakfinder");
logger = l;
}
return l;
}
}
@@ -0,0 +1,197 @@
package dev.leakfinder.core;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* Reflection helpers. The agent core lives in the system class loader and cannot link against Vaadin,
* which may sit in a child class loader, so all Vaadin access goes through here by name.
*/
final class Reflect {
static final String VAADIN_SESSION = "com.vaadin.flow.server.VaadinSession";
static final String UI = "com.vaadin.flow.component.UI";
static final String COMPONENT = "com.vaadin.flow.component.Component";
private static final ClassValue<Set<String>> SUPERTYPES = new ClassValue<>() {
@Override
protected Set<String> computeValue(Class<?> type) {
Set<String> names = new HashSet<>();
collect(type, names);
return names;
}
private void collect(Class<?> c, Set<String> names) {
if (c == null || !names.add(c.getName())) {
return;
}
collect(c.getSuperclass(), names);
for (Class<?> i : c.getInterfaces()) {
collect(i, names);
}
}
};
private static final ClassValue<Field[]> INSTANCE_FIELDS = new ClassValue<>() {
@Override
protected Field[] computeValue(Class<?> type) {
List<Field> fields = new ArrayList<>();
for (Class<?> c = type; c != null && c != Object.class; c = c.getSuperclass()) {
Field[] declared;
try {
declared = c.getDeclaredFields();
} catch (Throwable t) {
continue;
}
for (Field f : declared) {
if (Modifier.isStatic(f.getModifiers()) || f.getType().isPrimitive()) {
continue;
}
if (trySetAccessible(f)) {
fields.add(f);
}
}
}
return fields.toArray(new Field[0]);
}
};
private static final ClassValue<Map<String, Method>> METHODS = new ClassValue<>() {
@Override
protected Map<String, Method> computeValue(Class<?> type) {
return new ConcurrentHashMap<>();
}
};
private static final Method NO_METHOD;
static {
try {
NO_METHOD = Object.class.getMethod("hashCode");
} catch (NoSuchMethodException e) {
throw new ExceptionInInitializerError(e);
}
}
private Reflect() {
}
static boolean isA(Object o, String className) {
return o != null && SUPERTYPES.get(o.getClass()).contains(className);
}
static boolean isA(Class<?> c, String className) {
return SUPERTYPES.get(c).contains(className);
}
static Field[] instanceFields(Class<?> c) {
return INSTANCE_FIELDS.get(c);
}
static boolean trySetAccessible(java.lang.reflect.AccessibleObject f) {
try {
return f.trySetAccessible();
} catch (Throwable t) {
return false;
}
}
/** Invokes a public method by name and argument count; returns null on any failure. */
static Object call(Object target, String name, Object... args) {
if (target == null) {
return null;
}
Method m = method(target.getClass(), name, args.length);
if (m == null) {
return null;
}
try {
Object result = m.invoke(target, args);
return result instanceof Optional<?> opt ? opt.orElse(null) : result;
} catch (Throwable t) {
return null;
}
}
static boolean hasMethod(Object target, String name, int argCount) {
return target != null && method(target.getClass(), name, argCount) != null;
}
private static Method method(Class<?> type, String name, int argCount) {
Method m = METHODS.get(type).computeIfAbsent(name + '/' + argCount, k -> find(type, name, argCount));
return m == NO_METHOD ? null : m;
}
private static Method find(Class<?> type, String name, int argCount) {
for (Class<?> c = type; c != null; c = c.getSuperclass()) {
for (Method m : c.getMethods()) {
if (m.getName().equals(name) && m.getParameterCount() == argCount
&& !Modifier.isStatic(m.getModifiers())) {
// prefer the declaration on a public type, the runtime class may be hidden / package private
Method best = publicDeclaration(m);
trySetAccessible(best);
return best;
}
}
}
return NO_METHOD;
}
private static Method publicDeclaration(Method m) {
Set<Class<?>> seen = new HashSet<>();
List<Class<?>> todo = new ArrayList<>(List.of(m.getDeclaringClass()));
while (!todo.isEmpty()) {
Class<?> c = todo.remove(0);
if (c == null || !seen.add(c)) {
continue;
}
if (Modifier.isPublic(c.getModifiers())) {
try {
return c.getMethod(m.getName(), m.getParameterTypes());
} catch (NoSuchMethodException ignored) {
// keep looking
}
}
todo.add(c.getSuperclass());
todo.addAll(List.of(c.getInterfaces()));
}
return m;
}
/** Reads a declared field by name anywhere in the hierarchy. */
static Object field(Object target, String name) {
if (target == null) {
return null;
}
for (Class<?> c = target.getClass(); c != null; c = c.getSuperclass()) {
try {
Field f = c.getDeclaredField(name);
if (trySetAccessible(f)) {
return f.get(target);
}
return null;
} catch (NoSuchFieldException ignored) {
// continue with super class
} catch (Throwable t) {
return null;
}
}
return null;
}
/** Hidden class names contain an address suffix that differs between runs. */
static String stableName(Class<?> c) {
return stableName(c.getName());
}
static String stableName(String name) {
return name.replaceAll("/0x[0-9a-fA-F]+", "").replaceAll("\\$\\$SpringCGLIB\\$\\$\\d+", "\\$\\$SpringCGLIB");
}
}
@@ -0,0 +1,160 @@
package dev.leakfinder.core;
import dev.leakfinder.agent.Config;
import dev.leakfinder.bridge.LeakBridge;
import dev.leakfinder.bridge.LeakDetectedException;
import dev.leakfinder.core.SessionTracker.SessionInfo;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* Receives the callbacks of the instrumented Vaadin code: tracks session lifecycle and component
* ownership and reports cross-session attach / mutation as they happen.
*/
final class RuntimeChecks implements LeakBridge.Hooks {
private static final List<String> FRAMEWORK_PREFIXES = List.of(
"java.", "javax.", "jdk.", "sun.", "com.sun.", "jakarta.", "com.vaadin.",
"org.springframework.", "org.apache.", "org.eclipse.", "io.undertow.", "net.bytebuddy.",
"org.atmosphere.", "elemental.");
private final Config config;
private final SessionTracker tracker;
private final LeakReport report;
private final Runnable onServiceInit;
private final Runnable onNewFinding;
RuntimeChecks(Config config, SessionTracker tracker, LeakReport report, Runnable onServiceInit,
Runnable onNewFinding) {
this.config = config;
this.tracker = tracker;
this.report = report;
this.onServiceInit = onServiceInit;
this.onNewFinding = onNewFinding;
}
@Override
public void sessionCreated(Object vaadinSession) {
tracker.info(vaadinSession);
}
@Override
public void sessionDestroyed(Object vaadinSession) {
tracker.destroyed(vaadinSession);
}
@Override
public void serviceInitialized(Object vaadinService) {
tracker.addService(vaadinService);
onServiceInit.run();
}
@Override
public void componentCreated(Object component, Object currentSession) {
if (currentSession != null) {
tracker.setOwner(component, tracker.info(currentSession));
}
}
@Override
public void componentAttached(Object component, Object ui, Object uiSession, Object currentSession) {
if (uiSession == null) {
return;
}
SessionInfo target = tracker.info(uiSession);
SessionInfo previous = tracker.owner(component);
tracker.setOwner(component, target);
if (previous == null || previous == target) {
return;
}
List<String> stack = stack();
String type = component.getClass().getName();
String title = "Component " + type + " of " + previous + " was attached to a UI of " + target
+ ". The same component instance is shared between users.";
String parents = parentChain(component);
List<String> details = new ArrayList<>();
details.add("component: " + type);
details.add("attached inside: " + parents);
details.add("attached at:");
details.addAll(stack);
String key = Reflect.stableName(type) + "|" + parents + "|" + userFrames(stack);
record(Finding.Kind.CROSS_SESSION_ATTACH, key, title, details, previous, target);
}
@Override
public void crossSessionMutation(Object stateNode, Object ui, Object ownerSession, Object currentSession) {
SessionInfo owner = tracker.info(ownerSession);
SessionInfo current = tracker.info(currentSession);
List<String> stack = stack();
String title = "Request of " + current + " modified the UI (" + ui.getClass().getName() + ") of "
+ owner + " without holding that session's lock.";
List<String> details = new ArrayList<>();
details.add("modified at:");
details.addAll(stack);
record(Finding.Kind.CROSS_SESSION_MUTATION, userFrames(stack), title, details, owner, current);
}
private void record(Finding.Kind kind, String key, String title, List<String> details, SessionInfo a,
SessionInfo b) {
if (report.add(kind, key, title, details, List.of(a.id, b.id))) {
onNewFinding.run();
}
if (config.failFast) {
throw new LeakDetectedException("[vaadin-leakfinder] " + kind + ": " + title);
}
}
/** "MyView > MainLayout > UI": where the component sits, often more telling than the stack. */
private static String parentChain(Object component) {
List<String> names = new ArrayList<>();
Object parent = Reflect.call(component, "getParent");
while (parent != null && names.size() < 10) {
names.add(Reflect.stableName(parent.getClass()));
parent = Reflect.call(parent, "getParent");
}
return names.isEmpty() ? "(no parent)" : String.join(" > ", names);
}
/**
* Stack of the current thread without the agent's own frames: the top frames as they are, then
* only application frames (framework frames between them are elided).
*/
static List<String> stack() {
List<String> all = StackWalker.getInstance().walk(frames -> frames
.dropWhile(f -> f.getClassName().startsWith("dev.leakfinder."))
.map(f -> "at " + f)
.collect(Collectors.toList()));
List<String> result = new ArrayList<>(all.subList(0, Math.min(TOP_FRAMES, all.size())));
boolean elided = false;
for (String frame : all.subList(result.size(), all.size())) {
if (isApplicationFrame(frame) && result.size() < MAX_FRAMES) {
result.add(frame);
elided = false;
} else if (!elided) {
result.add("...");
elided = true;
}
}
return result;
}
private static final int TOP_FRAMES = 12;
private static final int MAX_FRAMES = 40;
private static boolean isApplicationFrame(String frame) {
return FRAMEWORK_PREFIXES.stream().noneMatch(p -> frame.startsWith("at " + p))
&& !frame.startsWith("at org.junit.");
}
/** The first few application frames identify the code location of a runtime finding. */
private static String userFrames(List<String> stack) {
List<String> user = stack.stream()
.filter(RuntimeChecks::isApplicationFrame)
.filter(line -> !line.equals("..."))
.limit(3)
.toList();
return Reflect.stableName(String.join("|", user.isEmpty() ? stack.stream().limit(5).toList() : user));
}
}
@@ -0,0 +1,137 @@
package dev.leakfinder.core;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Keeps track of Vaadin sessions, services and which session owns which component. Everything is
* weakly referenced; sessions are identified by a small sequence number in reports so that real
* HTTP session ids never end up in logs.
*/
final class SessionTracker {
/** Agent side view of a VaadinSession. Holds no strong reference to the session. */
static final class SessionInfo {
final int id;
final WeakReference<Object> session;
final long createdAt = System.currentTimeMillis();
volatile long destroyedAt;
/** Set once a retention of this destroyed session has been reported. */
volatile boolean retentionReported;
SessionInfo(int id, Object session) {
this.id = id;
this.session = new WeakReference<>(session);
}
boolean isDestroyed() {
return destroyedAt != 0;
}
Object get() {
return session.get();
}
@Override
public String toString() {
return "session#" + id + (isDestroyed() ? " (destroyed)" : "");
}
}
private final AtomicInteger sequence = new AtomicInteger();
private final WeakIdentityMap<Object, SessionInfo> sessions = new WeakIdentityMap<>();
private final WeakIdentityMap<Object, SessionInfo> owners = new WeakIdentityMap<>();
private final List<SessionInfo> all = new CopyOnWriteArrayList<>();
private final List<WeakReference<Object>> services = new CopyOnWriteArrayList<>();
SessionInfo info(Object session) {
return sessions.computeIfAbsent(session, s -> {
SessionInfo info = new SessionInfo(sequence.incrementAndGet(), s);
all.add(info);
return info;
});
}
SessionInfo existingInfo(Object session) {
return sessions.get(session);
}
void destroyed(Object session) {
SessionInfo info = info(session);
if (info.destroyedAt == 0) {
info.destroyedAt = System.currentTimeMillis();
}
}
void addService(Object service) {
for (WeakReference<Object> ref : services) {
if (ref.get() == service) {
return;
}
}
services.add(new WeakReference<>(service));
}
List<Object> services() {
List<Object> result = new ArrayList<>();
for (WeakReference<Object> ref : services) {
Object s = ref.get();
if (s != null) {
result.add(s);
} else {
services.remove(ref);
}
}
return result;
}
/** Snapshot of all sessions whose objects are still reachable. Drops infos of collected sessions. */
List<SessionInfo> sessions() {
List<SessionInfo> result = new ArrayList<>();
for (SessionInfo info : all) {
if (info.get() == null) {
all.remove(info);
} else {
result.add(info);
}
}
return result;
}
// ---- component ownership -------------------------------------------------------------------
SessionInfo owner(Object component) {
return owners.get(component);
}
void setOwner(Object component, SessionInfo owner) {
owners.put(component, owner);
}
int trackedComponents() {
return owners.size();
}
/**
* Owning session of a Vaadin object, or null when it has none (e.g. a component created at
* startup and never attached).
*/
SessionInfo ownerOf(Object o) {
if (Reflect.isA(o, Reflect.VAADIN_SESSION)) {
return info(o);
}
if (!Reflect.isA(o, Reflect.COMPONENT)) {
return null;
}
SessionInfo known = owner(o);
if (known != null) {
return known;
}
Object ui = Reflect.isA(o, Reflect.UI) ? o : Reflect.call(o, "getUI");
Object session = Reflect.call(ui, "getSession");
return session == null ? null : info(session);
}
}
@@ -0,0 +1,126 @@
package dev.leakfinder.core;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Concurrent map with weak, identity-compared keys. The agent must never keep application objects
* alive itself, otherwise it would create the very leaks it reports.
*/
final class WeakIdentityMap<K, V> {
private final ConcurrentHashMap<Object, V> map = new ConcurrentHashMap<>();
private final ReferenceQueue<K> queue = new ReferenceQueue<>();
V get(K key) {
return map.get(new LookupKey(key));
}
void put(K key, V value) {
expunge();
map.put(new WeakKey<>(key, queue), value);
}
V computeIfAbsent(K key, java.util.function.Function<K, V> factory) {
V v = get(key);
if (v != null) {
return v;
}
expunge();
return map.computeIfAbsent(new WeakKey<>(key, queue), k -> factory.apply(key));
}
V remove(K key) {
return map.remove(new LookupKey(key));
}
int size() {
expunge();
return map.size();
}
/** Snapshot of live entries; keys are strongly referenced only by the returned list. */
@SuppressWarnings("unchecked")
List<Map.Entry<K, V>> entries() {
expunge();
List<Map.Entry<K, V>> result = new ArrayList<>();
map.forEach((k, v) -> {
Object referent = ((WeakKey<?>) k).get();
if (referent != null) {
result.add(Map.entry((K) referent, v));
}
});
return result;
}
void clear() {
map.clear();
}
private void expunge() {
Reference<? extends K> ref;
while ((ref = queue.poll()) != null) {
map.remove(ref);
}
}
/** Stored key. Equal to a {@link LookupKey} or another WeakKey pointing at the same object. */
private static final class WeakKey<T> extends WeakReference<T> {
private final int hash;
WeakKey(T referent, ReferenceQueue<T> queue) {
super(referent, queue);
this.hash = System.identityHashCode(referent);
}
@Override
public int hashCode() {
return hash;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
Object mine = get();
if (mine == null) {
return false;
}
if (o instanceof WeakKey<?> other) {
return mine == other.get();
}
if (o instanceof LookupKey other) {
return mine == other.referent;
}
return false;
}
}
/** Short lived strong key used for lookups only. */
private static final class LookupKey {
private final Object referent;
LookupKey(Object referent) {
this.referent = referent;
}
@Override
public int hashCode() {
return System.identityHashCode(referent);
}
@Override
public boolean equals(Object o) {
if (o instanceof WeakKey<?> other) {
return referent == other.get();
}
return o instanceof LookupKey other && referent == other.referent;
}
}
}
@@ -0,0 +1,50 @@
package com.acme.fixtures;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.html.Div;
import java.util.ArrayList;
import java.util.List;
/** Application-like classes used by the tests. Outside of the agent's default exclude list. */
public final class Fixtures {
private Fixtures() {
}
/** Static, application wide cache: anything put here is shared by all sessions. */
public static final class GlobalCache {
public static final List<Object> ENTRIES = new ArrayList<>();
private GlobalCache() {
}
}
/** Static cache used by the destroyed-session test. */
public static final class Graveyard {
public static final List<Object> ENTRIES = new ArrayList<>();
private Graveyard() {
}
}
public static class LeakedView extends Div {
}
public static class RetainedView extends Div {
}
/** A view of one user keeping a reference to another user's component. */
public static class SnoopView extends Div {
public Component peeked;
}
/** Only references its own children. */
public static class CleanView extends Div {
public final Div child = new Div();
public CleanView() {
add(child);
}
}
}
@@ -0,0 +1,46 @@
package dev.leakfinder.agent;
import org.junit.jupiter.api.Test;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ConfigTest {
@Test
void parsesOptions() {
Config c = Config.parse("scanInterval=30s,gcWait=500ms,report=target/leaks.json,include=com.acme;org.foo,"
+ "failFast,maxNodes=10");
assertEquals(Duration.ofSeconds(30), c.scanInterval);
assertEquals(Duration.ofMillis(500), c.gcWait);
assertEquals(Path.of("target/leaks.json"), c.report);
assertEquals(List.of("com.acme", "org.foo"), c.include);
assertTrue(c.failFast);
assertEquals(10, c.maxNodes);
}
@Test
void rootCandidates() {
Config defaults = Config.parse(null);
assertTrue(defaults.isRootCandidate("com.example.MyView"));
assertFalse(defaults.isRootCandidate("com.vaadin.flow.component.UI"));
assertFalse(defaults.isRootCandidate("org.springframework.context.Foo"));
Config included = Config.parse("include=com.acme");
assertTrue(included.isRootCandidate("com.acme.Cache"));
assertFalse(included.isRootCandidate("com.example.MyView"));
}
@Test
void stopClasses() {
Config c = Config.parse("stop=com.example.internal.");
assertTrue(c.isStopClass("org.apache.catalina.session.StandardSession"));
assertTrue(c.isStopClass("com.example.internal.Foo"));
assertFalse(c.isStopClass("java.util.ArrayList"));
}
}
@@ -0,0 +1,226 @@
package dev.leakfinder.core;
import com.acme.fixtures.Fixtures;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.server.VaadinService;
import com.vaadin.flow.server.VaadinSession;
import com.vaadin.flow.server.WrappedSession;
import dev.leakfinder.agent.LeakFinderAgent;
import net.bytebuddy.agent.ByteBuddyAgent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Installs the agent into the test JVM and exercises it against real Flow classes with two
* hand-made sessions.
*/
class LeakFinderTest {
private static LeakFinder finder;
private static VaadinService service;
@BeforeAll
static void installAgent() {
LeakFinderAgent.start("scanInterval=0,jmx=false,gcWait=0s,include=com.acme", ByteBuddyAgent.install());
finder = LeakFinder.get();
assertNotNull(finder, "agent not installed");
service = Mockito.mock(VaadinService.class);
}
@Test
void componentMovedToOtherSessionIsReportedAtRuntime() {
TestSession a = new TestSession(service);
TestSession b = new TestSession(service);
UI uiA = ui(a);
UI uiB = ui(b);
Span shared = inSession(a, () -> {
Span s = new Span("shared banner");
uiA.add(s);
return s;
});
inSession(b, () -> {
// Flow refuses to move a node between state trees unless it is explicitly removed first
shared.getElement().removeFromTree();
uiB.add(shared);
return null;
});
Finding attach = only(Finding.Kind.CROSS_SESSION_ATTACH, Span.class.getName());
assertTrue(attach.details().stream().anyMatch(l -> l.contains("LeakFinderTest")), attach.format());
assertEquals(Set.of(a.info().id, b.info().id), attach.sessions());
Finding mutation = only(Finding.Kind.CROSS_SESSION_MUTATION, "LeakFinderTest");
assertTrue(mutation.sessions().contains(a.info().id));
}
@Test
void sameSessionMovesAreNotReported() {
TestSession a = new TestSession(service);
UI ui1 = ui(a);
UI ui2 = ui(a);
inSession(a, () -> {
Fixtures.CleanView view = new Fixtures.CleanView();
ui1.add(view);
view.getElement().removeFromTree();
ui2.add(view);
return null;
});
assertFalse(mentions(Finding.Kind.CROSS_SESSION_ATTACH, Fixtures.CleanView.class.getName()));
}
@Test
void staticFieldHoldingComponentIsReported() {
TestSession a = new TestSession(service);
UI uiA = ui(a);
inSession(a, () -> {
Fixtures.LeakedView view = new Fixtures.LeakedView();
uiA.add(view);
Fixtures.GlobalCache.ENTRIES.add(view);
return null;
});
finder.scanNow();
Finding f = only(Finding.Kind.SHARED_ROOT_HOLDS_SESSION_STATE, Fixtures.GlobalCache.class.getName());
assertTrue(f.details().get(0).startsWith("static " + Fixtures.GlobalCache.class.getName() + ".ENTRIES"),
f.format());
assertTrue(f.details().get(f.details().size() - 1).contains(Fixtures.LeakedView.class.getName()), f.format());
}
@Test
void sessionReferencingForeignComponentIsReported() {
TestSession a = new TestSession(service);
TestSession b = new TestSession(service);
UI uiA = ui(a);
UI uiB = ui(b);
Fixtures.CleanView victim = inSession(a, () -> {
Fixtures.CleanView v = new Fixtures.CleanView();
uiA.add(v);
return v;
});
inSession(b, () -> {
Fixtures.SnoopView snoop = new Fixtures.SnoopView();
snoop.peeked = victim;
uiB.add(snoop);
return null;
});
finder.scanNow();
Finding f = only(Finding.Kind.SESSION_REFERENCES_FOREIGN_STATE, Fixtures.SnoopView.class.getName());
assertTrue(f.details().stream().anyMatch(l -> l.contains(".peeked")), f.format());
assertEquals(Set.of(a.info().id, b.info().id), f.sessions());
// the victim's own UI tree is clean
assertFalse(f.title().startsWith("component " + Fixtures.CleanView.class.getName()), f.format());
}
@Test
void destroyedSessionKeptAliveByStaticIsReported() {
TestSession a = new TestSession(service);
UI uiA = ui(a);
inSession(a, () -> {
Fixtures.RetainedView view = new Fixtures.RetainedView();
uiA.add(view);
Fixtures.Graveyard.ENTRIES.add(view);
return null;
});
finder.hooks().sessionDestroyed(a);
finder.scanNow();
Finding f = only(Finding.Kind.DESTROYED_SESSION_RETAINED, Fixtures.Graveyard.class.getName());
assertTrue(f.sessions().contains(a.info().id));
}
@Test
void reportIsValidLookingJson() {
String json = finder.report().toJson();
assertTrue(json.startsWith("{") && json.trim().endsWith("}"), json);
assertTrue(json.contains("\"findings\""), json);
}
// ---- helpers -------------------------------------------------------------------------------
private static Finding only(Finding.Kind kind, String mention) {
List<Finding> matches = finder.report().findings().stream()
.filter(f -> f.kind() == kind)
.filter(f -> f.title().contains(mention) || f.details().stream().anyMatch(l -> l.contains(mention)))
.toList();
assertEquals(1, matches.size(), () -> "expected one " + kind + " mentioning " + mention + " in\n"
+ finder.report().toJson());
return matches.get(0);
}
private static boolean mentions(Finding.Kind kind, String mention) {
return finder.report().findings().stream()
.filter(f -> f.kind() == kind)
.anyMatch(f -> f.title().contains(mention) || f.details().stream().anyMatch(l -> l.contains(mention)));
}
private static <T> T inSession(TestSession session, Supplier<T> action) {
session.lock.lock();
VaadinSession.setCurrent(session);
try {
return action.get();
} finally {
VaadinSession.setCurrent(null);
session.lock.unlock();
}
}
@SuppressWarnings("unchecked")
private static UI ui(TestSession session) {
return inSession(session, () -> {
UI ui = new UI();
ui.getInternals().setSession(session);
Map<Integer, UI> uis = (Map<Integer, UI>) Reflect.field(session, "uIs");
uis.put(uis.size() + 1, ui);
return ui;
});
}
/** Session with a plain lock and a mocked HTTP session, enough for the agent's needs. */
static final class TestSession extends VaadinSession {
final ReentrantLock lock = new ReentrantLock();
private final WrappedSession wrapped = Mockito.mock(WrappedSession.class);
TestSession(VaadinService service) {
super(service);
Mockito.when(wrapped.getAttributeNames()).thenReturn(Set.of());
}
@Override
public Lock getLockInstance() {
return lock;
}
@Override
public boolean hasLock() {
return lock.isHeldByCurrentThread();
}
@Override
public WrappedSession getSession() {
return wrapped;
}
SessionTracker.SessionInfo info() {
return finder.tracker().info(this);
}
}
}