commit d9a9647742184551fe372aca56b89440bb6eef5a Author: Pit Friedrich Date: Sun Oct 11 17:45:06 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca9a23f --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +target/ +*.iml +.idea/ +.vscode/ +demo-app/node_modules/ +demo-app/package.json +demo-app/package-lock.json +demo-app/tsconfig.json +demo-app/types.d.ts +demo-app/vite.config.ts +demo-app/vite.generated.ts +demo-app/src/main/frontend/generated/ +demo-app/src/main/bundles/ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..c595b00 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ad2a63 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# Vaadin Leak Finder + +A Java agent that detects **inter-session leaks** in Vaadin Flow 25 applications: UI state of one +user (UIs, components, the `VaadinSession`) that is reachable from, or modified by, another user +or application-wide shared state. + +Typical bugs it catches: + +| Bug | How it shows up | +|---|---| +| Component stored in a `static` field / static list | `SHARED_ROOT_HOLDS_SESSION_STATE` | +| Spring singleton caching a `Grid`, view or `UI` | `SHARED_ROOT_HOLDS_SESSION_STATE` | +| Broadcaster listener never unregistered on detach | `SHARED_ROOT_HOLDS_SESSION_STATE`, later `DESTROYED_SESSION_RETAINED` | +| One component instance shown to several users (`removeFromTree()` + `add`) | `CROSS_SESSION_ATTACH`, `CROSS_SESSION_MUTATION` | +| Request of user B changes user A's UI without `ui.access()` | `CROSS_SESSION_MUTATION` | +| View / session-scoped bean of user B holding a component of user A | `SESSION_REFERENCES_FOREIGN_STATE` | +| Logged-out sessions that never get garbage collected | `DESTROYED_SESSION_RETAINED` | + +## Usage + +```bash +./mvnw -pl leakfinder-agent package +java -javaagent:leakfinder-agent/target/leakfinder-agent-0.1.0-SNAPSHOT.jar=scanInterval=30s,report=target/leaks.json \ + -jar your-app.jar +``` + +Findings are logged once each (through `System.Logger`, so they end up in the Spring Boot log) and +written to the JSON report. Example: + +``` +ERROR SHARED_ROOT_HOLDS_SESSION_STATE: spring singleton 'gridCache' is shared by all users but references component com.vaadin.flow.component.grid.Grid of a single session + spring singleton 'gridCache' (com.example.leakdemo.GridCache) + -> .lastGrid (com.vaadin.flow.component.grid.Grid) + +ERROR CROSS_SESSION_ATTACH: Component com.vaadin.flow.component.html.Span of session#1 was attached to a UI of session#2. The same component instance is shared between users. + component: com.vaadin.flow.component.html.Span + attached inside: com.example.leakdemo.SharedComponentView > com.vaadin.flow.component.UI + attached at: ... +``` + +Sessions are numbered (`session#1`); real HTTP session ids never appear in logs or reports. + +### Options + +Comma separated `key=value` pairs; list values are separated by `;`. + +| Option | Default | Meaning | +|---|---|---| +| `scanInterval` | `60s` | Period of heap scans (`ms`, `s`, `m`, `h`; `0` = only on demand via JMX) | +| `initialDelay` | = `scanInterval` | Delay of the first scan | +| `gcWait` | `30s` | Grace period before a destroyed session that is still alive is reported | +| `report` | — | JSON report file, rewritten after each scan and on new runtime findings | +| `include` | — | Only scan statics / singletons of these package prefixes (e.g. `com.mycompany`) | +| `exclude` | JDK, Vaadin, Spring, containers, common libs | Additional prefixes whose statics / singletons are not scanned | +| `stop` | containers, Spring, Vaadin internals, `java.lang.ref`, ... | Additional prefixes the heap walk never descends into | +| `maxNodes` / `maxDepth` | `2000000` / `64` | Heap walk budget per scan | +| `sessionScan` | `true` | Walk each live session's UI trees and attributes for foreign state | +| `runtimeChecks` | `true` | Instrument component attach and `StateNode.markAsDirty` | +| `failFast` | `false` | Throw `LeakDetectedException` into the request on runtime findings (tests / CI) | +| `jmx` | `true` | Register `dev.leakfinder:type=LeakFinder` (`scanNow()`, `getReportJson()`, ...) | +| `verbose` | `false` | Log a summary line after every scan | + +## How it works + +**Instrumentation** (Byte Buddy advice inlined into Flow classes): + +- `VaadinSession` constructor, `VaadinService.init()` / `fireSessionDestroy()` — session and service lifecycle. +- `Component` constructors — remember the session that was current when a component was created. +- `ComponentUtil.onComponentAttach` — a component owned by session A being attached to a UI of + session B is a `CROSS_SESSION_ATTACH`. +- `StateNode.markAsDirty` — every server side UI change ends here. If the thread serves session B + (`VaadinSession.getCurrent()`) but modifies a node of a UI of session A without holding A's lock, + that is a `CROSS_SESSION_MUTATION`. `ui.access()` sets the current session correctly and is not + reported. + +**Heap scans** (reflective breadth-first walk, shortest path reported): + +- *Shared roots*: static fields of loaded, initialized application classes and the singletons of + the Spring application context. Reaching a component / UI owned by a session, or a + `VaadinSession`, is a leak: shared state must not reference per-user state. +- *Per session*: under the session lock, the fields of every component in every UI tree, Vaadin + session attributes (UI / route scoped beans) and HTTP session attributes (session scoped beans). + Reaching a component of a *different* session is `SESSION_REFERENCES_FOREIGN_STATE`. +- *Destroyed sessions*: weakly tracked; still alive after `System.gc()` and `gcWait` means + something retains them. If the shared-root walk found the path it is reported, otherwise a + hint to take a heap dump. + +The walk stops at container / framework internals (Tomcat session managers, Spring, Flow +internals, weak references), so legitimate references from the servlet container to its sessions +are not reported. + +**Class loading**: the tiny `dev.leakfinder.bridge` package is appended to the bootstrap class +path so the inlined advice can reach it from any class loader (Spring Boot fat jar, servlet +container webapp, IDE). The rest of the agent accesses Vaadin reflectively; Byte Buddy is shaded. + +## Limits + +- Development / test tool. Instrumenting `markAsDirty` and periodic heap walks cost CPU, and + `System.gc()` is called when destroyed sessions are pending. +- Reachability is analysed from the scanned roots only. Leaks through threads, thread locals, + JNI or excluded libraries are not traced (the destroyed-session check still notices the + symptom). +- The walk reads live objects without stopping the application; concurrent changes can cause + missed edges. Findings are deduplicated by path, so they converge over several scans. +- Runtime checks only see components created / attached after the agent started, so attach the + agent at startup (`-javaagent`), not later. +- Flow 25 already refuses to move a node between two UIs' state trees unless it is removed first + (`removeFromTree()`); the agent catches the variant that slips past that guard. + +## Project layout + +``` +leakfinder-agent/ the agent (shaded jar with Premain-Class); unit tests run real Flow classes +demo-app/ Spring Boot 4 + Vaadin 25 app with deliberate leaks (see the view classes) +scripts/e2e.sh builds the demo as production jar, runs it with the agent, drives two users + over HTTP and asserts the JSON report +``` + +```bash +./mvnw verify # agent unit tests + demo build +scripts/e2e.sh # end-to-end (needs Node for the Vaadin production build, python3, curl) +./mvnw -pl demo-app spring-boot:run \ + -Dspring-boot.run.jvmArguments="-javaagent:$PWD/leakfinder-agent/target/leakfinder-agent-0.1.0-SNAPSHOT.jar=scanInterval=10s,verbose" +``` diff --git a/demo-app/pom.xml b/demo-app/pom.xml new file mode 100644 index 0000000..7771353 --- /dev/null +++ b/demo-app/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + + dev.leakfinder + vaadin-leakfinder-parent + 0.1.0-SNAPSHOT + + + demo-app + Vaadin Leak Finder Demo App + Vaadin 25 app with deliberate inter-session leaks + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + com.vaadin + vaadin-spring-boot-starter + + + com.vaadin + vaadin-dev + true + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + repackage + + + + + + + + + + + production + + + com.vaadin + vaadin-dev + provided + + + + + + com.vaadin + vaadin-maven-plugin + ${vaadin.version} + + + + prepare-frontend + build-frontend + + + + + + + + + diff --git a/demo-app/src/main/frontend/index.html b/demo-app/src/main/frontend/index.html new file mode 100644 index 0000000..7e3cdd4 --- /dev/null +++ b/demo-app/src/main/frontend/index.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + +
+ + diff --git a/demo-app/src/main/java/com/example/leakdemo/Application.java b/demo-app/src/main/java/com/example/leakdemo/Application.java new file mode 100644 index 0000000..8cf81b5 --- /dev/null +++ b/demo-app/src/main/java/com/example/leakdemo/Application.java @@ -0,0 +1,12 @@ +package com.example.leakdemo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/demo-app/src/main/java/com/example/leakdemo/Broadcaster.java b/demo-app/src/main/java/com/example/leakdemo/Broadcaster.java new file mode 100644 index 0000000..ca3cac3 --- /dev/null +++ b/demo-app/src/main/java/com/example/leakdemo/Broadcaster.java @@ -0,0 +1,23 @@ +package com.example.leakdemo; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +/** Classic Vaadin broadcaster. Fine by itself, leaks when listeners are never removed. */ +public final class Broadcaster { + + private static final List> LISTENERS = new CopyOnWriteArrayList<>(); + + private Broadcaster() { + } + + public static Runnable register(Consumer listener) { + LISTENERS.add(listener); + return () -> LISTENERS.remove(listener); + } + + public static void broadcast(String message) { + LISTENERS.forEach(l -> l.accept(message)); + } +} diff --git a/demo-app/src/main/java/com/example/leakdemo/BroadcasterView.java b/demo-app/src/main/java/com/example/leakdemo/BroadcasterView.java new file mode 100644 index 0000000..70ccfaa --- /dev/null +++ b/demo-app/src/main/java/com/example/leakdemo/BroadcasterView.java @@ -0,0 +1,23 @@ +package com.example.leakdemo; + +import com.vaadin.flow.component.AttachEvent; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.Route; + +/** LEAK: registers with the static broadcaster on attach but never unregisters on detach. */ +@Route("broadcaster-leak") +public class BroadcasterView extends VerticalLayout { + + public BroadcasterView() { + add(new H2("Broadcaster leak")); + } + + @Override + protected void onAttach(AttachEvent attachEvent) { + UI ui = attachEvent.getUI(); + Broadcaster.register(message -> ui.access(() -> add(new Span(message)))); + } +} diff --git a/demo-app/src/main/java/com/example/leakdemo/CleanView.java b/demo-app/src/main/java/com/example/leakdemo/CleanView.java new file mode 100644 index 0000000..f7de5ac --- /dev/null +++ b/demo-app/src/main/java/com/example/leakdemo/CleanView.java @@ -0,0 +1,23 @@ +package com.example.leakdemo; + +import com.vaadin.flow.component.html.Anchor; +import com.vaadin.flow.component.html.H1; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.Route; + +/** Control view: per-user state only, must produce no findings. */ +@Route("") +public class CleanView extends VerticalLayout { + + private final Paragraph counter = new Paragraph("0"); + + public CleanView() { + add(new H1("Vaadin leak finder demo"), counter); + add(new Anchor("static-leak", "Static list of views")); + add(new Anchor("singleton-leak", "Singleton bean holding a grid")); + add(new Anchor("broadcaster-leak", "Broadcaster without unregister")); + add(new Anchor("shared-component", "Component shared via static field")); + add(new Anchor("snoop", "View peeking into another user's view")); + } +} diff --git a/demo-app/src/main/java/com/example/leakdemo/GridCache.java b/demo-app/src/main/java/com/example/leakdemo/GridCache.java new file mode 100644 index 0000000..f3cc745 --- /dev/null +++ b/demo-app/src/main/java/com/example/leakdemo/GridCache.java @@ -0,0 +1,19 @@ +package com.example.leakdemo; + +import com.vaadin.flow.component.grid.Grid; +import org.springframework.stereotype.Service; + +/** LEAK: application wide singleton that caches a UI component. */ +@Service +public class GridCache { + + private Grid lastGrid; + + public void remember(Grid grid) { + this.lastGrid = grid; + } + + public Grid lastGrid() { + return lastGrid; + } +} diff --git a/demo-app/src/main/java/com/example/leakdemo/SessionController.java b/demo-app/src/main/java/com/example/leakdemo/SessionController.java new file mode 100644 index 0000000..8056eca --- /dev/null +++ b/demo-app/src/main/java/com/example/leakdemo/SessionController.java @@ -0,0 +1,27 @@ +package com.example.leakdemo; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** Helper endpoints for the end-to-end test. */ +@RestController +public class SessionController { + + @GetMapping("/api/logout") + public String logout(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session != null) { + session.invalidate(); + return "invalidated"; + } + return "no session"; + } + + @GetMapping("/api/broadcast") + public String broadcast() { + Broadcaster.broadcast("hello"); + return "sent"; + } +} diff --git a/demo-app/src/main/java/com/example/leakdemo/SharedComponentView.java b/demo-app/src/main/java/com/example/leakdemo/SharedComponentView.java new file mode 100644 index 0000000..9945d2a --- /dev/null +++ b/demo-app/src/main/java/com/example/leakdemo/SharedComponentView.java @@ -0,0 +1,23 @@ +package com.example.leakdemo; + +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.Route; + +/** + * LEAK: one component instance for all users. Flow throws "Can't move a node from one state tree to + * another" when the second user opens the view; calling {@code removeFromTree()} silences that, and + * now every visitor steals the banner from the previous visitor's UI, mutating that user's UI from + * the wrong request thread. + */ +@Route("shared-component") +public class SharedComponentView extends VerticalLayout { + + private static final Span BANNER = new Span("Banner of the day"); + + public SharedComponentView() { + BANNER.getElement().removeFromTree(); + add(new H2("Shared component"), BANNER); + } +} diff --git a/demo-app/src/main/java/com/example/leakdemo/SingletonLeakView.java b/demo-app/src/main/java/com/example/leakdemo/SingletonLeakView.java new file mode 100644 index 0000000..136fe70 --- /dev/null +++ b/demo-app/src/main/java/com/example/leakdemo/SingletonLeakView.java @@ -0,0 +1,20 @@ +package com.example.leakdemo; + +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.Route; + +import java.util.List; + +@Route("singleton-leak") +public class SingletonLeakView extends VerticalLayout { + + public SingletonLeakView(GridCache cache) { + Grid grid = new Grid<>(); + grid.addColumn(s -> s).setHeader("Item"); + grid.setItems(List.of("a", "b", "c")); + cache.remember(grid); + add(new H2("Singleton leak"), grid); + } +} diff --git a/demo-app/src/main/java/com/example/leakdemo/SnoopView.java b/demo-app/src/main/java/com/example/leakdemo/SnoopView.java new file mode 100644 index 0000000..5faa9df --- /dev/null +++ b/demo-app/src/main/java/com/example/leakdemo/SnoopView.java @@ -0,0 +1,24 @@ +package com.example.leakdemo; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.Route; + +/** LEAK: the view of one user keeps a reference to a view of a different user. */ +@Route("snoop") +public class SnoopView extends VerticalLayout { + + private Component peeked; + + public SnoopView() { + add(new H2("Snoop")); + synchronized (StaticLeakView.RECENT_VIEWS) { + if (!StaticLeakView.RECENT_VIEWS.isEmpty()) { + peeked = StaticLeakView.RECENT_VIEWS.get(0); + } + } + add(new Paragraph(peeked == null ? "nothing to peek at" : "peeking at " + peeked.getClass().getSimpleName())); + } +} diff --git a/demo-app/src/main/java/com/example/leakdemo/StaticLeakView.java b/demo-app/src/main/java/com/example/leakdemo/StaticLeakView.java new file mode 100644 index 0000000..87736b8 --- /dev/null +++ b/demo-app/src/main/java/com/example/leakdemo/StaticLeakView.java @@ -0,0 +1,23 @@ +package com.example.leakdemo; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.Route; + +import java.util.ArrayList; +import java.util.List; + +/** LEAK: a static "recently opened views" list keeps every user's view (and its UI) alive forever. */ +@Route("static-leak") +public class StaticLeakView extends VerticalLayout { + + static final List RECENT_VIEWS = new ArrayList<>(); + + public StaticLeakView() { + add(new H2("Static leak")); + synchronized (RECENT_VIEWS) { + RECENT_VIEWS.add(this); + } + } +} diff --git a/demo-app/src/main/resources/application.properties b/demo-app/src/main/resources/application.properties new file mode 100644 index 0000000..cc5bc7f --- /dev/null +++ b/demo-app/src/main/resources/application.properties @@ -0,0 +1,4 @@ +server.port=${PORT:8080} +vaadin.launch-browser=false +spring.main.banner-mode=off +logging.level.org.atmosphere=warn diff --git a/leakfinder-agent/pom.xml b/leakfinder-agent/pom.xml new file mode 100644 index 0000000..86b88a4 --- /dev/null +++ b/leakfinder-agent/pom.xml @@ -0,0 +1,119 @@ + + + 4.0.0 + + + dev.leakfinder + vaadin-leakfinder-parent + 0.1.0-SNAPSHOT + + + leakfinder-agent + Vaadin Leak Finder Agent + + + + net.bytebuddy + byte-buddy + + + + + com.vaadin + flow-server + provided + + + + com.vaadin + flow-html-components + test + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + net.bytebuddy + byte-buddy-agent + test + + + jakarta.servlet + jakarta.servlet-api + 6.1.0 + provided + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + -XX:+EnableDynamicAgentLoading -Djdk.attach.allowAttachSelf=true + true + + false + + + + org.apache.maven.plugins + maven-jar-plugin + + + + dev.leakfinder.agent.LeakFinderAgent + dev.leakfinder.agent.LeakFinderAgent + true + true + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + package + + shade + + + false + + + net.bytebuddy + dev.leakfinder.shaded.bytebuddy + + + + + *:* + + META-INF/versions/*/module-info.class + module-info.class + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + diff --git a/leakfinder-agent/src/main/java/dev/leakfinder/advice/VaadinAdvice.java b/leakfinder-agent/src/main/java/dev/leakfinder/advice/VaadinAdvice.java new file mode 100644 index 0000000..8cbd41c --- /dev/null +++ b/leakfinder-agent/src/main/java/dev/leakfinder/advice/VaadinAdvice.java @@ -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. + *

+ * 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; + } + } + } + } +} diff --git a/leakfinder-agent/src/main/java/dev/leakfinder/agent/Config.java b/leakfinder-agent/src/main/java/dev/leakfinder/agent/Config.java new file mode 100644 index 0000000..910ea5f --- /dev/null +++ b/leakfinder-agent/src/main/java/dev/leakfinder/agent/Config.java @@ -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 ;}. + * + *

+ * 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
+ * 
+ */ +public final class Config { + + static final List 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 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 include = new ArrayList<>(); + public List exclude = new ArrayList<>(DEFAULT_EXCLUDES); + public List 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 list(String v) { + return new ArrayList<>(Arrays.stream(v.split(";")).map(String::trim).filter(s -> !s.isEmpty()).toList()); + } +} diff --git a/leakfinder-agent/src/main/java/dev/leakfinder/agent/LeakFinderAgent.java b/leakfinder-agent/src/main/java/dev/leakfinder/agent/LeakFinderAgent.java new file mode 100644 index 0000000..3b64f5f --- /dev/null +++ b/leakfinder-agent/src/main/java/dev/leakfinder/agent/LeakFinderAgent.java @@ -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 before anything links against it, otherwise the + * system class loader would define a second, invisible copy. + */ +public final class LeakFinderAgent { + + static final List 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())); + } +} diff --git a/leakfinder-agent/src/main/java/dev/leakfinder/bridge/LeakBridge.java b/leakfinder-agent/src/main/java/dev/leakfinder/bridge/LeakBridge.java new file mode 100644 index 0000000..85f376a --- /dev/null +++ b/leakfinder-agent/src/main/java/dev/leakfinder/bridge/LeakBridge.java @@ -0,0 +1,147 @@ +package dev.leakfinder.bridge; + +/** + * Entry point called from the advice code that is inlined into Vaadin classes. + *

+ * 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 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); + } +} diff --git a/leakfinder-agent/src/main/java/dev/leakfinder/bridge/LeakDetectedException.java b/leakfinder-agent/src/main/java/dev/leakfinder/bridge/LeakDetectedException.java new file mode 100644 index 0000000..c75a515 --- /dev/null +++ b/leakfinder-agent/src/main/java/dev/leakfinder/bridge/LeakDetectedException.java @@ -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); + } +} diff --git a/leakfinder-agent/src/main/java/dev/leakfinder/core/Finding.java b/leakfinder-agent/src/main/java/dev/leakfinder/core/Finding.java new file mode 100644 index 0000000..d60f40f --- /dev/null +++ b/leakfinder-agent/src/main/java/dev/leakfinder/core/Finding.java @@ -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 details; + final Instant firstSeen = Instant.now(); + volatile Instant lastSeen = firstSeen; + final AtomicLong occurrences = new AtomicLong(); + final Set sessions = new TreeSet<>(); + + Finding(Kind kind, String key, String title, List 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 details() { + return details; + } + + public long occurrences() { + return occurrences.get(); + } + + public synchronized Set sessions() { + return new TreeSet<>(sessions); + } + + synchronized void addSessions(Iterable 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(); + } +} diff --git a/leakfinder-agent/src/main/java/dev/leakfinder/core/HeapWalker.java b/leakfinder-agent/src/main/java/dev/leakfinder/core/HeapWalker.java new file mode 100644 index 0000000..3051470 --- /dev/null +++ b/leakfinder-agent/src/main/java/dev/leakfinder/core/HeapWalker.java @@ -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. + *

+ * 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 path); + } + + private record Node(Object value, Node parent, String edge, int depth) { + } + + private final Set 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 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 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 path(Node end) { + List 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 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; + } +} diff --git a/leakfinder-agent/src/main/java/dev/leakfinder/core/Installer.java b/leakfinder-agent/src/main/java/dev/leakfinder/core/Installer.java new file mode 100644 index 0000000..be196ac --- /dev/null +++ b/leakfinder-agent/src/main/java/dev/leakfinder/core/Installer.java @@ -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> 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 + } + } + } +} diff --git a/leakfinder-agent/src/main/java/dev/leakfinder/core/Json.java b/leakfinder-agent/src/main/java/dev/leakfinder/core/Json.java new file mode 100644 index 0000000..fc20208 --- /dev/null +++ b/leakfinder-agent/src/main/java/dev/leakfinder/core/Json.java @@ -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(); + } +} diff --git a/leakfinder-agent/src/main/java/dev/leakfinder/core/LeakFinder.java b/leakfinder-agent/src/main/java/dev/leakfinder/core/LeakFinder.java new file mode 100644 index 0000000..8040913 --- /dev/null +++ b/leakfinder-agent/src/main/java/dev/leakfinder/core/LeakFinder.java @@ -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); + } + } +} diff --git a/leakfinder-agent/src/main/java/dev/leakfinder/core/LeakFinderControl.java b/leakfinder-agent/src/main/java/dev/leakfinder/core/LeakFinderControl.java new file mode 100644 index 0000000..624fc01 --- /dev/null +++ b/leakfinder-agent/src/main/java/dev/leakfinder/core/LeakFinderControl.java @@ -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(); + } +} diff --git a/leakfinder-agent/src/main/java/dev/leakfinder/core/LeakFinderControlMBean.java b/leakfinder-agent/src/main/java/dev/leakfinder/core/LeakFinderControlMBean.java new file mode 100644 index 0000000..6ce8c89 --- /dev/null +++ b/leakfinder-agent/src/main/java/dev/leakfinder/core/LeakFinderControlMBean.java @@ -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(); +} diff --git a/leakfinder-agent/src/main/java/dev/leakfinder/core/LeakReport.java b/leakfinder-agent/src/main/java/dev/leakfinder/core/LeakReport.java new file mode 100644 index 0000000..9c25e78 --- /dev/null +++ b/leakfinder-agent/src/main/java/dev/leakfinder/core/LeakReport.java @@ -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 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 details, Iterable 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 findings() { + List 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 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); + } + } +} diff --git a/leakfinder-agent/src/main/java/dev/leakfinder/core/LeakScanner.java b/leakfinder-agent/src/main/java/dev/leakfinder/core/LeakScanner.java new file mode 100644 index 0000000..2f02ef6 --- /dev/null +++ b/leakfinder-agent/src/main/java/dev/leakfinder/core/LeakScanner.java @@ -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: + *
    + *
  • shared roots (static fields, Spring singletons) that reach UIs, components or sessions
  • + *
  • session state (UI trees, session attributes) that reaches state of another session
  • + *
  • destroyed sessions that survive garbage collection
  • + *
+ */ +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 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 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> springSingletons(Object service) { + List> 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 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 components(Object session) { + List result = new ArrayList<>(); + if (!(Reflect.field(session, "uIs") instanceof Map uis)) { + return result; + } + List 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 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 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 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(); + } +} diff --git a/leakfinder-agent/src/main/java/dev/leakfinder/core/Log.java b/leakfinder-agent/src/main/java/dev/leakfinder/core/Log.java new file mode 100644 index 0000000..341611c --- /dev/null +++ b/leakfinder-agent/src/main/java/dev/leakfinder/core/Log.java @@ -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; + } +} diff --git a/leakfinder-agent/src/main/java/dev/leakfinder/core/Reflect.java b/leakfinder-agent/src/main/java/dev/leakfinder/core/Reflect.java new file mode 100644 index 0000000..32b49f8 --- /dev/null +++ b/leakfinder-agent/src/main/java/dev/leakfinder/core/Reflect.java @@ -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> SUPERTYPES = new ClassValue<>() { + @Override + protected Set computeValue(Class type) { + Set names = new HashSet<>(); + collect(type, names); + return names; + } + + private void collect(Class c, Set 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 INSTANCE_FIELDS = new ClassValue<>() { + @Override + protected Field[] computeValue(Class type) { + List 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> METHODS = new ClassValue<>() { + @Override + protected Map 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> seen = new HashSet<>(); + List> 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"); + } +} diff --git a/leakfinder-agent/src/main/java/dev/leakfinder/core/RuntimeChecks.java b/leakfinder-agent/src/main/java/dev/leakfinder/core/RuntimeChecks.java new file mode 100644 index 0000000..920a5af --- /dev/null +++ b/leakfinder-agent/src/main/java/dev/leakfinder/core/RuntimeChecks.java @@ -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 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 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 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 stack = stack(); + String title = "Request of " + current + " modified the UI (" + ui.getClass().getName() + ") of " + + owner + " without holding that session's lock."; + List 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 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 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 stack() { + List all = StackWalker.getInstance().walk(frames -> frames + .dropWhile(f -> f.getClassName().startsWith("dev.leakfinder.")) + .map(f -> "at " + f) + .collect(Collectors.toList())); + List 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 stack) { + List 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)); + } +} diff --git a/leakfinder-agent/src/main/java/dev/leakfinder/core/SessionTracker.java b/leakfinder-agent/src/main/java/dev/leakfinder/core/SessionTracker.java new file mode 100644 index 0000000..f036a12 --- /dev/null +++ b/leakfinder-agent/src/main/java/dev/leakfinder/core/SessionTracker.java @@ -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 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 sessions = new WeakIdentityMap<>(); + private final WeakIdentityMap owners = new WeakIdentityMap<>(); + private final List all = new CopyOnWriteArrayList<>(); + private final List> 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 ref : services) { + if (ref.get() == service) { + return; + } + } + services.add(new WeakReference<>(service)); + } + + List services() { + List result = new ArrayList<>(); + for (WeakReference 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 sessions() { + List 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); + } +} diff --git a/leakfinder-agent/src/main/java/dev/leakfinder/core/WeakIdentityMap.java b/leakfinder-agent/src/main/java/dev/leakfinder/core/WeakIdentityMap.java new file mode 100644 index 0000000..953ce78 --- /dev/null +++ b/leakfinder-agent/src/main/java/dev/leakfinder/core/WeakIdentityMap.java @@ -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 { + + private final ConcurrentHashMap map = new ConcurrentHashMap<>(); + private final ReferenceQueue 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 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> entries() { + expunge(); + List> 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 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 extends WeakReference { + private final int hash; + + WeakKey(T referent, ReferenceQueue 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; + } + } +} diff --git a/leakfinder-agent/src/test/java/com/acme/fixtures/Fixtures.java b/leakfinder-agent/src/test/java/com/acme/fixtures/Fixtures.java new file mode 100644 index 0000000..9de0521 --- /dev/null +++ b/leakfinder-agent/src/test/java/com/acme/fixtures/Fixtures.java @@ -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 ENTRIES = new ArrayList<>(); + + private GlobalCache() { + } + } + + /** Static cache used by the destroyed-session test. */ + public static final class Graveyard { + public static final List 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); + } + } +} diff --git a/leakfinder-agent/src/test/java/dev/leakfinder/agent/ConfigTest.java b/leakfinder-agent/src/test/java/dev/leakfinder/agent/ConfigTest.java new file mode 100644 index 0000000..454a6fc --- /dev/null +++ b/leakfinder-agent/src/test/java/dev/leakfinder/agent/ConfigTest.java @@ -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")); + } +} diff --git a/leakfinder-agent/src/test/java/dev/leakfinder/core/LeakFinderTest.java b/leakfinder-agent/src/test/java/dev/leakfinder/core/LeakFinderTest.java new file mode 100644 index 0000000..247d5be --- /dev/null +++ b/leakfinder-agent/src/test/java/dev/leakfinder/core/LeakFinderTest.java @@ -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 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 inSession(TestSession session, Supplier 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 uis = (Map) 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); + } + } +} diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..5761d94 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..ff5d4fd --- /dev/null +++ b/pom.xml @@ -0,0 +1,84 @@ + + + 4.0.0 + + dev.leakfinder + vaadin-leakfinder-parent + 0.1.0-SNAPSHOT + pom + Vaadin Leak Finder + Java agent detecting inter-session leaks in Vaadin Flow 25 applications + + + leakfinder-agent + demo-app + + + + 21 + UTF-8 + 25.2.1 + 4.0.5 + 1.18.10 + 6.0.3 + 5.20.0 + + + + + + com.vaadin + vaadin-bom + ${vaadin.version} + pom + import + + + org.junit + junit-bom + ${junit.version} + pom + import + + + net.bytebuddy + byte-buddy + ${bytebuddy.version} + + + net.bytebuddy + byte-buddy-agent + ${bytebuddy.version} + + + org.mockito + mockito-core + ${mockito.version} + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.6 + + + org.apache.maven.plugins + maven-jar-plugin + 3.5.0 + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.2 + + + + + diff --git a/scripts/e2e.sh b/scripts/e2e.sh new file mode 100755 index 0000000..53c5521 --- /dev/null +++ b/scripts/e2e.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# End-to-end check: runs the demo app (production fat jar) with the agent, drives two user sessions +# through the leaky views with plain HTTP requests and asserts the agent's JSON report. +# +# scripts/e2e.sh build everything, then test +# SKIP_BUILD=1 scripts/e2e.sh reuse existing jars +# MVN=/path/to/mvn PORT=18765 override maven / port +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +PORT=${PORT:-18765} +BASE="http://localhost:$PORT" +MVN=${MVN:-mvn} +WORK=$(mktemp -d) +REPORT="$WORK/leaks.json" +LOG="$WORK/app.log" +AGENT="$ROOT/leakfinder-agent/target/leakfinder-agent-0.1.0-SNAPSHOT.jar" +APP="$ROOT/demo-app/target/demo-app-0.1.0-SNAPSHOT.jar" + +if [[ "${SKIP_BUILD:-0}" != 1 ]]; then + (cd "$ROOT" && "$MVN" -q -pl leakfinder-agent install -DskipTests && "$MVN" -q -pl demo-app -Pproduction package -DskipTests) +fi + +APP_PID= +cleanup() { + [[ -n "$APP_PID" ]] && kill "$APP_PID" 2>/dev/null || true + echo "app log: $LOG" +} +trap cleanup EXIT + +PORT=$PORT java "-javaagent:$AGENT=scanInterval=2s,gcWait=1s,report=$REPORT" -jar "$APP" >"$LOG" 2>&1 & +APP_PID=$! + +for _ in $(seq 1 120); do + grep -q 'Started Application' "$LOG" && break + if ! kill -0 "$APP_PID" 2>/dev/null; then echo "app died"; tail -50 "$LOG"; exit 1; fi + sleep 1 +done +grep -q 'Started Application' "$LOG" || { echo "app did not start"; exit 1; } + +# Opens a route in a new UI of the session identified by cookie jar $1, like a browser tab would: +# bootstrap request (v-r=init) followed by the client side "ui-navigate" event. +open_view() { + local jar="$WORK/$1" route="$2" init key ui + init=$(curl -sf --max-time 20 -c "$jar" -b "$jar" "$BASE/?v-r=init&location=$route") + key=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["appConfig"]["uidl"]["Vaadin-Security-Key"])' <<<"$init") + ui=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["appConfig"]["v-uiId"])' <<<"$init") + curl -sf --max-time 20 -o /dev/null -c "$jar" -b "$jar" -H 'Content-Type: application/json' \ + -X POST "$BASE/?v-r=uidl&v-uiId=$ui" \ + -d "{\"csrfToken\":\"$key\",\"rpc\":[{\"type\":\"event\",\"node\":1,\"event\":\"ui-navigate\",\"data\":{\"route\":\"$route\",\"query\":\"\",\"appShellTitle\":\"\",\"historyState\":null,\"trigger\":\"\"}}],\"syncId\":0,\"clientId\":0}" + echo " $1 -> /$route" +} + +echo "driving sessions" +open_view alice static-leak +open_view alice singleton-leak +open_view alice broadcaster-leak +open_view alice shared-component +open_view bob shared-component # steals alice's banner +open_view bob singleton-leak +open_view bob broadcaster-leak +open_view bob snoop # keeps a reference to alice's view +open_view bob "" # clean view +curl -sf --max-time 20 "$BASE/api/broadcast" >/dev/null +curl -sf --max-time 20 -c "$WORK/alice" -b "$WORK/alice" "$BASE/api/logout" >/dev/null +echo " alice logged out" + +sleep 8 + +python3 - "$REPORT" <<'EOF' +import json, sys + +report = json.load(open(sys.argv[1])) +findings = report["findings"] +print(report["lastScanSummary"]) +for f in findings: + print(f" {f['severity']:5} {f['kind']:34} sessions={f['sessions']} {f['title']}") + +def text(f): + return f["title"] + "\n" + "\n".join(f["details"]) + +def expect(kind, *needles): + if not any(f["kind"] == kind and all(n in text(f) for n in needles) for f in findings): + failures.append(f"missing {kind} mentioning {needles}") + +failures = [] +expect("SHARED_ROOT_HOLDS_SESSION_STATE", "static com.example.leakdemo.StaticLeakView.RECENT_VIEWS") +expect("SHARED_ROOT_HOLDS_SESSION_STATE", "static com.example.leakdemo.Broadcaster.LISTENERS") +expect("SHARED_ROOT_HOLDS_SESSION_STATE", "spring singleton 'gridCache'", "Grid") +expect("SHARED_ROOT_HOLDS_SESSION_STATE", "static com.example.leakdemo.SharedComponentView.BANNER") +expect("CROSS_SESSION_ATTACH", "com.vaadin.flow.component.html.Span", "SharedComponentView") +expect("CROSS_SESSION_MUTATION", "SharedComponentView") +expect("SESSION_REFERENCES_FOREIGN_STATE", "SnoopView", ".peeked", "StaticLeakView") +expect("DESTROYED_SESSION_RETAINED", "StaticLeakView.RECENT_VIEWS") +if any("CleanView" in text(f) for f in findings): + failures.append("false positive: CleanView mentioned") + +if failures: + print("\nE2E FAILED:\n " + "\n ".join(failures)) + sys.exit(1) +print("\nE2E PASSED") +EOF