diff --git a/pom.xml b/pom.xml
index 6babd00..c1e6ce3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -150,8 +150,18 @@
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 2.21.0
+
+
+ ${project.build.directory}/heapdump-%s.hprof
+
+ always
+ -ea -XX:+UnlockDiagnosticVMOptions
+
+
-
-
diff --git a/src/main/java/com/test/MainUI.java b/src/main/java/com/test/MainUI.java
index 3a10ca9..afaaac9 100644
--- a/src/main/java/com/test/MainUI.java
+++ b/src/main/java/com/test/MainUI.java
@@ -7,6 +7,7 @@
import com.vaadin.ui.Button;
import com.vaadin.ui.Grid;
import com.vaadin.ui.HorizontalLayout;
+import com.vaadin.ui.Label;
import com.vaadin.ui.TextField;
import com.vaadin.ui.UI;
import com.vaadin.ui.VerticalLayout;
@@ -24,12 +25,13 @@ public class MainUI extends UI {
final TextField filter;
private final Button addNewBtn;
+ private final Button memoryLeak;
@Override
protected void init(VaadinRequest request) {
// build layout
- HorizontalLayout actions = new HorizontalLayout(filter, addNewBtn);
+ HorizontalLayout actions = new HorizontalLayout(filter, addNewBtn, memoryLeak);
VerticalLayout verticalLayout = new VerticalLayout(actions, grid, editor);
verticalLayout.setSizeFull();
setContent(verticalLayout);
@@ -50,9 +52,7 @@ protected void init(VaadinRequest request) {
filter.addValueChangeListener(e -> listCustomers(e.getValue()));
// Connect selected Customer to editor or hide if none is selected
- grid.asSingleSelect().addValueChangeListener(e -> {
- editor.editCustomer(e.getValue());
- });
+ grid.asSingleSelect().addValueChangeListener(e -> editor.editCustomer(e.getValue()));
// Instantiate and edit new Customer the new button is clicked
addNewBtn.addClickListener(e -> editor.editCustomer(new Customer("", "")));
@@ -65,6 +65,14 @@ protected void init(VaadinRequest request) {
// Initialize listing
listCustomers(null);
+
+ // Add a component with a listener without adding it to the design
+ memoryLeak.addClickListener(e ->
+ {
+ Label label = new Label();
+ addNewBtn.addClickListener(btnClick -> label.setValue(label.getValue() + "Clicked again!"));
+ });
+
}
public void listCustomers(String filterText) {
@@ -82,5 +90,6 @@ public MainUI(CustomerRepository repo, CustomerEditor editor) {
this.grid = new Grid<>(Customer.class);
this.filter = new TextField();
this.addNewBtn = new Button("New customer", VaadinIcons.PLUS);
+ this.memoryLeak = new Button("Memory Leak", VaadinIcons.BOMB);
}
}
\ No newline at end of file
diff --git a/src/test/java/com/test/ApplicationTests.java b/src/test/java/com/test/ApplicationTests.java
index 46e8932..3c5936f 100644
--- a/src/test/java/com/test/ApplicationTests.java
+++ b/src/test/java/com/test/ApplicationTests.java
@@ -1,16 +1,21 @@
package com.test;
import com.github.karibu.testing.MockVaadin;
+import com.test.utils.HeapDump;
+import com.test.utils.HeapInfo;
+import com.test.utils.MemoryLeakFailure;
+import com.test.utils.SingletonBeanStoreRetrievalStrategy;
import com.vaadin.data.provider.Query;
import com.vaadin.spring.internal.UIScopeImpl;
import com.vaadin.ui.Button;
import com.vaadin.ui.Grid;
import com.vaadin.ui.TextField;
-
import org.junit.After;
+import org.junit.AfterClass;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
+import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@@ -18,9 +23,12 @@
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.context.web.WebAppConfiguration;
+import java.util.function.Predicate;
import java.util.stream.Stream;
-import static com.github.karibu.testing.LocatorJ.*;
+import static com.github.karibu.testing.LocatorJ._click;
+import static com.github.karibu.testing.LocatorJ._get;
+import static com.github.karibu.testing.LocatorJ._setValue;
import static org.junit.Assert.assertTrue;
@RunWith(SpringRunner.class)
@@ -31,6 +39,7 @@ public class ApplicationTests {
@Autowired
private BeanFactory beanFactory;
+ private final Predicate classesToWatch = name -> name.contains("vaadin") || name.startsWith("com.test.");
@Before
public void setup() {
@@ -52,4 +61,67 @@ public void createNewCustomer() {
Stream customerStream = grid.getDataProvider().fetch(new Query<>());
assertTrue(customerStream.map(Customer::getFirstName).anyMatch("Halk"::equals));
}
+
+ @Test(expected = MemoryLeakFailure.class)
+ public void testMemoryLeak() {
+ Button memoryLeak = _get(Button.class, spec -> spec.withCaption("Memory Leak"));
+ _click(memoryLeak);
+
+ HeapInfo.tryGC();
+ HeapInfo heapInfo1 = new HeapInfo().classStatistics(classesToWatch);
+
+ _click(memoryLeak);
+ _click(memoryLeak);
+ _click(memoryLeak);
+ _click(memoryLeak);
+ _click(memoryLeak);
+ _click(memoryLeak);
+
+ HeapInfo.tryGC();
+ HeapInfo heapInfo2 = new HeapInfo().classStatistics(classesToWatch);
+ HeapInfo delta = heapInfo2.delta(heapInfo1);
+
+ if (delta.values().stream()
+ .map(HeapInfo.ClassHeapInfo::getClassName)
+ .anyMatch(s -> s.startsWith("com.vaadin.ui."))) {
+ LoggerFactory.getLogger(this.getClass()).error(delta.toString());
+ throw new MemoryLeakFailure("Memory Leak Detected: " + delta.toString(System.lineSeparator()));
+ }
+ }
+
+ @Test
+ public void createNew2Customers() {
+
+ _click(_get(Button.class, spec -> spec.withCaption("New customer")));
+ _setValue(_get(TextField.class, spec -> spec.withCaption("First name")), "Halk");
+ _click(_get(Button.class, spec -> spec.withCaption("Save")));
+
+ HeapInfo.tryGC();
+ HeapInfo heapInfo1 = new HeapInfo().classStatistics(classesToWatch);
+
+ _click(_get(Button.class, spec -> spec.withCaption("New customer")));
+ _setValue(_get(TextField.class, spec -> spec.withCaption("First name")), "Van Helsing");
+ _click(_get(Button.class, spec -> spec.withCaption("Save")));
+
+ HeapInfo.tryGC();
+ HeapInfo heapInfo2 = new HeapInfo().classStatistics(classesToWatch);
+
+ System.out.println("****** Class usage differences START *****");
+ HeapInfo delta = heapInfo2.delta(heapInfo1);
+ System.out.println(delta.toString(System.lineSeparator()));
+ System.out.println("******* Class usage differences END ******");
+
+ Grid grid = _get(Grid.class);
+ Stream customerStream = grid.getDataProvider().fetch(new Query<>());
+ assertTrue(customerStream.map(Customer::getFirstName).anyMatch("Halk"::equals));
+ customerStream = grid.getDataProvider().fetch(new Query<>());
+ assertTrue(customerStream.map(Customer::getFirstName).anyMatch("Van Helsing"::equals));
+ }
+
+ @AfterClass
+ public static void heapDump() {
+
+ HeapDump.heapDump(ApplicationTests.class);
+ }
+
}
diff --git a/src/test/java/com/test/utils/HeapDump.java b/src/test/java/com/test/utils/HeapDump.java
new file mode 100644
index 0000000..06fa871
--- /dev/null
+++ b/src/test/java/com/test/utils/HeapDump.java
@@ -0,0 +1,79 @@
+package com.test.utils;
+
+import com.sun.management.HotSpotDiagnosticMXBean;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.util.Lazy;
+
+import javax.management.MBeanServer;
+import java.io.File;
+import java.io.IOException;
+import java.lang.management.ManagementFactory;
+
+public class HeapDump implements TestRule {
+ public static final String HEAPDUMP_PATH = "heapdump.path";
+ private static final String HOTSPOT_DIAGNOSTICS_BEAN_NAME =
+ "com.sun.management:type=HotSpotDiagnostic";
+
+ public HeapDump() {
+
+ }
+
+ public Statement apply(final Statement base, Description description) {
+ return new Statement() {
+ @Override
+ public void evaluate() throws Throwable {
+ base.evaluate();
+ heapDump(description.getTestClass());
+ }
+ };
+ }
+
+
+ private static final Lazy diagnosticMXBean =
+ new Lazy<>(() ->
+ {
+ try {
+ MBeanServer server = ManagementFactory.getPlatformMBeanServer();
+ return ManagementFactory.newPlatformMXBeanProxy(server, HOTSPOT_DIAGNOSTICS_BEAN_NAME, HotSpotDiagnosticMXBean.class);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ );
+
+
+ public static void heapDump(Class clazz) {
+ heapDump(clazz.getSimpleName());
+ }
+
+ public static void heapDump(String qualifier) {
+ String dumpLocation = System.getProperty(HEAPDUMP_PATH);
+ if (dumpLocation != null && !dumpLocation.isEmpty()) {
+ dumpLocation = String.format(dumpLocation, qualifier);
+ System.out.println("dumpLocation = " + dumpLocation);
+ heapDumpToFile(dumpLocation);
+ } else {
+ LoggerFactory.getLogger(HeapDump.class).info("Property \"" + HEAPDUMP_PATH + "\"%s is not defined, heap dump skipped");
+ }
+
+ }
+
+ public static void heapDumpToFile(String dumpLocation) {
+ try {
+ System.gc();
+ Thread.sleep(300);
+ System.gc();
+ //noinspection ResultOfMethodCallIgnored
+ new File(dumpLocation).delete();
+ LoggerFactory.getLogger(HeapDump.class).info("Dumping heap to {}", dumpLocation);
+ diagnosticMXBean.get().dumpHeap(dumpLocation, true);
+ } catch (IOException | InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+
+}
diff --git a/src/test/java/com/test/utils/HeapInfo.java b/src/test/java/com/test/utils/HeapInfo.java
new file mode 100644
index 0000000..9bc8f4c
--- /dev/null
+++ b/src/test/java/com/test/utils/HeapInfo.java
@@ -0,0 +1,215 @@
+package com.test.utils;
+
+import javax.management.InstanceNotFoundException;
+import javax.management.MBeanException;
+import javax.management.MBeanServer;
+import javax.management.MalformedObjectNameException;
+import javax.management.ObjectName;
+import javax.management.ReflectionException;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.StringReader;
+import java.lang.management.ManagementFactory;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.TreeMap;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+@SuppressWarnings("WeakerAccess")
+public class HeapInfo extends HashMap {
+ private final static ObjectName diagCmdBeanName;
+ private static final String DIAGNOSTICS_COMMAND_BEAN_NAME =
+ "com.sun.management:type=DiagnosticCommand";
+
+
+ static {
+ try {
+ diagCmdBeanName = new ObjectName(DIAGNOSTICS_COMMAND_BEAN_NAME);
+ } catch (MalformedObjectNameException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static final Pattern CSV_PATTERN = Pattern.compile(",");
+
+ @SuppressWarnings({"WeakerAccess", "unused"})
+ public static class ClassHeapInfo {
+
+ private String className;
+ private long instSize;
+ private long instCount;
+ private long instBytes;
+
+ public ClassHeapInfo() {
+ }
+
+ public ClassHeapInfo(String className, long instSize, long instCount, long instBytes) {
+ this.className = className;
+ this.instSize = instSize;
+ this.instCount = instCount;
+ this.instBytes = instBytes;
+ }
+
+ @Override
+ public String toString() {
+ return className + ":{" +
+ "size:" + instSize +
+ ", count:" + instCount +
+ ", total bytes:" + instBytes +
+ '}';
+ }
+
+ public String getClassName() {
+ return className;
+ }
+
+ public long getInstSize() {
+ return instSize;
+ }
+
+ public long getInstCount() {
+ return instCount;
+ }
+
+ public long getInstBytes() {
+ return instBytes;
+ }
+
+ protected void setClassName(String classname) {
+ this.className = classname;
+ }
+
+ protected void setInstSize(int instSize) {
+ this.instSize = instSize;
+ }
+
+ protected void setInstCount(int instCount) {
+ this.instCount = instCount;
+ }
+
+ protected void setInstBytes(int instBytes) {
+ this.instBytes = instBytes;
+ }
+
+ public ClassHeapInfo negate() {
+ return new ClassHeapInfo(className, instSize, -instCount, -instBytes);
+ }
+
+ public ClassHeapInfo sub(ClassHeapInfo anotherInfo) {
+ return new ClassHeapInfo(className,
+ instSize,
+ instCount - anotherInfo.instCount,
+ instBytes - anotherInfo.instBytes);
+ }
+
+ public boolean isNotEmpty() {
+ return instBytes != 0 || instCount != 0;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof ClassHeapInfo)) return false;
+ ClassHeapInfo that = (ClassHeapInfo) o;
+ return Objects.equals(className, that.className);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(className);
+ }
+ }
+
+ public HeapInfo classStatistics(Predicate filter) {
+ try {
+ MBeanServer server = ManagementFactory.getPlatformMBeanServer();
+ String data = (String) server.invoke(diagCmdBeanName, "gcClassStats", new Object[]{new String[]{"-csv", "columns:ClassName,InstSize,InstCount,InstBytes"}}, new String[]{String[].class.getName()});
+ BufferedReader reader = new BufferedReader(new StringReader(data));
+ String header = reader.readLine();
+ if (!header.contains("ClassName")) {
+ throw new RuntimeException("Something went wrong with JMX beans: " + data);
+ }
+ String[] headers = CSV_PATTERN.split(header);
+ Predicate myFilter = s -> !s.startsWith(HeapInfo.class.getName());
+ if (filter != null) {
+ myFilter = myFilter.and(filter);
+ }
+ for (String l; (l = reader.readLine()) != null; ) {
+ String[] stats = CSV_PATTERN.split(l);
+ ClassHeapInfo info = new ClassHeapInfo();
+ for (int i = 0; i < headers.length; i++) {
+ if (headers.length <= stats.length) {
+
+ switch (headers[i]) {
+ case "ClassName":
+ if (myFilter.test(stats[i])) {
+ info.setClassName(stats[i]);
+ put(info.getClassName(), info);
+ }
+ break;
+ case "InstSize":
+ info.instSize = Long.parseLong(stats[i].trim());
+ break;
+ case "InstCount":
+ info.instCount = Long.parseLong(stats[i].trim());
+ break;
+ case "InstBytes":
+ info.instBytes = Long.parseLong(stats[i].trim());
+ break;
+ }
+ }
+
+ }
+ }
+ } catch (InstanceNotFoundException | MBeanException | ReflectionException | IOException e) {
+ throw new RuntimeException(e);
+ }
+ return this;
+ }
+
+ public HeapInfo delta(HeapInfo compare) {
+ Map copy = new TreeMap<>(this);
+ HeapInfo result = new HeapInfo();
+ for (ClassHeapInfo anotherInfo : compare.values()) {
+ ClassHeapInfo heapInfo = copy.remove(anotherInfo.getClassName());
+ ClassHeapInfo deltaHeapInfo;
+ if (heapInfo == null) {
+ deltaHeapInfo = anotherInfo.negate();
+ } else {
+ deltaHeapInfo = heapInfo.sub(anotherInfo);
+ }
+ if (deltaHeapInfo.isNotEmpty()) {
+ result.put(deltaHeapInfo.getClassName(), deltaHeapInfo);
+ }
+ }
+ copy.values().stream()
+ .filter(ClassHeapInfo::isNotEmpty)
+ .forEach(value -> result.put(value.getClassName(), value));
+ return result;
+ }
+
+ public static void tryGC() {
+ for (int i = 0; i < 5; i++) {
+ System.gc();
+ try {
+ Thread.sleep(50);
+ } catch (InterruptedException ignored) {
+
+ }
+ }
+ }
+
+ @Override
+ public String toString() {
+ return toString("; ");
+ }
+
+ public String toString(String delimiter) {
+ return "HeapInfo[" +
+ values().stream().map(ClassHeapInfo::toString).collect(Collectors.joining(delimiter)) +
+ "]";
+ }
+}
diff --git a/src/test/java/com/test/utils/MemoryLeakFailure.java b/src/test/java/com/test/utils/MemoryLeakFailure.java
new file mode 100644
index 0000000..04c5753
--- /dev/null
+++ b/src/test/java/com/test/utils/MemoryLeakFailure.java
@@ -0,0 +1,40 @@
+package com.test.utils;
+
+
+public class MemoryLeakFailure extends AssertionError {
+
+ public MemoryLeakFailure() {
+ }
+
+ public MemoryLeakFailure(Object detailMessage) {
+ super(detailMessage);
+ }
+
+ public MemoryLeakFailure(boolean detailMessage) {
+ super(detailMessage);
+ }
+
+ public MemoryLeakFailure(char detailMessage) {
+ super(detailMessage);
+ }
+
+ public MemoryLeakFailure(int detailMessage) {
+ super(detailMessage);
+ }
+
+ public MemoryLeakFailure(long detailMessage) {
+ super(detailMessage);
+ }
+
+ public MemoryLeakFailure(float detailMessage) {
+ super(detailMessage);
+ }
+
+ public MemoryLeakFailure(double detailMessage) {
+ super(detailMessage);
+ }
+
+ public MemoryLeakFailure(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/test/java/com/test/SingletonBeanStoreRetrievalStrategy.java b/src/test/java/com/test/utils/SingletonBeanStoreRetrievalStrategy.java
similarity index 97%
rename from src/test/java/com/test/SingletonBeanStoreRetrievalStrategy.java
rename to src/test/java/com/test/utils/SingletonBeanStoreRetrievalStrategy.java
index ac049bd..7f91d62 100644
--- a/src/test/java/com/test/SingletonBeanStoreRetrievalStrategy.java
+++ b/src/test/java/com/test/utils/SingletonBeanStoreRetrievalStrategy.java
@@ -1,4 +1,4 @@
-package com.test;
+package com.test.utils;
import com.vaadin.spring.internal.BeanStore;
import com.vaadin.spring.internal.BeanStoreRetrievalStrategy;
diff --git a/src/test/kotlin/com/test/ApplicationKotlinTest.kt b/src/test/kotlin/com/test/ApplicationKotlinTest.kt
index 9533c51..29d2c74 100644
--- a/src/test/kotlin/com/test/ApplicationKotlinTest.kt
+++ b/src/test/kotlin/com/test/ApplicationKotlinTest.kt
@@ -1,11 +1,14 @@
package com.test
import com.github.karibu.testing.*
+import com.test.utils.HeapDump
+import com.test.utils.SingletonBeanStoreRetrievalStrategy
import com.vaadin.spring.internal.UIScopeImpl
import com.vaadin.ui.Button
import com.vaadin.ui.Grid
import com.vaadin.ui.TextField
import org.junit.After
+import org.junit.AfterClass
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -47,4 +50,12 @@ class ApplicationKotlinTest {
dataProvider._findAll().any { it.firstName == "Halk" }
}
}
+
+ companion object {
+ @AfterClass
+ @JvmStatic
+ fun heapDump() {
+ HeapDump.heapDump(ApplicationKotlinTest::class.java)
+ }
+ }
}