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